# KILLS/DEATHS Scoreboard — Root Cause + Fix Plan (Gitea #45) **Status: ROOT-CAUSED, fix NOT written. Requires a DESIGN DECISION (see section 3).** 2026-07-25, from a read-only 9-agent investigation; every binary claim re-verified with capstone against `content/BTL4OPT.EXE`. Provenance: workflow `wf_225a6196-5a8`. Field data: 88 matchlogs in `content/matchlogs/`. ## Headline 1. **The port reads DEATHS from the wrong field.** The binary's `PilotList` draws `pilot+0x27c` (KILLS) and `pilot+0x280` (DEATHS) (`reference/decomp/all/part_014.c:3392-3393`, `fild [edi+0x27c]` / `[edi+0x280]` in `PilotList::Execute` @0x4cabd0). Our port declares `+0x280` as **`pad_0x280`** (`btplayer.hpp:394`), never writes it, and substituted the ENGINE's `Player::deathCount` (+0x200) -- which is the respawn handshake's identity number, seeded to **-2** (`PLAYER.cpp:777`). So a remote pilot's DEATHS cell reads a clamped fake `0` **by construction**. [T1] 2. **A FUNCTION IS MISSING FROM OUR DECOMP EXPORT, and that is why the KB got it wrong.** `BTPlayer::VehicleDeadMessageHandler` lives at **@0x4c05c4** and is **absent** from `reference/decomp/functions_index.tsv`, which jumps `4c052c` (146 B) -> `4c083c` (verified independently). The KB inferred "+0x280 has no writers" from that silence and labelled it padding. The real handler was disassembled from the binary in this pass: it does `cmp ebx,edi / je / inc [edi+0x280] / inc [ebx+0x280]` @0x4c0670-0x4c067a. `btplayer.hpp:231` still misattributes the handler to `@004c012c`, which is really a DropZoneReply-path helper (`inc [ebx+0x200]` @0x4c0139). [T1] **Implication beyond this bug: the export has holes -- an absent function is not evidence of an absent behaviour.** 3. **The observed-tally model is unsound here and only half-wired.** Per kill the port produces exactly TWO counter writes on TWO machines: `++killCount` on the killer's node, `++deathCount` on the victim's node. **Neither rides an update record** -- `Player__UpdateRecord` is `currentScore + dropZoneLocation` only (`PLAYER.h:73-80`) -- so a divergence can never self-heal and **bystanders show 0/0 for everyone, all mission.** [T0/T1] 4. **`BTPlayerCountObservedDeath` is unreachable dead code.** Its only call site (`mech4.cpp:2019-2027`) is gated on `ReplicantInstance` but sits INSIDE `Mech::UpdateDeathState`'s transition block, which a replicant can never enter (it takes the mode-9 early return at `mech4.cpp:1832`). [T1] 5. **"It showed I got a kill I didn't" = last-hitter-takes-all, and COLLISIONS COUNT.** `BTPostKillScore` resolves the killer from `((Mech*)victim)->lastInflictingID`, whose only writer latches the **last applied** hit rather than the fatal one -- and ram damage is an ordinary `TakeDamageMessage`. **5 of the 47 master deaths had a type=0 collision killing blow.** Bump a softened-up mech and the kill posts to your row. `Mech::Reset` also never clears the field. [T1] 6. **The phantom kill is AUTHENTIC 1995.** The binary's kill handler increments `killCount` on the VICTIM's player too (`inc [ebx+0x27c]` @0x4c0397 / `inc [edx+0x27c]` @0x4c03a3). Both of the binary's partner increments write the **wrong column**, which reads as a shipped adjacent-offset slip. [T3 on intent, T1 on the addresses] 7. **A serious unrelated bug found on the way:** `btplayer.cpp:453` does `simulationFlags |= 0x1` where the binary does `or word ptr [ebx+0x18], 1` = `updateModel |= DefaultUpdateModelFlag` = **`ForceUpdate()`** (`SIMULATE.h:146-147`). Bit 0 of `simulationFlags` is `DelayWatchersFlag` (`SIMULATE.h:170`) and **nothing clears it** -- so today **the first death of any pilot permanently disables `ExecuteWatchers` on that player's simulation.** `context/reconstruction-gotchas.md:474-476` flagged this exact line as un-audited; it is now settled. Filed separately. **The fix needs a decision, not just a patch:** the arcade had no convergence channel for the pod's own roster (`@0x513300` slots 6/7 are the ENGINE's Read/WriteUpdateRecord, so 1995 did not replicate the pair either). Section 3 recommends replicating both counters as a documented divergence, because a replicated pair is idempotent and self-healing where an event tally never is. **Step 0 is a BLOCKING PREREQUISITE:** land the #54/#57 death-notification work first, or the DEATHS column stays 0/0/0 in exactly the rounds users complained about and any "fixed" claim would be false. --- # Gitea #45 — "KILLS/DEATHS differ per machine": ROOT CAUSE + FIX PLAN *(read-only pass; nothing modified. All line numbers are the current working tree at `3260238` + the uncommitted `btplayer.cpp` #57 change.)* --- ## 1. VERDICT **The observed-tally model is UNSOUND BY DESIGN in this architecture, and only half of it was ever wired.** Per kill event, the port produces exactly **two** counter writes on **two different machines** and nothing on the other three: `++killCount` runs on the **killer's** node only (the `KillScore` ScoreMessage is built on the victim's node and rerouted over the wire by `Entity::Dispatch`, engine/MUNGA/ENTITY.cpp:244-251 [T0]), and `++deathCount` runs on the **victim's** node only (the `VehicleDead(-1)` handler, game/reconstructed/btplayer.cpp:436) [T1]. Neither counter is in any update record — `Player__UpdateRecord` is `currentScore + dropZoneLocation` and nothing else (engine/MUNGA/PLAYER.h:73-80, PLAYER.cpp:631-641/645-661 [T0]), and `BTPlayer` adds no override — so **a divergence can never self-heal**, and every bystander shows 0/0 for everyone forever. The intended symmetric half, `BTPlayerCountObservedDeath` (btplayer.cpp:1581), is **unreachable dead code**: its only call site (mech4.cpp:2019-2027) is gated on `ReplicantInstance` but sits *inside* `Mech::UpdateDeathState`'s transition block, which a replicant can never enter — a replicant learns of the death as replicated `simulationState 9`, which is exactly what routes it to the mode-9 early return at mech4.cpp:1832 [T1]. On top of that the port **reads DEATHS from the wrong field**: the binary's PilotList draws `pilot+0x27c` and `pilot+0x280` (reference/decomp/all/part_014.c:3392-3393; `fild [edi+0x27c]`/`[edi+0x280]` in `PilotList::Execute` @0x4cabd0) [T1], but the port declares `+0x280` as `pad_0x280` (btplayer.hpp:394), never writes it, and substituted the **engine's** `Player::deathCount` (+0x200) — which is the respawn handshake's identity number (seeded to **−2** at PLAYER.cpp:777, gated at PLAYER.cpp:230) and therefore reads a clamped fake "0" on every remote row by construction (btplayer.cpp:1547-1554) [T1]. Finally, the port faithfully reproduces a **1995 phantom kill**: the `KillScore` branch increments `killCount` on the *victim's* player as well as the killer's (btplayer.cpp:702/704 ≡ `inc [ebx+0x27c]` @0x4c0397 / `inc [edx+0x27c]` @0x4c03a3, decomp part_013.c:10633-10636) [T1]. **Why "faithful" cannot fix this:** the binary is symmetric-but-crossed. I disassembled the un-exported handler myself — `@0x4c05c4` is `BTPlayer::VehicleDeadMessageHandler` (handler table `0x512fec=0x17 / 0x512ff0→"VehicleDead" / 0x512ff4=0x4c05c4`, dumped from `content/BTL4OPT.EXE`) and it does `cmp ebx,edi / je / inc [edi+0x280] / inc [ebx+0x280]` @0x4c0670-0x4c067a, where `edi = GetEntityPointer(msg+0x2c)` is the **killer's Player** (proved by `Player__StatusMessage(edi, type 1, 6.0f)` @0x4c064d added to `this` @0x4c0668, and by `this->objectiveMech(+0x284) = edi->playerVehicle(+0x1fc)` @0x4c06dd-0x4c06e3 — the revenge lock) and `ebx = this` is the **victim's Player** [T1]. So each handler bumps the *same* offset on *both* parties: one kill gives both pilots +1 in column 1 on the killer's machine and +1 in column 2 on the victim's machine. That is the reported bug, in 1995. And `@0x513300` slots 6/7 are `0x42e2ac`/`0x42e2e4` — the **engine's** `Player::Read/WriteUpdateRecord` — so the arcade did not replicate the pair either [T1]. There is no authentic convergence channel for the pod's own pilot list; the console messages (@0x4c06a4-0x4c06da and the kill-side twin) go to `ConsoleClientID` and never feed a pod's roster. **A decision, not just a patch, is required.** Two facts that dissolve standing uncertainties: - **Column semantics are settled without the art.** `+0x27c` is written *only* by the KILL handler and `+0x280` *only* by the DEATH handler (whole-image: ctor zeroes at `param_1[0x9f]/[0xa0]`, part_013.c:10873-10874; the two `inc` pairs; the two `fild` reads). Whatever the Comm pixmap prints, column 1 is the kill-event counter and column 2 the death-event counter. [T1] - **`FUN_004c05c4` is ABSENT from the decomp export** — `reference/decomp/functions_index.tsv` jumps `4c052c` (146 B) → `4c083c` [T1]. That absence is *the* root cause of the whole wrong mental model: the KB inferred "+0x280 has no writers" from silence and labelled it a pad. `btplayer.hpp:231` still attributes `VehicleDeadMessageHandler` to `@004c012c`, which is actually a DropZoneReply-path helper (`inc [ebx+0x200]` @0x4c0139 — I disassembled it). --- ## 2. PER-SYMPTOM MAP Field baseline I re-derived independently (88 files in `content/matchlogs/`, 42 `-egg OPERATOR.EGG`, 32 with `MISSION st=4 run`; **7 death-bearing multi-node rounds** of 5,5,5,4,4,3,3 nodes + one 3-node no-death round; **47** `DEATH inst=M`, **96** `inst=R`, **43** `PLAYER_DEAD`; **max 5 game nodes** — host 1 is the relay, `PEER_UP host=1 type=3`). **The parent brief is wrong on three counts: 88 files not 45; the final round had THREE deaths not two; and the brief's "two nodes each" award claim is a different, earlier round (the `:108/:109` round).** The final round is `-egg OPERATOR.EGG`, `MISSION run 00:04:50.775` LIVINGROOM-local, 5 nodes (2=MS-FIREFLY, 3=XIAOLONG, 4=SIM-PIT, 5=LIVINGROOM-PC, 6=GAMERSLAB), victims **4:21, 6:21, 5:21 — all three killed by 3:21 (XIAOLONG)** — and all three kill awards land on XIAOLONG alone (`SCORE player=3:1 type=2 award=5.00/35.50/11.76 kills=1/2/3`, matching the victims' `killdmg 5.000/35.502/11.764` measured on three *different* machines). **Zero `PLAYER_DEAD` on any of the five nodes.** | Player quote | Speaker's seat | Mechanism (file:line) | Fixed by #57? | |---|---|---|---| | **"im seeing cyd got a kill but no death count"** | the **killer's** node (only a node that ran the kill handler shows any kill at all) | Two stacked bugs on one row: (a) **phantom kill** — `if (sender_owner) ++sender_owner->killCount;` btplayer.cpp:704, where `sender_owner = MECH_OWNING_PLAYER(sender_mech)` and `senderMechID` is the **victim** (btplayer.cpp:1723); the object bumped is the victim's *replicant* `BTPlayer`, which is exactly the object the row reads (roster = local "Players" group, mechmppr.cpp:1258-1300). (b) **no death** on that row because nothing on this node writes the victim's deaths: the observed tally is dead code (mech4.cpp:2019) and the remote copy's `Player::deathCount` is stuck at the −2 seed, clamped to 0 (btplayer.cpp:1551). | **NO** | | **"its showing i got a kill also which i didnt"** | speaker's **own row on their own screen** — the phantom cannot do this (it credits the victim, on the killer's node) | **Last-hitter-takes-all via `lastInflictingID`.** `BTPostKillScore` resolves the killer from `((Mech*)victim)->lastInflictingID` (btplayer.cpp:1706-1710); that field's only writer is `Mech::TakeDamageMessageHandler` (mech.cpp:746) and it latches the **last applied** hit, not the fatal one, and **a bare collision counts**: ram damage is a normal `TakeDamageMessage` (mech4.cpp:6606-6629). 5 of the 47 master deaths had a **type=0 (collision)** killing blow — including this round's `6:21` (`amt=35.502`) and `4:47` (`RAM raw=109268.5 → amt=109.269`). Bump a softened-up mech and the kill posts to **your** node, **your** row. Secondary: `Mech::Reset` never clears `lastInflictingID`/`lastInflictingDamage` (mech4.cpp:1708-1822), and the binary *seeds* it to the mech's own id in the ctor (@0x4a16ed-0x4a16fb) where our port leaves it 0:0. | **NO** | | **"K/D still not showing in upper right"** | a **bystander** (MS-FIREFLY, and any node not party to a kill) | Structural: only the two participants' nodes write anything; neither counter rides an update record (PLAYER.h:73-80). Bystanders show 0/0 for every pilot for the whole mission. | **NO** | | **"I'm not seeing any deaths on my screen"** | any node — this one has **three** layers | (i) **remote pilots' deaths are never tallied anywhere**: `BTPlayerCountObservedDeath` unreachable + no replication + −2 seed → clamped 0. (ii) **in the final round even the victims' OWN deaths were not tallied**: 5 logs, 3 `DEATH inst=M`, **0 `PLAYER_DEAD`** ⇒ the `deathCount == -1` branch never reached `btplayer.cpp:436` on any of the three victim nodes. That is the **#54 defect**, not #45. (iii) DEATHS is read from `Player::deathCount`, i.e. it is coupled to the respawn cycle and to the #57 latch. | (i) NO · (ii) **NO — see below** · (iii) partially | **#57 explains none of the four quotes, and the comment now in the working tree is factually wrong.** btplayer.cpp:1294-1305 asserts "*tonight 3 of 3 deaths in the final round were swallowed, each by a player who had respawned successfully earlier in the same process*." `deathPending` is set at **exactly one site** (btplayer.cpp:413), immediately *downstream* of the dedup and *upstream* of `++deathCount` (:436) and the unconditional `PLAYER_DEAD` emit (:441-443); it starts 0 in the ctor (:1355). Each of those five logs contains exactly **one** `MISSION run` and each victim has exactly **one** `DEATH inst=M` and **zero** `PLAYER_DEAD` — so the failing death was that pilot's **first** death in that process and **no latch could have been stranded**. Same for the 4th case (MYTHRIC-BEAST `6:25`, `74.99.73.241_matchlog_20260724_230825_39056.txt`). Corpus-wide, `PLAYER_DEAD == DEATH inst=M` in **every** log except those four. #57 is a real bug correctly fixed; it is not what bit them. **Do not let it stand in the changelog as the explanation for the final round.** Elapsed-time correlation also rules out a mission-timer/`MissionEndingState` cause: tallied deaths occur at t+415 s, +421 s, +428 s, +464 s, +469 s while untallied ones occur at +274 s, +381 s, +401 s, +497 s. No PEER_DOWN precedes any of the four. The surviving candidate is the one `docs/RESPAWN_REARM_PLAN.md` §6 already named: **`Mech::GetPlayerLink() == 0` on the victim's own master mech**, which is the *single shared gate* of both dispatch sites (btplayer.cpp:1735 and mech4.cpp:2045) — and the kill award landing on the killer's node in all four proves `BTPostKillScore` ran past btplayer.cpp:1724, so the failure is at or after :1735. `playerLink` is NULL-initialised (ENTITY.cpp:935) and written only by `Entity::PlayerLinkMessageHandler` (ENTITY.cpp:807-813) with **no null check, no retry, no log**, fed by one `InitializePlayerLink` at drop-in (btplayer.cpp:1216). --- ## 3. THE FIX **Design decision, stated plainly:** the two counters must be **replicated, not observed**. Justification, in order of force: (1) a per-node observed tally *cannot* work for KILLS on a bystander because the killer is unknown there — all 96 `DEATH inst=R` records carry `killer=0:0 killdmg=0.000`, because `lastInflictingID` rides no record (mech.cpp:2338-2545 writer / :2002-2302 reader) and damage is applied only on the victim's master (0 of 5574 `DMG` records are `inst=R`); (2) the binary offers no convergence channel for the pod roster, so *no* choice here is authentic and the honest move is the smallest documented divergence; (3) `Player` already replicates `currentScore` through this exact record and is already `ForceUpdate()`d on every change (btplayer.cpp:551/765/776) — which is why the SCORE column agrees cross-node while K/D cannot. **A replicated pair is idempotent and self-healing: it converges after a dropped packet, which an event tally never does.** **Double-count safety is achieved by construction: exactly ONE writer per counter, always the owning master, and every other node is a pure reader.** After the deletions below, `killCount` is written only in `BTPlayer::ScoreMessageHandler`, which `Verify`-aborts on a replicant (btplayer.cpp:597-606 ≡ binary @0x4c02f3) — master-only; `deaths` is written only in the `deathCount == -1` branch, reachable only on a master because `Entity::Dispatch` reroutes replicant dispatches. **No node ever both observes and receives.** ### Step 0 — BLOCKING PREREQUISITE: land the #54 death-notification fix first Without it the DEATHS column is still 0/0/0 in the exact round the users complained about, and any "#45 fixed" claim would be false. Land `docs/RESPAWN_REARM_PLAN.md` §4 Step 0 + Step 1 as written: the `DEAD_NOTIFY` record at mech4.cpp:2045 and `PLAYER_LINK` at the tail of `InitializePlayerLink`, plus the reverse-link fallback (`Player::playerVehicle == this`, resolved in `mech.cpp` where both types are complete — **not** a raw offset) at `Mech::PlayerLinkMessageHandler` (mech.cpp:675) and at the point of use (mech4.cpp:2044). Also clear `deathPending = 0` in the first-spawn branch (btplayer.cpp:~1239). ### Step 1 — give DEATHS the authentic field, and stop reading the respawn counter - `game/reconstructed/btplayer.hpp:393-394` — `int pad_0x280;` → `int deaths; // @0x280 the binary's DEATHS cell (inc @0x4c067a)`. **New name, not `deathCount`** — re-declaring an engine-base field is gotcha #1 (reads `0xCDCDCDCD` + mis-offsets). - `btplayer.hpp:324` — `GetDeaths()` returns `deaths`, not the inherited `deathCount`. - `btplayer.cpp:1478` — `pad_0x280 = 0;` → `deaths = 0;`. - `btplayer.cpp:1541-1554` — `BTPilotDeaths`: **delete the `< 0 ? 0` clamp** and the comment block explaining it. The clamp existed only to hide the −2 seed of a field that is no longer the display value; keeping it would mask a real negative. - `game/reconstructed/btl4gau3.cpp:511-516` — fix the comment ("DEATHS uses the real deathCount, not the binary's dead pad_0x280" is now provably backwards); the two `Draw` calls stay as-is. - `btplayer.cpp:436-443` — **add `++deaths;`** immediately before `++deathCount;`, and print `deaths` in the `PLAYER_DEAD` record alongside it. **Keep `++deathCount`**: in the binary +0x200 is advanced on the *respawn* side (`inc [ebx+0x200]` @0x4c0139 inside `FUN_004c012c`, whose decompiled caller is DropZoneReply @0x4bffd0, part_013.c:10480) and `@0x4c05c4` only *reads* it (@0x4c072b) — but our respawn handshake and #57 both depend on the current placement, and changing it risks the identity gate at PLAYER.cpp:230. Document the divergence; do not touch it in this change. ### Step 2 — fix the mis-transcribed dirty mark (this is a bug fix, and it is what makes replication authentic) `btplayer.cpp:453` — `simulationFlags |= 0x1; // request a forced update` → **`ForceUpdate();`**. `context/reconstruction-gotchas.md:474-476` flags this exact line as the one un-audited sibling of Gitea #12 and says "verify against the VehicleDead handler's disasm before changing". **Settled here:** the instruction is `or word ptr [ebx+0x18], 1` at 0x4c0155 = `updateModel |= DefaultUpdateModelFlag` = *exactly* `Simulation::ForceUpdate()` (SIMULATE.h:146-147) [T1]. `simulationFlags` bit 0 is `DelayWatchersFlag` (SIMULATE.h:170) and nothing clears it — so today the first death of any pilot **permanently disables `ExecuteWatchers` on that player's simulation**. The binary marks the player for replication right after the death bookkeeping; we currently do not mark it at all. ### Step 3 — delete the partner increments and the dead tally - `btplayer.cpp:703-704` — **delete** `if (sender_owner) ++sender_owner->killCount;`. This is the phantom kill. **Keep** the `Player__StatusMessage(sender_owner, 0, 6.0f)` block at :717-726 — that is a separate, authentic behaviour (@0x4c0632-0x4c0668's mirror). Do **not** add the binary's partner death bump (`inc [edi+0x280]` @0x4c0674); we never had it. Record the finding: both binary partner increments write the **wrong column** (kill handler → the victim's KILLS instead of DEATHS; death handler → the killer's DEATHS instead of KILLS). The mirror-image symmetry, plus the two self-kill guards (`cmp edi,eax/je` @0x4c0393 and `cmp ebx,edi/je` @0x4c0670) being deliberately per-other-party, makes a shipped 1995 adjacent-offset slip the only economical reading. **[T3 on intent, T1 on all four addresses.]** Under replication the partner writes are redundant *and* would fight the replicated value. - `btplayer.cpp:1572-1592` — **delete `BTPlayerCountObservedDeath`** and its comment block; `btplayer.hpp:389` — delete the `friend`; `mech4.cpp:2014-2027` — delete the whole `ReplicantInstance` block and its comment (which asserts the opposite of mech4.cpp:1836-1840 180 lines earlier). It cannot execute today, and under replication it would double-count. ### Step 4 — replicate the pair (the one deliberate divergence) In `btplayer.hpp`, beside the existing message typedefs: ``` struct BTPlayer__UpdateRecord : public Player::UpdateRecord { int killCount; int deaths; }; ``` plus `typedef BTPlayer__UpdateRecord UpdateRecord;` and, in the `protected:` block next to the handlers, overrides matching `Simulation`'s virtuals (SIMULATE.h:127-131; Player's own overrides at PLAYER.h:431-436 are the pattern): ``` void WriteUpdateRecord(Simulation::UpdateRecord *record, int update_model); void ReadUpdateRecord(Simulation::UpdateRecord *record); ``` Bodies in `btplayer.cpp`, modelled exactly on PLAYER.cpp:645-661 / :631-641: - **Write:** `Player::WriteUpdateRecord(record, update_model);` **then** `update->recordLength = sizeof(*update); update->killCount = killCount; update->deaths = deaths;` — the length overwrite must come **after** the base call, because each level rewrites it (ENTITY.cpp:343 → PLAYER.cpp:657). - **Read:** `Player::ReadUpdateRecord(message);` then copy both ints back. `simulation->ReadUpdateRecord(update)` is a virtual call (ENTITY.cpp:388) so this resolves correctly on a replicant `BTPlayer`. - Add `ForceUpdate();` after `++killCount` (btplayer.cpp:702) — btplayer.cpp:776 already covers the normal exit, but the `playerVehicle == 0` early return at :762-767 must also ship it (it already calls `ForceUpdate()`; verify). - Flag it: **`@0x513300` slots 6/7 are the engine's, so the arcade did NOT override these — this is a documented PORT DIVERGENCE [T3]**, taken because no authentic mechanism converges the pod roster. ### Step 5 — drop the duplicate `VehicleDead(-1)` dispatch `btplayer.cpp:1732-1742` — **delete** the victim dispatch from `BTPostKillScore`; keep `mech4.cpp:2044-2058`. Both fire in one straight-line pass of `UpdateDeathState` (`BTPostKillScore` is called at mech4.cpp:1963, the second dispatch at :2053, no return between) on the same `GetPlayerLink()`, and `Dispatch` at a valid master receives synchronously (ENTITY.cpp:268) — so the second **always** trips the dedup and fires the always-on `SWALLOWED` warning on every single death, making the #57 tripwire a permanent false positive. The binary sends exactly one (built @0x4a07c0, dispatched @0x4a0887). `BTPostKillScore` then means only what its name says. ### Step 6 — same wave, sequenced AFTER the counters: restore the authentic killer channel The 1995 `VehicleDeadMessage` is BT-extended and carries the **killer's PLAYER EntityID at msg+0x2c** (resolved @0x4c0611-0x4c062b); our engine class has only `deathCount` + `dropZoneID` (PLAYER.h:107-123) and `BTPostKillScore` constructs it with no killer at all. Subclass it in BT (`class BTPlayer__VehicleDeadMessage : public Player::VehicleDeadMessage { EntityID killerPlayerID; };`), build it at mech4.cpp:2048, and wire the **three** authentic consumers the port fakes or omits: `killerName` (btplayer.cpp:464 is a canned `SelfDestructName`, and the comment at :456-464 wrongly says the binary reads msg+0x1c — that is the `deathCount` the `-1` test uses at 0x4c05df), the **type-1 "KILLED BY" ticker line** (@0x4c0641-0x4c0668, the mirror of the type-0 cell at btplayer.cpp:717-726), and the **revenge lock** `objectiveMech = killer->playerVehicle` (@0x4c06dd-0x4c06e3). Not required for #45's counters — **do not gate #45 on it.** ### Step 7 — kill attribution (separate defect, same report) - `Mech::Reset` (mech4.cpp ~1755, next to the `graphicAlarm.SetLevel(0)`): clear `lastInflictingID`/`lastInflictingDamage` (already a to-do at `docs/RESPAWN_REARM_PLAN.md` Step 5). - Seed `lastInflictingID` to the mech's **own** EntityID in the ctor — the binary does (`EntityID` copy-ctor `mech+0x43c ← mech+0x184` @0x4a16ed-0x4a16fb) — so an unattributed death resolves to self and `BTPostKillScore`'s `killer != victim` guard (btplayer.cpp:1709) rejects it. Our port leaves it default `0:0`. - Guard `sender_mech` against NULL at btplayer.cpp:661 and :690 (`MECH_OWNING_PLAYER`/`MECH_TONNAGE` are dereferenced before any test; `GetEntityPointer` returns 0 for an unknown id). - Note for a later wave, **not** this one: the binary does not attribute from `mech+0x43c` at all — it builds the award inside the victim's `TakeDamage` handler on the *was-alive→now-dead edge of that hit* (@0x4a052b-0x4a05ce). Replacing `lastInflictingID` with that edge is the faithful cure for "a kill I didn't earn"; it is a bigger change than #45. ### KB / comment sweeps required by this change `btl4gau3.cpp:513-516` and `docs/GAUGE_COMPOSITE.md:531` ("the binary's dead pad_0x280") — it is the authentic DEATHS cell. `context/gauges-hud.md:330-340` + the `open_questions` entry at :15 ("**RESOLVED 2026-07-12 [T2]** deaths now tally per node from locally observed events") — it has never executed once. `btplayer.cpp:1572-1580` and :1692-1702 (both claim the KILLS credit already reaches remote local copies "via the player link") and `mech4.cpp:2014-2018` — all contradicted by ENTITY.cpp:244; mech4.cpp:1836-1840 is the correct statement. `btplayer.cpp:299-301` ("the binary also runs killer/victim score bumps (+0x280 via @0042e580)") — 0x42e580 is `AddStatusMessage`; the +0x280 pair is @0x4c0674/0x4c067a. `btplayer.hpp:231` — `VehicleDeadMessageHandler` is `@004c05c4`, not `@004c012c`. `btplayer.cpp:437` "message+0x38" — that is `FUN_004c012c`'s DropZoneReply message; the VehicleDeadMessage's `deathCount` is at msg+0x1c. `context/gauges-hud.md:332` cites `PLAYER.cpp:759` for the −2 seed; it is :777. `docs/RESPAWN_REARM_PLAN.md` §5 issue map — "**#45 SUBSUMED — same defect, not two**" is **wrong**: the respawn defect explains only the victim's own DEATHS in 4 of 47 deaths; the cross-machine disagreement is structural and independent. And **fix the decomp export for 0x4c05c4** (`reference/ghidra_scripts/`) before anyone else reasons from that gap. --- ## 4. VERIFICATION **Rig** (2-node localhost relay — the recipe verified in `context/multiplayer.md:352-360` and reused by `docs/RESPAWN_REARM_PLAN.md` §6): ``` python tools/btconsole.py --relay 1500 MP_RELAY.EGG --bind 127.0.0.1 # pod A = the VICTIM BT_RELAY=127.0.0.1:1500 BT_SELF=10.99.0.1:1502 BT_MATCHLOG=1 \ BT_SCORE_LOG=1 BT_DEATH_LOG=1 BT_CAM_LOG=1 BT_SPAWN_XZ=0,0 \ btl4 -egg MP_RELAY.EGG -net 1501 # pod B = the KILLER (BT_MP_FORCE_DMG makes B shoot A's replicant once a second, mech4.cpp:4823) BT_RELAY=127.0.0.1:1500 BT_SELF=10.99.0.2:1602 BT_MATCHLOG=1 \ BT_SCORE_LOG=1 BT_DEATH_LOG=1 BT_MP_FORCE_DMG=1 BT_SPAWN_XZ=0,60 \ btl4 -egg MP_RELAY.EGG -net 1601 ``` Use an egg that carries name bitmaps (OPERATOR-class), or the rows render as authentic cache-miss boxes. Kill A **twice: once by weapon fire, once by RAM** (drive B into A) — the collision path is the one that steals credit and it produced 5 of the 47 field deaths. **Baseline (today's build) — the exact failure signature:** - A's `btl4.log`: `[respawn] player H:1 death cycle START (death #1)` followed by **one** `[respawn] WARNING: death for player H:1 SWALLOWED` (the duplicate dispatch) and **no** `[score] *** KILL ***`. - B's `btl4.log`: `[score] *** KILL *** killerPlayer=… killCount=1 …` and **no** death line. - **Neither** log can ever contain `[score] observed death` — that string is unreachable in this build. - A's matchlog: `DEATH … inst=M killer=B:mech killdmg=X`, `PLAYER_DEAD player=A:1 deaths=1`, no `SCORE type=2`. - B's matchlog: `SCORE player=B:1 type=2 award=X kills=1` **plus** `DEATH … inst=R killer=0:0 killdmg=0.000`. - **On screen:** A shows A K0/D1, B K0/D0. B shows B K1/D0 **and A K1/D0** (the phantom). *That divergence is the bug, reproduced on two machines.* **Pass criteria after the fix — on BOTH screens, identical:** | # | artefact | criterion | |---|---|---| | 1 | A `btl4.log` | `[death] VehicleDead(-1) dispatched to the owning player` present, **exactly one** `death cycle START`, and **zero** `SWALLOWED` (Step 5 removed the duplicate — the warning is a tripwire again) | | 2 | A matchlog | `PLAYER_DEAD player=A:1 deaths=1 deathsCell=1`, ~15-35 ms before `DEATH … inst=M` (the working-case ordering, e.g. `69.117.98.17_matchlog_20260724_224438_16708.txt`) | | 3 | B matchlog | exactly **one** `SCORE … type=2 award=X kills=1`; `award` == A's `killdmg` to 2 dp | | 4 | **new** `TALLY` records (below) | on B: exactly **one** `TALLY field=kills target=B:1 inst=M new=1` and **no** `target=A:1` line. On A: exactly **one** `TALLY field=deaths target=A:1 inst=M new=1` | | 5 | **new** `ROSTER` record (below) | after replication settles (≤1 update tick), **byte-identical roster lines on both nodes**: `A kills=0 deaths=1` and `B kills=1 deaths=0`, with `inst=M` on the local row and `inst=R` on the remote one | | 6 | screen, both pods | Comm pilot list: **same two rows, same order, A 0/1, B 1/0.** Kill A again → both show A 0/2, B 2/0. Kill B once → both show A 1/2, B 2/1 | | 7 | ram kill | the credited killer is the rammer and the award appears once, on the rammer's node only — and A's `deaths` still increments | | 8 | control | the SCORE column agreed before this change and must still agree (it is the positive control for the record path) | **Diagnostics to ADD — the current logging cannot show a remote tally at all, which is why the phantom kill is invisible in 88 logs and cost this investigation four independent traces:** 1. `game/reconstructed/btplayer.cpp:702` (kill) and `:436` (death) — a `TALLY` matchlog record at **every** counter write: `field=`, `target=:`, `inst=M|R` (`GetInstance()`), `new=`. Unconditional (matchlog only arms on `-net`, matchlog.cpp:66-80, so solo stays silent). 2. `game/reconstructed/mechmppr.cpp:1265-1295` (tail of `FillPilotArray`) — a `ROSTER` record, one line per occupied slot: `slot=`, `player=:`, `inst=M|R`, `kills=`, `deaths=`, `score=`. **This is the artefact that would let one round's logs answer "what did the killer's screen actually show".** It also directly settles the one claim nobody measured: that the replicant `BTPlayer` copies really are the objects the rows read. 3. `game/reconstructed/btplayer.cpp:1216` (tail of `InitializePlayerLink`) — a `PLAYER_LINK … link=%p` record. `link=0` convicts #54 in one line. 4. Add checks 4/5 to `tools/matchcheck.py` as standing invariants: **`sum(TALLY field=kills) == count(SCORE type=2)`** and **every node's final `ROSTER` block is identical**. Those two make #45 regression-testable without a 5-pod session. **Regression case of record:** the final round — 5 nodes, one player takes all three kills, three victims. Reproduce it at 3 nodes if 5 are unavailable; it is simultaneously "one node got all the kills" and "no node counted any death", the cleanest case in the corpus. Per the standing rule, the tracker entry reads **"fix landed, awaiting human verification"** until a human confirms both screens live. --- ## 5. RISKS (concrete, keyed to `context/reconstruction-gotchas.md`) 1. **Shadow field (#1) — the trap this change walks straight into.** The new member must **not** be named `deathCount`: `Player::deathCount` already exists (PLAYER.h) and a re-declaration shadows the base, reads `0xCDCDCDCD`, and mis-offsets everything after it. Name it `deaths`. Likewise `BTPlayer__UpdateRecord` must **not** re-declare `currentScore` or `dropZoneLocation`. 2. **Databinding trap (#3) + no offset lock.** Our `BTPlayer` layout is **not** offset-faithful — `killerName` is declared *after* `deathPending` even though it is `@0x1d0` (btplayer.hpp:396-409), and the `// @0x2xx` comments are documentation of the 1995 object, not of ours. So: (a) never raw-read `pilot+0x280`; go through `GetDeaths()`/`BTPilotDeaths` (btplayer.cpp:1547) — the pre-#43 raw reads are exactly what produced the blank/`-1` scoreboard; (b) **do not add a `static_assert` on `offsetof(BTPlayer, deaths) == 0x280`** — it will fail and it would be asserting a false claim. Lock what is real instead: `sizeof(BTPlayer__UpdateRecord)` and `sizeof(BTPlayer__UpdateRecord) > sizeof(Player__UpdateRecord)`. 3. **/FORCE trap (#6).** `Read/WriteUpdateRecord` are virtuals reached only through the vtable (`simulation->ReadUpdateRecord`, ENTITY.cpp:388). A declared-but-undefined override links "successfully" under `/FORCE` and AVs at a garbage target near `__ImageBase` on the **first replicated player update** — i.e. seconds into any MP session, far from the edit. **Grep the link log for unresolved `BTPlayer::*UpdateRecord` before running.** 4. **Record growth into a shared fixed buffer (#21 shape).** `Simulation::WriteSimulationUpdate` writes straight into the stream with no bounds check before the write (SIMULATE.cpp:311-321), backed by a single static `Update_Buffer[MAX_UPDATE_LENGTH>>2]`, **`MAX_UPDATE_LENGTH == 1400`** (ENTITY.cpp:472-474). A Player emits **one** record per update, so +8 bytes is ample headroom — but it is also the wire `messageLength` (ENTITY.cpp:589), so keep the pair to two ints and do not let this become the precedent for a fat player record. 5. **Wire-format skew.** The record is self-describing (`AdvancePointer(update->recordLength)`, ENTITY.cpp:390) but a **mixed-build session mis-parses the stream from that record onward** — a pod on the old build feeds the new one a short record. All pods must be on the same build for the test; state it in the test instructions. 6. **Dirty-bit mis-mapping (#20) — the change in Step 2 is inside that trap.** Confirm at review that the edit is `ForceUpdate()` (`updateModel@0x18`) and **not** any `simulationFlags@0x28` write. Also check whether the pre-existing `DelayWatchersFlag` that btplayer.cpp:453 has been setting since it shipped was silently suppressing a player-simulation watcher — removing it may *change* behaviour elsewhere (that is a fix, but it will look like a side effect). 7. **Do not remove the entity.** Nothing in this plan may issue `DestroyEntityMessage` on a death — the wreck stays (the P5 teardown crash). 8. **Respawn-handshake coupling.** `Player::deathCount` is the respawn identity gate (`message->deathCount == deathCount`, PLAYER.cpp:230) *and* the #57 latch's sequence number. Step 1 deliberately **leaves it alone** and only stops *displaying* it. Any temptation to "clean up" its increment site while in here will destabilise respawn — defer it to its own change with its own verification. 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".