Files
BT411/context/combat-damage.md
T
Joe DiPrimaandClaude Fable 5 37dd7f9663 collision-pricing audit: restore the binary's crash economy -- two fixes
A. KNOCKDOWN PACING: the 0.4s gBlockCooldown contact-hysteresis is gone.
It suppressed the crash knockdown for as long as contact was held, letting
the kinematic drive re-slam the obstacle at full commanded speed EVERY
FRAME -- continuous full-price crash self-damage, a healthy 65-tonner dead
in ~2-3s of grinding (the night-7 SAURON ram death, 110 priced frames at
|v|~34.7).  The binary needs no guard (crash threshold @0x4ab178 read from
the exe = 0.0, dispatch per blocked frame is authentic): the bmp clip
zeroes the drive, and a pressed mech re-triggers the knockdown at v>6.3
long BEFORE its grind can price anything -- the type-0 divert's 0.5-pt
free floor needs v>~18.5 at 65t.  Wall grinding is free taps + paced
staggers; only genuine fast arrivals pay.

B. RAW RAM DISPATCH: the *0.001 "ram economy normalization" (2026-07-12)
predated the #83 type-0 divert and became a double-normalization -- the
victim's divert scale (~4e-5) compounded with it, so NO physically
possible ram could clear the 0.5-pt free floor: rams were a no-op against
the victim while the rammer's own un-scaled crash path rattled for real.
The binary dispatches raw [T1 @part_012:15324]; the divert IS the
normalizer.  Also un-starves cultural-icon crunches (trucks now crush on
contact per their authored armor, not after ~19 bumps).

Bench (scratchpad/night7/mp_rampricing.sh):
- wall leg: 150s full-throttle wall push = 125 paced knockdown binds
  (~1.2s cadence, iv2 40-43), 4767 crash frames ALL under the free floor,
  0 rattle, 0 deaths (pre-fix: dead in seconds).
- ram leg: BT_GOTO=enemy + BT_GOTO_STOP=2 rams the peer; raw amount
  arrives byte-identical on the victim (66239.1 tx == rx), victim prices
  2.55 pts internal rattle (first ram damage to a victim in the port's
  history), pressed follow-ups free, 0 deaths both nodes, 108 knockdowns
  -> exactly 108 type-5 records on the observer (no skating regression).

KB swept: combat-damage.md (the 1.3e6 moverMass mis-attribution corrected
to the measured 60-90k tonnage scale), locomotion.md, rendering.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 02:48:35 -05:00

902 lines
77 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 50140).
`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 — zone damage IGNORES it** [T1]; one Damage message = one `amount×scale`
application regardless of burstCount (burstCount is read only by the gyro-bounce math + the
SplashDamage falloff). A cluster missile therefore lands its damageAmount ONCE — see the
SALVO-LEAD FIX below for why the port's N-round missiles must damage once per salvo. ✓
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).
## 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 <zoneName>"` (@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=<frame>`):
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<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 `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 × (1e²) × 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
`<prefix>dbr.bgf` on the tree root (gendbr fallback; pending-swap if death precedes tree build).
Mesh flames + the settle motion are noted follow-ups; burning reads via 1001/1007 + the wreck-smoke
re-arm. Verified: `[BTrender] wreck swap: victim -> 'blhdbr.bgf'`. [T2]
**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<Subsystem*>.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=<zone>`. 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`.** So ONE arcade cluster
Missile per trigger (damageAmount = authored/missileCount, burstCount = missileCount) applies its hit
EXACTLY ONCE to a zone — `burstCount` is cosmetic for zone damage (only the gyro-bounce math
`gyro.cpp:834` and the SplashDamage falloff `damage.burstCount/dist^exp` read it). 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))²/
(1elasticity²)` (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.
## Key Relationships
- Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]].
- P5 forensics: `docs/HARD_PROBLEMS.md`. Data: [[decomp-reference]] §4-5.