Files
BT411/docs/KD_SCOREBOARD_PLAN.md
T
arcattackandClaude Opus 5 4fa7eee54f K/D scoreboard ROOT-CAUSED for real (#45): the kill credit was rerouted, not lost
Kills/deaths only ever appeared on ONE machine.  The cause is not a missing
tally -- it is Entity::Dispatch:

    if (GetInstance() == ReplicantInstance)              // ENTITY.cpp:244-251
        application->SendMessage(ownerID, EntityManagerClientID, message);

BTPostKillScore runs on the VICTIM's node (the only node whose mech carries a
populated lastInflictingID), resolves the killer's Player -- a REPLICANT there --
and Dispatch()es to it.  The engine reroutes that to the owning host, so
++killCount lands on the killer's own PC and nowhere else, and nothing carried it
back out: Player__UpdateRecord is currentScore + dropZoneLocation only.  Every
other pod's copy therefore read 0 all mission.  playerLink was never NULL for
these kills -- the dispatch proves it resolved.

Field proof over the whole corpus (255 node-logs): 125 of 125 SCORE type=2 rows
credit the LOGGING node's own player, not one credits a remote pilot; and 0 of
8800 DMG rows target a replicant, so no other node can even know the killer.

This means the owner's counter is already the single authoritative copy,
incremented exactly once per kill.  So the "design decision" the plan was blocked
on collapses: there is no second writer to reconcile, only a one-way
owner->replicant mirror to add.

  * BTPlayer__UpdateRecord = Player::UpdateRecord + killTally + deathTally, with
    Read/WriteUpdateRecord overrides.  No new data member, no new virtual ->
    sizeof(BTPlayer)==0x28c and every existing offset lock still holds.
  * Both counter writes already ForceUpdate() (:508 death, :829/:840 score), so
    no dirty-bit edit was needed (plan risk 6 avoided).
  * recordLength guard in ReadUpdateRecord: a pod on a build without the
    extension degrades to "remote counters don't move" instead of reading a
    neighbouring record as a kill count.
  * Size locks added per plan risk 2 (no false offsetof assert).

Rig-verified, 2-node loopback (scratchpad/rig45_up.ps1, BT_MP_FORCE_DMG):
owner 3:1 finished kills 3/deaths 2 and the peer read kills=3 deaths=2; owner 2:1
finished kills 2/deaths 3 and the peer read kills=2 deaths=3.  Exact convergence
where a remote column previously never left 0.  3 respawns per side with intact
death sequences (deathCount is only overwritten on replicant copies; the
handshake runs on masters), no crash, no GLITCH rows.

Kept byte-faithful: the kill handler's partner increment (the binary's
inc [ebx+0x27c] / inc [edx+0x27c] wrong-column slip) is still reproduced.  It
always lands on a replicant copy, so the owner's record now overwrites it -- the
rig caught the correction repeatedly (wasKills=3 -> kills=2).  The phantom kill
stops being visible without silently deciding the fidelity question.

Still open and deliberately untouched: which field the LOCAL pod's DEATHS column
should read (+0x280 vs deathCount), last-hitter-takes-all/ram kills, and the
slip's fidelity.  Awaiting live multi-pod verification by a human.

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

42 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.
  • The -2 seed is gone off-node: the first mirror on each side shows wasDeaths=-2 -> deaths=<real>, so a remote pilot's DEATHS no longer displays the clamped fake 0 that §Headline-1 describes. (§Headline-1's own-pod field-choice question is untouched and still open.)
  • Respawn unaffected (the §5 risk-8 worry): 3 respawns per side, deaths= sequences intact, no drop-zone or latch anomaly. deathCount is only ever overwritten on a REPLICANT copy, and the handshake runs on masters.
  • Phantom kill now self-corrects: the rig captured the owner's value overwriting the locally-applied partner increment repeatedly (wasKills=3 -> kills=2, wasKills=2 -> kills=1).
  • No crash, no GLITCH (plane-audit) rows, both nodes alive through 3 death/respawn cycles.

Still to do

  1. Live multi-pod verification by a human — the memory rule: this stays "fix landed, awaiting verification" until confirmed in a real session.
  2. All pods must run the same build for a scoreboard test (§5 risk 5) — the guard makes a mixed session degrade safely rather than corrupt, but the numbers only converge when both ends have the extension.
  3. Still open, deliberately untouched: §Headline-1 (which field the LOCAL pod's DEATHS column should read, +0x280 vs deathCount), §Headline-5 (last-hitter-takes-all and ram kills), and §Headline-6's fidelity question (the wrong-column slip is still reproduced; it is merely no longer visible).