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>
27 KiB
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) |
|
|
|
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 viaFUN_00408bf8+FUN_00408440reading the mech's transform atmech+0xd0; (2) HEIGHT→row:mechHeight = *(*(mech+0x2ec)+0xc); clamplocal.yto[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 vias_atan2), wrap<0 → +2π; else degenerateangle = FUN_00408050()·2π; then callFUN_0049e678(row, angle).FUN_0049e678(@0049e678, ROW-level angle→cell): ifRotateWithTorso(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 underi·(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 recoveredFUN_0049de14+ disasm): the cell → a cumulative hit-distribution;zone = FUN_0049de14(cell)rollsrandom()(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 glueFUN_0049ed0c(table,worldPt) = FUN_0049de14(FUN_0049eb54(...)); the realMech::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 basedamageZones[zone]->TakeDamage. STREAM FORMAT — BYTE-VERIFIED against the real type-29.RESbytes (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:
- Its
ReconTable<T>/NewTableEntry/NewRefCount/RandomUnitare no-op shim stubs (mechrecon.hpp —Insert(){},Lookup()→0) → the table stays empty + lookups return null. Must be backed by REAL storage (safe:ReconTableis used ONLY in dmgtable). Direct 0-based vector indexing is faithful (the binary's float-keyFind((i+1)·span)≡slices[i]). DamageZonePercentTable::ReadEntriesskips 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.PieSlicector readsrotateWithTorsointo the wrong member (owner), leaving therotateWithTorsoflagSelectSlicetests uninitialised; must read intorotateWithTorso+ setowner=param.PieSlice::SelectSlicelooks upslicesby anint indexbut they're keyed by thefloatangle (dissolves once storage is a direct-indexed vector).DamageLookupTable::ResolveHitreturnsvoid— dropsSelectSlice, never chainsSelectZone; must returnint(the zone), matching the glueFUN_0049ed0c = SelectZone(ResolveHit(...)).Mech::WorldToLocal/TorsoOrientationSourceare 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 mislabeledStandingAnimationstub @mech.cpp:1221) + the handler override.
- torso heading (
✅ STEP 6 COMPLETE (2026-07-08) [T2]
Built + runtime-verified. Changes:
dmgtable.cpp/.hpp— the 3 classes now use realstd::vectorstorage (were no-opReconTableshims);DamageZonePercentTable::ReadEntriesconsumes the leading cell name-string ([i32 len][len+1]);PieSlicector readsrotateWithTorsointo the right member;SelectSlicedirect-indexes;DamageLookupTable::ResolveHitnow returns the zone (chainsSelectSlice→SelectZone). Stream type is the realMemoryStream(ReadBytes).mech.cppctor — replaced the empty-name stub with the real load:FindResourceDescription(dzRes-> resourceName, DamageLookupTableStreamResourceType/*0x1d*/)→DynamicMemoryStream→new DamageLookupTablecached in the named memberMech::damageLookupTable(binarythis[0x111]/byte 0x444; was mislabeledammoExpendedin the offset map);~Mechdeletes it. ⚠ It was ORIGINALLY "cached" atWword(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::TakeDamageMessageHandleroverride registered (MESSAGE_ENTRY(Mech, TakeDamage), overlays Entity's by ID): oninvalidDamageZone,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 binarymech+0x2ec[+0xc]),TorsoHeading(via theBTGetTorsoTwistbridge intorso.cpp→Torso::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 Nresolves 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 reachDestroyedGraphicState, and the destroyed segment meshes visibly swap (RemakeEntity). Env gatesBT_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 cascadeRecurseSegmentTable/SendSubsystemDamage). On kill the wreck STAYS TARGETED (gEnemyMechis 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 viagEnemyDestroyed. [T2]Mech__DamageZone::TakeDamage→ engine armor model (damageLevel += damageAmount*damageScale[type]) → 1.0 = zone destroyed → death. The Mech ctor populates the inheriteddamageZones[]from the DamageZoneStream (type 0x14) resource.class Damage{ damageType (Collision/Ballistic/Explosive/Laser/Energy), damageAmount, damageForce, surfaceNormal, impactPoint, burstCount }. Verified:structureclimbs, 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]atmech+0x5cc+i*4;SetLegAnimation/SetBodyAnimation=FUN_004a7fc4/FUN_004a800c@part_012.c:13557/13570), one-shot viadeathAnimationLatched(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 loaderFUN_004a80d4(@part_012.c:13604) fills slots 0x00-0x1b + 0x20 (thebmpknockdown) by name and RETURNS — slots 0x1c-0x1f are never written; (2) the offsetsmech+0x63c..0x648appear 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+*ivariants) — 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()== (movementMode2 || 9), locomotion zeroed byFUN_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/meddead32-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 1996ExplosionScriptscase 4 (part_008.c:2663, LIVE — the "disabled" warning is case 6/ effect 106) loads the destroyed hulk +flamesml/flamebig.bgfflames with sweep-flicker and a slow settle. Every mech ships its hulk (BLHDBR/MADDBR/LOKDBR/VULDBR/AVADBR/OWNDBR/SNDDBR/THRDBR+GENDBRgeneric +LDBR/MDBR/RAPDBR/STIDBR); the 1996 script hardcodesthrdbr.bgf(dev shortcut). RECONSTRUCTED:BTL4VideoRenderer::SwapToWreck(btl4vid.cpp) — kill →blhdeadExplosion → effect 104 → hide every segment mesh + hang the victim's own<prefix>dbr.bgfon 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 inBTUpdateProjectiles) 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 fromlastInflictingID(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 asIsDisabled, btstubs.cpp);FUN_004ab1c8freezes locomotion (zeros mech+0x298) when destroyed.MechDeathHandler(ctorFUN_0042a984+ PerformanceFUN_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_0042a5f4detects the crossing,FUN_0042a664looks up the state) it applies theGraphicState("Destroyed" = enum 1, parsed @part_003.c:9099) to the subsystem graphic (sub+0xd4) AND dispatches an effect-creation message (FUN_0043663c→FUN_004364e4to the effect mgrapp+0x38). Fully exported but a no-op STUB in the recon (mech.cpp:221). GamedataDeathSplashDamage/Radius/Effectload 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
movementModeWRITER (sets 5-8 then 2/9 on death) + the fall-direction (5/6/7/8) selection. - The
Mech::DeathShutdownroster loop (RPVTV::DeathShutdownanalog — loop subsystems calling theSubsystem::DeathShutdown(int)vtable slot; the base is an empty virtual, SUBSYSTM.h:166). - The whole-mech
DeathEffect/DeathSplashradius dispatch + thegraphicAlarm.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.cppPerformAndWatch).
✅ 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) 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 = 9→IsDisabled→ 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 gateBT_DEATH_LOG=1. **Deferred (honest):** (a) the visible **collapse ANIMATION** may not latch through the active path — the gait SM showedstate=0post-death (the clip trigger viaAdvanceBodyAnimationneeds the deathmovementModeto 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.