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

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

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

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

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

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

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

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

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

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

36 KiB
Raw Blame History

Respawn Re-arm Root Cause — "dead weapons after dying"

Status: ROOT-CAUSED, fix NOT yet written. 2026-07-24, from a read-only 9-agent investigation (4 tracers -> adversarial verifiers -> plan), every binary claim re-verified with capstone against content/BTL4OPT.EXE. Provenance: workflow wf_1587c691-e0c. Field case: Gitea #54 (David/Auric, playtest night 3, build 4.11.524).

The one-paragraph answer

A respawn DOES correctly re-arm weapons in 4.11.524 -- a full census of content/matchlogs finds 21 own-deaths (DEATH ... inst=M) each with a 1:1 matching PLAYER_DEAD, in every log except David's (1 own-death, 0 PLAYER_DEAD). So #22's fix is holding and is NOT implicated.

What happened to David is one step UPSTREAM: his death->respawn cycle never started. PLAYER_DEAD is written unconditionally at game/reconstructed/btplayer.cpp:422, in the same straight-line block as ++deathCount, so its absence proves BTPlayer::VehicleDeadMessageHandler never entered its deathCount == -1 branch => no drop-zone hunt => no DropZoneReply => Mech::Reset never ran. Independently corroborated in his own log: DMG victim=6:25 zone=12 lvl=1.0000 11.18 s after death -- a zone Mech::Reset would have healed to 0.

With no Reset, movementMode stays 9 forever, and then ONE predicate -- Mech::IsDisabled() (movementMode 2 or 9) -- explains all six dark panels through two authentic per-frame gates: emitters get currentLevel = 0 (emitter.cpp:431-437) and projectile weapons get recoil = rechargeRate plus a latched weaponAlarm 7 (projweap.cpp:707-731). Both leave rechargeLevel = 0, and the sunburst ring AND the fire-ready disc are the SAME scalar -- so six dark panels is one data fault, not six. The frozen ammo digits and the all-green CRITICAL page fall out of the same cause.

Two mechanisms are needed to explain the whole night, though: his AFC 100 (wpn 22) went silent 123 s BEFORE he died while carrying 13 rounds, and LRM 15 #2 (wpn 29) 66 s before, while its byte-identical twin (wpn 27) fired four more times. That pre-death brick is the #21 family and is untouched by this fix.

Still UNKNOWN: why the -1 branch never ran. Static elimination leaves one survivor -- GetPlayerLink() == 0 on his own master mech -- and section 6 "Check 0" is the no-code-change run that settles it. Do that FIRST.


ROOT-CAUSE + FIX PLAN — "a respawned mech comes back with dead weapons"

All binary claims below were re-verified in this pass with capstone against content/BTL4OPT.EXE (tools/disas2.py <va> <len>); addresses given are the ones I actually disassembled.


1. VERDICT

The brief's hypothesis is refuted as stated, and replaced by a stronger one. A respawn does re-arm weapons in build 4.11.524: three respawns that night provably reloaded their bins, and a full census of content/matchlogs shows 21 own-deaths (DEATH … inst=M) with a 1:1 matching PLAYER_DEAD record in every single log — except David's, which has 1 own-death and 0 PLAYER_DEAD [T2, census re-run in this pass]. What happened to David is one step upstream of the re-arm: his death→respawn cycle never started. PLAYER_DEAD is written unconditionally at game/reconstructed/btplayer.cpp:422, in the same straight-line block as ++deathCount (:416) and the +5 s re-post (:459-465), so its absence proves BTPlayer::VehicleDeadMessageHandler never entered its deathCount == -1 branch ⇒ no drop-zone hunt ⇒ no DropZoneReplyMech::Reset never ran [T2]. Independently corroborated in his own log: DMG … victim=6:25 zone=12 lvl=1.0000 at 23:18:02.999, 11.18 s after death, a zone Mech::Reset would have healed to 0.0 (mech4.cpp:1758-1763mechdmg.cpp:1004-1014) [T2]. With no Reset, movementMode stays 9 forever (mech4.cpp:1944 is the only writer of 9; mech4.cpp:1749 the only writer back to 0), and then ONE predicateMech::IsDisabled() (movementMode 2‖9, btstubs.cpp:122-125, == FUN_0049fb54) — explains all six panels through two authentic per-frame gates: emitter.cpp:431-437 forces currentLevel = 0 then ComputeOutputVoltage()rechargeLevel = 0 for the three lasers, and projweap.cpp:707-731 GATE 1 pins recoil = rechargeRate + latches weaponAlarm 7 for the AFC and both LRMs ⇒ rechargeLevel = (raterate)/rate = 0. Since the sunburst ring and the fire-ready disc are the same scalar (percentDone = MechWeapon::rechargeLevel@0x320, disc = >0.99 lamp, btl4gau2.cpp:1655/1807/1900), six dark panels is one data fault, not six [T1]. The frozen partial ammo digits (0013/0005/0010 vs authored 20/16/16) and the all-green CRITICAL page fall out of the same cause. So: one mechanism explains the screenshot; two are needed to explain the night — his AFC 100 (wpn 22) went silent 123 s before he died with 13 rounds aboard and LRM 15 #2 (wpn 29) 66 s before while its byte-identical twin wpn 27 fired four more times. That pre-death brick is the #21 family and is untouched by this fix.

