The [map] audit named it in one run: subsystem 17 = Torso, attribute ids 12/13 unresolved. The donor's decompiled torso.hpp carries the authentic enum with binary offsets -- ids 3..15, including StickPosition(9), TorsoUp/Down/ Left/Right(10-13), TorsoCenter(14), MotionState(15). Our table stopped at seven entries with MotionState at id 9, on the strength of a comment claiming the rest were messages. The streamed control mappings bind BY ID as direct WRITE destinations, so the truncation produced two different crashes from one cause: in TM mode the button ids 12/13 resolved NULL (the boot write-fault the moment the joystick polled), and in RIO mode the analog id 9 resolved to our motionState StateIndicator -- every analog packet from a live vRIO wrote raw floats over a watcher-socketed object, and the corrupted chains walked into unmapped memory ~30-40s later. That was the 'intermittent' pod fault at the fixed DPMI-host address. vRIO down = no analog = no corruption, which is why the norio conf launched: every observation from the whole hunt drops out of this mechanism. Fix: the full 13-entry donor table; five int command members carved from the dynamicsState reserve (class size unchanged); ctor zeros them. Verified, two agreeing runs each way: TM mode launches with a clean audit and the Thrustmaster joystick driving the torso (stickY=0.907 -> torsoElev 0.349); RIO mode with vRIO STREAMING launches, zero faults, weapons cycling. pod_render_rec is no longer poisoned and the norio workaround is obsolete. The audit-before-the-archive-call pattern (BT_MAP_LOG walks the same streamed table CreateStreamedMappings consumes, naming what will not resolve) turned a two-day intermittent-corruption hunt into a one-run lookup. The streamed RES tables are binding contracts on our attribute enums. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9.8 KiB
BT 4.10 — the damage model, end to end
How a shot becomes lost armor, dead equipment, and a mech kill. Synthesized
from the surviving 1995 engine source (CODE/RP/MUNGA/), the reconstructed
BT-side TUs (restoration/source410/BT/), and the BT411 binary reversal.
Per-TU depth lives in the sibling *.NOTES.md files; this doc is the
cross-cutting flow.
weapon fire ──► Damage record ──► TakeDamageMessage ──► victim handler
│ (unaimed? cylinder table)
▼
damageZones[zone]->TakeDamage
│
armor economy: level += amount × scale[type]
│
┌──────────────────┬────────────────────────┤
▼ ▼ ▼
level ≥ 1.0 Energy special level < 1.0
zone destroyed (generator short) (leg ≥ 0.5 → limp)
│
┌──────────┼──────────────┐
▼ ▼ ▼
VITAL LEG other zone
mech kill mech down SendSubsystemDamage (crit allotments → equipment)
+ RecurseSegmentTable (SIBS / DESCEND)
1. The Damage record (engine: MUNGA/DAMAGE.HPP)
Every hit travels as one Damage struct:
| field | meaning |
|---|---|
damageType |
0 Collision · 1 Ballistic · 2 Explosive · 3 Laser · 4 Energy |
damageAmount |
points (the only field in the armor formula) |
damageForce |
impulse vector (physics/feel, not armor) |
surfaceNormal, impactPoint |
world-space impact geometry |
burstCount |
"times to apply" — NOT in the armor formula; one message = one application. Feeds splash falloff and the gyro bounce only. |
2. Producers — where Damage records are born
Beam weapons (EMITTER.CPP, PPC/lasers): instant-hit at the owner's
current target when rangeToTarget <= effectiveRange. The discharge energy
splits by the authored ratio damageFraction = dmg/(dmg+heat); the delivered
amount scales with charge: damagePortion = authored × chargeRatio² (an
undercharged PPC hits soft). The heat portion goes into the FIRER's own heat
sinks. Delivery = MechWeapon::SendDamage.
Ballistic / missile weapons (PROJWEAP.CPP, MISLANCH.CPP,
MISSILE.CPP): the weapon FSM's Loaded case pulls ammo (FeedAmmo), checks
jam, then FireWeapon spawns ONE cluster Missile entity per salvo
(salvo-split damageAmount, burstCount = missile count). The round flies
guided (seeker + thruster); the proximity fuse delivers the whole record
once, impactPoint = the round's world position, zone = −1 (unaimed).
Explosions / splash (MUNGA/EXPLODE.CPP): a boxed splash volume gathers
movers + cultural objects sorted by distance; each target gets
burstCount = original / r^1.2 (min 1), force along the radius vector,
zone = −1.
Collisions (MUNGA/MOVER.CPP ProcessCollisionList): type Collision,
amount computed by the bounce resolver (StaticBounce from velocity,
elasticity, friction), impact point from the collision slice. Multiple
same-frame collisions are averaged, damage summed.
3. Delivery — Entity::TakeDamageMessage (ENTITY3.HPP)
Fields: inflictingEntity, damageZone (+invalidDamageZone = zone < 0),
damageData, inflictingSubsystemID (so the BT message manager can bundle
explosion resource IDs). Dispatched AT the victim entity.
The authored contract (ENTITY3.HPP warning): only reticle-based (aimed) weapons carry a valid zone. Everything else — missiles, splash, rams — arrives zone = −1 and must be resolved by the victim.
The attacker also posts a ScoreInflicted message to its own player per
delivered hit (the damage score).
4. Victim routing — Mech::TakeDamageMessageHandler (MECH.CPP)
Binary hub @004a0230, in order:
- Feed the RAW record to the gyro (cockpit bounce — even an invalid-zone hit shakes the pilot). (staged: feel wave)
- Latch
lastInflictingID— keys the LOD damage-clustering below. - Unaimed resolve: if
invalidDamageZone, mapimpactPointthrough the cylinder hit-location table (DMGTABLE.CPP, type-29 resource, cached mech+0x444): world → mech-local; local height picks a ROW (feet rows are chassis-fixed, upper rows add the LIVE torso twist to the impact angle);atan2(z,x)picks the angular CELL; a uniform roll walks the cell's cumulative distribution (the BattleTech dice scatter) → hull zone. - Chain to
Entity::TakeDamageMessageHandler: zone −1 is DROPPED (base contract), otherwisedamageZones[zone]->TakeDamage(damageData).
5. The hull zone — Mech__DamageZone::TakeDamage (MECHDMG.CPP)
Artifact (LOD) zones — a zone with a non-empty redirect table is a low-LOD hull shell: it never takes damage itself, it routes to a real child zone. Same-attacker clustering: within 0.25 s the SAME child is hit again; past 10 s re-roll; in between, 33 % reuse. The artifact's displayed level = mean of its children.
Real zones — the armor economy:
damageLevel += damageAmount × damageScale[damageType] clamp [0,1]
Authoring streams armor POINTS per type; the ctor normalizes
scale[type] = 1/(points[type] × armorPoints), so 1.0 = the zone's full
point budget spent. Leg zones halve every scale (legs effectively carry
double points). 1.0 → BurningState + DestroyedGraphicState.
Per-hit specials: an Energy hit on a zone with critical subsystems rolls ONE of them; if the pick is a Generator it is force-shorted (screens flicker, weapons drop dead until recovery; novice cockpits exempt).
State outcomes by the new level:
| condition | result |
|---|---|
| VITAL zone hits 1.0 | mech kill (statusAlarm 9) |
| leg zone hits 1.0 | fall → mech kill |
| leg zone ≥ 0.5 | limp gait graphic (left 3 / right 4) |
| non-leg, non-vital hits 1.0 | destruction descent (below) |
6. Criticals and equipment — MECHDMG.CPP + MECHSUB.CPP
Each hull zone streams a critical table: {weight, damagePercentage allotment, roster subsystem index} per entry (plug binding is
master-authoritative — replicants never bind).
Every subsystem owns a PRIVATE DamageZone (index 0, not in the hull
array) with its own points + per-type scales (structureReference +
armorByFacing[5], same normalization rule). That's what makes criticals
measurable.
CriticalHit(aimed/critical fire; not yet on the weapon path — reticle wave): HALF the damage (cap 1.0) is carved off as the critical bite, applied to ONE weight-rolled crit subsystem viaApplyDamageAndMeasure, charged against that entry's allotment; the remainder runs the normal zone armor model.- Zone death →
SendSubsystemDamage: pins the zone at 1.0 and pushes each entry's UNUSED allotment into its subsystem's private zone. A subsystem at 1.0 →ForceCriticalFailure: alarm level 1 (Destroyed),SetSimulationState(DestroyedState)— the hard gate every weapon FSM polls, so destroyed weapons fall silent; a VITAL subsystem kills the mech. Repeat hits on a dead zone re-run the push (binary-authentic) — contained equipment keeps degrading under continued fire.
7. The destruction cascade — RecurseSegmentTable
A destroyed zone walks the skeleton by its streamed flags:
- SIBS (
destroySiblingsOnDestruction): destroy the other zones on the same segment. - DESCEND (
descendOnDestruction): destroy every zone on the child segments, recursing.
Fight-verified chain: arm zone dies → SIBS kills the searchlight zone (its Searchlight/ThermalSight crits destroyed) → DESCEND kills the gun zone → PPC_2 + ERMLaser_2 + Condenser6 destroyed and STOP FIRING, while the untouched PPC_1 keeps shooting.
8. Data authoring (the resource chain)
- Type-20 DamageZoneStream (per mech): zone count, then per zone the
engine record (name, 5 effect-site segment lists,
defaultArmorPoints,damageScale[5], material lists) + the BT tail (descend/sibs flags,segmentIndex, leftLeg/rightLeg/vital, crit table, LOD redirect table). Authored in.dmgnotation files:WeaponDamagePointsdefault withCollision/Ballistic/Explosive/Laser/EnergyDamagePointsoverrides (stored as 1/points). - Type-29 DamageLookupTableStream: the cylinder table (rows × angular cells × cumulative zone distributions; 18 tables shipped).
- Weapon subsystem resources:
damageAmount,damageType,heatCostToFire, range, recharge. - Reference magnitudes (bhk1): legs 70 pts, upper torso 124, searchlights 25; PPC ~12 (Energy), ER-M laser 3.43 (Laser), SRM 5.83 × 6 (Explosive).
9. Replication and respawn
- Criticals resolve on the MASTER instance only;
DamageZonestate replicates via update records (damageLevel, zone state, graphic state, changed flags).SubsystemMessageManagerconsolidates per-frame damage and bundles explosion resources (not yet reconstructed). Mech::Reset(respawn) heals every hull zone and DeathResets the roster, but critdamagePercentageUsedPERSISTS across lives — spent crit budgets stay spent (binary-authentic).
10. Known deltas from the 1995 binary (staged)
- Aimed fire: beam
SendDamagecurrently rolls a uniform random hull zone; authentic = the reticle hit.CriticalHitis unwired for the same reason. - Cylinder height = 10.0 constant (binary reads the collision cylinder).
- Leg-branch gates use "not already destroyed" instead of the live MovementMode/IsDisabled checks (gait FSM pending).
- Gyro hit-feed and destroyed-skin graphics are log stubs (feel/render waves).