Files
BT412/docs/P3_LOCOMOTION.md
T
arcattackandClaude Fable 5 d4e139a0d9 Gait v5: display+travel from the LEG channel (kills the stutter class)
User-reported recurrence of "legs stutter and lose sync".  Live [sync] showed
advSum/legSum drifting 5->92 during walk-up churn, then locking.  Reproduced
headlessly (BT_FORCE_OSC throttle feathering + BT_FORCE_TURN + the new
per-frame BT_SYNC_LOG probe) and root-caused in two parts:

- SEED: the bring-up turn-in-place trigger arms only the LEG channel, so the
  walk re-entry lands one frame apart -> a persistent clip phase offset.
- AMPLIFIER: with offset clips the two end-of-clip callbacks fire on different
  frames; a demand change landing between them picks DIFFERENT next states
  (one channel winds down, the other keeps walking) -> opposite-phase churn
  (observed bs=6 ls=7 -- body on walk-R, leg on walk-L).

Under v4 (display+travel = BODY) the out-of-phase LEG pose showed through on
every frame the body didn't write joints (Standing/wind-down) = the visible
stutter/pop.

DISASM GROUND TRUTH (neither Advance fn has a static decomp caller; found by
byte-scanning the CODE section for e8 calls):
  - master perf (0x4a9b5c gap) -> AdvanceLegAnimation @0x4aa399 (air @0x4aa388)
    -> -dist/dt into localVelocity (+0x1cc): the LEG drives LOCAL travel and
    (writing last) the displayed pose;
  - IntegrateMotion (0x4ab1c8, body advance @0x4ab312, caller FUN_004ab430 =
    the projected-origin updater) -> -dist/dt into projectedVelocity (+0x2a0):
    the BODY is the dead-reckoning PROJECTOR -- locally invisible.
  v4's reading of FUN_004ab430 as the travel source was wrong.

FIX: advance body FIRST (projection; its writes get overwritten), leg LAST
(displayed pose); travel = legAdv under the two-channel split.  Display ==
travel through the LEG by construction -- body-channel drift can no longer
become visible, structurally (not by input-symmetry luck).

Verified: trn+feathering repro clean; straight drive locked (adv==proj);
solo goto=enemy kill chain intact; MP 2-node drive-to-range cross-pod kill
intact (241 hits, DESTROYED); no crashes.  KB corrected (locomotion.md roles
+ P3_LOCOMOTION.md v5 entry).  New gated instruments: BT_SYNC_LOG,
BT_FORCE_OSC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 16:25:49 -05:00