What is still UNKNOWN: why the -1 branch never ran. Only one candidate survives static elimination — see §6, check 0.


2. THE CHAIN

The binary (all re-verified this pass)

step address what it does
death @004c05c4 +0x189 (0x4c06e9) push 0; push esi; call 0x49fe0c — the death sweep with arg 0
@0049fe0c for (i=0; i < mech[0x124]; ++i) roster[0x128][i]->vtbl[0x2c](sub, arg)i from 0, no null guard
@004ad10e slot 11 (DeathShutdown) universal base = { this->vtbl[0x28](this, arg); }forwards to slot 10
@004c0701.. RespawnDelay = 5.0f @0x4c0830, re-post to self
respawn @004bffd0 playerVehicle==0 ? first spawn : respawn (deathCount gate) → both fall through to Mech::Reset(origin, 1). playerVehicle is never nulled at death — reset-based respawn, confirmed
@0049fb74 +0x247 (0x49fdbb) esi = 2; while (esi < [ebx+0x124]) { s = [ebx+0x128][esi]; if (s) s->vtbl[0x28](s, mode); } then call 0x49f788, then ForceUpdate(0x1f)

Per-class slot 10, every one forwards the arg (verified): Generator @004b215cHeatSink @004ad760(arg), generatorOn=1, stateAlarm 0→2, outputVoltage@0x1DC = ratedVoltage@0x1D8, coolant 1 / 1.0f, startTimer = startTime · HeatSink @004ad760 → temp=startTemp, heatEnergy, coolant refill, filter flush, tail-calls @004ac22c(arg) · Subsystem @004ac22c (the terminus) → zone[0x158] = 0, SetLevel(zone+0x10, 0), SetLevel(this+0x2C, 0), optional slot 0x34 · MechWeapon @004b96ecPoweredSubsystem @004b0e6c(arg), recoil 0, rechargeLevel=1.0, impulses 0 · Emitter @004ba4d0 → alarm 3, currentLevel=0, seekRate@0x45C=0, 0x46C=0, 0x474=0, beamScale/orient, 0x418=0, ForceUpdate — does NOT touch seekVoltageIndex@0x3F0 · ProjectileWeapon @004bbaf8 → alarm 3, recoil = rechargeRate, eject restore, ejectState=-1 · MissileLauncher @004bcc3cdamageData.burstCount@0x3D4 = missileCount@0x448 then chains @004bbaf8(arg) · AmmoBin @004bd26c@004aea9c(arg), then if (arg) { feedTimer=0; [0x228]=0; ammoCount@0x180 = initialAmmoCount@0x220; cookOffArmed=0; }, then unconditional ammoAlarm.SetLevel(1).

Our port

Mech::UpdateDeathState                     mech4.cpp:1826
 ├ transition (one-shot, master only)
 │   ├ DeathShutdown(1) sweep i=0..n       mech4.cpp:1928-1932  ← ✗ WRONG VIRTUAL ARG + TOTAL NO-OP
 │   ├ ForceUpdate(death bit) ; SetMovementMode(9)  :1944
 │   ├ BTPostKillScore(this, dmg)          :1963 → btplayer.cpp:1681  ← GATE: GetPlayerLink()
 │   ├ DEBUG_STREAM "[damage] *** DESTROYED …"  :2010   (UNCONDITIONAL — the transition proof)
 │   └ owner = GetPlayerLink(); if (owner) owner->Dispatch(VehicleDead)  :2044-2058  ← SAME GATE
 └ next frame: MovementMode()==9 → BTMatchLog("DEATH")  :1845

BTPlayer::VehicleDeadMessageHandler        btplayer.cpp:308
 ├ state==MissionEndingState → return      :316   (excluded: app st=5 RunningMission all round)
 ├ deathCount != -1 → mech_dead gate → Player::VehicleDeadMessageHandler (T0 PLAYER.cpp:214)
 └ deathCount == -1: deathPending gate :391 → =1 :396 → warp → ++deathCount :416
     → BTMatchLog("PLAYER_DEAD") :422  ← DAVID'S LOG STOPS BEING EXPLICABLE HERE
     → application->Post(+5 s) :463
DropZone → BTPlayer::DropZoneReplyMessageHandler  btplayer.cpp:1163
 └ else-if deathCount==msg->deathCount :1222 … non-authentic IsMechDestroyed() guard :1237-1244
     → Mech::Reset(dropZoneLocation, 1)   :1262
