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

363 lines
33 KiB
Markdown
Raw Blame History

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