67 KiB
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.mdsection "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_destroyedargument was computed asdeathBlastArmed && IsMechDestroyed()— a movement-mode-derived flag, not an entry/exit edge.
After (the two binary-cited gates):
- Master-only gate (mech.cpp:1270 today):
if (GetInstance() != ReplicantInstance) { BTMechPostCombatReports(...); }. Grounded in the binary's opening assertionif ((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. - destroyedAtEntry latch (mech.cpp:1039 today):
const int destroyedAtEntry = IsMechDestroyed();sampled at handler entry, and the last argument became!destroyedAtEntry && IsMechDestroyed()— the binary'slocal_140->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_destroyedargument, i.e. block A (the type-2 KILL report to the shooter's player) insideBTMechPostCombatReports(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 theGetInstance() != ReplicantInstanceblock).BTMechPostVehicleDeadis the dispatcher of the VehicleDead message whosedeathCount == -1branch inBTPlayerdoes++deathCount/++deathTally, logsPLAYER_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 —46ff8ddhardened 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-handshakedeathCount(@+0x200, seeded -2); PLAYER_DEAD matchlog row logs BOTH (deaths=andtally=).a357dc4(07-30, #81 ghost): released the stuckdeathPendingdeath 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): restoredSpecialCaseDeathPenalty(role+0x20) and its consumer: every death withadvancedDamageOnhands the ENGINE basePlayer::ScoreMessageHandlera type-1 award of-penaltyvia 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 tailBTMechPostCombatReports(blocks A/B/C @0x4a02f4-0x4a0890) +BTMechPostVehicleDead(@0x4a07d4); retired the bring-up producersBTPostDamageScore/BTPostKillScore. This is what moved report emission into the victim's TakeDamage handler — the code46ff8ddlater 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 mustreturn, notbreak— the fall-through clobberedscoreAwardto 0 and ran the base handler again (a duplicate-row generator, then harmless-by-luck).a4bfb64(08-07): BOUNDscenarioRole(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 =advancedDamageOnalone, @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/SKIPPEDDEBUG_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:
grep -ac "Mech::Reset" sc_b.log— a count of B's resets (proxy for "kills A should have earned").grep -ah "SCORE\|KILL\|EJECT\|DEATH" matchlog_*.txt | head -25— the score sequence.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) andsc2.out(10:28, the run cited in the commit): in BOTH, thehead -25was exhausted by per-hit noise rows (type=0 award≈1.97 spam in sc.out; the victim node'stype=1 award=-0.00spam 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=-500can never appear in any matchlog — the death cost is a directPlayer::ScoreMessageHandlerbase call bypassing BTMatchLog (btplayer.cpp:712-715 says so explicitly;e0b91dfis 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.loghas 4×[deathcost] player 2:1 ... penalty=500 ... -> APPLYING,content/sc_b.loghas 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
- The bench asserted only the kill side, and only manually. Its scripted receipts were (a) a respawn count on the victim, (b) a
head -25matchlog 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. - 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.
- 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. - 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'sBTMechPostVehicleDead(mech.cpp:1335-1343) — the sole driver of++deathCount/++deathTally/PLAYER_DEAD and the death-cost block — kept the olddeathBlastArmed && 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) == 4→Fail("Replicant Mech recieving takedamage", MECH.CPP, 0x3da). The handler body runs ONLY on the victim's master node. - Gyro bounce — :14617-14624 (
mech+0x528→FUN_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 iffmech+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) == 0→FUN_0049ffcc(mech, &msg+0x2c)thengoto LAB_004a07b5— jumps straight to the death tail (binaryjmp @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 (gatedvictimPlayer+0x268 != 0, random subsystem viaFUN_004ac07c) and sends no messages. - Damage loop — :14655-14684:
local_28(tally) += per-burst appliedmsg+0x30plus each critically-destroyed subsystem's value*(subsys+0x108)(:14669);local_30(vitalHit) = 1 when the struck zone's destroyed flagzone[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 whenlocal_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 tallylocal_28(:14693,14700)+0x20 type= 2 (:14702)+0x24 basis=*(*(victimPlayer+0x208) + 0x1c)(:14691) = the VICTIM role's killBonus (role+0x1c; = 500 in shippedRole::Default) — this is the@0x4a0506killBonus 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 — whereBTPlayer0x16Score@0x4c02e4case 2 runs CalcKillScore × (senderTonnage/ownTonnage),killCount@0x27c++(dual — the 1995 phantom partner increment), self-killfchsnegation (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_004a0c14is a 0.0f literal parked after function end) +0x1c scoreAward= tally;+0x20 type= 0 (:14727);+0x24 basis= tally again (:14716,14728);+0x28/2c/30as above;+0x34 senderMechID= victim's mech ID (:14717,14729)- Dispatch :14733 → shooter's player. Receiver side:
BTPlayer::Dispatchoverride (@004bffa0, vtbl @00513300 slot 3) splits type 0 off to@004c0200 ScoreInflictedMessageHandlerbefore 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_28only (: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@0x4c02e4case 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 vialocal_1c + 0x61int* math, :14762,14773), +0x34 kill zone (msg+0x24, :14763,14774)}— matches the locked layout atdecomp-reference.md:293-294.- Dispatch :14775 →
local_20(victim's own player, master → local). Receiver: BTPlayer 0x17VehicleDead@0x4c05c4— increments the DEATHS cell+0x280on BOTH victim and killer players (inc [edi+0x280]/[ebx+0x280]@0x4c0670-0x4c067a) and, in its tail@004c07cd-0x4c0828, gated onadvancedDamageOn(player+0x264), direct-calls the ENGINE basePlayer::ScoreMessageHandler@0042da20 with a type-1 ScoreMessage whosescoreAward = −ScenarioRole::specialCaseDeathPenalty(role+0x20, 500 inRole::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 (ctorFUN_00430180, class data @DAT_004e6cc4) = the blast/Explosion entity, kept aslocal_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_004dcd94is an ST0-arg__ftolthe decompiler renders argless (decomp-reference.md:278-279); the float loaded ismech+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 callsFUN_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.837–4.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 837–870) 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 ~30–50ms 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 1–6, mode 1–2). 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 (confirmscontext/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) + sameteamName(+0x20c)→local_8 = −friendlyFirePenalty(role+0x14); elselocal_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(thefchs) 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; ifvictimMech == objectiveMech(+0x284)→ clear-targetFUN_004b04d8via 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 ≡ T0engine/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 == -1else → engine baseFUN_0042db80(drop-zone hunt);deathPending(+0x290) == 0guard @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) withmsg->deathCount ← this->deathCount(+0x200)(raw disasm recoveredmov 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:
- 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. lastInflictingID(+0x43c) ← msg->inflictingEntityID(+0x1c)— every hit, last-writer-wins.damageType(msg+0x2c) == 0(Collision) →FUN_0049ffccandgotothe 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]).- Burst loop applies damage;
local_28accumulates applied damage + crit contributions; the loop breaks when the mech goes disabled mid-burst. - 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::Dispatchreroutes to the shooter's master).tally = local_28(applied damage of the volley), **basis = the VICTIM's ownrole->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).
- KILL EDGE (alive at entry && disabled after) part_012.c:14685-14708: type 2 → Dispatch to the SHOOTER's owning player (
- 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≡ binaryFUN_0042e2e4, part_004.c:859-867):Entity::WriteUpdateRecord,recordLength = 0x3c,record+0x2c = currentScore(+0x1c8),record+0x30 = dropZoneLocation(+0x1d0)— nothing else.ReadUpdateRecord(PLAYER.cpp:631-641≡FUN_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), respawndeathCount(+0x200), and the bank+0x278NEVER 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-626≡FUN_0042e1ac): per-node sort of the local "Players" group bycurrentScore→playerRanking(+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. +0x278is a pending-score BANK, not the score (NEW precision): sole writer = the dead@0x4c0200(+0x278 += tonnageRatio × CalcKillScore(...), self-negated); the flusherFUN_004c083c(part_013.c:19164-19192, called from the BTPlayer master performanceFUN_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:
V.mech.lastInflictingID ← S.mech; damage applied; tally accumulated.- [out] ScoreMessage 0x16 type 2 → dispatched at S.player-replicant → rerouted to S's node (
ENTITY.cpp:244-251). - [sync] ScoreMessage 0x16 type 1 → V.player (master):
V.currentScore(+0x1c8) += (Vt/St) × −(basis × rcvMod)(0 with dfltrole); console VTVDamaged. - [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 0x15 → FUN_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_tail — bypasses 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) — andVerifycompiles to NOTHING: noDEBUG_LEVELis 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); matchlogSCORE ... type=<1|2>(:1257-1262);currentScore += award— via direct add if vehicleless (:1264-1268) elsePlayer::ScoreMessageHandler(:1270);ForceUpdate()(:1278).
4e. VehicleDeadMessageHandler (:418-771) — the deathCount writer
- MissionEndingState swallow (:426-439).
deathCount != -1branch (:441-539): the +5s re-post / -2 probe → drop-zone hunt via engine base (:537). Moot if mech alive again (:452-462).deathCount == -1arm (:541-771), the death notification. Dedup gateif (deathPending != 0) return(:546-558) — butdeathPendingis 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), matchlogPLAYER_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)
- 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).
- 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.
- 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].
- 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.
- Replicant execution of the 0x16 consumers is unguarded at runtime:
Verifyis 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. ++sender_owner->killCounton 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::Dispatchon a replicant reroutes to the owner host and does NOT execute locally (engine/MUNGA/ENTITY.cpp:235-272).- Wire arrival:
EntityManager::ReceiveNetworkPacketposts to the local copy by entityID, handler table via Receive — bypassing theBTPlayer::Dispatchtype-0 interceptor (btplayer.cpp:1092-1105) and any instance discipline (NTTMGR.cpp:107-149). - Matchlog receipt map:
DMGmech.cpp:1290 ·SCORE type=0btplayer.cpp:1018 ·SCORE type=1/2:1258 ·PLAYER_DEAD:601 ·DEAD_NOTIFY:2724 ·NOCREDIT:2637 ·SBMIRROR:2467 ·RESPAWN:1906 ·PROJmech4.cpp:2001 ·SPLASHmech4.cpp:1349. The death cost has NO tag.