diff --git a/context/combat-damage.md b/context/combat-damage.md index 02be611..e3a1ce5 100644 --- a/context/combat-damage.md +++ b/context/combat-damage.md @@ -318,6 +318,21 @@ 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. + ## Damage delivery + the real damage model `Entity::TakeDamageMessage(id, size, inflictingEntityID, zone, Damage&)` → `target->Dispatch`. **Base handler IGNORES zone==−1** (`Entity::TakeDamageMessageHandler`, ENTITY.cpp:878 — returns on diff --git a/context/subsystems.md b/context/subsystems.md index bb9f69a..277609e 100644 --- a/context/subsystems.md +++ b/context/subsystems.md @@ -84,9 +84,29 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER does not match the function you reconstructed, check whether the real target is a wrapper that chains the base sim — the whole family does (`Generator`/`PoweredSubsystem` lead with `HeatSink::HeatSinkSimulation`; the Torso's own perf @004b5cf0 leads with `UpdateWatch`). - Still unreconstructed [T4]: @004b8bb9-0x4b8c1e, an effect when `electricalStateAlarm == 1` and the - myomer's damage zone is < 1.0, building a small vector (const `0xbc343958` ≈ -0.011f) against the - owner's `localOrigin@0x100` — almost certainly the un-powered movement penalty. + **The full wrapper is now reconstructed** [T1/T2, 2026-07-28] — five blocks in binary order: + `@004b8bab` chain `PoweredSubsystemSimulation`; `@004b8bb9` the un-powered self-repair (see below); + `@004b8c1e` republish `outputVoltage@0x344` from the resolved source when the alarm is Ready; + `@004b8c5a` republish `speedEffect@0x31C` = `AvailableOutput(clamped V) / OwnerBaseSpeed()` — the + Mech base speed CANCELS, so `speedEffect` is a **0..1 fraction** of full drive carrying gear ratio, + 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`). + 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`. - **WAVE 7** — projectile/missile weapons (byte-exact; flying projectiles are a PORT reconstruction — the 2007 Entity is 0x1BC vs the binary's 0x300, so raw base-offset reads fail). - **TASK #56 (2026-07-11)** — Gyroscope LIVE byte-exact (ctor @004b3778, sizeof 0x3D0 locked; diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 4e28719..5ce8db9 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -715,6 +715,19 @@ int BTMechTargetZone(void *mech) return MECH_TARGET_SUBIDX(mech); } +// Myomers bridge: the owning entity's world position, the binary's raw +// `owner + 0x100`. Myomers' Performance @004b8be3 copies it into the Damage it +// hands its own zone; `owner` is an incomplete Mech* over in myomers.cpp, so the +// read happens here where Mover is complete (same idiom as BTPilotPosition). +int BTMechEntityPosition(void *entity, float *out_xyz) +{ + if (entity == 0 || out_xyz == 0) + return 0; + const Point3D &p = ((Mover *)entity)->localOrigin.linearPosition; // entity+0x100 + out_xyz[0] = p.x; out_xyz[1] = p.y; out_xyz[2] = p.z; + return 1; +} + // Bring-up body-animation player (file scope so OnBodyAnimFinished can re-arm it). // The engine AnimationInstance walks the mech's joint subsystem from a baked .ani // clip in btl4.res; we advance it each moving frame so the legs CYCLE. diff --git a/game/reconstructed/mechsub.cpp b/game/reconstructed/mechsub.cpp index 846228d..cca9264 100644 --- a/game/reconstructed/mechsub.cpp +++ b/game/reconstructed/mechsub.cpp @@ -421,6 +421,22 @@ Scalar return ((DamageZone *)damageZone)->damageLevel - before; } +// +// Straight to the zone's own virtual -- see the header note. Myomers' +// Performance (@004b8c0f-0x4b8c1b) does exactly: +// eax = this[0xE0]; (*(*eax + 0x18))(eax, &damage) +// i.e. DamageZone::TakeDamage, whose T0 body (engine/MUNGA/DAMAGE.cpp:374) is +// damageLevel += damageAmount * damageScale[damageType]; +// Clamp(damageLevel, 0.0f, 1.0f); SetDamageLevelChangedFlag(); +// so a NEGATIVE damageAmount walks the zone back toward intact, and the clamp +// is what stops it below 0. +// +void MechSubsystem::ApplyZoneDamage(Damage &damage) +{ + if (damageZone != 0) + ((DamageZone *)damageZone)->TakeDamage(damage); // zone vtable +0x18 +} + // // @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 diff --git a/game/reconstructed/mechsub.hpp b/game/reconstructed/mechsub.hpp index c9146a7..55ff2d0 100644 --- a/game/reconstructed/mechsub.hpp +++ b/game/reconstructed/mechsub.hpp @@ -276,6 +276,14 @@ class Damage; // @0x4ac07c -- run the virtual TakeDamage and return the resulting // rise in the subsystem's own damageLevel (the crit allotment used). Scalar ApplyDamageAndMeasure(Damage &damage); + // Apply a Damage straight to this subsystem's OWN zone -- the engine + // DamageZone::TakeDamage virtual (zone vtable +0x18), NOT the subsystem's + // own TakeDamage (this vtable +0x24, which is what ApplyDamageAndMeasure + // runs and which additionally cascades crits/vital checks). Myomers' + // Performance @004b8c16 calls the ZONE virtual directly, so it gets the + // plain `damageLevel += amount * damageScale[type]` and nothing else. + // Lives here because only a complete-DamageZone TU may deref the proxy. + void ApplyZoneDamage(Damage &damage); // The destroyed-side effects SendSubsystemDamage applies when the // subsystem's own zone hits 1.0: statusAlarm Destroyed(1) + the // PrintState debug gate + the zone's state valve. [T1 @0049c9a8] diff --git a/game/reconstructed/myomers.cpp b/game/reconstructed/myomers.cpp index 148ad6b..a037849 100644 --- a/game/reconstructed/myomers.cpp +++ b/game/reconstructed/myomers.cpp @@ -453,14 +453,173 @@ Logical Myomers::HasVoltage(Subsystem *source) } +// The un-powered recovery rate the Performance hands its own damage zone each +// tick, as a NEGATIVE damageAmount: the literal 0xbc343958 stored at @004b8bf0. +static const Scalar MyomerRecoveryPerTick = -0.010999999940395355f; // 0xbc343958 + + //*************************************************************************** -// Myomers::MyomersSimulation Performance @004b8d18 +// Myomers::MyomersSimulation Performance @004b8b9c //*************************************************************************** // -// Per-tick drive/heat integration. BEST-EFFORT (only reachable via the -// activePerformance function pointer; the SetPerformance call site was not -// captured in the shards -- registration is presumed in the un-decompiled -// ToggleSeekVoltage/activation path). +// THE REGISTERED PERFORMANCE -- the outer wrapper, decoded in full 2026-07-28. +// The ctor's [0x511620] resolves to 0x4b8b9c, NOT 0x4b8d18; the port had +// registered the inner drive-heat integrator and dropped this whole wrapper. +// Five blocks, in binary order: +// +// @004b8bab chain PoweredSubsystem::PoweredSubsystemSimulation (0x4b0bd0) +// @004b8bb9 un-powered self-repair of this myomer's own zone (INERT -- see +// the block comment; the original is dead code here too) +// @004b8c1e republish outputVoltage@0x344 from the resolved source +// @004b8c5a republish speedEffect@0x31C, the drive handed to the mover +// @004b8ceb run the inner integrator -- ONLY when outputVoltage > 0 +// +// Restoring the first block alone fixed Gitea #70 (torso twist dead after a +// respawn): the Torso is a PowerWatcher that MIRRORS this subsystem's +// electrical level, and with the base machine never running, a Myomers that +// lost power in the death/reset window sat at NoVoltage forever. The other +// four are the rest of that same dropped wrapper. +// +void Myomers::MyomersSimulation(Scalar time_slice) +{ + // 1. @004b8bab -- the base electrical state machine. MUST come first, and + // must precede any heat-model gate: OwnerAdvancedDamage() is off below + // veteran, so gating ahead of this denied the machine to most pilots. + PoweredSubsystemSimulation(time_slice); // call 0x4b0bd0 + + // 2. @004b8bb9-0x4b8c1b -- the INTENDED un-powered self-repair. While this + // myomer has NO VOLTAGE and its own zone is not yet destroyed, hand that + // zone a NEGATIVE explosive Damage every tick. DamageZone::TakeDamage is + // T0 (engine/MUNGA/DAMAGE.cpp:374) and reads only damageAmount/damageType: + // damageLevel += damageAmount * damageScale[damageType]; + // Clamp(damageLevel, 0.0f, 1.0f); + // 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. + // (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 + { + extern int BTMechEntityPosition(void *entity, float *out_xyz); // mech4.cpp + float ownerXYZ[3] = { 0.0f, 0.0f, 0.0f }; + BTMechEntityPosition(owner, ownerXYZ); // owner+0x100 + + Damage repair; // FUN_0041db7c (Damage::Damage) + repair.damageType = Damage::ExplosiveDamageType; // +0x00 = 2 + repair.damageAmount = MyomerRecoveryPerTick; // +0x04 = 0xbc343958 + repair.impactPoint.x = ownerXYZ[0]; // +0x20 <- FUN_00408440 + repair.impactPoint.y = ownerXYZ[1]; + repair.impactPoint.z = ownerXYZ[2]; + repair.burstCount = 1; // +0x2C + ApplyZoneDamage(repair); // zone vtable +0x18 + } + + // 3. @004b8c1e-0x4b8c5a -- republish the input voltage. (The binary also + // zeroes +0x344 when simulationState == Destroyed just above this, then + // overwrites it unconditionally here; that store is dead in the original + // too, so it is not reproduced.) + Generator *source = (Generator *)ResolveVoltageSource(); // FUN_00417ab4(this+0x1D0) + outputVoltage = (electricalStateAlarm.GetLevel() == PoweredSubsystem::Ready + && source != 0) + ? source->MeasuredVoltage() // source+0x1DC + : 0.0f; + + // 4. @004b8c5a-0x4b8ce8 -- republish speedEffect, the live drive fed to the + // mover. Clamp the input voltage to the SELECTED gear and then to the top + // gear, then normalise AvailableOutput by the same Mech base speed it was + // scaled by -- so speedEffect is a 0..1 FRACTION of full drive carrying the + // gear ratio, the thermal-degradation curve and the accumulated zone damage. + if (!HasVoltage(0)) // vtable +0x40 (slot 16) + { + speedEffect = 0.0f; + } + else + { + Scalar drive = outputVoltage; // @0x344 + if (drive >= seekVoltage[currentSeekVoltageIndex]) // @0x330[@0x320] + drive = seekVoltage[currentSeekVoltageIndex]; + if (drive > seekVoltage[maxSeekVoltageIndex]) // @0x330[@0x32C] + drive = seekVoltage[maxSeekVoltageIndex]; + speedEffect = AvailableOutput(drive) / OwnerBaseSpeed(); // @004b8ac0 / (owner+0x34C) + } + + // BRING-UP SCAFFOLDING (temporary, env-gated, off by default). + // + // BT_MYOMERS_REPAIR_TEST= seeds this myomer's OWN zone to once, + // at frame 300, so the un-powered self-repair above has something to walk + // back. Nothing else can damage that zone on a bench: unaimed self-damage + // goes through the mech's hit-location table and never crits a myomer, so + // block 2 of the Performance would otherwise stay unexercised. Pair it with + // BT_POWER_DETACH_TEST=Myomers to open the NoVoltage window. + // Self-contained: it also cuts the voltage link and drops the subsystem into + // MANUAL, so the AutoConnect hunt does NOT put it straight back (which is + // what BT_POWER_DETACH_TEST does -- that one re-attaches within a frame or + // two, far too short a window to watch a repair). + if (getenv("BT_MYOMERS_REPAIR_TEST")) + { + static int s_rtFrame = 0; + if (++s_rtFrame == 300) + { + Scalar seed = (Scalar)atof(getenv("BT_MYOMERS_REPAIR_TEST")); + if (seed <= 0.0f) seed = 0.5f; + SetSubsystemDamageLevel(seed); // mechsub.cpp bridge + DetachFromVoltageSource(); // @004b0e30 + modeAlarm.SetLevel(ManualConnect); // stay dark (no auto-hunt) + DEBUG_STREAM << "[myo] SEED zone damage = " << seed + << ", detached + forced Manual" << std::endl << std::flush; + } + } + + // PROBE (BT_MYOMERS_LOG): the wrapper's outputs. Sample every frame while + // the electrical state is anything but Ready -- the NoVoltage window is the + // self-repair window and it only lasts about a second, so a once-a-second + // probe would step right over the transient. + if (getenv("BT_MYOMERS_LOG")) + { + static int s_ml = 0; + int notReady = (electricalStateAlarm.GetLevel() != PoweredSubsystem::Ready); + if (notReady || (s_ml % 120) == 0) + { + DEBUG_STREAM << "[myo] " << (GetName() ? GetName() : "?") + << " elec=" << (int)electricalStateAlarm.GetLevel() + << " outV=" << outputVoltage + << " speed=" << speedEffect + << " dmg=" << DamageStructureLevel() + << " gear=" << currentSeekVoltageIndex + << std::endl << std::flush; + } + ++s_ml; + } + + // 5. @004b8ceb -- the drive-heat integrator runs ONLY with voltage present. + if (outputVoltage > 0.0f) // vs _DAT_004b8d14 (0.0f) + MyomersDriveHeat(time_slice); // call 0x4b8d18 +} + + +//*************************************************************************** +// Myomers::MyomersDriveHeat inner @004b8d18 +//*************************************************************************** +// +// Per-tick drive/heat integration. Reached only from the wrapper above, and +// only while outputVoltage > 0. // // Reads the owner Mech's motion state and the seek-voltage ratio, then -- when // the sim-control "advanced damage" gate is on (FUN_004ad7d4: *(Mech[0xD0]+0x190) @@ -482,46 +641,8 @@ Logical Myomers::HasVoltage(Subsystem *source) // inefficiency complements of VelocityEfficiency/AccelerationEfficiency -- is // faithful to the decompiled arithmetic.) // -void Myomers::MyomersSimulation(Scalar time_slice) +void Myomers::MyomersDriveHeat(Scalar time_slice) { - // - // CHAIN THE BASE FIRST (2026-07-28). The registered Performance in the - // binary is @004b8b9c, NOT @004b8d18: the ctor @004b8fec stores - // [0x511620] into activePerformance, and that pointer resolves to - // 0x4b8b9c, whose FIRST act is `call 0x4b0bd0` = - // PoweredSubsystem::PoweredSubsystemSimulation. @004b8d18 is the INNER - // drive-heat integrator it goes on to run. The port had registered the - // inner function and dropped the wrapper, so Myomers never advanced its - // own electrical state machine. - // - // WHY IT MATTERS: NoVoltage is only ever entered by that machine - // (`source == 0`), and only that machine walks it back - // (NoVoltage -> Starting -> Ready). With the machine never running, a - // Myomers that lost power during the death/reset window stayed at - // NoVoltage forever -- and the Torso is a PowerWatcher that MIRRORS the - // watched subsystem's electrical level, so torso.cpp:570 held - // effectiveTwistRate at 0. That is Gitea #70, "torso twist stops working - // after a death/respawn". Measured: 0 dips in 48 samples on a pristine - // mission, dips lasting seconds after a respawn, with the generator at a - // full 10000V and the voltage link resolving fine -- a stale state, not a - // live one. Every sibling chains its base the same way (Generator and - // PoweredSubsystem both call HeatSink::HeatSinkSimulation first; the - // Torso's own registered perf @004b5cf0 leads with UpdateWatch). - // - // ORDER IS LOAD-BEARING: the base call precedes the heat-model gate in the - // binary. Gating first (as this did) also denied the electrical machine - // to every NON-EXPERT pilot, since OwnerAdvancedDamage() is the +0x260 - // heat-model flag and that is off below veteran. - // - PoweredSubsystemSimulation(time_slice); // @004b8bab -> call 0x4b0bd0 - - // UNRECONSTRUCTED [T4], flagged not guessed: @004b8bb9-0x4b8c1e runs an - // additional effect when electricalStateAlarm == 1 (NoVoltage) and the - // myomer's own damage zone is below 1.0 -- it builds a small vector - // (constant 0xbc343958 ~= -0.011f) against the owner's localOrigin@0x100. - // Almost certainly the un-powered myomer movement penalty. Not written - // here; it needs its own decode. - if (!OwnerAdvancedDamage()) // FUN_004ad7d4 gate return; diff --git a/game/reconstructed/myomers.hpp b/game/reconstructed/myomers.hpp index c35acf5..c868fe4 100644 --- a/game/reconstructed/myomers.hpp +++ b/game/reconstructed/myomers.hpp @@ -234,8 +234,15 @@ class Mech; activePerformance = (Simulation::Performance)performance; } + // The REGISTERED Performance is the wrapper @004b8b9c (the ctor's + // [0x511620] resolves there, not to @004b8d18). It chains the base + // electrical machine, runs the un-powered self-repair, republishes + // outputVoltage/speedEffect, and only then -- and only when there IS + // voltage -- runs the inner drive-heat integrator @004b8d18 below. void - MyomersSimulation(Scalar time_slice); // @004b8d18 (Performance) + MyomersSimulation(Scalar time_slice); // @004b8b9c (Performance) + void + MyomersDriveHeat(Scalar time_slice); // @004b8d18 (inner, voltage-gated) //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Subsystem virtual overrides (vtable @005117dc) @@ -340,7 +347,14 @@ class Mech; extern int BTPlayerExperienceHeatModelOn(void *owner_mech); // btplayer.cpp (FUN_004ad7d4) return BTPlayerExperienceHeatModelOn(owner) ? True : False; } - Scalar DamageStructureLevel() const{ return 0.0f; } // DamageZone +0x158 + // This subsystem's OWN zone damage [0,1] (0 intact, 1 destroyed) -- the + // binary reads *(this[0xE0] + 0x158) here. Was a `return 0.0f` stand-in, + // which pinned damage to zero and so made AvailableOutput's (1 - damage) + // factor and the drive-heat damageGain permanently ideal: a shot-up + // myomer drove exactly as well as a fresh one. GetSubsystemDamageLevel() + // is the base's complete-type bridge to that same cell (mechsub.cpp:234), + // which sensor.cpp:312 already reads the same way. + Scalar DamageStructureLevel() const{ return GetSubsystemDamageLevel(); } // DamageZone +0x158 void MoverAttach(int index, Scalar *feed) { (void)index; (void)feed; } // JointedMover vtable +0x38 (no-op) void MoverDetach(int index) { (void)index; } // JointedMover vtable +0x3C (no-op)