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:
co-authored by
Claude Fable 5
parent
9353d59eb9
commit
d4e139a0d9
+20
-6
@@ -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`,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user