Files
BT411/docs/KD_SCOREBOARD_PLAN.md
T
arcattackandClaude Opus 5 a52207d779 #45 scoreboard: reclaim the binary's DEATHS field, add a heartbeat, sweep the false KB claims
Follow-up to 4fa7eee, driven by an adversarial review pass and three rig cycles.
Each item below is a real defect that pass found, not polish.

DEATHS now uses the binary's own column.  PilotList::Execute @0x4cabd0 draws
`fild [edi+0x27c]` (KILLS) and `fild [edi+0x280]` (DEATHS); +0x280 has exactly
two runtime writers image-wide plus the ctor zero.  Our port had declared it
`pad_0x280`, never written, and displayed the ENGINE's Player::deathCount
(+0x200) instead -- which is the respawn-handshake identity, seeded -2, and is
why it needed a clamp to pass as a count.  It is now BTPlayer::deathTally (our
offset 0x274, offsetof-locked), incremented beside ++deathCount, read by the
gauge, and replicated.  deathCount is left to the engine's handshake.  So this
half moves TOWARD the binary; it also closes the plan's Headline-1.
(`deathTally`, NOT `deaths`/`deathCount` -- those would shadow the base.)

The mirror was not self-healing: update records are UNRELIABLE by construction
(Entity::UpdateMessage clears ReliableFlag, ENTITY3.h:112; the relay's UDP path
also drops stale/reordered datagrams), and the pair was dirtied only on an
event.  One lost datagram left every peer stale until that pilot's next kill or
death -- forever for the last kill of a round.  Added a 2s heartbeat that
re-dirties the record, which also bounds how long the binary's phantom
partner-increment stays visible.  The timer is a function static deliberately: a
data member would change sizeof(BTPlayer) and break the offset locks.

Also fixed: the SBMIRROR row AND its change-detect both still read deathCount (a
constant -2 here), so the "log only the edge" guard could never be false and
every row printed deaths=-2.  That is what made the first rig runs look like
DEATHS was broken when a WRITE/READ trace proved the transport correct.  Row
count per node fell from ~20 to 3, one per real change.

Guarded the record against per-bit layouts: update_model is a BIT INDEX and
Entity::WriteUpdateRecord switches on it (ENTITY.cpp:329-352) -- the DamageZone
bit emits a variable-length packed stream whose length it computes itself, so
appending two ints and re-stamping recordLength over that would corrupt it.

Deleted BTPlayerCountObservedDeath -- definition, call site, extern and friend
together, since /FORCE hides stragglers.  It never executed (its call site sat
inside the once-per-death transition, which a replicant never enters) and could
not have worked (0 of 8800 corpus DMG rows target a replicant, which is why every
DEATH inst=R row reads killer=0:0).  Under replication it would have been a
second writer of a replicated counter.

New forensics: NOCREDIT names the failing link when a kill credit is skipped (it
used to be completely silent -- the counter simply never moved), and PLAYER_LINK
records whether the one-shot link resolved.  Both retire the NULL-playerLink
theory: every rig shows `PLAYER_LINK inst=R resolved=1` and no NOCREDIT rows.
PLAYER_DEAD now logs both counters (deaths=handshake, tally=scoreboard).

