Files
BT411/docs/KD_SCOREBOARD_PLAN.md
T
arcattackandClaude Opus 5 5174c638ce K/D scoreboard ROOT-CAUSED (#45) + respawn latch fix (#57) + plate stage ops (#58)
FIXES (compile clean; exe link pending -- a game client still holds btl4.exe):

#57 respawn latch: deathPending was released in ONE place (btplayer.cpp:343) and
only if a LATER re-post happened to find the mech alive -- so if the +5s re-post
landed before the drop-zone reply, or never arrived, the flag stayed set while
the pilot respawned and fought on, and their NEXT death hit the dedup at :391
and was swallowed whole (no warp, no tally, no hunt) for the rest of the
process.  Tonight 3 of 3 deaths in the final round were swallowed, each by a
player who had respawned successfully earlier in the same process.  Clear moved
to the actual completion point (after Mech::Reset at the drop zone) + the four
abandon paths that also stranded it.  Cycle logging promoted to always-on
(START / RESET / SWALLOWED warning) + a RESPAWN matchlog record.

#58 reticle callsign plate drew as a SOLID RECTANGLE on some maps: my
dpl2d_DrawTexturedRect bound a texture but never set COLOROP/ALPHAOP, and
L4D3D.cpp:1085 sets both to SELECTARG1 from TFACTOR (ignores the texture, fills
with a constant).  Which state was live depended on what the 3D pass drew last
-- hence 'depends on the level'.  Now sets MODULATE texture x diffuse for both
channels and restores.  Latent sibling noted (CameraHUDDrawQuad, same omission).

ROOT-CAUSE DOC (no code): docs/KD_SCOREBOARD_PLAN.md.  Headlines:
 - the port reads DEATHS from the WRONG FIELD: the binary's PilotList draws
   +0x27c/+0x280; we labelled +0x280 'pad_0x280', never write it, and
   substituted the engine's Player::deathCount (+0x200, the respawn identity
   number seeded to -2) -> remote rows read a clamped fake 0 by construction;
 - BTPlayer::VehicleDeadMessageHandler @0x4c05c4 is MISSING from our decomp
   export (functions_index.tsv jumps 4c052c -> 4c083c, verified) -- that silence
   is what produced the 'pad' belief.  An absent function is not evidence of an
   absent behaviour;
 - neither counter rides an update record, so divergence never self-heals and
   bystanders show 0/0 all mission;  BTPlayerCountObservedDeath is unreachable
   dead code;
 - 'a kill I didn't earn' = last-hitter-takes-all via lastInflictingID, and
   COLLISIONS count (5 of 47 deaths had a type=0 killing blow);
 - the phantom kill is authentic 1995 (the kill handler bumps the victim's
   killCount too);
 - btplayer.cpp:453 does simulationFlags |= 0x1 where the binary does
   ForceUpdate() -- bit 0 is DelayWatchersFlag and nothing clears it, so the
   first death of any pilot permanently disables ExecuteWatchers on that
   player's simulation.  Filing separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-25 01:50:07 -05:00

231 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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=<host>:<id>`, `inst=M|R` (`GetInstance()`), `new=<value>`. 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=<host>:<id>`, `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".