Files
BT411/docs/KD_CHAIN_AUDIT_20260811.md
T

67 KiB
Raw Blame History

The K/D-score chain audit -- full findings (2026-08-11, ticket #162)

The five-agent authority-model audit that settled the kill/death/score chain once and for all. CURATED digest: context/combat-damage.md section "The K/D-score AUTHORITY MODEL". This file is the complete evidence record -- every dispatch, handler arm, wire layout, replication fact, and field-receipt correlation, with decomp line cites. Read it before touching ANY limb of the scoring chain. The mandatory regression gate is scratchpad/night15/kd_bench.sh (five hard invariants).

Fixes shipped from this audit (4.11.885, commit 3545056): death tail master-gated; #81 latch-clear moved to the binary's DropZoneReply site; steam eggs stamp advancedDamage mission-wide; #38 badge/patch ride the lobby.

Task 5 — #150 fix archaeology + bench coverage hole

1. Exactly what 46ff8dd changed (the #150 fix, 2026-08-10 10:30, "4.11.87x" era)

Commit 46ff8dd262b20a08334d44338e69fc686a6c7958 touched only game/reconstructed/mech.cpp (+28/-1) and added scratchpad/night14/scorepair.sh (new, 30 lines). All changes are inside Mech::TakeDamageMessageHandler.

Before (pre-fix state of the report tail):

  • The call to BTMechPostCombatReports(this, message, damageTally, zoneDestroyed, reportZone, newly_destroyed) ran unconditionally — on BOTH master and replicant instances of the victim mech (the port deliberately applies damage on replicants for local visual response, and the report tail rode along).
  • The newly_destroyed argument was computed as deathBlastArmed && IsMechDestroyed() — a movement-mode-derived flag, not an entry/exit edge.

After (the two binary-cited gates):

  1. Master-only gate (mech.cpp:1270 today): if (GetInstance() != ReplicantInstance) { BTMechPostCombatReports(...); }. Grounded in the binary's opening assertion if ((flags & 0xc) == 4) Fail("Replicant Mech recieving takedamage", MECH.CPP, 0x3da) [part_012.c:14613] — in 1995 the tail could only ever run on the victim's master. Local damage application on replicants stays.
  2. destroyedAtEntry latch (mech.cpp:1039 today): const int destroyedAtEntry = IsMechDestroyed(); sampled at handler entry, and the last argument became !destroyedAtEntry && IsMechDestroyed() — the binary's local_14 0->1 edge across this handler call (part_012.c:14625-14629). A later salvo of the killing volley (arriving after the mech is already destroyed) takes the inflicted-only path.

What the latch gates vs what it does NOT gate (verified against current mech.cpp):

  • It gates only the newly_destroyed argument, i.e. block A (the type-2 KILL report to the shooter's player) inside BTMechPostCombatReports (btplayer.cpp, if (newly_destroyed) branch).
  • It does NOT gate block B (type-0 inflicted report) or block C (type-1 received report) — post-death salvos on the master still emit those.
  • It does NOT gate the death_tail at mech.cpp:1335-1343: if (deathBlastArmed && IsMechDestroyed()) { BTMechPostVehicleDead(...); BTApplyDeathSplash(...); } — this still uses the OLD pre-latch predicate, and the master-only gate does not cover it either (it sits outside the GetInstance() != ReplicantInstance block). BTMechPostVehicleDead is the dispatcher of the VehicleDead message whose deathCount == -1 branch in BTPlayer does ++deathCount / ++deathTally, logs PLAYER_DEAD, and applies the -500 death cost (btplayer.cpp:582-736). [Inference, flagged] This ungated, un-latched VehicleDead dispatch is the natural suspect for #162's deathCount multi-increment — 46ff8dd hardened the score reports and left the death notification on the old predicate.

2. Other recent score/death/penalty changes in btplayer.cpp (last 3 weeks)

git log on game/reconstructed/btplayer.cpp (all within 2026-07-25 → 2026-08-08):

  • a52207d / 48d47ef (07-25, #45): reclaimed the binary's DEATHS field (deathTally @+0x280) as the displayed/replicated counter, distinct from the engine's respawn-handshake deathCount (@+0x200, seeded -2); PLAYER_DEAD matchlog row logs BOTH (deaths= and tally=).
  • a357dc4 (07-30, #81 ghost): released the stuck deathPending death latch + stopped a duplicate VehicleDead. Note: deathPending (btplayer.cpp:546-559) is the ONLY dedup on the death cycle — a death arriving while a cycle is pending is SWALLOWED with a warning.
  • 1efe8ef (08-02, #118): restored SpecialCaseDeathPenalty (role+0x20) and its consumer: every death with advancedDamageOn hands the ENGINE base Player::ScoreMessageHandler a type-1 award of -penalty via a DIRECT base call — deliberately bypassing BT dispatch, therefore structurally invisible to the matchlog (btplayer.cpp:726-736 today).
  • 91bd286 (08-05, #45/#134): the big one — reconstructed the authentic report tail BTMechPostCombatReports (blocks A/B/C @0x4a02f4-0x4a0890) + BTMechPostVehicleDead (@0x4a07d4); retired the bring-up producers BTPostDamageScore/BTPostKillScore. This is what moved report emission into the victim's TakeDamage handler — the code 46ff8dd later had to gate.
  • 2772175 (08-07): restored the type-0 Dispatch-override interceptor (BTPlayer::ScoreInflictedMessageHandler @004c0200) — per-hit inflicted credit was real 1995 behavior.
  • e82f54c (08-07): finding-only + a console-flush watermark; two recorded wrong turns.
  • 1324c81 (08-07): inflicted credit lands on the OWNER's machine — Dispatch intercepts type 0 on a MASTER only; the type-0 arm of ScoreMessageHandler DELEGATES (wire deliveries go straight to the handler table, never the virtual Dispatch).
  • b2498ca (08-07): the delegating type-0 arm must return, not break — the fall-through clobbered scoreAward to 0 and ran the base handler again (a duplicate-row generator, then harmless-by-luck).
  • a4bfb64 (08-07): BOUND scenarioRole (was NULL forever — one commented-out lookup). This is what made deathPenalty=500 / killBonus=500 real; it also established that -500 on an ordinary combat death is authentic (gate = advancedDamageOn alone, @004c05c4 tail).
  • e0b91df (08-07): correction — the death cost was never missing; it is invisible to the matchlog because of the direct base call. Added the ungated [deathcost] ... -> APPLYING/SKIPPED DEBUG_STREAM receipt (btplayer.cpp:716-724). Explicit lesson recorded: "a value that cannot appear in the log you are reading is not evidence of absence."
  • 29b4d68 (08-07): the +1000 MissionStarting seed grant. 5b7e481 (08-08): mech+0x354 vestigial label, no behavior change.

3. The bench (scorepair.sh) — what it verified and what it could not see

scratchpad/night14/scorepair.sh (added by 46ff8dd): 2 nodes — B unarmed respawning target (player 3:1), A autofire missile killer that panic-ejects on a timer (player 2:1), relay via btconsole.py, 300 s. Its RESULT section is three checks:

  1. grep -ac "Mech::Reset" sc_b.log — a count of B's resets (proxy for "kills A should have earned").
  2. grep -ah "SCORE\|KILL\|EJECT\|DEATH" matchlog_*.txt | head -25 — the score sequence.
  3. grep -a "scorepost" sc_a.log / sc_b.log"scorepost" matches NOTHING in the codebase (grep over game/ + engine/ returns zero hits); these two checks were dead on arrival and always print empty.

What the captured receipts actually show:

  • scratchpad/night14/sc.out (02:56, pre-fix run) and sc2.out (10:28, the run cited in the commit): in BOTH, the head -25 was exhausted by per-hit noise rows (type=0 award≈1.97 spam in sc.out; the victim node's type=1 award=-0.00 spam in sc2.out) before a single KILL / type=2 / DEATH row appeared. The .out receipts therefore contain zero evidence about kills, deaths, or penalties. The commit's verified numbers ("kills 0->1->2, zero doubles, -900/-1050") match the raw matchlogs, so the verification was done by out-of-band manual matchlog reading, not by the bench's printed result.
  • PLAYER_DEAD is matched by none of the bench's grep patterns — "DEATH" does not substring-match "PLAYER_DEAD". The death counter was never surfaced, let alone compared against real deaths.
  • award=-500 can never appear in any matchlog — the death cost is a direct Player::ScoreMessageHandler base call bypassing BTMatchLog (btplayer.cpp:712-715 says so explicitly; e0b91df is the commit that learned this). The only receipts are the [deathcost] DEBUG_STREAM lines in the node logs — which the bench never greps. (They DO exist in the post-fix run: content/sc_a.log has 4× [deathcost] player 2:1 ... penalty=500 ... -> APPLYING, content/sc_b.log has 2× for player 3:1 — so at fix time the -500 was applying on both nodes, provable only by artifacts the bench never printed.)

The smoking gun in the pre-fix artifacts: scratchpad/night14/mlbak/matchlog_20260810_0250*.txt (the run that reproduced #150) already contains the #162 death-side signature, in the same file as the double kill:

PLAYER_DEAD t=1827953 w=02:53:58.090 st=5 player=3:1 deaths=1 tally=1
PLAYER_DEAD t=1827953 w=02:53:58.091 st=5 player=3:1 deaths=2 tally=2

— two deathCount increments 1 ms apart for ONE death, immediately adjacent to the double kill rows (type=2 award=501.96 kills=1 / award=505.88 kills=2, also 1 ms apart) that #150 was chasing. Post-fix (10:24 run, content/matchlog_20260810_102334_1912.txt) PLAYER_DEAD rows read singly (deaths=1 then deaths=2, minutes apart), so in THIS bench topology the fix incidentally quieted the double too — but nothing asserted it, and no one looked at the death side of either artifact.

4. The precise coverage hole

  1. The bench asserted only the kill side, and only manually. Its scripted receipts were (a) a respawn count on the victim, (b) a head -25 matchlog slice that noise-truncated before any kill/death row in both captured runs, and (c) two greps for a log tag (scorepost) that does not exist. Nothing scripted compared type-2 count vs death count, PLAYER_DEAD count vs actual deaths, or score totals vs expected arithmetic.
  2. PLAYER_DEAD was un-greppable by the pattern used ("DEATH" ≠ "PLAYER_DEAD"), so death-count duplication — already present and visible in the pre-fix artifact — was structurally invisible to the bench.
  3. The -500 penalty is invisible to the matchlog by design (direct base-handler call), and the bench never grepped the [deathcost] DEBUG_STREAM receipts or computed total-arithmetic deltas — the only two ways to see it. A regression that silenced the penalty would print an identical bench RESULT.
  4. The fix itself is asymmetric and the bench could not detect the asymmetry: 46ff8dd's master-only gate + entry latch cover only BTMechPostCombatReports (mech.cpp:1270-1276); the death_tail's BTMechPostVehicleDead (mech.cpp:1335-1343) — the sole driver of ++deathCount/++deathTally/PLAYER_DEAD and the death-cost block — kept the old deathBlastArmed && IsMechDestroyed() predicate, ungated by instance and un-latched. [Inference] That is the seam where #162's death-side breakage shipped: the kill side got both gates and a (manual) receipt; the death side got neither a gate nor a single line of bench assertion.

==============================================================================

TASK 1 — Binary truth: the report tail of Mech::TakeDamageMessageHandler @004a0230

Function: FUN_004a0230 (reference/decomp/all/part_012.c:14474-14837, export tag file=bt/mech.cpp), bound as Mech entity message id 0x12 TakeDamage (context/decomp-reference.md:265). All claims below are [T1] read from this export unless flagged.

0. Entry protocol (context for the tail)

  • Replicant assert — part_012.c:14613-14616: (mech+0x28 & 0xc) == 4Fail("Replicant Mech recieving takedamage", MECH.CPP, 0x3da). The handler body runs ONLY on the victim's master node.
  • Gyro bounce — :14617-14624 (mech+0x528FUN_004b2980(gyro, msg+0x2c damageType, msg+0x30 amount)); no messages.
  • THE LATCH — :14625-14629: local_14 = (FUN_0049fb54(mech) != 0) = destroyed-at-ENTRY. FUN_0049fb54 @0049fb54 (part_012.c:14174-14188) returns 1 iff mech+0x40 (mode) == 2 || == 9. Note it does NOT count mode 10 (see §3 gate asymmetry).
  • lastInflictingID stamp — :14631: mech+0x43c ← msg+0x1c (inflictor EntityID).
  • Party resolve — :14630-14635: registry-find shooter entity by msg+0x1c; local_1c = *(shooter+0x190) = SHOOTER's player; local_20 = *(mech+0x190) = VICTIM's own player. Both resolved BEFORE the collision divert.
  • COLLISION DIVERT — :14636-14639: msg+0x2c (damageType) == 0FUN_0049ffcc(mech, &msg+0x2c) then goto LAB_004a07b5 — jumps straight to the death tail (binary jmp @0x4a0375 → 0x4a07b5, context/combat-damage.md:598-601). Ram/wall damage therefore emits NO id-0x16 report of any type but STILL posts VehicleDead/blast if it killed. FUN_0049ffcc @0049ffcc (part_012.c:14374-14430) applies scaled internal-subsystem damage (gated victimPlayer+0x268 != 0, random subsystem via FUN_004ac07c) and sends no messages.
  • Damage loop — :14655-14684: local_28 (tally) += per-burst applied msg+0x30 plus each critically-destroyed subsystem's value *(subsys+0x108) (:14669); local_30 (vitalHit) = 1 when the struck zone's destroyed flag zone[9] goes 1 (:14674-14676); zone re-rolled per burst (:14680); loop breaks when bursts (msg+0x58) exhaust OR the mech becomes destroyed mid-volley (FUN_0049fb54, :14683). The do-while shape means the first burst always applies, even on a corpse, so tally can be nonzero when local_14 != 0.

All dispatches below are (**(code**)(*player + 0xc))(…) — vtbl slot 3 = Entity::Dispatch, which reroutes over the wire when the target player is a replicant on this node (engine/MUNGA/ENTITY.cpp:244-251 [T0], cited in docs/KD_SCOREBOARD_PLAN.md:86).

1. Wire layout of the id-0x16 report (ScoreMessage, sizeof 0x3C) [T1, static_assert-locked per decomp-reference.md:290-293]

{+0x00 size 0x3C, +0x04 id 0x16, +0x08 =1, +0x0c destID null (DAT_00522524), +0x14 =0, +0x18 =0, +0x1c scoreAward, +0x20 type, +0x24 basis, +0x28 vitalHit, +0x2c zone, +0x30 inflictingSubsystemID (=msg+0x5c, engine T0 name), +0x34 senderMechID}

2. The report block — three mutually structured id-0x16 dispatches

local_14 == 0 (ALIVE at entry) — part_012.c:14685-14709

Re-tests FUN_0049fb54 (:14686). If destroyed NOW (the alive→dead edge of THIS volley):

Report A — type 2 KILL → SHOOTER's player (:14688-14708; binary @0x4a04da, combat-damage.md:605-608; the KD plan calls this edge @0x4a052b-0x4a05ce, KD_SCOREBOARD_PLAN.md:164):

  • +0x1c scoreAward = applied tally local_28 (:14693,14700)
  • +0x20 type = 2 (:14702)
  • +0x24 basis = *(*(victimPlayer+0x208) + 0x1c) (:14691) = the VICTIM role's killBonus (role+0x1c; = 500 in shipped Role::Default) — this is the @0x4a0506 killBonus read (decomp-reference.md:477-478)
  • +0x28 vitalHit = local_30; +0x2c zone = msg+0x24 (loop-ENTRY zone); +0x30 = msg+0x5c; +0x34 senderMechID = the VICTIM's own mech ID (mech+0x184, :14692,14704)
  • Dispatch :14708 → local_1c = shooter's player. On the victim's node that object is a replicant, so Dispatch reroutes it over the wire to the killer's master — where BTPlayer 0x16 Score@0x4c02e4 case 2 runs CalcKillScore × (senderTonnage/ownTonnage), killCount@0x27c++ (dual — the 1995 phantom partner increment), self-kill fchs negation (decomp-reference.md:280-284).
  • Note: A and B are exclusive — a killing hit emits NO type-0.

If NOT destroyed now → goto LAB_004a05d9 (falls into the same block the already-dead path uses):

Report B — type 0 DamageInflicted → SHOOTER's player (LAB_004a05d9, :14711-14734; @0x4a05d9):

  • Gate: local_28 != 0.0f (the constant _DAT_004a0c14 is a 0.0f literal parked after function end)
  • +0x1c scoreAward = tally; +0x20 type = 0 (:14727); +0x24 basis = tally again (:14716,14728); +0x28/2c/30 as above; +0x34 senderMechID = victim's mech ID (:14717,14729)
  • Dispatch :14733 → shooter's player. Receiver side: BTPlayer::Dispatch override (@004bffa0, vtbl @00513300 slot 3) splits type 0 off to @004c0200 ScoreInflictedMessageHandler before base dispatch (combat-damage.md:609-623).

local_14 != 0 (ALREADY DESTROYED at entry)

Enters directly at LAB_004a05d9 → emits Report B (type 0) if tally ≠ 0. The believed "inflicted-only" is half right: the latch suppresses only the type-2 kill and the death tail — Report C below still fires too, because it sits OUTSIDE both branches.

Report C — type 1 DamageReceived → VICTIM's OWN player (:14736-14759; @0x4a06c0) — unconditional on the latch

  • Gate: 0.0f < local_28 only (:14736). Fires on non-lethal hits, on the killing volley, AND on corpse-hits whose first burst applied.
  • +0x1c scoreAward = applied tally; +0x20 type = 1 (:14752); +0x24 basis = INTENDED damage = msg+0x58 burstCount × msg+0x30 amount (:14737,14753); +0x28 vitalHit; +0x2c zone; +0x30 subsysID; +0x34 senderMechID = the SHOOTER's mech ID (msg+0x1c, :14742,14754)
  • Dispatch :14758 → local_20 = victim's own player (master, local delivery). Receiver: Score@0x4c02e4 case 1 = (ownTonnage/senderTonnage) × role->CalcDamageReceivedScore(basis) + the console VTVDamaged post (decomp-reference.md:276-279).

3. The death tail — LAB_004a07b5 (:14760-14835; @0x4a07b5; also reached by the collision divert)

Gate (:14761): local_14 == 0 && (mech+0x40 == 9 || mech+0x40 == 10) — was-alive-at-entry AND now in death mode 9|10 (combat-damage.md:691 calls +0x40 movementMode).

Latch asymmetry [T1 fact, T4 as bug mechanism]: the latch (FUN_0049fb54) counts modes 2|9 but the tail gate accepts 9|10. If a mech can sit at mode 10 at the ENTRY of a subsequent TakeDamage (e.g. the 1ms-later duplicate hits of the killing volley), local_14 stays 0 and the ENTIRE death tail — including VehicleDead — re-fires. Whether mode 10 persists across invocations in the binary is not established here; in the port it depends on when the reconstruction advances the mode. This is the one structural hole in the binary's own dedup and maps exactly onto the #162 deathCount multi-increment shape.

Dispatches inside the tail, in order:

(1) VehicleDead — id 0x17, BT-extended sizeof 0x38 → the VICTIM's OWN player (:14762-14775):

  • {+0x00 0x38, +0x04 0x17, +0x08 1, +0x0c destID null, +0x14 0, +0x18 0, +0x1c deathCount = -1 (0xffffffff, :14770), +0x20 dropZone EntityID = null, +0x28 = 0, +0x2c killed-by PLAYER EntityID = shooter-player's ID (local_1c+0x184 via local_1c + 0x61 int* math, :14762,14773), +0x34 kill zone (msg+0x24, :14763,14774)} — matches the locked layout at decomp-reference.md:293-294.
  • Dispatch :14775 → local_20 (victim's own player, master → local). Receiver: BTPlayer 0x17 VehicleDead@0x4c05c4 — increments the DEATHS cell +0x280 on BOTH victim and killer players (inc [edi+0x280]/[ebx+0x280] @0x4c0670-0x4c067a) and, in its tail @004c07cd-0x4c0828, gated on advancedDamageOn (player+0x264), direct-calls the ENGINE base Player::ScoreMessageHandler @0042da20 with a type-1 ScoreMessage whose scoreAward = ScenarioRole::specialCaseDeathPenalty (role+0x20, 500 in Role::Default) — the 500 death cost (decomp-reference.md:458-478, KD_SCOREBOARD_PLAN.md:88).
  • This is the ONLY VehicleDead-class dispatch anywhere in FUN_004a0230, and it is emitted at most once per handler invocation, solely under the was-alive latch + mode 9|10 gate. deathCount increment and 500 penalty share this single carrier: in the binary they cannot diverge (duplicate increments + missing penalty can only co-occur if the receiver path was split, which is a port-side property — inference, [T4] w.r.t. build 883).

(2) Death explosion — engine message id 3, sizeof 0x5C — NOT dispatched, consumed locally (:14776-14798):

  • {+0x04 id 3, +0x1c model/class 0x31, +0x28 = mech+0x4c0, +0x2c = 0x100, +0x30..0x48 = mech position/orientation (mech+0x100..0x118), +0x4c and +0x54 = victim mech EntityID} (:14779-14797)
  • Passed to FUN_004302ec(&msg) @004302ec (part_004.c:2362-2385): registry-finds the entity at msg+0x4c, news a 0x1dc-byte object (ctor FUN_00430180, class data @DAT_004e6cc4) = the blast/Explosion entity, kept as local_a0. Nothing goes on the wire from here.

(3) Delayed self-message — id 0x17 to the MECH, sizeof 0x1C — the respawn trigger, SCHEDULED not dispatched (:14799-14811):

  • {0x1c, 0x17, 1, destID null, 0, 0} (:14799-14804). Receiver id-space is the MECH's: mech-table 0x17 = SetBurningState@0049f674, whose body actually randomizes position + graphicAlarm 2 + sim re-arm — the respawn (decomp-reference.md:266, label/body mismatch).
  • Scheduled via FUN_0041acbc(world+0x60, 3, mech, msg, when) @0041acbc (part_002.c:4641-4658): allocates a scheduler node from pool 0x4e2fa4 (FUN_0041a818(mech,msg,when)) and inserts it in the world's delayed-delivery list. when = *FUN_00414b60() (global clock, part_001.c:7125) + __ftol(mech+0x430)FUN_004dcd94 is an ST0-arg __ftol the decompiler renders argless (decomp-reference.md:278-279); the float loaded is mech+0x430 (:14805-14809), the respawn delay. This is NOT a VehicleDead despite sharing id 0x17 — per-receiver-class id spaces.
  • (Do not confuse with mech id 0x16 = BalanceCoolant — the player/mech 0x16 ids are unrelated.)

(4) Roster sweep (:14812-14817): iterator over the mech+0x418 list (FUN_004a4eab/FUN_004a4ee9), calls vtbl+0x48 per item — no messages.

(5) Splash damage — the one tail helper that DOES send more messages (:14818-14832):

  • Gate: victimPlayer+0x264 != 0 && victimPlayer+0x258 == 0 (:14819; +0x264 = advancedDamageOn per decomp-reference.md:460; the +0x258 slot is labeled suppressConsole in the VTVDamaged context — reuse here unverified [T4]).
  • Builds damage spec local_274 {type=2, amount=mech+0x520, victim position, __ftol(...)} (:14820-14828) and calls FUN_0042fad0(explosionEntity local_a0, victimMechID, &spec, radius = mech+0x524) (:14831).
  • FUN_0042fad0 @0042fad0 (part_004.c:2059-2307) = the #89 SplashDamage: sweeps a ±radius box, and for each hit entity dispatches a fresh id-0x12 TakeDamageMessage (size 100=0x64) via (**(code**)(*piVar3+0xc))(piVar3,&local_214) (part_004.c:2276-2299), burst count = max(1, __ftol(distance falloff)) (:2258-2261), inflictor ID stamped into the target's +0x184-relative slot from the passed mech ID (:2272-2275). Exclusions (:2203, :2226): the explosion entity itself, *(explosion+0x1c8) (its source — the dying mech, inferred from the ctor message carrying the mech ID [T4 on the identity]), and one under-recovered extra stack arg (in_stack_00000024 — the decompiled 5-arg signature is short; [T4]). So the corpse does not re-splash itself, but every NEARBY mech's master re-enters this same handler with collateral damage during the killing volley.

(6) ForceUpdate (:14833): FUN_004a4c54(mech, 1) @004a4c54 (part_012.c:17180-17193): mech+0x18 |= 1 (updateModel bit 0 = Simulation::ForceUpdate, corroborated KD_SCOREBOARD_PLAN.md:131); if destroyed, mask & 0xfe03 still passes bit 0. Marks the dead mech for replication. No messages.

4. Emission matrix (per single handler invocation)

Scenario at entry type 0 → shooter type 1 → victim type 2 → shooter VehicleDead 0x17 → victim's player tail extras (blast/respawn-sched/splash)
alive, survives volley ✔ (tally≠0) ✔ (tally>0)
alive → DEAD this volley ✔ (basis=victim killBonus) ✔ (exactly once)
already destroyed (mode 2|9) ✔ (tally≠0) ✔ (tally>0)
collision (type 0 dmg), killed ✔ (killed-by = rammer's player)
collision, survived

Corrections to the task's priors: (a) the already-destroyed branch is not inflicted-only — the type-1 received report to the victim's own player also flows (Report C is outside both latch branches, gated only on tally>0); (b) the killing hit emits no type-0 (A/B exclusive); (c) collision damage emits no 0x16 at all yet still runs the full death tail.

5. Relevance flags for #162 (inference, clearly marked [T4] against build 883)

  • deathCount++ and the 500 penalty ride the SAME single VehicleDead message in the binary; 8 increments + 0 penalties means the port's VehicleDead emission or its receiver diverged from this map, not the binary's report logic.
  • The only binary-native re-fire hole is the mode-10 latch gap (§3): destroyed-at-entry is tested as mode 2|9 while the tail fires on 9|10.
  • "Duplicate pairs" of damage awards is consistent with B+C both existing per hit (one to each party) and/or corpse-hits still emitting B+C — and splash generates additional id-0x12 volleys during a kill.

==============================================================================

FIELD RECEIPTS A/B — build 857 (night14) vs build 883 (night15), ticket #162

All paths below are absolute. Steam logs have no per-line timestamps (line numbers cited); matchlog receipts carry t= (ms tick) and w=HH:MM:SS.mmm wall clock. Receipt-format matchlogs were only collected from the ALIA machine on night15 (2 files) plus a dev-machine archive scratchpad/night14/mlbak/ (builds 4.11.8374.11.870, DESKTOP-AE1SU9U). The night14 field steam logs (build 857) contain NO receipt-format lines at all — no SCORE t=, no PLAYER_DEAD (0 matches in all four files); the 857 baseline evidence is the [score]/[respawn]/[deathcost] prose lines. [T2 throughout — all claims below are read directly from field logs]

1. BUILD-857 BASELINE (night14 steam logs)

Multi-increment already existed in 857. Clustering [respawn] player X:1 death cycle START (death #N) lines (same player, consecutive N, <200 lines apart) across the four logs:

log increments real events multiplicity
steam_20260808_a_dave_SCREECH.log 9 6 {1:4, 2:1, 3:1}
steam_20260808_b_santo_MSFIREFLY.log 7 6 {1:5, 2:1}
steam_20260808_c_michael_XIAOLONG.log 11 9 {1:7, 2:2}
steam_20260808_e_rajel_GAMERSLAB.log 13 9 {1:7, 3:2}
TOTAL 857 40 30 {1:23, 2:4, 3:3}

Every increment fired the full chain: KILL report + death cycle + deathcost. KILL-report count == increment count in every log (9/9, 7/7, 11/11, 13/13) — this is the receipt-level shape of the 857 "kills doubled" bug that 46ff8dd later fixed.

The penalty fired once per increment, gated on advDmg: all 8 -> APPLYING lines have advDmg=1; all 32 -> SKIPPED have advDmg=0. Perfect 40/40 correlation. Verbatim triple-death, triple-penalty sample (dave log, C:/git/bt411/scratchpad/night14/steam_20260808_a_dave_SCREECH.log:59301-59317, one killing volley, scoreBefore stepping 500 each time — sequential application proof):

[score] *** KILL report *** -> shooterPlayer=0BE8BC20 tally=2.45198 bonus=500
[respawn] player 2:1 death cycle START (death #2) -- drop-zone hunt in 5s
[score] VehicleDead(-1): death #2, respawn hunt in 5s
[deathcost] player 2:1 advDmg=1 role=bound penalty=500 scoreBefore=4788.47  -> APPLYING
[death] VehicleDead(-1) dispatched to the owning player (killedBy=6:1 zone=21)
[score] *** KILL report *** -> shooterPlayer=0BE8BC20 tally=3.43099 bonus=500
[respawn] player 2:1 death cycle START (death #3) -- drop-zone hunt in 5s
[score] VehicleDead(-1): death #3, respawn hunt in 5s
[deathcost] player 2:1 advDmg=1 role=bound penalty=500 scoreBefore=4288.47  -> APPLYING
[death] VehicleDead(-1) dispatched to the owning player (killedBy=6:1 zone=21)
[score] *** KILL report *** -> shooterPlayer=0BE8BC20 tally=3.43232 bonus=500
[respawn] player 2:1 death cycle START (death #4) -- drop-zone hunt in 5s
[score] VehicleDead(-1): death #4, respawn hunt in 5s
[deathcost] player 2:1 advDmg=1 role=bound penalty=500 scoreBefore=3788.47  -> APPLYING

Other verbatim samples: single APPLYING (michael log :82303-82307, advDmg=1 scoreBefore=1513.08 -> APPLYING); triple SKIPPED (rajel log :57467-57481, player 6:1, 3× advDmg=0 scoreBefore=6858.66 -> SKIPPED, note scoreBefore does NOT move — skipped means no deduction); double SKIPPED (santo log :41879-41889, player 4:1, 2× scoreBefore=1264.11 -> SKIPPED).

Are there award=-500.00 SCORE receipt lines? No — in ANY build. In the entire mlbak receipt archive (builds 837870) plus both 883 matchlogs, award= is never 500; the penalty is applied OUTSIDE the id-0x16 SCORE receipt and shows only as a silent 500 drop in the running total= between consecutive receipts. E.g. mlbak/matchlog_20260807_235237_15576.txt (4.11.837): total 0.00 → 500.00 between lines 32→40 with all awards 0.00; mlbak/matchlog_20260809_165517_13216.txt (4.11.865) line ~172: total 1000.00 → 501.97 (= 500 + a 1.97 award). The type= on the surrounding lines is type=1 (DamageReceived) with award=-0.00 — the 500 never rides a typed report.

499 eject evidence: not present in the four night14 field steam logs (greps for -499/eject-score hit only coordinates and weapon-jam EJECT lines). The documented evidence is docs/OPEN_ISSUES_FOR_TESTERS.txt:87 — #151 Panic eject costs -499: verified REAL 1995 scoring (your death scored as suicide + death penalty) — i.e. 499 = suicide credit (+1) + death penalty (500) [inference on the arithmetic; the 499 itself is a ledger claim, not in these logs].

2. BUILD-883 (night15)

PLAYER_DEAD multiplicity — matchlog receipts (ALIA machine, both matches):

  • matchlog_20260810_213656_20476.txt (player 6:1 = Elengil): 8 PLAYER_DEAD, 5 real deaths, multiplicity per event: 2, 1, 1, 3, 1 (events at w=21:39:49.419, 21:41:17.884, 21:43:13.203, 21:44:36.445, 21:46:29.337). Matches ticket #162's "5 real deaths → 8 increments" exactly.
  • matchlog_20260810_223651_12120.txt (player 2:1, second match): 10 PLAYER_DEAD, 5 real deaths, multiplicity: 1, 2, 2, 4, 1.
  • DEAD_NOTIFY count == PLAYER_DEAD count in both (8/8, 10/10); every PLAYER_DEAD is immediately preceded by its own DEAD_NOTIFY ... link=<same ptr>.

All-machine distribution (six steam logs, death-cycle-START clustering, all players' ledgers): a:{1:4,2:2}, b:{1:16,2:6}, c:{1:9,2:3,4:1}, d:{1:11,2:8,3:5}, e:{1:8,2:6,3:1,4:2}, f:{1:14,2:6,3:1,6:1} → TOTAL {1:62, 2:31, 3:7, 4:3, 6:1} — 163 increments over 104 events; ~40% of deaths multi-count, up to 6×.

award=-500 count: 0 across both matchlogs (1,326 SCORE lines). No negative award of any magnitude exists (the only negative-looking value is the literal award=-0.00 on every type=1 line — 745 of them, all exactly 0.00). Also no silent 500 total-drops: the only two total-vs-award mismatches (ml1 lines 305, 1117) are the type=2 kill receipts, which print total= BEFORE adding the award (next receipt shows total jumped +512.00: 1471.83 → 1992.58 = 1471.83+512+8.75). So in 883 the penalty is gone from the receipts entirely.

Deathcost is still evaluated once per increment but always skips: across all six steam logs 163 [deathcost] lines: 162× advDmg=0 -> SKIPPED, 1× advDmg=1 -> APPLYING (steam_20260810_a_Dave_SCREECH-PC.log:5383 — player 2:1's FIRST death of the first round, scoreBefore=1000 -> APPLYING; the same player's later deathcost lines :19317, :26970 are advDmg=0 -> SKIPPED). Contrast 857 where 2:1's advDmg stayed 1 across deaths #1#4 mid-round (scoreBefore 2907…4788). Receipt-level verdict: the 500 path is intact but its advDmg gate reads 0 on (almost) every death in 883, where it read 1 persistently for the same player in 857. Eleng's own machine evaluated deathcost for 6:1 exactly 8 times (once per increment) — all SKIPPED.

3. ELENGIL CORRELATION (6:1, matchlog_20260810_213656_20476.txt)

death#1 @ w=21:39:49.419 — 2 increments. DMG victim=6:19 within ±3s: from 7:19: 12.000 (lvl .8806), 13.000 (lvl .6195) at .180/.181, and 8.750 burst=3 at .558 (post-death); from 5:19: 1.964/3.432/4.913 (lvl .8488→.9130) then type=4 amt=14.701 lvl=1.0000 → DEAD_NOTIFY → PLAYER_DEAD deaths=1, then type=4 amt=15.701 lvl=1.0000 → DEAD_NOTIFY → PLAYER_DEAD deaths=2, then DEATH ... killer=5:19 killdmg=15.701 46ms later. 2 increments = 2 lethal (lvl=1.0000) damage rounds, both from ONE shooter (5:19). Distinct shooters in window = 2; peers = 8.

death#4 @ w=21:44:36.445 — 3 increments. Window: 8:19 lands 9 rounds .596.866 (4×3.446, 2×5.889, 13.000, 3.500 burst=9, 1.500 burst=2 — none lethal); 5:19 lands 7 rounds: 1.977×2, 3.444 (lvl .9236→.9653), then 4.903 lvl=1.0000 → PLAYER_DEAD deaths=5, 15.717 lvl=1.0000 → deaths=6, 15.717 lvl=1.0000 → deaths=7, all at t=613221000 same ms, DEATH latch 53ms later. 3 increments = 3 lethal rounds, all from ONE shooter.

Verdict: increment multiplicity == the number of KILLING-WINDOW DAMAGE ROUNDS (damage applications with lvl=1.0000 that land between the killing blow and the DEATH-transition latch ~3050ms later). It is NOT the number of distinct shooters (death#1: 2 shooters in window, 2 increments from one; death#4: 2 shooters, 3 increments from one; ml2's 4-increment event at 22:45:40.768 has all 4 lethal rounds from the single shooter 8:19 while earlier lvl=1.0000 rounds from 6:19 and 9:19 at .323/.628 produced NO increment). It is NOT the peer count (8 peers; multiplicity observed 16, mode 12). Each in-flight round of the killing volley that applies before the wreck latch re-fires DEAD_NOTIFY→PLAYER_DEAD.

4. THE "DUPLICATE SCORE PAIRS" IN 883 RECEIPTS

Same-key (t + player + type + award) SCORE multiplicity histograms: ml1 type=0 {1:98, 2:63}, type=1 {1:46, 2:28, 3:10, 4:20, 5:3, 6:19}; ml2 type=0 {1:115, 2:120}, type=1 up to ×6. The nonzero-award duplicated groups are all type=0 (DamageInflicted); pair members carry IDENTICAL award and DIFFERENT, sequentially-advancing totals — e.g. ml1 lines 45-48:

SCORE t=612804640 w=21:37:40.090 st=5 player=6:1 type=0 award=1.97 total=1001.97 kills=0
SCORE t=612804640 w=21:37:40.090 st=5 player=6:1 type=0 award=1.97 total=1003.94 kills=0
SCORE t=612804640 w=21:37:40.090 st=5 player=6:1 type=0 award=3.44 total=1007.38 kills=0
SCORE t=612804640 w=21:37:40.090 st=5 player=6:1 type=0 award=3.44 total=1010.82 kills=0

BUT these type=0 pairs are not duplicates. Each pair member maps 1:1 to a distinct weapon event: the volley above is FIRE wpn=25 amt=3.439 / wpn=26 amt=3.439 / wpn=27 amt=1.971 / wpn=28 amt=1.971 (twin-mounted lasers, ml1 lines 41-44), and the following award=13.00 pair maps to PROJ wpn=22 + wpn=24 both amt=13.000 (lines 49-52). Aggregate: SCORE type=0 count ≤ hit-event count in both matchlogs (224 vs 242; 355 vs 408 — the excess hits are missile rounds paid as lump awards like 35.00=4×8.75). A build-865 control (mlbak/matchlog_20260809_165517_3316.txt lines 16-19: 2 FIRE → 2 identical 1.97 awards; 105 type=0 vs 108 hits) shows the identical pattern pre-883. Verdict: the type=0 "duplicate pairs" are twin-weapon awards, present in old builds, correctly paid once per hit — not part of bug #162. What IS genuinely duplicated per killing volley: the DMG/type=1 receipt (one per damage round, always award=-0.00, ×2–×6 same-ms) and DEAD_NOTIFY/PLAYER_DEAD (the real bug). Kill credit is exact in 883: SCORE type=2 award=512.00 / award=500.98 once per kill, SBMIRROR +1 steps (ml1 lines 305, 1117).

==============================================================================

TASK 2 — Binary truth: 1995 player-side score/K-D handlers + replication

All addresses verified against the 2026-08-06 re-export (reference/decomp/all/part_013.c, part_012.c, part_004.c) and, where load-bearing, re-verified with raw capstone disasm of content/BTL4OPT.EXE (tools/disas2.py). Tier tags inline.

0. The handler table (who consumes what) [T1, byte-scanned table + export]

BTPlayer message table (20-byte rows, 6 entries — matches the T0 PLAYER.h:235-240 enum DropZoneReply=Entity::NextMessageID(0x15), Score(0x16), VehicleDead(0x17), MissionStarting(0x18), MissionEnding(0x19), NextMessageID(0x1a)):

  • 0x15 DropZoneReply → FUN_004bffd0 (part_013.c:18814)
  • 0x16 Score → FUN_004c02e4 (part_013.c:18941) — the type-0/1/2 switch
  • 0x17 VehicleDead → FUN_004c05c4 (part_013.c:19066; formerly an export gap, now exported AND raw-disasm-confirmed this pass)
  • 0x1a ScoreUpdate → FUN_004c02a8 (part_013.c:18926)
  • FUN_004c0200 "ScoreInflicted" (part_013.c:18896) is bound in NO table entry — dead code in 1995 (confirms context/decomp-reference.md:273-275).

1. The id-0x16 Score handler @0x4c02e4 — all three arms [T1]

Entry: replicant Verify ((this+0x28 & 0xc)==4 → Verify print, btplayer.cpp line 0x1e9), then MissionEnding gate this+0x40 != 4 (whole body skipped during mission end). iVar3 = GetEntityPointer(msg->senderMechID @+0x34); local_c = playerVehicle(+0x1fc).

type 0 (DamageInflicted) — part_013.c:18970-18973: nothing but Verify (line 0x296). local_10 (the award local) is never written, and the shared tail (part_013.c:19015-19016) still runs: msg->scoreAward(+0x1c) = local_10 /*UNINITIALIZED*/; FUN_0042da20(this,msg)folds stack garbage into engine currentScore(+0x1c8) — the shipped 1995 bug the KB documents (decomp-reference.md:275-276). No counter increments.

type 1 (DamageReceived) — part_013.c:18974-18991: only if senderMech != own vehicle: award = (ownTonnage(+0x4bc)/senderTonnage) × CalcDamageReceivedScore(basis@msg+0x24) where FUN_00429b94 (part_003.c:10156-10160) = (basis × role->damageReceivedModifier(+0x10)) — a NEGATIVE (penalty). Then the console VTVDamaged post (code 5; gated ConsoleHost && !suppressConsole(+0x258)). If senderMech == own vehicle (self-inflicted), local_10 stays uninitialized and the tail still folds it — same uninit-fold class as type 0 (NEW this pass). No counter increments. dfltrole dmgRcv=0 ⇒ award 0 in shipped content.

type 2 (Kill) — part_013.c:18992-19013:

  • award = CalcKillScore(this, &basis@+0x24, victimMech, tally@+0x1c) × (victimTonnage/ownTonnage).
  • CalcKillScore @0x4c052c (part_013.c:19027-19060): team game (freeForAll(+0x250)==0) + same teamName(+0x20c)local_8 = friendlyFirePenalty(role+0x14); else local_8 = victimMech->avgZoneDamage(+0x354) × damageBias(role+0x18) + 1.0 (@0x4c05c0); returns (basis + tally) × damageInflictedModifier(role+0xC) × local_8.
  • Suicide (victimMech == own vehicle): local_10 = -local_10 (the fchs) and NO killCount increment.
  • Else — the dual killCount++, exact context (part_013.c:18998-19001; inc [+0x27c] @0x4c0397/@0x4c03a3):
else {
  *(int *)(param_1 + 0x27c) = *(int *)(param_1 + 0x27c) + 1;   // shooter (this) killCount++
  piVar1 = (int *)(*(int *)(iVar3 + 400) + 0x27c);             // victimMech+0x190 -> victim's player
  *piVar1 = *piVar1 + 1;                                       // PHANTOM: victim's player killCount++ (wrong column)
}

On the shooter's node the second object is the victim's replicant player copy — never corrected in 1995 (no replication, §4).

  • StatusMessage {type 0 "Destroyed", victim player, 6.0s} (pool @0x512f6c, vtable @0x513344) added to this player; if victimMech == objectiveMech(+0x284) → clear-target FUN_004b04d8 via own vehicle's roster head (+0x128).
  • Tail (all types): msg->scoreAward = award; FUN_0042da20(this,msg)currentScore(+0x1c8) += award; playerVehicle->RespondToScoreMessage(msg) (part_004.c:449-450 ≡ T0 engine/MUNGA/PLAYER.cpp:143,150).

killCount(+0x27c) is written NOWHERE else in the image — only the type-2 arm.

2. WHERE deathCount++ lives and WHERE the deathPenalty is subtracted [T1, raw disasm this pass]

Both live in BTPlayer::VehicleDeadMessageHandler @0x4c05c4, in the msg->deathCount(+0x1c) == -1 arm — one straight-line block, no exit between them:

  • Entry gates: this+0x40 != 4 (MissionEnding); msg+0x1c == -1 else → engine base FUN_0042db80 (drop-zone hunt); deathPending(+0x290) == 0 guard @0x4c05f7.
  • edi = GetEntityPointer(msg+0x2c) = the killer's PLAYER (the BT 0x38-byte extension field).
  • StatusMessage {type 1 "KILLED BY", killer player, 6.0s} @0x4c062d-0x4c0668.
  • The deaths increment @0x4c0670-0x4c067f:
0x4c0670:  cmp ebx, edi          ; victim(this) vs killer player
0x4c0672:  je  0x4c067a          ; self-kill: skip the partner bump
0x4c0674:  inc dword ptr [edi + 0x280]   ; killer's player deaths++  (wrong-column slip; on the victim's node this is the killer's REPLICANT copy)
0x4c067a:  inc dword ptr [ebx + 0x280]   ; victim's deaths++  (the real DEATHS cell) — UNCONDITIONAL
  • Console death notice (code 5, FUN_004c18cc) if !suppressConsole(+0x258) && console present.
  • Revenge lock objectiveMech(+0x284) = killerPlayer->playerVehicle(+0x1fc) @0x4c06dd.
  • Death sweep FUN_0049fe0c(playerVehicle, 0) @0x4c06e9 (DeathShutdown arg 0).
  • Lives branch on role(+0x208)->returnFromDeath(+0x28):
    • > 0: re-post the SAME VehicleDead to SELF at Now + 5.0 s (@0x4c0830) with msg->deathCount ← this->deathCount(+0x200) (raw disasm recovered mov esi,ebx @0x4c0731 — the Post target is the player, matching the export).
    • < 1 (out of lives): post {size 0x1c, id 0x18} to the VEHICLE at Now + 10.0 s (@0x4c0838) — in the Mech id space 0x18 = ClearBurningState; no respawn.
  • THE DEATH PENALTY @0x4c07cd-0x4c0825 — BOTH branches converge here (jmp 0x4c07cd @0x4c0754):
0x4c07cd:  mov ecx, [ebx + 0x264]        ; advancedDamageOn — THE ONLY GATE
0x4c07d5:  je  0x4c0828                  ; off -> no penalty
0x4c07d7:  mov eax, [ebx + 0x208]        ; scenarioRole
0x4c07dd:  fld dword ptr [eax + 0x20]    ; specialCaseDeathPenalty (dfltrole = 500)
0x4c07e0:  fchs                          ; negate
...        build a BARE 0x20-byte engine ScoreMessage {id 0x16, scoreAward@+0x1c = -penalty}  (NO type field — the msg ends at 0x20)
0x4c0820:  call 0x42da20                 ; DIRECT call to ENGINE Player::ScoreMessageHandler

So the 500: victim's master node, inside the id-0x17 VehicleDead(-1) arm, gated ONLY on advancedDamageOn(+0x264), amount = role+0x20 read live, applied by a DIRECT call (no Dispatch → no reroute, no BT type switch, no Verify) into currentScore(+0x1c8) + playerVehicle->RespondToScoreMessage. Confirms decomp-reference.md:458-478 (#118) and adds: the penalty also applies on the out-of-lives path.

The engine respawn counter is a DIFFERENT field: Player::deathCount(+0x200) (ctor seed 2, PLAYER.cpp:777; zeroed at first spawn, part_013.c:18834) increments ONLY in FUN_004c012c (part_013.c:18872, the DropZoneReply respawn helper) — once per completed respawn cycle, alongside msg->deathCount restamp, role->returnFromDeath(+0x28)--, ForceUpdate (or word [ebx+0x18],1), sim-state SetLevel(1)=DropZoneAcquired, respawn-location choose (advancedDamageOn → the ORIGINAL drop @DAT_00524b38, else the assigned zone), a delayed self re-post (the 2-pass latch), and clearing +0x290 and suppressConsole(+0x258) (part_013.c:18887-18888).

NEW [T1, whole-image byte scan]: the deathPending(+0x290) guard is NEVER SET in 1995

Every code-section instruction touching disp32 0x290: push 0x290 @0x429294 (an immediate, not a member); zero-writes @0x4b77fd (different object), @0x4c01e5 (FUN_004c012c: = 0), @0x4c0c07 (BTPlayer ctor: = 0); the lone READ @0x4c05f9 (the handler guard). No instruction ever writes it non-zero. The 1995 receiver has NO dedup — single increment/single penalty is guaranteed solely by the SENDER emitting exactly one VehicleDead per death (§3). Implication for #162: in the binary, deaths++ (@0x4c067a) and the penalty (@0x4c07cd) are one straight-line block — "deaths multi-increments while the penalty never applies" is impossible in a faithful transcription; it requires (i) advancedDamageOn==0, (ii) an unbound/zero role (+0x20==0 — the pre-2026-08-07 port state), or (iii) a port-side early exit between the two.

3. The sender: Mech::TakeDamageMessageHandler @0x4a0230 report tail [T1] (part_012.c:14474-14837)

Runs ONLY on the victim mech's MASTER node (replicant Verify at entry, line 0x3da; field: 0/18818 DMG rows inst=R). Per killing/damaging hit:

  1. Destroyed-at-entry latch: local_14 = FUN_0049fb54(this) (IsDisabled) at ENTRY — a hit landing on an already-dead mech can never re-send kill/VehicleDead.
  2. lastInflictingID(+0x43c) ← msg->inflictingEntityID(+0x1c) — every hit, last-writer-wins.
  3. damageType(msg+0x2c) == 0 (Collision) → FUN_0049ffcc and goto the death tail — SKIPS ALL THREE id-0x16 reports. 1995 ram kills award NO kill score and NO killCount; they still produce VehicleDead → deaths + penalty (state-9 timing inside FUN_0049ffcc not read this pass, [T3]).
  4. Burst loop applies damage; local_28 accumulates applied damage + crit contributions; the loop breaks when the mech goes disabled mid-burst.
  5. Reports, all ScoreMessage id 0x16 size 0x3C {+0x1c tally, +0x20 type, +0x24 basis, +0x28 vitalHit, +0x2c zone, +0x30 subsysID, +0x34 senderMechID}:
    • KILL EDGE (alive at entry && disabled after) part_012.c:14685-14708: type 2 → Dispatch to the SHOOTER's owning player (*(shooterMech+0x190); a replicant locally → Entity::Dispatch reroutes to the shooter's master). tally = local_28 (applied damage of the volley), **basis = the VICTIM's own role->killBonus(+0x1c)** (the @0x4a0506 read; dfltrole 500), senderMechID = the victim mech's own ID(+0x184)`.
    • Else (still alive, or wreck-shooting), if local_28 != 0 (part_012.c:14710-14734): type 0 → the SHOOTER's player (tally = basis = local_28) — lands in the 1995 dead/uninit arm (§1).
    • If local_28 > 0 (part_012.c:14736-14758, independent — ALSO fires on the kill edge): type 1 → the VICTIM's OWN player (local master → synchronous). tally = local_28, basis = burstCount(+0x58) × damageAmount(+0x30) (nominal volley), senderMechID = the inflicting entity (msg+0x1c).
  6. DEATH TAIL (part_012.c:14760-14835): gate local_14 == 0 && (this+0x40 == 9 || == 10) (10 = eject) — the one-shot alive→dead edge. Builds BT VehicleDeadMessage {size 0x38, id 0x17, deathCount(+0x1c) = -1, dropZoneID = Null, +0x28 = 0, +0x2c = the killer's PLAYER EntityID (shooterPlayer+0x184), +0x34 = kill zone}Dispatch to the VICTIM's OWN player (synchronous, master). Then: death-effect broadcast (id 3, 0x5c), a delayed SetBurningState (id 0x17, 0x1c) to the mech itself (delay = mech+0x430), watcher sweep, console damage-effect notify (advancedDamageOn && !suppressConsole), FUN_004a4c54(this,1). Exactly one VehicleDead per death, sender-side guaranteed.

4. Replication: what rides the Player update record [T0 + T1]

  • Player::WriteUpdateRecord (PLAYER.cpp:645-661 ≡ binary FUN_0042e2e4, part_004.c:859-867): Entity::WriteUpdateRecord, recordLength = 0x3c, record+0x2c = currentScore(+0x1c8), record+0x30 = dropZoneLocation(+0x1d0) — nothing else. ReadUpdateRecord (PLAYER.cpp:631-641FUN_0042e2ac) applies the same two.
  • BTPlayer does NOT override — vtable @0x513300 slots 6/7 = 0x42e2ac/0x42e2e4, the engine's (KD plan §1 [T1]).
  • Therefore in 1995: currentScore(+0x1c8) is the ONLY replicated scoring field. killCount(+0x27c), deaths(+0x280), respawn deathCount(+0x200), and the bank +0x278 NEVER cross the wire; each is authoritative (and only meaningful) on the single node where its increment ran. Bystanders show 0/0 K/D for everyone all mission; the two wrong-column partner increments (on replicant copies) are locally visible and never corrected.
  • Player::CalcRanking (PLAYER.cpp:542-626FUN_0042e1ac): per-node sort of the local "Players" group by currentScoreplayerRanking(+0x1cc)/playerHighlighted(+0x1c4). Runs in Player/CameraShip simulation each frame.
  • The scoreboard PilotList @0x4cabd0 (part_014.c:7656-7657) draws per row exactly (float)[player+0x27c] (KILLS) and (float)[player+0x280] (DEATHS) — no score cell in this widget.
  • +0x278 is a pending-score BANK, not the score (NEW precision): sole writer = the dead @0x4c0200 (+0x278 += tonnageRatio × CalcKillScore(...), self-negated); the flusher FUN_004c083c (part_013.c:19164-19192, called from the BTPlayer master performance FUN_004c0904) every 10.0 s (@0x4c08fc) self-Dispatches a {0x20, id 0x1a, award = +0x278} ScoreUpdate then zeroes the bank; handler @0x4c02a8 = Verify-master + engine fold. Design = replicant-side observed-score banking with reroute-to-owner; dead in 1995 because the only writer is unbound. The periodic console score post (every 15.0 s @0x4c0bc0) reads +0x1c8 (param_1[0x72], part_013.c:19274).

5. THE 1995 FLOW — one death event (shooter S kills victim V, weapon damage, advanced damage ON)

On V's node (V master), synchronously inside the killing TakeDamageMessage (id 0x12) processing:

  1. V.mech.lastInflictingID ← S.mech ; damage applied; tally accumulated.
  2. [out] ScoreMessage 0x16 type 2 → dispatched at S.player-replicant → rerouted to S's node (ENTITY.cpp:244-251).
  3. [sync] ScoreMessage 0x16 type 1 → V.player (master): V.currentScore(+0x1c8) += (Vt/St) × (basis × rcvMod) (0 with dfltrole); console VTVDamaged.
  4. [sync] VehicleDead 0x17 (deathCount=1, killer=S.player, zone) → V.player (master): a. "KILLED BY" ticker; b. S.player-replicant.deaths(+0x280)++ (wrong column, local-only); c. V.deaths(+0x280)++ ← THE death count; d. console death notice (unless suppressConsole — eject sets it); e. V.objectiveMech = S.vehicle; f. DeathShutdown(0) sweep; g. lives>0 → repost VehicleDead(deathCount=+0x200) to self @ +5 s; h. V.currentScore(+0x1c8) = role.specialCaseDeathPenalty(500) — direct engine call, advancedDamageOn-gated. No ForceUpdate anywhere in this block.

On S's node, when the rerouted type-2 executes on S.player (master): 5. award = (killBonus + tally) × dmgInfMod × (V.avgZoneDmg × bias + 1) × (Vt/St) [or ffPenalty same-team; negated + no counters on suicide]; S.killCount(+0x27c)++; V.player-replicant.killCount(+0x27c)++ (phantom, local-only); "DESTROYED" ticker; revenge clear-target; S.currentScore(+0x1c8) += award.

Respawn (V's node): +5 s repost → engine FUN_0042db80 (gate msg->deathCount == +0x200 && simState != DropZoneAcquired): closest non-"win" DropZone, AssignDropZone{reply 0x15, deathCount} → DropZone dispatches DropZoneReply 0x15FUN_004bffd0: stale gate (msg+0x38 == +0x200), MissionEnding gate; pass 1 (state≠1) → FUN_004c012c: deathCount(+0x200)++, returnFromDeath--, ForceUpdate, state←1, location choose, clear +0x290/+0x258, delayed self-repost; pass 2 (state==1) → state←2, Mech::Reset(vehicle, loc, 1) (FUN_0049fb74).

Convergence: peers learn ONLY currentScore (and position) from the Player update record; K/D never converge in 1995.

Authority table (1995)

field offset sole writer(s) node replicates
currentScore +0x1c8 engine @0x42da20 (BT 0x16 tail all types; death penalty direct; 0x1a flush) wherever the owning handler runs (master after reroute) YES (only one)
killCount +0x27c 0x16 type-2 arm only (@0x4c0397 + phantom @0x4c03a3) shooter's master node no
deaths +0x280 0x17 1 arm only (@0x4c067a + wrong-column @0x4c0674) victim's master node no
deathCount (respawn id) +0x200 ctor 2; first-spawn =0; @0x4c012c ++ victim's master no
score bank +0x278 dead @0x4c0200; flushed/zeroed @0x4c083c no
deathPending +0x290 never set; zeroed ctor + @0x4c01e5 no

==============================================================================

TASK 3 — The as-built scoring/death chain at HEAD (build 883, post-46ff8dd)

1. Producer: Mech::TakeDamageMessageHandler (game/reconstructed/mech.cpp:1031-1344)

Handler anatomy, in execution order:

Step Site Notes
destroyedAtEntry = IsMechDestroyed() mech.cpp:1039 #150 entry latch (added in 46ff8dd), sampled at handler ENTRY; IsMechDestroyed() = graphicAlarm >= 9 (mech.hpp:1145, impl mech4.cpp:2215)
gyro bounce, lastInflictingID/Damage, HUD threat mech.cpp:1063-1087 no score effect
deathBlastArmed = !(MovementMode()==9 || MovementMode()==10) mech.cpp:1119-1120 the OTHER once-latch, movementMode-based — arms the death tail
COLLISION DIVERT (damageType==0) mech.cpp:1142-1146 DistributeCollisionDamage then goto death_tailbypasses the report tail entirely (a collision death posts VehicleDead but NO 0x16 reports; credits no one — documented authentic, mech.cpp:1130-1140)
zone/burst application loop mech.cpp:1193-1241 accumulates damageTally (applied damage + crit bonuses, :1216/:1227), zoneDestroyed (:1229)
REPORT TAIL master-gate mech.cpp:1270 if (GetInstance() != ReplicantInstance)BTMechPostCombatReports(this, msg, damageTally, zoneDestroyed, reportZone, !destroyedAtEntry && IsMechDestroyed()) (:1272-1275). The kill flag = the 0→1 edge of IsMechDestroyed() across THIS call
matchlog DMG receipt mech.cpp:1283-1304 prints inst=M/R (:1294) — runs on BOTH instances, ungated
DEATH TAIL mech.cpp:1335-1343 if (deathBlastArmed && IsMechDestroyed())BTMechPostVehicleDead(...) (:1339) + BTApplyDeathSplash(this) (:1342)

The critical asymmetry (46ff8dd scope): the commit gated ONLY the 0x16 report tail (mech.cpp:1270) and only the report tail got the destroyedAtEntry edge-latch. The DEATH TAIL is (a) not instance-gated — it runs on replicant victim copies too — and (b) latched on deathBlastArmed (movementMode), not on destroyedAtEntry. On a replicant, movementMode cannot flip to 9/10 until the owner's death update record round-trips (replicants reach mode 9 only via replicated simulationState — mech4.cpp:2721-2725), while graphicAlarm IS raised to 9 locally by the damage application itself (mechdmg.cpp:495, :497, :548, :710, :798 — leg-gone/vital-zone/vital-subsystem/mech-kill writers that run wherever damage is applied). So inside the round-trip window every additional salvo of the killing volley re-enters the death tail on each peer's replicant copy: deathBlastArmed=1 (mode still 1) && IsMechDestroyed()=1 → another VehicleDead + another death splash, per salvo, per peer. This is byte-for-byte the same mechanism 46ff8dd proved for the duplicated kill reports ("two type-2 arms 1ms apart, victim inst=R" — commit message) — the fix covered the reports and left the tail.

2. BTMechPostCombatReports (game/reconstructed/btplayer.cpp:2574-2691) — every dispatch

Resolution: shooter from registry by msg->inflictingEntity (:2595), shooter_player = shooter mech playerLink (:2598), victim_player = victim playerLink (:2600).

Block Guard Message Destination kill-flag use
A kill (:2602-2644) if (newly_destroyed) && shooter_player != 0 ScoreMessage id 0x16 type 2, scoreAward=damageTally, damageAmount=victim role killBonus (:2612-2626) shooter_player->Dispatch(&kill) (:2627) — replicant on the victim's node → Entity::Dispatch reroutes to killer's host (ENTITY.cpp:244-251) consumed — sole gate. killer==victim IS dispatched (eject/suicide; handler negates) :2608-2610. Else-arm: matchlog NOCREDIT (:2633-2644)
B inflicted (:2646-2667) else if (damage_tally != 0 && shooter_player != 0) id 0x16 type 0, award & basis = tally (:2658-2665) shooter_player->Dispatch(&inflicted) (:2666) consumed via the else — the killing hit posts NO inflicted report (kill replaces it)
C received (:2669-2690) if (damage_tally > 0 && victim_player != 0) — NOT an else id 0x16 type 1, award=tally, damageAmount=INTENDED (burstCount×amount) (:2678-2688), senderMechID=INFLICTOR victim_player->Dispatch(&received) (:2689) — master on the victim's own node → local ignored — runs after A too, kills included (binary behavior, :2672-2674)

All three sit INSIDE the mech.cpp:1270 master-gate. Per-TakeDamage cadence: B+C once per non-lethal hit, A+C once on the killing hit, B+C again on every post-mortem salvo that still tallies damage on the wreck (destroyedAtEntry=1 → newly=0 → the "inflicted-only path", mech.cpp:1038).

3. BTMechPostVehicleDead (btplayer.cpp:2702-2758)

Builds BTPlayer::VehicleDeadMessage id 0x17, 0x38 bytes, killed_by = shooter PLAYER EntityID, killZone (:2748-2752); engine-base deathCount ctor-defaults to -1 (engine/MUNGA/PLAYER.h:111,120; btplayer.hpp:183) → every dispatch takes the consumer's "-1 immediate death notification" arm. Owner resolved via playerLink with the #55 mission-player fallback (:2711-2723); DEAD_NOTIFY matchlog (:2724). owner->Dispatch(&vehicle_dead) (:2753) — on a peer node owner is a replicant player → reroute to the owning host (ENTITY.cpp:244-251). No instance gate, no once-per-death latch of its own — it fires as often as the death tail fires (see §1 asymmetry).

BTApplyDeathSplash (mech4.cpp:1382-1428): gates = victim player's advancedDamageOn && !suppressConsole (:1399; punch-outs never blast), radius/damage authored per chassis; dispatches a type-2 TakeDamage (inflictor = the DYING mech, :1337 via BTSplashCore :1426) to every live mech in radius via e->Dispatch(&td) (mech4.cpp:1335-1338). Because the death tail runs on every node's copy, a bystander's master can receive the SAME death blast once locally (its own node's replicant-victim tail) and once over the wire (the victim-master's tail, rerouted) — each application then posts its own Block-C received award: a structural producer of "damage awards in duplicate pairs" [T3 inference from as-built structure; not yet log-confirmed].

4. Consumers (btplayer.cpp) — everything that mutates score/killCount/deathCount

Handler table: btplayer.cpp:346-356 (DropZoneReply, VehicleDead, Score, ScoreInflicted, ScoreUpdate, MissionStarting, MissionEnding).

4a. BTPlayer::Dispatch type-0 interceptor (:891-917)

Local id-0x16 type-0 dispatches, GetInstance()==MasterInstance only (:911) → ScoreInflictedMessageHandler; otherwise falls to Player::Dispatch (replicant → wire reroute). Wire deliveries NEVER pass through this vtable — they land in the handler table via Receive (:1092-1105).

4b. ScoreInflictedMessageHandler (:963-1022) — type 0

Verify-asserts type==0 (:971-980); drops if our_mech==0 (:994); currentScore += (tonnage ratio) * award, negated if target==self (:1001-1012); ForceUpdate() (:1013); matchlog receipt SCORE ... type=0 (:1018-1021). No instance guard at all.

4c. ScoreUpdateMessageHandler (:1032-1046)

Replicant check is Verify(False, ...) (:1036) then falls through to the base handler (:1045).

4d. ScoreMessageHandler (:1057-1279) — the 0x16 type-1/2 consumer

  • Replicant guard is a no-op at runtime: Verify(False, ...) (:1061-1067) — and Verify compiles to NOTHING: no DEBUG_LEVEL is defined anywhere in the build (grep of CMakeLists), so engine/MUNGA/STYLE.H:10-14 defaults DEBUG_LEVEL=0 → DEBUGOFF.h:42 #define Verify(c) (empty). Same for :973 and :1036. If a 0x16 ever reaches a replicant player copy, the handler executes fully on it. Delivery-side, EntityManager::ReceiveNetworkPacket (engine/MUNGA/NTTMGR.cpp:107-149) posts an inbound wire message to whatever LOCAL copy matches the entityID — no instance check — so nothing structural prevents that.
  • MissionEndingState drop (:1073-1076).
  • type 0 (:1086-1115): delegates to ScoreInflictedMessageHandler and RETURNS (wire-rerouted inflicted reports land here; :1114-1115).
  • type 1 DamageReceived (:1117-1169): award = tonnage-ratio × scenarioRole->CalcDamageReceivedScore(msg->damageAmount) — guards sender/our mech non-null, sender≠self, role bound (:1125-1131); self-hit → award stays 0 (deliberate deviation, :1132-1136); console VTVDamaged side-message (:1149-1167).
  • type 2 Kill (:1171-1241): award = tonnage-ratio × CalcInflictedScore(msg->damageAmount, sender_mech, msg->scoreAward) (:1188-1190); sender==our_mech → award = -award (suicide/eject, :1192-1195 — the previous build's measured -499); else ++killCount (:1204) and ++sender_owner->killCount — the binary's wrong-column slip reproduced on the VICTIM's (replicant) player copy (:1206, banner :2376-2389); status ticker (:1219-1228); objective notify (:1234-1240).
  • Common tail: message->scoreAward = award (:1252); matchlog SCORE ... type=<1|2> (:1257-1262); currentScore += award — via direct add if vehicleless (:1264-1268) else Player::ScoreMessageHandler (:1270); ForceUpdate() (:1278).

4e. VehicleDeadMessageHandler (:418-771) — the deathCount writer

  • MissionEndingState swallow (:426-439).
  • deathCount != -1 branch (:441-539): the +5s re-post / -2 probe → drop-zone hunt via engine base (:537). Moot if mech alive again (:452-462).
  • deathCount == -1 arm (:541-771), the death notification. Dedup gate if (deathPending != 0) return (:546-558) — but deathPending is SET at :559 and CLEARED at :768 inside the SAME synchronous invocation (the #81 fix moved the clear here, banner :739-767). A queued/wire duplicate arriving even 1ms later sails through. Per accepted -1 message: ++deathCount (:582), message->deathCount = deathCount (:583), ++deathTally (:592), matchlog PLAYER_DEAD (:601-602), life debit (:606-610), ForceUpdate (:634), killerName (:673), +5s respawn re-post (:690-695), THE DEATH COST (below), latch clear (:768), suppressConsole=0 (:770). No instance guard anywhere in this handler.

4f. The -500 deathPenalty — the ONLY site (:698-736)

if (advancedDamageOn && scenarioRole != 0)          // :726
    ScoreMessage death_cost(ScoreInflictedMessageID /*0x16*/, ..., type 1,
        -scenarioRole->GetSpecialCaseDeathPenalty(), 0.0f, EntityID::Null);  // :728-734
    Player::ScoreMessageHandler(&death_cost);       // :735 DIRECT base call

Properties: applied via a DIRECT engine-base call (PLAYER.cpp currentScore += scoreAward) — never dispatched, never on the wire, produces NO SCORE matchlog receipt (only the running total moves; acknowledged at :712-715). The one witness is the ungated [deathcost] DEBUG line (:716-724) printing advDmg=/role=/penalty=/scoreBefore=/APPLYING|SKIPPED. Gates: advancedDamageOn — assigned ONLY in the ctor's MASTER branch from bt_mission->AdvancedDamageOn() (:2137-2138); on a replicant player copy it is never assigned (ctor does not zero it; :1944-1947 zero only console/score/deathPending) [T3: indeterminate there]. scenarioRole — bound for both instances with a Role::Default fallback (:2066-2084).

4g. DropZoneReplyMessageHandler (:1743-1920)

deathCount = 0 on vehicle create (:1798); stale-reply discard when message->deathCount != deathCount (:1835-1867 — duplicate deaths that bump deathCount between the re-post and the reply strand the respawn here); RESPAWN matchlog (:1906).

4h. Scoreboard replication (:2391-2477)

WriteUpdateRecord ships killCount/deathTally (:2415-2416); ReadUpdateRecord mirrors onto replicants only (:2450-2458), SBMIRROR receipt (:2467). deathCount (the respawn-handshake identity) is NOT replicated — only deathTally is — so replicant-side deathCount writes are never corrected.

5. MechDeathHandler (game/reconstructed/mechdmg.cpp:1208-1363+)

Nothing score- or count-adjacent: skate detector (:1233-1313), ghost detector (:1315-1342), replicant un-wreck on the mode-9/2 exit edge (:1344-1360). The death-relevant writes in mechdmg.cpp are the graphicAlarm.SetLevel(9) sites (:495, :497, :548, :710, :798) which flip IsMechDestroyed() wherever damage is applied — including on replicant copies — which is what re-arms the ungated death tail (§1).

6. Every site that can run MORE THAN ONCE per death (vs a once-per-event model)

  1. Death tail on replicant victim copies (mech.cpp:1335-1343): ungated by instance, latched on movementMode which is frozen on replicants for the whole death-record round-trip → one VehicleDead + one death splash per salvo per peer node. The exact #150 mechanism, un-fixed for the tail. → explains deathCount 5→8, duplicates 1ms apart / same-ms ×N (each duplicate reroutes to the victim's master and is counted).
  2. VehicleDeadMessageHandler -1 arm has no effective dedup (btplayer.cpp:546/559/768): the latch is raised and lowered inside one synchronous body, so every queued duplicate increments deathCount/deathTally, debits a life, posts another +5s re-post, and (when its gates pass) applies another death cost.
  3. Death splash multi-application (mech4.cpp:1382-1428 via mech.cpp:1342): fires from the master's tail AND from every peer replicant's tail (×salvos) → bystander masters take the blast ≥2×paired type-1 damage awards (§3) [T3].
  4. Post-mortem salvos on the wreck: each still posts Block B+C (mech.cpp:1038 "inflicted-only path"; btplayer.cpp:2646-2690) — by design, but it means received penalties continue after death.
  5. Replicant execution of the 0x16 consumers is unguarded at runtime: Verify is compiled empty (DEBUG_LEVEL=0 → DEBUGOFF.h:42), VehicleDeadMessageHandler has no guard at all, and NTTMGR.cpp:107-149 delivers wire messages to the local copy by entityID with no instance check.
  6. ++sender_owner->killCount on the victim's replicant player copy (btplayer.cpp:1206) — bounded by the SBMIRROR correction (banner :2376-2389), kills currently read exact in the field.

7. Why the -500 can read "never applies" — as-built observations (mechanism NOT proven here)

The penalty has exactly one producer (§4f) and it is receipt-less: no matchlog row, no wire message — a duplicate-VehicleDead storm shows up as PLAYER_DEAD rows while the penalty leaves only the [deathcost] line and the raw total. As-built, the arm CANNOT be reached without also incrementing deathCount (:582 precedes :726 in the same arm), so "deathCount multi-increments AND penalty never applies" requires the :726 gate to fail on every accepted -1 — i.e. field [deathcost] lines reading advDmg=0 or role=NULL -> SKIPPED, or the -1 arms executing on player copies whose advancedDamageOn was never assigned (§4f). The previous build's -499 eject proof exercised the type-2 suicide negation (btplayer.cpp:1192-1195) plus the death cost (46ff8dd message: "1000 - ~999 - 500 = -499"), so the discriminator for build 883 is the ungated [deathcost] line and the [exp] master player ... advDamage= ctor line (:2141-2145) in the field logs.

8. Cross-node routing spine (for the graph)

  • Entity::Dispatch on a replicant reroutes to the owner host and does NOT execute locally (engine/MUNGA/ENTITY.cpp:235-272).
  • Wire arrival: EntityManager::ReceiveNetworkPacket posts to the local copy by entityID, handler table via Receive — bypassing the BTPlayer::Dispatch type-0 interceptor (btplayer.cpp:1092-1105) and any instance discipline (NTTMGR.cpp:107-149).
  • Matchlog receipt map: DMG mech.cpp:1290 · SCORE type=0 btplayer.cpp:1018 · SCORE type=1/2 :1258 · PLAYER_DEAD :601 · DEAD_NOTIFY :2724 · NOCREDIT :2637 · SBMIRROR :2467 · RESPAWN :1906 · PROJ mech4.cpp:2001 · SPLASH mech4.cpp:1349. The death cost has NO tag.