Gait: turn-then-walk stutter ROOT-CAUSED + FIXED -- one skeleton writer (task #64)

User repro (100%): turn in place, push forward before the turn stops -> the gait
skips/stutters + visibly reduced bob; standstill starts always clean.  Three-layer
fix, user-verified:

1) THE DISPLAY BUG (the actual visible artifact): v5's "body advances first with
mj=1, leg overwrites last, so body drift can't show" was FALSE.  Whenever the two
gait channels phase-split, the BODY channel's out-of-phase joint writes leaked
into the rendered skeleton (rhythmic leg skips, averaged-down bob) while every
leg-channel trace read clean -- the leg DATA was fine, the RENDERED pose wasn't
the leg's.  v6: AdvanceBodyAnimation(dt, mj=0) -- the body still advances +
projects for replication (records/cycle speeds unchanged) but no longer touches
the skeleton.  One writer, structurally; matches the binary's own observable
("body phase drift is locally INVISIBLE in the binary").  BT_BODY_MJ=1 = old A/B.

2) THE SPLIT SEED: the bring-up trn trigger armed only the LEG channel, so a
turn-in-place entry guaranteed the channels re-entered walk frames apart and the
walk cycles ran permanently out of phase.  Lockstep: the Standing trn entry arms
BOTH channels the same frame (body case-4 twin added, same rate/keying), gated on
both Standing.  The authentic dispatcher (un-decompiled master-perf gap 0x4a9b5c-
0x4ab188, the sole reader of turnDemand/turnCapable) armed both -- the body's
case-4 machinery is dead code otherwise [T1].

