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>
7.2 KiB
id, title, status, source_sections, related_topics, key_terms, open_questions
| id | title | status | source_sections | related_topics | key_terms | open_questions | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| locomotion | Locomotion — gait, the ground model, collision, shadow | established | PROGRESS_LOG.md §7, §10 (gait/ground/collision/shadow); docs/P3_LOCOMOTION.md |
|
|
|
Locomotion
Walking, the gait controller, the ground/collision model, and the shadow. All DEFAULT-ON now
(BT_GAIT_CUTOVER, BT_GAIT_SM, BT_COLLISION, BT_REAL_CONTROLS — set =0 to fall back). Full
detail: docs/P3_LOCOMOTION.md + docs/PROGRESS_LOG.md §7, §10.
Walk speed is animation-driven (not physics)
Each .ANI has a [RootTranslation] where .z = forward root SPEED (world units/s). The
engine AnimationInstance::Animate(dt, move_joints) integrates movement += Δt·rootTrans.z and
moves the body that far → feet plant by construction (no stride constant; k=1). Idle anims have
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 — 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
0x4a9b5cgap region) callsAdvanceLegAnimation@0x4aa399 (airborne @0x4aa388) and stores-dist/dtinto localVelocity (+0x1cc) — the leg drives the local mech's TRAVEL and, advancing LAST (mj=1), the DISPLAYED pose. It reads the LIVEspeedDemandand owns trn/turn-in-place + the early-stand wind-down guard. [T1] - BODY channel = the replication PROJECTOR.
IntegrateMotion(0x4ab1c8; body advance @0x4ab312, fromFUN_004ab430, the projected-origin/dead-reckon updater) stores-dist/dtinto 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 viaBT_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) slewsbodyCycleSpeedtoward the demand through walk/run caps (LoadLocomotionClips, mech3.cpp:326 — measureswalkStride/standSpeedvialegAnimation.SelectSequence). Transition callbacks (Mech::BodyClipFinished @0x4a6d8c,LegClipFinished @0x4a6928— PE-parsed jump tables) alternate walk 6↔7 / run 12↔13 + walk→run. [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.
The AUTHENTIC 1995 ground model (default-on, BT_GROUND_REAL)
Decoded by the 10-agent ground-model-decode workflow (binary FUN_004a9b5c = the master perf).
NO GRAVITY anywhere — accelerations zeroed every frame. Vertical position = an ABSOLUTE per-frame
ground SNAP:
- ctor lifts
collisionTemplate->minYby 5% of volume X-width (the probe height + wall-vs-floor separation). - per frame
MoveCollisionVolume()re-finds the containing node in the zone's BoundingBoxTree (BOXTREE.cpp — NOT a heightfield). - probe
q = origin + (0, lift, 0)→FindBoundingBoxUnder(q, &h)(h = distance down to the highest solid top; −1 = miss). - snap gate
h > 1e-4:origin.y -= (h − lift)⇒ y = surfaceY exactly. MISS ⇒ NOTHING (y holds, no runaway). Up-slope bounded by the lift window (implicit step allowance); walk-offs drop instantly. GetCurrentCollisions→ProcessCollisionList→Mech::ProcessCollision(@0x4abb40, vtbl+0x3c): BoxedSolid resolver → StaticBounce → owner classification. Crushable icon (flags&0x8000) = damage 0.00123f sentinel = move stands; damage>0 = FULL FRAME REJECTION (walls block, never slide); impactVel²>40 = crash-anim (SetLegAnimation(0x20)). [T2] KEY:FindSmallestNodeContainingColumn/FindBoundingBoxUnder(FUN_0040e36c/0040e5f0, BOXTREE.CPP) are ALIVE in the 2007 engine — the "heightfield" labels were wrong (caused two reverted attempts). A master mech needs a CollisionAssistant (GetCurrentCollisions iterates it unchecked). BT solids ship ZERO tiles (h never negative). [T2]
Knockdown / crash + the desync/stutter fixes
Wall impacts iv²>40 (~6.3 u/s) bind the bmp stagger clip. Two documented bug fixes: (a) the
knockdown must stagger BOTH channels (SetBodyAnimation(0x20) + SetLegAnimation(0x20)) or display
- travel split permanently (foot-slip); (b) a contact-hysteresis gate (0.4 s
gBlockCooldown) stops the knockdown re-firing on sustained/glancing contact.localVelocityis zeroed while crashed (spec-faithful) so a knocked mech can't keep advancing. [T2]
Controls (BT_REAL_CONTROLS, default-on)
MechControlsMapper (mechmppr.cpp @004afbe0; btl4mppr.cpp mappers) interprets input → speedDemand
/ turnDemand. ⚠ WndProc NEVER receives WM_KEYUP (the engine's per-frame reader GetMessages
them out) → poll GetAsyncKeyState per frame, gated on foreground. Virtual controls: W/S sweep a
persistent throttle LEVER with a zero detent; A/D a momentary auto-centering stick. [T2]
Shadow (default-on, BT_SHADOW_TILT)
BT 4.10's shadow is the flat *_tshd.bgf proxy drawn as blend-pass geometry, posed by jointshadow
(terrain tilt) + jointtshadow (torso twist). Terrain-tilt samples the collision surface gradient;
a depth-bias (D3DRS_DEPTHBIAS, decal-style) stops it z-fighting/vanishing on inclines. Polar maps
have STUB *_tshd (shadow authored-off on snow). Also the visual-ground conform (btvisgnd.cpp,
render-only lift to the visible terrain triangles — presentation only, never touches localOrigin). [T2]
Key Relationships
- Detail:
docs/P3_LOCOMOTION.md. Uses: asset-formats (SKL/ANI), decomp-reference (offsets). - Feeds: combat-damage (collision→damage), rendering (shadow/visual-conform).