diff --git a/context/locomotion.md b/context/locomotion.md index e868e34..0440994 100644 --- a/context/locomotion.md +++ b/context/locomotion.md @@ -322,6 +322,32 @@ trap never fired while the value changed) resets the sim cell between damage eve 1 Hz bench harness it restarted the warning before the 1.8 s voice note; sporadic real-play damage is unaffected. Needs a cdb write-watchpoint dig (candidates: a recon raw +0x2c-family write or a struct copy spanning it). +### The gimp cell is REPLICATED STATE — peers limped for free in 1995 (#82, fixed 2026-07-29) +Field: "peers see a limping mech **skating**" (first-person limp + voice correct; bystanders saw a +normal walk sliding at limp speed; a stop/start did not clear it). Root cause chain, all [T1/T2]: +- The binary's one `mech+0x40` **is** `Simulation::simulationState`, which + `Simulation::Write/ReadUpdateRecord` puts in **every update record header** — so a peer's + replicant learned the gimp level on every packet and its self-simulated gait limped with no + extra plumbing. There is no gimp-specific replication anywhere in the binary because none is + needed. +- A replicant NEVER re-derives the level: zone damage reaches it as a DamageZone update record + (`Entity::ReadDamageUpdateRecord` → `DamageZone::ReadUpdateRecord` writes `damageLevel` straight + from the wire), which never runs `Mech__DamageZone::TakeDamage` — where the leg-threshold + evaluation and the `graphicAlarm` 3/4 write live. ⚠ And zone levels replicate only when the + **explosion table's** damage tier is crossed (`EXPTBL.cpp:512-530` + `CrossedDamageLevelThreshold`) — coarse and lagging (measured: peer at 0.428 while the master + was at 0.857). **Never infer damage state on a peer from replicated zone levels.** +- The port's own `Mech::PerformAndWatch` wrote `SetMovementMode(1)` **every frame** ("ground, + non-death, non-airborne"), which erased the gimp level from that cell once it was mirrored in + for the voice — so the wire carried 1 and peers walked. Found with the scoped `[simstomp]` trap + (`g_btGimpWatchMech` + module-relative return address → `symcrash.py`). +Fix: that per-frame write now writes the authoritative level (`gimped ? 3/4 : 1`) via the +alarm-only bridge `BTMechGimpAlarmLevel` (never the cell it feeds, so a respawn-cleared alarm +can't re-latch a stale gimp). Verified two-node (`scratchpad/night6/mp_skate.sh`): the observer's +replicant receives `sim=4` and its gait runs `23 → 25` (wgr entry → ggl limp cycle). This also +made the voice fire exactly once per onset instead of restarting on every damage event, and +re-enabled the authentic leg-destroyed→fall gate (`mechdmg` MovementMode 3||4 [T1 task #60]), +which had been dead while the cell was pinned at 1. Open: the load-time per-clip callbacks `PTR_LAB_0050d738/744` on the wg clips (uncarved LABs, inert `Recon` placeholders in the port — likely footstep/cycle events, transitions work without them). diff --git a/context/reconstruction-gotchas.md b/context/reconstruction-gotchas.md index 0312688..0458c80 100644 --- a/context/reconstruction-gotchas.md +++ b/context/reconstruction-gotchas.md @@ -664,3 +664,28 @@ bugs the LIVE drivers had each been cured of long before: LIVE sibling for the port-glue idioms (mapper access via `MappingMapper()`, alarm→member re-syncs, null guards, `BTEnvOn` gates). The binary-faithful parts transplant cleanly; it is the RECON-side glue that will be missing, because every glue fix landed only where code was running. + +## 25. A SPLIT cell breaks REPLICATION silently — and per-frame writers erase the half you added (2026-07-29) +The gimp-level saga's second act (#82). When the port carries one binary cell as TWO members, the +damage isn't only cross-TU reads (gotcha #23) — it is **which half rides the wire**: +- `Simulation::simulationState` is replicated in EVERY update record header + (`Simulation::Write/ReadUpdateRecord`). Any binary state living in `mech+0x40` therefore + replicated for free. The port's parallel member (`graphicAlarm`) replicates **nowhere**, so every + behavior a peer derives from it silently becomes master-only — visible as "peers see something + different" bugs (here: a limping mech that skated on every other pod). +- Worse, the engine cell usually already has a **per-frame writer** with a narrower idea of what it + means (`Mech::PerformAndWatch`: `SetMovementMode(1)` = "ground, non-death, non-airborne"). The + moment you mirror extra semantics into it, that writer erases them 60×/second — and the symptom + is not a stuck value but a 1→N→1 oscillation whose edges retrigger anything watching (the + warning-voice sequence restarted on every damage tick and never reached its spoken note). +**Rules:** +1. Before mirroring into an engine cell, `grep` for its per-frame writers and teach them the new + value (write `gimped ? 3/4 : 1`, not a blind `1`). +2. Read the authority, never the cell you feed — an alarm-only bridge (`BTMechGimpAlarmLevel`) + keeps a respawn-cleared alarm from re-latching stale state out of the cell. +3. Don't "fix" a stomp by patching incoming records: on a replicant the master's records ARE the + authority, and pinning a local value against them makes state stick forever (that draft would + have kept peers limping through a respawn). Find the writer instead. +**Tool:** scope the trap. `StateIndicator`/`Simulation` state traps drown in subsystem churn — +filter to one watched object (`g_btGimpWatchMech`) and print the caller module-relative +(`btl4+0x…`) so `tools/symcrash.py` names it. That turned a two-hour guess into one line. diff --git a/engine/MUNGA/SIMULATE.h b/engine/MUNGA/SIMULATE.h index c333054..bedb160 100644 --- a/engine/MUNGA/SIMULATE.h +++ b/engine/MUNGA/SIMULATE.h @@ -225,14 +225,19 @@ public: {Check(this); // #78 DIAG: trap the gimp-cell downgrade (3/4 -> <=1) with the // caller's return address (symbolize via tools/symcrash.py). - if (getenv("BT_AUDIO_SPATIAL")) { + // #78/#82 DIAG: who resets the GIMP cell? Scoped to the one mech + // mechdmg just gimped (g_btGimpWatchMech) -- subsystems are + // Simulations too and their 3->0 churn drowned the earlier trap. + // ra is printed MODULE-RELATIVE (btl4+0xNNNN) for symcrash.py. + { + extern void *g_btGimpWatchMech; + extern void BTGimpStompTrap(void *sim, unsigned cur, + unsigned nw, void *ra); unsigned _cur = simulationState.GetState(); - if ((_cur == 3 || _cur == 4) && new_state != _cur) { - static int s_ds=0; if (s_ds++<40) - DEBUG_STREAM << "[simstomp] " << _cur << "->" << new_state - << " this=" << (void*)this - << " ra=" << _ReturnAddress() - << "\n" << std::flush; } + if ((void*)this == g_btGimpWatchMech + && (_cur == 3 || _cur == 4) && new_state != _cur) + BTGimpStompTrap((void*)this, _cur, new_state, + _ReturnAddress()); } simulationState.SetState(new_state);} diff --git a/game/reconstructed/mech.cpp b/game/reconstructed/mech.cpp index 2327d07..8c2e987 100644 --- a/game/reconstructed/mech.cpp +++ b/game/reconstructed/mech.cpp @@ -2176,21 +2176,14 @@ Logical // airborne 3,4) tracks the master automatically; the case-0 / tail edge // tests below then see the old->current transition. // -// #78 GIMP-MONOTONIC RECORD GUARD: the binary's mech+0x40 is one cell, so its -// records always carried the live gimp level (3/4). The port's mirror write -// (mechdmg) can be STOMPED by a record whose simulationState was captured -// pre-gimp -- and the loopback then perpetuates the stale value forever -// (write captures the stomped 1, read re-applies it): the "reverse disabled" -// voice sequence restarted on every damage tick and never reached the voice -// note. Gimp is monotonic within a life, so a record carrying <=1 while the -// live state is 3/4 is stale by construction: patch the RECORD field to the -// live value before the base read -- SetState then dedupes (no edge, no -// restart) and the next WriteUpdateRecord captures 3/4, converging the loop. -#define BT_GIMP_SAFE_BASE_READ(msg) \ - do { unsigned _cur = GetSimulationState(); \ - if ((_cur == 3 || _cur == 4) && (msg)->simulationState <= 1) \ - (msg)->simulationState = _cur; \ - Simulation::ReadUpdateRecord(msg); } while (0) +// (#78 history: a "gimp-monotonic record guard" briefly lived here, patching +// incoming records that carried a pre-gimp simulationState. It was treating a +// symptom -- the real cause was the mech's own per-frame SetMovementMode(1) in +// PerformAndWatch erasing the gimp level from that one cell every frame, found +// with the [simstomp] trap and fixed at source (mech4.cpp). The guard is gone +// because on a REPLICANT it would have been actively harmful: the master's +// records are authoritative, so pinning 3/4 against them would have kept a +// peer's mech limping forever through a respawn.) void Mech::ReadUpdateRecord(Simulation::UpdateRecord *message) @@ -2296,7 +2289,7 @@ void // real payload (simulationState). BT_T2_CLOCK restores the old stamp (A/B). static const int s_t2clock = getenv("BT_T2_CLOCK") ? 1 : 0; const Time savedLastUpdate = lastUpdate; - BT_GIMP_SAFE_BASE_READ(message); + Simulation::ReadUpdateRecord(message); if (!s_t2clock) lastUpdate = savedLastUpdate; } // FUN_0041bd34 bodyTargetSpeed = record->speedDemand; // @0x6b4 <- rec+0x10 @@ -2326,7 +2319,7 @@ void // real payload (simulationState). BT_T2_CLOCK restores the old stamp (A/B). static const int s_t2clock = getenv("BT_T2_CLOCK") ? 1 : 0; const Time savedLastUpdate = lastUpdate; - BT_GIMP_SAFE_BASE_READ(message); + Simulation::ReadUpdateRecord(message); if (!s_t2clock) lastUpdate = savedLastUpdate; } bodyResetLatch = record->legResetLatch; // @0x658 <- rec+0x10 @@ -2351,7 +2344,7 @@ void case 4: // orientation + angular-velocity re-sync { Mech__ResyncUpdateRecord *record = (Mech__ResyncUpdateRecord *)message; - BT_GIMP_SAFE_BASE_READ(message); + Simulation::ReadUpdateRecord(message); creationTime = Now(); // @0x778 -- the dead-reckon ref time { EulerAngles e(Radian(record->eulerX), Radian(record->eulerY), @@ -2390,7 +2383,7 @@ void // real payload (simulationState). BT_T2_CLOCK restores the old stamp (A/B). static const int s_t2clock = getenv("BT_T2_CLOCK") ? 1 : 0; const Time savedLastUpdate = lastUpdate; - BT_GIMP_SAFE_BASE_READ(message); + Simulation::ReadUpdateRecord(message); if (!s_t2clock) lastUpdate = savedLastUpdate; } heatAlarm.SetLevel(record->heatLevel); // @0x450 <- rec+0x10 @@ -2421,7 +2414,7 @@ void // real payload (simulationState). BT_T2_CLOCK restores the old stamp (A/B). static const int s_t2clock = getenv("BT_T2_CLOCK") ? 1 : 0; const Time savedLastUpdate = lastUpdate; - BT_GIMP_SAFE_BASE_READ(message); + Simulation::ReadUpdateRecord(message); if (!s_t2clock) lastUpdate = savedLastUpdate; } heatAlarm.SetLevel(record->heatLevel); @@ -2454,7 +2447,7 @@ void // real payload (simulationState). BT_T2_CLOCK restores the old stamp (A/B). static const int s_t2clock = getenv("BT_T2_CLOCK") ? 1 : 0; const Time savedLastUpdate = lastUpdate; - BT_GIMP_SAFE_BASE_READ(message); + Simulation::ReadUpdateRecord(message); if (!s_t2clock) lastUpdate = savedLastUpdate; } heatAlarm.SetLevel(record->heatLevel); @@ -2482,7 +2475,7 @@ void // real payload (simulationState). BT_T2_CLOCK restores the old stamp (A/B). static const int s_t2clock = getenv("BT_T2_CLOCK") ? 1 : 0; const Time savedLastUpdate = lastUpdate; - BT_GIMP_SAFE_BASE_READ(message); + Simulation::ReadUpdateRecord(message); if (!s_t2clock) lastUpdate = savedLastUpdate; } airborneSelect = record->airborne; // @0x3f4 <- rec+0x10 diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 8674b6e..02d60ea 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -2439,6 +2439,27 @@ void extern int BTMechGimpLevel(void *mech_v); const int replGl = BTMechGimpLevel(this); const bool replGimp = (replGl == 3 || replGl == 4) && hasGimpClips; + // #82 probe: what does this REPLICANT actually know? gl, the + // zone-derived level, sim cell, and every leg zone's level. + if (getenv("BT_REPL_LOG")) + { + static float s_ga = 0.0f; s_ga += dt; + if (s_ga >= 1.0f) + { + s_ga = 0.0f; + DEBUG_STREAM << "[replgimp] ent=" << (int)GetEntityID() + << " gl=" << replGl + << " sim=" << (int)GetSimulationState() + << " hasClips=" << hasGimpClips << " zones:"; + for (int zi = 0; zi < damageZoneCount; ++zi) + { + Mech__DamageZone *z = Zone(zi); + if (z != 0 && z->damageLevel > 0.0f) + DEBUG_STREAM << " " << zi << "=" << z->damageLevel; + } + DEBUG_STREAM << "\n" << std::flush; + } + } const Scalar replLegAdv = s_peerLegCh ? (replGimp ? AdvanceLegAnimationGimp(dt) // FUN_004a71f4 : AdvanceLegAnimation(dt)) // old: re-derived leg SM @@ -3805,12 +3826,24 @@ 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.) + // #78/#82: this cell IS the binary's one mech+0x40, so it must + // carry the GIMP LEVEL too -- writing a blind 1 here erased it + // every frame (the old note claiming "this write never touches + // them" stopped being true the moment the gimp level was + // mirrored in for the warning voice). Two consequences of the + // erasure, both field-reported: the "reverse disabled" sequence + // restarted on every damage event (1->4 edge per tick), and -- + // because simulationState rides EVERY update record header -- + // peers received 1 and their replicant walked instead of + // limping (#82, "peers see a limping mech skating"). Write the + // authoritative level: gimped -> 3/4, else 1. Alarm-only read + // (never the cell we feed), via the mechdmg bridge (gotcha #23). if (!IsMechDestroyed()) // a dead mech keeps its death movementMode - SetMovementMode(1); // ground, non-death, non-airborne + { + extern int BTMechGimpAlarmLevel(void *mech_v); + const int gl = BTMechGimpAlarmLevel(this); + SetMovementMode((gl == 3 || gl == 4) ? gl : 1); // ground, non-death, non-airborne + } // reverseSpeedMax2@0x7a0 is the run-cycle bodyCycleSpeed CLAMP (AdvanceBody // Animation case 12/13); LoadLocomotionClips does not set it -> it reads // 0xCDCDCDCD (-4.3e8) and the clamp clobbers bodyCycleSpeed -> the run cycle diff --git a/game/reconstructed/mechdmg.cpp b/game/reconstructed/mechdmg.cpp index 6025527..8badf6f 100644 --- a/game/reconstructed/mechdmg.cpp +++ b/game/reconstructed/mechdmg.cpp @@ -71,6 +71,25 @@ #endif #include // sscanf (offline streamer) + +// #78/#82 diagnostic scope: the mech whose GIMP cell the [simstomp] trap in +// SIMULATE.h should watch (set at each leg-threshold crossing). Definition +// lives here so the engine header can extern it without a game dependency. +void *g_btGimpWatchMech = 0; + +// The trap body (SIMULATE.h calls it): report WHO reset the gimp cell, with a +// module-relative return address for tools/symcrash.py. +void BTGimpStompTrap(void *sim, unsigned cur, unsigned nw, void *ra) +{ + static int s_ds = 0; + if (s_ds++ >= 40) + return; + unsigned long base = (unsigned long)GetModuleHandleA(0); + char buf[160]; + sprintf(buf, "[simstomp] %u->%u mech=%p ra=btl4+0x%lx", + cur, nw, sim, (unsigned long)ra - base); + DEBUG_STREAM << buf << std::endl << std::flush; +} #if !defined(RANDOM_HPP) # include // the engine's global RandomGenerator Random #endif @@ -502,6 +521,7 @@ void // if (damageLevel >= LegHalfStructure && (rightLeg != 0 || leftLeg != 0)) { + g_btGimpWatchMech = (void *)mech; // scope the #78 simstomp trap unsigned ss = mech->GetSimulationState(); if (getenv("BT_AUDIO_SPATIAL")) { static int s_gm=0; if (s_gm++<30) DEBUG_STREAM << "[gimp-sim] ss=" << ss << " -> " @@ -624,13 +644,56 @@ void // that typedef split is audited and unified, every cross-TU read of the // alarm goes through here. // -int BTMechGimpLevel(void *mech_v) +// #82 REPLICANTS (field: "peers see a limping mech SKATING", 2026-07-29): the +// binary's mech+0x40 is ONE cell -- and because that cell IS +// Simulation::simulationState, it rides EVERY update record header +// (Simulation::Write/ReadUpdateRecord), so a peer's replicant learned the gimp +// level for free and its gait machine limped with no extra plumbing. +// +// The port split the cell (graphicAlarm = where mechdmg writes; engine +// simulationState = what replicates). A replicant NEVER re-derives the level: +// zone damage reaches it as a DamageZone update record +// (Entity::ReadDamageUpdateRecord -> DamageZone::ReadUpdateRecord writes +// damageLevel straight from the wire), which never runs +// Mech__DamageZone::TakeDamage -- so the threshold evaluation, and with it +// graphicAlarm 3/4, only ever happens on the damage-resolving node. Its +// self-simulated gait therefore stayed in the WALK states while the replicated +// speed dragged it along at limp pace: the skate. +// +// So read the cell the way the binary had it: the master's authoritative +// graphicAlarm, else the replicated engine cell (which the master now mirrors +// for the warning voice, #78 -- the same value the binary put on the wire). +// Mech::StateCount is 0x21 and the engine base only defines 0/1 there, so 3/4 +// are unambiguously the mech's gimp levels (the authored audio watchers on +// SimulationState 3/4 are the binary's own confirmation). +int BTMechGimpAlarmLevel(void *mech_v) { if (mech_v == 0) return 0; return (int)((Mech *)mech_v)->graphicAlarm.GetLevel(); } +int BTMechGimpLevel(void *mech_v) +{ + if (mech_v == 0) + return 0; + Mech *m = (Mech *)mech_v; + const int alarm = (int)m->graphicAlarm.GetLevel(); + const int sim = (int)m->GetSimulationState(); + + // ONE-CELL PRECEDENCE: in the binary a fall / death / limbo state OVERWROTE + // the gimp level in the same cell, so those gaits win and the NORMAL + // drivers (which carry the death latch) must run. Report the alarm + // unchanged there -- never gimp. + if (sim == 2 || (sim >= 5 && sim <= 9)) + return alarm; + if (alarm == 3 || alarm == 4) + return alarm; // master authority + if (sim == 3 || sim == 4) + return sim; // the replicated cell + return alarm; // 0/9/... unchanged +} + void BTMechVitalSubsystemKill(void *owner_mech) { if (owner_mech != 0)