Files
BT412/context/combat-damage.md
T
arcattackandClaude Opus 4.8 a9467481c5 Death-state sequence: collapse + subsystem shutdown + freeze (wreck stays)
Reconstructs the mech DEATH state machine — the un-exported master-perf death
branch (region 0x4a9770-0x4ab188) — from its EXPORTED consumers + the RP
VTV::DeathShutdown analog, wired into the active path (the bring-up drive
override bypasses the authentic Simulate where this normally lives).

Mech::UpdateDeathState() + IsMechDestroyed() (mech4.cpp), called for every mech
early in PerformAndWatch: on a vital kill (graphicAlarm >= 9, raised by the
damage side) it
  1. sets movementMode = 5 -> the collapse clip's one-shot latch in
     AdvanceBodyAnimation (fall direction 5-8 is un-exported -> 5 [T3]);
  2. loops the roster calling Subsystem::DeathShutdown(1) (RP VTV::DeathShutdown
     analog; the base is a no-op virtual, overrides act -- a SHUTDOWN not a
     teardown, so it frees nothing and never removes the entity: the wreck STAYS);
  3. next frame settles to movementMode = 9 -> IsDisabled -> locomotion frozen.
The drive's lone `movementMode = 1` write (mech4.cpp ~1309) is guarded on
!IsMechDestroyed so the death state is not clobbered back to a live gait.

Runtime-verified (forced kill, BT_DEATH_LOG=1):
  [death] mech destroyed -> collapse + subsystem shutdown (wreck stays)
  [death] mech settled -> disabled (IsDisabled=1, frozen wreck)
mech frozen in place, subsystem tick + renderer keep running, no crash, wreck
stays.

Also records the full death-sequence decomp map in combat-damage.md (the
exported consumers: AdvanceLeg/BodyAnimation collapse latch, IsDestroyed, the
MechDeathHandler effect engine; and the un-exported orchestration gap).

Deferred (honest): the visible collapse ANIMATION latch through the active path
(gait SM shows state=0 post-death; spawned mechs don't advance body anim);
MechDeathHandler (FUN_0042a984/FUN_0042aa2c -- the exported per-subsystem
destroyed-skin + explosion engine, still a stub) for death explosions; the
whole-mech DeathSplash radius damage (un-exported).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 09:30:34 -05:00

18 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]

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 at Wword(0x111); ~Mech deletes it.
  • 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: boots clean, [cyl] table 'bhk1' layers=7 (the exact byte-verified layer count, found by name), mech spawns + walks, no asserts/AV/0xCDCDCDCD. The live [cyl] unaimed hit → zone N path fires on COLLISION damage (zone==1); not exercised in the solo DEV.EGG (no combat) but the resolver is live + byte-verified. Env gate BT_CYL_LOG=1. The recon's empty-name stub (mech.cpp:1219) must be fixed to the real name (the binary copies it from a sibling resource, local_130+0xc @part_012.c:10361 — reuse the mech's DamageZoneStream/model name). RE phase COMPLETE; the build (containers + handler + load + wire) is next. 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 collapse-anim latch: movementMode (mech+0x40) 5/6/7/8 = the four fall directions → clips 0x1c-0x1f (via SetLegAnimation/SetBodyAnimation = FUN_004a7fc4/FUN_004a800c), one-shot via deathAnimationLatched (mech+0x650). Already reconstructed (mech2.cpp:550-557/817-822). The active drive path DOES call AdvanceBodyAnimation (mech4.cpp:317) → reachable.
  • 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