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

257 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 = (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 @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:**
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 it***everything 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-1092`** — `Generator::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 volts**`Emitter::ReadyToDischarge` (`emitter.cpp:744-748`) gates charging on `source->MeasuredVoltage()` and `GeneratorStateOf()==2` [T1].
6. **`mechsub.cpp:330-348`** — `MechSubsystem::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:412`** — `MissileLauncher::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::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 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.log`s. 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`.