# 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=`, 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= (throttle feathering).