A. KNOCKDOWN PACING: the 0.4s gBlockCooldown contact-hysteresis is gone. It suppressed the crash knockdown for as long as contact was held, letting the kinematic drive re-slam the obstacle at full commanded speed EVERY FRAME -- continuous full-price crash self-damage, a healthy 65-tonner dead in ~2-3s of grinding (the night-7 SAURON ram death, 110 priced frames at |v|~34.7). The binary needs no guard (crash threshold @0x4ab178 read from the exe = 0.0, dispatch per blocked frame is authentic): the bmp clip zeroes the drive, and a pressed mech re-triggers the knockdown at v>6.3 long BEFORE its grind can price anything -- the type-0 divert's 0.5-pt free floor needs v>~18.5 at 65t. Wall grinding is free taps + paced staggers; only genuine fast arrivals pay. B. RAW RAM DISPATCH: the *0.001 "ram economy normalization" (2026-07-12) predated the #83 type-0 divert and became a double-normalization -- the victim's divert scale (~4e-5) compounded with it, so NO physically possible ram could clear the 0.5-pt free floor: rams were a no-op against the victim while the rammer's own un-scaled crash path rattled for real. The binary dispatches raw [T1 @part_012:15324]; the divert IS the normalizer. Also un-starves cultural-icon crunches (trucks now crush on contact per their authored armor, not after ~19 bumps). Bench (scratchpad/night7/mp_rampricing.sh): - wall leg: 150s full-throttle wall push = 125 paced knockdown binds (~1.2s cadence, iv2 40-43), 4767 crash frames ALL under the free floor, 0 rattle, 0 deaths (pre-fix: dead in seconds). - ram leg: BT_GOTO=enemy + BT_GOTO_STOP=2 rams the peer; raw amount arrives byte-identical on the victim (66239.1 tx == rx), victim prices 2.55 pts internal rattle (first ram damage to a victim in the port's history), pressed follow-ups free, 0 deaths both nodes, 108 knockdowns -> exactly 108 type-5 records on the observer (no skating regression). KB swept: combat-damage.md (the 1.3e6 moverMass mis-attribution corrected to the measured 60-90k tonnage scale), locomotion.md, rendering.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
415 lines
35 KiB
Markdown
415 lines
35 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:
|
||
- "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]:
|
||
- `<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]
|
||
|
||
**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<spd` → Standing (walk
|
||
takes over) or `spd<ZeroSpeed` → Standing (reverse), else advance the pivot at `idleStrideScale`
|
||
(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:
|
||
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. The knockdown must stagger BOTH
|
||
channels (`SetBodyAnimation(0x20)` + `SetLegAnimation(0x20)`) or display + travel split permanently
|
||
(foot-slip). `localVelocity` is zeroed while crashed (spec-faithful) so a knocked mech can't keep
|
||
advancing. **The 0.4 s `gBlockCooldown` contact-hysteresis is GONE (pricing audit 2026-07-31)**: it
|
||
held the knockdown off for as long as contact persisted, letting the drive pay full-commanded-speed
|
||
crash self-damage EVERY FRAME — a healthy mech dead in ~2-3 s of grinding (the night-7 SAURON ram
|
||
death). The binary needs no such gate: the clip zeroes the drive, and a pressed mech re-triggers
|
||
the knockdown at v>6.3 long before its grind can price anything (the type-0 divert's 0.5-pt floor
|
||
needs v>~18.5 at 65 t) — wall grinding = free taps + ~1.2 s-paced staggers, only fast ARRIVALS pay.
|
||
Bench: 150 s full-throttle wall push = 125 paced binds, 4,767 free-tap frames, 0 rattle, 0 deaths
|
||
(`scratchpad/night7/mp_rampricing.sh`). The mid-clip rebind guard (`legState != 0x20`) stays. [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=<n>` 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).
|