Files
BT412/context/combat-damage.md
T
arcattackandClaude Fable 5 305c928923 Death effects dispatch at the victim's death transition (task #42)
User report: enemy takes hits, starts smoking, then beams pass through
-- no more damage, no death. Root cause: the death-effects dispatch
(blhdead explosion chain + wreck swap + KILL score) lived in the
SHOOTER's laser fire block kill check. A missile killing blow lands
frames later in BTUpdateProjectiles -- outside that block -- and after
task #41 the boresight pick skips a dead mech, so the fire block never
ran against it again: the death chain was silently skipped, leaving an
internally-dead, smoking, standing, invulnerable mech.

Fix: the dispatch moved to the VICTIM's own once-per-death transition
(UpdateDeathState's movementMode->9 moment) -- fires exactly once for
ANY kill source (laser, missile, collision). Killer id for the
Explosion message = lastInflictingID (the task-#31 bookkeeping). The
BT_ENABLE_TEARDOWN cdb harness was removed with the old block (findings
preserved in docs/HARD_PROBLEMS.md).

Verified headless (75s soak, missile-heavy kill): destroyed ->
'blhdead' id=22 -> wreck swap 'blhdbr.bgf' -> smoke re-arm -> sink ->
buried INERT; post-death picks fall through to terrain; 0 crashes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 00:21:03 -05:00

27 KiB
Raw Blame History

id, title, status, source_sections, related_topics, key_terms, open_questions
id title status source_sections related_topics key_terms open_questions
combat-damage Combat & Damage — targeting, firing, damage zones, death established PROGRESS_LOG.md §10c; docs/HARD_PROBLEMS.md (P5)
subsystems
decomp-reference
reconstruction-gotchas
locomotion
rendering
damage-zone
TakeDamage
viewpoint-entity
master
replicant
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<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 SelectSliceSelectZone). Stream type is the real MemoryStream (ReadBytes).
  • mech.cpp ctor — replaced the empty-name stub with the real load: FindResourceDescription(dzRes-> resourceName, DamageLookupTableStreamResourceType/*0x1d*/)DynamicMemoryStreamnew DamageLookupTable cached in the named member Mech::damageLookupTable (binary this[0x111]/byte 0x444; was mislabeled ammoExpended in the offset map); ~Mech deletes 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.cppTorso::CurrentTwist = torso+0x1d8; torso.hpp can't be #included 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] ⚠ 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_0043663cFUN_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, destroyedsdestroyedd) @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

= 9it (1) setsmovementMode = 5→ the collapse clip's latch inAdvanceBodyAnimation(fall direction 5-8 un-exported → 5 [T3]); (2) loops the roster callingSubsystem::DeathShutdown(1)(RPVTV::DeathShutdownanalog; base is a no-op virtual, a SHUTDOWN not teardown → safe, wreck STAYS); (3) next frame settles tomovementMode = 9IsDisabled→ locomotion frozen. The drive's lonemovementMode = 1write (mech4.cpp ~1309) is guarded on!IsMechDestroyedso 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=0post-death (the clip trigger viaAdvanceBodyAnimationneeds the deathmovementMode 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