Files
BT411/context/locomotion.md
T
arcattackandClaude Fable 5 2e9c78d604 Gait: the AUTHENTIC walk lean -- interior/exterior clip sets, level cockpit + leaning mechs (task #59)
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>
2026-07-13 14:46:22 -05:00

15 KiB
Raw Blame History

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
asset-formats
decomp-reference
reconstruction-gotchas
combat-damage
rendering
RootTranslation
ground-model
SequenceController
MechControlsMapper
SLD
joint
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 → (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. GetCurrentCollisionsProcessCollisionListMech::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 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 *_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