#82 peers see a limping mech SKATING: the mech erased its own gimp cell every frame

The binary's one mech+0x40 IS Simulation::simulationState, which rides EVERY
update record header -- so in 1995 a peer's replicant learned the gimp level on
every packet and its gait limped with no gimp-specific replication anywhere.
The port's Mech::PerformAndWatch wrote SetMovementMode(1) every frame ("ground,
non-death, non-airborne"), which erased the level once it was mirrored in for
the warning voice (#78): the wire carried 1, bystanders walked while sliding at
limp speed, and the voice sequence restarted on every damage event (1->4 edge
per tick) instead of announcing once.

- mech4: that per-frame write now writes the AUTHORITATIVE level
  (gimped ? 3/4 : 1) via a new alarm-only bridge BTMechGimpAlarmLevel -- which
  deliberately never consults the cell it feeds, so a respawn-cleared alarm
  cannot re-latch stale gimp out of it.
- BTMechGimpLevel: falls back to the replicated cell, with ONE-CELL precedence
  (a fall/death/limbo state wins, so the normal drivers and their death latch
  run -- what the binary's single cell enforced structurally).
- Reverted the #78 record guard: on a replicant the master's records are
  authoritative, so pinning 3/4 against them would keep a peer limping through
  a respawn.  Fixed at the writer instead.
- [simstomp] trap now scoped to the watched mech with a module-relative return
  address (symcrash-able) -- that is what named the writer.
- Also learned + recorded: zone damage levels replicate only when the EXPLOSION
  TABLE's tier is crossed (peer measured at 0.428 vs master 0.857), so peer-side
  damage state must never be inferred from them.

Verified two-node (scratchpad/night6/mp_skate.sh): the observer's replicant gets
sim=4 and its gait runs 23 -> 25 (wgr entry -> ggl limp cycle).  Field clue that
cracked it: "after respawning a peer DID see the limp" (epilectrik/SAURON).
KB: locomotion.md + gotcha #25.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Joe DiPrima
2026-07-29 22:29:03 -05:00
co-authored by Claude Fable 5
parent 98907f45af
commit e1c15eb806
6 changed files with 180 additions and 35 deletions
+26
View File
@@ -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).
+25
View File
@@ -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.
+12 -7
View File
@@ -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);}
+15 -22
View File
@@ -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
+38 -5
View File
@@ -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
+64 -1
View File
@@ -71,6 +71,25 @@
#endif
#include <stdio.h> // 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 <random.hpp> // 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)