diff --git a/context/combat-damage.md b/context/combat-damage.md index 4cc9172..f458abc 100644 --- a/context/combat-damage.md +++ b/context/combat-damage.md @@ -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 diff --git a/context/decomp-reference.md b/context/decomp-reference.md index 3cdd759..4b720af 100644 --- a/context/decomp-reference.md +++ b/context/decomp-reference.md @@ -473,6 +473,7 @@ default-ON (`'0'` disables). | `BT_SELF_DAMAGE=` | 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=` | 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=` | 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=[,crashrepro]` | #35 bench hook. `` 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, diff --git a/context/subsystems.md b/context/subsystems.md index 277609e..957c384 100644 --- a/context/subsystems.md +++ b/context/subsystems.md @@ -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`. diff --git a/game/reconstructed/CLASSMAP.md b/game/reconstructed/CLASSMAP.md index fe47bc4..5c05a66 100644 --- a/game/reconstructed/CLASSMAP.md +++ b/game/reconstructed/CLASSMAP.md @@ -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). diff --git a/game/reconstructed/btstubs.cpp b/game/reconstructed/btstubs.cpp index 5db8455..5201059 100644 --- a/game/reconstructed/btstubs.cpp +++ b/game/reconstructed/btstubs.cpp @@ -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() diff --git a/game/reconstructed/mech.cpp b/game/reconstructed/mech.cpp index d8c398a..7506876 100644 --- a/game/reconstructed/mech.cpp +++ b/game/reconstructed/mech.cpp @@ -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 diff --git a/game/reconstructed/mechdmg.cpp b/game/reconstructed/mechdmg.cpp index 34d447b..37d7d52 100644 --- a/game/reconstructed/mechdmg.cpp +++ b/game/reconstructed/mechdmg.cpp @@ -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 // diff --git a/game/reconstructed/mechsub.cpp b/game/reconstructed/mechsub.cpp index cca9264..4a8bfa8 100644 --- a/game/reconstructed/mechsub.cpp +++ b/game/reconstructed/mechsub.cpp @@ -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; } diff --git a/game/reconstructed/mechsub.hpp b/game/reconstructed/mechsub.hpp index 55ff2d0..1a3d157 100644 --- a/game/reconstructed/mechsub.hpp +++ b/game/reconstructed/mechsub.hpp @@ -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. diff --git a/game/reconstructed/myomers.cpp b/game/reconstructed/myomers.cpp index a037849..a546f85 100644 --- a/game/reconstructed/myomers.cpp +++ b/game/reconstructed/myomers.cpp @@ -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