Files
BT411/context/locomotion.md
T
arcattackandClaude Opus 4.8 bfdd41bb9d 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>
2026-07-13 23:52:35 -05:00

215 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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; the authored WalkingTurnRate/RunningTurnRate now STREAM correctly since the task #4 record-layout fix -- rec words 0x18/0x19 deg->rad -> mech+0x574/0x578, Wword-absorbed -- wiring them in retires the bring-up rate)"
- "Airborne clip callbacks (FUN_004a7970) deferred"
---
# 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 walk lean IS authored — and INTERIOR clips omit it (2026-07-13, task #59 CORRECTION) [T1/T2]
**A walking mech DOES lean forward — it's authored, sustained, and clip-set-specific.** (This
supersedes the earlier "NO walk lean" audit conclusion, which was right that no CODE writes body
pitch and that the CYCLE clips carry no pitch keys, but wrong about the net behavior: the
TRANSITION clips ramp `jointhip` (hingex, parent of `jointtorso` → the whole upper body + cockpit)
to a steady pose that the cycle clips never rebind, so it HOLDS.) Decoded from BTL4.RES + live
`[hip]` probe [T2]:
- `<pfx>swr` ramps `jointhip` 0 → **8°** (stand→walk), `<pfx>wrl/wrr` 8 → **11.1°** (walk→run),
`<pfx>wsl/wsr` 8 → 0 (walk→stand). The dedicated return clip only exists because the lean
persists. The BLH has NO `jointhip` channel in any clip → it stays level (both views).
- **Two clip sets per mech.** The ctor gate (@part_012.c:10308-10320) binds the **INTERIOR** set
(4-char `'i'` suffix, `LoadLocomotionClipsExt` @004a86c8) to the **local cockpit master** — those
clips animate `jointshakey` (cockpit shake, a BALL joint) and OMIT `jointhip`, so the PILOT's own
view stays level. The **EXTERIOR** set (3-char, `LoadLocomotionClips` @004a80d4) goes to network
REPLICANTS and the forced `L4VIEWEXT` external view — it carries the `jointhip` lean, so everyone
ELSE sees the mech lean into its stride.
- **Port fix (task #59):** the interior loader was a stub aliased to the exterior loader, and the
authentic ctor gate was a no-op (`LoadLowDetailBody`/`LoadHighDetailBody` mislabeled the
FUN_004a80d4/86c8 clip-loader addresses as a body-LOD pair) — so the local cockpit mech played
exterior clips and pitched 8°. Now `LoadLocomotionClipsExt` is the real 4-char interior loader
and the ctor gate selects it (`getenv("L4VIEWEXT") || (instanceFlags&0xC)==4` → exterior; else
interior). Verified live: default (interior) → 4 stray `jointhip` writes over a continuous walk;
`L4VIEWEXT=1` (exterior) → 74 sustained writes at 8/11°.
- **Copy-flag → VIEWPOINT adaptation (the other half, DONE):** the port never sets the replicant
COPY bit (`instanceFlags & 0xC == 4`) — all MP mechs build as local masters (replication rides
the registry + update-records; heat-sim/scoring/torso-watcher-connect all run on both by design,
so flipping the bit would break them). The ctor gate therefore lands EVERY mech on interior. To
restore the authentic RESULT, `Mech::MaintainViewClipSet()` (mech3.cpp, called each frame from
`PerformAndWatch`) picks the set by VIEWPOINT: the mech you pilot (== `GetViewpointEntity()`)
keeps INTERIOR (level cockpit — you never see your own mech externally in the real pod); every
other mech flips ONCE to EXTERIOR (the 8/11° lean). Reloads only on a viewpoint-status change;
the two sets share stride data so the swap is seamless. Verified live: your mech `jointhip`=0
while walking; the peer's replica leans at 8°/11.1° in your view. (Model pointers stashed at
ctor via `BTStashClipState`; the GameModel resource persists for the mission.)
Other pitch sources (unchanged, still correct): `bmp` stagger (≈25° — THE knockdown pitch); JAK's
four directional fall clips (`JAKFB/FF/FL/FR`, root pitch to 1.79 rad — the only mech with them);
`IntegrateMotion`@0x4ab1c8 rotates orientation by `angularVelocity`@0x2d4·dt (yaw only); ground
snap is Y-only (terrain pitch/roll → `jointshadow`). The gyro's `bodyOrientation` spring
(@004b30ec) writes only `jointeye` (cockpit hit-bounce, not a lean). `fallDirection`@0x4a8/
`fallScalar`@0x4b4 are record-payload only. [T1]
## The MARCHING-GHOST desync — ROOT-CAUSED + FIXED (2026-07-13, live diagnosis) [T2]
The long-parked "replicant walks in place while its master stands still" reproduced live with
telemetry running: the master's `[gaitSM]` showed `state=0 cycleSpeed=-2.50725 kfCur=19` — BOTH
channels stuck at a REVERSE cadence inside Standing. Mechanism: the walk-family stop gate
(`cycleSpeed <= ZeroSpeed`, mech2.cpp) passes trivially for NEGATIVE (reverse) cadences, and
several stand-entry paths (turn exit, terminal poses) never touch the cycle — so a reverse-stop
enters Standing at full reverse cycle speed. The master looks still (case-0 never advances the
clip; `kfCur` freezes) but the stale cycle REPLICATES, and the peer's replicant animates it =
the marching ghost. A clean FORWARD stop decays the cycle first (`legSum ~3e-8` on the healthy
node) — which is why the bug was intermittent and direction-dependent. FIX: all four
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 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
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 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]
**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.
Replicants (task #50): travel = engine DeadReckon; leg channel driven joints-only from the replicated
worldLinearVelocity -> speedDemand (mech4.cpp REPLICANT MOTION) — detail in [[multiplayer]]. [T2]
## 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 `*_tshd.bgf` proxy, posed by `jointshadow` (terrain tilt — the SKL's own
contract: "apply terrain angle to pitch and roll") + `jointtshadow` (torso twist). The working 2026-07-09
arrangement (user-verified live: feet layer over it, opaque, survives inclines/declines) has FOUR
co-dependent parts — change one, re-check the others:
1. **TILT** (mech.cpp `UpdateShadowJoint`): quad up aligned to the surface normal via an orthonormal
basis fed to the ENGINE's own LinearMatrix→EulerAngles conversion — hand-derived Euler signs are
exactly how two prior tilt attempts "dug into the hillside"/vanished. ~35° cliff-guard cap.
2. **SAMPLER** (mech4.cpp): gradient from the collision probes PLUS `BTVisualGroundLift` per probe
(the quad hugs the VISIBLE terrain, whose lift varies across a slope); world→local by the TRUE yaw
from `localToWorld` (NOT the `gDriveHeading` scalar mirror — the task-#48 drift bug class).
`BT_SHADOW_LOG` traces the normals.
3. **DRAW ORDER** (`PASS_SHADOW`, l4d3d.h/L4VIDRND/L4VIDEO): shadows draw between the STATIC terrain
and the DYNAMIC opaque bodies (classic decal order), so the mech z-passes over the shadow — the
bias can never paint the shadow OVER the feet, structurally.
4. **BIAS pairing** (L4D3D.cpp): tilt on (default) → decal epsilon 0.0008; `BT_SHADOW_TILT=0`
(flat A/B fallback) → the old 0.004 it needs to survive slope burial. `BT_SHADOW_BIAS` overrides.
5. **LOADER-LEVEL TAG** (L4D3D.cpp `LoadObjectBGF`): `*tshd*` filenames get `SetIsShadow(1)` in the
LOADER, not at call sites. The V VIEW TOGGLE (`SetViewInside`) and the damage swaps RELOAD segment
meshes; an untagged reload disengages the whole pipeline — solid opaque black, no bias, buried on
any slope ("shadow broke mid-session after pressing V", user-confirmed fixed). The all-shadow-
material detector misses the mech proxy (plain black material, not shadow_mtl). [T2]
Polar maps have STUB `*_tshd` (shadow authored-off on snow). The visual-ground conform (`btvisgnd.cpp`)
stays render-only (never touches localOrigin). [T2]
**"Shadow vanishes on inclines" now has THREE confirmed/suspected causes — diagnose before fixing:**
(a) the V-toggle/reload UNTAG above (solid black + buried; broke mid-session; the likeliest cause of
the recurring reports — a relaunch always "fixed" it because the fresh build re-tagged); (b) gait
desync popping the displayed root pose past the bias margin (the first 2026-07-09 recurrence co-
resolved with gait v5, no shadow code changed — though (a) may have contributed) [T2 obs; mechanism
T4]; (c) genuine slope burial when the quad is flat/mis-tilted (the depth-bias margin is finite).
Check `[shadowobj]` tag lines and `[sync]`/`BT_SYNC_LOG` before touching bias/tilt.
## Key Relationships
- Detail: `docs/P3_LOCOMOTION.md`. Uses: [[asset-formats]] (SKL/ANI), [[decomp-reference]] (offsets).
- Feeds: [[combat-damage]] (collision→damage), [[rendering]] (shadow/visual-conform).