Files
BT412/context/combat-damage.md
T
arcattackandClaude Fable 5 c0fa6bf21a Death = the burning WRECK-HULK swap (effect 104) -- the authentic chain, reconstructed
The user was right: a dead mech turns into a pile of wreckage.  The authentic
1995 chain, recovered end-to-end:

  kill -> the victim's per-mech DEATH ModelList ('blhdead'/'lokdead'/'owndead'/
  'thrdead', .RES ids 22-25) -> its authored effects: 104 (the WRECK script) +
  1007 (dnboom big explosion) + 1001 (ddthsmk rubble smoke plume) + a damage-
  smoke burst (3/4/5/15).

Effect 104 = ExplosionScripts case 4 (part_008.c:2663, LIVE in the 1996 binary;
the "disabled" warning is case 6): loads the destroyed hulk + flamesml/flamebig
flame meshes with sweep flicker.  Every mech ships its hulk (BLHDBR/MADDBR/
LOKDBR/... + GENDBR generic); the 1996 script hardcoded thrdbr.bgf (dev
shortcut) -- we use the victim's own.

Reconstructed as BTL4VideoRenderer::SwapToWreck: hide every segment mesh, hang
"<prefix>dbr.bgf" on the tree root (pending-swap if death precedes tree build);
routed from the engine's ExplosionClassID dispatch (effect 104 ->
BTSwapMechToWreck(explosion->GetEntityHit())).  The kill now fires the
authentic 'blhdead' resource (manual 7+1 pfx calls removed -- the list carries
1007/1001 itself).  Verified live:
  [death] firing authentic death list 'blhdead' id=22
     ** effect_number = 104
  [BTrender] wreck swap: victim -> 'blhdbr.bgf'

Follow-ups noted: mesh flames + hulk settle (cosmetic), DeathSplash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:43:08 -05:00

