--- id: combat-damage title: "Combat & Damage — targeting, firing, damage zones, death" status: established source_sections: "PROGRESS_LOG.md §10c; docs/HARD_PROBLEMS.md (P5)" related_topics: [subsystems, decomp-reference, reconstruction-gotchas, locomotion, rendering, translocation-warp, multiplayer] key_terms: [damage-zone, TakeDamage, viewpoint-entity, master, replicant] open_questions: - "Missile cluster-hit variance/concentration (task #60-D deferred); per-player splash enable sub-gate (Missile+0x360 [T3]); ~100pt ram lethality vs the pod unverified [T3]" --- # 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). ## Turrets: PLANNED, never shipped — the props are scenery, but `PGN` is a real turret (2026-07-28) Players (and original-team "old timers") repeatedly report that **the level turrets should be active**. Both halves of that are true, which is why it reads as a mixed bag — confirmed by the project owner 2026-07-28. **What shipped is scenery** [T1]. The turret/cannon-looking objects in the maps — `TT1`/`TT2`/`TWR`/`APC` (+ `D` damaged variants) — are in `BTL4.RES` as `model + damaged-model + name + default` with a `.sld` collision solid: **no skeleton, no joints, no gun port**. Destructible props, nothing more. The destructible world classes (`Landmark`, `CulturalIcon`, `UnscalableTerrain` — `CULTURAL.h`) have no weapon/fire/target members at all, and `BTL4OPT.EXE` contains **zero** occurrences of `turret`/`sentry`/`emplac`/`brain`/`patrol`/`aggro`/`hostile`/`npc`. Consistent with the standing no-AI finding below. **But a real turret asset exists and was never shipped** [T1]. `PGN` is a fully-articulated gun emplacement, present as loose source assets (`content/VIDEO/PGN.SKL`, `content/VIDEO/GEO/PGN*.BGF`) and **absent from `BTL4.RES` entirely** (zero `pgn` bytes, any case): ``` [ROOT] Object=pgn_base.bgf dzone=dz_base static base [jointturret] parent=ROOT Type=hingey pgn_tur.bgf YAW = traverse [jointgun] parent=jointturret Type=hingex pgn_gun.bgf PITCH = elevation [sitegunport] parent=jointgun tranz=-9.8505 the MUZZLE ``` Traverse, elevation, a firing site, a damage zone, and `PGND*` destroyed geometry. It is **not** a mech (no legs/arms/torso). ⚠ Do not read its `[LAB_ONLY] // not approved for release` header as meaningful — that is boilerplate on **63 of 64** `.SKL` files, MadCat included. ⇒ Turrets were **designed and modelled, then cut before the content build**. Making them fire is therefore *finishing* a cut feature, not inventing one — but there is no surviving turret CODE to reconstruct: it would need an entity class, targeting, and (because BT is networked PvP) replication, none of which the binary shows a trace of. Treat it as new work, tiered accordingly. ⚠ **UNRESOLVED — do not cite either number as settled.** `engine/MUNGA/VDATA.h:209` declares `TurretClassID` as the last entry of the **BT** block (after `MechTechClassID`, before the ND section). Counting the enum gives `ThermalSightClassID = 0xBD9` and `TurretClassID = 0xBDE`, but `CLASSMAP.md` assigns **0xBDE to ThermalSight** (ctor `@4b8718`, vtable `0x511498`, perf `[0x511290]` — which does match `thermalsight.cpp`). The enum count independently agrees with CLASSMAP on HUD (0xBD6) and Searchlight (0xBD8) but disagrees on MechTech (enum 0xBDD vs CLASSMAP 0xBDC), so the port's `VDATA.h` ordering has drifted from the shipped binary somewhere in this range. CLASSMAP is binary-derived and wins for what the runtime does; the true numeric value of `TurretClassID` in the 1995 binary is **unknown**. Resolve by disassembling the ClassID each ctor stores before relying on it. ## 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) **STRUCTURAL DISCOVERY (2026-07-29, the #73 pick dig): the target block is an embedded engine `Reticle` at `mech+0x36c`** [T0 layout-verified]. `engine/MUNGA/RETICLE.h` lays out exactly: `reticlePosition@0x36c` (Vector2D), `reticleState@0x374`, `pickPointingOn@0x378`, **`rayIntersection@0x37c`**, **`targetEntity@0x388`**, **`targetDamageZone@0x38c`**, `reticleElementMask@0x390`. This is why every "who writes 0x388" scan came back empty — writers carry `&mech->reticle` (an `lea [mech+0x36c]`) and use small reticle-relative offsets. `ENTITY3.h:131` states the model in T0 words: *"For BattleTech, damage zones are only valid via reticle based weapons."* The engine `Reticle` is a **passive container** (ctor + resource parse only — no pick math); `HudSimulation @0x4b7830` opens with `lea esi,[owner+0x36c]` and READS the pick (range caret from `rayIntersection`, the designator transform) and slews `reticlePosition` via `@0x4b7ed4`, but does not write the pick either. **The pick WRITER is BT code, still unlocated** [T4 candidates]: the un-exported stretch `0x4a1674-0x4a2d48` (touches the reticle at `0x4a16a5` — the Mech::Make-called init — and `0x4a1f93`, `0x4a294a/54/61` state+pickPointing+base together near entry `0x4a2971`, which is called from `0x494483`), the two unidentified Mech vtable overrides `+0x18=0x4a122c` / `+0x1c=0x4a0c2c` (both big switch functions), and lone sites `0x4877a4` (byte read via the mission entity table `0x5015c8`), `0x4b0097`, `0x45fb14-24`. **RESOLVED (same dig, deeper): there IS no software pick writer — the 1995 pick was a DPL SCENE INTERSECTION** [T0 + T1 converging]. Evidence: (a) the WinTesla renderer still carries the result members `dplHitInstance/dplHitDCS/dplHitGeoGroup/dplHitGeometry` + `vehicleReticle` (L4VIDEO.cpp ctor, all NULL-init); (b) the stubbed 1995-era renderable constructors each took **`dpl_isect_mode_obj` ("type of intersections to do on this object") + an intersection MASK** — per-renderable intersection configuration against the scene; (c) Auric's account ("the pod's division card cast from the view"); (d) VGL Lynx's LOD warning reads as firsthand knowledge — the ray tested the DRAWN geometry, i.e. the active LOD's meshes; (e) exhaustive scans: nothing in the binary or the pseudocode ever writes `rayIntersection/targetEntity/targetDamageZone` — game code only reads, initializes (the Mech ctor @0x4a1674 constructs the embedded Reticle), gates (`FUN_004afd10`, the look-state machine, toggles `pickPointingOn` per view with the π rear case), and SUBMITS the reticle to the board for drawing (`FUN_00460a7c` packages +0x37c/+0x390 into dpl). ⇒ **Aimed fire in 1995 had PER-PART precision**: scene ray → struck triangle on the current LOD → the DCS = the struck SEGMENT → its `dzone` (the SKL segment→zone map, `GetSegmentIndex@49db20`) = `targetDamageZone`. The cylinder lottery (STEP 6) was only ever the UNAIMED path. The port's whole-mech box pick + "STEP-6 zone under the boresight" funnels AIMED fire through the unaimed lottery — which is exactly the night-6 report (#73): aim at the arm, get the spray. **IMPLEMENTED (2026-07-29/30) [T2 bench]: the aimed PER-PART pick.** `BTL4VideoRenderer::MechSegmentPick` (btl4vid.cpp): at tree build, each segment's draw object + `GetPrimaryDamageZone()` (SEGMENT.h — carried per segment in the skeleton STREAM, read by JMOVER.cpp:290) are recorded in `MechRenderTree::segPick`; the pick ray-tests the per-segment bounding spheres (`mCullCenter/mCullRadius`, world via the draw-cached `mLocalToWorld` — ≤1 frame stale). **Selection is SPECIFICITY-FIRST: among pierced spheres the SMALLEST radius wins** (normalized `d²/r²` tie-break). Both simpler rules were measured failing identically: the torso mesh's sphere (r≈4.1 on the MadCat vs shoulders r≈1.0) envelops the mech, so nearest-entry always faces you AND normalized distance rewards the giant sphere (a 0.45u-off-axis ray scores 0.012 vs it). Limbs nest inside the envelope; smallest-pierced picks the most specific part on the aim line — the per-part semantic the 1995 mesh intersection produced. `mech4.cpp` tries the segment pick per candidate (box `PickRayHit` + zone −1 → the unaimed lottery survives only as fallback: no tree / wrecked / structure occlusion), and the winner's zone rides `MECH_TARGET_SUBIDX` + `targetReticle.targetDamageZone` into `SendDamageMessage` — aimed hits now dispatch a REAL zone. Bench (L/C/R sweep, dummy at 8u): aim-left = **36/41 hits on zone 2 = `jointlshoulder`** with thread score 0.30 (was a 6-way lottery spray); envelope grazes log score ≈0.99 and credit the torso. The MadCat segment→zone map is authored and rich (shoulders 2/9, guns 6/17, hip 1, legs 3/5/8/10/16/19, torso 0). Known approximations for field verification: sphere bounds (not per-triangle), the torso-envelope graze credits the torso where the pod's mesh test would miss into air. Diag: `BT_PICK_LOG` (`[segpick]` the map at build, `[pickwin]` zone/score/t per pick). `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; task #39; RE-CORRECTED 2026-07-13, task #58 crosshair-twist forensics):** THERE IS NO FREE-AIM MOUSE CURSOR — and **the VIEW is TORSO-MOUNTED, not body-mounted**. The cockpit eye hangs off the torso yaw hinge in every twist-capable skeleton (`jointtorso` (hinge-Y) → `jointeye` → `siteeyepoint`; all 14 twist-enabled Torso records name `TorsoHorizontalJoint=jointtorso`; `Torso::UpdateJoints` writes currentTwist into that joint each frame; VIEW = inverse of the LIVE joint chain, `FUN_004c22c4` part_013.c:11742, no de-twist anywhere [T1]). So when the torso twists, **the CAMERA yaws with the guns and the crosshair stays SCREEN-CENTERED** — truthfully, because screen center always IS the boresight; the world rotates past it. The twist reads on INSTRUMENTS: the bottom 21-tick tape carets, the compass (= body yaw + twist, part_013.c:5674-5676), the radar wedge (part_013.c:11836-11859) [T1]. The old "crosshair = torso boresight offset in a body-mounted view" model was falsified — it was never discriminated before because every prior live test used the fixed-torso BLH, where both models center the crosshair. The engine `Reticle` struct (`engine/MUNGA/RETICLE.h`) carries `reticlePosition` (a variable screen position −1..+1 [T0]) — the reticle CAN move, but its writer is in an un-exported decomp gap (`s_TargetReticle` has exactly ONE xref binary-wide: the read-side lookup part_014.c:5132); the only coherent twist-era mover is the **fixed-torso free-aim channel**: `MechControlsMapper` writes `HUD::SetFreeAimSlew(stick_x)` → cockpit+0x28C **only when torso twist is disabled** (part_013.c:339-345/381-387 [T1]); HudSimulation integrates it (negated, part_013.c:5717) into +0x294 clamped ±(+0x29C) → `mech+0x36c` every frame (:5735) — no exported consumer; the mech+0x36c → reticlePosition link is [T4]. NB: an earlier version of this section conflated `reticlePosition` with **HotBoxVector** (HUD+0x1FC — the TARGET designator HudSimulation zeroes with no target, part_013.c:5680); they are different things. The pick 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` — no jointtorso in the skeleton at all) boresight dead-ahead. 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 is in the same un-exported gap. ⚠ **`hostZoneDamage` is `Subsystem::damageZone->damageLevel` (weapon `@0xE0 → +0x158`), NOT heatLoad** — the port originally computed `effectiveRange = (1 − heatLoad) × weaponRange` (mechweap.cpp), the SAME `@0xE0`-DamageZone-vs-heat misattribution corrected in `HeatSink::UpdateCoolant`. For a charge/discharge weapon (ER laser) the weapon's own `heatLoad` swings 0→1 every cycle, so `effectiveRange` collapsed to ~0 and the weapon was perpetually "out of range" → `SendDamageMessage` never ran → NO damage and NO impact explosion (the "lackluster/ absent laser hits, esp. the ER medium, on mechs AND buildings" report). FIXED 2026-07-15: read the QUALIFIED `this->Subsystem::damageZone->damageLevel`; an undamaged weapon now holds full STABLE `weaponRange`. Verified: laser `effRange` 500 stable (was 0/59/340/424), impact explosions 13/22s (was ~2). [T2] See [[reconstruction-gotchas]] (`@0xE0` shadow/heat class). **Port status: RECONSTRUCTED + runtime-verified (tasks #36/#39/#58) [T2].** The chain: the cockpit eye inherits the twist AUTHENTICALLY through the draw traversal (`Torso::PushTwist` → hinge `rotationAmount` → `HingeRenderable::Execute` multiplies the live hinge into the matrix stack; the eye executes under `jointtorso → jointeye`; verified `[eyefwd]` sweeps with the real jointtorso — NO explicit eye-side compose exists, one briefly added on false "frozen eye" probe readings double-rotated the view and is retired, see [[cockpit-view]]); the crosshair sits at screen center (`gBTAimX = 0` — the old `BTTwistToReticleX` slew was THE bug: the camera already yawed, so the crosshair counter-slid to HULL-forward, "twisting leaves the crosshairs behind"; `BT_AIM="x y"` still pins it for headless tests) → `BTGetAimRay` (the BORESIGHT eye publishes pos+basis via `BTSetAimCamera`, leveled in pitch, the twist yaw riding in the basis) → `Mech::PickRayHit` (world ray → local frame → `BoundingBox::HitBy` slab test vs the collision template's ExtentBox → world hit point). **PICK-RAY ORIGIN = the TRUE EYEPOINT (Gitea #16 "shots land LOW, worse up close" — FIXED 2026-07-20) [T1 evidence / T2 fix].** The task-#48 publish anchored the pick ray at `mech.y + 5.0` (a port improvisation) while the crosshair's sight line runs from the eyepoint (~y 6.2 on the DEV mech) — two parallel rays with a constant vertical offset, so the pick point slid DOWN the reticle as range closed (measured pre-fix via `BT_PARALLAX_LOG`, the pick point back-projected through the RENDER view: ry +0.07 @ 50u → +0.17 @ 16u → +0.32 @ 10u → +1.5 @ 2.7u; lasers AND missiles consume the pick, so both landed low). The binary's sight math is EYE-ORIGINATED [T1]: HudSimulation's designator HotBoxVector = target position transformed by `inverse(eyeSegToWorld)` ∘ `inverse(R(EyepointRotation))` where eyeSegToWorld = the HUD's mount SEGMENT frame × mech localToWorld (`@004b7830` part_013.c:5688-5702 — Ghidra drops the segment arg, disasm shows `hud+0xdc`; the seg→world composer is `FUN_00424da8` @00424da8); the VIEW is `inverse(jointtorso→jointeye→siteeyepoint)` (`FUN_004c22c4` part_013.c:11742); and the fired beam CONVERGES from the muzzle segment to the picked point `mech+0x37c` (`@004bace8` part_013.c:7742-7752) — so pick ray and sight line share the eye origin (the pod's division card cast from the view per Auric; the 0x37c/0x388 writer itself is in the un-exported gap). **The fix:** the viewpoint mech's cockpit `siteeyepoint` eye is marked the **BORESIGHT eye** (`DPLEyeRenderable::SetBoresightEye`, set in btl4vid.cpp) and owns the `BTSetAimCamera` publish in BOTH views (it executes in the mech's draw traversal every frame), origin = its own eye translation (eyeW row 4 — the exact frame the crosshair projects from); the leveling + `gBTEyeElev` re-application are unchanged. Chase ballistics are therefore IDENTICAL to cockpit (verified: chase-view pick lands at y=6.23076 = the cockpit eye height while the chase camera sits at y=11); the legacy `+5` anchor survives only as the no-boresight-eye fallback (spectator). Verified after: ry ≤ 5e-6 from 50u to point-blank 2.2u in cockpit AND glass builds; 26 laser `[dmghit]`s + 8 missile hits at range 1.7-2.2 on center-mass/upper zones with the lock ring on the target chest (`scratchpad/i16_laser.png`/`i16_missile.png`). Diags: `BT_PARALLAX_LOG` (pick→render-view back-projection via `BTProjectToRenderView`), `BT_GOTO_STOP`/`BT_GOTO_THR` (approach-test stop radius / walk throttle). **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). **WORLD STRUCTURES ARE TARGETABLE via the STATIC COLLISION TREE (task #50, 2026-07-15) [T2].** The `BTGroundRayHit` "terrain" tier only samples the VISUAL heightfield, which on arena1 is a single flat `'sky'`-named ground mesh (`[visgnd] sampler ready: 1 terrain instances`) — and the class-42 map-instance stream (`BuildTables`) likewise holds only `'sky'`. So the garages/walls were invisible to the pick → shots passed through them, and only mechs moved the range axis (the user-reported regression). The FIX: the boresight now also ray-tests the **zone's static collision solid tree** — the SAME geometry that blocks the mech's walk. Mechanism is authentic engine code: `Mover::FindBoxedSolidHitBy` (MOVER.cpp) already tests movers, doors, AND the static world (`application->GetInterestManager()->GetInterestZone(interestZoneID)->GetCollisionRoot()->FindBoundingBoxHitBy(line)`). We factored its "test against the static world" tail into **`Mover::FindStaticSolidHitBy(Line*)`** (static solids only, no movers), wrapped by **`Mech::WorldStructurePick(start,dir,range,&hit)`** (mech.cpp — builds the world-space `Line`, reads the entry point back via `line->FindEnd` since `FindBoundingBoxHitBy`→`HitByBounded` clips `line->length = enter`). The pick order in mech4.cpp is now: closest MECH (`PickRayHit`, damage zones + lock) → closest STRUCTURE (`WorldStructurePick`, occludes a mech BEHIND it) → flat ground (`BTGroundRayHit`) → sky (fire-at-nothing). A structure hit designates `mech+0x388 = gBTTerrainEntity` (the no-damage-zone sentinel) + `0x37c` = the entry point, so the range caret reads the structure distance and NO lock ring draws (mech4.cpp:4529, the `des != hotTarget` branch), exactly the reported authentic behavior. **⚠ SUPERSEDED IN PART (2026-07-19, issue #3): the struck ENTITY, not the sentinel, is now designated.** The binary damages world structures/icons under the boresight [T1]: `MechWeapon::SendDamageMessage` @004b9728 (part_013.c:6765-6794) gates only on "target NOT derived from Mech (Derivation@0x50bdb4) OR aimed zone != -1" — i.e. a NON-MECH target is sent the zone=−1 TakeDamageMessage UNCONDITIONALLY — and the Missile contact branch @004be078 dispatches at whatever entity's solid the round struck (owner via the solid's tag pointer) with NO class test. The port: `Mech::WorldStructurePick` (mech.cpp) now returns the struck solid's owning entity (`BoxedSolid::GetOwningSimulation` — every static solid carries its Terrain/CulturalIcon/Door entity, TERRAIN.cpp:107/246, DOOR.cpp:395), the mech4.cpp pick block designates it (sentinel only as fallback), and the projectile contact path grew the non-mech damage-zoned else-branch (@004be078 mirror, direct Dispatch). A plain terrain owner IGNORES the damage (Entity::TakeDamageMessageHandler returns on zone==−1/no zones, ENTITY.cpp:885); a CulturalIcon's own handler maps −1→0 and dies → its FULL res-32 `trkdead` explosion (dtrkboom+dtrkburn) instead of only the ram crunch — see [[rendering]] §Cultural-icon DESTRUCTION. HUD unchanged: the icon is never `hotTarget`, so no hotbox/lock ring (the `des != hotTarget` terrain branch). Verified live: 26 laser truck/prop kills (`[cult] TakeDamage type=3` → `DYING res=32`), ram crunch (res 31) unregressed. [T2] ⚠ There is NO ray query against the ground/`containedByNode` `BoundingBoxTree` — `FindBoundingBoxUnder` is DOWNWARD-only (gravity/ground-snap: `*height = FindDistanceBelowBounded`), useless for a horizontal boresight; the static SOLID tree (`BoxedSolidTree::FindBoundingBoxHitBy`) is the only ray-vs-world query. Verified: a `BT_WSWEEP` horizontal ray-fan on arena1 tracks position (3/24 hits near the boundary → 17-21/24 inside the interior garage cluster → 3/24 past it — discrete interior solids, not just an enclosing box), no crash. The interactive boresight (`BTGetAimRay`) can't be exercised headless (no window → `noRay`), so the SWEEP is the headless proof; interactive aim is user-verified. ## 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] ## REAR-FIRE + look views (task #68, 2026-07-16) [T1] The pod had a rear arsenal + a five-state LOOK-VIEW system (all reconstructed + live): - **weapon.rearFiring @0x334** = the MOUNT SEGMENT page name contains 'b' (ctor marker @0x511aa2; the back ports `sitelbgunport`/`siterbgunport`). Roster survey: **the Blackhawk's ERMLaser_2/3 are the ONLY rear weapons in the game** (both loadout variants; the Owens model HAS back ports but mounts nothing on them; every missile rack is forward). - **mech.rearFiring @0x410** = OR of the weapons' flags (ctor part_012.c:10220). - The mapper look machine (part_013.c:396-459; modes 0=fwd 1=left 2=right 3=BACK 4=down from the look buttons): on change it writes **EyepointRotation (mech+0x360)** — side yaw = the authored per-mech look angles (model rec +0x50-0x5c → mech 0x564-0x570), look-back = yaw π + lookBackAngle pitch — and re-arms each weapon's **viewFireEnable @0x3E0** (fwd = !rear, back = rear, else 0). 0x3E0 is the fire gate (the Loaded→Firing transition, part_013.c:7689 — the old `useConfiguredPip` label) AND selects the HUD pips: the reticle registers each pip in group 1(front)/2(rear) from the RearFiring attr (part_014.c:5429-34) and draws the group in the mech's reticleElementMask low bits (mech+0x390: fwd |1&~2, back |2&~1, part_013.c:5588-95). - Port: keyboard **HOLD 'V' = look back**; side/down looks not yet key-bound. `[rearfire]`/ `[look]` traces under BT_PROJ_LOG/BT_KEY_LOG. - Missile LAUNCH direction: rounds leave along the **mount segment's world frame** (the binary spawns the missile with the full muzzle-segment AffineMatrix, fire builder @0x4bcc60:8762-64, authored MuzzleVelocity composed with z NEGATED + the mech's own velocity) — was the "missiles fire out of his back" torso-twist bug (launch followed the LEGS while the muzzle followed the twist). - BANKED for the full Missile-entity revival (decoded, not yet ported — commit ab6ea83): the three authentic performances — Seeker 0x4be9a0 (aim = target-frame offset / 100u-ahead-of-own-frame dumb + loft/lead 0x4beae4), Thruster 0x4be474 (quaternion-slerp BODY turn toward the aim at turnRate°/s), MoveAndCollide 0x4bef78 (velocity aerodynamically aligned to thrust via signed-square per-axis gains, ballistic droop as fuel burns, PROXIMITY FUSE on seeker rangeToTarget, max-range + altitude-floor retire). ## 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] ## The RAM economy — CLOSED (2026-07-12) [T1 evidence, T3 normalization] Three-layer story, measured live + decomp-verified: 1. **The armor economy is POINTS**: every zone streams `damageScale[5]` — 5 cells indexed by `damageType` DIRECTLY (Collision/Ballistic/Explosive/Laser/Energy; task #60 correction — NOT an "even/odd slot" parity), each destructive type's cell ≈ `1/armorPoints` (armor 50–140). `damageLevel += amount × damageScale[type]`, 1.0 = destroyed — engine `DAMAGE.cpp:379` (arcade `@0041e4e0`), called from `mechdmg.cpp:427`; the `[zone-armor]` + per-hit `[dmghit]` dumps, BT_DMG_LOG. Weapons author point-scale amounts (laser 11.77 ≈ 10 torso hits). **`burstCount` is NOT in this formula — `TakeDamage` itself IGNORES it** [T1]. ⚠ But one Damage message is **NOT** one application: `Mech::TakeDamageMessageHandler` calls `TakeDamage` **`burstCount` times**, re-rolling the struck zone per burst (@0x4a0423-0x4a04d8; `mech.cpp`, task #80). `burstCount` = number of applications, and it is load-bearing (missile cluster count, splash falloff, gyro bounce). A cluster missile lands its damageAmount once **per connecting missile** — the count is rolled at impact, `Random(n) + n/4` clamped to `n`. See the corrected SALVO note below. ✓ 2. **StaticBounce prices rams at ~600×v² raw** (measured: 59221@9.94, 66239@9.59, 398@0.81; `[collide-tx]` logs mass/e — mech moverMass is the tonnage-scale 60000-90000, NOT the old "authored ≈1.3e6" mis-attribution; the ~24·v²·0.0005·mass factor is the head-on (vn·vp) algebra). The binary DISPATCHES it raw (@part_012:15324-15358) — but in pod MP it landed on the local REPLICANT of the victim (the master never heard it; MECH.CPP:986 warns and proceeds) — **ram damage was network-inert in the pod**. Our task-#47 replicant forwarding surfaced it as a one-shot, and a ×1e-3 normalization was patched in (2026-07-12 [T3]). **That normalization is GONE (pricing audit 2026-07-31): the #83 type-0 divert is the authentic normalizer** — the victim's TakeDamage hub routes collision damage into `DistributeCollisionDamage` (scale ≈ 3.9-4.5e-5), so with the 0.001 still applied no physically possible ram could clear the divert's 0.5-pt free floor (rams were a no-op against the victim). Raw restored [T1]: a 9.6 m/s ram = 2.55 pts of internal rattle on the victim (bench-measured, `scratchpad/night7/mp_rampricing.sh`); pressed follow-up contact prices under the floor = free. 3. **The contact EDGE**: the binary's bounce reversed the mover's velocity so the frame after a bump read as separating; our gait re-derives velocity per frame (still closing while pressed / respawn-overlapped) → sustained contact re-priced full rams at 60Hz (the respawn explode-loop). Port: `ramLastVictim`/`ramContactLinger` (mech.hpp) — one bump = one damage event; pressed = BLOCK; re-arms 0.35s after separation; cleared on Reset. Band effects: `MechDeathHandler::Tick` fires the CURRENT band descriptor on any damage rise (the binary's changed-flag semantics, workflow-verified) — a damaged mech under fire smokes/ burns per hit; the earlier crossing-only gate was over-tight (the metronome was SHKWAVE). ## Missile THRUST -- FIXED (2026-07-31, issue #84) [T1 values / T2 verified] The binary Missile hosts a **MissileThruster** subsystem: the round leaves the rack at the launcher's authored MuzzleVelocity (SRM eject 100 u/s flat; LRM 30 u/s up-tilted) and then ACCELERATES while BurnTime remains. Authored values (BTL4.RES type-15 missile model records +0x44/+0x48, reached from the AmmoBin's `ammoModelFile` through the type-1 MODELLIST indirection): **SRM 2.5s @ 600 u/s² (turn 120°/s) · LRM 10s @ 300 (turn 60°/s, climb 50) · Streak 3s @ 300 (turn 360°/s) · NARC 10s @ 300**; SplashRadius 30 for all (the port's constant was right); authored drag ~0.001 = negligible. The port pool flew at CONSTANT eject speed -- LRMs 10× slower than authored -- which was the whole of the field "missiles are slow" report, and the "explosion before the missiles arrive" perception (the salvo-lead detonates while the slow spread rounds straggle). Fixed: `BTMissileThrustOf` (mech4.cpp) lazily caches the RES thruster table (ModelList ids aliased to their type-15 member); both launch paths (master fire + the replicant salvo mirror) pass burn/accel into the pool, whose advance integrates `speed += accel·dt` while burning. Verified live: Streak impacts at 277 u/s (was 100). Note: the Black Hawk's "SRM6" fires **strk** (Streak) ammo per its bin -- the ammo model, not the launcher name, decides the flight profile. ## Destroyed weapons kept FIRING -- FIXED (2026-07-31, issue #86) [T1/T2] The fire gates (`ProjectileWeaponSimulation` gate 1 @4bbd36, `EmitterSimulation` hard-failure @4baab9) test `subsystem+0x40` for Destroyed(1). In the binary that offset IS the status alarm's level cell; the port splits it into `statusAlarm` + a never-written `int simulationState`, so the gates read a dead cell -- a weapon on a destroyed mount showed its X on the MFD and kept firing and SCORING (night-7: all three testers, with screenshots of a missile leaving a destroyed pod). Fixed by reading BOTH cells in both gates; the full pattern is [[reconstruction-gotchas]] §22. Verified A/B in one run (BT_KILL_SUBSYS bench hook -> ForceCriticalFailure, the same call the zone crit-cascade makes): before the kill both SRM6 launchers fired 2 salvos each; after, the destroyed launcher fired ZERO (gate log `destroyed=1`) while its twin kept firing. ## Ballistic damage type -- FIXED (2026-07-23, issue #27) [T2] Playtest matchlog forensics (2,591 applied-damage events over 2 rounds): the damageType histogram had Collision/Explosive/Laser/Energy but **Ballistic (type 1) NEVER appeared**, despite 136 projectile impacts + autocannon/Gauss mechs. The weapon parses its authentic `damageData.damageType` right (mechweap.cpp:316, "BallisticDamage"->1), but `BTPushProjectile` dropped it and every mech4.cpp projectile-impact site hardcoded `ExplosiveDamageType` -- so autocannon/Gauss scaled against armor via `damageScale[Explosive]` not `damageScale[Ballistic]` (distinct cells: `damageLevel += amount * damageScale[type]`). Lasers/PPCs were correct (their `SendDamageMessage` carries the weapon damageData); only the projectile path flattened. FIX: `BTProjectile` gains a `damageType` field, threaded from the weapon at launch through to impact (missiles still resolve Explosive from their own type; autocannon/Gauss now Ballistic). The missile-SPLASH template stays Explosive. [T2 verify: an autocannon mech produces type=1 DMG.] ## AMMO BAY FIRE / COOK-OFF — FIXED (2026-07-25, issue #46) [T1 mechanism, T2 live] Field report (night 3): "two ammo bay fires and no death" (Cyd + RajelAran, one purged one left burning). Root cause = **three stacked kill-switches** on the same path in `ammobin.cpp`: `GameClock::Now(){return 0}` (→ `cookOffTime < 0<0` — an armed fire NEVER detonated), `InjectHeat(void*){}` (detonation applied nothing), and a never-stamped damage record (0 dmg of type 0 even if it had). The authentic mechanism, raw-disasm verified (`scratchpad/disammo.py`): - **Arming**: bin `heatAlarm` FAILURE(2) with rounds left, or message 1 (crit-induced, `HandleMessage @004bdb94`). `cookOffTime = Now().ticks + round(10.0 × ticksPerSecond)` — a **FIXED 10-second fuse**. (The old "RandomDelay" was a Ghidra artifact: `FUN_004dcd94` is `__ftol`; the caller's x87 expression `10.0 × DAT_0052140c + 0.5` was dropped from the export. `DAT_0052140c` = SystemClock ticksPerSecond. See [[reconstruction-gotchas]] §__ftol.) - **Purge cancels**: bin Empty (eject-hold bay dump) → `cookOffArmed=0`, no detonation. - **The bin's damage record** (`Damage cookOffDamage @0x1F0..0x21C`, was mislabeled "heat descriptor"/"heatPerRound"): stamped at construction by the linked **ProjectileWeapon ctor @004bc3fc** — `bin+0x1F0.. = weapon->damageData @0x3A8` (type/per-round amount/force/normal/ impact/burst), via `owner->roster[0x128][resource+0x1C0]`, UNGUARDED. Sequencing is load-bearing: the stamp precedes MissileLauncher's chained ctor dividing its own damageData by missileCount — a missile bin holds the per-SALVO amount. (projweap.cpp's old comment called this "the bin's HUD display block", and it was never wired.) - **Detonation** (`CookOff @004bd300` → `@004ac274` = `MechSubsystem::DistributeCriticalHit`): local Damage = record, amount = ammoCount × per-round; statusAlarm pulse Exploding(2)→ Destroyed(1) (slot-13 = the `printSimulationState` state PRINT @004ac8c0, NOT an "explosion notify"); own private zone `structureLevel = 1.0`; then **collect the mech DamageZones whose crit entries plug this subsystem** (the binary iterates the bin's Node links filtering classID 0x4E = `DamageZoneClassID`, VDATA.h idx 78), split the amount evenly, and send the OWNER one full `TakeDamageMessage` per zone — `inflictingEntity`=SELF, `damageZone`=index, `inflictingSubsystemID`=the bin (the message-manager's explosion-resource bundling key, ENTITY3.h's own NOTE) — printing `"ammo explosion damaging "` (@0050df61). Port: `Mech::AmmoExplosionFanOut` (mechdmg.cpp) behind `BTAmmoExplosionFanOut`; guarded deviation: zoneCount==0 warns instead of the binary's unguarded divide. - **Verified live** (`scratchpad/baytest.py` / `baypurge.py`, hook `BT_BAYTEST=`): arm → 10s → `20 rounds × 35 = 700 (type 2 Explosive)` → `ammo explosion damaging dz_ltorso` → zone cascade → **mech DESTROYED** (authentic death list); purge arm → `EXTINGUISHED`, no detonation. `BAYBOOM` matchlog record added for MP forensics. ⚠ Family gap logged in [[open-questions]]: `HandleMessage` is vtable slot 8/9 in the binary but the reconstruction declares it NON-virtual across 10 classes (no live base-typed callers yet). ⚠ **Fix-of-the-fix (caught by the sim3 regression, same day):** `MechSubsystem`'s `ReconDamageZone` proxy puts `structureLevel` at **offset 0 — aliasing the real engine `DamageZone`'s VTABLE pointer** (the private zone IS `new DamageZone`, mechsub.cpp:154). Writing `damageZone->structureLevel = 1.0f` through the proxy overwrote the zone's vptr with `0x3F800000`; the respawn sweep's virtual `SetGraphicState` (slot 3 = vtable+0xC) then called through it → AV at `0x3f80000c` in `RespawnRepair` (`sim3` pod3, one frame after a bay-fire death). ALL EIGHT proxy-view sites in mechsub.cpp swept to the engine view (`((DamageZone*)damageZone)->damageLevel` @0x158) — including two silently-wrong LIVE readers: `GetStatusFlags` (read the vptr as a float → always "intact") and `ApplyDamageAndMeasure` (the crit cascade's damage-measure read garbage). Gotcha §5 (alias fields), new archetype. ## ⚠ CORRECTED (2026-07-29, #80): subsystem private zones ARE damageable — the port was writing ## their scales through the WRONG LAYOUT The 2026-07-28 verdict here ("a subsystem's private zone cannot be damaged via `TakeDamage` — in the original too") was **wrong about the original**, and the experiment that "confirmed" it was measuring a PORT bug. The truth [T1, raw disasm @0x4ac7bb + live-verified]: - The binary's `MechSubsystem` resource ctor **initializes the private zone's armour**: `defaultArmorPoints@0x140` ← resource `WeaponDamagePoints` (+0x44, REQUIRED key) and `damageScale[5]@0x144` ← the five per-damage-TYPE keys `Collision/Ballistic/Explosive/Laser/EnergyDamagePoints` (+0x30), normalized `1/(scale·armorPoints)` exactly like the mech zones (without their extra ×0.5). - The PORT had this copy — but wrote it through the **`ReconDamageZone` proxy**, whose `structureReference/armour[]` sit at struct offsets **+4/+8**, not the binary's +0x140/+0x144. The floats landed on the engine object's header and the real scales stayed at the engine ctor's zeros — hence the frozen-damage experiment. Classic databinding trap, now on the engine side. FIXED: the ctor writes the engine's NAMED members (layout-parity holds — `Mech__DamageZone` locks its derived fields from 0x160 up). - The port's CSS also never parsed the seven keys (now parsed; two are required with the binary's own error strings). Consequences: `MechSubsystem::TakeDamage @0x4ac0bc` (now real, was a stub) accumulates authored per-type damage on subsystems; crits land and destroy subsystems; and **the Myomers un-powered self-repair `@004b8bb9` was LIVE in 1995 and is live in the port now** — the earlier "dead code in the original too" note in [[subsystems]] WAVE 6 is superseded. `DistributeCriticalHit`'s direct `damageLevel = 1.0f` pin remains the ammo-explosion path, not the general mechanism. ## ⚠ ZONE SELECTION IS A WEIGHTED LOTTERY — pixel-precise limb damage does not exist (2026-07-29) [T1] Issue #73 ("fired only at the left arm, damage credited elsewhere, no crits") is **largely the authored 1995 model, not a bug**. The chain (`dmgtable.cpp`, binary-faithful, byte-verified stream format): impact point → `WorldToLocal` → **height layer** (`floor(layerCount·y/heightRef)`) → **pie slice** (`atan2(local.z, local.x)`, optionally rotated by live torso twist — `rotateWithTorso` per layer) → `DamageZonePercentTable::SelectZone()` = **`RandomUnit()` against cumulative percent thresholds** (`@0x49de14`). Each slice sprays damage across an authored *distribution* of zones — aiming at a limb at best biases WHICH SLICE you strike; the zone within it is dice. Measured live (L/C/R aim sweep at 8u, `BT_DMG_LOG`): 24-46 hits per aim point spread across 6+ zones each, with the distribution *shifting* by aim — the model working as designed. Consequences for triage: reports of "damage landed somewhere I didn't aim" need this base rate before being called bugs; crit expectations from aimed fire are likewise probabilistic. **Residual genuine question [T3]:** the shooter's pick point comes from `Mech::PickRayHit` = a **whole-mech AABB slab test** (a PORT stand-in — the binary's 0x37c/0x388/0x38c writer is in the un-exported gap, never decompiled). Theta computed from a flat box FACE clusters toward the facing slices, so flank slices may be under-reachable from frontal shots vs. the pod (which plausibly intersected the mech's *cylinder* — the damage table is literally cylindrical). Deciding whether that distortion is material needs a per-hit theta probe + a wheel dump (slices × percent tables, MadCat) — see #73 on the tracker. ## ✅ CRITS RECONSTRUCTED (2026-07-29, #80) — the section below records the GAP as found; all three ## layers are now fixed and live-verified The recovery: the un-exported gap held `Mech::TakeDamageMessageHandler @0x4a0230` (found via the Mech **message table @0x50bdf8** — rows `{id, name, handler}`: 0x12 TakeDamage, 0x14 PlayerLink, 0x15 RealMaxSpeed, 0x16 BalanceCoolant, 0x17/0x18 Set/ClearBurningState, 0x19 EjectPilot @0x49f854, 0x1a DuckRequest @0x49fa00) and the **crit-chance roll `@0x4a0164`**: `p = clamp(0.7·damageLevel² + 0.01, 0..1)`, gated on the player's `simLive` flag (+0x25c — novice never crits), rolled per BURST on the current zone (skip if the zone is already burning). The handler's application loop (binary @0x4a0423-0x4a04d8, now in `mech.cpp`): per burst — crit roll → `CriticalHit @0049ccc4` (replaces the zone application; routes half through armour internally, tally += the subsystem's `CriticalHitScoreBonus`) else `zone->TakeDamage`; then **re-run the cylinder lottery from the impact point for the next burst** (multi-burst damage sprays), stopping early once the mech is disabled. The engine base's single-application (which ignored `burstCount` entirely — multi-burst under-applied (burst-1)×) is superseded. `MechSubsystem::TakeDamage @0x4ac0bc` is real (zone damage → destroyed alarms → **vital-subsystem kill**: owner `graphicAlarm` level 9, the #28 machinery — CLASSMAP's "HandleMessage@4ac0bc" was a mislabel). Live-verified: `[subarmor]` shows parsed scales at spawn; `[critroll] zone=3 -> Myomers subLvl=1` — a full chain crit destroying a subsystem. Still deferred [T4→documented]: the `damageType==0` COLLISION divert (@0x4a0368 → `0x49ffcc`, its own distribution) and the **id-0x16 damage/kill reports** to shooter+victim players (@0x4a04da-0x4a07b2 — the authentic stats plumbing, feeds #45: `{tally, zone, destroyed-flag, inflicting subsystem, victim name}`, kill-flagged variant on newly disabled, plus a killed-by block gated on movementMode 9/10). Diags: `BT_CRIT_LOG` (`[subarmor]` + `[critroll]`), `BT_DMG_LOG`. ## (HISTORICAL — the gap as found 2026-07-29, superseded above) [T1] The authored crit machinery exists and is reconstructed — `Mech__DamageZone::CriticalHit @0049ccc4` (half the damage to armour, half to ONE critical subsystem chosen by `criticalWeight`, capped by `damagePercentage`) — but **nothing in the port calls it**. Raw byte-scan of the binary: exactly ONE call site, `@0x4a0461`, inside the **un-exported gap** (no decomp covers `0x4a03xx-0x4a05xx` — the same dark region as the `0x37c/0x388/0x38c` pick writer), so the crit TRIGGER CONDITIONS (per-hit chance? threshold crossing?) are unknown and unreconstructed. Second layer: `MechSubsystem::TakeDamage` is an **empty bring-up stub** (`btstubs.cpp:179`), so even a wired caller would measure a delta of 0 through `ApplyDamageAndMeasure` (and the weapon-family overrides chain to the engine base whose private-zone write is the zero-`damageScale` no-op — see the subsystem-zone finding above). Net: **the Critical view lights ONLY on zone destruction (`SendSubsystemDamage`, direct pin) or ammo cook-off (`DistributeCriticalHit`, direct pin), never from accumulating fire.** The DISPLAY is fine — the paper doll shows ZONE ARMOR, the Critical panel shows SUBSYSTEM crits (different data by design); the panel's near-permanent emptiness is the gap. Field signature (night 6, Conn Man): "Armor panel damage. Critical damage display did not show any crits." **#28 (vital-subsystem-crit death path) is very likely blocked by the same missing caller.** Recovery: raw-disasm the `@0x4a04xx` container (#60-class gap work) for the trigger; dump MechSubsystem vtable `0050e210` slot `+0x24` for the real TakeDamage body; wire both. Tracker: #80. ## 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 was = (6b) the name-load; (6c) the table + `ResolveHit→zone`; (6d) the `Mech::TakeDamageMessageHandler` override. **✅ ALL DONE — see "STEP 6 COMPLETE" below (`d07ac7d`):** the name-load BYPASSED the no-op `ResourceFindByName` via `SearchList(type=0x14)` (mech.cpp:1631), so the table loads live (`[cyl] table 'bhk1'/'madcat'/'ava1' layers=7`) and `ResolveHit` resolves unaimed hits to zones. **Nothing was guessed — geometry, roll, handler, and byte format all verified.** ## The classes ALREADY EXIST — `game/reconstructed/dmgtable.cpp` (2026-07 discovery) The three container classes are **already reconstructed** in `dmgtable.cpp`/`.hpp` (in the build): `DamageLookupTable` (layers=height) → `PieSlice` (slices=angle, `SelectSlice`) → `DamageZonePercentTable` (leaf, `SelectZone`=the roll, `ReadEntries`=stream parse). It **independently confirms this RE exactly**. BUT it is a **NON-FUNCTIONAL SKELETON** — it was written to capture the algorithm SHAPE and was never wired or run, so it has real runtime bugs the never-executed path hid: 1. Its `ReconTable`/`NewTableEntry`/`NewRefCount`/`RandomUnit` are **no-op shim stubs** (mechrecon.hpp — `Insert(){}`, `Lookup()→0`) → the table stays empty + lookups return null. Must be backed by REAL storage (safe: `ReconTable` is used ONLY in dmgtable). Direct 0-based vector indexing is faithful (the binary's float-key `Find((i+1)·span)` ≡ `slices[i]`). 2. `DamageZonePercentTable::ReadEntries` **skips the cell name-string** (`FUN_00402948` = `[i32 len][len+1 bytes: chars+NUL]`) that precedes the entry count → must read+discard it first or the parse misaligns. 3. `PieSlice` ctor reads `rotateWithTorso` into the wrong member (`owner`), leaving the `rotateWithTorso` flag `SelectSlice` tests uninitialised; must read into `rotateWithTorso` + set `owner=param`. 4. `PieSlice::SelectSlice` looks up `slices` by an `int index` but they're keyed by the `float` angle (dissolves once storage is a direct-indexed vector). 5. `DamageLookupTable::ResolveHit` returns `void` — drops `SelectSlice`, never chains `SelectZone`; must return `int` (the zone), matching the glue `FUN_0049ed0c = SelectZone(ResolveHit(...))`. 6. `Mech::WorldToLocal` / `TorsoOrientationSource` are DECLARED (mech.hpp) but UNDEFINED (never linked because dmgtable is never called); the height (`mech+0x2ec`=`[0xbb]`, the collision cylinder, `+0xc`=height) + torso heading (`torso+0x1d8`) need NAMED accessors (databinding trap — no raw offset reads). So 6c = back the classes with real storage + fix bugs 2-5 + implement the 3 accessors; 6d = wire the mech ctor (real name-load, replacing the mislabeled `StandingAnimation` stub @mech.cpp:1221) + the handler override. ## ✅ STEP 6 COMPLETE (2026-07-08) [T2] Built + runtime-verified. Changes: - **`dmgtable.cpp`/`.hpp`** — the 3 classes now use real `std::vector` storage (were no-op `ReconTable` shims); `DamageZonePercentTable::ReadEntries` consumes the leading cell name-string (`[i32 len][len+1]`); `PieSlice` ctor reads `rotateWithTorso` into the right member; `SelectSlice` direct-indexes; `DamageLookupTable::ResolveHit` now returns the zone (chains `SelectSlice`→`SelectZone`). Stream type is the real `MemoryStream` (`ReadBytes`). - **`mech.cpp` ctor** — replaced the empty-name stub with the real load: `FindResourceDescription(dzRes-> resourceName, DamageLookupTableStreamResourceType/*0x1d*/)` → `DynamicMemoryStream` → `new DamageLookupTable` cached in the **named member `Mech::damageLookupTable`** (binary `this[0x111]`/byte 0x444; was mislabeled `ammoExpended` in the offset map); `~Mech` `delete`s it. ⚠ It was ORIGINALLY "cached" at `Wword(0x111)` — the absorber bank — which stores NOTHING, so the whole unaimed path was inert until 2026-07-08 (the "can't kill the enemy" bug). See [[reconstruction-gotchas]] §2. [T2] - **`Mech::TakeDamageMessageHandler`** override registered (`MESSAGE_ENTRY(Mech, TakeDamage)`, overlays Entity's by ID): the handler FIRST feeds the raw `Damage` to the Gyroscope hit-bounce (`GyroApplyDamage`, @0x4a0264 hub, task #56) before zone resolution; then on `invalidDamageZone`, `msg->damageZone = table->ResolveHit(msg->damageData.impactPoint)`, clear the flag, then base-route. Aimed hits pass straight through. - **Named accessors (no databinding-trap raw reads):** `Mech::WorldToLocal` (`localToWorld.MultiplyByInverse`), `CylinderReferenceHeight` (`standingTemplateMaxY` = `collisionTemplate->maxY` = the binary `mech+0x2ec[+0xc]`), `TorsoHeading` (via the `BTGetTorsoTwist` bridge in `torso.cpp` → `Torso::CurrentTwist` = torso+0x1d8; torso.hpp can't be `#include`d into mech.cpp — subsystem-stub collision). - **Runtime verify (2026-07-08, POST-Wword-fix — the earlier "verified" claim was PARTIAL: the handler wiring was live but the table cache was inert):** with `BT_AUTOFIRE=1 BT_AUTODRIVE=0.5 BT_SPAWN_ENEMY=1`, the full chain runs end-to-end: `[cyl] unaimed hit → zone N` resolves across **19 distinct zones** (front-facing zones 0/6/17/15/4 dominate for head-on fire, as the geometry predicts), structure climbs gradually per hit, the enemy dies after **14** hits (not 1) via the authentic cascade, 4+ zones reach `DestroyedGraphicState`, and the destroyed segment meshes visibly swap (RemakeEntity). Env gates `BT_CYL_LOG=1`, `BT_AUTOFIRE`/`BT_AUTODRIVE` (dev force-inputs, mech4.cpp). [T2] - **Fire path now UNAIMED (2026-07-08):** both bring-up hit paths (the SHOT block + the projectile impact, mech4.cpp) dispatch `zone=−1` + a world impact point (beam entry point = enemy origin shifted ~3u toward the shooter at muzzle height; the projectile uses its own impact position) → the cylinder resolves the struck EXTERIOR zone. The old code aimed the internal VITAL zone directly → invisible 1-shot kills (internal zones are soft; the binary never aims them — they die only via the destruction cascade `RecurseSegmentTable`/`SendSubsystemDamage`). On kill the wreck STAYS TARGETED (`gEnemyMech` is NOT nulled) so beams keep terminating on it — nulling it made every later beam a "free" ray that visibly passed through the wreck; scoring latches off via `gEnemyDestroyed` — until the burial transition drops the lock (`gEnemyMech = 0` when the buried wreck goes INERT, mech4.cpp). [T2] `Mech__DamageZone::TakeDamage` → engine armor model (`damageLevel += damageAmount*damageScale[type]`) → 1.0 = zone destroyed → death. The Mech ctor populates the inherited `damageZones[]` from the DamageZoneStream (type 0x14) resource. `class Damage{ damageType (Collision/Ballistic/Explosive/Laser/Energy), damageAmount, damageForce, surfaceNormal, impactPoint, burstCount }`. Verified: `structure` climbs, vital zone destroyed → `*** TARGET DESTROYED ***`. [T2] Two non-field fixes were needed: **message-handler chaining** (an unchained set drops TakeDamage silently) + **entity validity** (`SetValidFlag()` on a manually-spawned entity) — see [[reconstruction-gotchas]] §9. [T2] ## Ram (mech-vs-mech collision) damage — the economy (2026-07-12) The binary's `Mech::ProcessCollision` (part_012.c:15280-15413) computes contact damage with the engine `Mover::StaticBounce` [T0 MOVER.cpp]: **damage = 0.0005 × (1−e²) × impact² × moverMass** (KE loss; `impact` = own `worldLinearVelocity·normal`; mass/elasticity/friction stream from the model gamedata `MoverMass` etc.). On a Mover owner it gates on separating-contact (`normal·rel < −1e-4 → return`), then on `ClassID==0xbb9` dispatches a `DamageMessage{id=0x64, zone=−1, CollisionDamageType}` at the OTHER mech (the receiver's cylinder table resolves the zone). Ported in `BTDispatchCollisionDamage` (mech4.cpp) [T1]. **Observed live economy [T2]:** weapons 3.4-11.8 per hit; a walking/running ram = 13-62 per contact frame; a sustained ram killed a pristine mech at 102.5 total (4 contacts) via (presumably) a vital torso zone — per-zone HP = the authored `CollisionDamagePoints` (fallback `WeaponDamagePoints`), scale = 1/points, replayed from the type-0x14 stream. Whether ~100-pt ram lethality matches the pod is unverified (the authored collision points for the torso zones haven't been dumped) [T3]. **⚠ The one-shot bug (fixed 2026-07-12):** multi-solid frames amplified the velocity StaticBounce priced damage from (62 → 112,375; killed a pristine mech through a single bump) — gotcha #16 in [[reconstruction-gotchas]]; fix = `frameEntryWorldVelocity` restore per contact + `Mech::Reset` motion zeroing. `[collide-tx]` (BT_MP_NET) logs every dispatched ram with the pricing velocity. ## 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] **⚠ The SILENT death-crash (Gitea #12, FIXED 2026-07-20) was NOT a mech-teardown bug at all — it was the DEATH-EXPLOSION AUDIO.** A silent hard crash (no WER/markers) a few seconds after ANY mech death — solo dummy, MP self-death, MP peer PEER_DOWN — deterministic on the first kill. Root cause: the death boom is the **AllExplosion preset (25 layered zones)**, but the port's OpenAL `SourceSet.sources[]` array was only **5** wide, so acquiring channels for it overwrote adjacent heap; the corruption turned fatal seconds later at the death Explosion's own audio-entity teardown (`FryDeathRow → Explosion::~Explosion → …DestroyEntityAudioObjects → ~Dynamic3DPatchSource → operator delete`) or the next sequenced virtual dispatch (`AudioControlSequence::RunSequence → Send`, garbage vtable). Fixed by sizing `sources[]` to the real max — see [[reconstruction-gotchas]] §21 (Gitea #12). NOT the held #16 boresight-eye dtor (exonerated), NOT the `rev=-64` mppr value (a `ControlsButton` negative = release, benign). [T2] ## Death SEQUENCE reconstruction — the map (2026-07-08) Full decomp map of the death path. Two tiers: **EXPORTED (reconstructable) [T1 read]:** - `Mech::AdvanceLegAnimation` (`FUN_004a5028`) / `AdvanceBodyAnimation` (`FUN_004a5678`) — the **fall-anim latch**: `movementMode` (mech+0x40) **5/6/7/8** = the four fall directions → animation-TABLE slots **0x1c-0x1f** (`table[i]` at `mech+0x5cc+i*4`; `SetLegAnimation`/`SetBodyAnimation` = `FUN_004a7fc4`/`FUN_004a800c` @part_012.c:13557/13570), one-shot via `deathAnimationLatched` (mech+0x650). Already reconstructed (mech2.cpp:550-557/817-822). ✅ **RESOLVED (2026-07-08, task #32): the fall latch is a VESTIGE — BT 4.11 has NO collapse animation.** [T1] Four convergent proofs: (1) the clip-table loader `FUN_004a80d4` (@part_012.c:13604) fills slots 0x00-0x1b + 0x20 (the `bmp` knockdown) by name and RETURNS — slots 0x1c-0x1f are never written; (2) the offsets `mech+0x63c..0x648` appear in NO exported function at all; (3) BTL4.RES ships **27 clip codes per mech** (`swr wwl wwr wsl wsr wrl wrr rrl rrr rwl rwr bmp bbl bbr bsl bsr sbl sbr ggl ggr gsl gsr wgl wgr sqd squ trn` + `*i` variants) — no death/fall clip; (4) firing the latch would bind the zero-initialised slot = **resource id 0 = a StaticAudioStream** as keyframes (garbage) — dead code in a shipped arcade build. The authentic BT death modes are the FREEZE modes: `IsDestroyed()` == (`movementMode` 2 || 9), locomotion zeroed by `FUN_004ab1c8`. `UpdateDeathState` (mech4.cpp) settles straight to 9 (never 5-8, which would trip the garbage latch). - ✅ **The DEATH VISUAL is the WRECK-HULK SWAP, not a fall (2026-07-08) [T1].** The authentic chain: death fires the victim's per-mech **death ModelList** (`blhdead`/`lokdead`/`owndead`/`thrdead` = .RES ids 22-25; + `trkdead`/`bigdead`/`meddead` 32-34 for icons), whose authored video objects dispatch (verified live from resource 22): **effect 104** = the burning-WRECK script + **1007** (dnboom big explosion) + **1001** (ddthsmk rubble smoke plume) + a 3/4/5/15 damage-smoke burst. The 1996 `ExplosionScripts` **case 4** (part_008.c:2663, LIVE — the "disabled" warning is case 6/ effect 106) loads the destroyed hulk + `flamesml/flamebig.bgf` flames with sweep-flicker and a slow settle. Every mech ships its hulk (`BLHDBR/MADDBR/LOKDBR/VULDBR/AVADBR/OWNDBR/SNDDBR/THRDBR` + `GENDBR` generic + `LDBR/MDBR/RAPDBR/STIDBR`); the 1996 script hardcodes `thrdbr.bgf` (dev shortcut). **RECONSTRUCTED**: `BTL4VideoRenderer::SwapToWreck` (btl4vid.cpp) — kill → `blhdead` Explosion → effect 104 → hide every segment mesh + hang the victim's own `dbr.bgf` on the tree root (gendbr fallback; pending-swap if death precedes tree build). Mesh flames + the settle motion are noted follow-ups; burning reads via 1001/1007 + the wreck-smoke re-arm. Verified: `[BTrender] wreck swap: victim -> 'blhdbr.bgf'`. [T2] **HULK CONTENT CENSUS (2026-07-12) [T1, byte-diffed]:** the shipped `*DBR.BGF` set shares geometry heavily — `AVADBR`/`MADDBR`/`VULDBR` are byte-identical EXCEPT the material-library prefix (`avafx:`/`madfx:`/`vulfx:` — same shape, per-mech palette); `RAPDBR`/`SNDDBR`/`STIDBR` are fully byte-identical (same md5). Distinct shapes: BLH (1537v), LOK, OWN, GEN (659v), the AVA/MAD/VUL shape (1351v), the RAP/SND/STI shape, `MDBR` (90KB), `LDBR` (46v scatter). All are the same authored style (gray `genNmedgry`/`blakskn` + orange `dam3orng` slump piles), so two different mechs' wrecks legitimately read alike; the original's visual differentiation came from the case-4 DRESSING (flamesml/flamebig flames + chunk/fireball stagger + the MakeDCSFall torso-height collapse), which is still a follow-up. ⚠ `THRDBR.BGF` is a 153-byte EMPTY placeholder (one zero-geometry patch, `btfx:sparkred_mtl`) — it loads "successfully" as an invisible object, so the gendbr fallback (which only fires on load FAILURE) never triggers for a Thor: a Thor wreck would show only ldbr debris. [T1 content; T4 whether the pod binary special-cased it] ⚠ **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. **IDENTIFICATION (task #1, 2026-07-11): `movementMode` (mech+0x40) IS `Simulation::simulationState.currentState`** -- the StateIndicator @0x2c (+0x10 oldState, +0x14 currentState); every binary "movementMode write" is a `SetLevel(this+0x2c, n)` call. The port's parallel `int movementMode` member is gone: `MovementMode()/SetMovementMode()` accessors ride the engine StateIndicator, which is why the death state (9) now REPLICATES -- it travels in every update record's header (+0xC) and the observer's replicant runs its own wreck sink off it (see [[multiplayer]] "Mech-level update records"). [T1] - **✅ Critical-subsystem propagation LIVE (task #2, 2026-07-11) [T2]:** a zone driven to 1.0 cascades its streamed crit allotments into the carried subsystems' OWN private DamageZones (SendSubsystemDamage @0049c9a8, rewritten to the recovered body); a subsystem at 1.0 fails (statusAlarm Destroyed + zone valve), a VITAL one (`vitalSubsystem@0xE4`, ex "videoObjectFlag") kills the mech via graphicAlarm 9. The plugs bind in the zone ctor (`SlotOf.Add(subsystemArray[streamedIndex])` @0049d0e1 — Ghidra had dropped the call's arguments, hiding the binding for the whole effort); `CriticalHit` uses the real `ApplyDamageAndMeasure` @4ac07c. Diag: `BT_CRIT_PROBE=`. Detail: [[open-questions]] (the resolved entry) + mechdmg.cpp/mechsub.cpp. - **`MechDeathHandler`** (ctor `FUN_0042a984` + Performance `FUN_0042aa2c`, cached mech+0x850 / `mech[0x214]`) — the **per-subsystem destroyed-skin + explosion engine**. Each tick walks the damage subsystems (mech+0x120[mech+0x11c]); as a subsystem's damage crosses a descriptor-table threshold (`FUN_0042a5f4` detects the crossing, `FUN_0042a664` looks up the state) it applies the `GraphicState` ("Destroyed" = enum 1, parsed @part_003.c:9099) to the subsystem graphic (`sub+0xd4`) AND dispatches an effect-creation message (`FUN_0043663c`→`FUN_004364e4` to the effect mgr `app+0x38`). ✅ Fully exported; was a no-op stub (mech.cpp:221) — now fully RECONSTRUCTED (mechdmg.cpp — ctor @0042a984 / Tick @0042aa2c; wired in the Mech ctor, mech.cpp:1469) [T2]. Gamedata `DeathSplashDamage`/`Radius`/`Effect` load into a descriptor @ +0x74/78/7c (`FUN_004a2da8`). Destroyed-skin variant set (`destroyed`, `destroyeds`…`destroyedd`) @part_003.c:5720. **NOT EXPORTED (master-perf gap 0x4a9770-0x4ab188 + the un-exported TakeDamage) [T4]:** - The `movementMode` WRITER (sets 5-8 then 2/9 on death) + the fall-direction (5/6/7/8) selection. - The `Mech::DeathShutdown` roster loop (RP `VTV::DeathShutdown` analog — loop subsystems calling the `Subsystem::DeathShutdown(int)` vtable slot; the base is an empty virtual, SUBSYSTM.h:166). - The whole-mech `DeathEffect`/`DeathSplash` radius dispatch + the `graphicAlarm.SetLevel(9)` call site (the recon already raises the alarm in mechdmg.cpp:426/586). **The problem:** the orchestration lives in the per-frame Simulate that the **bring-up drive override bypasses** (btstubs.cpp:116-119), so the death consumer must be reconstructed from its exported consumers + the RP analog and wired into the active path (mech4.cpp `PerformAndWatch`). ### ✅ Death-STATE core DONE (2026-07-08) [T2] `Mech::UpdateDeathState()` + `IsMechDestroyed()` (mech4.cpp) reconstruct the un-exported death branch from its exported consumers + the RP analog, called for EVERY mech early in `PerformAndWatch`: on `graphicAlarm >= 9` it (1) settles the death mode STRAIGHT to `movementMode = 9` (mech4.cpp:1183) — there is NO `movementMode = 5` collapse write (the only other writes are the drive's `= 1`, mech4.cpp:1044/1986); the fall latch 5-8 is never fired (task #32: no collapse anim exists in BT 4.11, see the VESTIGE proof above) [T1]; (2) loops the roster calling `Subsystem::DeathShutdown(1)` (RP `VTV::DeathShutdown` analog; base is a no-op virtual, a SHUTDOWN not teardown → safe, wreck STAYS); (3) `movementMode` 9 → `IsDisabled` → locomotion frozen. The drive's lone `movementMode = 1` write (mech4.cpp ~1309) is guarded on `!IsMechDestroyed` so the death state isn't clobbered. **Runtime-verified** (forced kill): `[death] … collapse + subsystem shutdown` → `[death] … settled -> disabled (IsDisabled=1, frozen wreck)`, mech frozen in place (pos unchanged), subsystem tick + renderer keep running, **no crash, wreck stays**. Env gate `BT_DEATH_LOG=1`. **Deferred (honest):** (a) ✅ RESOLVED (task #32) — there IS no collapse animation to latch: the fall latch is a VESTIGE (see the proof above); the authentic death visual is the wreck-hulk swap, and the death mode settles straight to 9. (b) ✅ RESOLVED — **`MechDeathHandler`** is fully RECONSTRUCTED (mechdmg.cpp — ctor @0042a984 / Tick @0042aa2c; wired in the Mech ctor, mech.cpp:1469) [T2]; destroyed-skin swaps + zone explosions verified live (see STEP 6 above). (c) the whole-mech **DeathSplash** radius damage (un-exported). ### ⚠ Death-latch correction (2026-07-09, task #52) [T1→T2] `IsMechDestroyed()` originally tested **`graphicAlarm >= 9` alone** — a bring-up shortcut that made a wreck **resurrectable**: `graphicAlarm` is a STATUS indicator, and a later leg hit on the wreck legitimately rewrites it to 4/3 (`SetDamageState`, mechdmg.cpp:417/419) → `IsMechDestroyed()` flipped false → the drive's guarded `movementMode = 1` write revived the gait → the NEXT vital hit re-ran the ENTIRE death transition (double kill-score, double `VehicleDead(-1)`; during the respawn window the re-entered `BTPostKillScore` → `Player::ScoreMessageHandler`'s `Check(playerVehicle)` **abort()** — engine MUNGA TUs compile with `Check` ACTIVE; caught live under cdb, 2-node force-damage). The AUTHENTIC latch is the death `movementMode`: `IsDestroyed == (movementMode 2 || 9)` (`FUN_0049fb54`/`FUN_004ab1c8`) — the death modes never revert. Fix: `IsMechDestroyed()` = `movementMode 2||9` (latch) **OR** `graphicAlarm >= 9` (the vital-kill TRIGGER that first enters the transition). Lesson: **a gauge/status alarm is never a state latch** — later writers rewrite it; latch on the state machine's own mode. **ROOT-CAUSE now fixed AT SOURCE (task #60):** the illegitimate wreck rewrite to 4/3 came from `mechdmg.cpp:458` gating the leg partial-failure graphic on the always-0 `IsAirborne()` stub instead of `IsDisabled()` (`@0049fb54` = movementMode 2||9). On a wreck the binary SUPPRESSES that write; the stub let it fire and corrupt `graphicAlarm`. Now `mech->IsDisabled()` — the latch above remains as defence-in-depth. Sibling fix: `mechdmg.cpp:451` read the phantom `stance` (perma-0) instead of `MovementMode()`, so the leg-shot-out → `graphicAlarm=9` fall/death branch was entirely DEAD; now live. ## Kill-score damage (task #60) `BTPostKillScore` (btplayer.cpp:1491) feeds the ScoreMessage `damageAmount` into the kill award (`@0x4c02e4` → `(damageAmount + scoreAward) × roleScalar × teamMult × tonnageRatio`, `@0x4c052c`). The port passed a flat `kShotDamage=12` (mech4.cpp:1551), so every kill scored identically regardless of the finishing weapon. Now it passes `lastInflictingDamage` — the real killing-blow magnitude latched in `TakeDamageMessageHandler` (mech.cpp:624), mirroring the working per-hit score path (mech4.cpp:1207). `scoreAward=0` (the authentic kill BONUS) stays an open bring-up gap — it lives in the un-exported master-perf writer (0x4a9770-0x4ab188 [T4]); the exact authentic `damageAmount` that writer emitted (msg+0x24) is likewise unrecovered, so the killing-blow magnitude is the faithful stand-in until it is. [T1 mechanism / T4 exact authentic value] ## Missile splash damage — `Explosion::SplashDamage` (task #62, 2026-07-13) [T1/T2] Area-of-effect blast on **missile** detonation — NOT the vestigial whole-mech `DeathSplashDamage` above (mech+0x520, never read). The T0 source is `engine/MUNGA/EXPLODE.cpp:50-254` (`@0042fad0`), called ONLY from the Missile's per-frame `Perform` (`@004bef78`, part_013.c:10097) inside the collision branch — so it fires on **any** missile impact (mover OR world geometry), gated on `Missile+0x360`. The AC's tracer is not a Missile and never splashes. **The model [T0, `EXPLODE.cpp`]:** build a Y-axis cylinder of `SplashRadius` at the blast, collect every `Mover` in it (+ static world), then for each target that has `damageZones` and is not the direct victim (`entityHit`), the detonator (`this`), or `excluded`: - `burstCount = baseBurst / pow(dist, 1.2f)`, `Min_Clamp(…, 1)` — dist = `|target.localOrigin − blastCenter|` (center-to-center). The **arcade** exponent is **1.25** (decomp `0x3ff40000` at part_004.c:865; the decompiler hid the x87 FDIV + the `FUN_004dcd94`=ROUND that WinTesla writes as truncation). Point-blank AMPLIFIES (dist<1 → >baseBurst); no distance floor (real victims are never at dist~0 — the one AT the center is the excluded direct victim). - `damageType`/`damageAmount` pass through UNCHANGED; only `burstCount`, `damageForce` (radial), and `impactPoint` (= blastCenter+offset) are set. Delivered via `TakeDamageMessage(inflicting, zone=-1)`. **The SplashRadius source [T1]:** it is the ROUND's GameModel resource `+0x50`, NOT the launcher's `ExplosionModelFile`. The launcher seeds each spawned Missile's model from its linked **AmmoBin** `ammoModelFile @0x1e8` (part_013.c:8778); the Missile ctor `SearchList(that_id, GameModelResourceType)` → `+0x50` → `Missile+0x364` (part_013.c:10184). The MissileThruster GameModel parser `FUN_004bf8ec` writes `"SplashRadius"` to `+0x50` of the type-`0xf`/size-`0x54` record. Live-resolved radius for both MP missile launchers = **30** (`BT_ROSTER` dump). **Port impl (`mech4.cpp`):** `BTResolveSplashRadius` gates on `MissileLauncher::ClassDerivations`, resolves launcher → `BTWeaponAmmoBin` → `BTAmmoRoundModelResource` (+0x1e8) → GameModel +0x50. `BTApplySplashDamage` walks `BTGetTargetCandidates` (excludes shooter), skips the direct victim + destroyed/zoneless mechs, applies the `dist>radius` gate + the burst falloff, and delivers via a DIRECT `e->Dispatch` to each victim (T0 `EXPLODE.cpp:246`). Hooked at BOTH detonation paths (world-impact + contact). **⚠ MUST NOT route splash through the shooter's `SubsystemMessageManager`: `AddDamageMessage` CONSOLIDATES every damage message of a frame onto the FIRST hit entity (`commonDamageInformation.entityHit`, messmgr.cpp:279) — so splash for the bystander landed on the DIRECT victim instead (2026-07-13 bug: the direct victim took 8 direct + 8 mis-routed splash = 16 applications, the bystander 0; a direct `Dispatch` splits them correctly 8/8).** Dispatch reroutes cross-pod for a replicant victim on its own. **⚠ THE SALVO-LEAD FIX (task #62 bug, found 2026-07-13 by live regression) — the N-round trap.** **KEY FACT [T1]: `DamageZone::TakeDamage` (arcade `@0041e4e0` == WinTesla `DAMAGE.cpp:379`) is `damageLevel += damageAmount * damageScale[type]` and IGNORES `burstCount`.** ⚠ **CORRECTION (2026-08-01, #95): do NOT generalise that into "burstCount is cosmetic for zone damage" — the earlier wording here said exactly that and it is WRONG.** `TakeDamage` ignores it; the **CALLER honours it**. `Mech::TakeDamageMessageHandler` (@0x4a0423-0x4a04d8, reconstructed in `mech.cpp` under task #80) loops `burstCount` times, calling `TakeDamage` once per burst and **re-rolling the struck zone each iteration** (@0x4a04b9) — so a burst SPRAYS across zones. `burstCount` is therefore "number of applications", and it is load-bearing for missiles (cluster count), splash (distance falloff) and the gyro bounce alike. So ONE arcade cluster Missile per trigger (damageAmount = authored/missileCount, burstCount = missileCount) delivers its salvo through that loop, not in a single application. **How many of the cluster connect is ROLLED at impact** in `Missile::Perform` right before dispatch (part_013.c:10082): `b = Random(n) + n/4`, clamped to `n` — between a quarter of the salvo and all of it. **And the arcade Missile dispatches DIRECTLY at the struck entity** (`FUN_004be078`: `param_2->Dispatch(&msg)`) — it does NOT route through the shooter's `SubsystemMessageManager`, whose consolidation would drop `burstCount` (`DamageInformation` carries only damageType + subsystemID). Routing a projectile through the manager therefore silently deletes the cluster count — that was the #95 bug, and it also produced the #84 double explosion. The port re-expresses that ONE cluster as N flying `BTProjectile` rounds (visual tracers), and task #62 damaged + splashed on EVERY round → **~`missileCount`× too lethal on BOTH the direct hit and the splash** (user-reported "missiles kill in 2 shots" — a mech that should take many salvos). Fix (`mislanch.cpp` FireWeapon): only the salvo-LEAD round (`i==0`) carries damage (`damageData. damageAmount`) AND the cluster splash (`splashBurst = nmiss`); the other N-1 rounds are VISUAL (damage 0, `splashBurst 0`) — the ripple of tracers. `BTProjectile.splashBurst` gates the two splash hooks; the direct-hit block already skips `p.damage<=0`. Replicant mirror rounds carry 0 (master delivers cross-pod — no double). **Lesson:** a cluster weapon re-expressed as N rounds must apply ALL zone effects (direct damage AND splash) ONCE per salvo, not once per round — the arcade's single Missile is one damage event, `burstCount` notwithstanding. **Verified [T2]:** per-hit missile damage = clean 5.83, burst 1, ONCE (`[dmghit]` trace); radius 30 for missiles / 0 for AC+Emitters. Missiles-ONLY, single enemy, 45s: 8 damaging hits, top zone reaches only 0.18, **NO death** (was death in ~3 pulls pre-fix) — "way more than 2 shots" restored. AC (one round/shot, not a cluster) unaffected. Env `BT_SPLASH_LOG`, `BT_DMG_LOG` (per-hit `[dmghit]` + zone-armor), `BT_AF_MISSILE` (missile-only autofire, independent of BT_AUTOFIRE), `BT_SPAWN_ENEMY=N` (N clustered dummies). ⚠ `BT_AUTOFIRE` fires ALL weapon groups (laser+PPC+AC) — use `BT_AF_MISSILE` alone to isolate missile TTK. **Deferred [T3]:** the per-player enable sub-gate `Missile+0x360 = BTPlayer+0x264` (part_013.c:8757) — its decomp writers (4668/10512) read as a per-frame state toggle, not a clean config flag, and the port's `showDamageInflicted` label is itself a guess; port treats authored `SplashRadius>0` as the enable. See [[open-questions]]. ## Collision damage — the type-0 DIVERT + rattle distributor (#83, 2026-07-30) [T1 disasm/T2] `Mech::TakeDamageMessageHandler` diverts `damageType==0` (@0x4a0368) into `DistributeCollisionDamage` (FUN_0049ffcc, export gap, raw disasm `scratchpad/night6/ gap_49ffcc.txt`): collision damage **never reaches the zone/armor loop**. Gate: the owning player's advancedDamage copy (+0x268, `BTPlayerAdvancedDamageOn` bridge) — the manual's "splash/collision damage" technician setting. Pricing: `raw × (2000/moverMass)/(100·(1/3.6))²/ (1−elasticity²)` (constants @0x4a0148=1/3.6 tbyte, 2000f, 0.5f threshold; mass@+0x20c, elasticity@+0x244 by Mover-layout walk); scaled <0.5 = FREE. Above: `n=Round(2×amount)` sub-hits of amount/n, each landed on ONE roster subsystem drawn by cumulative `collisionCriticalHitWeight` (@0x10C, MechSubsystem) vs a [0,1) roll, eligibility = IsDerivedFrom HeatSink-family/Gyroscope/Torso (GUIDs 0x50e590/0x50fdc0/0x510b08; all MechSubsystem-based, so +0x10C is valid), applied via `ApplyDamageAndMeasure` (@0x4ac07c — the subsystem TakeDamage virtual into its PRIVATE crit zone). Un-won rolls land nowhere (weights un-normalized — faithful). Mech-vs-mech ram damage arriving as type 0 prices the same way. Measured on bhk1 (mass 60000, e=0.2): scale 4.5e-5; a 94k spawn-slam = 4.2 rattle points; taps free. `[colldmg]` (BT_DMG_LOG) + `[crashdmg]` (always-on, rare) are the probes. ## The zone DESTRUCTION CASCADE is live -- an arm takes its gun (#110, 2026-08-02) [T1 decomp / T2 verified] `Mech__DamageZone::RecurseSegmentTable @0049cad4`: when a zone reaches damageLevel 1.0 (non-leg, non-vital path in TakeDamage), the binary walks the mech's SEGMENT TREE (mech+0x300, the engine `JointedMover::segmentTable` [T0]): * `destroySiblingsOnDestruction` (streamed, Wword 0x68) -> every other zone on the SAME segment (`EntitySegment::damageZoneTable`, seg+0xD0) recurses and is set graphic state 2 (**Gone**); * `descendOnDestruction` (Wword 0x67) -> every zone on every CHILD segment (`childIndexTable` seg+0xE8 -> child's damageZoneTable) recurses -- **the arm takes the gun pod with it**. Each recursed zone's `SendSubsystemDamage @0049c9a8` pushes the unused crit allotment and `ForceCriticalFailure`s any subsystem driven to 1.0 -- which the #86 fire gates then refuse. Authored (measured, ava1): descend=1 on the four arm zones (2/6/9/17); everything else 0. Verified live: dz_rarm destroyed -> zone 17 cascades -> AFC100 + bin + Condenser6 force-failed, `AFC100 -> NoAmmo (gate1): destroyed=1`; dz_larm -> PPC force-failed, `[emitter] 'PPC' fire REFUSED (destroyed=1)` x4998. Torso weapons unaffected. Re-entry note: every further hit on a 1.0 zone re-runs the cascade (no guard in the binary's child loop; only the sibling loop checks graphic state != 1) -- authentic, idempotent in effect. ⚠ The walk was a SILENT STUB until 2026-08-02 (three shim types whose iterators returned NULL) -- the cascade "ran" and touched nothing, which is why blown-off arms left firing gun pods across every chassis (Conn Man's three-chassis audit). See reconstruction-gotchas #25. ## Key Relationships - Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]]. - P5 forensics: `docs/HARD_PROBLEMS.md`. Data: [[decomp-reference]] §4-5.