From 520f6eecd3df7a497965a4364c25acc188e8693e Mon Sep 17 00:00:00 2001 From: Joe DiPrima Date: Wed, 29 Jul 2026 14:29:36 -0500 Subject: [PATCH] myomer damage reaches the wheels, legs gimp at half structure, reverse refuses -- and an ODR trap unmasked (#75/#78) The speed-demand site (MechControlsMapper::InterpretControls) now applies the drive scale the mover's feed roster applied in 1995: speedDemand multiplies by the myomers' live speedEffect (gear ratio, thermal curve, 1 - zone damage, via the BTMyomersDriveOf bridge) and, while GIMPED, by 0.5 [T3: VGL Lynx's "roughly 50%", BT_GIMP_SPEED overrides]. The gimp states were already being raised -- mechdmg sets graphicAlarm 3 (left) / 4 (right) when a LegDamageZone- flagged zone crosses half structure -- but nothing downstream ever saw them. While gimped, reverse input is refused ("reverse disabled"), matching the old-timers' account; the pod's audio cue rides the alarm's watchers. Bench, one trajectory, arithmetically exact: a deterministic right-leg ramp (the new BT_SELF_DAMAGE_ZONE harness) crosses 0.5 and the demand goes 44.837 -> 6.726 = 44.837 x 0.5 (gimp) x 0.3 (a crit-chewed myomers from the same ramp -- the #80 crits composing with #75's scale, unprompted). The reason "nothing downstream ever saw them" is the real find of the night, now gotcha #23: AlarmIndicator is typedef'd to DIFFERENT TYPES per header family -- mech.hpp says ReconAlarm (4 bytes), heat.hpp says GaugeAlarm (0x54) -- so Mech::graphicAlarm and EVERY member after it sit at different offsets depending on a TU's include order. mechdmg wrote level 4 and read it back; mechmppr read 0 from the same object, same expression. No compiler error can catch it: each TU only ever sees one definition. Until the split is audited, cross-TU reads of the gimp level go through BTMechGimpLevel (compiled in mechdmg's TU) -- and the same split-brain explains why the port carries the binary's ONE movementMode cell as two live members (engine simulationState vs graphicAlarm level) that never meet. Also landed en route: the Myomers un-powered self-repair observed healing in the field logs at exactly 0.011 x the authored Explosive scale per tick -- the 2026-07-29 reversal confirmed live; and Mech message 0x15 "RealMaxSpeed" raw-decoded (@0x49f604: sets mech+0x7a0 from the message unless the +0x7a4 latch holds -- a console-tunable top speed). Open on #78, documented in locomotion.md: the Gimp animation clips (authored keys in the binary's model-record parser; mech2's state enum vs mech3's reverse-fix disagree about slots 0x12-0x17 -- reconcile before wiring) and the audio-cue binding. Diags: BT_SELF_DAMAGE_ZONE, BT_DRIVE_LOG, BT_GIMP_SPEED. Co-Authored-By: Claude Fable 5 --- context/decomp-reference.md | 3 ++ context/locomotion.md | 19 ++++++++ context/reconstruction-gotchas.md | 24 ++++++++++ game/reconstructed/mech4.cpp | 17 ++++++- game/reconstructed/mechdmg.cpp | 23 +++++++++ game/reconstructed/mechmppr.cpp | 78 +++++++++++++++++++++++++++++++ game/reconstructed/myomers.cpp | 23 +++++++++ game/reconstructed/myomers.hpp | 5 ++ 8 files changed, 191 insertions(+), 1 deletion(-) diff --git a/context/decomp-reference.md b/context/decomp-reference.md index 1956bcf..5827e11 100644 --- a/context/decomp-reference.md +++ b/context/decomp-reference.md @@ -473,6 +473,9 @@ 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_SELF_DAMAGE_ZONE=` | aim the BT_SELF_DAMAGE harness at an explicit zone (-1/unset = the lottery) -- ramps one zone deterministically past thresholds (#78 bench) | +| `BT_DRIVE_LOG` | the demand-site drive scale: `[drive] n/drive/mm/dmd/mech` ~1Hz (drive = myomers speedEffect x gimp; mm = the gimp level via BTMechGimpLevel) | +| `BT_GIMP_SPEED=<0..1>` | override the gimped speed factor (default 0.5 [T3 VGL Lynx]) | | `BT_PICK_LOG` | #73 aimed-pick diagnostics: `[segpick]` the segment→zone map at tree build (index, name, zone, sphere), `[pickwin]` the winning zone/score/t per pick (score ~0 = threading the part core, ~1 = envelope graze) | | `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 | diff --git a/context/locomotion.md b/context/locomotion.md index 1ddf49f..e8069d7 100644 --- a/context/locomotion.md +++ b/context/locomotion.md @@ -253,3 +253,22 @@ Check `[shadowobj]` tag lines and `[sync]`/`BT_SYNC_LOG` before touching bias/ti ## Key Relationships - Detail: `docs/P3_LOCOMOTION.md`. Uses: [[asset-formats]] (SKL/ANI), [[decomp-reference]] (offsets). - Feeds: [[combat-damage]] (collision→damage), [[rendering]] (shadow/visual-conform). + +## Myomer drive + the GIMP (limp) chain — IMPLEMENTED 2026-07-30 (#75/#78) [T2 bench] +The speed-demand site (`mechmppr.cpp` InterpretControls) now applies the **drive scale**: +`speedDemand *= myomers.speedEffect × gimpFactor`. The myomers factor is the wrapper's live 0..1 +output (gear/thermal/1−damage, via `BTMyomersDriveOf`); the authentic coupling attached +`&speedEffect` into the mover's feed roster, which the 2007 engine lacks — the multiplication at +the demand site is the port equivalent. The GIMP chain: `mechdmg` raises **graphicAlarm 3 (left) +/ 4 (right)** when a leg zone (the `LegDamageZone`-flagged zones; MadCat: 3/5/8 left, 10/16/19 +right) crosses `LegHalfStructure` (0.5); while gimped the demand additionally multiplies by +**0.5 [T3 — VGL Lynx's "roughly 50%", `BT_GIMP_SPEED` overrides]** and **reverse input is +refused** ("reverse disabled", `[gimp]` log; the pod's audio cue rides the alarm's watchers if +audio rows exist). Measured composed and exact: `dmd 44.837 → 6.726 = 44.837 × 0.5 × 0.3` (a +crit-damaged myomers at 0.3 during the same ramp). ⚠ Cross-TU reads of the gimp level MUST use +`BTMechGimpLevel` (mechdmg.cpp) — see [[reconstruction-gotchas]] §23 (the AlarmIndicator typedef +split). Open on #78: the **Gimp animation clips** (`Left/RightGimpAnimation` + transitions — +authored KEYS in the binary's model-record parser; mech2's enum names states 0x12-0x17 for them +but mech3's reverse-fix reassigned those slots to the reverse figures — reconcile before wiring +clips) and the audio cue binding. Harness: `BT_SELF_DAMAGE_ZONE=` ramps one zone +deterministically; `BT_DRIVE_LOG` prints `[drive] n/drive/mm/dmd/mech`. diff --git a/context/reconstruction-gotchas.md b/context/reconstruction-gotchas.md index 4114961..a8a8ace 100644 --- a/context/reconstruction-gotchas.md +++ b/context/reconstruction-gotchas.md @@ -623,3 +623,27 @@ trusting what it is called — the reconstruction's names are reconstructions to **Sweep note:** the same misnomer sits in all three headers; renaming is safe (no binary meaning attaches to the port's accessor name) but touches three size-locked classes, so do it deliberately. + +## 23. `AlarmIndicator` is a DIFFERENT TYPE per header family — Mech's layout diverges across TUs (2026-07-30) + +Found chasing #78: `mechdmg.cpp` wrote `mech->graphicAlarm.SetLevel(4)` and read 4 back; +`mechmppr.cpp` read **0** from the *same object, same expression, same frame family*. Root cause: + +```cpp +mech.hpp:67 typedef ReconAlarm AlarmIndicator; // 4 bytes {unsigned level} +heat.hpp:52 typedef GaugeAlarm AlarmIndicator; // 0x54 bytes (the real binary alarm) +``` + +`Mech::graphicAlarm` is declared `AlarmIndicator` — so a TU's include ORDER decides which type +that member is, and **every Mech member after it shifts by 0x50 between the two families**. A +write through one family's layout is invisible to a read through the other. This is the shadow +trap operating at the TYPEDEF level, where no compiler error can catch it (each TU only ever sees +one definition). + +**Rule:** any cross-TU read/write of a Mech member declared past `graphicAlarm` must go through a +bridge compiled in a KNOWN TU (`BTMechGimpLevel` in mechdmg.cpp is the pattern) until the typedef +split is audited and unified. The audit itself — which TUs resolve `AlarmIndicator` to which +type, and which member traffic crosses families — is an open work item; the same split-brain also +explains why `MovementMode()` (the engine `simulationState`) and the KB's "movementMode IS the +graphicAlarm level" (task #1) never actually met in the port: they are two different cells, both +alive, written by different subsystems. diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 08f89db..2a7103d 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -3791,6 +3791,10 @@ void // the authentic MaxAcceleration reads directly since the task #4 // record-layout fix (madcat: 30 u/s^2); the old floor-25 block // and its one-shot log are retired. + // (#78 note: the gimp states live on graphicAlarm, NOT this + // engine simulationState cell -- the port carries the + // binary's one mech+0x40 as two members; see the split-brain + // note in combat-damage.md. This write never touches them.) if (!IsMechDestroyed()) // a dead mech keeps its death movementMode SetMovementMode(1); // ground, non-death, non-airborne // reverseSpeedMax2@0x7a0 is the run-cycle bodyCycleSpeed CLAMP (AdvanceBody @@ -5614,10 +5618,21 @@ void dmg.damageAmount = (want > 0.0f) ? want : 20.0f; dmg.burstCount = 1; dmg.impactPoint = localOrigin.linearPosition; + // BT_SELF_DAMAGE_ZONE=: aim the harness at an explicit zone + // (#78 bench: ramp a LEG zone deterministically past the + // LegHalfStructure threshold). Unset = -1 = the lottery. + int sdZone = -1; + { + const char *ze = getenv("BT_SELF_DAMAGE_ZONE"); + if (ze != 0 && *ze != '\0') + sdZone = atoi(ze); + if (sdZone < -1 || sdZone >= damageZoneCount) + sdZone = -1; + } Entity::TakeDamageMessage td( Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage), - GetEntityID(), -1 /*unaimed -> cylinder table resolves the zone*/, dmg); + GetEntityID(), sdZone /*-1 -> cylinder lottery*/, dmg); DEBUG_STREAM << "[selfdmg] " << dmg.damageAmount << " to own mech (unaimed)" << std::endl << std::flush; Dispatch(&td); diff --git a/game/reconstructed/mechdmg.cpp b/game/reconstructed/mechdmg.cpp index 37d7d52..0de6b69 100644 --- a/game/reconstructed/mechdmg.cpp +++ b/game/reconstructed/mechdmg.cpp @@ -469,6 +469,13 @@ void } else if (mech->IsDisabled() == 0) // FUN_0049fb54 (NOT disabled) { + if (getenv("BT_DMG_LOG") && (leftLeg || rightLeg)) + DEBUG_STREAM << "[gimp-eval] zone=" << damageZoneIndex + << " L=" << (int)leftLeg << " R=" << (int)rightLeg + << " lvl=" << damageLevel + << " half=" << (int)(damageLevel >= LegHalfStructure) + << " alarmBefore=" << (int)mech->graphicAlarm.GetLevel() + << "\n" << std::flush; // A live (non-disabled) mech: a half-destroyed leg zone trips the // partial-failure graphic (right -> 4, left -> 3). The oracle // compares against _DAT_0049c9a0 (0.5), NOT StructureMax. task #60: @@ -586,6 +593,22 @@ void // incomplete Mech; this TU already writes graphicAlarm level 9 for the // leg-destruction path, so the vital path reuses the same complete-type site. // +// +// #78 bridge: the gimp level, read in THIS TU. The mapper's direct +// `mech->graphicAlarm.GetLevel()` read 0 while this TU's read of the same +// object returned 4 -- AlarmIndicator is typedef'd to DIFFERENT TYPES per +// header family (mech.hpp: ReconAlarm, 4 bytes; heat.hpp: GaugeAlarm, 0x54), +// so Mech's layout past graphicAlarm diverges between TU families. Until +// that typedef split is audited and unified, every cross-TU read of the +// alarm goes through here. +// +int BTMechGimpLevel(void *mech_v) +{ + if (mech_v == 0) + return 0; + return (int)((Mech *)mech_v)->graphicAlarm.GetLevel(); +} + void BTMechVitalSubsystemKill(void *owner_mech) { if (owner_mech != 0) diff --git a/game/reconstructed/mechmppr.cpp b/game/reconstructed/mechmppr.cpp index a09b50a..0c15b98 100644 --- a/game/reconstructed/mechmppr.cpp +++ b/game/reconstructed/mechmppr.cpp @@ -933,6 +933,32 @@ void } } + // #78: a GIMPED mech cannot back up -- "reverse disabled". mechdmg raises + // graphicAlarm level 3 (left leg) / 4 (right leg) when a leg zone passes + // half structure, and MovementMode() IS that alarm's level (task #1). The + // pod's cue fired through the alarm's audio watchers on the level change; + // here the reverse INPUT is refused while limping (VGL Lynx's account of + // the 4.10 behavior, night 6). + { + // NB read the GRAPHIC ALARM, not MovementMode(): the port carries the + // binary's one mech+0x40 cell as TWO members (engine simulationState + // vs graphicAlarm level) and mechdmg raises the gimp states on the + // ALARM. See the split-brain note in combat-damage.md. + extern int BTMechGimpLevel(void *mech_v); // mechdmg.cpp (the TU-safe read) + int mm = BTMechGimpLevel(mech); + if ((mm == 3 || mm == 4) && reverseThrust >= 1) + { + reverseThrust = 0; + static int s_revWarned = 0; + if (!s_revWarned) + { + s_revWarned = 1; + DEBUG_STREAM << "[gimp] REVERSE DISABLED (leg damage, mode " + << mm << ")\n" << std::flush; + } + } + } + if (reverseThrust < 1) { speedDemand = @@ -942,7 +968,58 @@ void { speedDemand = -mech->reverseStrideLength * throttlePosition; } + + // #75 + #78: the DRIVE SCALE. The authentic coupling attached the + // myomers' speedEffect into the mech's mover, which multiplied every + // attached feed into its drive; the 2007 engine's mover has no feed + // roster, so the same multiplication is applied to the speed demand here. + // speedEffect is the wrapper's live 0..1 output (gear ratio, thermal + // curve, 1 - zone damage); a mech with no Myomers drives at 1.0. While + // GIMPED (mode 3/4) the demand additionally halves -- [T3: the 0.5 is VGL + // Lynx's "roughly 50%" firsthand account, BT_GIMP_SPEED overrides]. { + extern Scalar BTMyomersDriveOf(void *subsystem); + Scalar drive = 1.0f; + int n = mech->GetSubsystemCount(); + for (int i = 2; i < n; ++i) // 0/1 = mapper + voltage bus + { + Scalar f = BTMyomersDriveOf(mech->GetSubsystem(i)); + if (f >= 0.0f) // -1 = not a Myomers + { + drive = f; + break; + } + } + extern int BTMechGimpLevel(void *mech_v); // mechdmg.cpp (the TU-safe read) + int mm = BTMechGimpLevel(mech); // the gimp cell (see above) + if (mm == 3 || mm == 4) + { + static Scalar s_gimp = -1.0f; + if (s_gimp < 0.0f) + { + const char *e = getenv("BT_GIMP_SPEED"); + s_gimp = (e != 0 && *e != '\0') ? (Scalar)atof(e) : 0.5f; + if (s_gimp < 0.0f || s_gimp > 1.0f) s_gimp = 0.5f; + } + drive *= s_gimp; + } + speedDemand *= drive; + if (getenv("BT_DRIVE_LOG")) + { + static float s_dAcc = 0.0f; s_dAcc += time_slice; + if (s_dAcc >= 1.0f) + { + s_dAcc = 0.0f; + DEBUG_STREAM << "[drive] n=" << n << " drive=" << drive + << " mm=" << mm << " dmd=" << speedDemand + << " mech=" << (int)mech->GetEntityID() + << " @" << (void *)mech << "\n" << std::flush; + } + } + } + { + extern int BTMechGimpLevel(void *mech_v); + #define BTMechGimpLevelTrace BTMechGimpLevel static int s_cTrace = -1; if (s_cTrace < 0) { const char *e = getenv("BT_MPPR_TRACE"); s_cTrace = (e && *e != '0') ? 1 : 0; } if (s_cTrace) @@ -952,6 +1029,7 @@ void DEBUG_STREAM << "[mppr-c] thr=" << throttlePosition << " rev=" << reverseThrust << " topSpd=" << mech->reverseStrideLength << " fScale=" << mech->forwardThrottleScale + << " mm=" << BTMechGimpLevelTrace(mech) // #78: 3/4 = gimp << " -> dmd=" << speedDemand // glass-regression verification 2026-07-20: the same trace // carries the turn/aim scalars so one gated line proves the diff --git a/game/reconstructed/myomers.cpp b/game/reconstructed/myomers.cpp index a546f85..ccdb385 100644 --- a/game/reconstructed/myomers.cpp +++ b/game/reconstructed/myomers.cpp @@ -414,6 +414,29 @@ Scalar Myomers::SeekVoltageResponse(Scalar input_voltage) // (see emitter.cpp BTSeekVoltageSample). Guarded: a non-Myomers subsystem // samples 0 (only emitter/myomer pages own a graph in the shipped config). //*************************************************************************** +//*************************************************************************** +// #75 bridge -- the myomers' live drive fraction for the mech's speed demand. +// +// The authentic coupling: ConnectToMover attached &speedEffect into the +// mech's mover, which multiplied every attached feed into its drive. The +// 2007 engine's mover has no such roster, so the port applies the same +// multiplication at the speed-demand site (mechmppr.cpp): demand *= this. +// speedEffect@0x31C is the wrapper's live 0..1 output -- gear ratio, thermal +// curve and (1 - zone damage) -- already republished every tick. A mech +// with no Myomers (or no roster yet) drives at 1.0; a DESTROYED myomers +// drives 0.0 (no drive -- the subsystem that moves the mech is gone). +//*************************************************************************** +Scalar BTMyomersDriveOf(void *subsystem) +{ + Entity *entity = (Entity *)subsystem; + if (entity == 0 || !entity->IsDerivedFrom(Myomers::ClassDerivations)) + return -1.0f; // not a Myomers + Scalar f = ((Myomers *)entity)->SpeedEffect(); + if (f < 0.0f) f = 0.0f; + if (f > 1.0f) f = 1.0f; + return f; +} + Scalar BTMyomersSeekSample(void *subsystem, Scalar voltage) { Entity *entity = (Entity *)subsystem; diff --git a/game/reconstructed/myomers.hpp b/game/reconstructed/myomers.hpp index c868fe4..f7d9562 100644 --- a/game/reconstructed/myomers.hpp +++ b/game/reconstructed/myomers.hpp @@ -376,6 +376,11 @@ class Mech; // protected: Scalar speedEffect; // @0x31C attr 0x12; ctor 1.0f -- live drive fed to the mover + public: + // #75: the live drive fraction for the speed-demand coupling + // (BTMyomersDriveOf -> mechmppr's demand scale). + Scalar SpeedEffect() const { return speedEffect; } + protected: int currentSeekVoltageIndex; // @0x320 attr 0x13; ctor = recommended -- selected drive "gear" int recommendedSeekVoltageIndex;// @0x324 attr 0x14; ctor = resource +0x1AC int minSeekVoltageIndex; // @0x328 attr 0x15; ctor 0