Files
BT412/docs/P3_LOCOMOTION.md
T
arcattackandClaude Opus 4.8 7b7d465e5e Initial commit: bt411 -- standalone Windows BattleTech (Tesla 4.10 port)
Clean, self-contained extraction of the BattleTech-specific work from the
reverse-engineering workspace -- engine + game + content + build, with nothing
from Red Planet or the raw archive dumps. Builds green (Win32) and runs the
single-player drive->animate->target->fire->damage->destroy loop out of the box.

Layout:
  engine/   MUNGA + MUNGA_L4 shared 2007 engine, carrying our BT render/loader
            work (bgfload/L4D3D/L4VIDEO: BSL bit-slice decode, LOD/ground/shadow
            models) + image codec; the minimal rp/ headers the audio HAL needs
  game/     reconstructed BT logic + surviving-original BT source + fwd shims
            + WinMain launcher
  content/  full runtime tree (BTL4.RES, VIDEO/, GAUGE/, AUDIO/, eggs, BTDPL.INI)
  docs/     format specs + reconstruction ledgers
  reference/ raw Ghidra pseudocode (recon source-of-truth) + decomp exporter
  tools/    MP console emulator + map/resource scanners

One top-level CMake builds munga_engine lib + bt410_l4 game lib + btl4.exe.
All paths relativized (186 fwd shims + ~437 CMake abs paths -> repo-relative);
DXSDK is the one external, overridable via -DDXSDK. Verified: builds to a
byte-identical 2.27MB exe and runs combat (TARGET DESTROYED, 0 crashes) against
the bundled content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:03:40 -05:00

33 KiB
Raw Blame History

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 (ReconSeqAnimationInstance, 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: ReconMatrixAffineMatrix 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): IdentityBuildIdentity(), FromQuaternion/SetRotationoperator=(const Quaternion&). SAFE: its only live caller (IntegrateMotion) uses a LOCAL bodyFrame; other callers are the dead Simulate.
  • STEP 4 — ReconQuat backed (mechrecon.hpp): ReconQuatIdentityQuaternion::Identity, ReconQuatIntegrateQuaternion::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 branchPerformAndWatch (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 localOriginOrigin = { 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.AdvancelocalVel = {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 (adv0.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).