385 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# P3 — Locomotion Cutover (procedural slide → animation-driven gait)
Plan from the `p3-locomotion-cutover-scope` workflow (4 maps, source-verified). Goal: make BT movement
ANIMATION-DRIVEN (the walk/run clip's `[RootTranslation].z` IS the speed; feet plant by construction),
replacing the bring-up procedural slide + disconnected `gBodyAnim`.
## Ground truth (verified)
The real two-channel gait is reconstructed but **bracketed by no-op stubs on BOTH ends**, so it can't just
be "called on":
- **INPUT stubs:** `legAnimation@0x65c` / `bodyAnimation@0x6bc` are `ReconSeq` (mech.hpp:508-509);
`ReconSeq::Advance()` returns 0, `SelectSequence`/`Reset` no-op, `keyframeData==NULL` (mechrecon.hpp:225-236).
- **OUTPUT stub:** `Matrix34 == ReconMatrix` no-op (mechrecon.hpp:460); its `SetRotation/FromQuaternion` write
nothing → `IntegrateMotion`'s world-step (mech4.cpp:206-285) commits nothing even with nonzero input.
- `IntegrateMotion` is REAL logic (not a stub) but dead (input 0, output stub); its only caller `Mech::Simulate`
(mech4.cpp:307) has 0 call sites. The live tick is bring-up `Mech::PerformAndWatch` (mech4.cpp:549).
- `LoadLocomotionClips` (mech3.cpp:325) would CRASH today (reads `keyframeData[..]` on the NULL stub).
- **The lever that already works:** `gBodyAnim->Animate(dt,True)` (mech4.cpp) runs the REAL engine
`AnimationInstance::Animate` (JMOVER.cpp:1474-1700), cycling joints AND returning the clip's per-frame
root-translation distance — which the stand-in threw away. That is the smallest safe cutover.
## Steps (each independently build+test; mech keeps moving throughout)
- **✅ STEP 1 — DONE (commit below): forward travel animation-driven via the working `AnimationInstance`.**
Use the `adv` that `gBodyAnim->Animate(dt,True)` returns as the forward step (`localOrigin.linearPosition
+= facing * adv`); removed the `kDriveMaxSpeed * throttle * dt` slide (mech4.cpp). VERIFIED: mech walks at
the clip's authentic ~60 u/s (was a guessed 30), FORWARD (Z), feet plant (travel==stride); combat +
damage un-regressed; no crash.
- **✅ STEP 2 — DONE: gait clip selection by throttle** (walk/run/reverse). `|throttle| >= 0.5` → run
(`blhrrl` id 904), else walk (`blhwwl` id 898); throttle sign → direction (negative backs up); SetAnimation
only on gait change (tracked by `gCurrentGait`). Added a forced-throttle VALUE (`BT_FORCE_THROTTLE=<v>`,
btl4main.cpp) for headless testing. VERIFIED: run ~60 u/s, walk ~22 u/s (walk is ~1/3 of run — authentic
per-clip speeds, NOT a scalar of one guess), reverse backs up (pos.z increases); combat un-regressed (enemy
DESTROYED at walk speed, 8 hits). NOTE: the blh set has no dedicated reverse CYCLE clip (stand/walk/run +
transitions only) → reverse plays the forward clip and drives the body backward (bring-up; real reverse in STEP 7).
- **STEP 3 — back the embedded controllers with real `AnimationInstance`** (`ReconSeq``AnimationInstance`,
0x60 bytes each at 0x65c/0x6bc; adapters SelectSequence→SetAnimation, Advance→Animate, Reset, keyframe
accessors). Infrastructure only; live path unchanged.
- **STEP 4 — call `LoadLocomotionClips(model)` at model build** (mech3.cpp:325) to populate
`animationClips[]`/`namedClip[]` + stride caps (`standSpeed`, `walkStrideLength`, ...). Safe after Step 3.
- **STEP 5 — bring-up feed for `movementMode@0x40` + `bodyTargetSpeed@0x6b4` + `commandedSpeed`** from
`gBTDrive.throttle` (the real `MechControlsMapper::InterpretControls` is deferred — reads unsafe App offsets).
- **STEP 6 — back `Matrix34`/`ReconMatrix` with the real engine affine matrix + base-region audit** of
`IntegrateMotion`'s transform offsets (`+0x260` dual-use pos/quat suspected mislabel; `+0x100` labeled
maxSpeed but used as `localToWorld`) vs `part_012.c`, anchored from `damageZoneCount@0x11c`.
- **STEP 7 — THE FULL CUTOVER:** run the real two-channel gait (`AdvanceLegAnimation`/`AdvanceBodyAnimation`
+ `IntegrateMotion`) in `PerformAndWatch`, retiring the Step-1/2 stand-in translation + free-standing
`gBodyAnim`. Gives the authentic locally-simulated + displayed-motion gait.
**Deferred polish (post-core):** airborne/fall gaits (AdvanceBody/LegAnimationAirborne), torso-twist-to-target
(Torso is FULLY reconstructed — TorsoSimulation twist/elevation @004b5cf0 + WriteJoints; needs wiring), gyro sway.
**First-milestone recommendation:** Steps 1-2 give a visibly correct animation-driven walk/run (real speeds,
feet plant) on the already-proven engine path, with near-zero risk. Steps 3-7 are the deeper "authentic
two-channel gait" and can follow once the milestone is banked.
---
## ⭐ BASE-REGION RECONCILIATION (the STEP-6 audit — ground truth, the shared P3/P5/gyro de-risk)
This is THE root cause behind the P5 teardown crash, the gyro `+4` cross-link landmine, the dead `Mech::Simulate`
stomps, AND why `IntegrateMotion` can't be revived: the reconstructed `IntegrateMotion`/`Simulate` (mech4.cpp) use
**1995 raw binary offsets** (`this+0xNN`) that, in the 2007 RP411 engine base layout, land ON live engine fields.
**Ground-truth compiled layout (cdb `dt btl4!Mover` / `!JointedMover` / `!Mech`, from btl4.pdb):**
```
engine Mover/JointedMover: reconstructed Mech own region:
+0x0C8 localToWorld (LinearMatrix) +0x3D4 torsoAimCurrent
+0x0F8 localOrigin (Origin) +0x3E0 torsoAimTarget
+0x114 damageZoneCount +0x11C subsystemCount +0x3EC netOrientation (EulerAngles)
+0x124 updateOrigin (Origin) +0x458 movementMode +0x45C movementFlags
+0x194 creationTime (Time) +0x468 airborneSelect +0x46C forwardCycleRate
+0x250 projectedOrigin (Origin) +0x4A4 groundCycleRate +0x4A8 airborneCycleRate
+0x26C previousOrigin (Origin) +0x4C8 deathAnimationLatched
+0x288 projectedVelocity (Motion) +0x4D4 legAnimation +0x4E0 bodyAnimation (ReconSeq)
+0x2A0 updateAcceleration (Motion) +0x500 arrivalTime +0x504 simTime +0x508 spinRate
+0x2B8 updateVelocity (Motion) +0x598 motionEventName +0x5A4 motionEventArmed
+0x2D0 nextUpdate (Time)
+0x2D4..0x2EC collision cluster (Count/Volume/Template/containedByNode/Lists/last/Assistant)
+0x2F0 segmentTable +0x308 segmentCount +0x30C jointSubsystem (JointedMover ends 0x318)
```
**The stomp table — every `IntegrateMotion` raw offset vs the engine field it corrupts, and the correct target:**
| raw offset (1995) | intended 1995 field | 2007 engine field it STOMPS | correct target (declared member) |
|---|---|---|---|
| `this+0x100` | motion payload A | `localOrigin` (0xF8-0x114) | (relocate) `motionPayloadA` |
| `this+0x12c` | motion payload B | `updateOrigin` (0x124-0x140) | (relocate) `motionPayloadB` |
| `this+0x260` | motionDelta (Quat) | `projectedOrigin` (0x250-0x26c) | **declare** `motionDelta` |
| `this+0x26c` | worldPose (Quat) | `previousOrigin` (0x26c-0x288) | **declare** `worldPose` |
| `this+0x298`/`0x29c` | angular accum | `projectedVelocity` (0x288-0x2a0) | **declare** `angularAccum` |
| `this+0x2a4` | torsoAimTarget | `updateAcceleration` (0x2a0-0x2b8) | ✅ existing `torsoAimTarget`@0x3E0 |
| `this+0x2d4` | netOrientation | `collisionVolumeCount` | ✅ existing `netOrientation`@0x3EC |
| `this+0x598`/`0x5a4` | motionEventName/Armed | (past base, in Mech region) | ✅ existing `motionEventName`/`motionEventArmed` |
So `torsoAimTarget`, `netOrientation`, `motionEventName/Armed`, `arrivalTime`, `simTime`, `spinRate`, `movementMode/
Flags`, cycle rates ARE already relocated as declared Mech members — but `IntegrateMotion` still reads the RAW
offsets for them AND for the 3 un-declared motion fields (`motionDelta`/`worldPose`/`angularAccum`) + the 2 motion
payloads. Reviving `IntegrateMotion` as-is would corrupt `projectedOrigin`/`previousOrigin`/`projectedVelocity`/
`updateAcceleration`/the collision cluster → exactly the P5 teardown heap corruption.
## STEP-6/7 attack (revised, concrete)
1. **Lock the ground truth** (compile-time): `friend struct MechBaseLayoutCheck` with `static_assert(offsetof(...))`
on the engine-base fields (localOrigin@0xF8, projectedOrigin@0x250, previousOrigin@0x26c, collision cluster
@0x2d4-0x2ec, segmentTable@0x2f0) + the relocated members (netOrientation@0x3EC, torsoAimTarget@0x3E0,
arrivalTime@0x500, spinRate@0x508). Any future raw-offset stomp then fails the build.
2. **Declare the 3 missing motion members** (`motionDelta`/`worldPose` Quaternion, `angularAccum` Vector3D) +
the 2 motion payloads in the Mech's OWN region (offset > 0x318), so nothing lands on the engine base.
3. **Convert every `IntegrateMotion` raw `this+0xNN` (0xNN < 0x318) to the declared member** per the table above.
Same pass for the dead `Simulate` (so it's revivable) — this is the base-region audit HARD_PROBLEMS wanted.
4. **Back the 3 stub families with real engine types:** `ReconMatrix``AffineMatrix` ops (Identity/FromQuaternion/
transform-vector); `ReconQuat*`→real `Quaternion`; `ReconSeq`→real `AnimationInstance` (the two-channel gait
input, 0x60 bytes each @legAnimation/bodyAnimation).
5. **Revive `IntegrateMotion` in `PerformAndWatch`** (the cutover), retiring the Step-1/2 stand-in translation.
Verify: mech walks via the real gait, no engine-base stomp, no teardown crash, combat/heat un-regressed.
Payoff: this ALSO fixes the gyro cross-link (write to the correct relocated field, not gyro+4), makes the dead
`Simulate` revivable, and removes the P5 teardown corruptor — the single highest-leverage de-risk in the port.
### PROGRESS (this pass)
-**STEP 1 — layout locked:** `MechBaseLayoutCheck` (mech4.cpp, friend of Mech) static_asserts the engine-base
offsets (localOrigin@0xF8, projectedOrigin@0x250, previousOrigin@0x26c, collision cluster@0x2d4-0x2ec,
collisionLists@0x2e4, segmentTable@0x2f0) + the relocated members (torsoAimTarget@0x3E0, netOrientation@0x3EC,
arrivalTime@0x500, spinRate@0x508). All pass — the ground truth is proven in code; any stomp now fails the build.
-**STEP 2/3 (partial) — declared the missing motion members** (mech.hpp, appended so no locked offset shifts):
`motionDelta`/`worldPose`/`worldPoseBase`/`angularAccum` (Quaternion) + `motionSourceA/B`/`motionEventVector`
(Vector3D). **Converted the LIVE-PATH functions** `IntegrateMotion` (@004ab1c8) and `DeadReckonPose` (@004ab188)
— every raw `this+0xNN` (<0x318) now uses the declared member (0x260→motionDelta, 0x26c→worldPose, 0x298→
angularAccum, 0x100/0x12c→motionSourceA/B, 0x598→motionEventVector, 0x2a4→torsoAimTarget, 0x2d4→netOrientation,
0x138→worldPoseBase). Build green, combat un-regressed (TARGET DESTROYED, 0 crashes). The dead `Simulate`
(@004ab430) still holds raw offsets — DEFERRED (off the cutover path; convert when/if reviving it).
-**STEP 4 (partial) — `ReconMatrix` backed** with the real engine `AffineMatrix` (mechrecon.hpp): `Identity`
`BuildIdentity()`, `FromQuaternion`/`SetRotation``operator=(const Quaternion&)`. SAFE: its only live caller
(IntegrateMotion) uses a LOCAL `bodyFrame`; other callers are the dead Simulate.
-**STEP 4 — `ReconQuat` backed** (mechrecon.hpp): `ReconQuatIdentity``Quaternion::Identity`,
`ReconQuatIntegrate``Quaternion::Add(source, Vector3D delta)` (the exact FUN_00409f58 integrate). SAFE: all its
callers are dead (Simulate + the dead-until-cutover IntegrateMotion/DeadReckonPose). Also relocated the last
live-adjacent raw offset: the `this+0x1dc` "aim/torso" clear → a declared `aimRate` (Quaternion) member. (NOTE: I
earlier miscalled mech4.cpp:447 a LIVE PerformAndWatch call — it is actually in the dead `Simulate` telemetry tail
(<0x551 where PerformAndWatch starts), so ReconQuat had no live caller after all.) Build green, combat un-regressed.
-**STEP 4 remaining — the GAIT (`ReconSeq`) is the hard final piece, NOT a drop-in.** Verified: the leg/body
controllers use `SelectSequence`/`Advance`/`Reset` (FUN_004277a8/0042790c/004283b8) — a BT-specific **SequenceController**
keyframe player that **does NOT exist in the RP411 engine** (grep of MUNGA found no SelectSequence/Sequence/Controller).
So "ReconSeq→AnimationInstance" (the STEP-3 note) is inaccurate. TWO approaches, a real fork:
(A) **Reconstruct the SequenceController** from the binary (FUN_004277a8/0042790c/004283b8 + its keyframe-table
struct) and keep the authentic two-channel gait (AdvanceLeg/BodyAnimation + IntegrateMotion). Most faithful;
biggest. NOTE: retyping legAnimation/bodyAnimation to the full controller GROWS the Mech (they are ~0xC now vs
the binary's 0x60 each), shifting the relocated members after them (arrivalTime/spinRate) — update those
MechBaseLayoutCheck locks (they are by-name relocations, safe to move; the engine-base locks must NOT move).
(B) **Rewrite AdvanceLeg/BodyAnimation onto the engine `AnimationInstance`** (which STEP 1-2 already proved can
play the gait clips). Less faithful to the exact controller, reuses the engine, no SequenceController rebuild.
Also still a stub: **`FUN_00408744`** (mechrecon.hpp:391) — the world-step transform (transform angularAccum by the
bodyFrame matrix) IntegrateMotion needs; back it (AffineMatrix·Vector3D) as part of the cutover.
- **STEP 7 (revive) is unchanged** but gated on the gait decision above.
---
## ⭐ SequenceController RECONSTRUCTION SPEC (chosen path A — from the binary, part_003.c)
The BT gait controller (embedded `legAnimation`@0x65c / `bodyAnimation`@0x6bc, 0x60 bytes each). It is a BT-specific
keyframe animation player (NOT in the RP411 engine). Full decomp read: ctor @00427768, SelectSequence @004277a8,
Advance @0042790c, Reset @004283b8, dtor @004278d4. It parses the SAME animation-clip resource format the engine
`AnimationInstance` uses and writes joints through the SAME `Joint` API already backed for the gyro/torso.
**Object layout (0x60; offsets are byte offsets into the controller):**
```
+0x00 vtable (Plug base, FUN_00415e90 ctor) +0x34 rootTranslation[] table (0xC/frame; .z@+8 = stride)
+0x0c (base refcount region) +0x38 jointSubsystem (resolved from owner Mech +0x31c)
+0x14 frameCount (resource hdr[0]) +0x3c currentFrame (int)
+0x18 jointCount (resource hdr[1]) +0x40 owner (Mech*)
+0x1c jointIndices[] (jointCount ints) +0x44 clipResource (ref-counted handle)
+0x20 hdr+2 (metadata ptr) +0x48 finishedCallback (Mech method ptr)
+0x24 frameTimes[] (frameCount floats) +0x4c cbArg2 +0x50 cbArg3
+0x28 keyframeBase +0x2c keyframeCursor +0x54 currentTime (Scalar)
```
**Methods (behaviour + engine mapping):**
- **ctor(mech):** Plug base; store owner@0x40; `jointSubsystem@0x38 = mech->GetJointSubsystem()` (binary resolves
mech+0x31c); clipResource@0x44 = 0.
- **SelectSequence(clipID, cb, a2, a3):** store cb@0x48/a2@0x4c/a3@0x50; reset currentFrame@0x3c + currentTime@0x54;
release old clipResource; `FindResourceDescription(clipID)` → clipResource@0x44; parse the clip: frameCount@0x14,
jointCount@0x18, jointIndices@0x1c, frameTimes@0x24, keyframe pointers@0x28/0x2c; compute the per-frame keyframe
stride@0x34 by summing joint DOF sizes (hinge<3 →8B, ball==4 →0xC, balltrans==5 →0x18).
- **Advance(dt, moveJoints) -> Scalar distance:** `t = currentTime + dt`. Walk keyframes while `frameTimes[cur] <= t`:
for each joint (jointSubsystem->GetJoint(jointIndices[i])) snap to the keyframe pose (hinge→SetRotation(Radian),
ball→SetRotation(EulerAngles), balltrans→ + SetTranslation) IF moveJoints; accumulate `distance += (frameTimes[cur]
- currentTime) * rootTranslation[cur].z`; advance cur/currentTime. If clip ended (cur==frameCount): call the
finishedCallback (owner->*cb)(clipResource, carryover, moveJoints) and add its distance. Else interpolate the
partial frame (ratio = (t - currentTime)/(frameTimes[cur]-currentTime)); per joint LERP/SLERP between keyframes
(FUN_00409390 slerp / FUN_00408848 translation-lerp / FUN_00408dd4 hinge-lerp) and write; accumulate the partial
distance. Return distance. Engine helper map: joint iter→`JointSubsystem::GetJoint`; GetEulerAngles=FUN_0041cfa0;
SetRotation(Radian)=FUN_0041d0a8; SetRotation(EulerAngles)=FUN_0041d020; SetTranslation=FUN_0041d11c;
SetHinge/direct=FUN_0041cfc8; close-enough=FUN_00408d78(radian)/FUN_004091f4(euler)/FUN_004084fc(point);
slerp=FUN_00409390; fabsf=FUN_004dcd00.
- **Reset(loop):** reset each joint to a default pose (identity quat + default euler/translation) via the per-type set.
- **dtor:** reinstall vtable + release clipResource.
**Wiring:** replace the `ReconSeq` stub (mechrecon.hpp) with this class (keep a `typedef ... ReconSeq` so mech2's
`legAnimation.SelectSequence/Advance/Reset` calls are unchanged); retype is automatic. The Mech GROWS (0xC→0x60 per
controller = +0xA8) → move + update the by-name locks after them (arrivalTime/simTime/spinRate); engine-base locks
UNCHANGED. The Mech ctor must construct legAnimation/bodyAnimation with `this` (owner). Then STEP 7 cutover.
### ✅ BUILT (seqctl.cpp) — the SequenceController is reconstructed, linked, integrated (inert until the cutover)
- `SequenceController` class in mechrecon.hpp (extends the old ReconSeq: keeps keyframeCount/keyframeTimes/keyframeData
accessors for mech3; adds the real fields; `typedef SequenceController ReconSeq`). `seqctl.cpp` implements Init
(ctor logic — resolves `owner->GetJointSubsystem()`), SelectSequence (find via `application->GetResourceFile()->
SearchList(clip_id, 16)` + Lock + parse frameCount/jointCount/jointIndices/frameTimes/pose-base/rootTranslations),
Advance (snap full keyframes + interpolate the partial frame via the engine Joint API, accumulate the
rootTranslation.z distance, loop at end-of-clip), Reset (neutral pose per joint type), dtor (Unlock the clip).
`Hinge`==8B confirmed (`{int axisNumber; Radian rotationAmount}`) → keyframe hinge snap `SetHinge(*(Hinge*)cursor)`
is byte-faithful. Joint I/O reuses the same engine API the gyro/torso use (GetJointType/GetEulerAngles/GetTranslation
/ SetHinge/SetRotation(Radian|EulerAngles)/SetTranslation).
- Added seqctl.cpp to btbuild/CMakeLists.txt. Full build GREEN; combat un-regressed (TARGET DESTROYED, 0 crashes).
Inert for now: its callers (mech2 AdvanceLeg/BodyAnimation via IntegrateMotion, mech3 LoadLocomotionClips) are all
DEAD until the cutover, so the retype + link changes no live behavior.
- **Two known simplifications to revisit at the cutover (STEP 7):** (a) end-of-clip currently loops directly (reset to
frame 0) instead of invoking the stored member-fn-ptr finished-callback @0x48 — faithful in INTENT (that callback is
Mech::OnBodyAnimFinished's re-arm), refine if a non-looping callback is needed; (b) partial-frame rotation uses a
component LERP of the euler/hinge (the binary FUN_00409390 slerps) — visually identical for small gait deltas, upgrade
to slerp if needed.
- **Remaining for STEP 7 cutover (task 13):** (1) Mech ctor must call `legAnimation.Init(this)` + `bodyAnimation.Init(this)`
(currently default-constructed with jointSubsystem=0 — fine while inert, REQUIRED before use); (2) call
`LoadLocomotionClips(model)` at model build to populate the clips; (3) back the `FUN_00408744` world-step transform;
(4) revive `IntegrateMotion` in `PerformAndWatch`, retiring the STEP-1/2 stand-in. First point where it all
runtime-verifies together.
### ✅ STEP 7 CUTOVER — DONE & VERIFIED LIVE (gated `BT_GAIT_CUTOVER=1`; the real controller drives locomotion)
Rather than reviving the FULL `IntegrateMotion` (leg+body channels + world-step + DeadReckonPose, whose position-apply
lives in the dead `Simulate`/`MoveAndCollide`) in one leap, the cutover swaps the STEP-1/2 forward-step SOURCE to the
real `SequenceController` on the PROVEN position path (`localOrigin += facing*adv`). This runtime-verifies the
reconstructed controller with minimal risk and is the meaningful milestone (the authentic gait controller drives the
mech). Implemented + verified:
- **(1) DONE** — Mech ctor (`mech.cpp`, after the body-load `ResolveJoint("jointlocal")`, where `jointSubsystem` is
valid) calls `legAnimation.Init(this)` + `bodyAnimation.Init(this)`. Runs always (harmless when the cutover is off).
- **cutover branch** — `PerformAndWatch` (mech4.cpp), gated `static int s_gaitCutover = getenv("BT_GAIT_CUTOVER")`.
On a gait CHANGE it `bodyAnimation.SelectSequence(ResolveAnimationClip("blh",suffix), (void*)1 /*loop*/, 0, 0)`; each
frame `adv = bodyAnimation.Advance(dt,1)` (the real controller animates the skeleton AND returns the clip forward
step) → `localOrigin.linearPosition += facing*adv*dir`. STEP-1/2 `gBodyAnim` path preserved as the `else` (default).
- **KEY BUG (found via cdb av in `ResourceFile::SearchList+0x77`, RESOURCE.cpp:498):** `SelectSequence` fetched the
clip with `rf->SearchList(clip_id, 16)` — but `SearchList(list_id,type)` treats its arg as a resource LIST (reads the
resource's data as an ID array + iterates) → it walked clip 904's animation bytes as garbage resource IDs → crash.
The `clip_id` from `ResolveAnimationClip` is already a DIRECT resource ID → fixed to `rf->FindResourceDescription(clip_id)`
+ `Lock`, which mirrors the engine's own clip load `AnimationInstance::SetAnimation` (JMOVER.cpp:1406) EXACTLY (same
header layout too: hdr[0]=frameCount, hdr[1]=jointCount, hdr[2]=footStepThreshold skipped, hdr[3]=jointIndices).
- **Verified live under cdb** (`BT_FORCE_THROTTLE=1 BT_GAIT_CUTOVER=1`): `[gait] SequenceController -> blhrrl id=904
(run)`, mech walks (adv≈1.07, matching the STEP-1/2 baseline; pos advances), 0 crashes. Combat regression
(`+BT_SPAWN_ENEMY +BT_FORCE_FIRE`): identical to a same-harness BASELINE (no-cutover) run — both fire/heat/damage the
same (`FIRED #1..#141`, `structure` climbs 0.133/hit) and both walk PAST the stationary dummy before 8 hits (no
DESTROY in-window) — i.e. NOT a regression, just harness geometry. `SequenceController::Advance` runs live for the
first time with no crash.
- **DECOMP FACT (`Advance@0042790c` raw, part_003.c:6722):** distance is LUMPY BY DESIGN — whole keyframes add
`(frameTimes[cur]-currentTime)*rootTrans[cur].z` (matches seqctl.cpp:217); the partial-frame else-branch (6821)
interpolates joints but adds NO distance; end-of-clip (6814) CALLS the finished-callback `@0x48` recursively (passing
the carryover time) and folds its return into distance. Smoothness in the real game comes from VELOCITY integration
in `IntegrateMotion`, not per-Advance — so applying `adv` directly to position (this cutover) is inherently lumpy;
that's expected, faithful to the distance fn, and superseded once the velocity path lands.
- **STILL GATED (not default) pending fidelity refinements:** (a) end-of-clip `carryover*stride[last]` in place of the
recursive finished-callback `@0x48` (source of the occasional adv spike at loop boundaries — needs `OnBodyAnimFinished`
wired as the real member-fn-ptr callback that re-arms + recursively continues `Advance`); (b) component-LERP → slerp
(`FUN_00409390`); (c) the deep win — the FULL `IntegrateMotion` velocity path (leg+body via mech2 `AdvanceLeg/
BodyAnimation`, the `FUN_00408744` world-step, `DeadReckonPose`) which smooths the lumpy step and enables MP dead-reckon.
### ✅ STEP 7 DEEPER — the AUTHENTIC world-step (real velocity model) drives locomotion LIVE
Took the "harder path": replaced the cutover's direct position slide with the real `IntegrateMotion`
motion-tail (`@004ab1c8`), so the mech now moves through the authentic velocity→rotate→integrate model.
- **`FUN_00408744` BACKED** (mechrecon.hpp; was a no-op template stub): the world-step matrix×vector
(part_000.c:8331, `out[i]=Σ_j v[j]·M(i,j)`). Backed via the engine `AffineMatrix::GetFromAxis` column
basis — `out = v.x·colX + v.y·colY + v.z·colZ` reproduces the raw row-dot EXACTLY, and it is the SAME
`GetFromAxis` convention the drive facing uses, so sign-consistent. Only real caller = mech4.cpp:304
(gyro only name-drops it in comments).
- **KEY MAPPING (verified):** the 1995 motion transform `{ Point3D @0x260; Quaternion @0x26c }` IS the
engine `localOrigin` — `Origin = { Point3D linearPosition; Quaternion angularPosition }` (ORIGIN.h:15);
raw `FUN_0040ab44` builds the matrix from BOTH halves (rot from 0x26c, translation from 0x260). So the
motion state maps directly onto `localOrigin`, NOT the parallel `motionDelta`/`worldPose` placeholders.
- **Cutover branch now (mech4.cpp):** `adv = bodyAnimation.Advance` → `localVel = {0,0,-adv/dt}` →
`Matrix34::FromQuaternion(orient, localOrigin.angularPosition)` → `FUN_00408744(worldVel, localVel,
orient)` → `localOrigin.linearPosition += worldVel*dt`. == `facing*adv`, through the real machinery.
- **Latent bug fixed** in the (still-dead) full `IntegrateMotion`: it set only `spinRate`@0x508 and left
`angularAccum[2]` (velocity.z the world-step reads) stale → now sets `angularAccum[2] = -adv/dt`.
- **Verified:** walk-only `BT_GAIT_CUTOVER=1` walks straight fwd ~60 u/s, 0 crashes; combat fire/damage
identical to STEP-1/2. ⚠ A combat run faulted in the ENGINE BGF loader (`bld08.bgf`, `Builder::~Builder`)
walking into a building's range — pre-existing heap fragility, position-dependent (STEP-1/2 fired 600+
clean), NOT the world-step (zero heap ops). Track separately.
### Remaining full-`IntegrateMotion` work (the rest of the harder path)
1. **`AdvanceBodyAnimation` gait STATE MACHINE** (mech2.cpp:506, real — stand→walk→run transitions via
`SetBodyAnimation(state)` → `animationClips[state]`) in place of the inline `ResolveAnimationClip`+
`SelectSequence`. Needs #2.
2. **`LoadLocomotionClips`** (mech3.cpp:326, real) called at model build (Mech ctor) to populate
`namedClip[]`/`animationClips[]` + gait-speed caps (standSpeed/walkStride/reverseSpeedMax/gimp…) —
measured via `legAnimation.SelectSequence` + `keyframeData` (now that the SequenceController is real).
OPEN: reconcile `namedClip[]`@0x5e0 vs `animationClips[]`@0x5cc + the state→clip index mapping.
3. **Orientation INTEGRATION** — the real turn is angular-rate integration into `localOrigin.angular
Position` (raw `FUN_00409f58(0x26c, 0x26c, angRate*dt)`), driven by control input, not the bring-up
`gDriveHeading`. (Bring-up keeps `gDriveHeading` until the controls mapper is un-bypassed.)
4. **Velocity STORAGE + `DeadReckonPose`** — store the world velocity (raw @0x298) for MP dead-reckon;
`DeadReckonPose(fraction)` projects the render pose forward between sim ticks (the true render smoothing).
5. Then call the real `Mech::IntegrateMotion` (retargeted to `localOrigin`) from the cutover, retiring the
inline world-step.
### ✅ STEP 2 (steps 1-2 of the full IntegrateMotion) — LoadLocomotionClips + the gait STATE MACHINE drive the walk LIVE
Gated `BT_GAIT_SM` (requires `BT_GAIT_CUTOVER`). The real `Mech::AdvanceBodyAnimation` now drives locomotion.
- **Step 1 — `LoadLocomotionClips`** called in the Mech ctor (gated), completes cleanly for the Blackhawk:
resolves the full gait set + measures the speed caps (verified `walkStride=22.02`, `standSpeed=6.83`).
**RECONCILIATION FIX:** `namedClip[]`@0x5e0 and `animationClips[]`@0x5cc were declared as SEPARATE arrays
but in the binary are ONE (`namedClip[i]==animationClips[i+5]`, 0x5e0==0x5cc+0x14) -> the loader's writes
weren't visible to `SetBodyAnimation`/`MeasureClipStride`. Fixed: `namedClip` is a pointer aliased to
`&animationClips[5]` (ctor). Same class of bug as the field-shadowing systemic issue.
- **Step 2 — `AdvanceBodyAnimation` state machine** drives the walk: case 6/7 slews `bodyCycleSpeed` toward
`bodyTargetSpeed` with the loaded caps, Advances `animationClips[6]=wwr`, returns the cycle distance ->
world-step. **FIX:** `bodyAnimationState`@0x728 == `bodyStateAlarm.level` (also declared-separate) -> re-synced
from the alarm at the top of AdvanceBodyAnimation so `SetBodyAnimation`'s level reaches the switch.
- Verified live: mech walks forward (z 1600->-975), `adv~0.35-0.42` (authentic walk cadence, < run's 1.07),
clip loops its 15 keyframes, 0 crashes.
- **Bring-up substitute (MARKED, mech2.cpp):** `SetBodyAnimation` passes a non-null loop sentinel `(void*)1`
where the binary passes `PTR_LAB_0050d6fc` = the real body finished-callback (gait TRANSITION stand->walk->run
+ leg alternation, @0x48). Without a non-null cb the SequenceController leaves the cycle stuck at its last
keyframe (`adv=0`). Reconstruct `PTR_LAB_0050d6fc` for authentic transitions.
### Remaining for the FULL gait (after steps 1-2)
1. **Reconstruct `PTR_LAB_0050d6fc`** (+ the leg `PTR_LAB_0050d6f0`) -- the finished-callback that transitions
gait states (stand->walk->run->reverse) + alternates legs. Replaces the `(void*)1` loop sentinel.
2. **LEG channel** `AdvanceLegAnimation` for visible leg stepping (body channel already drives world motion).
3. **Gait SELECTION** from throttle -> `bodyTargetSpeed`/`movementMode` (currently forces walk state 6);
wire walk/run/reverse via the real commanded speed (the `MechControlsMapper` is still bypassed).
4. Then orientation integration + velocity storage + DeadReckonPose (the later IntegrateMotion pieces).
### ✅ TRANSITION CALLBACK — reconstructed + wired (walk/run leg alternation + walk->run transition)
The real gait finished-callback (was a `(void*)1` loop sentinel).
- **Resolved the .data fn ptr by PE-parsing** BTL4OPT.EXE: `PTR_LAB_0050d6fc`@0x50d6fc -> body cb **0x4a6d8c**;
`PTR_LAB_0050d6f0`@0x50d6f0 -> leg cb **0x4a6928**; cbArg2/3 = 0. Neither in the assert-anchored decomp, so
**disassembled with capstone** + decoded the jump table (byte idx @0x4a6de9, dword targets @0x4a6e0a; 33 states
-> 10 handlers). Reusable: `/tmp/readptr.py` (PE VA->DWORD) + `/tmp/disas.py`, `/tmp/jt.py`, `/tmp/dh*.py`.
- **Reconstructed `Mech::BodyClipFinished` (= FUN_004a6d8c)** byte-for-byte (mech2.cpp): dispatch on
bodyAnimationState, compare bodyTargetSpeed to the caps (standSpeed/walkStrideLength/reverseSpeedMax), pick the
next state, `SetBodyAnimation(next)` (re-binds with THIS callback) + `bodyAnimation.Advance(carryover-derived)`
via the shared tail `Mech::BodyTransition`. Walk 6<->7, run 12<->13, stop->8/9, walk->run 6->11->12.
- **Plumbing:** `SequenceController::Advance` now CALLS `cbCode` as `Scalar(*)(Mech*,unsigned,Scalar,int)` at
end-of-clip (matches the binary `**(code**)(this+0x48)`), folding the returned distance. `SetBodyAnimation`
passes `&Mech::BodyClipFinished`; the inline cutover passes `&Mech::LoopBodyClip` (bring-up loop).
- **Verified live (cdb):** walk alternates 6<->7 (adv~0.42), run alternates 12<->13 (adv~0.5-0.85), walk->run
transition fires (6->11->12), pos advances, 0 crashes; inline cutover (LoopBodyClip) + combat un-regressed.
- **Fixed a bring-up gap:** `reverseSpeedMax2`@0x7a0 (run bodyCycleSpeed clamp) unset by LoadLocomotionClips ->
0xCDCDCDCD -> run exploded; set to reverseStrideLength (case-12 divisor -> ratio~1).
- **Remaining:** LEG callback FUN_004a6928 (state@0x3b0 + motion source@0x128); airborne FUN_004a6344; gimp
handlers (16-19; targets 0x70b2/0x7161 undecoded); gait SELECTION from real throttle (mapper still bypassed).
### ✅ PILLAR A COMPLETE — leg channel + orientation + velocity storage + DEFAULT-ON
- **LEG finished-callback** FUN_004a6928 (== PTR_LAB_0050d6f0) reconstructed (`Mech::LegClipFinished`,
mech2.cpp): jump table @0x4a69aa decoded (same 33-state shape as the body's); compares the LIVE mapper
speedDemand (*(subsystemArray[0])+0x128 -> typed mirror controlsMapper), slews legCycleSpeed@0x348,
re-arms via SetLegAnimation; gimp alternates 0x12<->0x13 with |ratio| (gimpStrideLength stored negative).
- **Two-channel split LIVE** (real-controls): leg = joints from live demand, body = motion only
(Advance(dt, 0)). Natural Standing entry verified: legState 0 -> 5 -> 11 -> 13<->12 @ 61.5 u/s.
- **RAW FIX: Standing case INVERTED in BOTH channels** (raw case 0: standSpeed < commanded -> walk(5);
commanded < 0 -> reverse(0x10); else stand). Also: AdvanceLegAnimation controlSource double-deref AV ->
controlsMapper->speedDemand; legAnimationState==legStateAlarm.level re-sync.
- **Orientation**: yaw rate composed via Quaternion::Add (FUN_00409f58 form) -- spawn orientation kept
authentic (turn-rate constant still bring-up).
- **Velocity storage every frame**: worldLinearVelocity + localVelocity (fwd -Z + yaw rate) == the
Mover::WriteUpdateRecord publish set = the MP dead-reckon writer data.
- **DEFAULTS FLIPPED** (BTEnvOn): BT_GAIT_CUTOVER / BT_GAIT_SM / BT_COLLISION / BT_REAL_CONTROLS default
ON; "=0" opts back to the bring-up paths (verified). BT_SPAWN_ENEMY now places the dummy along the
spawn FACING (the authentic orientation exposed the old fixed-offset assumption).
- Verified: env-free default run = full authentic chain, combat damage -> 0.933, 0 crashes; all =0
fallbacks work. P3 is CLOSED for multiplayer purposes (the update-writer data is maintained).
### v5 (2026-07-09, task #49) — CHANNEL ROLES CORRECTED FROM RAW DISASM: display+travel = LEG
User-reported recurrence of "legs stutter / lose sync" (live [sync]: advSum/legSum drifted 5->92 during
walk-up churn then locked). Reproduced headlessly (BT_FORCE_OSC feathering + BT_FORCE_TURN; per-frame
BT_SYNC_LOG probe): the SEED is the bring-up trn trigger arming only the LEG channel (walk re-entry lands
1 frame apart -> clip phase offset); the AMPLIFIER is a demand change landing between the two channels'
end-of-clip callbacks -> DIVERGENT next-state picks (one winds down, one keeps walking) -> opposite-phase
churn (observed bs=6 ls=7). Under v4 (display+travel = body) the out-of-phase LEG pose showed through on
every frame the body didn't write (Standing/wind-down) = the visible stutter.
DISASM GROUND TRUTH (both Advance fns have NO static decomp caller; found by byte-scanning CODE for e8
calls): the master perf (0x4a9b5c gap region) calls AdvanceLegAnimation @0x4aa399 (airborne @0x4aa388) and
stores -dist/dt into localVelocity (+0x1cc) == the LEG drives local travel (and pose, writing last);
IntegrateMotion (0x4ab1c8; body advance @0x4ab312; caller FUN_004ab430 = the projected-origin updater)
stores -dist/dt into projectedVelocity (+0x2a0) == the BODY is the dead-reckoning PROJECTOR, locally
invisible. v4's reading of 0x4ab430 as the travel source was WRONG.
FIX (mech4.cpp): advance body FIRST (projection; writes overwritten), leg LAST (displayed pose); travel =
legAdv under the two-channel split (travelAdv selector; single-channel path keeps adv). Display == travel
through the LEG by construction; body drift can no longer show. VERIFIED: trn+feather repro clean; straight
drive locked (adv==proj); solo goto=enemy kill chain intact; MP 2-node drive-to-range cross-pod kill intact
(hdlr=241, DESTROYED). FOLLOW-UPS: wire the body adv into the named projectedVelocity member (the +0x2a0
store, currently diagnostic-only); locate the authentic trn entry dispatcher (would also arm... TBD).
New gated instruments: BT_SYNC_LOG (per-frame channel divergence), BT_FORCE_OSC=<s> (throttle feathering).