--- 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: - "Turn-in-place FEEL re-verification (authentic release at standSpeed vs the earlier 0.25x stand-in -- subjective playtest, non-gating)" - "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]: - `swr` ramps `jointhip` 0 → **−8°** (stand→walk), `wrl/wrr` −8 → **−11.1°** (walk→run), `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] **Gait DEAD BAND (2026-07-16, byte-decoded) [T1]:** the SM has NO stable state for a demand in (`walkStrideLength`@0x534, `reverseSpeedMax`@0x538). Cap semantics (LoadLocomotionClips @0x4a80d4): 0x534 = walk-cycle avg root speed (clips 6+7); **0x538 = the LAST KEYFRAME SPEED of clip slot 10 = the walk→run transition clip's exit speed — the run ENGAGE threshold, not a "reverse max"**; 0x34c = run-cycle avg root speed (clips 12+13) — and 0x34c is the mapper's demand multiplier (`FUN_004afd10`: `speedDemand = mech->0x34c × throttle × 0x5c0`, continuous). Walk cyc clamps to [standSpeed, 0x534]; run cyc clamps to [0x538, 0x7a0]. The finished-callback up-shifts when `tgt > 0x534` but enters/sustains run only when `tgt ≥ 0x538` (x87 decode of 0x4a6f11/0x4a6fc7/ 0x4a7041) → a demand parked between them cycles 7→10→14→7 forever ("gallop hunt"), firing the authored EngineShiftFwd/Rev audio each swing (Blackhawk: 22.02–30.87 = throttle 36–50%). Faithful mechanics — likely masked on real hardware by MECHANICAL lever detents ([[pod-hardware]] [T4]). Port: keyboard lever snaps out of the band at rest (mech4.cpp "GAIT DETENT" accommodation); the glass PadRIO throttle slew applies the SAME at-rest snap (L4PADRIO.cpp, band published in lever units via the mech4.cpp `gBTGaitDetentLo/Hi` seam — 2026-07-20 glass re-test) [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 — `SelectSequence` hard-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 steps `trn 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 by `AdvanceBodyAnimation(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|`; base `turnRate = walkingTurnRate`(mech+0x574, rad/s). If `spd ≥ 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** — below `reverseSpeedMax` (walk / turn-in-place) turnRate is the constant walkTR base; the walk→run interpolation only engages above it, and `runningTurnRate < walkingTurnRate` (a running mech is less maneuverable). Port: `walkingTurnRate`/`runningTurnRate` are now REAL named members (mech.hpp, set from the model deg→rad at mech.cpp:1334-5 — the old `Wword(0x15d/0x15e)` writes were no-ops into a static scratch bank, so the values were silently discarded). The drive (mech4.cpp ~2617) computes `authTurnRate` per the formula and yaws by it instead of the bring-up constant `kDriveTurnRate` (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) AND `0 ≤ spd ≤ standSpeed` (the FULL sub-walk range — CORRECTS the `0.25·standSpeed` stand-in) AND `turnCapable`(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 on `legResetLatch`(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` (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: 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] **⚠ The WALL-GRIND KNOCKDOWN STORM = the surviving "#82 peers see skating" — ROOT-CAUSED + FIXED (2026-07-30) [T1 disasm / T2 verified].** Each stagger bind also broadcasts `ForceUpdate(0x20)` — the **type-5 KNOCKDOWN record** — and the replicant handler (`mech.cpp` case 5) plays the bmp clip AND zeroes `projectedVelocity`. A gimped mech held against a wall re-crashes each time its gg cycle rebuilds speed (the gg ceiling is authentically the CLIP'S NATURAL SPEED — bhk1 ≈ 14.9 u/s, ratio divisor + accel clamp @0x544/0x548, loader formula byte-matched — so 14.9² ≈ 222 ≫ the 40.0 crash threshold @0x4ab184): 26 broadcasts in one bench run, every peer's replicant in knockdown/recovery churn — gliding + trn leg-lifts = "skating". 4-config A/B (`scratchpad/night6/mp_gimpAB.sh`, `[animind]` inst=1 verdict): grass straight/circle clean (198 gimp entries; **17 car crunches during it — crushable icons are the `0.00123f` sentinel path, gyro kick only, NO broadcast**); arena1 single strike recovers (196); arena1 wall-grind = 0 gimp entries. **What the port was missing is the crash SELF-DAMAGE *and its own pricing path*** (two halves; the first alone one-shot mechs on wall taps — corrected same day): the blocking branch (export gap @4aa89f-4aaab4, `scratchpad/night6/gap_4a9770.txt`) fills `fallDirection`=worldToLocal( dmg.damageForce) (FUN_0040879c = Mᵀv, un-normalized), `fallScalar`=−(fallDirection·localVelocity .linearMotion), then **Dispatches `TakeDamageMessage{0x64, zone=−1, the collision Damage verbatim}` at its own mech** (was the long-standing "DEFERRED" note; mech4.cpp). CRUCIALLY the TakeDamage hub then **DIVERTS damageType 0 (@0x4a0368 → FUN_0049ffcc, gap disasm `gap_49ffcc.txt`) — collision damage NEVER touches zone armor.** It is the manual's "splash/collision damage" technician setting: gated on the player's advancedDamage copy (+0x268), priced `raw × (2000/mass)/(100/3.6)²/(1−elasticity²)` (a 60-ton mech: ~4.5e-5 — a wall tap prices ~0.05, and <0.5 is FREE), then split into 0.5-point RATTLE CRITS applied to random internal subsystems (HeatSink family/Gyroscope/Torso, drawn by `collisionCriticalHitWeight`@0x10C). See [[combat-damage]]. Verified: wall-grinding no longer kills or heals the limper (0 deaths, 27 rattle events, mech stays gimped) and the peer's replicant re-enters the gimp cycle between bounded staggers; grass circling unchanged (198). **Falsified en route (do not revive):** (a) "the stagger's action-request flags feed a drive suppressor" — image-wide + full-gap sweep shows `word[this+0x18]` is read ONLY by the net record emitter; (b) "the bmp clip's ~6.5 u/s root motion presses back into the wall" — measured `adv=0 cycleSpeed=0` during state 0x20; the re-strike velocity is the REBUILT gg cycle; (c) "the gimp cap is ±5.8 (so √40 sits just above it)" — that number is `gimpStrideLength`@0x350 (the BACK-cycle figure, printed under a misleading label since renamed `ggCapL/R`); field @0x52c (`gimpSpeedMax`) has NO binary consumer. Advance caps, loader fills, crunch path and crash branch are all verified byte-faithful — the self-damage dispatch was the only gap. Replication detail in [[multiplayer]]. **The LAST #82 layer: the peer body-channel TRN-LOCK (root-caused + fixed 2026-07-30) [T2].** After the storm fix, a CIRCLING limper still skated on peers (user repro): post-knockdown recovery lands the peer at body-stand; the port's peer turn-step arming block (mech4.cpp `PerformAndWatch`, needed because the peer no longer runs the leg channel that cross-arms trn) saw `replMppr->turnDemand` (circling → always turning) and armed body state 4 — and NOTHING could exit: (a) body case 4's speed exit read the LOCAL mapper's `speedDemand`, a **dead cell on a replicant** (nothing writes it → 0 forever); (b) turning never stops for a circler. Peer pinned in trn (advance-only churn = leg-lifty skating) while `bodyTargetSpeed` held the replicated demand (~24 u/s full). Measured via `[replgimp]` (`bts=24.6 standSpeed=6.83 bodyState=4` in 168/171 samples) and a `_ReturnAddress` trap on `SetAnimationState(4)` (`[trntrap]`, BT_TRNTRAP → linker map: ALL 60 hits from PerformAndWatch = the port's own arm, NOT the type-3 reader — the master broadcasts clean gimp states). Fix pair, mirroring the leg twin's authentic precedence (part_012.c:12013 — the Standing speed test outranks the turn test): (1) mech4 peer arm gated on `!wantsWalk` (`standSpeed < bodyTargetSpeed || bts < 0`), exit trn when wantsWalk; (2) mech2 body case 4 `bspd` reads `bodyTargetSpeed` on ReplicantInstance (the replicated demand — the same number the peer Standing case walks on). Verified: arena circle replicant 218×state-24/0×state-4 (was 116×trn churn), grass circle 235-clean, knockdowns bounded, 0 deaths. **Lesson: on a replicant, every `MechControlsMapper` demand cell except those explicitly re-derived (`turnDemand`) is DEAD — any peer-side state machine judging a local mapper read is judging 0.** ## 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). ## Myomer drive + the GIMP (limp) chain — COMPLETE 2026-07-30 (#75/#78) The speed-demand site (`mechmppr.cpp` InterpretControls) applies the **drive scale**: `speedDemand *= myomers.speedEffect`. The myomers factor is the wrapper's live 0..1 output (gear/thermal/1−damage, via `BTMyomersDriveOf`); the authentic coupling attached `&speedEffect` into the mover's feed roster, which the 2007 engine lacks — the multiplication at the demand site is the port equivalent. The GIMP chain: `mechdmg` raises **graphicAlarm 3 (left) / 4 (right)** when a leg zone (the `LegDamageZone`-flagged zones; MadCat: 3/5/8 left, 10/16/19 right) crosses `LegHalfStructure` (0.5), and **reverse input is refused** ("reverse disabled", `[gimp]` log — and the binary agrees: the gimp gait machines have NO standing→reverse entry). ⚠ Cross-TU reads of the gimp level MUST use `BTMechGimpLevel` (mechdmg.cpp) — see [[reconstruction-gotchas]] §23 (the AlarmIndicator typedef split). Harness: `BT_SELF_DAMAGE_ZONE=` ramps one zone deterministically; `BT_DRIVE_LOG` prints `[drive] n/drive/mm/dmd/mech`. ### The VISIBLE limp — the gimp gait machines (reconstructed + bench-verified 2026-07-30) [T2] Bench (madcat, novice, `BT_SELF_DAMAGE=60 BT_SELF_DAMAGE_ZONE=16 BT_SELF_DAMAGE_TICKS=2`, `BT_GOTO` walk): right-leg crossing → alarm 4 → body enters 0x17/wgr FROM A LEFT STEP (phase-correct) → both channels settle in 0x19/ggl and hold it 8k+ frames at the gimp cadence cap (cycle 14.77 = the wgr entry stride, vs 18.5 walk / 22+ run) while raw demand stays 50 — the slowdown IS the gait machine, no demand multiply. Two revive-bugs fixed en route: see [[reconstruction-gotchas]] §24. The binary's limp was hiding behind a **"jump-jet" misread**: the port had `FUN_004a5bf8` / `FUN_004a71f4` reconstructed as `AdvanceBody/LegAnimationAirborne`, gated on `(MovementMode()==3||4) && jumpCapable@0x580` and thought DEAD (mechs never jump). Truth [T1, raw part_012.c]: **mech+0x40 there is the graphicAlarm level (3=left gimp, 4=right)** and **+0x580 is `hasGimpClips`** — set by the conditional loader block that probes `wgl` and loads clip slots 22-27 (`wgl wgr ggr ggl gsl gsr`, +0x624..0x638) plus four measurements: `gimpLeft/RightSpeedMax`@0x53c/0x540 (the wg entry clips' final keyframe strides) and `gimpLeft/RightStrideLength`@0x544/0x548 (MeasureClipStride over the gg cycles). The five `jump*`-named members were renamed accordingly (mech.hpp; there is no jump clip set). Machinery (all four reconstructed in mech2.cpp, gates in mech4.cpp): - **Drivers** `AdvanceBodyAnimationGimp`@004a5bf8 / `AdvanceLegAnimationGimp`@004a71f4 — selected per-frame when `(gimpLevel 3|4) && hasGimpClips`; clamp the demand (body: `bodyTargetSpeed`; leg: the LIVE mapper `speedDemand`, written back) to the gimped side's speed cap and advance the gg cycles at gimp cadence. **This clamp IS the authentic gimp slowdown** — ground speed is clip travel, so the limp cadence bounds it; the earlier T3 `×0.5` stand-in in mechmppr is retired (`BT_GIMP_SPEED` remains as an override, default 1.0). - **Finished-callbacks** `GimpBodyClipFinished`@004a6344 / `GimpLegClipFinished`@004a7970 — branched to from the tops of the normal `Body/LegClipFinished`. **Phase-correct entry**: left-gimp enters 0x16/wgl only from a RIGHT step (walk-R cases 5/6/0xe), right-gimp enters 0x17/wgr from a LEFT step (7/0xf) — the machine forces one extra normal step if mid-wrong-foot. Cycles: left = 0x18 (ggr clip, stride @0x544), right = 0x19 (ggl, @0x548); exits through the gs transitions 0x1a/0x1b when demand dies. No reverse entry exists while gimped. **Turn-in-place while gimped** (field find, `95cf49d`): the binary's trn dispatcher (`FUN_004a9b5c`, master perf) runs OUTSIDE the driver selection, so it arms the turn step for gimped mechs too — the port relocated it into the NORMAL leg driver's Standing case, which stops running when the gimp driver takes over ("rotating statue"). The arming is now mirrored in `AdvanceLegAnimationGimp`. No gimp-turn clip exists — a limping mech step-turns with the normal `trn` clip [T1: the RES limp set is only wg/gg/gs]. **Gimp audio — the "REVERSE DISABLED" voice IS REAL and now plays** (`1671b7d`; the earlier "no voice exists" verdict here was WRONG — user/old-timer pushback found it). The authored mech audio has **state watchers on `Entity.SimulationState == 3/4`** (the binary's one-cell mech+0x40 = the gimp level) that start a sequence playing notes 29, 16, **40** — two klaxon hits then Warnings01 zone 8 (`Warnings01_z7.wav`, key 40-41) = the spoken "reverse disabled". It never fired in the port because of the SAME cell split that hid the limp (graphicAlarm vs engine simulationState, gotcha #23) — the audio watches the half the gimp system never wrote. Fixed: mechdmg mirrors gimp 3/4 into `SetSimulationState` at the leg-half crossing (guarded vs disabled/fall/dead), and `BT_GIMP_SAFE_BASE_READ` stops pre-gimp replication records from stomping the cell (gimp is monotonic per life). Verified: `SetupPatch bank2 patch113 note=40`. Layered under it: the `Entity.AnimationState` triggers on limp entries 22/23 + stops 26/27 (+ walk-stands 8/9) play **EngineShiftRev01** (downshift foley), velocity-gained — a separate working layer (earlier mis-ID'd here as "the" limp audio). The earlier "watcher rebind" hazard was a bench artifact (the harness gimped the mech before the audio object loaded — impossible in play). ⚠ Open: an unidentified RAW writer (bypasses SetState; indicator-level trap never fired while the value changed) resets the sim cell between damage events — under a 1 Hz bench harness it restarted the warning before the 1.8 s voice note; sporadic real-play damage is unaffected. Needs a cdb write-watchpoint dig (candidates: a recon raw +0x2c-family write or a struct copy spanning it). ### The gimp cell is REPLICATED STATE — peers limped for free in 1995 (#82, fixed 2026-07-29) Field: "peers see a limping mech **skating**" (first-person limp + voice correct; bystanders saw a normal walk sliding at limp speed; a stop/start did not clear it). Root cause chain, all [T1/T2]: - The binary's one `mech+0x40` **is** `Simulation::simulationState`, which `Simulation::Write/ReadUpdateRecord` puts in **every update record header** — so a peer's replicant learned the gimp level on every packet and its self-simulated gait limped with no extra plumbing. There is no gimp-specific replication anywhere in the binary because none is needed. - A replicant NEVER re-derives the level: zone damage reaches it as a DamageZone update record (`Entity::ReadDamageUpdateRecord` → `DamageZone::ReadUpdateRecord` writes `damageLevel` straight from the wire), which never runs `Mech__DamageZone::TakeDamage` — where the leg-threshold evaluation and the `graphicAlarm` 3/4 write live. ⚠ And zone levels replicate only when the **explosion table's** damage tier is crossed (`EXPTBL.cpp:512-530` `CrossedDamageLevelThreshold`) — coarse and lagging (measured: peer at 0.428 while the master was at 0.857). **Never infer damage state on a peer from replicated zone levels.** - The port's own `Mech::PerformAndWatch` wrote `SetMovementMode(1)` **every frame** ("ground, non-death, non-airborne"), which erased the gimp level from that cell once it was mirrored in for the voice — so the wire carried 1 and peers walked. Found with the scoped `[simstomp]` trap (`g_btGimpWatchMech` + module-relative return address → `symcrash.py`). Fix: that per-frame write now writes the authoritative level (`gimped ? 3/4 : 1`) via the alarm-only bridge `BTMechGimpAlarmLevel` (never the cell it feeds, so a respawn-cleared alarm can't re-latch a stale gimp). Verified two-node (`scratchpad/night6/mp_skate.sh`): the observer's replicant receives `sim=4` and its gait runs `23 → 25` (wgr entry → ggl limp cycle). This also made the voice fire exactly once per onset instead of restarting on every damage event, and re-enabled the authentic leg-destroyed→fall gate (`mechdmg` MovementMode 3||4 [T1 task #60]), which had been dead while the cell was pinned at 1. Open: the load-time per-clip callbacks `PTR_LAB_0050d738/744` on the wg clips (uncarved LABs, inert `Recon` placeholders in the port — likely footstep/cycle events, transitions work without them).