Targeted pass on "stand-in / not-wired / stub / no-op" claims, verified against
CODE + runtime (not KB wording -- the trap that hid the peer-warp staleness):
- combat-damage.md STEP-6 "remaining = fix ResourceFindByName no-op -> empty
table" (reads present-tense above the "STEP 6 COMPLETE" header): STALE. The
table LOADS live ([cyl] table 'bhk1'/'madcat'/'ava1' layers=7); the name-load
BYPASSED the no-op ResourceFindByName via SearchList(type=0x14) (mech.cpp:1631).
d07ac7d. ResolveHit resolves unaimed hits.
- multiplayer.md "Mech::Reset full subsystem-reset sweep is still a bring-up TODO":
STALE. mech4.cpp:1616 loops every subsystem -> DeathReset(mode) (heat/power/ammo/
charge) + heals zones + ForceUpdate(0x1f). Per-subsystem DeathReset bodies are
authentically trivial for some classes -- not a missing sweep.
Verified ACCURATE (not stale, left as-is): GaussRifle FireWeapon no-op (a decomp
fact -- inert in the 1995 binary; btl4gau2 faithfully marks it "not yet supported");
StatusMessagePool NULL stub (kill ticker live, non-kill status messages genuinely
deferred); Myomers coupling inert (genuinely open); searchlight fog-swap (open by
decision). checkctx CLEAN.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
551 lines
47 KiB
Markdown
551 lines
47 KiB
Markdown
---
|
||
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).
|
||
|
||
## 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; 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 ACTIVE 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).
|
||
|
||
**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. ⚠ 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]
|
||
|
||
## Weapon fire is a PORT reconstruction (the dpl_* beam layer is unported)
|
||
`Emitter::FireWeapon` builds a muzzle→target beam but the `dpl_*` beam renderable was never ported →
|
||
the beam is drawn by `BTPushBeam`/`BTDrawBeams` (L4VIDEO — an additive quad / the real `ermlaser.bgf`
|
||
tube with the scrolling `bexp` grit). Fire rate is decoded-exact (DischargeTime 0.2 + RechargeRate 2.2
|
||
per ER-M laser; [[decomp-reference]] §5). Flying projectiles (LRM/autocannon) are also a PORT
|
||
reconstruction (`BTPushProjectile` — the 2007 Entity is too small for the binary's raw integrator
|
||
offsets). [T2]
|
||
|
||
## The RAM economy — CLOSED (2026-07-12) [T1 evidence, T3 normalization]
|
||
Three-layer story, measured live + decomp-verified:
|
||
1. **The armor economy is POINTS**: every zone streams `damageScale[5]` — 5 cells indexed by
|
||
`damageType` DIRECTLY (Collision/Ballistic/Explosive/Laser/Energy; task #60 correction — NOT an
|
||
"even/odd slot" parity), each destructive type's cell ≈ `1/armorPoints` (armor 50–140).
|
||
`damageLevel += amount × damageScale[type]`, 1.0 = destroyed — engine `DAMAGE.cpp:379` (arcade
|
||
`@0041e4e0`), called from `mechdmg.cpp:427`; the `[zone-armor]` + per-hit `[dmghit]` dumps,
|
||
BT_DMG_LOG. Weapons author point-scale amounts (laser 11.77 ≈ 10 torso hits). **`burstCount` is
|
||
NOT in this formula — 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 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 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 × (1−e²) × impact² × moverMass**
|
||
(KE loss; `impact` = own `worldLinearVelocity·normal`; mass/elasticity/friction stream from the
|
||
model gamedata `MoverMass` etc.). On a Mover owner it gates on separating-contact (`normal·rel <
|
||
−1e-4 → return`), then on `ClassID==0xbb9` dispatches a `DamageMessage{id=0x64, zone=−1,
|
||
CollisionDamageType}` at the OTHER mech (the receiver's cylinder table resolves the zone). Ported
|
||
in `BTDispatchCollisionDamage` (mech4.cpp) [T1]. **Observed live economy [T2]:** weapons 3.4-11.8
|
||
per hit; a walking/running ram = 13-62 per contact frame; a sustained ram killed a pristine mech
|
||
at 102.5 total (4 contacts) via (presumably) a vital torso zone — per-zone HP = the authored
|
||
`CollisionDamagePoints` (fallback `WeaponDamagePoints`), scale = 1/points, replayed from the
|
||
type-0x14 stream. Whether ~100-pt ram lethality matches the pod is unverified (the authored
|
||
collision points for the torso zones haven't been dumped) [T3].
|
||
**⚠ The one-shot bug (fixed 2026-07-12):** multi-solid frames amplified the velocity StaticBounce
|
||
priced damage from (62 → 112,375; killed a pristine mech through a single bump) — gotcha #16 in
|
||
[[reconstruction-gotchas]]; fix = `frameEntryWorldVelocity` restore per contact + `Mech::Reset`
|
||
motion zeroing. `[collide-tx]` (BT_MP_NET) logs every dispatched ram with the pricing velocity.
|
||
|
||
## Death — the wreck STAYS (P5 CLOSED)
|
||
A killed mech does NOT disappear — BT death is a STATE transition
|
||
(`SetGraphicState(DestroyedGraphicState)` + death anim + effect/splash), the mech becomes a persistent
|
||
WRECK (RP analog: `VTV::DeathShutdown`). **NEVER issue `DestroyEntityMessage` on death** — the
|
||
teardown crash was an artifact of forcing a removal the original never does. The actual bug was ONE
|
||
thing: an EXPLICIT `JointedMover::~JointedMover()` in the reconstructed `~Mech` ran the whole base-dtor
|
||
chain TWICE (the dtor-epilogue rule, [[reconstruction-gotchas]] §7) → a double-free of `collisionLists`
|
||
+ the segment table. Removing it fixed BOTH the death-row crash AND app-exit crashes. [T2]
|
||
|
||
## 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]].
|
||
|
||
## Key Relationships
|
||
- Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]].
|
||
- P5 forensics: `docs/HARD_PROBLEMS.md`. Data: [[decomp-reference]] §4-5.
|