Files
BT411/context/combat-damage.md
T
Joe DiPrimaandClaude Opus 5 06adaee523 #110: the zone destruction cascade is LIVE -- an arm now takes its gun pod with it
Lynx: "You can destroy an upper arm, and the gun pod is intact and can still
fire."  Conn Man's audit: three chassis, both arms, reproducible.  And the user
is right that this family was claimed fixed before: #86 gated fire on the
WEAPON's own destruction and was verified by destroying the weapon directly
(BT_KILL_SUBSYS) -- the field scenario was the ARM ZONE dying with the weapon
subsystem healthy, which that bench never reproduced.  Oracle's 693-night "I can
still fire destroyed energy weapons" was almost certainly this mechanism.

ROOT CAUSE -- a silent stub.  Mech__DamageZone::RecurseSegmentTable @0049cad4
was transcribed faithfully... against three local shim types whose iterators
unconditionally return NULL:
    struct SegmentIterator { DZRef *Next() { return 0; } };
    struct SegTableX       { SegmentRecord *operator[](int) { return 0; } };
The cascade fired on zone death (bench: 72 [cascade] lines), "descended", and
touched nothing.  Every line read correct; none of it did anything.

THE REAL WALK (decomp re-read, FUN_0049cad4): iterate the mech's segment table
(mech+0x300, vtbl+0x34 GetNth) to this zone's segment, then
    destroySiblingsOnDestruction (Wword 0x68): recurse every other zone on the
        SAME segment (seg+0xD0), setting graphic state 2 (Gone)
    descendOnDestruction (Wword 0x67): recurse every zone on every CHILD
        segment (seg+0xE8 -> child+0xD0) -- the arm takes the gun
The engine ALREADY HAS both structures: EntitySegment::damageZoneTable @0xD0 /
childIndexTable @0xE8 (TableOf is 0x18 -- byte-identical to the decomp offsets),
populated at stream time by JMOVER.cpp:373 [T0].  The stub was never necessary.
Implemented with the engine iterators via two new read accessors on
EntitySegment (SEGMENT.h); the binary's asymmetry is reproduced as-is (the
sibling loop checks graphic state != 1, the child loop does not; re-hits on a
1.0 zone re-run the cascade -- idempotent in effect).

VERIFIED (ava1, self-damage to one zone, autofire everything):
  dz_rarm -> [cascade] zone 9 -> zone 17; AFC100 + AmmoBinAFC100 + Condenser6
      ForceCriticalFailure'd; "[ammo] AFC100 -> NoAmmo (gate1): destroyed=1";
      torso LRM5/SRM2 keep firing (correct)
  dz_larm -> [cascade] zone 2 -> zone 6; PPC + Condenser4 force-failed;
      "[emitter] 'PPC' fire REFUSED (destroyed=1)" x4998 -- the emitter gate now
      logs refusals BY NAME (the FIRED line never named its weapon, which is how
      #86's verification gap survived)
  regression smoke: frame time 7.13/7.20ms, 0 asserts, 0 cascades in ordinary
      combat (they fire only on genuine zone deaths)

Authored data (now dumped under BT_DMG_LOG): descend=1 on exactly the four arm
zones (ava1: 2/6/9/17), destroySibs=0 everywhere -- so legs/torso behavior is
untouched by this change.

KB: combat-damage.md cascade section; reconstruction-gotchas #26 (the
silent-stub trap: a shim that returns EMPTY converts a reconstruction into
fiction that reads correct -- shims must Fail() loudly or log their emptiness).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 08:26:53 -05:00

82 KiB
Raw Blame History

