The sticky lock made aim irrelevant after the first acquisition (the crosshair starts on the face-to-face enemy, so it locked at spawn and never let go -- played as auto-targeting). The binary treats no-target as a frequent live state (fire path re-checks 0x388!=0 at every step, part_013.c:7689/7727; HUD range feed has a permanent no-target default, :5636), so the target slots now mirror the reticle pick each frame: locked while the crosshair is ON the mech, no lock otherwise. Keeping the crosshair on the enemy is the gunnery. Verified headless: BT_AIM="0 0" -> HOT + aimed zone hits; BT_AIM="0.6 0.4" -> no lock, zero damage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
25 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, engine/MUNGA/RETICLE.h [T0]): the
engine Reticle struct (the mech's "TargetReticle" attribute — the same one the reticle
renderable ctor binds) carries reticlePosition (screen −1..+1, SLEWABLE — the mechmppr
freeAimSlew), pickPointingOn, rayIntersection (Point3D), targetEntity, and
targetDamageZone: the pilot slews the crosshair; the engine pick-rays through it into the
3D world; the entity (and the specific DAMAGE ZONE) under the crosshair become the target. The
mech's three slots are this pick's result: 0x37c = rayIntersection, 0x388 = targetEntity,
0x38c = targetDamageZone (so aimed shots hit the zone under the crosshair — the aimed-damage
path). Reticle::ReticleElements has a TargetDesignatorOn box + direction-arrow bit (the HUD's
lock box; the reticle's off-screen turn arrows point at it). Once locked, Emitter::FireWeapon
converges on the target with NO aim/arc test (part_013.c:7758: range <= weaponRange → damage *(mech+0x388)) — convergence-on-lock IS authentic; the skill is keeping the crosshair on the
enemy. MechWeapon::UpdateTargetState (FUN_004b9bdc [T1]): targetWithinRange = dist < (1 − hostZoneDamage) × weaponRange — damage DEGRADES weapon reach; that flag is the HUD pip's
lit state. The 0x388 WRITER (the per-frame reticle→mech copy) is in an un-exported decomp gap.
Port status: RECONSTRUCTED + runtime-verified (task #36, 2026-07-08) [T2]. The chain:
Mech::targetReticle (real Reticle member, bound to the TargetReticle attr 0x1d) ← slew from
the MOUSE (dev-box stand-in for the pod stick free-aim; BT_AIM="x y" pins it headless) →
BTGetAimRay (L4VIDEO; the ACTIVE eye publishes pos+LookAtRH basis via BTSetAimCamera, the
render loop publishes proj._22) → Mech::PickRayHit (world ray → local frame →
BoundingBox::HitBy slab test vs the collision template's ExtentBox → world hit point) → the
entity target slots. The slots MIRROR the pick EACH FRAME: locked while the crosshair is ON the
mech, no target otherwise — keeping the crosshair on the enemy IS the gunnery. (⚠ A first cut
made the designation STICKY — once hovered, locked forever — which made aim irrelevant after the
first acquisition; play-tested wrong, and the binary treats no-target as a FREQUENT live state:
the fire path re-checks 0x388!=0 at every step, part_013.c:7689/7727, and the HUD range feed has
a permanent no-target default, :5636. Corrected 2026-07-08.) While locked the impact point = the
PICKED hull point → STEP-6 resolves the zone under the crosshair (verified: center-aim at the
head band → zone 13 dominant). The HUD
draws the aim group at the slewed position ([0x9a] translate list, contained by push/pop), the
designator ring at the target's projected point (subB9 hot / subB8 designated,
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] 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.