The MadCat (and every leg-mech) leans -8deg into a walk / -11deg into a run --
an authored jointhip (hingex) pose in the EXTERIOR gait clips that the walk/run
CYCLE clips never rebind, so it HOLDS. The 1995 game keeps a separate INTERIOR
('i'-suffix) clip set for the mech you PILOT: those shake jointshakey (cockpit
rattle) and OMIT jointhip, so your own view stays level while everyone else sees
you lean. The port had this entirely dead -- the local cockpit mech pitched
-8deg into the ground (user-reported "staring at the ground").
ROOT CAUSE (two stacked bugs):
- The authentic ctor clip-set gate (@part_012.c:10308-10320) was reconstructed
as no-op stubs: LoadLowDetailBody/LoadHighDetailBody MISLABELED the
FUN_004a80d4/86c8 GAIT-CLIP loader addresses as a "body LOD" pair, so the gate
did nothing and a separate unconditional LoadLocomotionClips always loaded
EXTERIOR clips.
- LoadLocomotionClipsExt (the interior loader) was a stub aliased to the
exterior loader.
FIX:
- Reconstructed the real 4-char INTERIOR loader (byte-exact vs @004a86c8; the
'i'-suffix table dumped from the exe @0x10d74d: swri/wwri/wwli/...; all 18
base clips confirmed present in BTL4.RES for mad/blh/ava).
- Fixed the ctor gate: getenv("L4VIEWEXT") || (instanceFlags&0xC)==4 -> exterior
(replicant / forced external view); else -> interior (local cockpit master).
Removed the mislabeling no-op stubs.
- PORT ADAPTATION (MaintainViewClipSet, per-frame in PerformAndWatch): the port
never sets the replicant COPY bit (all MP mechs build as local masters --
heat-sim/scoring/torso-watcher-connect all run on both, by design), so the
ctor gate lands everyone on interior. Pick the set by VIEWPOINT instead: the
mech you pilot keeps interior (level cockpit), every other mech flips once to
exterior (the lean). Reloads only on a viewpoint-status change; the sets
share stride data so the swap is seamless. Model pointers stashed at ctor.
VERIFIED live (BT_HIP_LOG probe): your walking mech writes jointhip=0 (level);
the peer's replica in the other pod's view leans at exactly -8.0/-11.1deg --
the authored clip values. A/B control: L4VIEWEXT=1 forces the walking master
back to 74 sustained -8/-11 writes.
KB: locomotion.md ("NO walk lean" audit conclusion CORRECTED -- the lean is
authored + sustained + clip-set-specific), asset-formats.md (.ANI parses
pitch/yaw/roll for all joint types; KeyJointPos translation; cycle clips carry
no pitch), open-questions.md (item closed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
184 lines
15 KiB
Markdown
184 lines
15 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; 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 likely shares this root — re-observe after this fix before chasing separately.
|
||
|
||
## 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]
|
||
**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.
|
||
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).
|