Respawn re-arm ROOT-CAUSED: David's death transition never started (#54) -- plan doc only
9-agent read-only investigation, every binary claim re-verified with capstone. VERDICT: respawn re-arm WORKS in 4.11.524 -- 21 own-deaths in tonight's matchlogs each have a 1:1 PLAYER_DEAD, except David's (1 death, 0 PLAYER_DEAD). So #22's fix is holding and is NOT implicated (my earlier claim retracted). His cycle never STARTED: PLAYER_DEAD is written unconditionally at btplayer.cpp:422 in the same straight-line block as ++deathCount, so its absence proves VehicleDeadMessageHandler never entered the deathCount==-1 branch -> no drop-zone hunt -> no DropZoneReply -> Mech::Reset never ran. Corroborated: a zone reads lvl=1.0000 11.18s AFTER death, which Reset would have healed. With no Reset movementMode stays 9, and ONE predicate -- Mech::IsDisabled() (mode 2||9) -- darkens all six panels via two authentic gates (emitter.cpp:431 currentLevel=0; projweap.cpp:707 recoil=rechargeRate + alarm 7). Ring and ready-disc are the SAME scalar, so six dark panels is one data fault. SEPARATE second bug: his AFC 100 went silent 123s BEFORE death holding 13 rounds, and one LRM 66s before while its twin kept firing -- that is the #21 family, untouched by this. Port divergences found on the way: mech4.cpp:1928 calls DeathShutdown(1) but the binary sweeps with 0 and forwards to DeathReset -- and our DeathShutdown is an empty base nobody overrides, so the whole death sweep is a no-op; only 7 of ~20 subsystem classes implement DeathReset; HeatSink::ResetToInitialState clobbers the startingTemperature it just wrote and stops short of the authentic terminus @004ac22c (which heals each subsystem's own crit zone + status alarm). UNKNOWN: why the -1 branch never ran. One survivor after static elimination (GetPlayerLink()==0 on his own master mech); section 6 'Check 0' settles it with no code change. Do that first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
This commit is contained in:
co-authored by
Claude Opus 5
parent
b517c4cb30
commit
326023812a
@@ -83,7 +83,7 @@ precise than anything you can infer.
|
||||
| Term / acronym definitions | `reference/glossary.yaml` |
|
||||
| The ORIGINAL 1995 player manual (controls, per-mech stats, coolant loops) | `reference/manual/Tesla40_BT_manual.pdf` (+ alignment audit in `context/pod-hardware.md` §Manual) |
|
||||
| The complete verbatim detail (fallback) | `docs/PROGRESS_LOG.md` (the old 2236-line CLAUDE.md) |
|
||||
| Detailed running ledgers | `docs/RECONCILE.md`, `docs/GAUGE_COMPOSITE.md`, `docs/HARD_PROBLEMS.md`, `docs/SUBSYS_PLAN.md`, `docs/P3_LOCOMOTION.md`, `docs/RESOURCE_AUDIT.md`, `docs/VEHICLE_SUBSYSTEMS.md`, `docs/BGF_FORMAT.md`, `docs/ASSET_PIPELINE.md`, `docs/BT_SOURCE_STATUS.md`, `docs/WAVE_PLAN.md`, `docs/GLASS_COCKPIT.md`, `docs/REVOLVING_DOOR_PLAN.md`, `docs/INPUT_PATH_AUDIT.md` |
|
||||
| Detailed running ledgers | `docs/RECONCILE.md`, `docs/GAUGE_COMPOSITE.md`, `docs/HARD_PROBLEMS.md`, `docs/SUBSYS_PLAN.md`, `docs/P3_LOCOMOTION.md`, `docs/RESOURCE_AUDIT.md`, `docs/VEHICLE_SUBSYSTEMS.md`, `docs/BGF_FORMAT.md`, `docs/ASSET_PIPELINE.md`, `docs/BT_SOURCE_STATUS.md`, `docs/WAVE_PLAN.md`, `docs/GLASS_COCKPIT.md`, `docs/REVOLVING_DOOR_PLAN.md`, `docs/INPUT_PATH_AUDIT.md`, `docs/RESPAWN_REARM_PLAN.md` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
# 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:**
|
||||
|
||||
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" | **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`.
|
||||
Reference in New Issue
Block a user