Mech::Reset                                mech4.cpp:1709
 ├ SetMovementMode(0) :1749 ; graphicAlarm.SetLevel(0) :1755   ← the only un-disable in the tree
 ├ zone->Heal() every zone :1758-1763
 └ for i=2..count: s->DeathReset(mode) :1789 ; ((MechSubsystem*)s)->RespawnRepair() :1790

Where the port diverges from the binary, exactly:

  1. mech4.cpp:1928-1932 — calls DeathShutdown(**1**); the binary calls the sweep with 0, and its base body forwards to DeathReset. Our Subsystem::DeathShutdown(int){} is empty (engine/MUNGA/SUBSYSTM.h:165-167) and no port class overrides iteverything the binary does at death is skipped [T2].
  2. mech4.cpp:1789 — the respawn sweep is structurally right (i=2, arg forwarded) but only 7 classes declare DeathReset: ammobin.hpp:202, emitter.hpp:213, mechweap.hpp:284, mislanch.hpp:91, projweap.hpp:212, sensor.hpp:83, messmgr.hpp:102. Generator / HeatSink / HeatableSubsystem / Condenser / Reservoir / HeatSinkBank / PoweredSubsystem / Myomers / Torso / Gyro / HUD / PowerWatcher / MechTech / MechControlsMapper fall through to the empty base [T1].
  3. The chain never reaches the authentic terminus. HeatSink::ResetToInitialState (heat.cpp:636-650) tail-calls HeatableSubsystem::ResetToInitialState (heat.cpp:176-180, a HEAT.TCP shard body: currentTemperature = 300; heatLoad = 0) — which clobbers the startingTemperature the line above just wrote, and stops there. The binary tail-calls @004ac22c, which heals the subsystem's own crit zone and status alarm. RespawnRepair (mechsub.cpp:260-269) is the bolt-on that papers over the missing terminus.
  4. mech4.cpp:1790((MechSubsystem*)s)->RespawnRepair() is applied blindly to every roster slot, but SubsystemMessageManager (messmgr.hpp:78, 0xBD3, factory mech.cpp:1337) and MechTech (mechtech.hpp:127, 0xBDC, mech.cpp:1364) are public Subsystem, not MechSubsystem. statusAlarm is declared in MechSubsystem (mechsub.hpp:305) — so this is an out-of-type write plus a garbage AlarmIndicator::SetLevel watcher dispatch, twice per respawn [T1]. Textbook databinding trap.
  5. powersub.cpp:1088-1092Generator::ResetToInitialState sets outputVoltage = 0.0f, transcribed from the stale GNRATOR.TCP. The binary sets outputVoltage = ratedVoltage (0x4b219f: mov eax,[ebx+0x1d8]; mov [ebx+0x1dc],eax). Harmless only because nothing dispatches it. Wiring Generator::DeathReset before fixing this body bricks every respawn at 0 voltsEmitter::ReadyToDischarge (emitter.cpp:744-748) gates charging on source->MeasuredVoltage() and GeneratorStateOf()==2 [T1].
  6. mechsub.cpp:330-348MechSubsystem::ResetToInitialState(Logical) is labelled "reset" but is a transcription of @004ac1d4 = Subsystem::GenerateFault(int) (SUBSYSTM.h:146), whose !powered branch sets damageZone->structureLevel = 1.0f + statusAlarm.SetLevel(1) = destroys the subsystem. The real root reset @004ac22c sits at mechsub.cpp:355 under the name ClearStatus(), on no path.
  7. mislanch.cpp:412MissileLauncher::DeathReset calls ProjectileWeapon::ResetToInitialState() and skips burstCount = missileCount; there is no MissileLauncher::ResetToInitialState in the tree.
  8. ammobin.cpp:390-396 — comment "ResetToInitialState … authentically EMPTY per AMMOBIN.TCP" is WRONG: @004bd26c refills, arg-gated. The refill lives in a port-invented DeathReset (:378-388) that ignores the arg.
  9. emitter.cpp:918-932 — omits seekRate@0x45C = 0; writes targetLocalFlag@0x470, which @004ba4d0 never touches.
  10. btplayer.cpp:1237-1244 — a non-authentic IsMechDestroyed() guard the binary does not have, and a bare else { return; } at :1246 where the binary retries via @004c012c. Either exit skips Mech::Reset entirely.

3. WHAT PERSISTS (state after a respawn, our port)

