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:
co-authored by
Claude Fable 5
parent
f7cf9850b1
commit
a5fb96ae96
+120
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user