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