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
+42 -11
View File
@@ -77,7 +77,8 @@ node) — which is why the bug was intermittent and direction-dependent. FIX: a
Advance*Animation Standing cases zero their channel's cycle on entry + ForceUpdate(8) so the
peer hears the stop immediately. (The signed-demand change (task #15) is what let negative
cycles reach machinery written for forward decel.) The earlier "bob reduction + stutter"
symptom family likely shares this root — re-observe after this fix before chasing separately.
symptom family did NOT share this root — it was the body channel's mj=1 joint writes leaking
through on channel phase-splits, closed by v6 (task #64, see the two-channel section).
## The two-channel gait (real controls)
The BT `SequenceController` (`seqctl.cpp`, from `SelectSequence@004277a8`+`Advance@0042790c`) drives
@@ -93,20 +94,50 @@ raw disasm** (both Advance fns had NO static decomp caller; found by byte-scanni
locally INVISIBLE in the binary. The literal projectedVelocity store is unwired, but the
update-record velocity feed is live via the localVelocity/worldLinearVelocity maintenance
(mech4.cpp VELOCITY STORAGE block) — replicant gait (task #50) runs off it. [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]
**v6 (current, task #64 2026-07-13): the body channel does NOT write joints — `AdvanceBodyAnimation(dt, mj=0)`.**
Display == travel == the LEG channel STRUCTURALLY (one skeleton writer), matching the binary's own
observable ("the body channel's phase drift is locally INVISIBLE in the binary"). History: v3
(travel=leg, display=body) foot-slipped; v4 unified BOTH on the body — the out-of-phase LEG pose
showed through on frames the body didn't write = the recurring stutter; **v5 (body-first/leg-last,
believing the leg's overwrite hid body drift) was WRONG the same way MIRRORED** — user-verified
2026-07-13: whenever the channels phase-split, the BODY's out-of-phase mj=1 writes leaked into the
displayed skeleton (rhythmic leg skips + visibly REDUCED BOB) while every leg-channel trace read
clean — the leg DATA was fine; the RENDERED pose wasn't the leg's. The turn-then-walk repro (below)
made it 100% reproducible. `BT_BODY_MJ=1` restores the double-writer for A/B. The old split
mechanism [T2]: (a) SEED — the trn trigger armed only the leg → walk re-entry frames apart;
(b) AMPLIFIER — offset clips → end-of-clip callbacks on different frames → divergent transitions.
Both are ALSO mitigated at the source now (the lockstep trn arming below), but mj=0 is what makes any
residual split invisible, structurally. ⚠ The "bob reduction + stutter" symptom family (previously
parked as "likely shares the reverse-stop root") is THIS — CLOSED. [T2 user-verified]
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`,
`LegClipFinished @0x4a6928` — PE-parsed jump tables) alternate walk 6↔7 / run 12↔13 + walk→run. [T2]
## Turn-in-place (trn, state 4) — reconstructed (task #64, 2026-07-13)
**The authentic entry dispatcher is NOT in the decomp** — it lives in the un-decompiled master-perf
gap `0x4a9b5c0x4ab188` (functions_index.tsv jumps FUN_004a9770→FUN_004ab188; that gap is the SOLE
reader of `turnDemand`@mapper+0x12c and `turnCapable`@0x588 — both grep-empty elsewhere) [T1 proof of
absence]. The decompiled lifecycle: `animationClips[4]` = the `trn`/`trni` clip (loaders 13767/13971);
leg case-4 advance/exits @part_012.c:12013 (exit at `standSpeed<spd` or reverse → Standing);
clip-finish case 4 → Standing (one-shot pivot step); the BODY channel has identical case-4 machinery
(dead unless armed — proof the dispatcher armed BOTH). Port reconstruction (mech2.cpp):
- **THE POSE-MATCH INVARIANT (the key insight):** this engine has NO pose blending — `SelectSequence`
hard-resets to frame 0 — and avoids pops purely by AUTHORING (every transition clip starts in the
pose its predecessor ends in; swr starts from stand). Cutting the pivot clip MID-STEP (any exit
while `legFrm` is mid-clip) teleports the legs = the 100%-repro turn-then-walk stutter. NEVER cut a
clip mid-step; route through its end pose.
- **Entry** (leg Standing case): near-zero speed gate (`spd < 0.25·standSpeed` [T3 stand-in for the
gap's constant]) + turnDemand deadband ±0.05 [T3] + `turnCapable` + **LOCKSTEP: arms BOTH channels
the same frame** (SetLegAnimation(4) + SetBodyAnimation(4), gated on body also Standing) — arming
only the leg is what seeded the channel phase-split.
- **Release** (both case-4s): when `spd > 0.25·standSpeed`, the pivot is FAST-FORWARDED to completion
(advance rate ×4 [T3]) — feet hurry down to the plant, the authentic finish callback drops to
Standing AT the stand pose, then the normal pose-matched stand→walk runs. Decompiled reverse abort
kept; the decompiled `standSpeed` mid-clip abort deliberately subsumed (it WAS the pose cut). [T3]
- Verified: seam trace `frm 15→17→18→19 → stand(frm holds 19) → swr frm1` — no mid-clip reset; both
channels enter walk the same frame. Harness: `BT_FORCE_TURN` (now reaches gBTDrive.turn),
`BT_WALK_DELAY=<s>` (turn-first-then-ramp repro), `BT_GAIT_TRACE=1` (per-frame gait trace). [T2]
**Aliasing bug class (namedClip):** `namedClip[]`@0x5e0 == `animationClips[]`@0x5cc+0x14 (ONE array);
`gimpBaseClip`@0x64c == `animationClips[0x20]`; `bodyAnimationState`@0x728 == `bodyStateAlarm.level`.
Declared-separate members don't see each other's writes — alias them.