KB sweep of the claims that hid this bug for so long:
  * context/gauges-hud.md's "RESOLVED -- deaths tally per node from locally
    observed events" was FALSE; corrected with the measured evidence.
  * docs/GAUGE_COMPOSITE.md + btl4gau3.cpp "the dead pad_0x280" -- never dead,
    merely unwritten.
  * btplayer.hpp attributed VehicleDeadMessageHandler to @004c012c; it is
    @004c05c4 (absent from the decomp export -- the #60 gap).
  * docs/RESPAWN_REARM_PLAN.md's "#45 SUBSUMED" -- the PLAYER_DEAD symptom was
    subsumed, the scoreboard defect was not.
  * The corpus figure in the record banner now states its method so it is
    reproducible.

Rig-verified (2-node loopback, several cycles): owner 3:1 kills 2/tally 1 read
`kills=2 deaths=1` on the peer; owner 2:1 kills 1/tally 2 read `kills=1 deaths=2`
on the peer.  Respawn unaffected, no crash, no GLITCH rows.  Still awaiting live
multi-pod verification by a human; all pods must run the same build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:09:00 -05:00

46 KiB
Raw Blame History

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 exportreference/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 killif (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-394int 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:324GetDeaths() returns deaths, not the inherited deathCount.
  • btplayer.cpp:1478pad_0x280 = 0;deaths = 0;.
  • btplayer.cpp:1541-1554BTPilotDeaths: 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-443add ++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:453simulationFlags |= 0x1; // request a forced updateForceUpdate();. 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-704delete 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-1592delete 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-1742delete 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:231VehicleDeadMessageHandler 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.

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:

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_asserts hold), and no BTPlayer::*UpdateRecord among the unresolved externals — §5 risk 3 cleared. The 40 LNK2019s 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.
  • DEATHS now uses the binary's own column, and §Headline-1 is CLOSED. The port displayed Player::deathCount (+0x200) -- the respawn-handshake identity, seeded -2, which is exactly why it needed a display clamp. DEATHS is now BTPlayer::deathTally (our offset 0x274 = the binary's +0x280, what PilotList::Execute @0x4cabd0 actually draws with fild [edi+0x280], two runtime writers image-wide). deathCount is left to the engine's handshake. So this half of the change moves TOWARD the binary. PLAYER_DEAD now logs both (deaths= handshake, tally= scoreboard) so a future divergence between them is visible.
  • NOCREDIT and PLAYER_LINK records added, and they retire the "barrier 1" hypothesis. A skipped credit used to be completely silent -- the counter just never moved. NOCREDIT now names the failing link (missing killer entity / self-kill / unregistered mech / NULL link) and PLAYER_LINK records whether the one-shot link resolved. Both rigs: NOCREDIT empty, and PLAYER_LINK ... inst=R ... resolved=1 -- a replicant's playerLink DOES resolve, so a NULL link was never the cause. [T2]
  • 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.

Review-driven refinements (adversarial pass, same day)

A 4-dimension review with a refutation pass ran against the first cut. What survived, and what shipped because of it:

  1. The mirror is NOT self-healing on its own — update records are UNRELIABLE. Entity::UpdateMessage clears ReliableFlag (ENTITY3.h:112) and the relay's UDP path drops stale/reordered datagrams behind a per-sender sequence gate. Since the pair was dirtied only on a score/death EVENT, one lost datagram would leave every peer stale until that pilot's next kill or death — forever, for the last kill of a round. Fixed: a 2 s scoreboard heartbeat re-dirties the record (BTPlayer::PlayerSimulation), so the pair reconverges within a beat. Cheap — a Player has no 60 Hz stream of its own and the record is 68 bytes. The timer is a function static on purpose: a data member would change sizeof(BTPlayer) and break the offset locks. [T0 on the unreliability]
  2. The phantom kill's correction was a race. Same fix: the visibility window for the binary's wrong-column partner increment is now bounded by one heartbeat instead of "whenever that pilot next scores". The increment itself stays byte-faithful.
  3. The edge filter could never be false, and the log lied. The SBMIRROR row and its change-detect both still read the engine's deathCount (a constant 2 here) instead of the replicated deathTally. That is what made the first rig runs look like DEATHS was broken (deaths=-2 on every row) when the transport was provably correct — a WRITE/READ trace showed the right values on both sides. Fixed, and confirmed by the row count dropping from ~20 to 3 per node (one per real change).
  4. Per-bit record layouts. update_model is a BIT INDEX and Entity::WriteUpdateRecord switches on it (ENTITY.cpp:329-352); the DamageZone bit emits a variable-length packed stream whose length it computes itself. Appending two ints and re-stamping recordLength over that would corrupt it. A pilot only ever sets the default bit, which is why the engine's own Player::WriteUpdateRecord is bit-agnostic — guarded anyway.
  5. The dead observed-death tally is DELETED — definition, call site, extern and friend together (/FORCE hides stragglers). Under replication it would have been a second writer of a replicated counter, and it incremented a field the scoreboard no longer reads.
  6. A skipped credit is no longer silent. New NOCREDIT record names the failing link, and PLAYER_LINK records whether the one-shot link resolved. Both rigs: NOCREDIT empty and PLAYER_LINK ... inst=R ... resolved=1 — which retires the NULL-playerLink hypothesis outright: a replicant's link does resolve, so that was never the cause.

Not acted on, recorded instead: the review measured kill credits landing while the application is not in RunningMission (st= on every matchlog row is the APPLICATION state). In this rig those are an artifact of BT_MP_FORCE_DMG firing during load; the corpus instances are a pre-existing separate concern that replication makes more visible, and gating or resetting the counters per round is a design change for its own pass. killCount/deathTally are still zeroed only in the ctor, exactly as killCount always was.

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).