diff --git a/restoration/dmgscan.py b/restoration/dmgscan.py new file mode 100644 index 00000000..3b011800 --- /dev/null +++ b/restoration/dmgscan.py @@ -0,0 +1,105 @@ +# +# dmgscan -- extract the type-29 cylinder hit-location tables from BTL4.RES. +# +# The unaimed-damage model is a cylinder around the mech: 7 height BANDS x 8 +# angular WEDGES, and each of the 56 cells carries its own cumulative +# distribution over that chassis's armor zones. DMGTABLE.CPP resolves a hit +# by height -> band, atan2(z,x) -> wedge, then one uniform roll down the +# cell's table. +# +# The stream format is documented in source410/BT/DMGTABLE.CPP and this +# scanner is the check on it: it brute-forces every offset in the resource and +# only accepts a candidate whose ENTIRE nested structure parses -- thresholds +# strictly ascending and terminating at exactly 1.0, zone indices in range, +# names NUL-terminated. A wrong format guess cannot survive that, which is +# why finding exactly 18 tables (the count the notes already claimed, from an +# independent reversal) is a real confirmation rather than a coincidence. +# +# Findings, 2026-07-30: +# * 18 tables, every one 7 rows x 8 cells. +# * Only 8 are DISTINCT by content; the rest are duplicates. +# * Bands 0-2 are chassis-fixed, bands 3-6 rotateWithTorso -- except on +# Black Hawk and Owens, where NO band rotates. +# * 18 wedge names in 6 anatomical rings: Foot, Leg, Hip, Waist, Chest, Top. +# * Zone counts (17/20/21/22/24) match the per-chassis .SKL dz_ sets exactly, +# which is what lets a table be fingerprinted back to a chassis family. +# +# Usage: python3 dmgscan.py [path/to/BTL4.RES] +# +# Format (DMGTABLE.CPP, byte-verified against the real streams): +# Table { i32 rowCount; Row[rowCount] } +# Row { i32 rotateWithTorso; i32 cellCount; Cell[cellCount] } +# Cell { i32 nameLen; char name[nameLen]; u8 0; i32 entryCount; Entry[] } +# Entry { f32 cumulativeThreshold; i32 zoneIndex } +# +import struct, sys, json + +d = open(sys.argv[1] if len(sys.argv) > 1 else r'../ALPHA_1/REL410/BT/BTL4.RES', 'rb').read() +N = len(d) +u32 = lambda o: struct.unpack_from(' N: return None + ln = i32(o) + if not (0 <= ln <= 64): return None + o2 = o + 4 + if o2 + ln + 1 > N: return None + nm = d[o2:o2+ln] + if ln and not all(32 <= c < 127 for c in nm): return None + if d[o2+ln] != 0: return None + o2 += ln + 1 + if o2 + 4 > N: return None + ec = i32(o2); o2 += 4 + if not (1 <= ec <= 40): return None + if o2 + 8*ec > N: return None + ents, prev = [], -1.0 + for k in range(ec): + t = f32(o2); z = i32(o2+4); o2 += 8 + if not (0.0 < t <= 1.0001) or t < prev - 1e-6: return None + if not (0 <= z < 64): return None + ents.append((t, z)); prev = t + if abs(ents[-1][0] - 1.0) > 1e-3: return None + return nm.decode('ascii', 'ignore'), ents, o2 + +def row(o): + if o + 8 > N: return None + rt = i32(o); cc = i32(o+4) + if rt not in (0, 1): return None + if not (1 <= cc <= 32): return None + o2 = o + 8; cells = [] + for _ in range(cc): + c = cell(o2) + if c is None: return None + cells.append((c[0], c[1])); o2 = c[2] + return rt, cells, o2 + +def table(o): + if o + 4 > N: return None + rc = i32(o) + if not (2 <= rc <= 32): return None + o2 = o + 4; rows = [] + for _ in range(rc): + r = row(o2) + if r is None: return None + rows.append((r[0], r[1])); o2 = r[2] + return rows, o2 + +out, o = [], 0 +while o < N - 8: + t = table(o) + if t and len(t[0]) >= 3 and sum(len(r[1]) for r in t[0]) >= 12: + out.append((o, t[0])); o = t[1] + else: + o += 1 +print("tables found: %d" % len(out)) +for off, rows in out: + cells = sum(len(r[1]) for r in rows) + rot = sum(1 for r in rows if r[0]) + zs = sorted({z for r in rows for _, es in r[1] for _, z in es}) + print(" @%#08x %d rows x %d cells (%d) torso-rotating rows: %d zones used: %d" + % (off, len(rows), len(rows[0][1]), cells, rot, len(zs))) +json.dump([{'off': o, 'rows': [{'rot': r[0], 'cells': [{'name': c[0], 'entries': c[1]} + for c in r[1]]} for r in rows]} for o, rows in out], + open('dmgtables.json', 'w')) diff --git a/restoration/source410/BT/DAMAGE-MODEL.md b/restoration/source410/BT/DAMAGE-MODEL.md index b5b71d51..f73c4234 100644 --- a/restoration/source410/BT/DAMAGE-MODEL.md +++ b/restoration/source410/BT/DAMAGE-MODEL.md @@ -1,197 +1,261 @@ -# 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). +# 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). + +## 11. The cylinder table, measured — `dmgscan.py` + +Section 4.3 describes the unaimed resolve; this is the shipped data behind it, +extracted by `restoration/dmgscan.py` (brute-forces every offset and accepts +only candidates whose entire nested structure parses, so the format itself is +under test). **18 tables, every one 7 bands x 8 wedges = 56 cells.** + +**Only 8 are distinct by content** — the other 10 are duplicates. + +| distinct table | zones | torso-rotating bands | copies | chassis family (by zone-set fingerprint) | +|---|---|---|---|---| +| `b586e6f9` | 22 | 4 | 3 | Avatar / Mad Cat class — table A | +| `bdf3d3a8` | 22 | 4 | 2 | Avatar / Mad Cat class — table B | +| `ad4eb428` | 21 | 4 | 3 | Loki | +| `db6cc00d` | 22 | 4 | 2 | Thor | +| `2b34fe3e` | 21 | 4 | 2 | SND2 | +| `9b481293` | 24 | 4 | 2 | Battlemaster / Vulture | +| `11c840bc` | 20 | **0** | 2 | Black Hawk | +| `4c07e216` | 17 | **0** | 2 | Owens | + +The zone COUNTS (17/20/21/22/24) match the per-chassis `.SKL` `dz_` sets +exactly, which is what allows the fingerprint. It is not always unique: the +Avatar and Mad Cat class share a zone set but have two DIFFERENT tables, and +the resource carries no chassis name near the stream, so which is which is +undetermined — recorded as A/B rather than guessed. + +**Black Hawk and Owens rotate NO band with the torso.** Everything else +rotates its upper four. That is a real behavioural difference, not missing +data. + +### The 18 wedge names — six anatomical rings + +``` +band 6 TopRight TopLeft (2 names / 8 slots) +band 5 RightChest FrontChest LeftChest RearChest (4) +band 4 RightWaist FrontWaist LeftWaist RearWaist (4) +band 3 RightHip FrontHip LeftHip RearHip (4) +band 2 RightLeg FrontHip LeftLeg RearHip (transition) +band 1 RightLeg LeftLeg (2) +band 0 RightFoot LeftFoot (2) +``` + +Slot 0 starts at angle 0 and each spans 45 degrees, so with `atan2(z,x)` the +mech's **+X is right and +Z is front**. The named faces each cover TWO adjacent +wedges (Right = slots 7,0 · Front = 1,2 · Left = 3,4 · Rear = 5,6), which means +**dead ahead is the seam between two Front cells, not the centre of one**. + +### Sample cell distributions (Avatar/Mad Cat table A) + +``` +band 0 RightFoot 50% rfoot · 30% rdleg · 20% lfoot +band 1 RightLeg 40% ruleg · 40% rdleg · 10% luleg · 10% ldleg +band 5 FrontChest 50% utorso · 10% each larm/rarm/ltorso/rtorso/dtorso +``` + +Note the scatter is deliberate and generous: a clean foot hit is only half a +foot hit, and one shot in five crosses to the OTHER foot. + +### Playtester-facing visual + +An interactive plate of all of the above — every cell of all 8 tables, a plan +and elevation, and a torso-twist slider that rotates the upper bands live — +was published for playtesting. Regenerate its dataset with `dmgscan.py`.