field initialised where reset body that exists reached on respawn? symptom
AmmoBin::ammoCount@0x180 ctor ammobin.cpp:150, snapshot :162 AmmoBin::DeathReset :378 (authentic body is @004bd26c, arg-gated) only via mech4.cpp:1789 David: frozen 0013/0005/0010
MissileLauncher burstCount@0x3D4 ctor NONE (mislanch.cpp:412 skips it) never LRM salvo size wrong after any respawn
MechWeapon::rechargeLevel@0x320 ctor 1.0 mechweap.cpp:354 MechWeapon::RTIS :474 via Reset only — and re-pinned to 0 every frame while IsDisabled() (emitter.cpp:434, projweap.cpp:729+mechweap.cpp:587) dark sunburst + no ready disc, all 6
MechWeapon::recoil@0x3E8 ctor 0 :355 MechWeapon::RTIS :473 / ProjWeap::RTIS projweap.cpp:419 as above dial 0 on ammo weapons
weaponAlarm 5 Jam / 7 NoAmmo ctor Emitter::RTIS emitter.cpp:923 / ProjWeap::RTIS :417 → 3 as above; GATE 1/2 re-latch 7 every frame cannot fire; no jam lamp (lamp only lights on ==5, btl4gau2.cpp:2048)
weapon-local heat/coolant HeatSink ctor MechWeapon::RTISPoweredSubHeatSink::RTIS heat.cpp:636 via Reset only — and HeatableSubsystem::RTIS clobbers temp to 300 wrong restart temperature
subsystem private crit zone + statusAlarm ctor new DamageZone(this,0) authentic @004ac22c, present but unreachable (named ClearStatus, mechsub.cpp:355); port bolt-on RespawnRepair :260 RespawnRepair via :1790 (illegal on 2 slots) destroyed weapons stay destroyed; latent corruption
Generator outputVoltage/stateAlarm/generatorOn ctor powersub.cpp:1031ff Generator::RTIS :1088 — wrong body (0 V) never (no override) latent hard-brick when wired
mech-level HeatSink/Condenser/Reservoir (loop temps, coolant) ctors heat.cpp:636, :335, Reservoir never coolant loops keep death-frame state
Gyro / HUD / Myomers / Torso / PowerWatcher / Seeker / Searchlight / ThermalSight ctors (each has an RTIS) gyro.cpp:384, hud.cpp:260, myomers.cpp:521, … never assorted stale state
Emitter::seekVoltageIndex@0x3F0 ctor emitter.cpp:1015/1065 none anywhere never — and the arcade never reset it either (@004ba4d0 verified) authentic: keep
mech damage zones model loader Mech__DamageZone::Heal mechdmg.cpp:1005 via Reset (:1758) David: zone 12 lvl=1.0000 at +11.18 s
movementMode 9 / graphicAlarm ≥9 death mech4.cpp:1944/… Reset :1749/:1755 only via Reset the master switch for everything above
collisionVolumeCount, collisionVolume (parked 100 km at burial :1876-1893) Mech ctor NONE never post-burial respawn is un-hittable
deathPending ctor 0 btplayer.cpp:~1301 cleared only in the != -1 branch when the mech is already alive (:343) one-shot permanent respawn kill if ever latched without a reset

4. THE FIX

House rules applied: reuse the binary's own entry points, propagate the real arg, no stand-ins, bridge in a complete-type TU rather than raw offsets.

