--- 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, translocation-warp, multiplayer] 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] **The AUTHENTIC acquisition model (recovered 2026-07-08; corrected 2026-07-08, task #39):** THERE IS NO FREE-AIM MOUSE CURSOR. The pod had a stick whose yaw drove the **torso twist** (`MechControlsMapper` writes `HUD::SetFreeAimSlew(stick_x)` → cockpit+0x28C, gated on the torso's horizontal-twist being enabled — `mechmppr.cpp:735/762`, hud.hpp:167 [T1]); the crosshair marks where the **torso guns boresight** relative to the body-mounted view. You aim by **steering the mech / twisting the torso**, not by moving a cursor. The engine `Reticle` struct (`engine/MUNGA/RETICLE.h`) carries `reticlePosition`, `pickPointingOn`, `rayIntersection`, `targetEntity`, `targetDamageZone` — a general struct shared with Red Planet — but in BT `reticlePosition`/HotBoxVector (@mech-HUD+0x1FC) is **COMPUTED by HudSimulation** from the mech pose quaternion (`FUN_0040954c` quat→euler of mech+0x10C) + the target geometry, and **zeroed to centre when there is no target** (part_013.c:5680, `FUN_00408440(...,&DAT_004e0f74)` [T1]) — it is NOT a free-floating cursor. The pick then hits whatever the guns point at; that entity + its DAMAGE ZONE become the target (`0x37c` rayIntersection, `0x388` targetEntity, `0x38c` targetDamageZone → aimed-zone damage). Fixed-torso mechs (the BLH: `TorsoHorizontalEnabled=0`) boresight dead-ahead — the crosshair sits centred and you point the whole mech. Once locked, `Emitter::FireWeapon` converges with NO aim/arc test (part_013.c:7758). `MechWeapon:: UpdateTargetState` (`FUN_004b9bdc` [T1]): `targetWithinRange = dist < (1 − hostZoneDamage) × weaponRange`. The 0x388 WRITER (the per-frame reticle→mech copy) is in an un-exported decomp gap. **Port status: RECONSTRUCTED + runtime-verified (tasks #36/#39, 2026-07-08) [T2].** The chain: the crosshair = **torso boresight** (`BTTwistToReticleX(torsoTwist)`, L4VIDEO — tan(twist) projected through the live per-axis projection; **0 = dead-centre on the fixed-torso BLH**; `BT_AIM="x y"` pins it for headless tests) → `BTGetAimRay` (the ACTIVE eye publishes pos+LookAtRH basis via `BTSetAimCamera`, the render loop publishes proj._11/_22 + backbuffer dims) → `Mech::PickRayHit` (world ray → local frame → `BoundingBox::HitBy` slab test vs the collision template's ExtentBox → world hit point). **THE WORLD-PICK TARGET MODEL (task #41, the reconciliation — binary + pod-video evidence).** The target slot `mech+0x388` holds **whatever entity is downrange of the torso boresight** — a mech OR a WORLD entity (terrain/mesa/building). Evidence: (a) the weapon fire path is DOUBLY gated on `0x388 != 0` (`FUN_004baa88` :7689 AND `FUN_004bace8` :7727 wrap everything incl. `beamFlag`=+0x46c :7749 — no target ⇒ no discharge [T1]); (b) pod demo VIDEOS show lasers firing with no mech targeted — reconciled by (c): HudSimulation :5620 **explicitly handles a target WITHOUT damage zones** (`target->0x120 == 0`) — dead code unless non-mech entities get targeted; and (d) `0x388` has **11 reads / ZERO direct stores** across CODE (capstone scan) — the pick is written indirectly, AUTOMATICALLY, every frame; never a manual player lock. So: **fire at the enemy under your boresight → aimed zone damage; fire anywhere else → the beam hits the scenery downrange ("firing at nothing"); only a true sky shot (nothing within range) refuses to discharge.** The spinning-ring **LOCK** (HudSimulation 5619-5634) is a SEPARATE, stricter state (mech target only + host-zone < 0.75 damage + targeted-zone < 1.0). **Port implementation:** the boresight ray tests the enemy's collision box (`PickRayHit` → aimed hull point → STEP-6 zone under the boresight + hotbox + lock ring), else the terrain (`BTGroundRayHit` to 1200 → `gBTTerrainEntity` + the ground point — beam/missiles fly to the scenery, NO damage dispatch; the range caret authentically reads the ground distance, :5639), else no target (sky — the double gate refuses). Verified all three states (`BT_AIM` 0,0 / 0.6,0.5 / 0,−0.8). (⚠ This is the FOURTH targeting model iteration; the first three — sticky lock, mouse cursor, enemy auto-converge — all over-read the RP-shared `Reticle` struct or under-read the pick. The world-pick model is the one that fits ALL the evidence.) The HUD draws the aim group at the boresight ([0x9a] translate), the designator hotbox at the mech target's projected point (subB9 hot / subB8 designated, `BTProjectHotBox`/`BTProjectToReticle`), and the edge arrows when off-screen/behind. `BT_FIRE_ARC` is now an EXPLICIT OPT-IN presentation clamp (unset = authentic no-arc). The old hardwired lock, the ±30°-default cone, and the projectile path's gEnemyMech fallback are all REMOVED. LMB fires lasers / RMB missiles (with SPACE/CTRL). ⚠ A view-selection bug was fixed en route: every renderable rebuild stomped `mCamera` back to the chase eye (btl4vid `mViewInside` now persists the chosen view — the aim camera feed and the V toggle both depend on it). ## 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`/`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): the handler FIRST feeds the raw `Damage` to the Gyroscope hit-bounce (`GyroApplyDamage`, @0x4a0264 hub, task #56) before zone resolution; then 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` — until the burial transition drops the lock (`gEnemyMech = 0` when the buried wreck goes INERT, mech4.cpp). [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 `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] ⚠ **The death-effects DISPATCH lives at the VICTIM's death transition** (`UpdateDeathState`'s movementMode→9 moment, task #42) — NOT in any shooter's fire path. It originally sat in the laser fire block's kill check, which a MISSILE killing blow (landing frames later in `BTUpdateProjectiles`) never reached — and post-#41 the boresight pick skips a dead mech, so the block never re-ran: internally dead, smoking, standing, invulnerable (user-reported). The transition fires once for ANY kill source (laser/missile/collision); the killer id for the Explosion message comes from `lastInflictingID` (the task-#31 bookkeeping). Verified: missile kill → full chain (blhdead → wreck swap → smoke → sink → burial → INERT). [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; was a no-op stub (mech.cpp:221) — now fully RECONSTRUCTED (mechdmg.cpp — ctor @0042a984 / Tick @0042aa2c; wired in the Mech ctor, mech.cpp:1469) [T2]. 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) settles the death mode STRAIGHT to `movementMode = 9` (mech4.cpp:1183) — there is NO `movementMode = 5` collapse write (the only other writes are the drive's `= 1`, mech4.cpp:1044/1986); the fall latch 5-8 is never fired (task #32: no collapse anim exists in BT 4.11, see the VESTIGE proof above) [T1]; (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) `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) ✅ RESOLVED (task #32) — there IS no collapse animation to latch: the fall latch is a VESTIGE (see the proof above); the authentic death visual is the wreck-hulk swap, and the death mode settles straight to 9. (b) ✅ RESOLVED — **`MechDeathHandler`** is fully RECONSTRUCTED (mechdmg.cpp — ctor @0042a984 / Tick @0042aa2c; wired in the Mech ctor, mech.cpp:1469) [T2]; destroyed-skin swaps + zone explosions verified live (see STEP 6 above). (c) the whole-mech **DeathSplash** radius damage (un-exported). ### ⚠ Death-latch correction (2026-07-09, task #52) [T1→T2] `IsMechDestroyed()` originally tested **`graphicAlarm >= 9` alone** — a bring-up shortcut that made a wreck **resurrectable**: `graphicAlarm` is a STATUS indicator, and a later leg hit on the wreck legitimately rewrites it to 4/3 (`SetDamageState`, mechdmg.cpp:417/419) → `IsMechDestroyed()` flipped false → the drive's guarded `movementMode = 1` write revived the gait → the NEXT vital hit re-ran the ENTIRE death transition (double kill-score, double `VehicleDead(-1)`; during the respawn window the re-entered `BTPostKillScore` → `Player::ScoreMessageHandler`'s `Check(playerVehicle)` **abort()** — engine MUNGA TUs compile with `Check` ACTIVE; caught live under cdb, 2-node force-damage). The AUTHENTIC latch is the death `movementMode`: `IsDestroyed == (movementMode 2 || 9)` (`FUN_0049fb54`/`FUN_004ab1c8`) — the death modes never revert. Fix: `IsMechDestroyed()` = `movementMode 2||9` (latch) **OR** `graphicAlarm >= 9` (the vital-kill TRIGGER that first enters the transition). Lesson: **a gauge/status alarm is never a state latch** — later writers rewrite it; latch on the state machine's own mode. ## Key Relationships - Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]]. - P5 forensics: `docs/HARD_PROBLEMS.md`. Data: [[decomp-reference]] §4-5.