From bfdd41bb9de4af7769ae639e6fdc33192a1a0237 Mon Sep 17 00:00:00 2001 From: arcattack Date: Mon, 13 Jul 2026 23:52:35 -0500 Subject: [PATCH] Gait: turn-then-walk stutter ROOT-CAUSED + FIXED -- one skeleton writer (task #64) User repro (100%): turn in place, push forward before the turn stops -> the gait skips/stutters + visibly reduced bob; standstill starts always clean. Three-layer fix, user-verified: 1) THE DISPLAY BUG (the actual visible artifact): v5's "body advances first with mj=1, leg overwrites last, so body drift can't show" was FALSE. Whenever the two gait channels phase-split, the BODY channel's out-of-phase joint writes leaked into the rendered skeleton (rhythmic leg skips, averaged-down bob) while every leg-channel trace read clean -- the leg DATA was fine, the RENDERED pose wasn't the leg's. v6: AdvanceBodyAnimation(dt, mj=0) -- the body still advances + projects for replication (records/cycle speeds unchanged) but no longer touches the skeleton. One writer, structurally; matches the binary's own observable ("body phase drift is locally INVISIBLE in the binary"). BT_BODY_MJ=1 = old A/B. 2) THE SPLIT SEED: the bring-up trn trigger armed only the LEG channel, so a turn-in-place entry guaranteed the channels re-entered walk frames apart and the walk cycles ran permanently out of phase. Lockstep: the Standing trn entry arms BOTH channels the same frame (body case-4 twin added, same rate/keying), gated on both Standing. The authentic dispatcher (un-decompiled master-perf gap 0x4a9b5c- 0x4ab188, the sole reader of turnDemand/turnCapable) armed both -- the body's case-4 machinery is dead code otherwise [T1]. 3) THE POSE-MATCH INVARIANT: the engine has NO pose blending; transitions avoid pops purely by authored pose-matched boundaries. The old trn exits cut the pivot clip MID-STEP (legFrm 7->1 teleport). Now: entry gated on near-zero speed (turn-IN-place); on forward command the pivot FAST-FORWARDS to completion (x4) and the authentic finish callback lands Standing at the stand pose -> the normal pose-matched stand->walk runs. Decompiled reverse abort kept; the standSpeed mid-clip abort subsumed (it WAS the pose cut). [T3: 0.25*standSpeed threshold + 4x rate stand in for the gap's constants.] Harness: BT_FORCE_TURN now reaches gBTDrive.turn (was silently inert for the gait), BT_WALK_DELAY= holds forced throttle then ramps (the turn-first repro), BT_GAIT_TRACE=1 per-frame gait trace. Regressions: standstill start, turn-entry, pure pivot loops, run cycle -- all clean, bob full amplitude (1.33/1.32). KB: locomotion.md v6 section + trn reconstruction + symptom-family closure. Co-Authored-By: Claude Opus 4.8 (1M context) --- context/locomotion.md | 53 +++++++++++---- game/reconstructed/mech2.cpp | 122 ++++++++++++++++++++++++++++------- game/reconstructed/mech4.cpp | 83 ++++++++++++++++++++++-- 3 files changed, 218 insertions(+), 40 deletions(-) diff --git a/context/locomotion.md b/context/locomotion.md index 836cb2b..6c96ad1 100644 --- a/context/locomotion.md +++ b/context/locomotion.md @@ -77,7 +77,8 @@ node) — which is why the bug was intermittent and direction-dependent. FIX: a Advance*Animation Standing cases zero their channel's cycle on entry + ForceUpdate(8) so the peer hears the stop immediately. (The signed-demand change (task #15) is what let negative cycles reach machinery written for forward decel.) The earlier "bob reduction + stutter" -symptom family likely shares this root — re-observe after this fix before chasing separately. +symptom family did NOT share this root — it was the body channel's mj=1 joint writes leaking +through on channel phase-splits, closed by v6 (task #64, see the two-channel section). ## The two-channel gait (real controls) The BT `SequenceController` (`seqctl.cpp`, from `SelectSequence@004277a8`+`Advance@0042790c`) drives @@ -93,20 +94,50 @@ raw disasm** (both Advance fns had NO static decomp caller; found by byte-scanni locally INVISIBLE in the binary. The literal projectedVelocity store is unwired, but the update-record velocity feed is live via the localVelocity/worldLinearVelocity maintenance (mech4.cpp VELOCITY STORAGE block) — replicant gait (task #50) runs off it. [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] +**v6 (current, task #64 2026-07-13): the body channel does NOT write joints — `AdvanceBodyAnimation(dt, mj=0)`.** +Display == travel == the LEG channel STRUCTURALLY (one skeleton writer), matching the binary's own +observable ("the body channel's phase drift is locally INVISIBLE in the binary"). History: v3 +(travel=leg, display=body) foot-slipped; v4 unified BOTH on the body — the out-of-phase LEG pose +showed through on frames the body didn't write = the recurring stutter; **v5 (body-first/leg-last, +believing the leg's overwrite hid body drift) was WRONG the same way MIRRORED** — user-verified +2026-07-13: whenever the channels phase-split, the BODY's out-of-phase mj=1 writes leaked into the +displayed skeleton (rhythmic leg skips + visibly REDUCED BOB) while every leg-channel trace read +clean — the leg DATA was fine; the RENDERED pose wasn't the leg's. The turn-then-walk repro (below) +made it 100% reproducible. `BT_BODY_MJ=1` restores the double-writer for A/B. The old split +mechanism [T2]: (a) SEED — the trn trigger armed only the leg → walk re-entry frames apart; +(b) AMPLIFIER — offset clips → end-of-clip callbacks on different frames → divergent transitions. +Both are ALSO mitigated at the source now (the lockstep trn arming below), but mj=0 is what makes any +residual split invisible, structurally. ⚠ The "bob reduction + stutter" symptom family (previously +parked as "likely shares the reverse-stop root") is THIS — CLOSED. [T2 user-verified] 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`, `LegClipFinished @0x4a6928` — PE-parsed jump tables) alternate walk 6↔7 / run 12↔13 + walk→run. [T2] + +## Turn-in-place (trn, state 4) — reconstructed (task #64, 2026-07-13) +**The authentic entry dispatcher is NOT in the decomp** — it lives in the un-decompiled master-perf +gap `0x4a9b5c–0x4ab188` (functions_index.tsv jumps FUN_004a9770→FUN_004ab188; that gap is the SOLE +reader of `turnDemand`@mapper+0x12c and `turnCapable`@0x588 — both grep-empty elsewhere) [T1 proof of +absence]. The decompiled lifecycle: `animationClips[4]` = the `trn`/`trni` clip (loaders 13767/13971); +leg case-4 advance/exits @part_012.c:12013 (exit at `standSpeed 0.25·standSpeed`, the pivot is FAST-FORWARDED to completion + (advance rate ×4 [T3]) — feet hurry down to the plant, the authentic finish callback drops to + Standing AT the stand pose, then the normal pose-matched stand→walk runs. Decompiled reverse abort + kept; the decompiled `standSpeed` mid-clip abort deliberately subsumed (it WAS the pose cut). [T3] +- Verified: seam trace `frm 15→17→18→19 → stand(frm holds 19) → swr frm1` — no mid-clip reset; both + channels enter walk the same frame. Harness: `BT_FORCE_TURN` (now reaches gBTDrive.turn), + `BT_WALK_DELAY=` (turn-first-then-ramp repro), `BT_GAIT_TRACE=1` (per-frame gait trace). [T2] ⚠ **Aliasing bug class (namedClip):** `namedClip[]`@0x5e0 == `animationClips[]`@0x5cc+0x14 (ONE array); `gimpBaseClip`@0x64c == `animationClips[0x20]`; `bodyAnimationState`@0x728 == `bodyStateAlarm.level`. Declared-separate members don't see each other's writes — alias them. diff --git a/game/reconstructed/mech2.cpp b/game/reconstructed/mech2.cpp index cdce518..b3ca40e 100644 --- a/game/reconstructed/mech2.cpp +++ b/game/reconstructed/mech2.cpp @@ -595,18 +595,39 @@ Scalar else { distance = 0.0f; - // TURN-IN-PLACE entry (bring-up TRIGGER, real machinery): the trn - // clip/state-4 advance/exit are binary-verified, but the authentic - // entry DISPATCHER isn't yet located in the decomp -- enter from - // Standing on a live turn demand with no speed demand. The clip's - // finished-callback drops back to stand; while the demand persists - // this trigger re-arms it (a looping pivot step). + // TURN-IN-PLACE entry. The authentic entry DISPATCHER lives in the + // un-decompiled master-perf gap (0x4a9b5c-0x4ab188), the sole reader of + // turnDemand(+0x12c) + turnCapable(+0x588); the decompiled lifecycle is + // case-4 advance/exit (below) + the clip-finish drop-to-stand. The + // authentic gate is turn-IN-PLACE == NEAR-ZERO commanded speed. The old + // stand-in gated on `ZeroSpeed <= commandedSpeed` -- i.e. it re-armed trn + // for ANY forward speed in [0, standSpeed~6.83], so pushing forward mid- + // turn SWALLOWED the throttle (the mech kept pivoting) until spd crossed + // standSpeed, then lurched to walk (task #64 user-reported stutter). Gate + // on near-zero speed instead: the instant real throttle is applied the trn + // re-arm stops, the in-flight pivot finishes to Standing, and case 0 hands + // off to walk normally. turnEntrySpeed = 0.25*standSpeed is a STAND-IN for + // the gap's threshold constant [T3]; the deadband mirrors the replicant's. + const Scalar turnEntrySpeed = standSpeed * 0.25f; if (hasCrashSet != 0 && mppr != 0 - && ZeroSpeed <= commandedSpeed + && commandedSpeed >= ZeroSpeed && commandedSpeed < turnEntrySpeed && (mppr->turnDemand > 0.05f - || mppr->turnDemand < -0.05f)) + || mppr->turnDemand < -0.05f) + && bodyAnimationState == StandingAnimation) // weld: enter only when BOTH can { - SetLegAnimation(4); // turn-in-place (trn) + // LOCKSTEP (task #64): arm BOTH channels on the same frame. Arming + // only the leg let the body enter walk on its own schedule ~7-20 + // frames apart -> the two walk cycles ran permanently out of phase + // and the body's pose flashed through on leg clip-boundary frames + // (the rhythmic gait skip + reduced bob). Both SequenceControllers + // bind the same trn clip at the same frame + advance at the same + // rate (case 4 twins), so they complete + re-enter walk together -- + // the same phase-weld a standstill start gets for free. This is the + // workflow plan's "master perf arms both channels" reconstruction + // (the authentic dispatcher in the un-decompiled 0x4a9b5c gap arms + // both -- the body's case-4/finish machinery is dead code otherwise). + SetLegAnimation(4); // turn-in-place (trn), channel A + SetBodyAnimation(4); // channel B, same frame [lockstep] goto advance_normally; } if (ZeroSpeed <= commandedSpeed) @@ -642,26 +663,48 @@ Scalar break; case 4: // TURN-IN-PLACE (trn clip) - // BINARY-VERIFIED (raw FUN_004a5028 case 4, part_012.c:12013): state 4 is - // the turn-in-place animation (animationClips[4] = the "trn" clip, loaded - // under turnCapable@0x588). It EXITS to stand when a real speed is - // commanded (standSpeed < demand -- the earlier draft had this comparison - // INVERTED, the same bug class as the Standing case) or when reverse is - // commanded; otherwise it ADVANCES the turn clip while the mech pivots. - if (standSpeed < commandedSpeed) - { - legStateAlarm.SetLevel(0); - ForceUpdate(8); // FUN_004a4c54(this,8): type-3 record - break; - } - distance = 0.0f; + // state 4 is the turn-in-place animation (animationClips[4] = the "trn" clip, + // loaded under turnCapable@0x588). THE POSE-SNAP RULE (task #64, root-caused + // from the user's BT_GAIT_TRACE): this engine has NO pose blending -- every + // authored transition is pose-MATCHED at its boundary (swr starts from the + // stand pose; the walk clips alternate at matched extremes). Any exit that + // abandons the pivot MID-CLIP (SetLevel(0) or SetLegAnimation(5)) hard-resets + // the next clip to frame 0 while the legs are mid-step -> the legs TELEPORT + // (legFrm 7->1 in the trace) = the 100%-repro turn-then-walk stutter. Three + // prior fixes all failed because each still cut the clip mid-step, just at a + // different speed threshold. + // + // The reconstruction: when forward is commanded past the turn-in-place band, + // FAST-FORWARD the pivot to completion (accelerated advance) -- the feet + // hurry down to the plant, the clip's own finished-callback (case 4 -> + // SetLevel(0), the DECOMPILED one-shot tail) drops to Standing AT THE STAND + // POSE, and the normal pose-matched stand->walk path takes over. No mid-clip + // cut anywhere. trn has zero root translation, so the fast-forward moves no + // distance. [T3: the release threshold (0.25*standSpeed) + hurry rate (4x) + // stand in for the un-decompiled master-perf dispatcher constants (gap + // 0x4a9b5c); the completion->Standing tail itself is T1 decompiled.] + // + // The decompiled REVERSE abort is kept verbatim; the decompiled standSpeed + // abort is deliberately SUBSUMED by the hurry (it was the mid-clip pose cut + // itself -- any spd above the band fast-forwards to the plant instead, and + // Standing picks up the walk one frame after the callback). [T3 deviation + // from the literal case-4 exit, in service of the pose-match invariant.] if (commandedSpeed < ZeroSpeed) { legStateAlarm.SetLevel(0); ForceUpdate(8); // type-3 record + distance = 0.0f; break; } - goto advance_normally; + { + Scalar trnRate = 1.0f; + if (standSpeed * 0.25f < commandedSpeed) + trnRate = 4.0f; // forward commanded: hurry the plant [T3] + distance = legAnimation.Advance( // same advance advance_normally does, + time_slice * globalTimeScale * idleStrideScale * trnRate, 1); // rate-scaled + legCycleSpeed = distance / time_slice; // trn z=0 -> stays 0 (no travel) + } + break; case 6: case 7: // WalkToRun // @@ -869,7 +912,38 @@ Scalar } // FALLTHROUGH - case 2: case 3: case 4: case 5: case 8: case 9: case 10: case 0x0b: + case 4: // TURN-IN-PLACE, LOCKSTEP twin (task #64) + // The body channel runs trn in LOCKSTEP with the leg: armed together at + // entry (leg Standing cross-arms both), advanced at the SAME rate keyed on + // the SAME live speedDemand, so both clips complete on the same frame and + // both channels re-enter walk on the same frame. Without this the body + // entered walk ~7-20 frames apart from the leg and the two walk cycles ran + // permanently out of phase -- the body's pose flashes through on the leg's + // clip-boundary frames (body advances mj=1 FIRST, leg overwrites LAST; + // boundary frames the leg doesn't write) = the rhythmic gait skip + halved + // bob the user reproduced 100% from turn-then-walk (standstill starts weld + // the phases, which is why they never stuttered). [T3 rates/threshold; the + // per-channel case-4 machinery itself is T1 decompiled.] + { + MechControlsMapper *bm = MappingMapper(); + const Scalar bspd = (bm != 0) ? bm->speedDemand : 0.0f; + if (bspd < ZeroSpeed) // reverse abort (leg-symmetric) + { + bodyStateAlarm.SetLevel(0); + ForceUpdate(8); + distance = 0.0f; + break; + } + Scalar trnRate = 1.0f; + if (standSpeed * 0.25f < bspd) + trnRate = 4.0f; // hurry the plant (leg-symmetric) + distance = bodyAnimation.Advance( + time_slice * globalTimeScale * idleStrideScale * trnRate, loop); + bodyCycleSpeed = distance / time_slice; + } + break; + + case 2: case 3: case 5: case 8: case 9: case 10: case 0x0b: case 0x0e: case 0x0f: case 0x10: case 0x11: case 0x14: case 0x15: case 0x1c: case 0x1d: case 0x1e: case 0x1f: case 0x20: distance = bodyAnimation.Advance( // FUN_0042790c(this+0x6bc, ...) diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index f0ef179..4ae23f6 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -2251,6 +2251,26 @@ void { gBTDrive.forced = 1; gBTDrive.forcedThrottle = sAutoDrive; + // task #64 harness: BT_WALK_DELAY holds the FORCED throttle (the + // mapper's actual input -- the local `throttle` gate below never + // reached it) at 0 for n sim-secs (turn-in-place phase, with + // BT_FORCE_TURN active), then ramps it over 1.2s -- the manual + // "lever dwell through the walk threshold" repro. + { + static float s_wdD = -1.0f, s_wdC = 0.0f; + if (s_wdD < -0.5f) + { + const char *v = getenv("BT_WALK_DELAY"); + s_wdD = v ? (float)atof(v) : 0.0f; + } + if (s_wdD > 0.0f) + { + s_wdC += dt; + float r = (s_wdC - s_wdD) / 1.2f; + gBTDrive.forcedThrottle = (r <= 0.0f) ? 0.0f + : (r < 1.0f ? sAutoDrive * r : sAutoDrive); + } + } } else if (sGotoDrive) { @@ -2388,6 +2408,35 @@ void s_forceTurn = ft ? (float)atof(ft) : 0.0f; } turn = s_forceTurn; + // Publish the forced turn to gBTDrive too: mechmppr derives turnDemand + // from gBTDrive.turn (NOT this local), so without this the headless rig + // never entered the trn state -- BT_FORCE_TURN was silently inert for + // the gait (task #64 harness fix). + gBTDrive.turn = s_forceTurn; + // BT_WALK_DELAY= (stutter repro, task #64): hold throttle at 0 for the + // first n sim-seconds (TURN-IN-PLACE, turn stays active) then release the + // forced throttle -- reproduces "turn in place, then start walking BEFORE + // the turn stops" exactly. Turn (BT_FORCE_TURN) persists across the seam. + static float s_walkDelay = -1.0f, s_wdClock = 0.0f; + if (s_walkDelay < -0.5f) + { + const char *wd = getenv("BT_WALK_DELAY"); + s_walkDelay = wd ? (float)atof(wd) : 0.0f; + } + if (s_walkDelay > 0.0f) + { + s_wdClock += dt; + if (s_wdClock < s_walkDelay) + throttle = 0.0f; // turn-in-place phase + else + { + // RAMP the throttle up over 1.2s (simulate the manual lever + // sweep DWELLING through standSpeed) with the turn still active + // -- the continuous-stutter repro. + float r = (s_wdClock - s_walkDelay) / 1.2f; + if (r < 1.0f) throttle *= r; + } + } if (s_forceLimit > 0.0f) { s_forceClock += dt; @@ -2723,10 +2772,27 @@ void // 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 + // v5 ordered body-first/leg-last believing the leg's overwrite made + // body drift invisible -- WRONG (task #64): the leakage merely + // inverted (the body's pose flashed through instead). v6 (below) + // removes the body from the skeleton entirely (mj=0). + // task #64 RESOLUTION (user-verified): the body channel must NOT + // write joints. The v5 claim "body writes first, leg overwrites, + // so body drift can never show" was FALSE -- whenever the two + // channels phase-split (the turn-in-place entry armed only the + // leg), the body's out-of-phase pose leaked into the displayed + // skeleton: rhythmic leg skips + visibly reduced bob, 100% repro + // on turn-then-walk, while every leg-channel trace read clean + // (the leg data was fine -- the RENDERED pose wasn't the leg's). + // mj=0 advances + projects the body channel identically (records, + // cycle speeds, travel projection all unchanged -- same engine + // mode the replicant leg path uses) but structurally removes the + // second skeleton writer. This also matches the binary's own + // observable: "the body channel's phase drift is locally + // INVISIBLE in the binary" (locomotion.md) -- now it is here too. + // BT_BODY_MJ=1 restores the old double-writer for A/B. + static const int s_bodyMj = BTEnvOn("BT_BODY_MJ", 0); + adv = AdvanceBodyAnimation(dt, s_bodyMj); // channel B: replication projection (mj=0) legAdv = AdvanceLegAnimation(dt); // channel A: local sim -- pose + travel } else @@ -2781,7 +2847,10 @@ void static float s_smlog = 0.0f; s_smlog += dt; static double s_advSum = 0.0, s_legSum = 0.0; s_advSum += adv; s_legSum += legAdv; - if (s_smlog >= 1.0f) { s_smlog = 0.0f; + // BT_GAIT_TRACE=1 (task #64): log the gait state EVERY frame (not 1 Hz) + // so a frame-level stutter at a transition is visible. + static const int s_gtrace = getenv("BT_GAIT_TRACE") ? 1 : 0; + if (s_smlog >= 1.0f || s_gtrace) { s_smlog = 0.0f; extern float gBTEyeBobY; static Joint *s_dbgRoot = 0; static int s_dbgTried = 0; if (!s_dbgTried) { s_dbgTried = 1; s_dbgRoot = ResolveJoint("jointlocal"); } @@ -2791,10 +2860,14 @@ void << " legSum=" << (float)s_legSum << " posZ=" << localOrigin.linearPosition.z << " rootZ=" << dbgRt.z << " rootX=" << dbgRt.x << "\n" << std::flush; + MechControlsMapper *s_tm = MappingMapper(); // input demands (task #64) DEBUG_STREAM << "[gaitSM] adv=" << adv << " legAdv=" << legAdv << " cycleSpeed=" << bodyCycleSpeed << " legCycle=" << legCycleSpeed << " state=" << bodyAnimationState << " legState=" << legAnimationState << " kfCur=" << bodyAnimation.currentFrame + << " legFrm=" << legAnimation.currentFrame + << " spd=" << (s_tm ? s_tm->speedDemand : 0.0f) + << " turn=" << (s_tm ? s_tm->turnDemand : 0.0f) << " bob=" << gBTEyeBobY << "\n" << std::flush; } } else