Files
BT411/context/combat-damage.md
arcattackandClaude Fable 5 065c114590 Impact-FX FORENSICS wave: the i860 specialfx engine, per-round detonations, the ram economy closed
The "we're off the rails" reinvestigation -- a 5-thread evidence workflow
(logs / PFX data / RES model lists / decomp / port audit) + the i860 firmware
decode, then surgical fixes.  Every claim tiered; the wrong turns are on the
record in the KB alongside the corrections.

THE OLD-STYLE SPECIALFX ENGINE [T1, firmware-decoded from VREND.MNG]:
rebuilt scratchpad/i860dis.py (binutils opcode table); mapped the dispatch
(data+0xdd0c; sfx trigger 0xf040cda0, install 0xf040cdc0, step ~0xf0413698,
instance init 0xf04128d8); decoded the heat model EXACTLY: per 30Hz board
frame h *= cool_a; RGB_ch = h_old*(h_new*cook_ch - 0.25) + 0.25 (K=0.25 =
the ember floor @VA 0xF080; kill at h <= 1e-4 @0xF0A0); alpha fades cool_b
x RAW dt (PER-SECOND -- the x30 scaling made laser hits invisible); 7s cap;
y_off = the kill plane; "variance" is DEAD DATA (binary reads "variance",
INI authors "varience").  The 13 descriptors (PPCHit/LaserHit/MissileHit/
Chunks/Sparks/Fireball...) parse from BTDPL.INI and render via the BTPfx
layer -- heat bursts draw the FIERY sheet (brightness over fire), .PFX keeps
GRAYSCALE (authored colours: DNBOOM orange, DDAM gray).

HIT-PACKAGE CENSUS [T1, RES byte-verified]: ppchit=[8] lzrhit=[9] mghit=[7,11]
canhit=[11] mslhit=[10,12,1023] explode=[6]; all 8 mech death lists identical.
Effect routing corrected in BOTH consumers: <100 = specialfx, >=1000 = psfx.

"SILVER MIST x5" ROOT CAUSE: SHKWAVE.PFX (mslhit's 1023) authors maxIssue=5
relPeriod=0.2 rate=1 -- the ONE file where rate contradicts the window; the
emitter trusted rate -> 5 shells at exactly 1Hz.  Emission rate now always
maxIssue/releasePeriod.

PER-ROUND DETONATIONS [T1 @004bef78]: every missile round spawns its own
ExplosionModelFile at ITS impact (hull + terrain) -- a volley ripples 12
fireballs like the demos; rack-tube launch spread [T3] (GUIDED rounds only --
the slot-0 deflection sent every AFC100 shell 3.4deg left/2.5deg down: the
phantom "4th gold beam"); replicant salvos detonate too (launcher index rides
the visual push).  Missile damage bundles through the shooter's messmgr with
the launcher's subsystemID (mslhit fires at the consolidated point; the
binary's dedup CONFIRMED [T1 @0049b784] -- a workflow agent's per-record
claim REFUTED by direct decomp read).

