diff --git a/context/locomotion.md b/context/locomotion.md index 6c96ad1..4f93f34 100644 --- a/context/locomotion.md +++ b/context/locomotion.md @@ -114,30 +114,50 @@ through walk/run caps (`LoadLocomotionClips`, mech3.cpp:326 — measures `walkSt `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] +## Turn-in-place (trn, state 4) — AUTHENTIC dispatcher decoded (task #64, 2026-07-14) +**The entry dispatcher is NOT in the Ghidra decomp** — it lives in the master-perf gap +`0x4a9b5c–0x4ab188` (functions_index.tsv jumps FUN_004a9770→FUN_004ab188; the SOLE reader of +`turnDemand`@mapper+0x12c and `turnCapable`@0x588) [T1 proof of absence]. **Ghidra never lifted it, so +it was disassembled with objdump** (`scratchpad/masterperf.asm`, 1530 lines) and hand-decoded — this +REPLACES the earlier [T3] stand-in guesses (`0.25·standSpeed` entry gate + `×4` fast-forward) with the +real logic. `animationClips[4]` = the `trn`/`trni` clip (loaders 13767/13971). Port recon (mech2.cpp): +- **THE POSE-MATCH INVARIANT:** this engine has NO pose blending — `SelectSequence` hard-resets to + frame 0 — and avoids pops purely by AUTHORING (every transition clip starts in its predecessor's end + pose; a turn-in-place CYCLE returns to the neutral/stand pose at its last frame). The trn→walk seam + therefore steps `trn legFrm7 (cycle end) → swr legFrm1` — a small ONE-TIME bob step that is authentic + (the binary cuts on speed, not at an arbitrary frame). **What made it STUTTER was NOT this cut** — it + was the body channel leaking out-of-phase joints into the rendered skeleton every walk cycle (mj=1); + fixed at the source by `AdvanceBodyAnimation(dt, mj=0)` (see two-channel section). With mj=0 the + authentic cut renders cleanly. +- **Turn rate (disasm 0x4aa3d3–0x4aa4ff) [T1]:** `spd = |linearSpeed|`; base `turnRate = + walkingTurnRate`(mech+0x574, rad/s). If `spd ≥ reverseSpeedMax`(0x538): `t = (spd − walkStride + 0x534)/(topSpeed 0x34c − walkStride)`; `turnRate = t≤1 ? lerp(walkingTurnRate, runningTurnRate + 0x578, t) : runningTurnRate/t²` (over-run falloff). `turnRate = max(0, turnRate)`. Then + `angularVelocity`(0x1d4) `= turnDemand(mapper+0x12c) × turnRate`, ZEROED if legState∈{1,2,3} or + latch 0x650 set. ⚠ **Not yet wired into the port** — the bring-up drive still yaws at the constant + `kDriveTurnRate` (mech4.cpp:2618/2628/3062); `walkingTurnRate`/`runningTurnRate` are stored + (mech.cpp:1334-5, Wword(0x15d)/(0x15e)→0x574/0x578, deg→rad) but only the ANIMATION dispatcher is + wired. Wiring the rate lerp is the next locomotion polish item. +- **Entry** (leg Standing case, disasm 0x4aa505 [T1]): arm trn when TURNING (`|angVel|>1e-4`, port + proxy = turnDemand deadband ±0.05) AND `0 ≤ spd ≤ standSpeed` (the FULL sub-walk range — CORRECTS the + `0.25·standSpeed` stand-in) AND `turnCapable`(hasCrashSet@0x588). **LOCKSTEP: arms BOTH channels the + same frame** (SetLegAnimation(4)+SetBodyAnimation(4), gated on body also Standing) — arming only the + leg seeded the channel phase-split. The disasm also gates on `legResetLatch`(0x654), a master-perf + ONE-FRAME debounce (cleared every frame @0x4a9bff, set on wind-down/turn-stop) — **omitted in the + port** because its split leg-SM never per-frame-clears the latch, so gating on it would wrongly block + trn after every walk. +- **Exits:** leg SM (part_012.c:12013 [T1], restored VERBATIM) — `standSpeed` (turn-then-ramp repro), + `BT_GAIT_TRACE=1` (per-frame [gaitSM]). [T2] **Feel re-verification pending** (release now at + standSpeed vs the invented 0.25×). [T1 logic / T2 runtime] ⚠ **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 b3ca40e..ea85219 100644 --- a/game/reconstructed/mech2.cpp +++ b/game/reconstructed/mech2.cpp @@ -595,22 +595,26 @@ Scalar else { distance = 0.0f; - // 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; + // TURN-IN-PLACE entry -- AUTHENTIC, decoded from the master-perf disasm + // (0x4aa505-0x4aa588, task #64b). The dispatcher lives in the master perf + // FUN_004a9b5c (Ghidra never decompiled it -> objdump); it arms trn from + // Standing when: the mech is TURNING (|angularVelocity| > 1e-4, ds:0x4ab16c + // -- angularVelocity = turnDemand * turnRate, so turnDemand outside a tiny + // deadband) AND 0 <= speedDemand <= standSpeed (the FULL sub-walk range, + // ds:0x4ab178=0 .. mech[0x530]) AND turnCapable(mech[0x588]) AND the + // legResetLatch(mech[0x654]) debounce is clear. CORRECTS the task-#64a + // stand-in `commandedSpeed < 0.25*standSpeed` (that near-zero gate was a + // guess; the binary allows the full [0,standSpeed] range -- trn is gated on + // TURNING, not on being slow). [T1 from disasm] The turnDemand deadband + // (0.05) is the port proxy for the |angVel|>1e-4 test. NOTE: the disasm's + // legResetLatch gate is a master-perf ONE-FRAME debounce (cleared every + // frame at 0x4a9bff, set on wind-down/turn-stop) -- it does NOT map onto + // the port's split leg-SM (wind-down + trn-entry run in different frames/ + // cases and the port never per-frame-clears the latch), so gating entry on + // it here would wrongly block trn after every walk. Omitted by design; + // see locomotion.md "turn-in-place dispatcher". if (hasCrashSet != 0 && mppr != 0 - && commandedSpeed >= ZeroSpeed && commandedSpeed < turnEntrySpeed + && commandedSpeed >= ZeroSpeed && commandedSpeed <= standSpeed && (mppr->turnDemand > 0.05f || mppr->turnDemand < -0.05f) && bodyAnimationState == StandingAnimation) // weld: enter only when BOTH can @@ -664,47 +668,41 @@ Scalar case 4: // TURN-IN-PLACE (trn clip) // 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. + // loaded under turnCapable@0x588). AUTHENTIC exits, restored VERBATIM from the + // decompiled leg SM (part_012.c:12013 == FUN_004a5028 case 4 [T1]): + // * standSpeed < commandedSpeed -> SetLevel(0)+ForceUpdate(8): walk takes over + // * commandedSpeed < ZeroSpeed -> SetLevel(0)+ForceUpdate(8): reverse + // * else -> goto advance_normally: advance the pivot at idleStrideScale. + // PLUS the master-perf turn-STOP exit (disasm 0x4aa5c6-0x4aa5e6 [T1], which the + // leg SM does NOT contain -- it lives in the un-decompiled master perf that the + // port has no separate frame for, so it folds in here): when the turn stops + // (|angularVelocity| <= 1e-4, i.e. turnDemand back inside the deadband) -> + // SetLevel(0) + legResetLatch(mech[0x654])=1. Without it the trn clip would + // shuffle in place forever after the stick re-centers. // - // 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) + // The task-#64 fast-forward (4x) + 0.25*standSpeed early-release were STAND-INS + // invented to dodge a turn->walk stutter that was ACTUALLY the body channel + // leaking joints into the rendered skeleton (mj=1); root-caused + fixed by the + // body-channel mj=0 change (AdvanceBodyAnimation, mech4.cpp). With the body no + // longer writing joints the authentic mid-clip cut renders cleanly, so the + // inventions are removed. trn has zero root translation -> distance stays 0. + if (standSpeed < commandedSpeed || commandedSpeed < ZeroSpeed) { - legStateAlarm.SetLevel(0); + legStateAlarm.SetLevel(0); // -> Standing (leg-SM exit) ForceUpdate(8); // type-3 record distance = 0.0f; break; } + if (mppr == 0 + || (mppr->turnDemand <= 0.05f && mppr->turnDemand >= -0.05f)) { - 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) + legStateAlarm.SetLevel(0); // turn stopped -> Standing (master-perf exit) + ForceUpdate(8); + legResetLatch = 1; // mech[0x654] (master perf sets it here, 0x4aa5e1) + distance = 0.0f; + break; } - break; + goto advance_normally; // still turning, sub-walk -> advance the pivot case 6: case 7: // WalkToRun // @@ -918,27 +916,32 @@ Scalar // 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.] + // permanently out of phase. Exits mirror the leg twin VERBATIM + // (part_012.c:12013 [T1]): speed exits + the master-perf turn-stop exit. + // The body does NOT set legResetLatch (leg-channel/master-perf state, set + // once by the leg twin). Since the body no longer writes joints (mj=0, + // AdvanceBodyAnimation) this channel only tracks state so both channels + // re-enter walk on the same frame. { MechControlsMapper *bm = MappingMapper(); const Scalar bspd = (bm != 0) ? bm->speedDemand : 0.0f; - if (bspd < ZeroSpeed) // reverse abort (leg-symmetric) + if (standSpeed < bspd || bspd < ZeroSpeed) // walk / reverse (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); + if (bm == 0 + || (bm->turnDemand <= 0.05f && bm->turnDemand >= -0.05f)) + { + bodyStateAlarm.SetLevel(0); // turn stopped (leg-symmetric) + ForceUpdate(8); + distance = 0.0f; + break; + } + distance = bodyAnimation.Advance( // still turning -> advance the pivot + time_slice * globalTimeScale * idleStrideScale, loop); bodyCycleSpeed = distance / time_slice; } break;