The master-perf disasm (0x4aa3d3-0x4aa4ff) computes the yaw rate as lerp(walkingTurnRate@0x574, runningTurnRate@0x578) by ground speed, not a constant. Wired it into the drive, replacing the bring-up constant kDriveTurnRate: - walkingTurnRate/runningTurnRate are now REAL named members. The ctor's `Wword(0x15d)/(0x15e) = model->...TurnRate * DegreesToRadians` writes were NO-OPS -- Wword() returns a shared static scratch cell, so the rates were silently discarded. Now stored + read by name. - The drive (mech4.cpp) computes authTurnRate: base = walkingTurnRate; above reverseSpeedMax(0x538) lerp walk->run across [walkStride 0x534 .. topSpeed 0x34c] with an over-run falloff runningTurnRate/t^2; clamp >=0; zeroed in the death/limbo/airborne leg states (1/2/3) or when deathAnimationLatched(0x650) is set. Falls back to kDriveTurnRate if the model gave no rates. The lerp is a RUN-speed refinement -- below reverseSpeedMax (walk / turn-in-place) the rate is the constant walkTR base; runningTurnRate < walkingTurnRate (a running mech is less maneuverable). Verified headless (DEV mech, BT_TURN_LOG): walkTR 1.309 rad/s (75 deg/s), runTR 0.873 (50 deg/s), turn-in-place + walk yaw at walkTR, no crash. [T1 logic / T2 runtime] Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
20 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 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>swrrampsjointhip0 → −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 NOjointhipchannel 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 animatejointshakey(cockpit shake, a BALL joint) and OMITjointhip, so the PILOT's own view stays level. The EXTERIOR set (3-char,LoadLocomotionClips@004a80d4) goes to network REPLICANTS and the forcedL4VIEWEXTexternal view — it carries thejointhiplean, 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/LoadHighDetailBodymislabeled the FUN_004a80d4/86c8 clip-loader addresses as a body-LOD pair) — so the local cockpit mech played exterior clips and pitched −8°. NowLoadLocomotionClipsExtis 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 strayjointhipwrites 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 fromPerformAndWatch) 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 mechjointhip=0 while walking; the peer's replica leans at −8°/−11.1° in your view. (Model pointers stashed at ctor viaBTStashClipState; 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
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 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=1restores 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) 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]
Turn-in-place (trn, state 4) — AUTHENTIC dispatcher decoded (task #64, 2026-07-14)
The entry dispatcher is NOT in the Ghidra decomp — it lives in the master-perf gap
0x4a9b5c–0x4ab188 (functions_index.tsv jumps FUN_004a9770→FUN_004ab188; the SOLE reader of
turnDemand@mapper+0x12c and turnCapable@0x588) [T1 proof of absence]. Ghidra never lifted it, so
it was disassembled with objdump (scratchpad/masterperf.asm, 1530 lines) and hand-decoded — this
REPLACES the earlier [T3] stand-in guesses (0.25·standSpeed entry gate + ×4 fast-forward) with the
real logic. animationClips[4] = the trn/trni clip (loaders 13767/13971). Port recon (mech2.cpp):
- THE POSE-MATCH INVARIANT: this engine has NO pose blending —
SelectSequencehard-resets to frame 0 — and avoids pops purely by AUTHORING (every transition clip starts in its predecessor's end pose; a turn-in-place CYCLE returns to the neutral/stand pose at its last frame). The trn→walk seam therefore stepstrn legFrm7 (cycle end) → swr legFrm1— a small ONE-TIME bob step that is authentic (the binary cuts on speed, not at an arbitrary frame). What made it STUTTER was NOT this cut — it was the body channel leaking out-of-phase joints into the rendered skeleton every walk cycle (mj=1); fixed at the source byAdvanceBodyAnimation(dt, mj=0)(see two-channel section). With mj=0 the authentic cut renders cleanly. - Turn rate (disasm 0x4aa3d3–0x4aa4ff) [T1] — WIRED (task #64b):
spd = |linearSpeed|; baseturnRate = walkingTurnRate(mech+0x574, rad/s). Ifspd ≥ reverseSpeedMax(0x538):t = (spd − walkStride 0x534)/(topSpeed=reverseStrideLength 0x34c − walkStride);turnRate = t≤1 ? lerp(walkingTurnRate, runningTurnRate 0x578, t) : runningTurnRate/t²(over-run falloff).turnRate = max(0, turnRate). Then yaw= turnDemand(mapper+0x12c) × turnRate, ZEROED if legState∈{1,2,3} or the death latch (deathAnimationLatched@0x650) is set. Note the lerp is a RUN-speed refinement — belowreverseSpeedMax(walk / turn-in-place) turnRate is the constant walkTR base; the walk→run interpolation only engages above it, andrunningTurnRate < walkingTurnRate(a running mech is less maneuverable). Port:walkingTurnRate/runningTurnRateare now REAL named members (mech.hpp, set from the model deg→rad at mech.cpp:1334-5 — the oldWword(0x15d/0x15e)writes were no-ops into a static scratch bank, so the values were silently discarded). The drive (mech4.cpp ~2617) computesauthTurnRateper the formula and yaws by it instead of the bring-up constantkDriveTurnRate(kept as the fallback when a model supplies no rates). Diagnostic:BT_TURN_LOG→[turnrate]. Verified headless (DEV mech): walkTR 1.309 rad/s (75°/s), runTR 0.873 (50°/s); turn-in-place + walk yaw at walkTR, no crash. [T1 logic / T2 runtime] - Entry (leg Standing case, disasm 0x4aa505 [T1]): arm trn when TURNING (
|angVel|>1e-4, port proxy = turnDemand deadband ±0.05) AND0 ≤ spd ≤ standSpeed(the FULL sub-walk range — CORRECTS the0.25·standSpeedstand-in) ANDturnCapable(hasCrashSet@0x588). LOCKSTEP: arms BOTH channels the same frame (SetLegAnimation(4)+SetBodyAnimation(4), gated on body also Standing) — arming only the leg seeded the channel phase-split. The disasm also gates onlegResetLatch(0x654), a master-perf ONE-FRAME debounce (cleared every frame @0x4a9bff, set on wind-down/turn-stop) — omitted in the port because its split leg-SM never per-frame-clears the latch, so gating on it would wrongly block trn after every walk. - Exits: leg SM (part_012.c:12013 [T1], restored VERBATIM) —
standSpeed<spd→ Standing (walk takes over) orspd<ZeroSpeed→ Standing (reverse), else advance the pivot atidleStrideScale(goto advance_normally). PLUS the master-perf turn-STOP exit (disasm 0x4aa5c6 [T1]) folded into case 4: turn stopped (turnDemand back in deadband) → Standing +legResetLatch=1. No fast-forward, no 0.25× — both were stand-ins, now removed. Body case-4 twin mirrors the exits (does NOT set the latch; mj=0, so it only tracks state for lockstep). - Verified headless (DEV.EGG,
BT_AUTODRIVE=0.5 BT_FORCE_TURN=1 BT_WALK_DELAY=2 BT_GAIT_TRACE=1): turn-in-place enters state 4; throttle ramp past standSpeed → 4→Standing→5(swr)→walk cycle; legFrm7(trn end)→legFrm1(swr) at the pose-matched boundary; both channels enter walk the same frame. Harness:BT_FORCE_TURN(reaches gBTDrive.turn),BT_WALK_DELAY=<s>(turn-then-ramp repro),BT_GAIT_TRACE=1(per-frame [gaitSM]). [T2] Feel re-verification pending (release now at standSpeed vs the invented 0.25×). [T1 logic / T2 runtime] ⚠ 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:
- 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 *_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:
- 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. - SAMPLER (mech4.cpp): gradient from the collision probes PLUS
BTVisualGroundLiftper probe (the quad hugs the VISIBLE terrain, whose lift varies across a slope); world→local by the TRUE yaw fromlocalToWorld(NOT thegDriveHeadingscalar mirror — the task-#48 drift bug class).BT_SHADOW_LOGtraces the normals. - 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. - 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_BIASoverrides. - LOADER-LEVEL TAG (L4D3D.cpp
LoadObjectBGF):*tshd*filenames getSetIsShadow(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_LOGbefore 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).