DAMAGE-BAND SEMANTICS [T1]: MechDeathHandler fires the CURRENT band effect on
any damage rise (the binary's changed-flag coalescing) -- a mauled mech under
fire smokes/burns per hit; bands 3/4 are authored fire plumes.

THE RAM ECONOMY -- CLOSED (3 layers, measured live):
 1. armor = POINTS (every zone: damageScale = 1/armorPoints, armor 50-140;
    the [zone-armor] BT_DMG_LOG dump);
 2. StaticBounce prices rams with authored moverMass ~1.3e6 -> ~59,000 pts
    @10m/s; the binary dispatches it RAW but pod MP dropped it on the local
    replicant (MECH.CPP:986 warns) -- ram damage was NETWORK-INERT; our
    task-#47 replicant forwarding surfaced it as a one-shot.  Port
    normalizes x1e-3 to the point economy [T3];
 3. contact EDGE (ramLastVictim/ramContactLinger): the binary's bounce made
    separation implicit; our gait-derived velocity re-priced full rams at
    60Hz (the respawn explode-loop).  One bump = one hit; pressed = BLOCK.

PLUS: sfx size x0.5 + 1.25m visibility floor + hot-phase occlusion [T3];
particle pool 2048->8192 (missile traffic starved laser bursts -- the
"intermittent effects"); replicant beam aging (a lost beam-END record pinned
a stale beam on forever); UV-variant noise stamps (mask-safe mirror/swap);
no scroll on particle stamps (material-path only); BT_FX_TEST/fxshot.py
self-verification harness ([beam-draw]/[zone-armor]/[collide-tx] telemetry).

KB: rendering.md (specialfx engine + census + closeout), combat-damage.md
(ram economy + band semantics).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 08:14:12 -05:00

409 lines
34 KiB
Markdown
Raw Permalink 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:
- "Per-impact aim (cylinder lookup); collision damage application; DeathShutdown + collapse anim"
---
# Combat & Damage
The drive→target→fire→damage→destroy loop. Full detail: `docs/PROGRESS_LOG.md §10c`; the death/
teardown forensics: `docs/HARD_PROBLEMS.md` (P5).
## No AI — BT is PvP-only (verified, not a recovery gap)
The mission system is NETWORKED and BT never had NPCs/AI. Every mech is driven by a `BTPlayer` (or a
`BTCameraDirector` spectator). The BT registry knows only Mech/Projectile/Missile/BTPlayer/BTTeam; no
Bot/Brain/AI class exists; `BTL4OPT.EXE` has no AI strings. `Crusher/Blocker/Runner` are RED PLANET
classes, NOT BT. ⇒ a solo mission has no enemy by design; `BT_SPAWN_ENEMY` is a TEST DUMMY
(uncontrolled Mech). The authentic opponent path is [[multiplayer]]. [T1]
## Entity spawn + the per-mech gate
`Mech::Make(MakeMessage*)` (`FUN_004a2d48`) allocs `sizeof(Mech)` + runs the ctor; the base Entity
ctor self-registers it (renders + ticks). `Mech::PerformAndWatch` runs for EVERY mech → gate
player-only logic on `this == application->GetViewpointEntity()` (else a spawned enemy is driven by
player input). [T1]
## Targeting (mech offsets, [[decomp-reference]] §3)
`mech+0x37c` = target world Point3D; `mech+0x388` = target `Entity*` (the `HasActiveTarget()` gate);
`mech+0x38c` = targeted sub-zone (1=whole). Weapons cache hasTarget/targetPoint/muzzlePoint,
refreshed each frame. [T1]
**The AUTHENTIC acquisition model (recovered 2026-07-08; corrected 2026-07-08, task #39):** THERE
IS NO FREE-AIM MOUSE CURSOR. The pod had a stick whose yaw drove the **torso twist**
(`MechControlsMapper` writes `HUD::SetFreeAimSlew(stick_x)` → cockpit+0x28C, gated on the torso's
horizontal-twist being enabled — `mechmppr.cpp:735/762`, hud.hpp:167 [T1]); the crosshair marks
where the **torso guns boresight** relative to the body-mounted view. You aim by **steering the
mech / twisting the torso**, not by moving a cursor. The engine `Reticle` struct
(`engine/MUNGA/RETICLE.h`) carries `reticlePosition`, `pickPointingOn`, `rayIntersection`,
`targetEntity`, `targetDamageZone` — a general struct shared with Red Planet — but in BT
`reticlePosition`/HotBoxVector (@mech-HUD+0x1FC) is **COMPUTED by HudSimulation** from the mech
pose quaternion (`FUN_0040954c` quat→euler of mech+0x10C) + the target geometry, and **zeroed to
centre when there is no target** (part_013.c:5680, `FUN_00408440(...,&DAT_004e0f74)` [T1]) — it is
NOT a free-floating cursor. The pick then hits whatever the guns point at; that entity + its
DAMAGE ZONE become the target (`0x37c` rayIntersection, `0x388` targetEntity, `0x38c`
targetDamageZone → aimed-zone damage). Fixed-torso mechs (the BLH: `TorsoHorizontalEnabled=0`)
boresight dead-ahead — the crosshair sits centred and you point the whole mech. Once locked,
`Emitter::FireWeapon` converges with NO aim/arc test (part_013.c:7758). `MechWeapon::
UpdateTargetState` (`FUN_004b9bdc` [T1]): `targetWithinRange = dist < (1 hostZoneDamage) ×
weaponRange`. The 0x388 WRITER (the per-frame reticle→mech copy) is in an un-exported decomp gap.
**Port status: RECONSTRUCTED + runtime-verified (tasks #36/#39, 2026-07-08) [T2].** The chain:
the crosshair = **torso boresight** (`BTTwistToReticleX(torsoTwist)`, L4VIDEO — tan(twist)
projected through the live per-axis projection; **0 = dead-centre on the fixed-torso BLH**;
`BT_AIM="x y"` pins it for headless tests) → `BTGetAimRay` (the ACTIVE eye publishes pos+LookAtRH
basis via `BTSetAimCamera`, the render loop publishes proj._11/_22 + backbuffer dims) →
`Mech::PickRayHit` (world ray → local frame → `BoundingBox::HitBy` slab test vs the collision
template's ExtentBox → world hit point).
**THE WORLD-PICK TARGET MODEL (task #41, the reconciliation — binary + pod-video evidence).**
The target slot `mech+0x388` holds **whatever entity is downrange of the torso boresight** — a
mech OR a WORLD entity (terrain/mesa/building). Evidence: (a) the weapon fire path is DOUBLY
gated on `0x388 != 0` (`FUN_004baa88` :7689 AND `FUN_004bace8` :7727 wrap everything incl.
`beamFlag`=+0x46c :7749 — no target ⇒ no discharge [T1]); (b) pod demo VIDEOS show lasers firing
with no mech targeted — reconciled by (c): HudSimulation :5620 **explicitly handles a target
WITHOUT damage zones** (`target->0x120 == 0`) — dead code unless non-mech entities get targeted;
and (d) `0x388` has **11 reads / ZERO direct stores** across CODE (capstone scan) — the pick is
written indirectly, AUTOMATICALLY, every frame; never a manual player lock. So: **fire at the
enemy under your boresight → aimed zone damage; fire anywhere else → the beam hits the scenery
downrange ("firing at nothing"); only a true sky shot (nothing within range) refuses to
discharge.** The spinning-ring **LOCK** (HudSimulation 5619-5634) is a SEPARATE, stricter state
(mech target only + host-zone < 0.75 damage + targeted-zone < 1.0). **Port implementation:** the
boresight ray tests the enemy's collision box (`PickRayHit` → aimed hull point → STEP-6 zone
under the boresight + hotbox + lock ring), else the terrain (`BTGroundRayHit` to 1200 →
`gBTTerrainEntity` + the ground point — beam/missiles fly to the scenery, NO damage dispatch;
the range caret authentically reads the ground distance, :5639), else no target (sky — the
double gate refuses). Verified all three states (`BT_AIM` 0,0 / 0.6,0.5 / 0,0.8). (⚠ This is
the FOURTH targeting model iteration; the first three — sticky lock, mouse cursor, enemy
auto-converge — all over-read the RP-shared `Reticle` struct or under-read the pick. The
world-pick model is the one that fits ALL the evidence.) The HUD draws the aim group at the
boresight ([0x9a] translate), the designator hotbox at the mech target's projected point (subB9
hot / subB8 designated, `BTProjectHotBox`/`BTProjectToReticle`), and the edge arrows when
off-screen/behind. `BT_FIRE_ARC` is now an EXPLICIT OPT-IN presentation clamp (unset = authentic
no-arc). The old hardwired lock, the ±30°-default cone, and the projectile path's gEnemyMech
fallback are all REMOVED. LMB fires lasers / RMB missiles (with SPACE/CTRL). ⚠ A view-selection
bug was fixed en route: every
renderable rebuild stomped `mCamera` back to the chase eye (btl4vid `mViewInside` now persists
the chosen view — the aim camera feed and the V toggle both depend on it).
## Firing arc + muzzles
There is NO firing-arc field in the binary (`Emitter::FireWeapon` fires whenever HasActiveTarget) —
the 1995 game got away with it because it was **cockpit-only** (a beam behind you is off-camera). The
port's external camera exposes it, so the arc is gated on the **torso twist range**
(`Torso::GetHorizontalReach`, 0 for the twist-disabled Blackhawk) + a base aim tolerance
(`BT_FIRE_ARC`, a labeled PORT presentation param). Beams fire from the mech's real gun-port SITE
segments (siterugunport etc.) resolved via `GetSegment(name)→segmentToEntity×localToWorld`. [T2]
## Weapon fire is a PORT reconstruction (the dpl_* beam layer is unported)
`Emitter::FireWeapon` builds a muzzle→target beam but the `dpl_*` beam renderable was never ported →
the beam is drawn by `BTPushBeam`/`BTDrawBeams` (L4VIDEO — an additive quad / the real `ermlaser.bgf`
tube with the scrolling `bexp` grit). Fire rate is decoded-exact (DischargeTime 0.2 + RechargeRate 2.2
per ER-M laser; [[decomp-reference]] §5). Flying projectiles (LRM/autocannon) are also a PORT
reconstruction (`BTPushProjectile` — the 2007 Entity is too small for the binary's raw integrator
offsets). [T2]
## 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]` EVEN = `1/armorPoints`
(armor 50140; `damageLevel += amount/armor`, 1.0 = destroyed — the `[zone-armor]` dump,
BT_DMG_LOG, mechdmg.cpp). Weapons author point-scale amounts (laser 11.77 ≈ 10 torso hits). ✓
2. **StaticBounce prices rams with the AUTHORED moverMass ≈ 1.3e6 units** → ~600×v² ≈ 59,000
points for a 10 m/s ram (measured: 59221@9.94, 398@0.81; `[collide-tx]` now logs mass/e).
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 (required
for MP weapon damage) surfaced it as a one-shot. Port: `BTDispatchCollisionDamage`
normalizes ×1e-3 to the armor-point economy (bump = points, charge = tens) [T3].
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).
## Damage delivery + the real damage model
`Entity::TakeDamageMessage(id, size, inflictingEntityID, zone, Damage&)``target->Dispatch`.
**Base handler IGNORES zone==1** (`Entity::TakeDamageMessageHandler`, ENTITY.cpp:878 — returns on
`damageZone==1`; the message carries `invalidDamageZone = damageZone<0` + `damageData.impactPoint`).
The Mech override that resolves an unaimed hit's zone from the impact point (the **cylinder hit-location
model**, **STEP 6**) is now **RECONSTRUCTED + runtime-verified [T2]** — see "STEP 6 COMPLETE" below.
(Historical: it used to be unreconstructed, meanwhile aiming a valid zone.) **Investigated 2026-07 (durable):** the earlier-cited addresses `FUN_004a0230`/`FUN_0049ed0c` do
NOT exist — the real pieces are: the **CylinderDamageZoneTable** = a list of 0x30-byte cylinder entries
(entry ctor `FUN_0049e740`) loaded from resource type **0x1d** by the mech's cylinder-table NAME; the
table ctor is `FUN_0049ea48` (vtable 0x50bd84, alloc 0x2c, cached at `Mech[0x111]`=byte 0x444) — the recon
DOES build it (mislabeled `StandingAnimation`, mech.cpp:1221) but with an EMPTY name → 0 entries → the
lookup is a no-op. **Fully reversed (2026-07):** the table is a passive nested-list **height × angle grid** — 3 container
classes: TABLE (0x2c, ctor `FUN_0049ea48` / dtor `0x49eadc`, vtable 0x50bd84, `this[3]`=mech,
`this[4]`=row list, `this[10]`=row count) → ROWS by HEIGHT (0x30, ctor `FUN_0049e740` / dtor `0x49e814`,
vtable 0x50bd90, `this[3]`=RotateWithTorso flag, `this[4]`=torso (mech+0x438), `this[5]`=cell list,
`this[0xb]`=cell count, cell key `i·(2π/count)``_DAT_0049e810`=**6.2831855=2π**) → CELLS by ANGLE
(0x2c, ctor `FUN_0049deb0` / dtor `0x49df80`, vtable 0x50bd9c, `this[4]`=a refcounted zone-dict filled
by `FUN_0049e5e4`, count-prefixed {int→int} entries) → zone ref(s).
**⚠ CORRECTION (2026-07, verified from decomp): the lookup is NOT "entirely in the unexported handler"
— it is EXPORTED pseudocode [T1].** Two functions:
- `FUN_0049eb54` (@0049eb54, TABLE-level, `void(table, float* worldImpactPoint)` — Ghidra mistypes the
return; it tail-returns the found cell): (1) transform world impact → **mech-local** via
`FUN_00408bf8`+`FUN_00408440` reading the mech's transform at `mech+0xd0`; (2) **HEIGHT→row**:
`mechHeight = *(*(mech+0x2ec)+0xc)`; clamp `local.y` to `[0,mechHeight]`;
`rowIndex = clamp(floor(rowCount·y/mechHeight), 0, rowCount-1)`; `row = rowList.Find(&rowIndex)`
(int key, list method vtable+0xc); (3) **ANGLE**: if `|local.z|>eps && |local.x|>eps`,
`angle = atan2(local.z, local.x)` (`FUN_004dc8ec`=atan2, confirmed via `s_atan2`), wrap `<0 → +2π`;
else degenerate `angle = FUN_00408050()·2π`; then call `FUN_0049e678(row, angle)`.
- `FUN_0049e678` (@0049e678, ROW-level angle→cell): if `RotateWithTorso` (row+0xc), `angle += torso facing
(*(torso+0x1d8))` and wrap into `[0,2π)`; `cellIndex = floor(cellCount·angle/2π)`; reconstruct the exact
float key `(cellIndex+1)·2π/cellCount` (cells stored under `i·(2π/count)`, i=1..count);
`cell = cellList.Find(&key)` (float key, list method vtable+0xc).
Constants: `_DAT_0049e810 = _DAT_0049e72c = _DAT_0049ed08 = 2π`; `_DAT_0049e734 = 1/(2π)`;
`_DAT_0049e730 = _DAT_0049ed00 = 0.0`; `_DAT_0049ed04 = eps`; `DAT_004e0f80/84/88 = (0,0,0)`.
**Within-cell selection (fully recovered `FUN_0049de14` + disasm):** the cell → a **cumulative
hit-distribution**; `zone = FUN_0049de14(cell)` rolls `random()` (`FUN_00408050` = `(1/RAND_MAX)·engineRand`,
uniform [0,1)) and returns the **first entry whose threshold > roll** (entries sorted ascending) — classic
BattleTech dice hit-scatter. The tiny glue `FUN_0049ed0c(table,worldPt) = FUN_0049de14(FUN_0049eb54(...))`;
the real **`Mech::TakeDamageMessageHandler`** (disasm @0x4a037a, two call sites 0x4a038c/0x4a04b9) is:
`if (msg->invalidDamageZone /*msg+0x28*/) { msg->damageZone /*+0x24*/ = FUN_0049ed0c(mech[0x111]/*+0x444*/,
&msg->damageData.impactPoint /*+0x4c*/); msg->invalidDamageZone = 0; }` then the base
`damageZones[zone]->TakeDamage`.
**STREAM FORMAT — BYTE-VERIFIED against the real type-29 `.RES` bytes (EXACT consumption, 18 tables) [T1]:**
```
Table { i32 rowCount; Row[rowCount] }
Row { i32 rotateWithTorso; i32 cellCount; Cell[cellCount] }
Cell { i32 nameLen; char name[nameLen]; u8 0x00; i32 entryCount; Entry[entryCount] }
Entry { f32 cumulativeThreshold; i32 zoneIndex } // zoneIndex → damageZones[]
```
Real sample (`ava1`, 7 rows × 8 cells): rows 0-2 `rotateWithTorso=0` (feet/legs — fixed to chassis),
rows 3-6 `=1` (upper body — rotates with torso twist); a foot cell = `{0.5→16, 0.8→10, 1.0→5}`. The table
is found by the mech's **type-0x14 DamageZoneStream NAME** (`ResourceDescription::resourceName`) →
`ResourceFile::FindResourceDescription(name, DamageLookupTableStreamResourceType/*29*/)`.
So STEP 6 remaining = (6b) fix the name-load (current `ResourceFindByName` is a NO-OP template stub → empty
name → 0 rows); (6c) make the table functional + `ResolveHit→zone`; (6d) register the
`Mech::TakeDamageMessageHandler` override + verify. **Nothing is guessed — geometry, roll, handler, and byte
format are all verified.**
## The classes ALREADY EXIST — `game/reconstructed/dmgtable.cpp` (2026-07 discovery)
The three container classes are **already reconstructed** in `dmgtable.cpp`/`.hpp` (in the build):
`DamageLookupTable` (layers=height) → `PieSlice` (slices=angle, `SelectSlice`) → `DamageZonePercentTable`
(leaf, `SelectZone`=the roll, `ReadEntries`=stream parse). It **independently confirms this RE exactly**.
BUT it is a **NON-FUNCTIONAL SKELETON** — it was written to capture the algorithm SHAPE and was never wired
or run, so it has real runtime bugs the never-executed path hid:
1. Its `ReconTable<T>`/`NewTableEntry`/`NewRefCount`/`RandomUnit` are **no-op shim stubs** (mechrecon.hpp —
`Insert(){}`, `Lookup()→0`) → the table stays empty + lookups return null. Must be backed by REAL storage
(safe: `ReconTable` is used ONLY in dmgtable). Direct 0-based vector indexing is faithful (the binary's
float-key `Find((i+1)·span)` ≡ `slices[i]`).
2. `DamageZonePercentTable::ReadEntries` **skips the cell name-string** (`FUN_00402948` = `[i32 len][len+1
bytes: chars+NUL]`) that precedes the entry count → must read+discard it first or the parse misaligns.
3. `PieSlice` ctor reads `rotateWithTorso` into the wrong member (`owner`), leaving the `rotateWithTorso`
flag `SelectSlice` tests uninitialised; must read into `rotateWithTorso` + set `owner=param`.
4. `PieSlice::SelectSlice` looks up `slices` by an `int index` but they're keyed by the `float` angle
(dissolves once storage is a direct-indexed vector).
5. `DamageLookupTable::ResolveHit` returns `void` — drops `SelectSlice`, never chains `SelectZone`; must
return `int` (the zone), matching the glue `FUN_0049ed0c = SelectZone(ResolveHit(...))`.
6. `Mech::WorldToLocal` / `TorsoOrientationSource` are DECLARED (mech.hpp) but UNDEFINED (never linked
because dmgtable is never called); the height (`mech+0x2ec`=`[0xbb]`, the collision cylinder, `+0xc`=height)
+ torso heading (`torso+0x1d8`) need NAMED accessors (databinding trap — no raw offset reads).
So 6c = back the classes with real storage + fix bugs 2-5 + implement the 3 accessors; 6d = wire the mech
ctor (real name-load, replacing the mislabeled `StandingAnimation` stub @mech.cpp:1221) + the handler override.
## ✅ STEP 6 COMPLETE (2026-07-08) [T2]
Built + runtime-verified. Changes:
- **`dmgtable.cpp`/`.hpp`** — the 3 classes now use real `std::vector` storage (were no-op `ReconTable`
shims); `DamageZonePercentTable::ReadEntries` consumes the leading cell name-string (`[i32 len][len+1]`);
`PieSlice` ctor reads `rotateWithTorso` into the right member; `SelectSlice` direct-indexes;
`DamageLookupTable::ResolveHit` now returns the zone (chains `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]
## 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.
## Key Relationships
- Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]].
- P5 forensics: `docs/HARD_PROBLEMS.md`. Data: [[decomp-reference]] §4-5.