Step 0 — make the chain observable (do this first; it is the David fix's evidence)

  • mech4.cpp:2045 — record the owner resolution unconditionally in the matchlog: BTMatchLog("DEAD_NOTIFY", "mech=%d:%d link=%p mission=%p"). Today a NULL link is a silent swallow.
  • btplayer.cpp:1143 (tail of InitializePlayerLink) — BTMatchLog("PLAYER_LINK", "player=… vehicle=… link=%p"). link=0 proves the race in one line.
  • mech4.cpp:1791 (sweep tail) — promote the BT_DEATH_LOG line at :1814 to an unconditional BTMatchLog("RESPAWN", "mech=… zones=%d subsys=%d weapons=%d bins=%d alive=%d"). A respawn is currently invisible in the matchlog (VEHICLE is only logged on the CreatePlayerVehicle path, btplayer.cpp:977, which a reset-based respawn never takes) — every respawn that night had to be inferred from ammo arithmetic.

Step 1 — the David fix: the death notification must not hinge on one un-retried pointer

Both dispatch sites (btplayer.cpp:1681, mech4.cpp:2045) share the single gate Mech::GetPlayerLink(). playerLink is NULL-initialised (engine/MUNGA/ENTITY.cpp:935), written only by Entity::PlayerLinkMessageHandler (ENTITY.cpp:807-813, playerLink = host->GetEntityPointer(message->playerID) — no null check, no retry, no log), fed by exactly one InitializePlayerLink dispatch at the initial drop-in (btplayer.cpp:1196). Fix in two places:

  • mech.cpp:675 Mech::PlayerLinkMessageHandler — after Entity::PlayerLinkMessageHandler(message), if GetPlayerLink() == 0, log loudly and resolve the same object the second way the port already maintains: the reverse link (Player::playerVehicle == this), reachable from application->GetMissionPlayer() for a local master. Do it in this TU (complete Mech + Player types) — no raw offsets.
  • mech4.cpp:2044 — same fallback at the point of use, so a link that was never resolved cannot swallow the cycle: Player *owner = GetPlayerLink(); if (owner == 0) owner = <mission player whose GetPlayerVehicle() == this>;. This is not a stand-in for game logic — it is the identical object, resolved by the identity relation the binary's own @004bffd0 respawn branch uses (player+0x1FC).
  • btplayer.cpp:1219 — also clear deathPending = 0 alongside deathCount = 0 in the first-spawn branch. Removes a whole class of "one death and you're a permanent wreck".
  • btplayer.cpp:1237-1244 — delete the non-authentic IsMechDestroyed() guard; keep the binary's two checks (msg->deathCount == deathCount, state != MissionEnding). Route the else at :1246 to a retry, not a bare return. (The binary's @004c012c two-pass DropZoneAcquiredState latch cannot be restored as-is: our Set_Alarm_Level is a no-op stub (btstubs.cpp:87), so the port has no player simulation-state pulse at all. That is why the T0 base's GetSimulationState() != DropZoneAcquiredState gate (PLAYER.cpp:229) currently always passes and the loop terminates on mech_dead instead. Leave that as the documented port shape; do not half-restore the pulse or the base handler will stop hunting.)

Step 2 — restore the authentic dispatch shape (kills defects 1-4 and the illegal cast)

  • engine/MUNGA/SUBSYSTM.h:165-167 — give DeathShutdown(int c) the authentic base body { DeathReset(c); } (@004ad10e, verified).
  • mech4.cpp:1928-1932 — pass 0, keep i = 0..count-1 (matches @0049fe0c exactly, including no index skip).
  • Make every existing DeathReset override propagate the arg instead of relying on powered = True defaults (mechweap.cpp:676, projweap.cpp:403, emitter.cpp:909, mislanch.cpp:412, ammobin.cpp:378, sensor.cpp:347). Every binary body forwards it (verified at 0x4b96f3, 0x4bbaff, 0x4bcc4e, 0x4bd271, 0x4ad7c3, 0x4b2164).
  • Rename and re-point the two mislabeled bodies in mechsub.cpp: :330 ResetToInitialState(Logical)GenerateFault(int) (it is @004ac1d4, engine slot 8, SUBSYSTM.h:146); :355 ClearStatus() → the root reset DeathReset(int) (it is @004ac22c). Before doing so, re-point its writes at the engine DamageZone (damageLevel, as GetSubsystemDamageLevel already does at mechsub.cpp:236) — today it writes 0.0f/1.0f through the ReconDamageZone proxy whose offset-0 structureLevel aliases the live zone's vtable pointer (mechrecon.hpp:326-333, warning at mechsub.hpp:257-263). Wiring it unfixed stores 0x3F800000 over a vptr.
  • heat.cpp:636-650 — terminate HeatSink::ResetToInitialState at the new root reset (@004ad760 tail-calls @004ac22c — verified at 0x4ad7c8), and delete HeatableSubsystem::ResetToInitialState (heat.cpp:176-180); it is a stale-shard invention that clobbers currentTemperature. Pre-flight check: read HeatableSubsystem's vtable slot 10 (+0x28) — if it is @004ac22c, the class genuinely has no override and the deletion is correct.
  • Delete MechSubsystem::RespawnRepair and the cast at mech4.cpp:1790. Once the chain terminates at @004ac22c, its work is authentic and universal — and the out-of-type write on SubsystemMessageManager/MechTech disappears with it.

Step 3 — fix the bodies before extending coverage

  • powersub.cpp:1088 Generator::ResetToInitialState: HeatSink::RTIS(arg); generatorOn = 1; startTimer = 0; stateAlarm.SetLevel(0) then SetLevel(2) (a forced change, so watchers re-latch); outputVoltage = ratedVoltage; coolantAvailable = 1; coolantFlowScale = 1.0f; startTimer = startTime. (@004b215c, disassembled above.)
  • mislanch.cpp — add MissileLauncher::ResetToInitialState: damageData.burstCount = missileCount; then ProjectileWeapon::ResetToInitialState() (@004bcc3c).
  • ammobin.cpp:390 — move the refill into ResetToInitialState(int active) with the authentic arg gate, delete the "authentically EMPTY per AMMOBIN.TCP" comment, add the missing [0x228] = 0, and keep ammoAlarm.SetLevel(Loaded) unconditional (@004bd26c).
  • emitter.cpp:918-932 — add seekRate = 0; drop the targetLocalFlag@0x470 write.

Step 4 — extend coverage (now safe)

Add DeathReset(int) overrides that chain each class's own RTIS with the propagated arg: Generator, HeatSink, Condenser, Reservoir, HeatSinkBank, PoweredSubsystem, Myomers, Torso, Gyroscope, HUD, PowerWatcher, MechTech, MechControlsMapper.

Step 5 — companion port gaps in Mech::Reset

Restore collisionVolumeCount and un-park collisionVolume (the burial at mech4.cpp:1876-1893 is a port addition, so its inverse must be too), and clear lastInflictingID/lastInflictingDamage.

What a naive fix would get wrong

  • Double-apply: once DeathShutdown forwards to DeathReset, keeping both the forwarding base and any per-class DeathShutdown override runs the reset twice per death. Override DeathShutdown nowhere.
  • Resetting on the wrong side of the arg: AmmoBin must not refill at death (arg == 0); the wreck keeps its counts. Our current DeathReset ignores the arg — after Step 2 that would refill the corpse.
  • The arcade deliberately keeps: Emitter::seekVoltageIndex@0x3F0 (the pilot's seek gear — @004ba4d0 provably does not touch it; the suggestion to reset it to seekVoltageRecommendedIndex would be a stand-in), deathCount/score, and the mode == 0 variant of Reset (mission-review park: ammo not refilled, PoweredSubsystem takes the SetLevel(0) branch).
  • The arcade deliberately does NOT keep: damage (every mech zone healed + every subsystem's private zone healed at @004ac22c) or heat (@004ad760 restores startingTemperature and refills coolant; Condenser @004ae534 restores its own pair). So "does the pod keep heat/damage across a respawn?" — no, on both counts [T1].
  • /FORCE: a declared-but-undefined new override links clean and AVs at a garbage call target. Grep the link log after adding ~13 overrides.

5. ISSUE MAP

issue disposition
#22 "ammo and destroyed weapons do not reset on respawn" Retitle, don't reopen as "the reset is missing". The sweep shipped in 4.11.524 (commit 5f115ec is an ancestor of 6c3fca2, rev-list --count = 524) and provably works. Residual real gaps that #22 should now carry: MissileLauncher burstCount, the AmmoBin refill being in the wrong function, the missing chain terminus, and the illegal RespawnRepair cast. New title: "the death→respawn cycle can be skipped entirely (no PLAYER_DEAD, no Mech::Reset)".
#45 "no PLAYER_DEAD record" CORRECTED 2026-07-25: NOT subsumed. The missing PLAYER_DEAD record was one symptom and it is fixed, but #45's substance is the SCOREBOARD: both counters lived only on the owning pod (KILLS because Entity::Dispatch reroutes a replicant's ScoreMessage to the master, ENTITY.cpp:244-251; DEATHS because VehicleDead(-1) runs on the victim's own master) and neither rode an update record, so every REMOTE row read 0/0 all mission. Fixed separately by replicating them (docs/KD_SCOREBOARD_PLAN.md addendum). Original note kept for history: SUBSUMED — same defect, not two. PLAYER_DEAD is written in the same straight-line block that starts the respawn. Merge into this investigation.
#21 "recharge arc + fire-ready dot go dark" STAYS SEPARATE and is still live. Its fix (the overcharge rescue, emitter.cpp:513-538) is unreachable from a dead mech (it sits inside case 3, after the return at :437) and never covered the projectile family at all (no charge accumulator, no seek voltage; their only pin is recoil == rechargeRate). Independent evidence it is unrelated: single-weapon permanent silence with ammo left appears in six logs that night, five with no death at all, plus David's own wpn 22 (123 s) and wpn 29 (66 s). Tightest repro in the corpus: David's wpn 27 vs wpn 29 — two byte-identical LRM 15s on identical 16-round bins, in lockstep for four volleys, then one fires four more times alone. That rules out anything global (heat, bus voltage, target, trigger).
#46 ammo-bay fire does not kill · #28 vital-crit death path unconfirmed RELATED ROOT, separate fix. MechSubsystem::TakeDamage is an empty stub (btstubs.cpp:178-181), so the authentic slot-9 body @004ac0bc — which on the destroy edge sets statusAlarm 1, pins zone[0x158] = 1.0, and if [0xE4] (vital) sets the owning mech's graphicAlarm to 9 (verified at 0x4ac112/0x4ac12c) — never runs. Compounded by ApplyDamageAndMeasure (mechsub.cpp:409-415) returning a deterministic 0.0 through the proxy alias, so the crit budget never saturates. That is why 164 crits were all vital=0 and every row stayed green. Not a respawn bug; do not bundle.
KB corrections to land (a) DamageZone + 0x158 is accumulated DAMAGE: 0 = pristine, 1.0 = destroyed — settled this pass from @004ac22c (reset writes 0), @004ac0bc (destroy writes 1.0 alongside statusAlarm 1), @004ac144 (>= 1.0 ⇒ tier 1). So mechdmg.hpp:24's structureLevel naming and MechSubsystem::GetStatusFlags's ">= 1.0 → intact" comment are inverted; damageLevel is right. (b) CLASSMAP slot labels: slot 8 = GenerateFault(int), slot 9 = TakeDamage(Damage&), slot 10 = DeathReset(int) (== the BT source's ResetToInitialState), slot 11 = DeathShutdown(int) = the @004ad10e forwarder — anchored to T0 SUBSYSTM.h:146-167. (c) CLASSMAP's "AmmoBin s10 empty per AMMOBIN.TCP" is wrong; the .TCP shards are a stale pre-MechWeapon revision (EMITTER.TCP chains PoweredSubsystem, the shipped Emitter chains MechWeapon) and must never be used as body evidence against the binary. (d) Delete "the binary's respawn severed the vehicle and built a NEW mech" (context/multiplayer.md:~810, echoed in mech4.cpp:1770-1780 and ammobin.cpp:372) — @004c05c4 provably never clears playerVehicle; the arcade re-armed in place, so ammo refill on respawn is authentic, not a port divergence. (e) docs/GAUGE_COMPOSITE.md:677 / context/gauges-hud.md:531-533 "the port sim never calls slot 17" is stale. (f) Record in gauges-hud.md that the fire-ready disc is a percentDone > 0.99 lamp (btl4gau2.cpp:1655), not a weaponAlarm == Loaded lamp — that single fact collapses six failures into one. (g) btplayer.cpp:508-511 still asserts "during the 5-second dead window playerVehicle is severed" — the opposite of what the same file implements.

6. VERIFICATION

Check 0 — settle the upstream unknown first (no code change, one env-gated run). Two candidates for the missing -1 branch remain after static elimination (MissionEndingState is excluded: app st=5 RunningMission on every one of his 239 lines; deathPending is excluded because it is only set inside the branch that logs PLAYER_DEAD): (i) GetPlayerLink() == 0 on his own master mech, (ii) nothing else. The discriminator is the pair of logs at the transition: [damage] *** … DESTROYED (death effects dispatched from the death transition) *** (mech4.cpp:2010, unconditional) present without [death] VehicleDead(-1) dispatched to the owning player (mech4.cpp:2054, BT_DEATH_LOG) ⇒ the link was NULL. [T3 until this runs.]

Rig (2-node localhost relay, verified recipe from context/multiplayer.md:352-360):

python tools/btconsole.py --relay 1500 MP_RELAY.EGG --bind 127.0.0.1
# pod A (victim)
BT_RELAY=127.0.0.1:1500 BT_SELF=10.99.0.1:1502 BT_DEATH_LOG=1 BT_SCORE_LOG=1 \
  BT_MATCHLOG=1 BT_CAM_LOG=1 BT_AMMO_LOG=1 BT_SPAWN_XZ=0,0 \
  btl4 -egg MP_RELAY.EGG -net 1501
# pod B (killer) — BT_MP_FORCE_DMG makes B shoot A's replicant once a second (mech4.cpp:4823)
BT_RELAY=127.0.0.1:1500 BT_SELF=10.99.0.2:1602 BT_MP_FORCE_DMG=1 BT_MP_LOG=1 \
  BT_MATCHLOG=1 BT_SPAWN_XZ=0,60 btl4 -egg MP_RELAY.EGG -net 1601

Kill A twice: once by weapon damage (BT_MP_FORCE_DMG), once by ram (drive B into A at speed — both field non-resumers died to type=0 CollisionDamage, raw 36014 / 109269, so the collision path must be covered even though it is confounded with one-shot overkill and must not be reported as a root cause).

On A, in order — before the fix (expected failure signature) / after the fix (pass):

# artefact pass criterion
1 btl4.log [damage] *** … DESTROYED … and [death] VehicleDead(-1) dispatched to the owning player both present
2 matchlog PLAYER_DEAD player=… deaths=1 present, ~35 ms before DEATH … inst=M (the working-case ordering, e.g. 69.117.98.17_matchlog_20260724_224438_16708.txt:285-286)
3 btl4.log [score] VehicleDead(-1): death #1, respawn hunt in 5s[score] VehicleDead: drop-zone hunt[score] DropZoneReply: hasVehicle=1 deathCount=1 msgDeath=1 state=…
4 matchlog (new) RESPAWN mech=… zones=N subsys=M weapons=6 bins=3 alive=1 — and [respawn] Mech::Reset … alive=1 in btl4.log
5 btl4.log [respawn] ammo bin 21 refilled 13 -> 20 (one line per bin)
6 matchlog the first post-death DMG on A shows lvl ≈ the fresh hit, not 1.0000 (the zone-heal probe that convicted David; add it to tools/matchcheck.py as a standing check)
7 matchlog (new) WPN … state 7 -> 3 for all three ammo weapons and WPN … state 0/… -> 3 for the three emitters within one frame of the RESPAWN line
8 matchlog A produces FIRE and PROJ records after the respawn — with the caveat that both are hit records (mechweap.cpp:697-700 requires target != 0; PROJ is emitted at impact, mech4.cpp:1516), so aim at B
9 screen sunburst ring sweeping + fire-ready disc on all six panels; compare against scratchpad/ab_names_wpnL.png
10 matchlog no PLAYER_LINK … link=0 line

Diagnostic to ADD (the current logs cannot show weapon readiness — this is the gap that cost a day): today projweap.cpp:722-727 logs only the edge INTO state 7, and there is no log anywhere for leaving it, so "Reset never ran" and "Reset ran and the weapons recovered" produce byte-identical btl4.logs. Add:

  1. game/reconstructed/projweap.cpp:417 (immediately after weaponAlarm.SetLevel(3) in ResetToInitialState) and game/reconstructed/emitter.cpp:923 — the exit edge, as a matchlog WPN record carrying name, previous alarm state, new state, rechargeLevel, recoil/rechargeRate.
  2. game/reconstructed/mech4.cpp:1791 — the RESPAWN record with per-class counts (§Step 0). A count would have separated "sweep never called" from "sweep called, dispatch missed the class" on the night.
  3. game/reconstructed/btplayer.cpp:1143 — the PLAYER_LINK record. Keep all three unconditional (matchlog only arms on -net, matchlog.cpp:66-76, so solo stays silent).

Field follow-ups that need no code: (a) ask David whether he could walk during those 2 m 21 s — mech4.cpp:3282-3290 zeroes throttle/turn/fire while IsMechDestroyed(), so "I could not move" confirms the whole chain and re-reads his report as "I never respawned"; (b) the remaining un-explained non-resumer, 4:47/LAPTOP (174.20.49.23_matchlog_20260724_212633_2448.txt:99), did get PLAYER_DEAD and its zones were healed, yet fired nothing for 6 m 33 s on an all-energy chassis — that is either disengagement or a second bug; do not fold it into this fix, and do not cite the collision-vs-weapon split as established (3/3 vs 4-resume-1-not, n small, type=0 perfectly co-linear with one-shot overkill).


7. RISKS (concrete, from context/reconstruction-gotchas.md)

  1. Databinding trap / shadow field — BLOCKING. MechSubsystem::damageZone is declared ReconDamageZone * at mechsub.hpp:307, shadowing the engine Subsystem::damageZone (SUBSYSTM.h:158), and the proxy's offset-0 structureLevel aliases the live DamageZone's vtable pointer as a float. ClearStatus (mechsub.cpp:357) writes 0.0f there and DistributeCriticalHit (:429) writes 1.0f. Both are currently callerless — putting ClearStatus on the reset path without first re-pointing it at the engine zone's damageLevel (as GetSubsystemDamageLevel, mechsub.cpp:236, already does) stores 0x00000000 over a live vptr on every respawn. Sweep mechsub.cpp:291, 308, 315, 336, 357, 412, 414, 429 in the same change.
  2. Out-of-type write already shipping. mech4.cpp:1790's blind (MechSubsystem*) cast hits SubsystemMessageManager (0xBD3) and MechTech (0xBDC), both public Subsystem. RespawnRepair writes statusAlarm (a MechSubsystem member) and fires an AlarmIndicator::SetLevel watcher dispatch on whatever lives at that offset — twice per respawn, today. Step 2 deletes it; until then it is a live corruption risk, not a theoretical one.
  3. static_assert-lock the layouts you touch. statusAlarm @0x2C (mechsub.hpp:305), damageZone @0xE0 (:307, confirmed this pass: @004ac22c reads [ebx+0xe0]), Generator outputVoltage@0x1DC/ratedVoltage@0x1D8/stateAlarm@0x1FC, MissileLauncher missileCount@0x448/damageData.burstCount@0x3D4, AmmoBin initialAmmoCount@0x220/[0x228] — none of these has an offset lock today.
  4. /FORCE masking. ~13 new DeathReset overrides; a declared-but-undefined one links "successfully" and AVs at a garbage call target near __ImageBase. Grep the link log.
  5. Order of operations is load-bearing. Fix Generator::ResetToInitialState's body (Step 3) before wiring Generator::DeathReset (Step 4), or every respawn hard-bricks at 0 volts via Emitter::ReadyToDischarge (emitter.cpp:744-748).
  6. Death-side re-arm changes what a wreck looks like. Restoring DeathShutdown → DeathReset(0) makes death actually reset every subsystem (electrical alarm 0, temp → start, coolant full, zone damage 0, statusAlarm 0, ammoAlarm Loaded). That is authentic, but it will visibly change the wreck's gauge/crit read during the 5 s dead window and the replicated wreck on peers — check the CRITICAL page and the death warp (context/translocation-warp.md) after landing it.
  7. Do not remove the entity. Nothing in this plan may issue DestroyEntityMessage on death — the wreck stays (P5 teardown crash).
  8. Don't reconstruct dtor glue while touching the heat/power chain terminus; the trailing base-dtor calls run the chain twice.
  9. AmmoBin +0x228 is written to 0 by @004bd26c alongside feedTimer/cookOffArmed and is the object's last dword (sizeof == 0x22C); our ammobin.hpp:270 calls it reserved // unused. It is a live per-feed latch — identify it from AmmoBinSimulation @004bd394 / FeedAmmo @004bd4f4 before shipping the refill move, or copy the write blind and flag it [T3].
  10. Roster-order sensitivity is a non-risk. PoweredSubsystem::RTIS @004b0e6c gates the electrical restore on the source Generator's stateAlarm == 2, but Generator @004b215c sets level 2 unconditionally (0x4b2194) and the death sweep already ran DeathReset(0) over the whole roster with no index skip — so no authored segment order can produce a partial re-arm. Do not add ordering logic to Mech::Reset.