BT410 5.3.68: THE LIVE-RIO FAULT IS FIXED -- it and the TM crash were one defect

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>
This commit is contained in:
Cyd
2026-07-29 14:41:52 -05:00
co-authored by Claude Fable 5
parent f30acc6a94
commit 8231bee58c
19 changed files with 2473 additions and 65 deletions
+197
View File
@@ -0,0 +1,197 @@
# 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:
1. Feed the RAW record to the gyro (cockpit bounce — even an invalid-zone
hit shakes the pilot). *(staged: feel wave)*
2. Latch `lastInflictingID` — keys the LOD damage-clustering below.
3. **Unaimed resolve**: if `invalidDamageZone`, map `impactPoint` through 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.
4. Chain to `Entity::TakeDamageMessageHandler`: zone 1 is DROPPED (base
contract), otherwise `damageZones[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 via
`ApplyDamageAndMeasure`, 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 `.dmg` notation files: `WeaponDamagePoints` default with
`Collision/Ballistic/Explosive/Laser/EnergyDamagePoints` overrides
(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; `DamageZone` state
replicates via update records (`damageLevel`, zone state, graphic state,
changed flags). `SubsystemMessageManager` consolidates per-frame damage
and bundles explosion resources (not yet reconstructed).
- `Mech::Reset` (respawn) heals every hull zone and DeathResets the roster,
but crit `damagePercentageUsed` PERSISTS across lives — spent crit
budgets stay spent (binary-authentic).
## 10. Known deltas from the 1995 binary (staged)
- Aimed fire: beam `SendDamage` currently rolls a uniform random hull zone;
authentic = the reticle hit. `CriticalHit` is 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).
+30
View File
@@ -29,6 +29,20 @@ Derivation
"Torso"
);
//
// The full 13-entry authentic table (ids 3..15) -- donor torso.cpp:152 with
// binary offsets, cross-confirmed by BTL4.RES's streamed control mappings
// binding ids 12/13 on subsystem 17. The five command members and
// analogTwistAxis are DIRECT WRITE DESTINATIONS for device polls
// (L4CTRL.CPP:1936), which is why every id from 9 up must exist and be the
// right kind of storage: id 9 short of this table landed the RIO's analog
// stick on the motionState StateIndicator.
//
// SpeedOf* still point at the base rates rather than the donor's live
// velocity accumulators (elevationVelocity/twistVelocity @0x1EC/0x1E8) --
// those members arrive with TorsoSimulation; the authored audio watcher on
// SpeedOfTorsoHorizontal just reads a constant until then.
//
const Torso::IndexEntry
Torso::AttributePointers[]=
{
@@ -38,6 +52,12 @@ const Torso::IndexEntry
ATTRIBUTE_ENTRY(Torso, HorizontalLimitLeft, horizontalLimitLeft),
ATTRIBUTE_ENTRY(Torso, SpeedOfTorsoVertical, baseElevationRate),
ATTRIBUTE_ENTRY(Torso, SpeedOfTorsoHorizontal, baseTwistRate),
ATTRIBUTE_ENTRY(Torso, StickPosition, analogTwistAxis),
ATTRIBUTE_ENTRY(Torso, TorsoUp, elevateUpCommand),
ATTRIBUTE_ENTRY(Torso, TorsoDown, elevateDownCommand),
ATTRIBUTE_ENTRY(Torso, TorsoLeft, twistLeftCommand),
ATTRIBUTE_ENTRY(Torso, TorsoRight, twistRightCommand),
ATTRIBUTE_ENTRY(Torso, TorsoCenter, centerCommand),
ATTRIBUTE_ENTRY(Torso, MotionState, motionState)
};
@@ -89,6 +109,16 @@ Torso::Torso(
buttonRamp = 0.0f;
horizontalEnabled = r->torsoHorizontalEnabled;
//
// The device-poll write destinations (published ids 9..14). Zero =
// stick centred, no button held -- their state before the first poll.
//
elevateUpCommand = 0;
elevateDownCommand = 0;
twistLeftCommand = 0;
twistRightCommand = 0;
centerCommand = 0;
//
// Resolve the named torso-twist skeleton joints (twist body + shadow) from
// the mech skeleton (now live). A mech with a fixed torso, or one whose
+36 -10
View File
@@ -108,12 +108,24 @@
// HorizontalLimitRight HorizontalLimitLeft
// SpeedOfTorsoVertical SpeedOfTorsoHorizontal
//
// (the TorsoUp/Down/Left/Right/Center that follow are MESSAGES, not
// attributes). All six map onto members we already carry. An
// authored AttributeWatcher binds SpeedOfTorsoHorizontal.
// The FULL authentic table, ids 3..15, confirmed twice over: the BT411
// donor's decompiled enum carries these names WITH binary member offsets
// (torso.hpp 111-124, @0x1E4..@0x20C), and BTL4.RES's own streamed
// control mappings bind ids 12/13 as DIRECT button destinations on
// subsystem 17 = Torso (the [map] audit).
//
// An earlier revision declared "TorsoUp/Down/Left/Right/Center are
// MESSAGES, not attributes" and stopped at 7 entries with MotionState at
// id 9. That was WRONG in a specifically nasty way: the ids are the
// binding key for the streamed mappings, so Thrustmaster's buttons
// (12/13) resolved NULL -- the boot write-fault at
// ControlsInstanceDirectOf<int>::Update+0xE -- and the RIO's ANALOG
// StickPosition (9) resolved to our motionState StateIndicator, letting
// every analog update from a live RIO smash a watcher-socketed object
// with raw floats.
//
// The chain -- PowerWatcher -> HeatWatcher -> MechSubsystem -- publishes
// nothing of its own, so these start at Subsystem::NextAttributeID.
// nothing of its own, so these start at MechSubsystem::NextAttributeID.
//
public:
enum {
@@ -123,11 +135,12 @@
HorizontalLimitLeftAttributeID,
SpeedOfTorsoVerticalAttributeID,
SpeedOfTorsoHorizontalAttributeID,
//
// Bound by an authored watcher (BTL4.RES pairs it as
// Torso/MotionState). Appended at the END of this leaf class's
// own range, so no downstream id moves.
//
StickPositionAttributeID,
TorsoUpAttributeID,
TorsoDownAttributeID,
TorsoLeftAttributeID,
TorsoRightAttributeID,
TorsoCenterAttributeID,
MotionStateAttributeID,
NextAttributeID
};
@@ -169,10 +182,23 @@
//
StateIndicator motionState; // authored watcher
//
// The five BUTTON COMMAND destinations (donor torso.hpp @0x1F8-0x208).
// The streamed control mappings bind these BY ID as DIRECT WRITE
// targets -- LBE4ControlsManager::CreateStreamedMappings registers
// GetAttributePointer(attributeID) as the destination a device poll
// writes through (L4CTRL.CPP:1936). Carved from the front of the
// dynamicsState reserve so the class size is unchanged.
//
int elevateUpCommand;
int elevateDownCommand;
int twistLeftCommand;
int twistRightCommand;
int centerCommand;
//
// Twist/elevation dynamics accumulators advanced by TorsoSimulation.
// Reserved until the per-frame sim is reconstructed (phase 5).
//
Scalar dynamicsState[22];
Scalar dynamicsState[17];
};
#endif
+70
View File
@@ -434,6 +434,76 @@ Entity*
if (description)
{
description->Lock();
//
// MAPPING AUDIT (env BT_MAP_LOG) -- walk the same streamed
// table CreateStreamedMappings is about to consume and name
// every DirectMapping whose attribute does not resolve.
//
// For a DirectMapping the archive code registers
// subsystem->GetAttributePointer(attributeID) as the WRITE
// DESTINATION of a controls instance; an attribute our
// reconstruction has not published resolves NULL, and the
// first device poll that fires the control writes through
// it -- the Thrustmaster-mode boot crash at
// ControlsInstanceDirectOf<int>::Update+0xE (write to
// [eax]=NULL, guest 00485E1A). RIO mode never trips it only
// because the RIO's analog stream arrives by a different
// path. This audit turns that crash into a named list of
// missing attribute publications.
//
if (getenv("BT_MAP_LOG"))
{
const int
*words = (const int *)description->resourceAddress;
int
map_count = *words;
const ControlsMapping
*map_entry = (const ControlsMapping *)(words + 1);
int
map_index;
DEBUG_STREAM << "[map] " << map_count
<< " streamed mappings for '" << primary_mapping_name
<< "'\n" << flush;
for (map_index = 0; map_index < map_count; map_index++)
{
Simulation
*map_subsystem =
viewing_entity->GetSimulation(
map_entry[map_index].subsystemID);
void
*map_attribute = NULL;
if (
map_subsystem != NULL &&
map_entry[map_index].mappingType
== ControlsMapping::DirectMapping
)
{
map_attribute =
map_subsystem->GetAttributePointer(
map_entry[map_index].attributeID);
}
if (
map_entry[map_index].mappingType
== ControlsMapping::DirectMapping &&
(map_subsystem == NULL || map_attribute == NULL)
)
{
DEBUG_STREAM << "[map] BAD direct: idx="
<< map_index
<< " group=" << (int)map_entry[map_index].controlsGroup
<< " elem=" << (int)map_entry[map_index].controlsElement
<< " subsysID=" << (int)map_entry[map_index].subsystemID
<< " attrID=" << (int)map_entry[map_index].attributeID
<< (map_subsystem == NULL
? " (NO SUBSYSTEM)" : " (attr NULL)")
<< "\n" << flush;
}
}
}
GetControlsManager()->CreateStreamedMappings(
viewing_entity,
(int*)description->resourceAddress
@@ -1393,3 +1393,57 @@ TOOLING: pose_probe.py replaces the throwaway harness -- updir=+1 default
pose_chase_upfixed.png shows the MadCat right side up -- chicken-walker legs,
purple knee actuators, feet planted, shadow quad UNDER the feet, weapon-pod
arms. The milestone stands, now with the correct picture.
--------------------------------------------------------------------------------
THE LIVE-RIO FAULT AND THE TM BOOT CRASH WERE ONE DEFECT: THE TORSO TABLE
--------------------------------------------------------------------------------
The [map] audit (BT_MAP_LOG in BTL4APP.CPP, printed just before the archive's
CreateStreamedMappings consumes the streamed table) named it in one run:
[map] BAD direct: idx=1 group=3 elem=68 subsysID=17 attrID=12 (attr NULL)
[map] BAD direct: idx=2 group=3 elem=67 subsysID=17 attrID=13 (attr NULL)
Subsystem 17 = Torso (the [roster] dump). The donor's decompiled torso.hpp
carries the authentic enum WITH binary offsets: ids 3..15 =
RotationOfTorsoVertical..SpeedOfTorsoHorizontal (3-8), then StickPosition(9),
TorsoUp(10), TorsoDown(11), TorsoLeft(12), TorsoRight(13), TorsoCenter(14),
MotionState(15). Our table had SEVEN entries with MotionState at id 9, on
the strength of a comment that said "TorsoUp/Down/etc are MESSAGES, not
attributes". Wrong -- and wrong in a specifically nasty way, because the
streamed control mappings bind BY ID as DIRECT WRITE DESTINATIONS
(L4CTRL.CPP:1936 registers GetAttributePointer(attributeID) as the pointer a
device poll writes through):
* TM mode: button ids 12/13 resolved NULL -> the boot write-fault at
ControlsInstanceDirectOf<int>::Update+0xE the moment the PC joystick
polled. Instant, clean, easy.
* RIO mode: analog id 9 (StickPosition) resolved to our motionState
STATEINDICATOR -- so every analog packet from a live vRIO wrote raw
floats over a watcher-socketed object. Corrupted socket chains walked
into unmapped memory ~30-40s later: the "intermittent" pod fault at the
fixed DPMI-host address, the one that ate two days. vRIO down = no
analog stream = no corruption = mission runs. Every observation from
the whole hunt drops out of this one mechanism.
FIX: Torso publishes the full 13-entry donor table; the five int command
members are carved from the front of the dynamicsState reserve (class size
unchanged); ctor zeros them. SpeedOf* still point at base rates until
TorsoSimulation brings the live velocity accumulators.
VERIFIED, two agreeing runs each way:
* TM mode: launches, [map] audit clean, and the Thrustmaster joystick
DRIVES the torso -- stickY=0.907 -> torsoElev/eyePitch=0.349. The
non-RIO controls path lives.
* RIO mode with vRIO STREAMING: launches, zero faults, mission runs deep
enough that weapons cycle. pod_render_rec is no longer poisoned; the
norio workaround is obsolete.
Incidentally the [euler] probe now prints during missions with sane
stack-local pointers -- EulerAngles::operator=(const LinearMatrix&) was
always innocent, now visibly so.
METHOD NOTE for the file: the audit-before-the-archive-call pattern (walk the
same streamed table our code hands to archive code, name what will not
resolve) turned a two-day intermittent heap-corruption hunt into a one-run
lookup. The streamed RES tables are BINDING CONTRACTS on our attribute
enums; every subsystem the RES maps should get the same audit treatment.