Gait v5: display+travel from the LEG channel (kills the stutter class)

User-reported recurrence of "legs stutter and lose sync".  Live [sync] showed
advSum/legSum drifting 5->92 during walk-up churn, then locking.  Reproduced
headlessly (BT_FORCE_OSC throttle feathering + BT_FORCE_TURN + the new
per-frame BT_SYNC_LOG probe) and root-caused in two parts:

- SEED: the bring-up turn-in-place trigger arms only the LEG channel, so the
  walk re-entry lands one frame apart -> a persistent clip phase offset.
- AMPLIFIER: with offset clips the two end-of-clip callbacks fire on different
  frames; a demand change landing between them picks DIFFERENT next states
  (one channel winds down, the other keeps walking) -> opposite-phase churn
  (observed bs=6 ls=7 -- body on walk-R, leg on walk-L).

Under v4 (display+travel = BODY) the out-of-phase LEG pose showed through on
every frame the body didn't write joints (Standing/wind-down) = the visible
stutter/pop.

DISASM GROUND TRUTH (neither Advance fn has a static decomp caller; found by
byte-scanning the CODE section for e8 calls):
  - master perf (0x4a9b5c gap) -> AdvanceLegAnimation @0x4aa399 (air @0x4aa388)
    -> -dist/dt into localVelocity (+0x1cc): the LEG drives LOCAL travel and
    (writing last) the displayed pose;
  - IntegrateMotion (0x4ab1c8, body advance @0x4ab312, caller FUN_004ab430 =
    the projected-origin updater) -> -dist/dt into projectedVelocity (+0x2a0):
    the BODY is the dead-reckoning PROJECTOR -- locally invisible.
  v4's reading of FUN_004ab430 as the travel source was wrong.

FIX: advance body FIRST (projection; its writes get overwritten), leg LAST
(displayed pose); travel = legAdv under the two-channel split.  Display ==
travel through the LEG by construction -- body-channel drift can no longer
become visible, structurally (not by input-symmetry luck).

