From b1cf01029a7d488890dac689ac4f3122dfd5bf3b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 3 Jul 2026 17:00:03 +0200 Subject: [PATCH] fix(R4): #161 remote landing stuck in falling pose - apply-pass params decode Retail's apply_interpreted_movement (0x00528600) does NOT dispatch with ctor-default MovementParameters: the BN decomp smears the bitfield store into the mush expression at raw 305778 - (word & 0x37ff) | cancelMoveTo<<15 | disableJump<<17 - which CLEARS SetHoldKey/ModifyInterpretedState/ CancelMoveTo. ACE MotionInterp.cs:444-449 confirms independently. Three legs fixed, all retail decode, no adaptations added: 1. ApplyInterpretedMovement now builds the pass params retail actually uses (ModifyInterpretedState=false is load-bearing): no dispatch in the pass writes InterpretedState, so the airborne Falling substitution PRESERVES the wire's forward command and HitGround's re-apply dispatches it - the motion table plays the Falling->X landing link. The W6 entry-cache (built on the wrong "retail self-heals via hoisted registers" theory) is deleted; live reads are retail semantics. Raw arg3 decoded as DisableJumpDuringLink -> every (N,0) caller means allowJump=true; all 9 caller polarities fixed. copy_movement_from's current_style copy (raw 0051e757) added to the UM flat-copy. 2. Both GameWindow landing blocks cleared the Gravity state bit BEFORE Motion.HitGround(), whose verbatim state&0x400 gate then no-opped the whole retail re-apply; the UP-driven landing block never called HitGround at all. Both now run HitGround (minterp then moveto, MovementManager::HitGround 0x00524300 order) with Gravity still set, then do the DR bookkeeping clear (register row AP-81 added for the remaining flag dance, retire in R6). 3. K-fix17's forced SetCycle (both copies) deleted: it executed every landing but read the leg-1-clobbered ForwardCommand (0x40000015) and re-set the very Falling cycle it meant to clear. Tests: new HitGround_AfterFall_RedispatchesPreservedForward_ExitsFalling lifecycle pin; AirborneBody state assertion flipped (it had pinned the bug value). Suite 3,964 green incl. the 183-case retail-observer trace. Filed #164 (action-replay Autonomous bit, no current consumer). Awaiting live verify: stand-still landing must exit the falling pose with zero wire input. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 79 ++++--- .../retail-divergence-register.md | 3 +- src/AcDream.App/Rendering/GameWindow.cs | 74 +++---- src/AcDream.Core/Physics/MotionInterpreter.cs | 203 +++++++++--------- .../Input/W6EdgeDrivenMovementTests.cs | 26 +-- .../Physics/MotionInterpreterFunnelTests.cs | 94 ++++++-- 6 files changed, 280 insertions(+), 199 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index b2f36d37..8668ce4d 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -73,31 +73,60 @@ actually steps at (Player_Move.cs / Creature GetRunRate usage). ## #161 — Remote jump landing holds the falling pose (no landing anim) -**Status:** OPEN (2026-07-03; retested after TS-40/`41006e79` — still -broken; user: stuck in falling until the char moves or the window loses -focus). -**Evidence (launch.log, session bklydthrr, guid 0x50000001):** `VU.land` -fires; Falling completions (0x40000015) flow; but the FIRST post-landing -UM still reports `seq now motion=0x40000015` — the landing block's reset -(rm.Motion.HitGround() apply + K-fix17 SetCycle) did NOT move the cycle -off Falling. Recovery happened only via SUBSEQUENT wire UMs (the mover -pressed keys); a stand-still landing gets none → stuck forever. The -focus-change unstick hints a frame-advance/big-dt interaction as well. -**Prime suspect:** the funnel's airborne branch dispatches Falling via -DoInterpretedMotion with ctor-default ModifyInterpretedState=true → -InterpretedState.ForwardCommand becomes 0x40000015; HitGround's -apply_current_movement (interpreted branch for remotes) then re-applies -the interpreted state — i.e. re-dispatches FALLING — instead of the -pre-fall wire command. Check retail: does apply_interpreted_movement's -falling dispatch (raw ~305730, fresh local var_2c params) really write -interpreted fwd (bit 0x4000 in the local's 0x1EE0F default) — and if so, -what exits Falling on a retail REMOTE landing (HitGround raw @305949 -apply_current_movement(this, 1, ...) — the arg2=1 semantics)? Also -verify K-fix17's SetCycle actually executes there (add a probe) and -whether it lost a fight with the same-frame HitGround apply ordering. -**Where:** GameWindow remote landing block (`VU.land` region); -MotionInterpreter.ApplyInterpretedMovement falling branch; -HitGround/apply_current_movement. +**Status:** FIX LANDED (2026-07-03) — awaiting user live verify. +**Root cause (three legs, all retail-decode):** +1. **Apply-pass params (the big one):** retail's `apply_interpreted_movement` + runs ALL its dispatches with `ModifyInterpretedState=false` — the ctor + default 0x1EE0F is REWRITTEN by a bitfield store the BN decomp smears + into the mush expression at raw 305778 (`(word & 0x37ff) | + cancelMoveTo<<15 | disableJump<<17`; 0x37ff clears SetHoldKey/ + ModifyInterpretedState/CancelMoveTo). ACE MotionInterp.cs:444-449 + confirms. Our pass dispatched with ctor defaults (modify=true), so the + airborne Falling substitution CLOBBERED `InterpretedState.ForwardCommand` + → HitGround's re-apply re-dispatched Falling instead of the preserved + pre-fall wire command. (The handoff's "HitGround raw @305949" was a + mislabel — 305949 is inside `move_to_interpreted_state`; the real + HitGround is 0x00528ac0 → `apply_current_movement(0,0)`.) Bonus decode: + raw arg3 = DisableJumpDuringLink, so every `(N, 0)` caller means + allowJump=TRUE — all caller polarities fixed. The W6 entry-cache (whose + "retail self-heals via hoisted registers" theory was this same + misreading) is deleted; live field reads are retail semantics now that + the pass cannot write state. +2. **Landing-order:** both GameWindow landing blocks cleared the Gravity + state bit BEFORE `rm.Motion.HitGround()`, whose verbatim `state & 0x400` + gate then no-opped the entire retail re-apply (retail never clears + GRAVITY on landing). The UP-driven landing block never called HitGround + at all. Both now: transients → `Motion.HitGround()` → `MoveTo.HitGround()` + → THEN the DR bookkeeping gravity-clear. +3. **K-fix17 answered (user Q3):** its SetCycle DID execute — and re-set + the Falling cycle, because it read the leg-1-clobbered ForwardCommand + (0x40000015 ≠ 0, so the Ready fallback never fired). Both SetCycle + adaptation blocks deleted — superseded by the retail HitGround path, + which also plays the proper Falling→X landing LINK animation SetCycle + never did. Also fixed en route: `copy_movement_from` omitted + `current_style` (raw 0051e757 copies it first) — the landing re-apply's + style dispatch read a stale style. +**Tests:** `MotionInterpreterFunnelTests.HitGround_AfterFall_ +RedispatchesPreservedForward_ExitsFalling` (full lifecycle) + the flipped +`AirborneBody_NoCycleDispatches_OnlyTurnStop` state assertion (it had +PINNED the bug value 0x40000015 — noted in-file). Suite 3,964 green. +**Verify:** retail mover jumps in place near acdream observer → lands → +legs exit falling pose into idle WITHOUT pressing any key; jump while +running → lands into run cycle. + +## #164 — UM action-replay dispatches drop the per-action Autonomous bit + +**Status:** OPEN (2026-07-03, filed during #161) +**Finding:** retail's `move_to_interpreted_state` action loop sets the +dispatch params' Autonomous bit (0x1000) from each action's autonomy flag +(raw 305982: `var_28 ^= ((autonomous << 0xc) ^ var_28) & 0x1000`), which +flows into `InterpretedMotionState::AddAction`'s stored node. Our +`MoveToInterpretedState` action loop dispatches with `new +MovementParameters { Speed }` — Autonomous stays false. No observed +symptom today (stored-node autonomy has no current consumer); fix when +R5/R6 touches the action list. **Where:** +`MotionInterpreter.MoveToInterpretedState` action loop / +`DispatchInterpretedMotion` (doc comment marks the gap). ## #162 — Observer-side moveto cancelled by ACE's autonomous MTS reflections (the "glide" class) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 770502b6..a2533f12 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -99,7 +99,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 72 rows +## 3. Documented approximation (AP) — 73 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -179,6 +179,7 @@ accepted-divergence entries (#96, #49, #50). | AP-77 | **`apply_current_movement`'s interpreted-branch tail (`ApplyCurrentMovementInterpreted`) writes body velocity DIRECTLY via `get_state_velocity`/`set_local_velocity` when grounded, instead of dispatching through an `IInterpretedMotionSink`** — retail's real `apply_interpreted_movement` (0x00528600) drives velocity indirectly through `DoInterpretedMotion`'s animation-table backend (the SAME function the funnel's `ApplyInterpretedMovement` already uses WITH a sink); this dual-dispatch call site (`HitGround`/`LeaveGround`/`ReportExhaustion`/hold-key toggles/`SetWeenieObject`/`SetPhysicsObject`) has no sink threaded through it | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`) | Direct continuation of the pre-R3-W4 `apply_current_movement` approximation (R3-W4 plan explicitly keeps this shape rather than relocating it — "the direct grounded-velocity write MOVES to the controller-side call site unchanged"); correct for the grounded, no-animation-table-yet state acdream is in today | A MotionTable whose locomotion cycle bakes a DIFFERENT velocity than `get_state_velocity`'s constants would silently diverge from what the animation actually plays, since this path never touches the animation backend at all | `CMotionInterp::apply_interpreted_movement` 0x00528600; retire when a sink is threaded through `apply_current_movement`'s callers (R6 root motion) | | AP-79 | **P4 TargetTracker minimal adapter**: MoveToManager's setTarget/clearTarget seams store the tracked guid on RemoteMotion (remotes, R4-V4) or the `_playerMoveToTarget*` GameWindow fields (local player, R4-V5); GameWindow's per-tick block (remotes) / pre-Update feed (player) delivers `HandleUpdateTarget(Ok)` from the live entity table whenever the target moved beyond the set_target radius (and `ExitWorld` when the entity despawns) — retail's TargetManager is a full subscription system with per-target quantums and callback scheduling (R4-V4 + R4-V5, 2026-07-03) | `src/AcDream.App/Rendering/GameWindow.cs` (the tracker block in TickAnimations + the setTarget seam in EnsureRemoteMotionBindings; the player twin: `_playerMoveToTarget*` fields, the EnterPlayerModeNow setTarget seam, and the pre-Update feed in OnUpdateFrame) | The manager's deferred-start/retarget lifecycle only needs position deltas at the tracked radius; the adapter delivers exactly that from data the client already holds | Update cadence is frame-quantized rather than quantum-scheduled; a target moving sub-radius never re-fires (retail same); multi-listener semantics absent until R5's TargetManager port | `TargetManager::ReceiveUpdate` chain (r4-moveto-decomp.md); retire in R5 | | AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) | +| AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) | ## 4. Temporary stopgap (TS) — 36 rows (TS-37 is a retired-row historical note, not an active count) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 39acb9d9..1e567afc 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5403,27 +5403,30 @@ public sealed class GameWindow : IDisposable { rmState.Airborne = false; rmState.Body.Velocity = System.Numerics.Vector3.Zero; - rmState.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; rmState.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact | AcDream.Core.Physics.TransientStateFlags.OnWalkable; rmState.Interp.Clear(); rmState.Body.Position = worldPos; - // Reset the sequencer out of Falling — see matching block in - // TickAnimations Step 5 (env-var path) for rationale. + // #161: retail landing = MovementManager::HitGround + // (minterp → moveto, 0x00524300) with the Gravity state + // bit STILL SET (CMotionInterp::HitGround gates on + // state&0x400). The re-apply dispatches the PRESERVED + // pre-fall forward command → landing link → cycle. This + // replaces the forced SetCycle, which read the + // then-clobbered ForwardCommand (Falling) and re-set the + // pose it meant to clear. See the twin block in + // TickAnimations (VU.land). if (_animatedEntities.TryGetValue(entity.Id, out var aeForLand) && aeForLand.Sequencer is not null) { - uint style = aeForLand.Sequencer.CurrentStyle != 0 - ? aeForLand.Sequencer.CurrentStyle - : 0x8000003Du; - uint landingCmd = rmState.Motion.InterpretedState.ForwardCommand; - if (landingCmd == 0) - landingCmd = AcDream.Core.Physics.MotionCommand.Ready; - float landingSpeed = rmState.Motion.InterpretedState.ForwardSpeed; - if (landingSpeed <= 0f) landingSpeed = 1f; - aeForLand.Sequencer.SetCycle(style, landingCmd, landingSpeed); + EnsureRemoteMotionBindings(rmState, aeForLand, update.Guid); } + rmState.Motion.HitGround(); + rmState.MoveTo?.HitGround(); + // DR bookkeeping only (partner of the jump-start + // `State |= Gravity`). + rmState.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; return; } @@ -10045,9 +10048,22 @@ public sealed class GameWindow : IDisposable rm.Airborne = false; rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact | AcDream.Core.Physics.TransientStateFlags.OnWalkable; - rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; rm.Body.Velocity = new System.Numerics.Vector3( rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f); + // #161: HitGround MUST run with the Gravity state + // bit still set — CMotionInterp::HitGround + // (0x00528ac0) gates on state&0x400 (retail never + // clears GRAVITY on landing; it's a persistent + // object property). Clearing it first made this + // re-apply a silent no-op, which is why the + // falling pose never exited. The re-apply + // dispatches the PRESERVED pre-fall forward + // command through the funnel → the motion table + // plays the Falling→X landing link. (The old + // K-fix17 forced SetCycle is deleted: it read the + // then-clobbered InterpretedState.ForwardCommand + // — 0x40000015 — and re-set the very Falling + // cycle it meant to clear.) rm.Motion.HitGround(); // R4-V5 (closes the V4 wiring-contract gap the // adversarial review caught): retail order — @@ -10057,34 +10073,10 @@ public sealed class GameWindow : IDisposable // without it a chasing NPC that lands stalls // until ACE's ~1 Hz re-emit. rm.MoveTo?.HitGround(); - - // K-fix17 (2026-04-26): reset the sequencer cycle - // from Falling back to whatever the interpreted - // motion state says they should be doing now. - // Without this, the remote stays in the Falling - // pose forever (legs folded) until the next - // server-sent UpdateMotion arrives. Use the - // sequencer's current style (preserved across - // jump) and pick the cycle from - // InterpretedState.ForwardCommand: Ready - // (idle), WalkForward, RunForward, WalkBackward. - // SideStep / Turn aren't strict locomotion - // priorities — the next UM the server sends will - // refine the cycle if the player is mid-strafe - // when they land; this just gets the legs out - // of Falling immediately. - if (ae.Sequencer is not null) - { - uint style = ae.Sequencer.CurrentStyle != 0 - ? ae.Sequencer.CurrentStyle - : 0x8000003Du; - uint landingCmd = rm.Motion.InterpretedState.ForwardCommand; - if (landingCmd == 0) - landingCmd = AcDream.Core.Physics.MotionCommand.Ready; - float landingSpeed = rm.Motion.InterpretedState.ForwardSpeed; - if (landingSpeed <= 0f) landingSpeed = 1f; - ae.Sequencer.SetCycle(style, landingCmd, landingSpeed); - } + // DR bookkeeping only (partner of the jump-start + // `State |= Gravity`): stops the per-tick gravity + // integration for the grounded body. + rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}"); diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index 7a7c492d..8639688c 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -1749,11 +1749,14 @@ public sealed class MotionInterpreter : IMotionDoneSink bool isThePlayer = WeenieObj is null || WeenieObj.IsThePlayer(); if (isThePlayer && PhysicsObj.LastMoveWasAutonomous) { - apply_raw_movement(cancelMoveTo: false, allowJump: false); + // Raw (0, 0): arg3 is DisableJumpDuringLink → allowJump: true + // (#161 polarity decode; consumed since the apply-pass params + // became real). + apply_raw_movement(cancelMoveTo: false, allowJump: true); return; } - ApplyCurrentMovementInterpreted(cancelMoveTo: false, allowJump: false); + ApplyCurrentMovementInterpreted(cancelMoveTo: false, allowJump: true); } // ── FUN_00528920 / FUN_00528970 — SetWeenieObject / SetPhysicsObject ─────── @@ -1800,11 +1803,13 @@ public sealed class MotionInterpreter : IMotionDoneSink bool isThePlayer = weenie is null || weenie.IsThePlayer(); if (isThePlayer && PhysicsObj.LastMoveWasAutonomous) { - apply_raw_movement(cancelMoveTo: true, allowJump: false); + // Raw (1, 0): cancelMoveTo set, DisableJumpDuringLink clear → + // allowJump: true (#161 polarity decode). + apply_raw_movement(cancelMoveTo: true, allowJump: true); return; } - ApplyCurrentMovementInterpreted(cancelMoveTo: true, allowJump: false); + ApplyCurrentMovementInterpreted(cancelMoveTo: true, allowJump: true); } /// @@ -1848,11 +1853,12 @@ public sealed class MotionInterpreter : IMotionDoneSink bool isThePlayer = WeenieObj is null || WeenieObj.IsThePlayer(); if (isThePlayer && physicsObj.LastMoveWasAutonomous) { - apply_raw_movement(cancelMoveTo: true, allowJump: false); + // Raw (1, 0) → allowJump: true (#161 polarity decode). + apply_raw_movement(cancelMoveTo: true, allowJump: true); return; } - ApplyCurrentMovementInterpreted(cancelMoveTo: true, allowJump: false); + ApplyCurrentMovementInterpreted(cancelMoveTo: true, allowJump: true); } // ── FUN_00527a50 — jump_charge_is_allowed ───────────────────────────────── @@ -2506,7 +2512,8 @@ public sealed class MotionInterpreter : IMotionDoneSink JumpExtent = 0f; RemoveLinkAnimations?.Invoke(); - apply_current_movement(cancelMoveTo: false, allowJump: false); + // Raw (0, 0) → allowJump: true (#161 polarity decode). + apply_current_movement(cancelMoveTo: false, allowJump: true); } // ── FUN_00528ac0 — HitGround ────────────────────────────────────────────── @@ -2551,7 +2558,11 @@ public sealed class MotionInterpreter : IMotionDoneSink return; RemoveLinkAnimations?.Invoke(); - apply_current_movement(cancelMoveTo: false, allowJump: false); + // Raw (0, 0) → allowJump: true (#161 polarity decode). The + // re-apply dispatches the PRESERVED interpreted forward command + // (the apply pass never writes it — ModifyInterpretedState=false) + // — this IS the falling-pose exit on landing. + apply_current_movement(cancelMoveTo: false, allowJump: true); } // ── FUN_00528c80 — enter_default_state ──────────────────────────────────── @@ -2638,7 +2649,8 @@ public sealed class MotionInterpreter : IMotionDoneSink if (runKeyUp != notCurrentlyRun) { RawState.CurrentHoldKey = holdingRun ? HoldKey.Run : HoldKey.None; - apply_current_movement(cancelMoveTo: interrupt, allowJump: false); + // Raw (arg3, 0) → allowJump: true (#161 polarity decode). + apply_current_movement(cancelMoveTo: interrupt, allowJump: true); } } @@ -2694,13 +2706,14 @@ public sealed class MotionInterpreter : IMotionDoneSink if (current == HoldKey.Run) { RawState.CurrentHoldKey = HoldKey.None; - apply_current_movement(cancelMoveTo, allowJump: false); + // Raw (arg3, 0) → allowJump: true (#161 polarity decode). + apply_current_movement(cancelMoveTo, allowJump: true); } } else if (key == HoldKey.Run && current != HoldKey.Run) { RawState.CurrentHoldKey = HoldKey.Run; - apply_current_movement(cancelMoveTo, allowJump: false); + apply_current_movement(cancelMoveTo, allowJump: true); } } @@ -2815,6 +2828,11 @@ public sealed class MotionInterpreter : IMotionDoneSink bool allowJump = MotionAllowsJump(InterpretedState.ForwardCommand) == WeenieError.None; // copy_movement_from — flat overwrite, no per-field presence checks. + // current_style is the FIRST copied field (raw 0051e757) — it is + // what HitGround/LeaveGround re-applies as the style dispatch + // (#161: previously omitted, so the landing re-apply read a stale + // interpreted style). + InterpretedState.CurrentStyle = ims.CurrentStyle; InterpretedState.ForwardCommand = ims.ForwardCommand; InterpretedState.ForwardSpeed = ims.ForwardSpeed; InterpretedState.SideStepCommand = ims.SideStepCommand; @@ -2872,24 +2890,31 @@ public sealed class MotionInterpreter : IMotionDoneSink /// /// /// - /// R3-W4 (closes J11's param-plumbing leg): / - /// are retail's arg2/arg3 — - /// threaded through NOW per the plan (every real caller already has an - /// opinion: 's dual-dispatch tail - /// passes its own args through unchanged; - /// passes (true, motion_allows_jump(OLD forward_command) == 0) per - /// the Adjacent finding). Their ONLY retail consumer in this function is - /// the garbled tail expression at raw 305778 - /// ((((arg3&1)<<2)|(arg2&1))<<0xf feeding an - /// uninitialized-local byte test before conditionally calling - /// InterpretedMotionState::RemoveMotion(0x6500000d) a SECOND time) - /// — a BN x87/uninit-local artifact class, not a readable retail - /// algorithm. TODO(W5): resolve that tail against a clean decompile (or - /// cdb capture) once MovementParameters flows through this - /// funnel end to end; until then the params are accepted and preserved - /// for signature parity but do not change this function's control flow - /// (matches the funnel's existing "no MovementParameters yet" posture, - /// e.g. 's own TODO(W5)). + /// #161 (2026-07-03, closes R3-W4's TODO(W5)): retail builds ONE + /// MovementParameters for the whole pass + /// (MovementParameters::MovementParameters(&var_2c) @305719) + /// and then rewrites its bitfield: the "garbled tail expression" at raw + /// 305778 is the BN decompiler smearing that store into its single read + /// site — (word & 0x37ff) | (arg2&1)<<15 | + /// (arg3&1)<<17 clears bits 11/14/15 (SetHoldKey / + /// ModifyInterpretedState / CancelMoveTo), re-sets bit 15 + /// from arg2 (= ) and bit 17 from + /// arg3 (= DisableJumpDuringLink, so + /// = arg3 == 0). ACE + /// MotionInterp.cs:444-449 confirms independently. The load-bearing bit + /// is ModifyInterpretedState = false: NO dispatch issued by this + /// pass writes — the airborne Falling + /// substitution leaves the wire's forward command intact, which is what + /// lets 's re-apply dispatch the PRESERVED + /// pre-fall command (the motion table then plays the Falling→X landing + /// link — the falling-pose exit needs no wire input). The previous + /// revision dispatched with ctor-default params + /// (ModifyInterpretedState = true) — the #161 stuck-falling-pose + /// root cause, and the true mechanism behind the W6 "press W and stop + /// instantly" regression (the style dispatch could never have clobbered + /// forward_command in retail; the entry-cache workaround this body used + /// to carry is deleted — with no mid-pass state writes possible, live + /// field reads ARE retail's semantics). /// /// public void ApplyInterpretedMovement( @@ -2898,67 +2923,61 @@ public sealed class MotionInterpreter : IMotionDoneSink { if (PhysicsObj is null) return; - // cancelMoveTo/allowJump: accepted for signature parity (see the - // TODO(W5) above) — not yet consumed by this function's body. - _ = cancelMoveTo; - _ = allowJump; - - // ENTRY-CACHE all axis values BEFORE the style dispatch (W6 stop-bug - // fix, 2026-07-03). The style dispatch's success path runs - // InterpretedMotionState::ApplyMotion(style), whose style branch - // resets forward_command to Ready UNCONDITIONALLY (raw 0051ea6c — - // verbatim). Retail SELF-HEALS because its compiled apply pass reads - // the axis fields into registers before the style call, so the fwd - // dispatch re-applies the pre-reset command (proven by our own - // 183-case live-retail observer trace: the fwd dispatch carries the - // wire's RunForward after the style dispatch on the same UM — the - // BN pseudo-C's apparent post-style field reads at 0x528687 are - // decompiler rendering of hoisted registers, the same artifact - // class as the A1 polarity). A live-field read here dispatched - // READY after every style apply, leaving the field permanently - // Ready — the "press W and stop instantly" W6 regression. - uint entryFwdCmd = InterpretedState.ForwardCommand; - float entryFwdSpeed = InterpretedState.ForwardSpeed; - uint entrySideCmd = InterpretedState.SideStepCommand; - float entrySideSpeed = InterpretedState.SideStepSpeed; - uint entryTurnCmd = InterpretedState.TurnCommand; - float entryTurnSpeed = InterpretedState.TurnSpeed; - - if (entryFwdCmd == MotionCommand.RunForward) - MyRunRate = entryFwdSpeed; - - DispatchInterpretedMotion(currentStyle, 1.0f, sink); - - if (!contact_allows_move(entryFwdCmd)) + // Retail's rewritten var_2c (raw 305778 decoded; ACE 444-449): + // Speed is re-set per axis below, everything else rides the pass. + var p = new MovementParameters { - DispatchInterpretedMotion(MotionCommand.Falling, 1.0f, sink); + SetHoldKey = false, + ModifyInterpretedState = false, + CancelMoveTo = cancelMoveTo, + DisableJumpDuringLink = !allowJump, + }; + + if (InterpretedState.ForwardCommand == MotionCommand.RunForward) + MyRunRate = InterpretedState.ForwardSpeed; + + p.Speed = 1.0f; + DoInterpretedMotion(currentStyle, p, sink); + + if (!contact_allows_move(InterpretedState.ForwardCommand)) + { + p.Speed = 1.0f; // raw 305728: var_18_2 = 0x3f800000 + DoInterpretedMotion(MotionCommand.Falling, p, sink); } else if (StandingLongJump) { - DispatchInterpretedMotion(MotionCommand.Ready, 1.0f, sink); - DispatchStopInterpretedMotion(MotionCommand.SideStepRight, sink); + p.Speed = 1.0f; + DoInterpretedMotion(MotionCommand.Ready, p, sink); + StopInterpretedMotion(MotionCommand.SideStepRight, p, sink); } else { - DispatchInterpretedMotion(entryFwdCmd, entryFwdSpeed, sink); - if (entrySideCmd == 0) - DispatchStopInterpretedMotion(MotionCommand.SideStepRight, sink); + p.Speed = InterpretedState.ForwardSpeed; + DoInterpretedMotion(InterpretedState.ForwardCommand, p, sink); + if (InterpretedState.SideStepCommand == 0) + { + StopInterpretedMotion(MotionCommand.SideStepRight, p, sink); + } else - DispatchInterpretedMotion(entrySideCmd, entrySideSpeed, sink); + { + p.Speed = InterpretedState.SideStepSpeed; + DoInterpretedMotion(InterpretedState.SideStepCommand, p, sink); + } } - if (entryTurnCmd != 0) + if (InterpretedState.TurnCommand != 0) { - DispatchInterpretedMotion(entryTurnCmd, entryTurnSpeed, sink); + p.Speed = InterpretedState.TurnSpeed; + DoInterpretedMotion(InterpretedState.TurnCommand, p, sink); return; // retail early return — no idle-stop this call } // Tail (raw 305766-305786): unconditional StopInterpretedMotion(TurnRight, // params) — the merged StopInterpretedMotion ITSELF performs the - // add_to_queue(context, Ready, 0) + RemoveMotion(TurnRight) on - // success, so no separate AddToQueue call is needed here anymore - // (R3-W5 moves this bookkeeping into the merged function body). - DispatchStopInterpretedMotion(MotionCommand.TurnRight, sink); + // add_to_queue(context, Ready, 0) on success; with the pass params' + // ModifyInterpretedState=false the RemoveMotion(TurnRight) state + // clear does NOT fire (retail identical — ACE 497-498). + StopInterpretedMotion(MotionCommand.TurnRight, p, sink); } /// @@ -3263,33 +3282,17 @@ public sealed class MotionInterpreter : IMotionDoneSink } /// - /// R3-W5 internal per-axis dispatch primitive - /// (DispatchInterpretedMotion, formerly the S2a funnel's public - /// surface). 's four - /// DoInterpretedMotion-shaped calls (style, forward-or-Falling, - /// sidestep, turn) each need to dispatch through an EXPLICIT per-call - /// (a remote entity's own sink, threaded in from - /// ) — retail's own - /// apply_interpreted_movement calls CMotionInterp::DoInterpretedMotion - /// directly (the SAME function - /// - /// implements) with a fresh local MovementParameters per axis — - /// this wraps that shape with a throwaway params object carrying only - /// Speed (ctor default for everything else, matching retail's own - /// local var_2c). + /// R3-W5 internal dispatch primitive (formerly the S2a funnel's public + /// surface), now used ONLY by 's + /// action-replay loop — retail's action dispatches (raw 305983) use the + /// UM apply's ctor-default var_2c (notably + /// ModifyInterpretedState = true, so replayed actions DO enter the + /// interpreted actions list via InterpretedMotionState::AddAction), + /// unlike the apply pass's rewritten params (see + /// ). Known residual gap: retail + /// also sets the params' Autonomous bit (0x1000) per action (raw + /// 305982) — not yet threaded (tracked in ISSUES, #161 follow-up note). /// private WeenieError DispatchInterpretedMotion(uint motion, float speed, IInterpretedMotionSink? sink) => DoInterpretedMotion(motion, new MovementParameters { Speed = speed }, sink); - - /// - /// R3-W5 internal per-axis STOP primitive — the - /// - /// analogue of , used by - /// 's sidestep-stop/turn-stop tail - /// calls (raw 305739/305748/305770 — retail's own - /// CPhysicsObj::StopInterpretedMotion calls with a fresh local - /// MovementParameters). - /// - private WeenieError DispatchStopInterpretedMotion(uint motion, IInterpretedMotionSink? sink) - => StopInterpretedMotion(motion, new MovementParameters(), sink); } diff --git a/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs b/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs index 5cc7f54a..3198da4a 100644 --- a/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs +++ b/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs @@ -13,18 +13,19 @@ namespace AcDream.Core.Tests.Input; /// /// R3-W6 regression suite for the edge-driven local player — specifically /// the "press W and stop instantly" bug (2026-07-03): the funnel's apply -/// pass live-read InterpretedState.ForwardCommand AFTER the style -/// dispatch, whose ApplyMotion(style) state-write resets forward to -/// Ready unconditionally (raw 0051ea6c, verbatim). Retail self-heals via -/// register-cached entry reads (proven by the 183-case live observer trace: -/// the fwd dispatch carries the pre-reset command); the fix entry-caches -/// the axes in ApplyInterpretedMovement. +/// pass let its own style dispatch's ApplyMotion(style) state-write +/// reset forward to Ready (raw 0051ea6c). The original W6 fix entry-cached +/// the axis fields; #161 replaced that with the TRUE retail mechanism — +/// the apply pass's MovementParameters carry +/// ModifyInterpretedState = false (retail's smeared bitfield store +/// at raw 305778, mask 0x37ff; ACE MotionInterp.cs:447), so NO dispatch in +/// the pass can write InterpretedState at all and live field reads +/// are retail semantics. These tests pin the user-visible invariant either +/// way: pressing W keeps you moving. /// -/// The 183-case suite could NOT catch this: its RecordingSink's ApplyMotion -/// return doesn't mirror the REAL , -/// which returns TRUE for the style dispatch (manager Branch 1 -/// style==target → success) — the true return is what gates the resetting -/// state-write on. These tests bind the REAL sink over a real sequencer. +/// These tests bind the REAL over a +/// real sequencer (a fake sink's return values can mask state-write gating +/// — the lesson that created this file). /// public class W6EdgeDrivenMovementTests { @@ -115,7 +116,8 @@ public class W6EdgeDrivenMovementTests /// trigger was ApplyServerRunRate (the ACE autonomous-echo tap); /// V5's P1 gate drops that echo before it reaches the player, and the /// tap is deleted — but the regression these tests pin lives in - /// ApplyInterpretedMovement's entry-caching, which any + /// ApplyInterpretedMovement's pass-params state protection + /// (ModifyInterpretedState=false since #161), which any /// apply_current_movement pass (HitGround re-apply, future R6 per-tick /// order) still exercises. Same two statements the deleted tap ran. /// diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs index 52da4322..e4a9de98 100644 --- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs +++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs @@ -177,26 +177,80 @@ public class MotionInterpreterFunnelTests // (0x00528240 early-accept), so it reaches the sink; the style and // forward dispatches are gate-blocked (apply-only path). Assert.Equal(new[] { "DIM 40000015@1.00", "STOP 6500000d" }, sink.Calls); - // R3-W5 correction: DoInterpretedMotion's verbatim body (raw - // 305575-305631) writes InterpretedState via - // InterpretedMotionState::ApplyMotion on BOTH the success path - // (raw 305609-305610, gated on the sink's own result) AND the - // blocked/apply-only `label_528440` path (raw 305616-305618, - // UNCONDITIONAL on the ModifyInterpretedState bit — no sink - // involved at all). This W5 slice is the first port to actually - // WIRE that state-write (the pre-W5 funnel never called - // InterpretedMotionState.ApplyMotion from dispatch at all — state - // was set only by MoveToInterpretedState's flat copy at the top of - // the call), so the blocked style dispatch DOES flip - // ForwardCommand to Ready (0x41000003) at step 1, which then makes - // the Falling substitution dispatch (step 2) ITself write - // ForwardCommand = Falling (0x40000015) via the SAME mechanism — - // this replaces the assertion this comment used to make ("state - // still flat-copies") with the verbatim end state. Only - // sink.Calls (dispatch ORDER) was ever cdb-verified by this file's - // module doc; this resulting-state assertion was an untested - // assumption from the pre-W5 architecture. - Assert.Equal(0x40000015u, mi.InterpretedState.ForwardCommand); + // #161 correction (2026-07-03): the apply pass runs its dispatches + // with ModifyInterpretedState = FALSE — retail constructs var_2c + // then CLEARS bits 11/14/15 (SetHoldKey / ModifyInterpretedState / + // CancelMoveTo) and re-sets 15/17 from the args; the BN pseudo-C + // smears that bitfield store into the mush expression at raw + // 305778 (`(word & 0x37ff) | (arg2&1)<<15 | (arg3&1)<<17`). ACE + // MotionInterp.cs:444-449 confirms independently. So NEITHER the + // blocked style dispatch NOR the Falling substitution writes + // InterpretedState — the wire's forward command survives the + // airborne pass. This is the retail landing-exit mechanism: + // HitGround's re-apply dispatches the PRESERVED command, and the + // motion table plays the Falling→X landing link. (The previous + // revision of this assertion pinned 0x40000015 — the #161 bug + // itself: the ctor-default params let the Falling dispatch clobber + // forward_command, so a stand-still landing re-dispatched Falling + // forever.) + Assert.Equal(0x44000007u, mi.InterpretedState.ForwardCommand); + Assert.Equal(2.85f, mi.InterpretedState.ForwardSpeed); + } + + [Fact] + public void HitGround_AfterFall_RedispatchesPreservedForward_ExitsFalling() + { + // #161 (remote jump landing stuck in the falling pose): the full + // remote lifecycle. Wire says RunForward@2.85 while grounded; the + // body leaves the ground (LeaveGround engages Falling through the + // sink WITHOUT clobbering the interpreted forward command — the + // apply pass's ModifyInterpretedState=false, raw 305778 / ACE + // MotionInterp.cs:447); on touchdown, HitGround (0x00528ac0) — + // called with GRAVITY STILL SET, its verbatim state&0x400 gate — + // re-applies the PRESERVED command, which is what makes + // GetObjectSequence play the Falling→RunForward landing link. No + // wire input is needed to exit the falling pose. + var body = new PhysicsBody(); + body.State |= PhysicsStateFlags.Gravity; + body.TransientState |= TransientStateFlags.Contact + | TransientStateFlags.OnWalkable + | TransientStateFlags.Active; + var mi = new MotionInterpreter(body, new RemoteWeenie()); + var sink = new RecordingSink(); + mi.DefaultSink = sink; // HitGround/LeaveGround re-apply through DefaultSink + + mi.MoveToInterpretedState(Ims(fwd: 0x44000007u, fwdSpd: 2.85f), sink); + Assert.Equal(0x44000007u, mi.InterpretedState.ForwardCommand); + + // Jump start: ground contact drops FIRST (GameWindow's VectorUpdate + // handler order), then LeaveGround re-applies → Falling engages. + body.TransientState &= ~(TransientStateFlags.Contact + | TransientStateFlags.OnWalkable); + sink.Calls.Clear(); + mi.LeaveGround(); + + Assert.Contains(sink.Calls, c => c.StartsWith("DIM 40000015")); + Assert.Equal(0x44000007u, mi.InterpretedState.ForwardCommand); // NOT clobbered + Assert.Equal(2.85f, mi.InterpretedState.ForwardSpeed); + + // Touchdown: contact restored, Gravity still set (the retail + // contract — CMotionInterp::HitGround no-ops without it). + body.TransientState |= TransientStateFlags.Contact + | TransientStateFlags.OnWalkable; + sink.Calls.Clear(); + mi.HitGround(); + + // Retail re-apply order: style (from the copy_movement_from-adopted + // interpreted current_style, raw 0051e757) → preserved forward → + // sidestep-stop → turn-stop. The forward dispatch at the wire + // command IS the falling-pose exit. + Assert.Equal(new[] + { + "DIM 8000003d@1.00", + "DIM 44000007@2.85", + "STOP 6500000f", + "STOP 6500000d", + }, sink.Calls); } [Fact]