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>
42 KiB
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
-
The port reads DEATHS from the wrong field. The binary's
PilotListdrawspilot+0x27c(KILLS) andpilot+0x280(DEATHS) (reference/decomp/all/part_014.c:3392-3393,fild [edi+0x27c]/[edi+0x280]inPilotList::Execute@0x4cabd0). Our port declares+0x280aspad_0x280(btplayer.hpp:394), never writes it, and substituted the ENGINE'sPlayer::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 fake0by construction. [T1] -
A FUNCTION IS MISSING FROM OUR DECOMP EXPORT, and that is why the KB got it wrong.
BTPlayer::VehicleDeadMessageHandlerlives at @0x4c05c4 and is absent fromreference/decomp/functions_index.tsv, which jumps4c052c(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 doescmp ebx,edi / je / inc [edi+0x280] / inc [ebx+0x280]@0x4c0670-0x4c067a.btplayer.hpp:231still 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. -
The observed-tally model is unsound here and only half-wired. Per kill the port produces exactly TWO counter writes on TWO machines:
++killCounton the killer's node,++deathCounton the victim's node. Neither rides an update record --Player__UpdateRecordiscurrentScore + dropZoneLocationonly (PLAYER.h:73-80) -- so a divergence can never self-heal and bystanders show 0/0 for everyone, all mission. [T0/T1] -
BTPlayerCountObservedDeathis unreachable dead code. Its only call site (mech4.cpp:2019-2027) is gated onReplicantInstancebut sits INSIDEMech::UpdateDeathState's transition block, which a replicant can never enter (it takes the mode-9 early return atmech4.cpp:1832). [T1] -
"It showed I got a kill I didn't" = last-hitter-takes-all, and COLLISIONS COUNT.
BTPostKillScoreresolves the killer from((Mech*)victim)->lastInflictingID, whose only writer latches the last applied hit rather than the fatal one -- and ram damage is an ordinaryTakeDamageMessage. 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::Resetalso never clears the field. [T1] -
The phantom kill is AUTHENTIC 1995. The binary's kill handler increments
killCounton 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] -
A serious unrelated bug found on the way:
btplayer.cpp:453doessimulationFlags |= 0x1where the binary doesor word ptr [ebx+0x18], 1=updateModel |= DefaultUpdateModelFlag=ForceUpdate()(SIMULATE.h:146-147). Bit 0 ofsimulationFlagsisDelayWatchersFlag(SIMULATE.h:170) and nothing clears it -- so today the first death of any pilot permanently disablesExecuteWatcherson that player's simulation.context/reconstruction-gotchas.md:474-476flagged 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.
+0x27cis written only by the KILL handler and+0x280only by the DEATH handler (whole-image: ctor zeroes atparam_1[0x9f]/[0xa0], part_013.c:10873-10874; the twoincpairs; the twofildreads). Whatever the Comm pixmap prints, column 1 is the kill-event counter and column 2 the death-event counter. [T1] FUN_004c05c4is ABSENT from the decomp export —reference/decomp/functions_index.tsvjumps4c052c(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:231still attributesVehicleDeadMessageHandlerto@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, notdeathCount— re-declaring an engine-base field is gotcha #1 (reads0xCDCDCDCD+ mis-offsets).btplayer.hpp:324—GetDeaths()returnsdeaths, not the inheriteddeathCount.btplayer.cpp:1478—pad_0x280 = 0;→deaths = 0;.btplayer.cpp:1541-1554—BTPilotDeaths: delete the< 0 ? 0clamp 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 twoDrawcalls stay as-is.btplayer.cpp:436-443— add++deaths;immediately before++deathCount;, and printdeathsin thePLAYER_DEADrecord alongside it. Keep++deathCount: in the binary +0x200 is advanced on the respawn side (inc [ebx+0x200]@0x4c0139 insideFUN_004c012c, whose decompiled caller is DropZoneReply @0x4bffd0, part_013.c:10480) and@0x4c05c4only 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— deleteif (sender_owner) ++sender_owner->killCount;. This is the phantom kill. Keep thePlayer__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 andcmp 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— deleteBTPlayerCountObservedDeathand its comment block;btplayer.hpp:389— delete thefriend;mech4.cpp:2014-2027— delete the wholeReplicantInstanceblock 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);thenupdate->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 replicantBTPlayer. - Add
ForceUpdate();after++killCount(btplayer.cpp:702) — btplayer.cpp:776 already covers the normal exit, but theplayerVehicle == 0early return at :762-767 must also ship it (it already callsForceUpdate(); verify). - Flag it:
@0x513300slots 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 thegraphicAlarm.SetLevel(0)): clearlastInflictingID/lastInflictingDamage(already a to-do atdocs/RESPAWN_REARM_PLAN.mdStep 5).- Seed
lastInflictingIDto the mech's own EntityID in the ctor — the binary does (EntityIDcopy-ctormech+0x43c ← mech+0x184@0x4a16ed-0x4a16fb) — so an unattributed death resolves to self andBTPostKillScore'skiller != victimguard (btplayer.cpp:1709) rejects it. Our port leaves it default0:0. - Guard
sender_mechagainst NULL at btplayer.cpp:661 and :690 (MECH_OWNING_PLAYER/MECH_TONNAGEare dereferenced before any test;GetEntityPointerreturns 0 for an unknown id). - Note for a later wave, not this one: the binary does not attribute from
mech+0x43cat all — it builds the award inside the victim'sTakeDamagehandler on the was-alive→now-dead edge of that hit (@0x4a052b-0x4a05ce). ReplacinglastInflictingIDwith 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, noSCORE type=2. - B's matchlog:
SCORE player=B:1 type=2 award=X kills=1plusDEATH … 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:
game/reconstructed/btplayer.cpp:702(kill) and:436(death) — aTALLYmatchlog 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).game/reconstructed/mechmppr.cpp:1265-1295(tail ofFillPilotArray) — aROSTERrecord, 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 replicantBTPlayercopies really are the objects the rows read.game/reconstructed/btplayer.cpp:1216(tail ofInitializePlayerLink) — aPLAYER_LINK … link=%precord.link=0convicts #54 in one line.- Add checks 4/5 to
tools/matchcheck.pyas standing invariants:sum(TALLY field=kills) == count(SCORE type=2)and every node's finalROSTERblock 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)
- Shadow field (#1) — the trap this change walks straight into. The new member must not be named
deathCount:Player::deathCountalready exists (PLAYER.h) and a re-declaration shadows the base, reads0xCDCDCDCD, and mis-offsets everything after it. Name itdeaths. LikewiseBTPlayer__UpdateRecordmust not re-declarecurrentScoreordropZoneLocation. - Databinding trap (#3) + no offset lock. Our
BTPlayerlayout is not offset-faithful —killerNameis declared afterdeathPendingeven though it is@0x1d0(btplayer.hpp:396-409), and the// @0x2xxcomments are documentation of the 1995 object, not of ours. So: (a) never raw-readpilot+0x280; go throughGetDeaths()/BTPilotDeaths(btplayer.cpp:1547) — the pre-#43 raw reads are exactly what produced the blank/-1scoreboard; (b) do not add astatic_assertonoffsetof(BTPlayer, deaths) == 0x280— it will fail and it would be asserting a false claim. Lock what is real instead:sizeof(BTPlayer__UpdateRecord)andsizeof(BTPlayer__UpdateRecord) > sizeof(Player__UpdateRecord). - /FORCE trap (#6).
Read/WriteUpdateRecordare virtuals reached only through the vtable (simulation->ReadUpdateRecord, ENTITY.cpp:388). A declared-but-undefined override links "successfully" under/FORCEand AVs at a garbage target near__ImageBaseon the first replicated player update — i.e. seconds into any MP session, far from the edit. Grep the link log for unresolvedBTPlayer::*UpdateRecordbefore running. - Record growth into a shared fixed buffer (#21 shape).
Simulation::WriteSimulationUpdatewrites straight into the stream with no bounds check before the write (SIMULATE.cpp:311-321), backed by a single staticUpdate_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 wiremessageLength(ENTITY.cpp:589), so keep the pair to two ints and do not let this become the precedent for a fat player record. - 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. - 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 anysimulationFlags@0x28write. Also check whether the pre-existingDelayWatchersFlagthat 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). - Do not remove the entity. Nothing in this plan may issue
DestroyEntityMessageon a death — the wreck stays (the P5 teardown crash). - Respawn-handshake coupling.
Player::deathCountis 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. - Deleting
BTPlayerCountObservedDeathis safe but must be complete. It survives today only via theextern+ thefriendat btplayer.hpp:389; remove the definition, the call site, the extern and the friend together, or/FORCEhides the stragglers again. - 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.mdwith 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".
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=2records across 255 node-logs credit the LOGGING node's OWN player; not one credits a remote pilot. - 0 of 8800
DMGrows target a replicant — so a non-victim node genuinely cannot know the killer, which is why everyDEATH inst=Rrow readskiller=0:0and 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 thekilldmg=5.881recorded 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 newstatic_asserts hold), and noBTPlayer::*UpdateRecordamong the unresolved externals — §5 risk 3 cleared. The 40LNK2019s in that log are the pre-existing/FORCE-toleratedCreateStreamedSubsystemfamily frommech3.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 readkills=2 deaths=3. Before the fix a remote pilot's column never left 0. - The
-2seed is gone off-node: the first mirror on each side showswasDeaths=-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.deathCountis 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
- Live multi-pod verification by a human — the memory rule: this stays "fix landed, awaiting verification" until confirmed in a real session.
- 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.
- Still open, deliberately untouched: §Headline-1 (which field the LOCAL pod's DEATHS
column should read,
+0x280vsdeathCount), §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).