id, title, status, source_sections, related_topics, key_terms, open_questions
id title status source_sections related_topics key_terms open_questions
combat-damage Combat & Damage — targeting, firing, damage zones, death established PROGRESS_LOG.md §10c; docs/HARD_PROBLEMS.md (P5)
subsystems
decomp-reference
reconstruction-gotchas
locomotion
rendering
translocation-warp
multiplayer
damage-zone
TakeDamage
viewpoint-entity
master
replicant
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, UnscalableTerrainCULTURAL.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) → jointeyesiteeyepoint; 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 rotationAmountHingeRenderable::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 FindBoundingBoxHitByHitByBounded 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=3DYING res=32), ram crunch (res 31) unregressed. [T2] ⚠ There is NO ray query against the ground/containedByNode BoundingBoxTreeFindBoundingBoxUnder 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 — TakeDamage itself IGNORES it [T1]. ⚠ But one Damage message is NOT one application: Mech::TakeDamageMessageHandler calls TakeDamage burstCount times, re-rolling the struck zone per burst (@0x4a0423-0x4a04d8; mech.cpp, task #80). burstCount = number of applications, and it is load-bearing (missile cluster count, splash falloff, gyro bounce). A cluster missile lands its damageAmount once per connecting missile — the count is rolled at impact, Random(n) + n/4 clamped to n. See the corrected SALVO note below. ✓
  2. StaticBounce prices rams at ~600×v² raw (measured: 59221@9.94, 66239@9.59, 398@0.81; [collide-tx] logs mass/e — mech moverMass is the tonnage-scale 60000-90000, NOT the old "authored ≈1.3e6" mis-attribution; the ~24·v²·0.0005·mass factor is the head-on (vn·vp) algebra). The binary DISPATCHES it raw (@part_012:15324-15358) — but in pod MP it landed on the local REPLICANT of the victim (the master never heard it; MECH.CPP:986 warns and proceeds) — ram damage was network-inert in the pod. Our task-#47 replicant forwarding surfaced it as a one-shot, and a ×1e-3 normalization was patched in (2026-07-12 [T3]). That normalization is GONE (pricing audit 2026-07-31): the #83 type-0 divert is the authentic normalizer — the victim's TakeDamage hub routes collision damage into DistributeCollisionDamage (scale ≈ 3.9-4.5e-5), so with the 0.001 still applied no physically possible ram could clear the divert's 0.5-pt free floor (rams were a no-op against the victim). Raw restored [T1]: a 9.6 m/s ram = 2.55 pts of internal rattle on the victim (bench-measured, scratchpad/night7/mp_rampricing.sh); pressed follow-up contact prices under the floor = free.
  3. The contact EDGE: the binary's bounce reversed the mover's velocity so the frame after a bump read as separating; our gait re-derives velocity per frame (still closing while pressed / respawn-overlapped) → sustained contact re-priced full rams at 60Hz (the respawn explode-loop). Port: ramLastVictim/ramContactLinger (mech.hpp) — one bump = one damage event; pressed = BLOCK; re-arms 0.35s after separation; cleared on Reset. Band effects: MechDeathHandler::Tick fires the CURRENT band descriptor on any damage rise (the binary's changed-flag semantics, workflow-verified) — a damaged mech under fire smokes/ burns per hit; the earlier crossing-only gate was over-tight (the metronome was SHKWAVE).

Missile THRUST -- FIXED (2026-07-31, issue #84) [T1 values / T2 verified]

The binary Missile hosts a MissileThruster subsystem: the round leaves the rack at the launcher's authored MuzzleVelocity (SRM eject 100 u/s flat; LRM 30 u/s up-tilted) and then ACCELERATES while BurnTime remains. Authored values (BTL4.RES type-15 missile model records +0x44/+0x48, reached from the AmmoBin's ammoModelFile through the type-1 MODELLIST indirection): SRM 2.5s @ 600 u/s² (turn 120°/s) · LRM 10s @ 300 (turn 60°/s, climb 50) · Streak 3s @ 300 (turn 360°/s) · NARC 10s @ 300; SplashRadius 30 for all (the port's constant was right); authored drag ~0.001 = negligible. The port pool flew at CONSTANT eject speed -- LRMs 10× slower than authored -- which was the whole of the field "missiles are slow" report, and the "explosion before the missiles arrive" perception (the salvo-lead detonates while the slow spread rounds straggle). Fixed: BTMissileThrustOf (mech4.cpp) lazily caches the RES thruster table (ModelList ids aliased to their type-15 member); both launch paths (master fire

  • the replicant salvo mirror) pass burn/accel into the pool, whose advance integrates speed += accel·dt while burning. Verified live: Streak impacts at 277 u/s (was 100). Note: the Black Hawk's "SRM6" fires strk (Streak) ammo per its bin -- the ammo model, not the launcher name, decides the flight profile.

Destroyed weapons kept FIRING -- FIXED (2026-07-31, issue #86) [T1/T2]

The fire gates (ProjectileWeaponSimulation gate 1 @4bbd36, EmitterSimulation hard-failure @4baab9) test subsystem+0x40 for Destroyed(1). In the binary that offset IS the status alarm's level cell; the port splits it into statusAlarm + a never-written int simulationState, so the gates read a dead cell -- a weapon on a destroyed mount showed its X on the MFD and kept firing and SCORING (night-7: all three testers, with screenshots of a missile leaving a destroyed pod). Fixed by reading BOTH cells in both gates; the full pattern is reconstruction-gotchas §22. Verified A/B in one run (BT_KILL_SUBSYS bench hook -> ForceCriticalFailure, the same call the zone crit-cascade makes): before the kill both SRM6 launchers fired 2 salvos each; after, the destroyed launcher fired ZERO (gate log destroyed=1) while its twin kept firing.

Ballistic damage type -- FIXED (2026-07-23, issue #27) [T2]

Playtest matchlog forensics (2,591 applied-damage events over 2 rounds): the damageType histogram had Collision/Explosive/Laser/Energy but Ballistic (type 1) NEVER appeared, despite 136 projectile impacts + autocannon/Gauss mechs. The weapon parses its authentic damageData.damageType right (mechweap.cpp:316, "BallisticDamage"->1), but BTPushProjectile dropped it and every mech4.cpp projectile-impact site hardcoded ExplosiveDamageType -- so autocannon/Gauss scaled against armor via damageScale[Explosive] not damageScale[Ballistic] (distinct cells: damageLevel += amount * damageScale[type]). Lasers/PPCs were correct (their SendDamageMessage carries the weapon damageData); only the projectile path flattened. FIX: BTProjectile gains a damageType field, threaded from the weapon at launch through to impact (missiles still resolve Explosive from their own type; autocannon/Gauss now Ballistic). The missile-SPLASH template stays Explosive. [T2 verify: an autocannon mech produces type=1 DMG.]

AMMO BAY FIRE / COOK-OFF — FIXED (2026-07-25, issue #46) [T1 mechanism, T2 live]

Field report (night 3): "two ammo bay fires and no death" (Cyd + RajelAran, one purged one left burning). Root cause = three stacked kill-switches on the same path in ammobin.cpp: GameClock::Now(){return 0} (→ cookOffTime < 0<0 — an armed fire NEVER detonated), InjectHeat(void*){} (detonation applied nothing), and a never-stamped damage record (0 dmg of type 0 even if it had). The authentic mechanism, raw-disasm verified (scratchpad/disammo.py):

  • Arming: bin heatAlarm FAILURE(2) with rounds left, or message 1 (crit-induced, HandleMessage @004bdb94). cookOffTime = Now().ticks + round(10.0 × ticksPerSecond) — a FIXED 10-second fuse. (The old "RandomDelay" was a Ghidra artifact: FUN_004dcd94 is __ftol; the caller's x87 expression 10.0 × DAT_0052140c + 0.5 was dropped from the export. DAT_0052140c = SystemClock ticksPerSecond. See reconstruction-gotchas §__ftol.)
  • Purge cancels: bin Empty (eject-hold bay dump) → cookOffArmed=0, no detonation.
  • The bin's damage record (Damage cookOffDamage @0x1F0..0x21C, was mislabeled "heat descriptor"/"heatPerRound"): stamped at construction by the linked ProjectileWeapon ctor @004bc3fcbin+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 → WorldToLocalheight 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 SelectSliceSelectZone). Stream type is the real MemoryStream (ReadBytes).
  • mech.cpp ctor — replaced the empty-name stub with the real load: FindResourceDescription(dzRes-> resourceName, DamageLookupTableStreamResourceType/*0x1d*/)DynamicMemoryStreamnew DamageLookupTable cached in the named member Mech::damageLookupTable (binary this[0x111]/byte 0x444; was mislabeled ammoExpended in the offset map); ~Mech deletes 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.cppTorso::CurrentTwist = torso+0x1d8; torso.hpp can't be #included 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_0043663cFUN_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, destroyedsdestroyedd) @part_003.c:5720. NOT EXPORTED (master-perf gap 0x4a9770-0x4ab188 + the un-exported TakeDamage) [T4]:
  • The movementMode WRITER (sets 5-8 then 2/9 on death) + the fall-direction (5/6/7/8) selection.
  • The Mech::DeathShutdown roster loop (RP VTV::DeathShutdown analog — loop subsystems calling the Subsystem::DeathShutdown(int) vtable slot; the base is an empty virtual, SUBSYSTM.h:166).
  • The whole-mech DeathEffect/DeathSplash radius dispatch + the graphicAlarm.SetLevel(9) call site (the recon already raises the alarm in mechdmg.cpp:426/586). The problem: the orchestration lives in the per-frame Simulate that the bring-up drive override bypasses (btstubs.cpp:116-119), so the death consumer must be reconstructed from its exported consumers + the RP analog and wired into the active path (mech4.cpp PerformAndWatch).

Death-STATE core DONE (2026-07-08) [T2]

Mech::UpdateDeathState() + IsMechDestroyed() (mech4.cpp) reconstruct the un-exported death branch from its exported consumers + the RP analog, called for EVERY mech early in PerformAndWatch: on `graphicAlarm

= 9it (1) settles the death mode STRAIGHT tomovementMode = 9(mech4.cpp:1183) — there is NOmovementMode = 5collapse 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)(RPVTV::DeathShutdownanalog; base is a no-op virtual, a SHUTDOWN not teardown → safe, wreck STAYS); (3)movementMode9 →IsDisabled→ locomotion frozen. The drive's lonemovementMode = 1write (mech4.cpp ~1309) is guarded on!IsMechDestroyedso the death state isn't clobbered. **Runtime-verified** (forced kill):[death] … collapse + subsystem shutdown[death] … settled -> disabled (IsDisabled=1, frozen wreck), mech frozen in place (pos unchanged), subsystem tick + renderer keep running, **no crash, wreck stays**. Env gate BT_DEATH_LOG=1. **Deferred (honest):** (a) ✅ 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 BTPostKillScorePlayer::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)+0x50Missile+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 → BTWeaponAmmoBinBTAmmoRoundModelResource (+0x1e8) → GameModel +0x50. BTApplySplashDamage walks BTGetTargetCandidates (excludes shooter), skips the direct victim + destroyed/zoneless mechs, applies the dist>radius gate + the burst falloff, and delivers via a DIRECT e->Dispatch to each victim (T0 EXPLODE.cpp:246). Hooked at BOTH detonation paths (world-impact + contact). ⚠ MUST NOT route splash through the shooter's SubsystemMessageManager: AddDamageMessage CONSOLIDATES every damage message of a frame onto the FIRST hit entity (commonDamageInformation.entityHit, messmgr.cpp:279) — so splash for the bystander landed on the DIRECT victim instead (2026-07-13 bug: the direct victim took 8 direct + 8 mis-routed splash = 16 applications, the bystander 0; a direct Dispatch splits them correctly 8/8). Dispatch reroutes cross-pod for a replicant victim on its own.

⚠ THE SALVO-LEAD FIX (task #62 bug, found 2026-07-13 by live regression) — the N-round trap. KEY FACT [T1]: DamageZone::TakeDamage (arcade @0041e4e0 == WinTesla DAMAGE.cpp:379) is damageLevel += damageAmount * damageScale[type] and IGNORES burstCount.CORRECTION (2026-08-01, #95): do NOT generalise that into "burstCount is cosmetic for zone damage" — the earlier wording here said exactly that and it is WRONG. TakeDamage ignores it; the CALLER honours it. Mech::TakeDamageMessageHandler (@0x4a0423-0x4a04d8, reconstructed in mech.cpp under task #80) loops burstCount times, calling TakeDamage once per burst and re-rolling the struck zone each iteration (@0x4a04b9) — so a burst SPRAYS across zones. burstCount is therefore "number of applications", and it is load-bearing for missiles (cluster count), splash (distance falloff) and the gyro bounce alike. So ONE arcade cluster Missile per trigger (damageAmount = authored/missileCount, burstCount = missileCount) delivers its salvo through that loop, not in a single application. How many of the cluster connect is ROLLED at impact in Missile::Perform right before dispatch (part_013.c:10082): b = Random(n) + n/4, clamped to n — between a quarter of the salvo and all of it. And the arcade Missile dispatches DIRECTLY at the struck entity (FUN_004be078: param_2->Dispatch(&msg)) — it does NOT route through the shooter's SubsystemMessageManager, whose consolidation would drop burstCount (DamageInformation carries only damageType + subsystemID). Routing a projectile through the manager therefore silently deletes the cluster count — that was the #95 bug, and it also produced the #84 double explosion. The port re-expresses that ONE cluster as N flying BTProjectile rounds (visual tracers), and task #62 damaged + splashed on EVERY round → ~missileCount× too lethal on BOTH the direct hit and the splash (user-reported "missiles kill in 2 shots" — a mech that should take many salvos). Fix (mislanch.cpp FireWeapon): only the salvo-LEAD round (i==0) carries damage (damageData. damageAmount) AND the cluster splash (splashBurst = nmiss); the other N-1 rounds are VISUAL (damage 0, splashBurst 0) — the ripple of tracers. BTProjectile.splashBurst gates the two splash hooks; the direct-hit block already skips p.damage<=0. Replicant mirror rounds carry 0 (master delivers cross-pod — no double). Lesson: a cluster weapon re-expressed as N rounds must apply ALL zone effects (direct damage AND splash) ONCE per salvo, not once per round — the arcade's single Missile is one damage event, burstCount notwithstanding. Verified [T2]: per-hit missile damage = clean 5.83, burst 1, ONCE ([dmghit] trace); radius 30 for missiles / 0 for AC+Emitters. Missiles-ONLY, single enemy, 45s: 8 damaging hits, top zone reaches only 0.18, NO death (was death in ~3 pulls pre-fix) — "way more than 2 shots" restored. AC (one round/shot, not a cluster) unaffected. Env BT_SPLASH_LOG, BT_DMG_LOG (per-hit [dmghit] + zone-armor), BT_AF_MISSILE (missile-only autofire, independent of BT_AUTOFIRE), BT_SPAWN_ENEMY=N (N clustered dummies). ⚠ BT_AUTOFIRE fires ALL weapon groups (laser+PPC+AC) — use BT_AF_MISSILE alone to isolate missile TTK. Deferred [T3]: the per-player enable sub-gate Missile+0x360 = BTPlayer+0x264 (part_013.c:8757) — its decomp writers (4668/10512) read as a per-frame state toggle, not a clean config flag, and the port's showDamageInflicted label is itself a guess; port treats authored SplashRadius>0 as the enable. See open-questions.

Collision damage — the type-0 DIVERT + rattle distributor (#83, 2026-07-30) [T1 disasm/T2]

Mech::TakeDamageMessageHandler diverts damageType==0 (@0x4a0368) into DistributeCollisionDamage (FUN_0049ffcc, export gap, raw disasm scratchpad/night6/ gap_49ffcc.txt): collision damage never reaches the zone/armor loop. Gate: the owning player's advancedDamage copy (+0x268, BTPlayerAdvancedDamageOn bridge) — the manual's "splash/collision damage" technician setting. Pricing: raw × (2000/moverMass)/(100·(1/3.6))²/ (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.

The zone DESTRUCTION CASCADE is live -- an arm takes its gun (#110, 2026-08-02) [T1 decomp / T2 verified]

Mech__DamageZone::RecurseSegmentTable @0049cad4: when a zone reaches damageLevel 1.0 (non-leg, non-vital path in TakeDamage), the binary walks the mech's SEGMENT TREE (mech+0x300, the engine JointedMover::segmentTable [T0]):

  • destroySiblingsOnDestruction (streamed, Wword 0x68) -> every other zone on the SAME segment (EntitySegment::damageZoneTable, seg+0xD0) recurses and is set graphic state 2 (Gone);
  • descendOnDestruction (Wword 0x67) -> every zone on every CHILD segment (childIndexTable seg+0xE8 -> child's damageZoneTable) recurses -- the arm takes the gun pod with it. Each recursed zone's SendSubsystemDamage @0049c9a8 pushes the unused crit allotment and ForceCriticalFailures any subsystem driven to 1.0 -- which the #86 fire gates then refuse. Authored (measured, ava1): descend=1 on the four arm zones (2/6/9/17); everything else 0. Verified live: dz_rarm destroyed -> zone 17 cascades -> AFC100 + bin + Condenser6 force-failed, AFC100 -> NoAmmo (gate1): destroyed=1; dz_larm -> PPC force-failed, [emitter] 'PPC' fire REFUSED (destroyed=1) x4998. Torso weapons unaffected. Re-entry note: every further hit on a 1.0 zone re-runs the cascade (no guard in the binary's child loop; only the sibling loop checks graphic state != 1) -- authentic, idempotent in effect.

⚠ The walk was a SILENT STUB until 2026-08-02 (three shim types whose iterators returned NULL) -- the cascade "ran" and touched nothing, which is why blown-off arms left firing gun pods across every chassis (Conn Man's three-chassis audit). See reconstruction-gotchas #25.

Key Relationships