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>
36 KiB
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 DropZoneReply ⇒ Mech::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-1763 → mechdmg.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 predicate — Mech::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 = (rate−rate)/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 @004b215c → HeatSink @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 @004b96ec → PoweredSubsystem @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 @004bcc3c → damageData.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:
mech4.cpp:1928-1932— callsDeathShutdown(**1**); the binary calls the sweep with 0, and its base body forwards toDeathReset. OurSubsystem::DeathShutdown(int){}is empty (engine/MUNGA/SUBSYSTM.h:165-167) and no port class overrides it ⇒ everything the binary does at death is skipped [T2].mech4.cpp:1789— the respawn sweep is structurally right (i=2, arg forwarded) but only 7 classes declareDeathReset: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].- The chain never reaches the authentic terminus.
HeatSink::ResetToInitialState(heat.cpp:636-650) tail-callsHeatableSubsystem::ResetToInitialState(heat.cpp:176-180, a HEAT.TCP shard body:currentTemperature = 300; heatLoad = 0) — which clobbers thestartingTemperaturethe 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. mech4.cpp:1790—((MechSubsystem*)s)->RespawnRepair()is applied blindly to every roster slot, butSubsystemMessageManager(messmgr.hpp:78, 0xBD3, factorymech.cpp:1337) andMechTech(mechtech.hpp:127, 0xBDC,mech.cpp:1364) arepublic Subsystem, notMechSubsystem.statusAlarmis declared inMechSubsystem(mechsub.hpp:305) — so this is an out-of-type write plus a garbageAlarmIndicator::SetLevelwatcher dispatch, twice per respawn [T1]. Textbook databinding trap.powersub.cpp:1088-1092—Generator::ResetToInitialStatesetsoutputVoltage = 0.0f, transcribed from the staleGNRATOR.TCP. The binary setsoutputVoltage = ratedVoltage(0x4b219f: mov eax,[ebx+0x1d8]; mov [ebx+0x1dc],eax). Harmless only because nothing dispatches it. WiringGenerator::DeathResetbefore fixing this body bricks every respawn at 0 volts —Emitter::ReadyToDischarge(emitter.cpp:744-748) gates charging onsource->MeasuredVoltage()andGeneratorStateOf()==2[T1].mechsub.cpp:330-348—MechSubsystem::ResetToInitialState(Logical)is labelled "reset" but is a transcription of@004ac1d4=Subsystem::GenerateFault(int)(SUBSYSTM.h:146), whose!poweredbranch setsdamageZone->structureLevel = 1.0f+statusAlarm.SetLevel(1)= destroys the subsystem. The real root reset@004ac22csits atmechsub.cpp:355under the nameClearStatus(), on no path.mislanch.cpp:412—MissileLauncher::DeathResetcallsProjectileWeapon::ResetToInitialState()and skipsburstCount = missileCount; there is noMissileLauncher::ResetToInitialStatein the tree.ammobin.cpp:390-396— comment "ResetToInitialState … authentically EMPTY per AMMOBIN.TCP" is WRONG:@004bd26crefills, arg-gated. The refill lives in a port-inventedDeathReset(:378-388) that ignores the arg.emitter.cpp:918-932— omitsseekRate@0x45C = 0; writestargetLocalFlag@0x470, which@004ba4d0never touches.btplayer.cpp:1237-1244— a non-authenticIsMechDestroyed()guard the binary does not have, and a bareelse { return; }at :1246 where the binary retries via@004c012c. Either exit skipsMech::Resetentirely.
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::RTIS→PoweredSub→HeatSink::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 ofInitializePlayerLink) —BTMatchLog("PLAYER_LINK", "player=… vehicle=… link=%p").link=0proves the race in one line.mech4.cpp:1791(sweep tail) — promote theBT_DEATH_LOGline at :1814 to an unconditionalBTMatchLog("RESPAWN", "mech=… zones=%d subsys=%d weapons=%d bins=%d alive=%d"). A respawn is currently invisible in the matchlog (VEHICLEis only logged on theCreatePlayerVehiclepath,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:675Mech::PlayerLinkMessageHandler— afterEntity::PlayerLinkMessageHandler(message), ifGetPlayerLink() == 0, log loudly and resolve the same object the second way the port already maintains: the reverse link (Player::playerVehicle == this), reachable fromapplication->GetMissionPlayer()for a local master. Do it in this TU (completeMech+Playertypes) — 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@004bffd0respawn branch uses (player+0x1FC).btplayer.cpp:1219— also cleardeathPending = 0alongsidedeathCount = 0in the first-spawn branch. Removes a whole class of "one death and you're a permanent wreck".btplayer.cpp:1237-1244— delete the non-authenticIsMechDestroyed()guard; keep the binary's two checks (msg->deathCount == deathCount,state != MissionEnding). Route theelseat :1246 to a retry, not a bare return. (The binary's@004c012ctwo-passDropZoneAcquiredStatelatch cannot be restored as-is: ourSet_Alarm_Levelis a no-op stub (btstubs.cpp:87), so the port has no player simulation-state pulse at all. That is why the T0 base'sGetSimulationState() != DropZoneAcquiredStategate (PLAYER.cpp:229) currently always passes and the loop terminates onmech_deadinstead. 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— giveDeathShutdown(int c)the authentic base body{ DeathReset(c); }(@004ad10e, verified).mech4.cpp:1928-1932— pass 0, keepi = 0..count-1(matches@0049fe0cexactly, including no index skip).- Make every existing
DeathResetoverride propagate the arg instead of relying onpowered = Truedefaults (mechweap.cpp:676,projweap.cpp:403,emitter.cpp:909,mislanch.cpp:412,ammobin.cpp:378,sensor.cpp:347). Every binary body forwards it (verified at0x4b96f3,0x4bbaff,0x4bcc4e,0x4bd271,0x4ad7c3,0x4b2164). - Rename and re-point the two mislabeled bodies in
mechsub.cpp::330ResetToInitialState(Logical)→GenerateFault(int)(it is@004ac1d4, engine slot 8,SUBSYSTM.h:146);:355ClearStatus()→ the root resetDeathReset(int)(it is@004ac22c). Before doing so, re-point its writes at the engineDamageZone(damageLevel, asGetSubsystemDamageLevelalready does atmechsub.cpp:236) — today it writes0.0f/1.0fthrough theReconDamageZoneproxy whose offset-0structureLevelaliases the live zone's vtable pointer (mechrecon.hpp:326-333, warning atmechsub.hpp:257-263). Wiring it unfixed stores0x3F800000over a vptr. heat.cpp:636-650— terminateHeatSink::ResetToInitialStateat the new root reset (@004ad760tail-calls@004ac22c— verified at0x4ad7c8), and deleteHeatableSubsystem::ResetToInitialState(heat.cpp:176-180); it is a stale-shard invention that clobberscurrentTemperature. Pre-flight check: readHeatableSubsystem's vtable slot 10 (+0x28) — if it is@004ac22c, the class genuinely has no override and the deletion is correct.- Delete
MechSubsystem::RespawnRepairand the cast atmech4.cpp:1790. Once the chain terminates at@004ac22c, its work is authentic and universal — and the out-of-type write onSubsystemMessageManager/MechTechdisappears with it.
Step 3 — fix the bodies before extending coverage
powersub.cpp:1088Generator::ResetToInitialState:HeatSink::RTIS(arg);generatorOn = 1;startTimer = 0;stateAlarm.SetLevel(0)thenSetLevel(2)(a forced change, so watchers re-latch);outputVoltage = ratedVoltage;coolantAvailable = 1;coolantFlowScale = 1.0f;startTimer = startTime. (@004b215c, disassembled above.)mislanch.cpp— addMissileLauncher::ResetToInitialState:damageData.burstCount = missileCount;thenProjectileWeapon::ResetToInitialState()(@004bcc3c).ammobin.cpp:390— move the refill intoResetToInitialState(int active)with the authentic arg gate, delete the "authentically EMPTY per AMMOBIN.TCP" comment, add the missing[0x228] = 0, and keepammoAlarm.SetLevel(Loaded)unconditional (@004bd26c).emitter.cpp:918-932— addseekRate = 0; drop thetargetLocalFlag@0x470write.
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
DeathShutdownforwards toDeathReset, keeping both the forwarding base and any per-classDeathShutdownoverride runs the reset twice per death. OverrideDeathShutdownnowhere. - Resetting on the wrong side of the arg:
AmmoBinmust not refill at death (arg == 0); the wreck keeps its counts. Our currentDeathResetignores the arg — after Step 2 that would refill the corpse. - The arcade deliberately keeps:
Emitter::seekVoltageIndex@0x3F0(the pilot's seek gear —@004ba4d0provably does not touch it; the suggestion to reset it toseekVoltageRecommendedIndexwould be a stand-in),deathCount/score, and themode == 0variant of Reset (mission-review park: ammo not refilled,PoweredSubsystemtakes theSetLevel(0)branch). - The arcade deliberately does NOT keep: damage (every mech zone healed + every subsystem's private zone healed at
@004ac22c) or heat (@004ad760restoresstartingTemperatureand refills coolant;Condenser @004ae534restores 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:
game/reconstructed/projweap.cpp:417(immediately afterweaponAlarm.SetLevel(3)inResetToInitialState) andgame/reconstructed/emitter.cpp:923— the exit edge, as a matchlogWPNrecord carrying name, previous alarm state, new state,rechargeLevel,recoil/rechargeRate.game/reconstructed/mech4.cpp:1791— theRESPAWNrecord with per-class counts (§Step 0). A count would have separated "sweep never called" from "sweep called, dispatch missed the class" on the night.game/reconstructed/btplayer.cpp:1143— thePLAYER_LINKrecord. 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)
- Databinding trap / shadow field — BLOCKING.
MechSubsystem::damageZoneis declaredReconDamageZone *atmechsub.hpp:307, shadowing the engineSubsystem::damageZone(SUBSYSTM.h:158), and the proxy's offset-0structureLevelaliases the liveDamageZone's vtable pointer as a float.ClearStatus(mechsub.cpp:357) writes0.0fthere andDistributeCriticalHit(:429) writes1.0f. Both are currently callerless — puttingClearStatuson the reset path without first re-pointing it at the engine zone'sdamageLevel(asGetSubsystemDamageLevel,mechsub.cpp:236, already does) stores0x00000000over a live vptr on every respawn. Sweepmechsub.cpp:291, 308, 315, 336, 357, 412, 414, 429in the same change. - Out-of-type write already shipping.
mech4.cpp:1790's blind(MechSubsystem*)cast hitsSubsystemMessageManager(0xBD3) andMechTech(0xBDC), bothpublic Subsystem.RespawnRepairwritesstatusAlarm(aMechSubsystemmember) and fires anAlarmIndicator::SetLevelwatcher 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. static_assert-lock the layouts you touch.statusAlarm @0x2C(mechsub.hpp:305),damageZone @0xE0(:307, confirmed this pass:@004ac22creads[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./FORCEmasking. ~13 newDeathResetoverrides; a declared-but-undefined one links "successfully" and AVs at a garbage call target near__ImageBase. Grep the link log.- Order of operations is load-bearing. Fix
Generator::ResetToInitialState's body (Step 3) before wiringGenerator::DeathReset(Step 4), or every respawn hard-bricks at 0 volts viaEmitter::ReadyToDischarge(emitter.cpp:744-748). - 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,statusAlarm0,ammoAlarmLoaded). 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. - Do not remove the entity. Nothing in this plan may issue
DestroyEntityMessageon death — the wreck stays (P5 teardown crash). - Don't reconstruct dtor glue while touching the heat/power chain terminus; the trailing base-dtor calls run the chain twice.
AmmoBin +0x228is written to 0 by@004bd26calongsidefeedTimer/cookOffArmedand is the object's last dword (sizeof == 0x22C); ourammobin.hpp:270calls itreserved // unused. It is a live per-feed latch — identify it fromAmmoBinSimulation @004bd394/FeedAmmo @004bd4f4before shipping the refill move, or copy the write blind and flag it [T3].- Roster-order sensitivity is a non-risk.
PoweredSubsystem::RTIS @004b0e6cgates the electrical restore on the source Generator'sstateAlarm == 2, butGenerator @004b215csets level 2 unconditionally (0x4b2194) and the death sweep already ranDeathReset(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 toMech::Reset.