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>
98 lines
7.2 KiB
Markdown
98 lines
7.2 KiB
Markdown
---
|
||
id: locomotion
|
||
title: "Locomotion — gait, the ground model, collision, shadow"
|
||
status: established
|
||
source_sections: "PROGRESS_LOG.md §7, §10 (gait/ground/collision/shadow); docs/P3_LOCOMOTION.md"
|
||
related_topics: [asset-formats, decomp-reference, reconstruction-gotchas, combat-damage, rendering]
|
||
key_terms: [RootTranslation, ground-model, SequenceController, MechControlsMapper, SLD, joint]
|
||
open_questions:
|
||
- "Authentic per-mech turn-rate constant (currently bring-up constant)"
|
||
- "Body-callback gimp handlers (states 16-19); airborne callbacks"
|
||
---
|
||
|
||
# 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 `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`,
|
||
`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:
|
||
1. ctor lifts `collisionTemplate->minY` by 5% of volume X-width (the probe height + wall-vs-floor separation).
|
||
2. per frame `MoveCollisionVolume()` re-finds the containing node in the zone's **BoundingBoxTree**
|
||
(BOXTREE.cpp — NOT a heightfield).
|
||
3. probe `q = origin + (0, lift, 0)` → `FindBoundingBoxUnder(q, &h)` (h = distance down to the highest
|
||
solid top; −1 = miss).
|
||
4. 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.
|
||
5. `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. `localVelocity` is 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 `GetMessage`s
|
||
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).
|