258 lines
21 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.
---
id: combat-damage
title: "Combat & Damage — targeting, firing, damage zones, death"
status: established
source_sections: "PROGRESS_LOG.md §10c; docs/HARD_PROBLEMS.md (P5)"
related_topics: [subsystems, decomp-reference, reconstruction-gotchas, locomotion, rendering]
key_terms: [damage-zone, TakeDamage, viewpoint-entity, master, replicant]
open_questions:
- "Per-impact aim (cylinder lookup); collision damage application; DeathShutdown + collapse anim"
---
# Combat & Damage
The drive→target→fire→damage→destroy loop. Full detail: `docs/PROGRESS_LOG.md §10c`; the death/
teardown forensics: `docs/HARD_PROBLEMS.md` (P5).
## No AI — BT is PvP-only (verified, not a recovery gap)
The mission system is NETWORKED and BT never had NPCs/AI. Every mech is driven by a `BTPlayer` (or a
`BTCameraDirector` spectator). The BT registry knows only Mech/Projectile/Missile/BTPlayer/BTTeam; no
Bot/Brain/AI class exists; `BTL4OPT.EXE` has no AI strings. `Crusher/Blocker/Runner` are RED PLANET
classes, NOT BT. ⇒ a solo mission has no enemy by design; `BT_SPAWN_ENEMY` is a TEST DUMMY
(uncontrolled Mech). The authentic opponent path is [[multiplayer]]. [T1]
## Entity spawn + the per-mech gate
`Mech::Make(MakeMessage*)` (`FUN_004a2d48`) allocs `sizeof(Mech)` + runs the ctor; the base Entity
ctor self-registers it (renders + ticks). `Mech::PerformAndWatch` runs for EVERY mech → gate
player-only logic on `this == application->GetViewpointEntity()` (else a spawned enemy is driven by
player input). [T1]
## Targeting (mech offsets, [[decomp-reference]] §3)
`mech+0x37c` = target world Point3D; `mech+0x388` = target `Entity*` (the `HasActiveTarget()` gate);
`mech+0x38c` = targeted sub-zone (1=whole). Weapons cache hasTarget/targetPoint/muzzlePoint,
refreshed each frame. [T1]
## Firing arc + muzzles
There is NO firing-arc field in the binary (`Emitter::FireWeapon` fires whenever HasActiveTarget) —
the 1995 game got away with it because it was **cockpit-only** (a beam behind you is off-camera). The
port's external camera exposes it, so the arc is gated on the **torso twist range**
(`Torso::GetHorizontalReach`, 0 for the twist-disabled Blackhawk) + a base aim tolerance
(`BT_FIRE_ARC`, a labeled PORT presentation param). Beams fire from the mech's real gun-port SITE
segments (siterugunport etc.) resolved via `GetSegment(name)→segmentToEntity×localToWorld`. [T2]
## Weapon fire is a PORT reconstruction (the dpl_* beam layer is unported)
`Emitter::FireWeapon` builds a muzzle→target beam but the `dpl_*` beam renderable was never ported →
the beam is drawn by `BTPushBeam`/`BTDrawBeams` (L4VIDEO — an additive quad / the real `ermlaser.bgf`
tube with the scrolling `bexp` grit). Fire rate is decoded-exact (DischargeTime 0.2 + RechargeRate 2.2
per ER-M laser; [[decomp-reference]] §5). Flying projectiles (LRM/autocannon) are also a PORT
reconstruction (`BTPushProjectile` — the 2007 Entity is too small for the binary's raw integrator
offsets). [T2]
## Damage delivery + the real damage model
`Entity::TakeDamageMessage(id, size, inflictingEntityID, zone, Damage&)``target->Dispatch`.
**Base handler IGNORES zone==1** (`Entity::TakeDamageMessageHandler`, ENTITY.cpp:878 — returns on
`damageZone==1`; the message carries `invalidDamageZone = damageZone<0` + `damageData.impactPoint`).
The Mech override that resolves an unaimed hit's zone from the impact point (the **cylinder hit-location
model**, **STEP 6**) is now **RECONSTRUCTED + runtime-verified [T2]** — see "STEP 6 COMPLETE" below.
(Historical: it used to be unreconstructed, meanwhile aiming a valid zone.) **Investigated 2026-07 (durable):** the earlier-cited addresses `FUN_004a0230`/`FUN_0049ed0c` do
NOT exist — the real pieces are: the **CylinderDamageZoneTable** = a list of 0x30-byte cylinder entries
(entry ctor `FUN_0049e740`) loaded from resource type **0x1d** by the mech's cylinder-table NAME; the
table ctor is `FUN_0049ea48` (vtable 0x50bd84, alloc 0x2c, cached at `Mech[0x111]`=byte 0x444) — the recon
DOES build it (mislabeled `StandingAnimation`, mech.cpp:1221) but with an EMPTY name → 0 entries → the
lookup is a no-op. **Fully reversed (2026-07):** the table is a passive nested-list **height × angle grid** — 3 container
classes: TABLE (0x2c, ctor `FUN_0049ea48` / dtor `0x49eadc`, vtable 0x50bd84, `this[3]`=mech,
`this[4]`=row list, `this[10]`=row count) → ROWS by HEIGHT (0x30, ctor `FUN_0049e740` / dtor `0x49e814`,
vtable 0x50bd90, `this[3]`=RotateWithTorso flag, `this[4]`=torso (mech+0x438), `this[5]`=cell list,
`this[0xb]`=cell count, cell key `i·(2π/count)``_DAT_0049e810`=**6.2831855=2π**) → CELLS by ANGLE
(0x2c, ctor `FUN_0049deb0` / dtor `0x49df80`, vtable 0x50bd9c, `this[4]`=a refcounted zone-dict filled
by `FUN_0049e5e4`, count-prefixed {int→int} entries) → zone ref(s).
**⚠ CORRECTION (2026-07, verified from decomp): the lookup is NOT "entirely in the unexported handler"
— it is EXPORTED pseudocode [T1].** Two functions:
- `FUN_0049eb54` (@0049eb54, TABLE-level, `void(table, float* worldImpactPoint)` — Ghidra mistypes the
return; it tail-returns the found cell): (1) transform world impact → **mech-local** via
`FUN_00408bf8`+`FUN_00408440` reading the mech's transform at `mech+0xd0`; (2) **HEIGHT→row**:
`mechHeight = *(*(mech+0x2ec)+0xc)`; clamp `local.y` to `[0,mechHeight]`;
`rowIndex = clamp(floor(rowCount·y/mechHeight), 0, rowCount-1)`; `row = rowList.Find(&rowIndex)`
(int key, list method vtable+0xc); (3) **ANGLE**: if `|local.z|>eps && |local.x|>eps`,
`angle = atan2(local.z, local.x)` (`FUN_004dc8ec`=atan2, confirmed via `s_atan2`), wrap `<0 → +2π`;
else degenerate `angle = FUN_00408050()·2π`; then call `FUN_0049e678(row, angle)`.
- `FUN_0049e678` (@0049e678, ROW-level angle→cell): if `RotateWithTorso` (row+0xc), `angle += torso facing
(*(torso+0x1d8))` and wrap into `[0,2π)`; `cellIndex = floor(cellCount·angle/2π)`; reconstruct the exact
float key `(cellIndex+1)·2π/cellCount` (cells stored under `i·(2π/count)`, i=1..count);
`cell = cellList.Find(&key)` (float key, list method vtable+0xc).
Constants: `_DAT_0049e810 = _DAT_0049e72c = _DAT_0049ed08 = 2π`; `_DAT_0049e734 = 1/(2π)`;
`_DAT_0049e730 = _DAT_0049ed00 = 0.0`; `_DAT_0049ed04 = eps`; `DAT_004e0f80/84/88 = (0,0,0)`.
**Within-cell selection (fully recovered `FUN_0049de14` + disasm):** the cell → a **cumulative
hit-distribution**; `zone = FUN_0049de14(cell)` rolls `random()` (`FUN_00408050` = `(1/RAND_MAX)·engineRand`,
uniform [0,1)) and returns the **first entry whose threshold > roll** (entries sorted ascending) — classic
BattleTech dice hit-scatter. The tiny glue `FUN_0049ed0c(table,worldPt) = FUN_0049de14(FUN_0049eb54(...))`;
the real **`Mech::TakeDamageMessageHandler`** (disasm @0x4a037a, two call sites 0x4a038c/0x4a04b9) is:
`if (msg->invalidDamageZone /*msg+0x28*/) { msg->damageZone /*+0x24*/ = FUN_0049ed0c(mech[0x111]/*+0x444*/,
&msg->damageData.impactPoint /*+0x4c*/); msg->invalidDamageZone = 0; }` then the base
`damageZones[zone]->TakeDamage`.
**STREAM FORMAT — BYTE-VERIFIED against the real type-29 `.RES` bytes (EXACT consumption, 18 tables) [T1]:**
```
Table { i32 rowCount; Row[rowCount] }
Row { i32 rotateWithTorso; i32 cellCount; Cell[cellCount] }
Cell { i32 nameLen; char name[nameLen]; u8 0x00; i32 entryCount; Entry[entryCount] }
Entry { f32 cumulativeThreshold; i32 zoneIndex } // zoneIndex → damageZones[]
```
Real sample (`ava1`, 7 rows × 8 cells): rows 0-2 `rotateWithTorso=0` (feet/legs — fixed to chassis),
rows 3-6 `=1` (upper body — rotates with torso twist); a foot cell = `{0.5→16, 0.8→10, 1.0→5}`. The table
is found by the mech's **type-0x14 DamageZoneStream NAME** (`ResourceDescription::resourceName`) →
`ResourceFile::FindResourceDescription(name, DamageLookupTableStreamResourceType/*29*/)`.
So STEP 6 remaining = (6b) fix the name-load (current `ResourceFindByName` is a NO-OP template stub → empty
name → 0 rows); (6c) make the table functional + `ResolveHit→zone`; (6d) register the
`Mech::TakeDamageMessageHandler` override + verify. **Nothing is guessed — geometry, roll, handler, and byte
format are all verified.**
## The classes ALREADY EXIST — `game/reconstructed/dmgtable.cpp` (2026-07 discovery)
The three container classes are **already reconstructed** in `dmgtable.cpp`/`.hpp` (in the build):
`DamageLookupTable` (layers=height) → `PieSlice` (slices=angle, `SelectSlice`) → `DamageZonePercentTable`
(leaf, `SelectZone`=the roll, `ReadEntries`=stream parse). It **independently confirms this RE exactly**.
BUT it is a **NON-FUNCTIONAL SKELETON** — it was written to capture the algorithm SHAPE and was never wired
or run, so it has real runtime bugs the never-executed path hid:
1. Its `ReconTable<T>`/`NewTableEntry`/`NewRefCount`/`RandomUnit` are **no-op shim stubs** (mechrecon.hpp —
`Insert(){}`, `Lookup()→0`) → the table stays empty + lookups return null. Must be backed by REAL storage
(safe: `ReconTable` is used ONLY in dmgtable). Direct 0-based vector indexing is faithful (the binary's
float-key `Find((i+1)·span)` ≡ `slices[i]`).
2. `DamageZonePercentTable::ReadEntries` **skips the cell name-string** (`FUN_00402948` = `[i32 len][len+1
bytes: chars+NUL]`) that precedes the entry count → must read+discard it first or the parse misaligns.
3. `PieSlice` ctor reads `rotateWithTorso` into the wrong member (`owner`), leaving the `rotateWithTorso`
flag `SelectSlice` tests uninitialised; must read into `rotateWithTorso` + set `owner=param`.
4. `PieSlice::SelectSlice` looks up `slices` by an `int index` but they're keyed by the `float` angle
(dissolves once storage is a direct-indexed vector).
5. `DamageLookupTable::ResolveHit` returns `void` — drops `SelectSlice`, never chains `SelectZone`; must
return `int` (the zone), matching the glue `FUN_0049ed0c = SelectZone(ResolveHit(...))`.
6. `Mech::WorldToLocal` / `TorsoOrientationSource` are DECLARED (mech.hpp) but UNDEFINED (never linked
because dmgtable is never called); the height (`mech+0x2ec`=`[0xbb]`, the collision cylinder, `+0xc`=height)
+ torso heading (`torso+0x1d8`) need NAMED accessors (databinding trap — no raw offset reads).
So 6c = back the classes with real storage + fix bugs 2-5 + implement the 3 accessors; 6d = wire the mech
ctor (real name-load, replacing the mislabeled `StandingAnimation` stub @mech.cpp:1221) + the handler override.
## ✅ STEP 6 COMPLETE (2026-07-08) [T2]
Built + runtime-verified. Changes:
- **`dmgtable.cpp`/`.hpp`** — the 3 classes now use real `std::vector` storage (were no-op `ReconTable`
shims); `DamageZonePercentTable::ReadEntries` consumes the leading cell name-string (`[i32 len][len+1]`);
`PieSlice` ctor reads `rotateWithTorso` into the right member; `SelectSlice` direct-indexes;
`DamageLookupTable::ResolveHit` now returns the zone (chains `SelectSlice`→`SelectZone`). Stream type is
the real `MemoryStream` (`ReadBytes`).
- **`mech.cpp` ctor** — replaced the empty-name stub with the real load: `FindResourceDescription(dzRes->
resourceName, DamageLookupTableStreamResourceType/*0x1d*/)` → `DynamicMemoryStream` → `new DamageLookupTable`
cached in the **named member `Mech::damageLookupTable`** (binary `this[0x111]`/byte 0x444; was mislabeled
`ammoExpended` in the offset map); `~Mech` `delete`s it. ⚠ It was ORIGINALLY "cached" at `Wword(0x111)` —
the absorber bank — which stores NOTHING, so the whole unaimed path was inert until 2026-07-08 (the
"can't kill the enemy" bug). See [[reconstruction-gotchas]] §2. [T2]
- **`Mech::TakeDamageMessageHandler`** override registered (`MESSAGE_ENTRY(Mech, TakeDamage)`, overlays
Entity's by ID): on `invalidDamageZone`, `msg->damageZone = table->ResolveHit(msg->damageData.impactPoint)`,
clear the flag, then base-route. Aimed hits pass straight through.
- **Named accessors (no databinding-trap raw reads):** `Mech::WorldToLocal` (`localToWorld.MultiplyByInverse`),
`CylinderReferenceHeight` (`standingTemplateMaxY` = `collisionTemplate->maxY` = the binary `mech+0x2ec[+0xc]`),
`TorsoHeading` (via the `BTGetTorsoTwist` bridge in `torso.cpp` → `Torso::CurrentTwist` = torso+0x1d8;
torso.hpp can't be `#include`d into mech.cpp — subsystem-stub collision).
- **Runtime verify (2026-07-08, POST-Wword-fix — the earlier "verified" claim was PARTIAL: the handler
wiring was live but the table cache was inert):** with `BT_AUTOFIRE=1 BT_AUTODRIVE=0.5 BT_SPAWN_ENEMY=1`,
the full chain runs end-to-end: `[cyl] unaimed hit → zone N` resolves across **19 distinct zones**
(front-facing zones 0/6/17/15/4 dominate for head-on fire, as the geometry predicts), structure climbs
gradually per hit, the enemy dies after **14** hits (not 1) via the authentic cascade, 4+ zones reach
`DestroyedGraphicState`, and the destroyed segment meshes visibly swap (RemakeEntity). Env gates
`BT_CYL_LOG=1`, `BT_AUTOFIRE`/`BT_AUTODRIVE` (dev force-inputs, mech4.cpp). [T2]
- **Fire path now UNAIMED (2026-07-08):** both bring-up hit paths (the SHOT block + the projectile
impact, mech4.cpp) dispatch `zone=1` + a world impact point (beam entry point = enemy origin shifted
~3u toward the shooter at muzzle height; the projectile uses its own impact position) → the cylinder
resolves the struck EXTERIOR zone. The old code aimed the internal VITAL zone directly →
invisible 1-shot kills (internal zones are soft; the binary never aims them — they die only via the
destruction cascade `RecurseSegmentTable`/`SendSubsystemDamage`). On kill the wreck STAYS TARGETED
(`gEnemyMech` is NOT nulled) so beams keep terminating on it — nulling it made every later beam a
"free" ray that visibly passed through the wreck; scoring latches off via `gEnemyDestroyed`. [T2]
`Mech__DamageZone::TakeDamage` → engine armor model (`damageLevel
+= damageAmount*damageScale[type]`) → 1.0 = zone destroyed → death. The Mech ctor populates the
inherited `damageZones[]` from the DamageZoneStream (type 0x14) resource. `class Damage{ damageType
(Collision/Ballistic/Explosive/Laser/Energy), damageAmount, damageForce, surfaceNormal, impactPoint,
burstCount }`. Verified: `structure` climbs, vital zone destroyed → `*** TARGET DESTROYED ***`. [T2]
Two non-field fixes were needed: **message-handler chaining** (an unchained set drops TakeDamage
silently) + **entity validity** (`SetValidFlag()` on a manually-spawned entity) — see
[[reconstruction-gotchas]] §9. [T2]
## Death — the wreck STAYS (P5 CLOSED)
A killed mech does NOT disappear — BT death is a STATE transition
(`SetGraphicState(DestroyedGraphicState)` + death anim + effect/splash), the mech becomes a persistent
WRECK (RP analog: `VTV::DeathShutdown`). **NEVER issue `DestroyEntityMessage` on death** — the
teardown crash was an artifact of forcing a removal the original never does. The actual bug was ONE
thing: an EXPLICIT `JointedMover::~JointedMover()` in the reconstructed `~Mech` ran the whole base-dtor
chain TWICE (the dtor-epilogue rule, [[reconstruction-gotchas]] §7) → a double-free of `collisionLists`
+ the segment table. Removing it fixed BOTH the death-row crash AND app-exit crashes. [T2]
## Death SEQUENCE reconstruction — the map (2026-07-08)
Full decomp map of the death path. Two tiers:
**EXPORTED (reconstructable) [T1 read]:**
- `Mech::AdvanceLegAnimation` (`FUN_004a5028`) / `AdvanceBodyAnimation` (`FUN_004a5678`) — the **fall-anim
latch**: `movementMode` (mech+0x40) **5/6/7/8** = the four fall directions → animation-TABLE slots
**0x1c-0x1f** (`table[i]` at `mech+0x5cc+i*4`; `SetLegAnimation`/`SetBodyAnimation` =
`FUN_004a7fc4`/`FUN_004a800c` @part_012.c:13557/13570), one-shot via `deathAnimationLatched` (mech+0x650).
Already reconstructed (mech2.cpp:550-557/817-822).
✅ **RESOLVED (2026-07-08, task #32): the fall latch is a VESTIGE — BT 4.11 has NO collapse animation.**
[T1] Four convergent proofs: (1) the clip-table loader `FUN_004a80d4` (@part_012.c:13604) fills slots
0x00-0x1b + 0x20 (the `bmp` knockdown) by name and RETURNS — slots 0x1c-0x1f are never written; (2) the
offsets `mech+0x63c..0x648` appear in NO exported function at all; (3) BTL4.RES ships **27 clip codes
per mech** (`swr wwl wwr wsl wsr wrl wrr rrl rrr rwl rwr bmp bbl bbr bsl bsr sbl sbr ggl ggr gsl gsr
wgl wgr sqd squ trn` + `*i` variants) — no death/fall clip; (4) firing the latch would bind the
zero-initialised slot = **resource id 0 = a StaticAudioStream** as keyframes (garbage) — dead code in a
shipped arcade build. The authentic BT death modes are the FREEZE modes: `IsDestroyed()` ==
(`movementMode` 2 || 9), locomotion zeroed by `FUN_004ab1c8`. `UpdateDeathState` (mech4.cpp) settles
straight to 9 (never 5-8, which would trip the garbage latch).
- ✅ **The DEATH VISUAL is the WRECK-HULK SWAP, not a fall (2026-07-08) [T1].** The authentic chain:
death fires the victim's per-mech **death ModelList** (`blhdead`/`lokdead`/`owndead`/`thrdead` =
.RES ids 22-25; + `trkdead`/`bigdead`/`meddead` 32-34 for icons), whose authored video objects
dispatch (verified live from resource 22): **effect 104** = the burning-WRECK script + **1007**
(dnboom big explosion) + **1001** (ddthsmk rubble smoke plume) + a 3/4/5/15 damage-smoke burst.
The 1996 `ExplosionScripts` **case 4** (part_008.c:2663, LIVE — the "disabled" warning is case 6/
effect 106) loads the destroyed hulk + `flamesml/flamebig.bgf` flames with sweep-flicker and a slow
settle. Every mech ships its hulk (`BLHDBR/MADDBR/LOKDBR/VULDBR/AVADBR/OWNDBR/SNDDBR/THRDBR` +
`GENDBR` generic + `LDBR/MDBR/RAPDBR/STIDBR`); the 1996 script hardcodes `thrdbr.bgf` (dev
shortcut). **RECONSTRUCTED**: `BTL4VideoRenderer::SwapToWreck` (btl4vid.cpp) — kill →
`blhdead` Explosion → effect 104 → hide every segment mesh + hang the victim's own
`<prefix>dbr.bgf` on the tree root (gendbr fallback; pending-swap if death precedes tree build).
Mesh flames + the settle motion are noted follow-ups; burning reads via 1001/1007 + the wreck-smoke
re-arm. Verified: `[BTrender] wreck swap: victim -> 'blhdbr.bgf'`. [T2]
- `Mech::IsDestroyed()` (`FUN_0049fb54`) = `movementMode==2||9` (reconstructed as `IsDisabled`, btstubs.cpp);
`FUN_004ab1c8` freezes locomotion (zeros mech+0x298) when destroyed.
- **`MechDeathHandler`** (ctor `FUN_0042a984` + Performance `FUN_0042aa2c`, cached mech+0x850 / `mech[0x214]`) —
the **per-subsystem destroyed-skin + explosion engine**. Each tick walks the damage subsystems
(mech+0x120[mech+0x11c]); as a subsystem's damage crosses a descriptor-table threshold (`FUN_0042a5f4`
detects the crossing, `FUN_0042a664` looks up the state) it applies the `GraphicState` ("Destroyed" = enum 1,
parsed @part_003.c:9099) to the subsystem graphic (`sub+0xd4`) AND dispatches an effect-creation message
(`FUN_0043663c`→`FUN_004364e4` to the effect mgr `app+0x38`). **Fully exported but a no-op STUB** in the
recon (mech.cpp:221). Gamedata `DeathSplashDamage`/`Radius`/`Effect` load into a descriptor @ +0x74/78/7c
(`FUN_004a2da8`). Destroyed-skin variant set (`destroyed`, `destroyeds`…`destroyedd`) @part_003.c:5720.
**NOT EXPORTED (master-perf gap 0x4a9770-0x4ab188 + the un-exported TakeDamage) [T4]:**
- The `movementMode` WRITER (sets 5-8 then 2/9 on death) + the fall-direction (5/6/7/8) selection.
- The `Mech::DeathShutdown` roster loop (RP `VTV::DeathShutdown` analog — loop subsystems calling the
`Subsystem::DeathShutdown(int)` vtable slot; the base is an empty virtual, SUBSYSTM.h:166).
- The whole-mech `DeathEffect`/`DeathSplash` radius dispatch + the `graphicAlarm.SetLevel(9)` call site (the
recon already raises the alarm in mechdmg.cpp:426/586).
**The problem:** the orchestration lives in the per-frame Simulate that the **bring-up drive override
bypasses** (btstubs.cpp:116-119), so the death consumer must be reconstructed from its exported consumers +
the RP analog and wired into the active path (mech4.cpp `PerformAndWatch`).
### ✅ Death-STATE core DONE (2026-07-08) [T2]
`Mech::UpdateDeathState()` + `IsMechDestroyed()` (mech4.cpp) reconstruct the un-exported death branch from
its exported consumers + the RP analog, called for EVERY mech early in `PerformAndWatch`: on `graphicAlarm
>= 9` it (1) sets `movementMode = 5` → the collapse clip's latch in `AdvanceBodyAnimation` (fall direction
5-8 un-exported → 5 [T3]); (2) loops the roster calling `Subsystem::DeathShutdown(1)` (RP `VTV::DeathShutdown`
analog; base is a no-op virtual, a SHUTDOWN not teardown → safe, wreck STAYS); (3) next frame settles to
`movementMode = 9` → `IsDisabled` → locomotion frozen. The drive's lone `movementMode = 1` write (mech4.cpp
~1309) is guarded on `!IsMechDestroyed` so the death state isn't clobbered. **Runtime-verified** (forced kill):
`[death] … collapse + subsystem shutdown` → `[death] … settled -> disabled (IsDisabled=1, frozen wreck)`,
mech frozen in place (pos unchanged), subsystem tick + renderer keep running, **no crash, wreck stays**.
Env gate `BT_DEATH_LOG=1`.
**Deferred (honest):** (a) the visible **collapse ANIMATION** may not latch through the active path — the
gait SM showed `state=0` post-death (the clip trigger via `AdvanceBodyAnimation` needs the death `movementMode`
to reach its latch; and spawned NON-player mechs don't advance their body anim at all). (b) **`MechDeathHandler`**
(the exported per-subsystem destroyed-skin + explosion engine, `FUN_0042a984`/`FUN_0042aa2c`) is still the
no-op stub → no death explosions/skin-swaps yet. (c) the whole-mech **DeathSplash** radius damage (un-exported).
## Key Relationships
- Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]].
- P5 forensics: `docs/HARD_PROBLEMS.md`. Data: [[decomp-reference]] §4-5.