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
+20 -6
View File
@@ -24,12 +24,26 @@ z=0. The `.MOD` physics governed thrust VEHICLES, NOT walk gait. [T1]
## The two-channel gait (real controls)
The BT `SequenceController` (`seqctl.cpp`, from `SelectSequence@004277a8`+`Advance@0042790c`) drives
locomotion. Under `BT_REAL_CONTROLS` two channels run:
- **LEG channel** writes the skeleton JOINTS from the live demand (a local sim; its joint writes are
overwritten by the body).
- **BODY channel** is the DISPLAYED motion — travel comes from the SAME Advance that writes the
displayed joints (`move_joints=1`), so display == travel BY CONSTRUCTION (v4 fix — killed the
desync class). [T2]
locomotion. Under `BT_REAL_CONTROLS` two channels run — **roles corrected 2026-07-09 (task #49) from
raw disasm** (both Advance fns had NO static decomp caller; found by byte-scanning for `e8` calls):
- **LEG channel = the LOCAL sim.** The master perf (the `0x4a9b5c` gap region) calls
`AdvanceLegAnimation` @0x4aa399 (airborne @0x4aa388) and stores `-dist/dt` into **localVelocity
(+0x1cc)** — the leg drives the local mech's TRAVEL and, advancing LAST (mj=1), the DISPLAYED pose.
It reads the LIVE `speedDemand` and owns trn/turn-in-place + the early-stand wind-down guard. [T1]
- **BODY channel = the replication PROJECTOR.** `IntegrateMotion` (0x4ab1c8; body advance @0x4ab312,
from `FUN_004ab430`, the projected-origin/dead-reckon updater) stores `-dist/dt` into
**projectedVelocity (+0x2a0)** — the dead-reckoning feed for update records. Its phase drift is
locally INVISIBLE in the binary. (The recon does not yet wire this store — follow-up.) [T1]
**v5 (current): display == travel BY CONSTRUCTION through the LEG** (body advances first, leg last).
History: v3 (travel=leg, display=body) foot-slipped — two channels, different consumers; v4 unified
BOTH on the body (mis-reading 0x4ab430 as the travel source) — feet planted, but whenever the two
state machines phase-split, the out-of-phase LEG pose showed through on frames the body didn't write
(Standing/wind-down) = the RECURRING "legs stutter / lose sync" report. The split mechanism [T2,
reproduced via `BT_FORCE_OSC` + `BT_FORCE_TURN` + `BT_SYNC_LOG`]: (a) SEED — the bring-up trn trigger
arms only the leg (authentic trn dispatcher still unlocated), so the walk re-entry lands 1 frame
apart; (b) AMPLIFIER — with offset clips the end-of-clip callbacks fire on different frames, so a
demand change landing between them picks DIFFERENT next states (one winds down, one keeps walking)
→ opposite-phase churn. Under v5 both effects still exist but only skew the projection channel. [T2]
The gait STATE MACHINE (`AdvanceBodyAnimation`, mech2.cpp) slews `bodyCycleSpeed` toward the demand
through walk/run caps (`LoadLocomotionClips`, mech3.cpp:326 — measures `walkStride`/`standSpeed` via
`legAnimation.SelectSequence`). Transition callbacks (`Mech::BodyClipFinished @0x4a6d8c`,
+22
View File
@@ -360,3 +360,25 @@ The real gait finished-callback (was a `(void*)1` loop sentinel).
spawn FACING (the authentic orientation exposed the old fixed-offset assumption).
- Verified: env-free default run = full authentic chain, combat damage -> 0.933, 0 crashes; all =0
fallbacks work. P3 is CLOSED for multiplayer purposes (the update-writer data is maintained).
### v5 (2026-07-09, task #49) — CHANNEL ROLES CORRECTED FROM RAW DISASM: display+travel = LEG
User-reported recurrence of "legs stutter / lose sync" (live [sync]: advSum/legSum drifted 5->92 during
walk-up churn then locked). Reproduced headlessly (BT_FORCE_OSC feathering + BT_FORCE_TURN; per-frame
BT_SYNC_LOG probe): the SEED is the bring-up trn trigger arming only the LEG channel (walk re-entry lands
1 frame apart -> clip phase offset); the AMPLIFIER is a demand change landing between the two channels'
end-of-clip callbacks -> DIVERGENT next-state picks (one winds down, one keeps walking) -> opposite-phase
churn (observed bs=6 ls=7). Under v4 (display+travel = body) the out-of-phase LEG pose showed through on
every frame the body didn't write (Standing/wind-down) = the visible stutter.
DISASM GROUND TRUTH (both Advance fns have NO static decomp caller; found by byte-scanning CODE for e8
calls): the master perf (0x4a9b5c gap region) calls AdvanceLegAnimation @0x4aa399 (airborne @0x4aa388) and
stores -dist/dt into localVelocity (+0x1cc) == the LEG drives local travel (and pose, writing last);
IntegrateMotion (0x4ab1c8; body advance @0x4ab312; caller FUN_004ab430 = the projected-origin updater)
stores -dist/dt into projectedVelocity (+0x2a0) == the BODY is the dead-reckoning PROJECTOR, locally
invisible. v4's reading of 0x4ab430 as the travel source was WRONG.
FIX (mech4.cpp): advance body FIRST (projection; writes overwritten), leg LAST (displayed pose); travel =
legAdv under the two-channel split (travelAdv selector; single-channel path keeps adv). Display == travel
through the LEG by construction; body drift can no longer show. VERIFIED: trn+feather repro clean; straight
drive locked (adv==proj); solo goto=enemy kill chain intact; MP 2-node drive-to-range cross-pod kill intact
(hdlr=241, DESTROYED). FOLLOW-UPS: wire the body adv into the named projectedVelocity member (the +0x2a0
store, currently diagnostic-only); locate the authentic trn entry dispatcher (would also arm... TBD).
New gated instruments: BT_SYNC_LOG (per-frame channel divergence), BT_FORCE_OSC=<s> (throttle feathering).
+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;
}
+18
View File
@@ -613,6 +613,24 @@ void
key_throttle = key_throttle * gBTGotoThrottle;
}
}
// BT_FORCE_OSC=<period>: square-wave the forced throttle between the
// commanded value and near-idle every <period> sim-seconds -- the
// headless FEATHERING repro (run->walk-downshift->run churn, the
// live-play gait-desync trigger pattern, task #49).
{
static float s_oscP = -1.0f, s_oscClock = 0.0f;
if (s_oscP < 0.0f)
{
const char *fo = getenv("BT_FORCE_OSC");
s_oscP = fo ? (float)atof(fo) : 0.0f;
}
if (s_oscP > 0.0f)
{
s_oscClock += time_slice;
if (fmodf(s_oscClock, 2.0f * s_oscP) >= s_oscP)
key_throttle = 0.08f; // near-idle half-cycle
}
}
// BT_FORCE_FLIP=<t>: invert the forced throttle after t sim-seconds
// (the mid-stride direction-change repro).
static float s_hFlip = -1.0f;