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>
35 KiB
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@0x6bcareReconSeq(mech.hpp:508-509);ReconSeq::Advance()returns 0,SelectSequence/Resetno-op,keyframeData==NULL(mechrecon.hpp:225-236). - OUTPUT stub:
Matrix34 == ReconMatrixno-op (mechrecon.hpp:460); itsSetRotation/FromQuaternionwrite nothing →IntegrateMotion's world-step (mech4.cpp:206-285) commits nothing even with nonzero input. IntegrateMotionis REAL logic (not a stub) but dead (input 0, output stub); its only callerMech::Simulate(mech4.cpp:307) has 0 call sites. The live tick is bring-upMech::PerformAndWatch(mech4.cpp:549).LoadLocomotionClips(mech3.cpp:325) would CRASH today (readskeyframeData[..]on the NULL stub).- The lever that already works:
gBodyAnim->Animate(dt,True)(mech4.cpp) runs the REAL engineAnimationInstance::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 theadvthatgBodyAnim->Animate(dt,True)returns as the forward step (localOrigin.linearPosition += facing * adv); removed thekDriveMaxSpeed * throttle * dtslide (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 (blhrrlid 904), else walk (blhwwlid 898); throttle sign → direction (negative backs up); SetAnimation only on gait change (tracked bygCurrentGait). 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 populateanimationClips[]/namedClip[]+ stride caps (standSpeed,walkStrideLength, ...). Safe after Step 3. - STEP 5 — bring-up feed for
movementMode@0x40+bodyTargetSpeed@0x6b4+commandedSpeedfromgBTDrive.throttle(the realMechControlsMapper::InterpretControlsis deferred — reads unsafe App offsets). - STEP 6 — back
Matrix34/ReconMatrixwith the real engine affine matrix + base-region audit ofIntegrateMotion's transform offsets (+0x260dual-use pos/quat suspected mislabel;+0x100labeled maxSpeed but used aslocalToWorld) vspart_012.c, anchored fromdamageZoneCount@0x11c. - STEP 7 — THE FULL CUTOVER: run the real two-channel gait (
AdvanceLegAnimation/AdvanceBodyAnimationIntegrateMotion) inPerformAndWatch, retiring the Step-1/2 stand-in translation + free-standinggBodyAnim. 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)
- Lock the ground truth (compile-time):
friend struct MechBaseLayoutCheckwithstatic_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. - Declare the 3 missing motion members (
motionDelta/worldPoseQuaternion,angularAccumVector3D) + the 2 motion payloads in the Mech's OWN region (offset > 0x318), so nothing lands on the engine base. - Convert every
IntegrateMotionrawthis+0xNN(0xNN < 0x318) to the declared member per the table above. Same pass for the deadSimulate(so it's revivable) — this is the base-region audit HARD_PROBLEMS wanted. - Back the 3 stub families with real engine types:
ReconMatrix→AffineMatrixops (Identity/FromQuaternion/ transform-vector);ReconQuat*→realQuaternion;ReconSeq→realAnimationInstance(the two-channel gait input, 0x60 bytes each @legAnimation/bodyAnimation). - Revive
IntegrateMotioninPerformAndWatch(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 functionsIntegrateMotion(@004ab1c8) andDeadReckonPose(@004ab188) — every rawthis+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 deadSimulate(@004ab430) still holds raw offsets — DEFERRED (off the cutover path; convert when/if reviving it). - ✅ STEP 4 (partial) —
ReconMatrixbacked with the real engineAffineMatrix(mechrecon.hpp):Identity→BuildIdentity(),FromQuaternion/SetRotation→operator=(const Quaternion&). SAFE: its only live caller (IntegrateMotion) uses a LOCALbodyFrame; other callers are the dead Simulate. - ✅ STEP 4 —
ReconQuatbacked (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: thethis+0x1dc"aim/torso" clear → a declaredaimRate(Quaternion) member. (NOTE: I earlier miscalled mech4.cpp:447 a LIVE PerformAndWatch call — it is actually in the deadSimulatetelemetry 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 useSelectSequence/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 engineAnimationInstance(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 whileframeTimes[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.
- currentTime) * rootTranslation[cur].z
- 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)
SequenceControllerclass in mechrecon.hpp (extends the old ReconSeq: keeps keyframeCount/keyframeTimes/keyframeData accessors for mech3; adds the real fields;typedef SequenceController ReconSeq).seqctl.cppimplements Init (ctor logic — resolvesowner->GetJointSubsystem()), SelectSequence (find viaapplication->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 snapSetHinge(*(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) callLoadLocomotionClips(model)at model build to populate the clips; (3) back theFUN_00408744world-step transform; (4) reviveIntegrateMotioninPerformAndWatch, 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-loadResolveJoint("jointlocal"), wherejointSubsystemis valid) callslegAnimation.Init(this)+bodyAnimation.Init(this). Runs always (harmless when the cutover is off). - cutover branch —
PerformAndWatch(mech4.cpp), gatedstatic int s_gaitCutover = getenv("BT_GAIT_CUTOVER"). On a gait CHANGE itbodyAnimation.SelectSequence(ResolveAnimationClip("blh",suffix), (void*)1 /*loop*/, 0, 0); each frameadv = bodyAnimation.Advance(dt,1)(the real controller animates the skeleton AND returns the clip forward step) →localOrigin.linearPosition += facing*adv*dir. STEP-1/2gBodyAnimpath preserved as theelse(default). - KEY BUG (found via cdb av in
ResourceFile::SearchList+0x77, RESOURCE.cpp:498):SelectSequencefetched the clip withrf->SearchList(clip_id, 16)— butSearchList(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. Theclip_idfromResolveAnimationClipis already a DIRECT resource ID → fixed torf->FindResourceDescription(clip_id)Lock, which mirrors the engine's own clip loadAnimationInstance::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,structureclimbs 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::Advanceruns live for the first time with no crash. - DECOMP FACT (
Advance@0042790craw, 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@0x48recursively (passing the carryover time) and folds its return into distance. Smoothness in the real game comes from VELOCITY integration inIntegrateMotion, not per-Advance — so applyingadvdirectly 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 — needsOnBodyAnimFinishedwired as the real member-fn-ptr callback that re-arms + recursively continuesAdvance); (b) component-LERP → slerp (FUN_00409390); (c) the deep win — the FULLIntegrateMotionvelocity path (leg+body via mech2AdvanceLeg/ BodyAnimation, theFUN_00408744world-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_00408744BACKED (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 engineAffineMatrix::GetFromAxiscolumn basis —out = v.x·colX + v.y·colY + v.z·colZreproduces the raw row-dot EXACTLY, and it is the SAMEGetFromAxisconvention 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 enginelocalOrigin—Origin = { Point3D linearPosition; Quaternion angularPosition }(ORIGIN.h:15); rawFUN_0040ab44builds the matrix from BOTH halves (rot from 0x26c, translation from 0x260). So the motion state maps directly ontolocalOrigin, NOT the parallelmotionDelta/worldPoseplaceholders. - 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 onlyspinRate@0x508 and leftangularAccum[2](velocity.z the world-step reads) stale → now setsangularAccum[2] = -adv/dt. - Verified: walk-only
BT_GAIT_CUTOVER=1walks 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)
AdvanceBodyAnimationgait STATE MACHINE (mech2.cpp:506, real — stand→walk→run transitions viaSetBodyAnimation(state)→animationClips[state]) in place of the inlineResolveAnimationClip+SelectSequence. Needs #2.LoadLocomotionClips(mech3.cpp:326, real) called at model build (Mech ctor) to populatenamedClip[]/animationClips[]+ gait-speed caps (standSpeed/walkStride/reverseSpeedMax/gimp…) — measured vialegAnimation.SelectSequence+keyframeData(now that the SequenceController is real). OPEN: reconcilenamedClip[]@0x5e0 vsanimationClips[]@0x5cc + the state→clip index mapping.- Orientation INTEGRATION — the real turn is angular-rate integration into
localOrigin.angular Position(rawFUN_00409f58(0x26c, 0x26c, angRate*dt)), driven by control input, not the bring-upgDriveHeading. (Bring-up keepsgDriveHeadinguntil the controls mapper is un-bypassed.) - 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). - Then call the real
Mech::IntegrateMotion(retargeted tolocalOrigin) 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 —
LoadLocomotionClipscalled in the Mech ctor (gated), completes cleanly for the Blackhawk: resolves the full gait set + measures the speed caps (verifiedwalkStride=22.02,standSpeed=6.83). RECONCILIATION FIX:namedClip[]@0x5e0 andanimationClips[]@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 toSetBodyAnimation/MeasureClipStride. Fixed:namedClipis a pointer aliased to&animationClips[5](ctor). Same class of bug as the field-shadowing systemic issue. - Step 2 —
AdvanceBodyAnimationstate machine drives the walk: case 6/7 slewsbodyCycleSpeedtowardbodyTargetSpeedwith the loaded caps, AdvancesanimationClips[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 soSetBodyAnimation'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):
SetBodyAnimationpasses a non-null loop sentinel(void*)1where the binary passesPTR_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). ReconstructPTR_LAB_0050d6fcfor authentic transitions.
- leg alternation, @0x48). Without a non-null cb the SequenceController leaves the cycle stuck at its last
keyframe (
Remaining for the FULL gait (after steps 1-2)
- Reconstruct
PTR_LAB_0050d6fc(+ the legPTR_LAB_0050d6f0) -- the finished-callback that transitions gait states (stand->walk->run->reverse) + alternates legs. Replaces the(void*)1loop sentinel. - LEG channel
AdvanceLegAnimationfor visible leg stepping (body channel already drives world motion). - Gait SELECTION from throttle ->
bodyTargetSpeed/movementMode(currently forces walk state 6); wire walk/run/reverse via the real commanded speed (theMechControlsMapperis still bypassed). - 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 tailMech::BodyTransition. Walk 6<->7, run 12<->13, stop->8/9, walk->run 6->11->12. - Plumbing:
SequenceController::Advancenow CALLScbCodeasScalar(*)(Mech*,unsigned,Scalar,int)at end-of-clip (matches the binary**(code**)(this+0x48)), folding the returned distance.SetBodyAnimationpasses&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 (adv0.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).