the crit system, complete: two gap functions recovered, the dead sink revived, and a wrong verdict reversed (#80)

The whole critical-hit pipeline was dark, three layers deep, and one of those
layers had fooled us into a false conclusion about the 1995 binary itself.

LAYER 1 -- the trigger, recovered from the un-exported gap. The Mech MESSAGE
TABLE at 0x50bdf8 ({id, name, handler} rows) names the real
Mech::TakeDamageMessageHandler at 0x4a0230 -- message 0x12 "TakeDamage" --
plus seven sibling handlers (PlayerLink, RealMaxSpeed, BalanceCoolant,
Set/ClearBurningState, EjectPilot, DuckRequest). Inside it, the crit chance
at 0x4a0164: p = clamp(0.7 * damageLevel^2 + 0.01, 0..1), gated on the
player's simLive flag (+0x25c -- novice never crits), rolled PER BURST on the
current zone, skipping a zone already burning. Chance is ~1% on fresh armour,
~18% at half-stripped, ~58% at 90% -- crits arrive exactly as armour fails.

The handler's application loop replaces the engine base's single call, which
ignored burstCount entirely (multi-burst damage under-applied (burst-1)x).
Faithful shape: per burst, crit-roll -> CriticalHit @0049ccc4 (which routes
half the amount through the armour internally and picks ONE critical
subsystem by criticalWeight) else zone->TakeDamage -- then RE-RUN the
cylinder lottery from the impact point for the next burst, stopping early
once the mech is disabled. Multi-burst damage sprays across zones by design.

LAYER 2 -- the sink. MechSubsystem::TakeDamage was an empty btstubs stand-in;
the real body is at 0x4ac0bc (CLASSMAP had that address mislabeled
"HandleMessage"): zone damage, then on level >= 1.0 the Destroyed alarm, the
PrintState gate, the 1.0 pin, and -- for a vital subsystem -- the owner
mech's graphicAlarm to level 9, the same fall/death level the leg path
raises. That is the #28 vital-subsystem kill machinery, now real.

LAYER 3 -- the one that rewrites yesterday. The subsystem ctor DID copy
armour points + per-type scales into the private zone -- through the
ReconDamageZone PROXY, whose fields sit at struct offsets +4/+8, not the
binary's +0x140/+0x144. The floats landed on the engine object's header and
the real damageScale[] stayed zero. The 2026-07-28 experiment that "proved"
subsystem zones cannot be damaged -- and that the Myomers un-powered
self-repair was dead code in the original -- was measuring exactly this port
bug. Both verdicts reversed: the binary ctor (0x4ac7bb) initializes the zone
from the resource keys WeaponDamagePoints (required) + CriticalHitScoreBonus
(required) + Collision/Ballistic/Explosive/Laser/EnergyDamagePoints, none of
which the CSS parsed. Now parsed (with the binary's own error strings), and
the ctor writes the engine's NAMED members -- layout-parity holds, so they
land on +0x140/+0x144 faithfully. The Myomers repair branch is LIVE, in 1995
and here. KB corrected and swept (combat-damage, subsystems WAVE 6,
myomers.cpp, CLASSMAP).

Live-verified twice: [subarmor] prints real parsed scales for every subsystem
at spawn (HeatSink pts=10 scale=0.1x5, Condensers pts=5 scale=0.2x5, ...);
[critroll] landed full-chain crits in both runs (zone -> weighted pick ->
subsystem's own zone driven to 1.0 -> Destroyed); mech death/respawn and the
ammo gates un-regressed; zero crashes/asserts. Honest gaps: burst>1 spraying
is transcribed but not yet exercised live (self-damage fires burst=1), the
damageType==0 COLLISION divert (@0x49ffcc) is documented-not-reconstructed,
and the id-0x16 damage/kill report messages to the players (the authentic
stats plumbing, decoded to field level in the KB) are deferred to the #45
work.

Diags: BT_CRIT_LOG ([subarmor] + [critroll]), the existing BT_DMG_LOG.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Joe DiPrima
2026-07-29 10:08:33 -05:00
co-authored by Claude Fable 5
parent f7cf9850b1
commit a5fb96ae96
10 changed files with 352 additions and 74 deletions
+48 -15
View File
@@ -359,20 +359,29 @@ death). ALL EIGHT proxy-view sites in mechsub.cpp swept to the engine view
`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.
## ⚠ A subsystem's PRIVATE zone cannot be damaged via `TakeDamage` (2026-07-28) [T0]
Each `MechSubsystem` owns a private `DamageZone` at `+0xE0`, built by the **2-arg trivial ctor**
`new DamageZone(this, 0)`. That ctor (`engine/MUNGA/DAMAGE.cpp:187-190`) zeroes **all five**
`damageScale[]` entries, `Reset()` never touches them, and the only other writer in the tree is
`Mech__DamageZone` — the mech's *streamed* zones (`mechdmg.cpp:246-253`), a different class. Since
`DamageZone::TakeDamage` is `damageLevel += damageAmount * damageScale[damageType]`, **any**
`TakeDamage` against a subsystem's own zone is arithmetically a no-op, whatever the amount or type.
Verified by experiment: a zone seeded to 0.6 and fed 0.011f for ~1500 ticks never moved.
The crit path works because it **bypasses this entirely** and writes `damageLevel` directly
(`DistributeCriticalHit` pins `*(this[0x38]+0x158) = 1.0f`; `ForceCriticalFailure` sets the state).
Practical consequence: if you are reconstructing something that "damages a subsystem", route it the
way the crit path does — a `TakeDamage` call there will silently do nothing. This is also why
Myomers' Performance `@004b8bb9` (an un-powered self-repair) is dead code in the 1995 binary itself:
see [[subsystems]] WAVE 6.
## ⚠ 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
@@ -394,7 +403,31 @@ plausibly intersected the mech's *cylinder* — the damage table is literally cy
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 NEVER ROLL FROM WEAPON FIRE — the trigger is un-exported + the sink is a stub (2026-07-29) [T1]
## 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
+1
View File
@@ -473,6 +473,7 @@ default-ON (`'0'` disables).
| `BT_SELF_DAMAGE=<dps>` | dispatch an unaimed `TakeDamage` at your OWN mech once a second, through the real `Entity::Dispatch` path, so the whole RESPAWN family is bench-testable solo (nothing else can kill the local pilot: `BT_MP_FORCE_DMG` only targets replicants). **Latches off at first death** so everything after the respawn is the respawn's doing, not the harness still shooting you |
| `BT_POWER_DETACH_TEST=<name\|1>` | drop a subsystem's voltage link + force Auto, so the auto-hunt must recover it. `1` = first powered subsystem to tick; a NAME (`PPC_1`, `Myomers`) targets one, which is what proves FAILOVER to a different generator rather than a same-generator re-attach |
| `BT_AUDIO_SOURCES=<n>` | request `n` OpenAL mono sources instead of the driver default (~256). **Opt-in on purpose** — the cap doubles as a governor, and with EFX reverb live a higher ceiling means more simultaneous voices mixing during heavy combat. Measure frame time. See [[wintesla-port]] |
| `BT_CRIT_LOG` | #80 crit diagnostics: `[subarmor]` per-subsystem armour/scales/critBonus at ctor (proves the resource keys parsed + the zone got REAL scales), `[critroll]` per landed crit (zone, subsystem, its resulting own-zone level). NB the type-0x1e loader's `[crit]` tag is a different, older log |
| `BT_DEVICELOST_TEST=<frame>[,crashrepro]` | #35 bench hook. `<frame>` forces the D3D9 DEVICELOST branch at that render frame (+600/+1200 = 3 cycles), driving the REAL `BTResetLostDevice` recovery. `,crashrepro` runs the field null-teardown shape (double `ParticleEngine::Destroy`) — pre-fix this reproduced the field crash byte-for-byte (`Destroy +0x11`, `target=0x0`); post-fix it must log `SURVIVED`. See [[wintesla-port]] §Device-loss |
Full render/locomotion gates (BT_RAMP, BT_MATPRI, BT_CULL, BT_SHADOW_*, BT_LODSEL, BT_ADDLOD,
+10 -12
View File
@@ -92,18 +92,16 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
thermal curve and zone damage; `@004b8ceb` run the inner integrator **only when `outputVoltage > 0`**.
Verified live: healthy `outV=10000 speed=1`, un-powered `outV=0 speed=0`, and 96/96 torso samples at
`elec=4` across two death/respawn cycles.
**`@004b8bb9` is DEAD CODE — in the original too** [T0]. It builds a `Damage`
(type=`Explosive`, amount=`0xbc343958`≈-0.011f, impactPoint=`owner+0x100`, burst=1) and calls the
**zone's** `TakeDamage` (zone vtable `+0x18`, *not* the subsystem's `+0x24`). Reading it as
"an un-powered myomer heals" is the evident intent, but it can never fire: a subsystem's private zone
comes from the 2-arg `new DamageZone(this,0)`, and `engine/MUNGA/DAMAGE.cpp:187-190` zeroes **all five**
`damageScale[]` entries; `Reset()` never touches them and the only other writer is `Mech__DamageZone`
(the mech's *streamed* zones — `mechdmg.cpp:246-253`). Since `TakeDamage` is
`damageLevel += amount * damageScale[type]`, the sum is always `+= amount * 0`. Confirmed by
experiment: seeded to 0.6 and held at NoVoltage ~1500 ticks, `damageLevel` never moved.
Reconstructed and deliberately NOT "fixed" — inventing a working repair would be made-up behavior.
**General rule:** you cannot damage a subsystem's own zone through `DamageZone::TakeDamage`; the crit
path writes `damageLevel` directly (`DistributeCriticalHit` pins `*(this[0x38]+0x158) = 1.0f`).
**CORRECTED 2026-07-29 (#80): `@004b8bb9` — the un-powered self-repair — is LIVE, in 1995 and
now in the port.** The 2026-07-28 "dead code in the original too" verdict here was wrong about the
original: the frozen-at-0.6 experiment was measuring a PORT bug (the subsystem ctor wrote the
zone's armour/scales through the `ReconDamageZone` proxy at struct offsets +4/+8 instead of the
engine members at +0x140/+0x144, so the real `damageScale[]` stayed zero). The binary ctor
(`@0x4ac7bb`) initializes them from the resource keys `WeaponDamagePoints` + the five per-type
`...DamagePoints`; the port now does the same through the engine's named members, and the CSS
parses the keys. An un-powered, not-yet-destroyed myomer heals at `0.011 × damageScale[Explosive]`
per tick. The retracted "cannot damage a subsystem's own zone" rule is superseded — see
[[combat-damage]] §CORRECTED for the full mechanism (this also revived the crit sink, #80).
Also un-stubbed here: `Myomers::DamageStructureLevel()` returned a hardcoded `0.0f`, which pinned
`AvailableOutput`'s `(1 - damage)` factor at 1 — a shot-up myomer drove exactly as well as a fresh
one. Now routed to the base bridge `GetSubsystemDamageLevel()`; measured `dmg=0.6 → speed=0.4`.
+10 -1
View File
@@ -14,7 +14,16 @@ Subsystem (MUNGA base — have source: RP/MUNGA/SUBSYSTM.HPP)
│ CollisionCriticalHitWeight, VideoObjectName, VitalSubsystem) — NO thermal. Couples subsystem→DamageZone@this+0xE0.
│ members: statusAlarm@0x2C (this+0xb), simulationState@0x40, damageZone*@0xE0, refCount@0xF4, alarmModel@0xF8,
│ printSimulationState@0x104, criticalReference@0x108, collisionCriticalHitWeight@0x10C, vitalSubsystemIndex@0x110(-1).
│ methods: GetStatusFlags@4ac144 (structureLevel tier), HandleMessage@4ac0bc, ResetToInitialState@4ac1d4, ClearStatus@4ac22c,
│ methods: GetStatusFlags@4ac144 (structureLevel tier), TakeDamage@4ac0bc (vtable +0x24; #80 CORRECTION -- was mislabeled
│ "HandleMessage": the body consumes a Damage&, hits the private zone, and on level>=1.0 raises Destroyed + the
│ vital-subsystem kill. HandleMessage's real address is unknown), ResetToInitialState@4ac1d4, ClearStatus@4ac22c,
│ #80 GAP RECOVERIES (raw disasm; none exported): Mech::TakeDamageMessageHandler@4a0230 (via the Mech MESSAGE TABLE
│ @50bdf8: rows {id,name,handler} -- 0x12 TakeDamage/4a0230, 0x14 PlayerLink/49f624, 0x15 RealMaxSpeed/49f604,
│ 0x16 BalanceCoolant/49f728, 0x17 SetBurningState/49f674, 0x18 ClearBurningState/49f700, 0x19 EjectPilot/49f854,
│ 0x1a DuckRequest/49fa00), crit-chance@4a0164 (p = clamp(0.7*lvl^2+0.01, 0..1), gate player+0x25c simLive),
│ collision-divert@49ffcc (damageType==0, unreconstructed), Mech vtable +0x18/@4a122c +0x1c/@4a0c2c (switch fns,
│ unidentified). Subsystem CSS keys @50e09d..50e15d: WeaponDamagePoints(req), CriticalHitScoreBonus(req),
│ Collision/Ballistic/Explosive/Laser/EnergyDamagePoints -> res+0x44/+0xE0/+0x30[5].
│ PrintState@4ac8c0 (DefaultState/Destroyed/Exploding), IsDamaged@4ac9c8 (mech bus down), ApplyDamageAndMeasure@4ac07c,
│ DistributeCriticalHit@4ac274 ("ammo explosion damaging"), LookupStatusType@4ac194 (name table @50de74).
│ TechStatusType states @50df17: Destroyed/Damaged/CoolantLeaking/Overheating/AmmoBurning/Jammed/BadPower (count=7).
+3 -4
View File
@@ -175,10 +175,9 @@ void Mech::RaiseStatusAlarm(int /*alarm_id*/)
// MechSubsystem method stubs.
//===========================================================================//
// TODO(bring-up): apply damage to a generic mech subsystem (virtual override).
void MechSubsystem::TakeDamage(Damage & /*damage*/)
{
}
// (MechSubsystem::TakeDamage was a no-op stub here; it is now the real
// @0x4ac0bc body in mechsub.cpp -- zone damage + destroyed alarms + the
// vital-subsystem kill. #80.)
// TODO(bring-up): react to a change in this subsystem's alarm level.
void MechSubsystem::OnAlarmChanged()
+120 -6
View File
@@ -765,12 +765,47 @@ void
}
//
// Mech override of Entity::TakeDamageMessageHandler (binary @0x4a037a, the two
// call sites into the glue @0x49ed0c). An unaimed hit arrives with
// @0x4a0164 -- the CRIT CHANCE roll (#80; raw-disasm 2026-07-29 -- the single
// binary caller of Mech__DamageZone::CriticalHit lives in the handler below,
// and both sat in the un-exported decomp gap).
//
// zone = mech->damageZones[zone_index]
// if (mech->player(+0x190)->simLive(+0x25c) == 0) return 0 ; novice: no crits
// x = zone->damageLevel^2 * 0.7 + 0.01 ; dbl pool @0x4a0208/0x4a0210
// clamp x to [0.0, 1.0] ; @0x4a0218..0x4a0228
// return RandomUnit() <= x ; FUN_00408050(0x521f5c)
//
// Chance is ~1% on pristine armour, ~18.5% at half-stripped, ~58% at 90% --
// crits become likely exactly as a zone's armour fails.
//
static int
BTMechCriticalChance(Mech *mech, int zone_index)
{
extern int BTPlayerExperienceSimLive(void *owner_mech); // btplayer.cpp (+0x25c)
if (!BTPlayerExperienceSimLive(mech))
return 0;
DamageZone *zone = (DamageZone *)mech->damageZones[zone_index];
double x = (double)zone->damageLevel * (double)zone->damageLevel * 0.7 + 0.01;
if (x < 0.0) x = 0.0;
else if (x > 1.0) x = 1.0;
return (RandomUnit() <= (Scalar)x) ? 1 : 0;
}
//
// Mech override of Entity::TakeDamageMessageHandler -- the REAL binary body is
// @0x4a0230 (#80: recovered from the un-exported gap via the Mech message
// table @0x50bdf8, row {0x12, "TakeDamage", 0x4a0230}; the old "@0x4a037a"
// note pointed into its middle). An unaimed hit arrives with
// invalidDamageZone set (damageZone < 0); resolve its zone from the cylinder
// hit-location table (mech[0x111]) using the impact point, clear the flag, then
// hand off to the base handler which routes damageZones[zone]->TakeDamage. Aimed
// (reticle) hits carry a valid zone and pass straight through.
// hit-location table (mech[0x111]) using the impact point, clear the flag,
// then apply the burst loop below (which supersedes the engine base's single
// application). Aimed (reticle) hits carry a valid zone and pass straight to
// the loop. Binary blocks not yet reconstructed here: the damageType==0
// COLLISION divert (@0x4a0368 -> 0x49ffcc, its own distribution path) and the
// id-0x16 damage/kill reports (see the tail comment) [T3/T4 -- decoded in
// context/combat-damage.md].
//
void
Mech::TakeDamageMessageHandler(TakeDamageMessage *message)
@@ -839,7 +874,86 @@ void
<< message->damageData.impactPoint.z << ")\n" << std::flush;
}
}
Entity::TakeDamageMessageHandler(message); // base: damageZones[zone]->TakeDamage
//
// #80 -- the faithful application loop (binary @0x4a0423-0x4a04d8), which
// REPLACES the engine-base single application. Three things the base
// never did:
// 1. BURSTS: the base applied the Damage once and ignored burstCount
// entirely (DamageZone::TakeDamage never reads it) -- multi-burst
// damage under-applied by (burstCount-1)x.
// 2. PER-BURST ZONE RE-ROLL: with bursts remaining, the binary re-runs
// the cylinder lottery from the same impact point (@0x4a04b9), so a
// burst SPRAYS across zones -- authentic scatter.
// 3. THE CRIT ROLL (@0x4a0450): per burst, on the current zone -- gated
// on the zone not already burning, on the player's simLive flag
// (novice never crits), and on chance = clamp(0.7*lvl^2 + 0.01, 0..1)
// rising quadratically as the zone's armour strips. A landed crit
// REPLACES the zone application for that burst (CriticalHit @0049ccc4
// already routes half the amount through the armour internally).
//
// The engine base's -1 guard is preserved: an unresolvable zone applies
// nothing (matches ENTITY.cpp:878; the binary would deref -1 -- it can't
// happen there because every mech ships a lookup table).
//
if (damageZones != 0 && message->damageZone >= 0
&& message->damageZone < damageZoneCount)
{
int zoneIndex = message->damageZone; // local_20
Scalar damageTally = 0.0f; // local_24 (the id-0x16 report tally)
int zoneDestroyed = 0; // local_2c
int burstsLeft = message->damageData.burstCount; // local_28
if (burstsLeft < 1)
burstsLeft = 1; // port guard (binary trusts >= 1)
for (;;)
{
Mech__DamageZone *zone =
(Mech__DamageZone *)damageZones[zoneIndex]; // this[0x120][idx]
Subsystem *critted = 0;
if (zone->GetDamageZoneState() != DamageZone::BurningState // zone state != 1
&& BTMechCriticalChance(this, zoneIndex)) // @0x4a0164 (roll below)
{
critted = zone->CriticalHit(message->damageData); // @0049ccc4
if (critted != 0)
{
damageTally +=
((MechSubsystem *)critted)->CriticalScoreBonus(); // +0x108
if (BTEnvOn("BT_CRIT_LOG", 0))
DEBUG_STREAM << "[critroll] zone=" << zoneIndex
<< " -> " << (critted->GetName() ? critted->GetName() : "?")
<< " subLvl=" << ((MechSubsystem *)critted)->GetSubsystemDamageLevel()
<< "\n" << std::flush;
}
}
if (critted == 0)
zone->TakeDamage(message->damageData); // zone vtbl+0x18 @0x4a0488
damageTally += message->damageData.damageAmount; // +0x30
if (zone->GetDamageZoneState() == DamageZone::BurningState)
zoneDestroyed = 1;
if (--burstsLeft == 0)
break;
DamageLookupTable *tbl = (DamageLookupTable *)damageLookupTable;
if (tbl != 0) // per-burst re-roll
zoneIndex = tbl->ResolveHit(message->damageData.impactPoint);
if (zoneIndex < 0 || zoneIndex >= damageZoneCount)
break; // port guard
if (IsDisabled()) // @0x4a04cc -- stop once dead
break;
}
message->damageZone = zoneIndex; // matchlog sees the LAST zone
// Binary tail (@0x4a04da-0x4a07b2, decoded + deferred): builds id-0x16
// damage/kill REPORT messages -- {tally, zone, zoneDestroyed flag,
// inflicting subsystem, victim name} -- to the shooter's player (with a
// kill-flagged variant when this damage NEWLY disabled the mech) and to
// the victim's player. That is the authentic stats plumbing (#45); the
// port's matchlog + BTPostDamageScore cover the bookkeeping today [T3].
(void)zoneDestroyed;
(void)damageTally;
}
// MP MATCH FORENSICS (matchlog.hpp): the victim-side authoritative damage
// application -- one line per applied TakeDamage with the resolved zone
+12
View File
@@ -580,6 +580,18 @@ void
}
//
// #80 bridge: MechSubsystem::TakeDamage's vital-subsystem kill (@0x4ac126-
// 0x4ac137: owner alarm+0x2C -> level 9). MechSubsystem's TU holds only an
// incomplete Mech; this TU already writes graphicAlarm level 9 for the
// leg-destruction path, so the vital path reuses the same complete-type site.
//
void BTMechVitalSubsystemKill(void *owner_mech)
{
if (owner_mech != 0)
((Mech *)owner_mech)->graphicAlarm.SetLevel(9); // owner+0x2C, level 9
}
//#############################################################################
// CriticalHit -- @0049ccc4
//
+111 -14
View File
@@ -192,21 +192,45 @@ MechSubsystem::MechSubsystem(
damageZone = (ReconDamageZone *)new DamageZone(this, 0); // this[0x38]
BindName((char *)damageZone + 0x15c, GetName()); // FUN_00402a98(., dz+0x15c, name)
// structure reference and per-facing armour into the DamageZone
damageZone->structureReference = subsystem_resource->structureReference; // dz+0x140 = res+0x44
for (int i = 0; i < 5; ++i)
//
// #80 ROOT CAUSE, half one: armour points + per-damage-TYPE scales into the
// private zone (binary @0x4ac7bb-0x4ac853). The old code wrote these
// through the ReconDamageZone PROXY -- whose structureReference/armour[]
// sit at struct offsets +4/+8, NOT the binary's +0x140/+0x144 -- so the
// floats landed on the ENGINE object's header (owningSimulation / the
// StateIndicators) and the engine's REAL damageScale[] stayed at the
// ctor's zeros. Every subsystem-zone TakeDamage was therefore
// `damageLevel += amount * 0` -- the dead crit sink, and the reason the
// 2026-07-28 "cannot damage a subsystem's own zone -- in the original too"
// verdict was WRONG about the original. The engine base layout is
// binary-parity (Mech__DamageZone locks its derived fields from 0x160 up),
// so the engine's NAMED members land exactly on +0x140/+0x144.
//
{
damageZone->armour[i] = subsystem_resource->armorByFacing[i]; // dz+0x144+i = res+0x30+i
}
// invert each facing into an absorption coefficient
for (int i = 0; i < 5; ++i)
{
Scalar a = damageZone->armour[i] - ArmourNumerator / damageZone->structureReference;
if (ArmourEpsilon < fabsf(a)) // FUN_004dcd00
DamageZone *dz = (DamageZone *)damageZone;
dz->defaultArmorPoints = subsystem_resource->weaponDamagePoints; // dz+0x140 = res+0x44
for (int i = 0; i < 5; ++i)
{
damageZone->armour[i] =
ArmourNumerator / (damageZone->armour[i] * damageZone->structureReference);
dz->damageScale[i] = subsystem_resource->damageTypePoints[i]; // dz+0x144+i*4 = res+0x30+i*4
}
// normalize: points -> absorption coefficient, exactly the mech-zone
// math (no *0.5 here -- that halving is the MECH zones' extra step)
for (int i = 0; i < 5; ++i)
{
Scalar a = dz->damageScale[i] - ArmourNumerator / dz->defaultArmorPoints;
if (ArmourEpsilon < fabsf(a)) // FUN_004dcd00, eps @0x4ac864
{
dz->damageScale[i] =
ArmourNumerator / (dz->damageScale[i] * dz->defaultArmorPoints);
}
}
if (BTEnvOn("BT_CRIT_LOG", 0))
DEBUG_STREAM << "[subarmor] " << (GetName() ? GetName() : "?")
<< " pts=" << dz->defaultArmorPoints
<< " scale={" << dz->damageScale[0] << "," << dz->damageScale[1]
<< "," << dz->damageScale[2] << "," << dz->damageScale[3]
<< "," << dz->damageScale[4] << "}"
<< " critBonus=" << criticalReference << "\n" << std::flush;
}
}
@@ -437,6 +461,47 @@ void MechSubsystem::ApplyZoneDamage(Damage &damage)
((DamageZone *)damageZone)->TakeDamage(damage); // zone vtable +0x18
}
//
// @0x4ac0bc -- the REAL virtual TakeDamage (vtable slot +0x24), raw-disasm
// 2026-07-29 (#80). CLASSMAP had this address mislabeled "HandleMessage"; the
// body unambiguously consumes a Damage&. Was an empty btstubs stand-in, so
// ApplyDamageAndMeasure always measured a delta of 0 and no crit ever landed.
//
// zone->TakeDamage(damage) ; zone vtbl+0x18
// if (zone->damageLevel >= 1.0) ; vs [0x4ac140] = 1.0f
// statusAlarm.SetLevel(1) ; Destroyed (0x41bbd8, this+0x2C)
// if (printSimulationState) PrintState() ; vtbl+0x34, gate this+0x104
// zone->damageLevel = 1.0f ; pin
// if (vitalSubsystem) ; this+0xE4
// owner(+0xD0) alarm+0x2C SetLevel(9) ; the VITAL-SUBSYSTEM KILL (#28)
//
void MechSubsystem::TakeDamage(Damage &damage)
{
DamageZone *dz = (DamageZone *)damageZone;
if (dz == 0) // port guard (binary derefs)
return;
dz->TakeDamage(damage); // zone vtable +0x18
if (dz->damageLevel >= 1.0f) // _DAT_004ac140
{
statusAlarm.SetLevel(1); // Destroyed
if (printSimulationState) // +0x104
{
PrintState(); // vtable +0x34 @4ac8c0
}
dz->damageLevel = 1.0f;
if (vitalSubsystem) // +0xE4
{
// the owner Mech's graphicAlarm (+0x2C) to level 9 -- the same
// fall/death level the leg-destruction path raises. Mech is an
// incomplete type here; the bridge lives in mechdmg.cpp.
extern void BTMechVitalSubsystemKill(void *owner_mech);
BTMechVitalSubsystemKill(owner);
}
}
}
//
// @0x4ac274 -- the AMMO-EXPLOSION fan-out (Gitea #46, re-read from the raw
// decomp). The old reconstruction here was wrong in kind: it "re-applied the
@@ -536,8 +601,8 @@ int
// first pass: prime to "unset"
subsystem_resource->criticalReference = ResourceUnset; // res+0xe0 = -1.0f
for (int i = 0; i < 5; ++i)
subsystem_resource->armorByFacing[i] = ResourceUnset; // res+0x30.. = -1.0f
subsystem_resource->structureReference = ResourceUnset; // res+0x44
subsystem_resource->damageTypePoints[i] = ResourceUnset; // res+0x30.. = -1.0f
subsystem_resource->weaponDamagePoints = ResourceUnset; // res+0x44
subsystem_resource->vitalSubsystemIndex = -1; // res+0x48
memset(subsystem_resource->videoObjectName, 0, 128); // res+0x4c
strcpy(subsystem_resource->videoObjectName, "None"); // DAT_0050dfc0
@@ -570,6 +635,38 @@ int
Get_Segment_Index(model_file, model_name, directories, vitalName); // FUN_004274f8
}
// #80 -- the armour/crit keys the parse never consumed (binary key strings
// @0x50e09d..0x50e15d, in this order). Their absence is the second half of
// why the subsystem's private zone could never take damage: the ctor's
// scale block normalized ResourceUnset garbage (and wrote it through the
// wrong layout besides). WeaponDamagePoints and CriticalHitScoreBonus are
// REQUIRED by the binary (" Must have a ..." @0x50e0b0/@0x50e0e6); the five
// per-damage-type keys are optional.
if (!model_file->GetEntry(subsystem_name, "WeaponDamagePoints",
&subsystem_resource->weaponDamagePoints) // 0x50e09d
&& subsystem_resource->weaponDamagePoints == ResourceUnset)
{
DebugStream << subsystem_name << " Must have a WeaponDamagePoints"; // 0x50e0b0
return False;
}
if (!model_file->GetEntry(subsystem_name, "CriticalHitScoreBonus",
&subsystem_resource->criticalReference) // 0x50e0d0
&& subsystem_resource->criticalReference == ResourceUnset)
{
DebugStream << subsystem_name << " Must have a CriticalHitScoreBonus"; // 0x50e0e6
return False;
}
model_file->GetEntry(subsystem_name, "CollisionDamagePoints",
&subsystem_resource->damageTypePoints[0]); // 0x50e109
model_file->GetEntry(subsystem_name, "BallisticDamagePoints",
&subsystem_resource->damageTypePoints[1]); // 0x50e11f
model_file->GetEntry(subsystem_name, "ExplosiveDamagePoints",
&subsystem_resource->damageTypePoints[2]); // 0x50e135
model_file->GetEntry(subsystem_name, "LaserDamagePoints",
&subsystem_resource->damageTypePoints[3]); // 0x50e14b
model_file->GetEntry(subsystem_name, "EnergyDamagePoints",
&subsystem_resource->damageTypePoints[4]); // 0x50e15d
Check_Fpu();
return True;
}
+27 -5
View File
@@ -95,8 +95,15 @@ class Damage;
struct MechSubsystem__SubsystemResource:
public Subsystem::SubsystemResource
{
Scalar armorByFacing[5]; // +0x30 per-facing armour (default -1.0f)
Scalar structureReference; // +0x44 health/structure divisor (default -1.0f)
// task #80 RENAME (binary key strings @0x50e109..0x50e15d): these five are
// per-DAMAGE-TYPE points in Damage enum order -- "CollisionDamagePoints" /
// "Ballistic" / "Explosive" / "Laser" / "EnergyDamagePoints" -- NOT
// per-facing armour (the old name was a guess).
Scalar damageTypePoints[5]; // +0x30 per-damage-TYPE points (default -1.0f)
// "WeaponDamagePoints" (@0x50e09d) -- REQUIRED (" Must have a
// WeaponDamagePoints", @0x50e0b0) -- the subsystem's armour points,
// copied to its private zone's defaultArmorPoints@0x140 by the ctor.
Scalar weaponDamagePoints; // +0x44 "WeaponDamagePoints" (default -1.0f)
int vitalSubsystemIndex; // +0x48 "VitalSubsystem" segment (default -1)
char videoObjectName[128]; // +0x4C "VideoObjectName" (default "None")
ResourceDescription::ResourceID
@@ -111,7 +118,9 @@ class Damage;
int _alarmModelReserved[2]; // +0xD0 remainder of the 12-byte 0xCC field
int printSimulationState; // +0xD8 "PrintSimulationState"
Scalar collisionCriticalHitWeight; // +0xDC "CollisionCriticalHitWeight"
Scalar criticalReference; // +0xE0 (default -1.0f)
Scalar criticalReference; // +0xE0 "CriticalHitScoreBonus" (@0x50e0d0,
// REQUIRED) -- the damage-tally value a
// crit on this subsystem is worth
};
//###########################################################################
@@ -245,9 +254,17 @@ class Damage;
void
PrintState(); // slot 13, @0x4ac8c0
Logical
HandleMessage(int message); // @0x4ac0bc
HandleMessage(int message); // (address unknown -- the old @0x4ac0bc
// claim was the CLASSMAP mislabel; that
// address is TakeDamage, see below)
// @0x4ac0bc -- vtable slot +0x24 [T1, raw disasm 2026-07-29]. Routes the
// Damage into the subsystem's PRIVATE zone (zone vtbl+0x18), then on
// damageLevel >= 1.0: statusAlarm=Destroyed, the PrintState gate, pin 1.0,
// and -- if vitalSubsystem -- the owner mech's graphicAlarm to level 9 (the
// vital-subsystem KILL, issue #28). Was an empty btstubs stand-in, which is
// half of why crits never registered (#80). Defined in mechsub.cpp.
virtual void
TakeDamage(Damage &damage); // slot via this+0x24 (see DamageDelta)
TakeDamage(Damage &damage); // @0x4ac0bc (vtable +0x24)
Logical
IsDamaged(); // @0x4ac9c8 -- NOVICE-experience lockout predicate (player+0x274 == 0), NOT a damage query (issue #2); see mechsub.cpp
@@ -269,6 +286,11 @@ class Damage;
Scalar GetSubsystemDamageLevel() const;
void SetSubsystemDamageLevel(Scalar level);
// #80: the crit-tally worth of this subsystem ("CriticalHitScoreBonus",
// member @0x108) -- the TakeDamage handler adds it per landed crit
// (binary @0x4a0473: tally += critted->+0x108).
Scalar CriticalScoreBonus() const { return criticalReference; }
// task #2 (crit propagation): the zone-side crit paths
// (Mech__DamageZone::CriticalHit @0049ccc4 / SendSubsystemDamage
// @0049c9a8) reach these subsystem facets.
+10 -17
View File
@@ -496,23 +496,16 @@ void Myomers::MyomersSimulation(Scalar time_slice)
// so the READING is "a myomer you power down slowly heals, and the `< 1.0`
// test stops a destroyed zone being resurrected".
//
// ⚠ BUT IT IS INERT -- IN THE ORIGINAL TOO. [T0, measured 2026-07-28]
// A subsystem's private zone is built by the 2-arg trivial ctor
// `new DamageZone(this, 0)`, and DAMAGE.cpp:187-190 zeroes ALL FIVE
// damageScale[] entries; DamageZone::Reset never touches them, and the only
// other writer in the codebase is Mech__DamageZone (the MECH's streamed
// zones, a different class -- mechdmg.cpp:246-253). So damageScale stays
// {0,0,0,0,0} for the life of the zone and the sum above is always
// `damageLevel += amount * 0.0f` == no change. Confirmed live: seeded to
// 0.6, held at NoVoltage for ~1500 ticks, damageLevel never moved off 0.6
// (BT_MYOMERS_REPAIR_TEST below). The crit path reaches subsystem damage a
// different way -- it writes damageLevel DIRECTLY (mechsub.cpp
// DistributeCriticalHit pins *(this[0x38]+0x158) = 1.0f) -- which is why
// subsystems can still be destroyed even though this route cannot.
//
// Reconstructed anyway, and deliberately NOT "fixed": this is what the 1995
// binary does, and inventing a working repair here would be a behavior we
// made up. Kept so the dead branch is visible rather than silently missing.
// ⚠ CORRECTION (2026-07-29, #80): the earlier "inert in the original too"
// note here was WRONG about the original. The 2026-07-28 experiment that
// froze at 0.6 was measuring a PORT bug: the subsystem ctor wrote the
// zone's armour/scales through the ReconDamageZone PROXY (struct offsets
// +4/+8) instead of the engine members at +0x140/+0x144, so the real
// damageScale[] stayed zero. The binary's ctor (@0x4ac7bb) initializes
// them from the resource ("WeaponDamagePoints" + the five per-type
// "...DamagePoints" keys), and the port now does the same -- so this
// branch is LIVE, in 1995 and here: an un-powered, not-yet-destroyed
// myomer heals at 0.011 * damageScale[Explosive] per tick.
// (impactPoint/burstCount are set for fidelity; the callee ignores both.)
if (electricalStateAlarm.GetLevel() == PoweredSubsystem::NoVoltage // this+0x278 == 1
&& DamageStructureLevel() < 1.0f) // zone+0x158 < _DAT_004b8d10