diff --git a/context/locomotion.md b/context/locomotion.md index fbff5ea..1ec55fa 100644 --- a/context/locomotion.md +++ b/context/locomotion.md @@ -24,12 +24,26 @@ z=0. The `.MOD` physics governed thrust VEHICLES, NOT walk gait. [T1] ## The two-channel gait (real controls) The BT `SequenceController` (`seqctl.cpp`, from `SelectSequence@004277a8`+`Advance@0042790c`) drives -locomotion. Under `BT_REAL_CONTROLS` two channels run: -- **LEG channel** writes the skeleton JOINTS from the live demand (a local sim; its joint writes are - overwritten by the body). -- **BODY channel** is the DISPLAYED motion — travel comes from the SAME Advance that writes the - displayed joints (`move_joints=1`), so display == travel BY CONSTRUCTION (v4 fix — killed the - desync class). [T2] +locomotion. Under `BT_REAL_CONTROLS` two channels run — **roles corrected 2026-07-09 (task #49) from +raw disasm** (both Advance fns had NO static decomp caller; found by byte-scanning for `e8` calls): +- **LEG channel = the LOCAL sim.** The master perf (the `0x4a9b5c` gap region) calls + `AdvanceLegAnimation` @0x4aa399 (airborne @0x4aa388) and stores `-dist/dt` into **localVelocity + (+0x1cc)** — the leg drives the local mech's TRAVEL and, advancing LAST (mj=1), the DISPLAYED pose. + It reads the LIVE `speedDemand` and owns trn/turn-in-place + the early-stand wind-down guard. [T1] +- **BODY channel = the replication PROJECTOR.** `IntegrateMotion` (0x4ab1c8; body advance @0x4ab312, + from `FUN_004ab430`, the projected-origin/dead-reckon updater) stores `-dist/dt` into + **projectedVelocity (+0x2a0)** — the dead-reckoning feed for update records. Its phase drift is + locally INVISIBLE in the binary. (The recon does not yet wire this store — follow-up.) [T1] +**v5 (current): display == travel BY CONSTRUCTION through the LEG** (body advances first, leg last). +History: v3 (travel=leg, display=body) foot-slipped — two channels, different consumers; v4 unified +BOTH on the body (mis-reading 0x4ab430 as the travel source) — feet planted, but whenever the two +state machines phase-split, the out-of-phase LEG pose showed through on frames the body didn't write +(Standing/wind-down) = the RECURRING "legs stutter / lose sync" report. The split mechanism [T2, +reproduced via `BT_FORCE_OSC` + `BT_FORCE_TURN` + `BT_SYNC_LOG`]: (a) SEED — the bring-up trn trigger +arms only the leg (authentic trn dispatcher still unlocated), so the walk re-entry lands 1 frame +apart; (b) AMPLIFIER — with offset clips the end-of-clip callbacks fire on different frames, so a +demand change landing between them picks DIFFERENT next states (one winds down, one keeps walking) +→ opposite-phase churn. Under v5 both effects still exist but only skew the projection channel. [T2] The gait STATE MACHINE (`AdvanceBodyAnimation`, mech2.cpp) slews `bodyCycleSpeed` toward the demand through walk/run caps (`LoadLocomotionClips`, mech3.cpp:326 — measures `walkStride`/`standSpeed` via `legAnimation.SelectSequence`). Transition callbacks (`Mech::BodyClipFinished @0x4a6d8c`, diff --git a/docs/P3_LOCOMOTION.md b/docs/P3_LOCOMOTION.md index 2cab5ba..0848472 100644 --- a/docs/P3_LOCOMOTION.md +++ b/docs/P3_LOCOMOTION.md @@ -360,3 +360,25 @@ The real gait finished-callback (was a `(void*)1` loop sentinel). spawn FACING (the authentic orientation exposed the old fixed-offset assumption). - Verified: env-free default run = full authentic chain, combat damage -> 0.933, 0 crashes; all =0 fallbacks work. P3 is CLOSED for multiplayer purposes (the update-writer data is maintained). + +### v5 (2026-07-09, task #49) — CHANNEL ROLES CORRECTED FROM RAW DISASM: display+travel = LEG +User-reported recurrence of "legs stutter / lose sync" (live [sync]: advSum/legSum drifted 5->92 during +walk-up churn then locked). Reproduced headlessly (BT_FORCE_OSC feathering + BT_FORCE_TURN; per-frame +BT_SYNC_LOG probe): the SEED is the bring-up trn trigger arming only the LEG channel (walk re-entry lands +1 frame apart -> clip phase offset); the AMPLIFIER is a demand change landing between the two channels' +end-of-clip callbacks -> DIVERGENT next-state picks (one winds down, one keeps walking) -> opposite-phase +churn (observed bs=6 ls=7). Under v4 (display+travel = body) the out-of-phase LEG pose showed through on +every frame the body didn't write (Standing/wind-down) = the visible stutter. +DISASM GROUND TRUTH (both Advance fns have NO static decomp caller; found by byte-scanning CODE for e8 +calls): the master perf (0x4a9b5c gap region) calls AdvanceLegAnimation @0x4aa399 (airborne @0x4aa388) and +stores -dist/dt into localVelocity (+0x1cc) == the LEG drives local travel (and pose, writing last); +IntegrateMotion (0x4ab1c8; body advance @0x4ab312; caller FUN_004ab430 = the projected-origin updater) +stores -dist/dt into projectedVelocity (+0x2a0) == the BODY is the dead-reckoning PROJECTOR, locally +invisible. v4's reading of 0x4ab430 as the travel source was WRONG. +FIX (mech4.cpp): advance body FIRST (projection; writes overwritten), leg LAST (displayed pose); travel = +legAdv under the two-channel split (travelAdv selector; single-channel path keeps adv). Display == travel +through the LEG by construction; body drift can no longer show. VERIFIED: trn+feather repro clean; straight +drive locked (adv==proj); solo goto=enemy kill chain intact; MP 2-node drive-to-range cross-pod kill intact +(hdlr=241, DESTROYED). FOLLOW-UPS: wire the body adv into the named projectedVelocity member (the +0x2a0 +store, currently diagnostic-only); locate the authentic trn entry dispatcher (would also arm... TBD). +New gated instruments: BT_SYNC_LOG (per-frame channel divergence), BT_FORCE_OSC= (throttle feathering). diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index e86fdd0..7f775d4 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -1819,23 +1819,56 @@ void } if (s_realControls) { - // FOOT-PLANT FIDELITY (raw FUN_004ab430:15076 -> FUN_004ab1c8): - // the binary advances the BODY controller with move_joints=1 -- - // the DISPLAYED pose and the travel distance come from the SAME - // Advance (same clip, same phase), so feet plant by construction. - // The leg channel (local-sim, hardcoded move_joints=1 in the raw, - // FUN_004a5028:12006) runs FIRST; the body's writes land after and - // win the frame, exactly the binary's order. The earlier split - // (body silenced with move_joints=0, leg pose displayed) showed - // leg-channel joints against body-channel travel -- two - // independently-phased state machines -> visible foot glide. - legAdv = AdvanceLegAnimation(dt); // channel A: local sim (live demand) - adv = AdvanceBodyAnimation(dt, 1); // channel B: displayed pose + motion + // CHANNEL ROLES (task #49, disasm-corrected [T1]): in the binary + // - the MASTER PERF (0x4a9b5c region) advances the LEG channel + // (call @0x4aa399, airborne @0x4aa388) and stores -dist/dt into + // localVelocity (+0x1cc) -- the LEG drives the LOCAL mech's + // travel AND (writing last, mj=1) the displayed pose; + // - IntegrateMotion (0x4ab1c8, body advance @0x4ab312) stores + // -dist/dt into the PROJECTED velocity (+0x2a0) -- the BODY + // channel is the dead-reckoning/replication PROJECTOR, whose + // phase drift is locally INVISIBLE. + // The previous arrangement here (v4: display+travel from the BODY) + // had the roles swapped: whenever the two state machines phase-split + // (the leg-only trn pivot seed, or a demand change landing between + // the channels' end-of-clip callbacks -> divergent transitions), the + // out-of-phase LEG pose showed through on every frame the body + // didn't write (Standing/wind-down) = the visible leg stutter. + // Body first (projection; writes get overwritten), leg LAST (the + // displayed pose): display == travel through the LEG channel, by + // construction -- body-channel drift can no longer show. + adv = AdvanceBodyAnimation(dt, 1); // channel B: replication projection + legAdv = AdvanceLegAnimation(dt); // channel A: local sim -- pose + travel } else { adv = AdvanceBodyAnimation(dt, 1); // single-channel: body does both } + // [syncF] per-frame divergence probe (task #49): log every frame + // where the two channels return different distances or either + // state machine changed state -- pinpoints WHICH frames the + // advSum/legSum drift and the visible pose pops come from. + if (getenv("BT_SYNC_LOG")) + { + static int s_lastBS = -1, s_lastLS = -1; + const int bs = (int)bodyStateAlarm.GetLevel(); + const int ls = (int)legStateAlarm.GetLevel(); + const float dD = (float)(adv - legAdv); + if (bs != s_lastBS || ls != s_lastLS + || dD > 0.001f || dD < -0.001f) + { + DEBUG_STREAM << "[syncF] dt=" << dt + << " bs=" << bs << " ls=" << ls + << " adv=" << adv << " legAdv=" << legAdv + << " bCyc=" << bodyCycleSpeed << " lCyc=" << legCycleSpeed + << " bFrm=" << bodyAnimation.currentFrame + << " bT=" << bodyAnimation.currentTime + << " lFrm=" << legAnimation.currentFrame + << " lT=" << legAnimation.currentTime + << "\n" << std::flush; + s_lastBS = bs; s_lastLS = ls; + } + } // COCKPIT BOB (task #15): the leg channel writes the clip's vertical // root motion into jointlocal (balltranslate) every frame, but the // camera's DCS chain doesn't consume animated joints -- publish the @@ -1924,25 +1957,23 @@ void // accumulated distance over the elapsed time since the last // keyframe contribution, HELD across zero-distance frames, hard // zero when Standing (and after 0.3s of silence, e.g. paused clip). - // FOOT-PLANT BY CONSTRUCTION (v4 -- BINARY-FAITHFUL: travel = the - // BODY channel, the SAME Advance that writes the displayed joints; - // raw FUN_004ab430:15076 advances the body with move_joints=1 and - // takes the travel from it). v3 sourced travel from the LEG channel - // while the BODY drew the joints -- TWO state machines that could - // (and did) drift apart whenever any event touched one channel's - // state without the other: the knockdown (fixed by staggering both), - // then AGAIN on the Mad Cat / analog-lever sweeps crossing gait - // boundaries (each channel's end-of-clip callback samples the demand - // at a different instant, so near a walk/run threshold they can pick - // DIFFERENT next states -> permanent phase split = the foot-slip). - // Sourcing travel from the displayed channel kills the whole desync - // CLASS: display == travel by construction. The earlier "tunnels - // through obstacles" objection to this is OBSOLETE -- the knockdown - // now staggers the BODY channel too (SetBodyAnimation(0x20)), so a - // hard impact freezes travel exactly as before (re-verified). The - // leg channel keeps running as the local sim it is in the binary - // (its joint writes land first and are overwritten by the body's). - const Scalar localAdv = adv * dir; + // FOOT-PLANT BY CONSTRUCTION (v5 -- task #49, disasm-corrected): + // display AND travel BOTH come from the LEG channel (the binary's + // master perf advances the leg and writes -dist/dt into + // localVelocity @+0x1cc; the leg writes the pose last). The v4 + // reading ("raw FUN_004ab430:15076 takes the travel from the body + // advance") was WRONG -- 0x4ab312's IntegrateMotion stores its + // -dist/dt into the PROJECTED velocity (+0x2a0), the dead-reckoning + // feed, not local travel. v3 (travel=leg, display=body) foot-slipped + // because display and travel were DIFFERENT channels; v4 unified on + // the body, which planted feet but let the out-of-phase LEG pose show + // through wherever the body didn't write (the visible stutter). v5 + // unifies on the leg exactly as the binary does: display == travel by + // construction, live-demand channel authoritative, body drift local- + // invisible. (Knockdown still staggers BOTH channels, so a hard + // impact freezes travel as before.) + const Scalar travelAdv = s_realControls ? legAdv : adv; + const Scalar localAdv = travelAdv * dir; linearSpeed = (localAdv < 0.0f ? -localAdv : localAdv) * invDt; // forward ground speed -> LinearSpeed gauge Vector3D localVel(0.0f, 0.0f, -localAdv * invDt); // exact frame distance as velocity Matrix34 orient; // rotation from the heading (set @ line ~626) @@ -1957,7 +1988,8 @@ void if (gBodyAnimLog >= 1.0f) { gBodyAnimLog = 0.0f; - DEBUG_STREAM << "[gait] adv=" << adv << " pos=(" + DEBUG_STREAM << "[gait] adv=" << travelAdv << " (proj=" << adv + << ") pos=(" << localOrigin.linearPosition.x << ", " << localOrigin.linearPosition.z << ")\n" << std::flush; } diff --git a/game/reconstructed/mechmppr.cpp b/game/reconstructed/mechmppr.cpp index 09600d0..411b67f 100644 --- a/game/reconstructed/mechmppr.cpp +++ b/game/reconstructed/mechmppr.cpp @@ -613,6 +613,24 @@ void key_throttle = key_throttle * gBTGotoThrottle; } } + // BT_FORCE_OSC=: square-wave the forced throttle between the + // commanded value and near-idle every sim-seconds -- the + // headless FEATHERING repro (run->walk-downshift->run churn, the + // live-play gait-desync trigger pattern, task #49). + { + static float s_oscP = -1.0f, s_oscClock = 0.0f; + if (s_oscP < 0.0f) + { + const char *fo = getenv("BT_FORCE_OSC"); + s_oscP = fo ? (float)atof(fo) : 0.0f; + } + if (s_oscP > 0.0f) + { + s_oscClock += time_slice; + if (fmodf(s_oscClock, 2.0f * s_oscP) >= s_oscP) + key_throttle = 0.08f; // near-idle half-cycle + } + } // BT_FORCE_FLIP=: invert the forced throttle after t sim-seconds // (the mid-stride direction-change repro). static float s_hFlip = -1.0f;