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
+48
-15
@@ -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
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user