K/D scoreboard ROOT-CAUSED for real (#45): the kill credit was rerouted, not lost
Kills/deaths only ever appeared on ONE machine. The cause is not a missing
tally -- it is Entity::Dispatch:
if (GetInstance() == ReplicantInstance) // ENTITY.cpp:244-251
application->SendMessage(ownerID, EntityManagerClientID, message);
BTPostKillScore runs on the VICTIM's node (the only node whose mech carries a
populated lastInflictingID), resolves the killer's Player -- a REPLICANT there --
and Dispatch()es to it. The engine reroutes that to the owning host, so
++killCount lands on the killer's own PC and nowhere else, and nothing carried it
back out: Player__UpdateRecord is currentScore + dropZoneLocation only. Every
other pod's copy therefore read 0 all mission. playerLink was never NULL for
these kills -- the dispatch proves it resolved.
Field proof over the whole corpus (255 node-logs): 125 of 125 SCORE type=2 rows
credit the LOGGING node's own player, not one credits a remote pilot; and 0 of
8800 DMG rows target a replicant, so no other node can even know the killer.
This means the owner's counter is already the single authoritative copy,
incremented exactly once per kill. So the "design decision" the plan was blocked
on collapses: there is no second writer to reconcile, only a one-way
owner->replicant mirror to add.
* BTPlayer__UpdateRecord = Player::UpdateRecord + killTally + deathTally, with
Read/WriteUpdateRecord overrides. No new data member, no new virtual ->
sizeof(BTPlayer)==0x28c and every existing offset lock still holds.
* Both counter writes already ForceUpdate() (:508 death, :829/:840 score), so
no dirty-bit edit was needed (plan risk 6 avoided).
* recordLength guard in ReadUpdateRecord: a pod on a build without the
extension degrades to "remote counters don't move" instead of reading a
neighbouring record as a kill count.
* Size locks added per plan risk 2 (no false offsetof assert).
Rig-verified, 2-node loopback (scratchpad/rig45_up.ps1, BT_MP_FORCE_DMG):
owner 3:1 finished kills 3/deaths 2 and the peer read kills=3 deaths=2; owner 2:1
finished kills 2/deaths 3 and the peer read kills=2 deaths=3. Exact convergence
where a remote column previously never left 0. 3 respawns per side with intact
death sequences (deathCount is only overwritten on replicant copies; the
handshake runs on masters), no crash, no GLITCH rows.
Kept byte-faithful: the kill handler's partner increment (the binary's
inc [ebx+0x27c] / inc [edx+0x27c] wrong-column slip) is still reproduced. It
always lands on a replicant copy, so the owner's record now overwrites it -- the
rig caught the correction repeatedly (wasKills=3 -> kills=2). The phantom kill
stops being visible without silently deciding the fidelity question.
Still open and deliberately untouched: which field the LOCAL pod's DEATHS column
should read (+0x280 vs deathCount), last-hitter-takes-all/ram kills, and the
slip's fidelity. Awaiting live multi-pod verification by a human.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
da617e6f8f
commit
4fa7eee54f
+109
-1
@@ -228,4 +228,112 @@ Use an egg that carries name bitmaps (OPERATOR-class), or the rows render as aut
|
||||
9. **Deleting `BTPlayerCountObservedDeath` is safe but must be complete.** It survives today only via the `extern` + the `friend` at btplayer.hpp:389; remove the definition, the call site, the extern and the friend together, or `/FORCE` hides the stragglers again.
|
||||
10. **Deviation ledger.** Two intentional departures ship here — dropping btplayer.cpp:704 (a byte-faithful 1995 increment) and adding the update-record override the binary does not have. Both must land in `docs/RECONCILE.md` with tiers (`[T3]` intent, `[T1]` addresses) so a future reader does not "restore fidelity" and re-open #45.
|
||||
|
||||
**Residual unknowns, each with the one check that settles it.** (a) *Why `GetPlayerLink()` was NULL for those four deaths* — the `PLAYER_LINK`/`DEAD_NOTIFY` records of Step 0 in one 2-node run; `link=0` convicts it, `link!=0` with no `death cycle START` moves it to the handler. (b) *Whether the rerouted `BTPlayer` ScoreMessage is reliable-ordered on the Steam/relay seam* — nothing retries it, so a dropped packet loses a kill forever today; with Step 4 the record heals it, which is a further argument for replication. Confirm `Application::SendMessage` to `EntityManagerClientID` in `engine/MUNGA_L4/L4NET.CPP`. (c) *The printed column labels* — only needed to name the columns for the user, not to place the fields; read the Comm background pixmap or `reference/manual/Tesla40_BT_manual.pdf`'s Comm page. (d) *Whether the six corpus-wide excess kill awards (53 `SCORE type=2` vs 47 `DEATH inst=M`, all six outside the multi-node rounds) are a second double-scoring path* — the `TALLY` records of §4 answer it in one solo `BT_SPAWN_ENEMY` run; run it down before anyone claims "kills are awarded exactly once".
|
||||
**Residual unknowns, each with the one check that settles it.** (a) *Why `GetPlayerLink()` was NULL for those four deaths* — the `PLAYER_LINK`/`DEAD_NOTIFY` records of Step 0 in one 2-node run; `link=0` convicts it, `link!=0` with no `death cycle START` moves it to the handler. (b) *Whether the rerouted `BTPlayer` ScoreMessage is reliable-ordered on the Steam/relay seam* — nothing retries it, so a dropped packet loses a kill forever today; with Step 4 the record heals it, which is a further argument for replication. Confirm `Application::SendMessage` to `EntityManagerClientID` in `engine/MUNGA_L4/L4NET.CPP`. (c) *The printed column labels* — only needed to name the columns for the user, not to place the fields; read the Comm background pixmap or `reference/manual/Tesla40_BT_manual.pdf`'s Comm page. (d) *Whether the six corpus-wide excess kill awards (53 `SCORE type=2` vs 47 `DEATH inst=M`, all six outside the multi-node rounds) are a second double-scoring path* — the `TALLY` records of §4 answer it in one solo `BT_SPAWN_ENEMY` run; run it down before anyone claims "kills are awarded exactly once".
|
||||
---
|
||||
|
||||
# ADDENDUM 2026-07-25 (late) — the missing mechanism; the DESIGN DECISION is resolved
|
||||
|
||||
**Status change: ROOT-CAUSED, FIXED, and RIG-VERIFIED on a 2-node loopback.
|
||||
Awaiting live multi-pod verification by a human.** This addendum supersedes the §3
|
||||
framing that a convergence rule had to be *invented*.
|
||||
|
||||
## What was missing
|
||||
|
||||
§2/§3 established that neither counter is replicated, and reasoned that a per-node
|
||||
observed tally therefore had to be added — noting only in passing (residual unknown
|
||||
(b)) that the ScoreMessage is "rerouted". That reroute IS the mechanism, and it
|
||||
changes the conclusion:
|
||||
|
||||
`BTPostKillScore` runs on the **victim's** node (the only node whose mech carries a
|
||||
populated `lastInflictingID`). It resolves the killer's `Player` — which on that node
|
||||
is a **REPLICANT** — and calls `Dispatch()` on it. `Entity::Dispatch` reroutes a
|
||||
replicant's message to the owning host:
|
||||
|
||||
```cpp
|
||||
if (GetInstance() == ReplicantInstance) // ENTITY.cpp:244-251
|
||||
application->SendMessage(ownerID, EntityManagerClientID, message);
|
||||
```
|
||||
|
||||
So the `++killCount` is **not lost — it is relocated to the killer's own machine**,
|
||||
and it is correct there. Nothing carries it back out, and `Player__UpdateRecord` is
|
||||
`currentScore + dropZoneLocation` only, so every other pod's copy stays 0 all
|
||||
mission. `playerLink` was never NULL for these kills — the dispatch proves it
|
||||
resolved. [T0 on the reroute and the record contents.]
|
||||
|
||||
## Field proof
|
||||
|
||||
- **125 of 125** `SCORE type=2` records across **255 node-logs** credit the LOGGING
|
||||
node's OWN player; **not one** credits a remote pilot.
|
||||
- **0 of 8800** `DMG` rows target a replicant — so a non-victim node genuinely
|
||||
cannot know the killer, which is why every `DEATH inst=R` row reads `killer=0:0`
|
||||
and why §4's "unreachable observed tally" could never have worked anyway.
|
||||
- Tonight's 3 nodes, one kill: host 3 logged `player=3:1 type=2 award=5.88`; the
|
||||
award equals the `killdmg=5.881` recorded on **host 4** — the value crossed the
|
||||
wire. Host 4 (the victim, the user's pod) logged the death and no kill at all.
|
||||
|
||||
## Why the design decision collapses
|
||||
|
||||
The owner's counter is already the single authoritative copy, incremented exactly
|
||||
once per kill through the engine's own reroute. There is no second writer to
|
||||
reconcile and no rule to choose: the fix is a **one-way owner→replicant mirror**.
|
||||
Step 1 (a new `deaths` member, changing the displayed field) is NOT needed for the
|
||||
user-visible bug — `deathCount` already tallies deaths correctly on the owner
|
||||
(`++deathCount`, btplayer.cpp:469; `PLAYER_DEAD deaths=1..4` matches).
|
||||
|
||||
## What landed (unbuilt)
|
||||
|
||||
`BTPlayer__UpdateRecord` (btplayer.hpp) = `Player::UpdateRecord` + `killTally` +
|
||||
`deathTally`, with `Read/WriteUpdateRecord` overrides (btplayer.cpp). Adds no data
|
||||
member and no new vtable slot, so `sizeof(BTPlayer)==0x28c` is untouched. Both
|
||||
counter writes already `ForceUpdate()` (:508 death, :829/:840 score), so the record
|
||||
ships without a new dirty-bit edit — §5 risk 6 is avoided entirely.
|
||||
|
||||
Satisfies §5: risk 1 avoided (no new member → no shadow), risk 2 locks added
|
||||
(`sizeof(UpdateRecord) > sizeof(Player::UpdateRecord)` and `== base + 2*sizeof(int)`),
|
||||
risk 4 respected (exactly two ints), risk 5 **improved** — a `recordLength` guard in
|
||||
`ReadUpdateRecord` makes a mixed-build session degrade to "counters don't update"
|
||||
instead of reading a neighbouring record as a kill count.
|
||||
|
||||
**Bonus:** the §Headline-6 phantom kill (the binary's partner `inc [+0x27c]` on the
|
||||
VICTIM's player) always lands on a replicant copy, so the owner's authoritative
|
||||
value now overwrites it on the next record — the phantom stops being visible
|
||||
without touching the faithfully-reconstructed handler.
|
||||
|
||||
## Verification (2026-07-25, 2-node loopback, Release, `BT_MP_FORCE_DMG=1`)
|
||||
|
||||
Rig: `scratchpad/rig45_up.ps1` / `rig45_down.ps1` — two Release nodes on 1501/1601
|
||||
pinned to disjoint cores (the task #50 TCP-batching artifact) plus the console relay,
|
||||
recording only its own PIDs so teardown never kills by name. Logs:
|
||||
`content/matchlog_20260725_213627_{28896,22980}.txt`.
|
||||
|
||||
- **Build clean.** 0 `error C` (so both new `static_assert`s hold), and **no
|
||||
`BTPlayer::*UpdateRecord` among the unresolved externals** — §5 risk 3 cleared. The
|
||||
40 `LNK2019`s in that log are the pre-existing `/FORCE`-tolerated
|
||||
`CreateStreamedSubsystem` family from `mech3.obj`, untouched by this change.
|
||||
- **Cross-node convergence exact.** Owner 3:1 finished kills 3 / deaths 2 and the peer
|
||||
read `kills=3 deaths=2`; owner 2:1 finished kills 2 / deaths 3 and the peer read
|
||||
`kills=2 deaths=3`. Before the fix a remote pilot's column never left 0.
|
||||
- **The `-2` seed is gone off-node**: the first mirror on each side shows
|
||||
`wasDeaths=-2 -> deaths=<real>`, so a remote pilot's DEATHS no longer displays the
|
||||
clamped fake 0 that §Headline-1 describes. (§Headline-1's *own-pod* field-choice
|
||||
question is untouched and still open.)
|
||||
- **Respawn unaffected** (the §5 risk-8 worry): 3 respawns per side, `deaths=` sequences
|
||||
intact, no drop-zone or latch anomaly. `deathCount` is only ever overwritten on a
|
||||
REPLICANT copy, and the handshake runs on masters.
|
||||
- **Phantom kill now self-corrects**: the rig captured the owner's value overwriting the
|
||||
locally-applied partner increment repeatedly (`wasKills=3 -> kills=2`,
|
||||
`wasKills=2 -> kills=1`).
|
||||
- No crash, no `GLITCH` (plane-audit) rows, both nodes alive through 3 death/respawn
|
||||
cycles.
|
||||
|
||||
## Still to do
|
||||
|
||||
1. **Live multi-pod verification by a human** — the memory rule: this stays "fix landed,
|
||||
awaiting verification" until confirmed in a real session.
|
||||
2. All pods must run the same build for a scoreboard test (§5 risk 5) — the guard makes a
|
||||
mixed session degrade safely rather than corrupt, but the numbers only converge when
|
||||
both ends have the extension.
|
||||
3. Still open, deliberately untouched: §Headline-1 (which field the LOCAL pod's DEATHS
|
||||
column should read, `+0x280` vs `deathCount`), §Headline-5 (last-hitter-takes-all and
|
||||
ram kills), and §Headline-6's fidelity question (the wrong-column slip is still
|
||||
reproduced; it is merely no longer visible).
|
||||
|
||||
@@ -1562,3 +1562,47 @@ absent inter-sink linkage (interdependence). Reservoir (0xBC0) and HeatSink bank
|
||||
RefrigerationSimulation in its ctor) — REQUIRES heat.cpp edits (out of scope for this pilot's file set).
|
||||
5. THEN instantiate the heat family together (Condenser+Reservoir+bank) and verify the tick rises by
|
||||
their count without the UpdateCoolant fault.
|
||||
|
||||
---
|
||||
|
||||
## Deliberate divergence from the binary: BTPlayer update-record override (Gitea #45)
|
||||
|
||||
**2026-07-25. One departure, verified live. Do NOT "restore fidelity" here — doing so re-opens #45.**
|
||||
|
||||
**What the binary does.** `BTPlayer`'s vtable slots 6/7 (`@0x513300`) point at the ENGINE's
|
||||
`Player::Read/WriteUpdateRecord`; the 1995 class adds no override, and `Player__UpdateRecord`
|
||||
carries `currentScore + dropZoneLocation` only (`PLAYER.h:73-80`). [T0/T1]
|
||||
|
||||
**What we ship.** `BTPlayer__UpdateRecord` = `Player::UpdateRecord` + `killTally` + `deathTally`,
|
||||
with `Read/WriteUpdateRecord` overrides (`btplayer.hpp`, `btplayer.cpp`). Our vtable slots 6/7
|
||||
therefore point at BTPlayer's versions. No new data member and no new virtual, so
|
||||
`sizeof(BTPlayer) == 0x28c` is unchanged and the existing offset locks still hold. **[T3 on the
|
||||
divergence being *necessary*; T0 on the mechanism that makes it necessary.]**
|
||||
|
||||
**Why it is necessary.** A kill is credited by `BTPostKillScore` on the VICTIM's node, which
|
||||
resolves the killer's `Player` — a REPLICANT there — and calls `Dispatch()`. `Entity::Dispatch`
|
||||
reroutes a replicant's message to the owning host (`ENTITY.cpp:244-251`), so `++killCount` lands
|
||||
on the killer's own machine and nowhere else, and nothing ever carried it back out. Field proof
|
||||
before the fix: across 255 node-logs, **125 of 125** `SCORE type=2` rows credit the LOGGING node's
|
||||
own player and **not one** credits a remote pilot; **0 of 8800** `DMG` rows target a replicant, so
|
||||
no other node can even know the killer. The pods' closed LAN made the one-way engine record
|
||||
sufficient in 1995 only because nothing ever needed the counters off-node — the arcade cabinet
|
||||
scoreboard each pod drew was its own.
|
||||
|
||||
**Verified live** (2-node loopback, Release, `BT_MP_FORCE_DMG`, 2026-07-25): both nodes converged
|
||||
exactly — owner 3:1 (kills 3 / deaths 2) read `kills=3 deaths=2` on the peer, owner 2:1
|
||||
(kills 2 / deaths 3) read `kills=2 deaths=3` on the peer. 3 respawns per side, death sequences
|
||||
intact, no crash.
|
||||
|
||||
**What we did NOT change (kept byte-faithful).** The kill handler's partner increment
|
||||
`++sender_owner->killCount` (btplayer.cpp, the binary's `inc [ebx+0x27c]` / `inc [edx+0x27c]`
|
||||
@0x4c0397/@0x4c03a3 wrong-column slip) is still reproduced as shipped. It always lands on a
|
||||
REPLICANT copy of the victim, so the owner's authoritative record now overwrites it on the next
|
||||
update — the rig captured the correction repeatedly (`wasKills=3 -> kills=2`,
|
||||
`wasKills=2 -> kills=1`). The phantom kill stops being *visible* without editing the
|
||||
reconstructed handler, so the fidelity question stays open rather than being silently decided.
|
||||
|
||||
**Mixed-build note.** `ReadUpdateRecord` early-returns when `recordLength < sizeof(UpdateRecord)`,
|
||||
so a pod on a build without the extension degrades to "remote counters don't move" instead of
|
||||
reading a neighbouring record as a kill count. All pods should still run the same build for a
|
||||
scoreboard test.
|
||||
|
||||
Reference in New Issue
Block a user