3) THE POSE-MATCH INVARIANT: the engine has NO pose blending; transitions avoid
pops purely by authored pose-matched boundaries.  The old trn exits cut the pivot
clip MID-STEP (legFrm 7->1 teleport).  Now: entry gated on near-zero speed
(turn-IN-place); on forward command the pivot FAST-FORWARDS to completion (x4)
and the authentic finish callback lands Standing at the stand pose -> the normal
pose-matched stand->walk runs.  Decompiled reverse abort kept; the standSpeed
mid-clip abort subsumed (it WAS the pose cut).  [T3: 0.25*standSpeed threshold +
4x rate stand in for the gap's constants.]

Harness: BT_FORCE_TURN now reaches gBTDrive.turn (was silently inert for the
gait), BT_WALK_DELAY=<s> holds forced throttle then ramps (the turn-first repro),
BT_GAIT_TRACE=1 per-frame gait trace.  Regressions: standstill start, turn-entry,
pure pivot loops, run cycle -- all clean, bob full amplitude (1.33/1.32).
KB: locomotion.md v6 section + trn reconstruction + symptom-family closure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-13 23:52:35 -05:00
co-authored by Claude Opus 4.8
parent c0a0ec5e69
commit bfdd41bb9d
3 changed files with 218 additions and 40 deletions
+78 -5
View File
@@ -2251,6 +2251,26 @@ void
{
gBTDrive.forced = 1;
gBTDrive.forcedThrottle = sAutoDrive;
// task #64 harness: BT_WALK_DELAY holds the FORCED throttle (the
// mapper's actual input -- the local `throttle` gate below never
// reached it) at 0 for n sim-secs (turn-in-place phase, with
// BT_FORCE_TURN active), then ramps it over 1.2s -- the manual
// "lever dwell through the walk threshold" repro.
{
static float s_wdD = -1.0f, s_wdC = 0.0f;
if (s_wdD < -0.5f)
{
const char *v = getenv("BT_WALK_DELAY");
s_wdD = v ? (float)atof(v) : 0.0f;
}
if (s_wdD > 0.0f)
{
s_wdC += dt;
float r = (s_wdC - s_wdD) / 1.2f;
gBTDrive.forcedThrottle = (r <= 0.0f) ? 0.0f
: (r < 1.0f ? sAutoDrive * r : sAutoDrive);
}
}
}
else if (sGotoDrive)
{
@@ -2388,6 +2408,35 @@ void
s_forceTurn = ft ? (float)atof(ft) : 0.0f;
}
turn = s_forceTurn;
// Publish the forced turn to gBTDrive too: mechmppr derives turnDemand
// from gBTDrive.turn (NOT this local), so without this the headless rig
// never entered the trn state -- BT_FORCE_TURN was silently inert for
// the gait (task #64 harness fix).
gBTDrive.turn = s_forceTurn;
// BT_WALK_DELAY=<n> (stutter repro, task #64): hold throttle at 0 for the
// first n sim-seconds (TURN-IN-PLACE, turn stays active) then release the
// forced throttle -- reproduces "turn in place, then start walking BEFORE
// the turn stops" exactly. Turn (BT_FORCE_TURN) persists across the seam.
static float s_walkDelay = -1.0f, s_wdClock = 0.0f;
if (s_walkDelay < -0.5f)
{
const char *wd = getenv("BT_WALK_DELAY");
s_walkDelay = wd ? (float)atof(wd) : 0.0f;
}
if (s_walkDelay > 0.0f)
{
s_wdClock += dt;
if (s_wdClock < s_walkDelay)
throttle = 0.0f; // turn-in-place phase
else
{
// RAMP the throttle up over 1.2s (simulate the manual lever
// sweep DWELLING through standSpeed) with the turn still active
// -- the continuous-stutter repro.
float r = (s_wdClock - s_walkDelay) / 1.2f;
if (r < 1.0f) throttle *= r;
}
}
if (s_forceLimit > 0.0f)
{
s_forceClock += dt;
@@ -2723,10 +2772,27 @@ void
// the channels' end-of-clip callbacks -> divergent transitions), the
// out-of-phase LEG pose showed through on every frame the body
// didn't write (Standing/wind-down) = the visible leg stutter.
// Body first (projection; writes get overwritten), leg LAST (the
// displayed pose): display == travel through the LEG channel, by
// construction -- body-channel drift can no longer show.
adv = AdvanceBodyAnimation(dt, 1); // channel B: replication projection
// v5 ordered body-first/leg-last believing the leg's overwrite made
// body drift invisible -- WRONG (task #64): the leakage merely
// inverted (the body's pose flashed through instead). v6 (below)
// removes the body from the skeleton entirely (mj=0).
// task #64 RESOLUTION (user-verified): the body channel must NOT
// write joints. The v5 claim "body writes first, leg overwrites,
// so body drift can never show" was FALSE -- whenever the two
// channels phase-split (the turn-in-place entry armed only the
// leg), the body's out-of-phase pose leaked into the displayed
// skeleton: rhythmic leg skips + visibly reduced bob, 100% repro
// on turn-then-walk, while every leg-channel trace read clean
// (the leg data was fine -- the RENDERED pose wasn't the leg's).
// mj=0 advances + projects the body channel identically (records,
// cycle speeds, travel projection all unchanged -- same engine
// mode the replicant leg path uses) but structurally removes the
// second skeleton writer. This also matches the binary's own
// observable: "the body channel's phase drift is locally
// INVISIBLE in the binary" (locomotion.md) -- now it is here too.
// BT_BODY_MJ=1 restores the old double-writer for A/B.
static const int s_bodyMj = BTEnvOn("BT_BODY_MJ", 0);
adv = AdvanceBodyAnimation(dt, s_bodyMj); // channel B: replication projection (mj=0)
legAdv = AdvanceLegAnimation(dt); // channel A: local sim -- pose + travel
}
else
@@ -2781,7 +2847,10 @@ void
static float s_smlog = 0.0f; s_smlog += dt;
static double s_advSum = 0.0, s_legSum = 0.0;
s_advSum += adv; s_legSum += legAdv;
if (s_smlog >= 1.0f) { s_smlog = 0.0f;
// BT_GAIT_TRACE=1 (task #64): log the gait state EVERY frame (not 1 Hz)
// so a frame-level stutter at a transition is visible.
static const int s_gtrace = getenv("BT_GAIT_TRACE") ? 1 : 0;
if (s_smlog >= 1.0f || s_gtrace) { s_smlog = 0.0f;
extern float gBTEyeBobY;
static Joint *s_dbgRoot = 0; static int s_dbgTried = 0;
if (!s_dbgTried) { s_dbgTried = 1; s_dbgRoot = ResolveJoint("jointlocal"); }
@@ -2791,10 +2860,14 @@ void
<< " legSum=" << (float)s_legSum
<< " posZ=" << localOrigin.linearPosition.z
<< " rootZ=" << dbgRt.z << " rootX=" << dbgRt.x << "\n" << std::flush;
MechControlsMapper *s_tm = MappingMapper(); // input demands (task #64)
DEBUG_STREAM << "[gaitSM] adv=" << adv << " legAdv=" << legAdv
<< " cycleSpeed=" << bodyCycleSpeed << " legCycle=" << legCycleSpeed
<< " state=" << bodyAnimationState << " legState=" << legAnimationState
<< " kfCur=" << bodyAnimation.currentFrame
<< " legFrm=" << legAnimation.currentFrame
<< " spd=" << (s_tm ? s_tm->speedDemand : 0.0f)
<< " turn=" << (s_tm ? s_tm->turnDemand : 0.0f)
<< " bob=" << gBTEyeBobY << "\n" << std::flush; }
}
else