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
+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