Verified: trn+feathering repro clean; straight drive locked (adv==proj);
solo goto=enemy kill chain intact; MP 2-node drive-to-range cross-pod kill
intact (241 hits, DESTROYED); no crashes.  KB corrected (locomotion.md roles
+ P3_LOCOMOTION.md v5 entry).  New gated instruments: BT_SYNC_LOG,
BT_FORCE_OSC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-09 16:25:49 -05:00
co-authored by Claude Fable 5
parent 9353d59eb9
commit d4e139a0d9
4 changed files with 124 additions and 38 deletions
+64 -32
View File
@@ -1819,23 +1819,56 @@ void
}
if (s_realControls)
{
// FOOT-PLANT FIDELITY (raw FUN_004ab430:15076 -> FUN_004ab1c8):
// the binary advances the BODY controller with move_joints=1 --
// the DISPLAYED pose and the travel distance come from the SAME
// Advance (same clip, same phase), so feet plant by construction.
// The leg channel (local-sim, hardcoded move_joints=1 in the raw,
// FUN_004a5028:12006) runs FIRST; the body's writes land after and
// win the frame, exactly the binary's order. The earlier split
// (body silenced with move_joints=0, leg pose displayed) showed
// leg-channel joints against body-channel travel -- two
// independently-phased state machines -> visible foot glide.
legAdv = AdvanceLegAnimation(dt); // channel A: local sim (live demand)
adv = AdvanceBodyAnimation(dt, 1); // channel B: displayed pose + motion
// CHANNEL ROLES (task #49, disasm-corrected [T1]): in the binary
// - the MASTER PERF (0x4a9b5c region) advances the LEG channel
// (call @0x4aa399, airborne @0x4aa388) and stores -dist/dt into
// localVelocity (+0x1cc) -- the LEG drives the LOCAL mech's
// travel AND (writing last, mj=1) the displayed pose;
// - IntegrateMotion (0x4ab1c8, body advance @0x4ab312) stores
// -dist/dt into the PROJECTED velocity (+0x2a0) -- the BODY
// channel is the dead-reckoning/replication PROJECTOR, whose
// phase drift is locally INVISIBLE.
// The previous arrangement here (v4: display+travel from the BODY)
// had the roles swapped: whenever the two state machines phase-split
// (the leg-only trn pivot seed, or a demand change landing between
// 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
legAdv = AdvanceLegAnimation(dt); // channel A: local sim -- pose + travel
}
else
{
adv = AdvanceBodyAnimation(dt, 1); // single-channel: body does both
}
// [syncF] per-frame divergence probe (task #49): log every frame
// where the two channels return different distances or either
// state machine changed state -- pinpoints WHICH frames the
// advSum/legSum drift and the visible pose pops come from.
if (getenv("BT_SYNC_LOG"))
{
static int s_lastBS = -1, s_lastLS = -1;
const int bs = (int)bodyStateAlarm.GetLevel();
const int ls = (int)legStateAlarm.GetLevel();
const float dD = (float)(adv - legAdv);
if (bs != s_lastBS || ls != s_lastLS
|| dD > 0.001f || dD < -0.001f)
{
DEBUG_STREAM << "[syncF] dt=" << dt
<< " bs=" << bs << " ls=" << ls
<< " adv=" << adv << " legAdv=" << legAdv
<< " bCyc=" << bodyCycleSpeed << " lCyc=" << legCycleSpeed
<< " bFrm=" << bodyAnimation.currentFrame
<< " bT=" << bodyAnimation.currentTime
<< " lFrm=" << legAnimation.currentFrame
<< " lT=" << legAnimation.currentTime
<< "\n" << std::flush;
s_lastBS = bs; s_lastLS = ls;
}
}
// COCKPIT BOB (task #15): the leg channel writes the clip's vertical
// root motion into jointlocal (balltranslate) every frame, but the
// camera's DCS chain doesn't consume animated joints -- publish the
@@ -1924,25 +1957,23 @@ void
// accumulated distance over the elapsed time since the last
// keyframe contribution, HELD across zero-distance frames, hard
// zero when Standing (and after 0.3s of silence, e.g. paused clip).
// FOOT-PLANT BY CONSTRUCTION (v4 -- BINARY-FAITHFUL: travel = the
// BODY channel, the SAME Advance that writes the displayed joints;
// raw FUN_004ab430:15076 advances the body with move_joints=1 and
// takes the travel from it). v3 sourced travel from the LEG channel
// while the BODY drew the joints -- TWO state machines that could
// (and did) drift apart whenever any event touched one channel's
// state without the other: the knockdown (fixed by staggering both),
// then AGAIN on the Mad Cat / analog-lever sweeps crossing gait
// boundaries (each channel's end-of-clip callback samples the demand
// at a different instant, so near a walk/run threshold they can pick
// DIFFERENT next states -> permanent phase split = the foot-slip).
// Sourcing travel from the displayed channel kills the whole desync
// CLASS: display == travel by construction. The earlier "tunnels
// through obstacles" objection to this is OBSOLETE -- the knockdown
// now staggers the BODY channel too (SetBodyAnimation(0x20)), so a
// hard impact freezes travel exactly as before (re-verified). The
// leg channel keeps running as the local sim it is in the binary
// (its joint writes land first and are overwritten by the body's).
const Scalar localAdv = adv * dir;
// FOOT-PLANT BY CONSTRUCTION (v5 -- task #49, disasm-corrected):
// display AND travel BOTH come from the LEG channel (the binary's
// master perf advances the leg and writes -dist/dt into
// localVelocity @+0x1cc; the leg writes the pose last). The v4
// reading ("raw FUN_004ab430:15076 takes the travel from the body
// advance") was WRONG -- 0x4ab312's IntegrateMotion stores its
// -dist/dt into the PROJECTED velocity (+0x2a0), the dead-reckoning
// feed, not local travel. v3 (travel=leg, display=body) foot-slipped
// because display and travel were DIFFERENT channels; v4 unified on
// the body, which planted feet but let the out-of-phase LEG pose show
// through wherever the body didn't write (the visible stutter). v5
// unifies on the leg exactly as the binary does: display == travel by
// construction, live-demand channel authoritative, body drift local-
// invisible. (Knockdown still staggers BOTH channels, so a hard
// impact freezes travel as before.)
const Scalar travelAdv = s_realControls ? legAdv : adv;
const Scalar localAdv = travelAdv * dir;
linearSpeed = (localAdv < 0.0f ? -localAdv : localAdv) * invDt; // forward ground speed -> LinearSpeed gauge
Vector3D localVel(0.0f, 0.0f, -localAdv * invDt); // exact frame distance as velocity
Matrix34 orient; // rotation from the heading (set @ line ~626)
@@ -1957,7 +1988,8 @@ void
if (gBodyAnimLog >= 1.0f)
{
gBodyAnimLog = 0.0f;
DEBUG_STREAM << "[gait] adv=" << adv << " pos=("
DEBUG_STREAM << "[gait] adv=" << travelAdv << " (proj=" << adv
<< ") pos=("
<< localOrigin.linearPosition.x << ", "
<< localOrigin.linearPosition.z << ")\n" << std::flush;
}