From df7b096d6f7615e7f1ae9f90ae345249edad54cb Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 3 Jul 2026 08:57:33 +0200 Subject: [PATCH] feat(R3-W5): DoMotion family verbatim + the ONE DoInterpretedMotion + per-op zero-tick flush (closes J3, J4, J9, J14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two parallel DoInterpretedMotion implementations MERGE into one verbatim pair (0x00528360/@305639): contact gate, StandingLongJump state-only branch, Dead → RemoveLinkAnimations, the jump_error_code double-check WITH the DisableJumpDuringLink 0x20000 leg (W2's TODO(W5) resolved — MovementParameters now flows), ModifyInterpretedState gating, CurCell-null tail, AddToQueue with the real ContextId; StopInterpretedMotion's post-stop Ready node + raw-mirror RemoveMotion. Legacy overloads + ApplyMotionToInterpretedState DELETED. The funnel's public surface unchanged (183-case suite compiles + passes as-is). DoMotion 0x00528d20 verbatim: cancel_moveto interrupt; SetHoldKey(key, cancelMoveToBit) BEFORE adjust_motion; params re-default for the interpreted call; combat-stance gates on the ORIGINAL id (Crouch/Sit/ Sleep → 0x3f/0x40/0x41; & 0x2000000 → 0x42 outside NonCombat); action depth cap ≥6 → 0x45; raw mirror ORIGINAL id. StopMotion 0x00528530 mirror shape. StopCompletely 0x00527e40 with the A9 snapshot quirk (motion_allows_jump on the OLD forward command, stashed in the queued node) + the J9 fix (sidestep/turn SPEEDS untouched — the 1.0 resets were a divergence). PerformMovement flushes zero-tick completions after every dispatched op via the new CheckForCompletedMotions seam (0x0050fe30) — bound App-side per entity (remotes via EnsureRemoteMotionBindings, player at the sequencer bind site). PORT DISCOVERY (not in the W0 pins): retail's DoInterpretedMotion RESULT gates both add_to_queue AND the state write — a void sink let the style dispatch's apply-only failure clobber ForwardCommand before the forward axis read it, regressing 74/183 live-trace cases. IInterpretedMotionSink.ApplyMotion/StopMotion now return bool (documented on the interface with the raw anchors); one funnel assertion corrected with raw-line citations (airborne ForwardCommand ends at Falling under the fully-wired verbatim algorithm). 62 new gate-table tests. Full suite: 3,729 passed. Implemented by a dedicated agent against the W0-pinned spec; seam bound + suite independently re-verified by the orchestrator. Co-Authored-By: Claude Fable 5 --- src/AcDream.App/Rendering/GameWindow.cs | 5 + .../Physics/InterpretedMotionFunnel.cs | 28 +- .../Physics/Motion/MotionTableDispatchSink.cs | 10 +- src/AcDream.Core/Physics/MotionInterpreter.cs | 785 +++++++++++----- .../MotionInterpreterDoMotionFamilyTests.cs | 878 ++++++++++++++++++ .../Physics/MotionInterpreterFunnelTests.cs | 43 +- .../RetailObserverTraceConformanceTests.cs | 14 +- 7 files changed, 1522 insertions(+), 241 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/MotionInterpreterDoMotionFamilyTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6ca78cb5..486892d0 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -4217,6 +4217,9 @@ public sealed class GameWindow : IDisposable rm.Motion.DefaultSink = rm.Sink; rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations; rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState(); + // R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions + // 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions). + rm.Motion.CheckForCompletedMotions = ae.Sequencer.Manager.CheckForCompletedMotions; return rm.Sink; } @@ -12992,6 +12995,8 @@ public sealed class GameWindow : IDisposable _playerController.Motion.RemoveLinkAnimations = playerSeq.RemoveAllLinkAnimations; _playerController.Motion.InitializeMotionTables = () => playerSeq.Manager.InitializeState(); + _playerController.Motion.CheckForCompletedMotions = + playerSeq.Manager.CheckForCompletedMotions; } var q = playerEntity.Rotation; diff --git a/src/AcDream.Core/Physics/InterpretedMotionFunnel.cs b/src/AcDream.Core/Physics/InterpretedMotionFunnel.cs index 8982ca7e..f276013d 100644 --- a/src/AcDream.Core/Physics/InterpretedMotionFunnel.cs +++ b/src/AcDream.Core/Physics/InterpretedMotionFunnel.cs @@ -24,14 +24,38 @@ public interface IInterpretedMotionSink /// (PerformMovement → GetObjectSequence + is_allowed decide) — /// no sink-side axis pick or fallback chain. /// - void ApplyMotion(uint motion, float speed); + /// + /// R3-W5: retail's own CPhysicsObj::DoInterpretedMotion RETURNS a + /// result (result == 0 = the motion table found a cycle for this + /// id; nonzero = MotionTableManagerError.MotionFailed/no-table) — + /// CMotionInterp::DoInterpretedMotion's caller (raw 305591-305610) + /// gates BOTH the add_to_queue call AND the + /// InterpretedMotionState::ApplyMotion state-write on that result. + /// This matters concretely for the very first dispatch of every + /// apply_interpreted_movement call — the STYLE/stance id + /// (current_style, always >= 0x80000000) has no + /// locomotion MotionData entry in the dat, so + /// CMotionTable::DoObjectMotion genuinely fails for it; if that + /// failure isn't propagated, the state-write's negative-motion branch + /// (: arg2 < 0 → + /// forward_command = 0x41000003) clobbers ForwardCommand + /// BEFORE the very next line reads it for the real forward dispatch — + /// confirmed regression against the 183-case live retail-observer trace + /// (RetailObserverTraceConformanceTests) when this was void. + /// Return true when the underlying PerformMovement call + /// succeeded (MotionTableManagerError.Success). + /// + bool ApplyMotion(uint motion, float speed); /// /// StopInterpretedMotion notification for an axis the incoming /// state cleared (sidestep 0x6500000F / turn 0x6500000D). Unconditional /// (no contact gate) per apply_interpreted_movement. /// - void StopMotion(uint motion); + /// Same result-propagation rationale as + /// — retail's CPhysicsObj::StopInterpretedMotion also returns a + /// result that gates the post-stop add_to_queue + state-removal. + bool StopMotion(uint motion); } /// diff --git a/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs b/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs index a5e7de0e..55546fa1 100644 --- a/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs +++ b/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs @@ -52,19 +52,21 @@ public sealed class MotionTableDispatchSink : IInterpretedMotionSink private static bool IsTurn(uint motion) => (motion & 0xFF000000u) == 0x65000000u && (motion & 0xFFu) is 0x0D or 0x0E; - public void ApplyMotion(uint motion, float speed) + public bool ApplyMotion(uint motion, float speed) { if (IsTurn(motion)) TurnApplied?.Invoke(motion, speed); - _sequencer.PerformMovement(MotionTableMovement.Interpreted(motion, speed)); + uint result = _sequencer.PerformMovement(MotionTableMovement.Interpreted(motion, speed)); + return result == MotionTableManagerError.Success; } - public void StopMotion(uint motion) + public bool StopMotion(uint motion) { if (IsTurn(motion)) TurnStopped?.Invoke(); - _sequencer.PerformMovement(MotionTableMovement.StopInterpreted(motion, 1f)); + uint result = _sequencer.PerformMovement(MotionTableMovement.StopInterpreted(motion, 1f)); + return result == MotionTableManagerError.Success; } } diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index 51740272..0d34a7d5 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -676,6 +676,24 @@ public sealed class MotionInterpreter : IMotionDoneSink /// public Action? InitializeMotionTables { get; set; } + /// + /// R3-W5 seam standing in for retail CPhysicsObj::CheckForCompletedMotions + /// (0x0050fe30, decomp §7d @277925) → CPartArray::CheckForCompletedMotions + /// → MotionTableManager::CheckForCompletedMotions (the SECOND call + /// site of the shared animation-completion driver, §7b) — called by + /// after EVERY dispatched op (raw + /// 306234/306241/306248/306255/306262, §2/§7d), synchronously draining + /// any already-elapsed (0-duration) animation-table node right after the + /// motion is applied, in case the newly-applied motion table entry + /// completes immediately. The App layer binds this to the entity's + /// MotionTableManager.CheckForCompletedMotions (R2-Q3 object), + /// matching the existing / + /// Action? seam convention. A null + /// binding is a silent no-op (safe default for tests and for any physics + /// body with no MotionTableManager attached yet). + /// + public Action? CheckForCompletedMotions { get; set; } + /// /// R3-W4 — retail CMotionInterp::initted (set by /// , raw @306152: this->initted = @@ -780,196 +798,288 @@ public sealed class MotionInterpreter : IMotionDoneSink /// case 5: StopCompletely() /// default: return 0x47 /// } - /// FUN_00510900() — CheckForCompletedMotions (animation flush, not simulated here) + /// FUN_0050fe30 — CPhysicsObj::CheckForCompletedMotions, called after EVERY + /// dispatched op (raw 306234/306241/306248/306255/306262, R3-W5 closes J14). /// public WeenieError PerformMovement(MovementStruct mvs) { + var p = new MovementParameters + { + Speed = mvs.Speed, + ModifyInterpretedState = mvs.ModifyInterpretedState, + ModifyRawState = mvs.ModifyRawState, + }; + + bool dispatched = true; WeenieError result = mvs.Type switch { - MovementType.RawCommand => DoMotion(mvs.Motion, mvs.Speed), - MovementType.InterpretedCommand => DoInterpretedMotion(mvs.Motion, mvs.Speed, mvs.ModifyInterpretedState), - MovementType.StopRawCommand => StopMotion(mvs.Motion), - MovementType.StopInterpretedCommand => StopInterpretedMotion(mvs.Motion, mvs.ModifyInterpretedState), + MovementType.RawCommand => DoMotion(mvs.Motion, p), + MovementType.InterpretedCommand => DoInterpretedMotion(mvs.Motion, p), + MovementType.StopRawCommand => StopMotion(mvs.Motion, p), + MovementType.StopInterpretedCommand => StopInterpretedMotion(mvs.Motion, p), MovementType.StopCompletely => StopCompletely(), - _ => WeenieError.GeneralMovementFailure, + _ => Invalid(out dispatched), }; - // FUN_00510900 — CheckForCompletedMotions is an animation system flush; - // no simulation state to update here. + + // R3-W5 (closes J14): CPhysicsObj::CheckForCompletedMotions fires + // after EVERY dispatched op — a synchronous zero-tick flush so a + // newly-applied motion-table entry that completes IMMEDIATELY + // (0-duration node) gets its MotionDone callback in the same call, + // not on the next tick. The type-dispatch failure path (bad + // MovementStruct.type, 0x47) never reaches a dispatch, so it must + // NOT flush — matches retail's raw 306227 early-return before the + // switch even begins. + if (dispatched) + CheckForCompletedMotions?.Invoke(); + return result; + + static WeenieError Invalid(out bool dispatched) + { + dispatched = false; + return WeenieError.GeneralMovementFailure; + } } // ── FUN_00529930 — DoMotion ─────────────────────────────────────────────── /// - /// Process one raw motion command from a network packet. - /// - /// Decompiled logic (FUN_00529930): - /// Copy packet fields into local variables (at local_24..local_4). - /// If the speed byte in flags is negative → call FUN_00510cc0 (cancel moveto). - /// If 0x800 flag → FUN_005297c0 (set hold key from packet). - /// FUN_00528c20 — adjust_motion (raw→interpreted adjustments). - /// Guard against special mid-animation states (returns 0x3F/0x40/0x41/0x42). - /// If Action bit (0x10000000) set and num_actions ≥ 6 → return 0x45. - /// Call DoInterpretedMotion(motion, movementParams). - /// - /// Our simplified port focuses on the state fields and physics side-effects. + /// CMotionInterp::DoMotion (0x00528d20, decomp §2 @306159, FULL + /// BODY, R3-W5, closes J3). App-facing 2-arg compat overload — + /// PlayerMovementController's call sites pass a raw + /// (uint motion, float speed) pair; this builds a + /// default-constructed (retail's own + /// UnPack/CommandInterpreter callers build one the same way for a bare + /// raw command) with overridden, + /// then delegates to the verbatim overload below. /// public WeenieError DoMotion(uint motion, float speed = 1.0f) - { - if (PhysicsObj is null) - return WeenieError.NoPhysicsObject; - - // Record the new raw forward command and speed. - // In the decompile, local_24 = *(param_3+8) = ForwardCommand, - // local_18 = ForwardSpeed, etc. - RawState.ForwardCommand = motion; - RawState.ForwardSpeed = speed; - - // Delegate to the interpreted path. DoMotion ultimately calls - // DoInterpretedMotion after adjust_motion in the retail client. - return DoInterpretedMotion(motion, speed, modifyInterpretedState: true); - } - - // ── DoInterpretedMotion ──────────────────────────────────────────────────── + => DoMotion(motion, new MovementParameters { Speed = speed }); /// - /// Core animation-state-machine entry point (FUN_00528f70). + /// CMotionInterp::DoMotion (0x00528d20, decomp §2 @306159, FULL + /// BODY, R3-W5, closes J3). /// - /// In the full retail engine this runs the animation sequencer. In this - /// physics-only port we update the InterpretedState and call - /// apply_current_movement so that the velocity is immediately reflected. + /// + /// Verbatim (raw 306159-306221): + /// + /// if (physics_obj == 0) return 8; + /// ebp = arg2; // ORIGINAL motion id, unmutated + /// snapshot every MovementParameters field onto the stack; + /// var_2c = fresh local MovementParameters(); // re-default + /// if (bitfield sign bit) interrupt_current_movement(physics_obj); + /// if (bitfield & 0x800 /*SetHoldKey*/) + /// SetHoldKey(hold_key_to_apply, (bitfield>>0xf)&1 /*cancel_moveto bit, A4*/); + /// adjust_motion(&arg2, &speed, hold_key_to_apply); // mutates arg2/speed in place + /// if (interpreted_state.current_style != 0x8000003d) { // combat/special stance + /// if (ebp == 0x41000012) return 0x3f; + /// if (ebp == 0x41000013) return 0x40; + /// if (ebp == 0x41000014) return 0x41; + /// if (ebp & 0x2000000) return 0x42; + /// } + /// if ((ebp & 0x10000000) && GetNumActions() >= 6) return 0x45; + /// result = DoInterpretedMotion(arg2 /*ADJUSTED*/, &var_2c /*fresh local params*/); + /// if (result == 0 && bitfield & 0x2000 /*ModifyRawState*/) + /// RawMotionState::ApplyMotion(ebp /*ORIGINAL*/, arg3 /*CALLER's params, not var_2c*/); + /// return result; + /// + /// + /// + /// + /// Two mirror-discipline details, both verbatim: (1) the + /// call + /// receives a FRESH local (re-defaulted, + /// only Speed/HoldKeyToApply carried through + /// adjust_motion's mutation) — the caller's own + /// distance/heading/fail-distance fields are + /// discarded for the interpreted call, matching retail's explicit + /// re-construction of var_2c. (2) the raw-state mirror at the + /// bottom reads the ORIGINAL arg3 () — NOT + /// var_2c — for / + /// /, + /// per RawMotionState::ApplyMotion's own signature + /// (const MovementParameters*) receiving the same pointer + /// DoMotion was called with. + /// /// - public WeenieError DoInterpretedMotion(uint motion, float speed = 1.0f, bool modifyInterpretedState = false) + /// Retail arg2 (the ORIGINAL, pre-adjustment + /// motion id — ebp). + /// Retail arg3. + public WeenieError DoMotion(uint motion, MovementParameters p) { if (PhysicsObj is null) return WeenieError.NoPhysicsObject; - if (!contact_allows_move(motion)) + uint originalMotion = motion; + float speed = p.Speed; + var local = new MovementParameters(); // var_2c — fresh re-default + + if (p.CancelMoveTo) // bitfield high-byte sign bit + InterruptCurrentMovement?.Invoke(); + + if (p.SetHoldKey) // bitfield & 0x800 + SetHoldKey(p.HoldKeyToApply, p.CancelMoveTo); // A4: 2nd arg IS the cancel_moveto bit + + adjust_motion(ref motion, ref speed, p.HoldKeyToApply); // mutates motion/speed in place + + if (InterpretedState.CurrentStyle != 0x8000003du) // not MotionStance_NonCombat { - // Action commands (bit 0x10000000) are blocked mid-air. A10: - // DoInterpretedMotion @305622-305623 returns 0x24 (NotGrounded) - // here, not 0x48 (that code is reserved for the - // motion_allows_jump literal-range blocklist). - if ((motion & 0x10000000u) != 0) - return WeenieError.NotGrounded; - // Non-action motions are queued silently; state still updates. + if (originalMotion == MotionCommand.Crouch) + return WeenieError.CrouchInCombatStance; // 0x3f + if (originalMotion == MotionCommand.Sitting) + return WeenieError.SitInCombatStance; // 0x40 + if (originalMotion == MotionCommand.Sleeping) + return WeenieError.SleepInCombatStance; // 0x41 + if ((originalMotion & 0x2000000u) != 0) + return WeenieError.ChatEmoteOutsideNonCombat; // 0x42 } - if (modifyInterpretedState) - ApplyMotionToInterpretedState(motion, speed); + if ((originalMotion & 0x10000000u) != 0 && InterpretedState.GetNumActions() >= 6) + return WeenieError.ActionDepthExceeded; // 0x45 - apply_current_movement(cancelMoveTo: false, allowJump: true); - return WeenieError.None; + local.Speed = speed; + local.HoldKeyToApply = p.HoldKeyToApply; + + WeenieError result = DoInterpretedMotion(motion, local); // ADJUSTED id, fresh local params + + if (result == WeenieError.None && p.ModifyRawState) // bitfield & 0x2000 + RawState.ApplyMotion(originalMotion, p); // ORIGINAL id, CALLER's params (arg3, not var_2c) + + return result; } // ── StopMotion ──────────────────────────────────────────────────────────── /// - /// Stop a specific raw motion (FUN_00529140 → StopInterpretedMotion). + /// App-facing compat overload for StopMotion (0x00528530, decomp + /// §5b @305674). Builds a default-constructed + /// and delegates to the verbatim overload below. /// public WeenieError StopMotion(uint motion) - { - if (PhysicsObj is null) - return WeenieError.NoPhysicsObject; - - if (RawState.ForwardCommand == motion) - { - RawState.ForwardCommand = MotionCommand.Ready; - RawState.ForwardSpeed = 1.0f; - } - if (RawState.SidestepCommand == motion) - { - RawState.SidestepCommand = 0; - RawState.SidestepSpeed = 1.0f; - } - if (RawState.TurnCommand == motion) - { - RawState.TurnCommand = 0; - RawState.TurnSpeed = 1.0f; - } - - return StopInterpretedMotion(motion, modifyInterpretedState: true); - } - - // ── StopInterpretedMotion ──────────────────────────────────────────────── + => StopMotion(motion, new MovementParameters()); /// - /// Stop a specific interpreted motion (FUN_00529080). - /// - public WeenieError StopInterpretedMotion(uint motion, bool modifyInterpretedState = false) - { - if (PhysicsObj is null) - return WeenieError.NoPhysicsObject; - - if (modifyInterpretedState) - { - if (InterpretedState.ForwardCommand == motion) - { - InterpretedState.ForwardCommand = MotionCommand.Ready; - InterpretedState.ForwardSpeed = 1.0f; - } - if (InterpretedState.SideStepCommand == motion) - { - InterpretedState.SideStepCommand = 0; - InterpretedState.SideStepSpeed = 1.0f; - } - if (InterpretedState.TurnCommand == motion) - { - InterpretedState.TurnCommand = 0; - InterpretedState.TurnSpeed = 1.0f; - } - } - - apply_current_movement(cancelMoveTo: false, allowJump: false); - return WeenieError.None; - } - - // ── FUN_00528a50 — StopCompletely ───────────────────────────────────────── - - /// - /// Reset both raw and interpreted states to Ready/idle, then push zero velocity. + /// CMotionInterp::StopMotion (0x00528530, decomp §5b @305674, FULL + /// BODY, R3-W5, closes J3's StopMotion leg). The DoMotion mirror: + /// same defaulting/reconstruction pattern, same optional + /// interrupt_current_movement, same adjust_motion + /// reinterpretation — but NO combat-stance or action-depth gating (those + /// are DoMotion-only). /// - /// Decompiled logic (FUN_00528a50): - /// if (PhysicsObj == null) return 8 - /// FUN_00510cc0() — cancel moveto - /// uVar1 = FUN_005285e0(InterpretedState.ForwardCommand) — motion_allows_jump - /// *(+0x20) = 0x41000003 (RawState.ForwardCommand = Ready) - /// *(+0x28) = 0x3f800000 (RawState.ForwardSpeed = 1.0f) - /// *(+0x2c) = 0 (RawState.SidestepCommand = 0) - /// *(+0x38) = 0 (RawState.TurnCommand = 0) - /// *(+0x4c) = 0x41000003 (InterpretedState.ForwardCommand = Ready) - /// *(+0x50) = 0x3f800000 (InterpretedState.ForwardSpeed = 1.0f) - /// *(+0x54) = 0 (InterpretedState.SideStepCommand = 0) - /// *(+0x5c) = 0 (InterpretedState.TurnCommand = 0) - /// FUN_0050f5a0() — StopCompletely_Internal (zero velocity on PhysicsObj) - /// FUN_00528790(…) — add_to_queue - /// if (PhysicsObj != null && CurCell == null) → FUN_005108f0 (RemoveLinkAnimations) - /// return 0 + /// + /// Verbatim (raw 305674-305706): snapshot fields, + /// build a fresh local , optional + /// interrupt on the sign bit, adjust_motion(&arg3, &speed, + /// hold_key_to_apply) (mutates the motion id in place — note retail + /// reuses the arg3 register slot for the motion id here, not a + /// separate local), delegate to + /// StopInterpretedMotion(adjusted, &local); on success AND + /// bit 0x2000 (ModifyRawState), mirror via + /// RawMotionState::RemoveMotion(ORIGINAL motion) — using the + /// ORIGINAL unmutated id, matching DoMotion's equivalent + /// ApplyMotion(ebp, arg3) mirror discipline. + /// + /// + /// Retail arg2 (the ORIGINAL motion id). + /// Retail arg3. + public WeenieError StopMotion(uint motion, MovementParameters p) + { + if (PhysicsObj is null) + return WeenieError.NoPhysicsObject; + + uint originalMotion = motion; + float speed = p.Speed; + var local = new MovementParameters(); // fresh re-default + + if (p.CancelMoveTo) + InterruptCurrentMovement?.Invoke(); + + adjust_motion(ref motion, ref speed, p.HoldKeyToApply); + + local.Speed = speed; + local.HoldKeyToApply = p.HoldKeyToApply; + + WeenieError result = StopInterpretedMotion(motion, local); // ADJUSTED id + + if (result == WeenieError.None && p.ModifyRawState) + RawState.RemoveMotion(originalMotion); // ORIGINAL id + + return result; + } + + // ── FUN_00527e40 — StopCompletely ───────────────────────────────────────── + + /// + /// CMotionInterp::StopCompletely (0x00527e40, decomp §5a @305208, + /// FULL BODY, R3-W5, closes J9). W0-pins.md A9 — ported INCLUDING the + /// jump-snapshot quirk. + /// + /// + /// Verbatim (raw 305208-305236): + /// + /// if (physics_obj == 0) return 8; + /// interrupt_current_movement(physics_obj); + /// eax_2 = motion_allows_jump(interpreted_state.forward_command); // OLD fwd, BEFORE overwrite + /// raw_state.forward_command = 0x41000003; raw_state.forward_speed = 1f; + /// raw_state.sidestep_command = 0; raw_state.turn_command = 0; + /// interpreted_state.forward_command = 0x41000003; interpreted_state.forward_speed = 1f; + /// interpreted_state.sidestep_command = 0; interpreted_state.turn_command = 0; + /// StopCompletely_Internal(physics_obj); + /// add_to_queue(0, 0x41000003, eax_2); // eax_2 = the PRE-overwrite snapshot + /// if (physics_obj != 0 && physics_obj->cell == 0) RemoveLinkAnimations(physics_obj); + /// return 0; + /// + /// + /// + /// + /// A9 quirk: motion_allows_jump is evaluated on the OLD + /// interpreted_state.forward_command BEFORE it gets overwritten + /// two lines later — the queued node's JumpErrorCode reflects the + /// PRE-stop command, not the post-stop Ready command (which would always + /// pass). J9: retail touches ONLY the forward command/speed plus + /// the sidestep/turn COMMANDS — it does NOT write sidestep_speed + /// or turn_speed on either state. The pre-R3 acdream divergence + /// (unconditional 1.0 speed resets on all four speed fields) is REMOVED + /// here. + /// + /// + /// + /// StopCompletely_Internal (0x0050f5a0, CPhysicsObj-side) stands + /// in as (Zero) — register row, + /// kept (full port is physics-layer scope, r3-port-plan.md §2 KEEP + /// LIST). CurCell proxy: .ObjCellId + /// == 0 (unseeded/detached body — matches cell == 0). + /// /// public WeenieError StopCompletely() { if (PhysicsObj is null) return WeenieError.NoPhysicsObject; - // Reset raw state + InterruptCurrentMovement?.Invoke(); + + // A9: snapshot BEFORE the overwrite below. + WeenieError jumpSnapshot = MotionAllowsJump(InterpretedState.ForwardCommand); + + // J9: forward cmd/speed + sidestep/turn COMMANDS only — speeds untouched. RawState.ForwardCommand = MotionCommand.Ready; RawState.ForwardSpeed = 1.0f; RawState.SidestepCommand = 0; - RawState.SidestepSpeed = 1.0f; RawState.TurnCommand = 0; - RawState.TurnSpeed = 1.0f; - // Reset interpreted state InterpretedState.ForwardCommand = MotionCommand.Ready; InterpretedState.ForwardSpeed = 1.0f; InterpretedState.SideStepCommand = 0; - InterpretedState.SideStepSpeed = 1.0f; InterpretedState.TurnCommand = 0; - InterpretedState.TurnSpeed = 1.0f; - // Zero the body velocity (FUN_0050f5a0 = StopCompletely_Internal) + // StopCompletely_Internal (0x0050f5a0) stand-in. PhysicsObj.set_velocity(Vector3.Zero); + AddToQueue(contextId: 0, MotionCommand.Ready, (uint)jumpSnapshot); + + if (PhysicsObj.CellPosition.ObjCellId == 0) + RemoveLinkAnimations?.Invoke(); + return WeenieError.None; } @@ -2461,42 +2571,11 @@ public sealed class MotionInterpreter : IMotionDoneSink return RunAnimSpeed * rate; } - // ── private helper ──────────────────────────────────────────────────────── - - /// - /// Apply a motion command to the interpreted state fields. - /// Mirrors the InterpretedState.ApplyMotion logic in ACE. - /// - private void ApplyMotionToInterpretedState(uint motion, float speed) - { - switch (motion) - { - case MotionCommand.WalkForward: - case MotionCommand.RunForward: - case MotionCommand.WalkBackward: - InterpretedState.ForwardCommand = motion; - InterpretedState.ForwardSpeed = speed; - break; - case MotionCommand.SideStepRight: - case MotionCommand.SideStepLeft: - InterpretedState.SideStepCommand = motion; - InterpretedState.SideStepSpeed = speed; - break; - case MotionCommand.TurnRight: - case MotionCommand.TurnLeft: - InterpretedState.TurnCommand = motion; - InterpretedState.TurnSpeed = speed; - break; - case MotionCommand.Ready: - InterpretedState.ForwardCommand = MotionCommand.Ready; - InterpretedState.ForwardSpeed = 1.0f; - InterpretedState.SideStepCommand = 0; - InterpretedState.SideStepSpeed = 1.0f; - InterpretedState.TurnCommand = 0; - InterpretedState.TurnSpeed = 1.0f; - break; - } - } + // R3-W5: the former `ApplyMotionToInterpretedState` private helper + // (a hand-rolled switch approximating InterpretedMotionState.ApplyMotion) + // is DELETED per the plan (closes J3/J4) — the merged DoInterpretedMotion + // below calls the verbatim InterpretedMotionState.ApplyMotion(motion, p) + // directly (already ported, W1). // ══ L.2g S2 — the inbound CMotionInterp funnel (DEV-1) ════════════════ // Spec: docs/research/2026-07-02-s2-inbound-funnel-pseudocode.md. @@ -2656,14 +2735,14 @@ public sealed class MotionInterpreter : IMotionDoneSink else if (StandingLongJump) { DispatchInterpretedMotion(MotionCommand.Ready, 1.0f, sink); - sink?.StopMotion(MotionCommand.SideStepRight); + DispatchStopInterpretedMotion(MotionCommand.SideStepRight, sink); } else { DispatchInterpretedMotion( InterpretedState.ForwardCommand, InterpretedState.ForwardSpeed, sink); if (InterpretedState.SideStepCommand == 0) - sink?.StopMotion(MotionCommand.SideStepRight); + DispatchStopInterpretedMotion(MotionCommand.SideStepRight, sink); else DispatchInterpretedMotion( InterpretedState.SideStepCommand, InterpretedState.SideStepSpeed, sink); @@ -2676,77 +2755,329 @@ public sealed class MotionInterpreter : IMotionDoneSink return; // retail early return — no idle-stop this call } - sink?.StopMotion(MotionCommand.TurnRight); - // add_to_queue(context, Ready, 0) — R3-W2 (closes J1). TODO(W5): - // real ContextId once MovementParameters flows through this funnel - // (retail's own local here is READ UNINITIALIZED in the decompile — - // see the final-report contextId note). - AddToQueue(contextId: 0, MotionCommand.Ready, jumpErrorCode: 0); + // 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); } /// - /// CMotionInterp::DoInterpretedMotion (0x00528360, pseudo-C - /// 305575), sink-dispatch form: motions that pass - /// contact_allows_move reach the GetObjectSequence backend - /// (); blocked - /// non-action motions take the apply-only path (state already copied, - /// NO cycle change — this is retail's real mechanism behind "airborne - /// remotes keep their Falling cycle"). Blocked action-class motions - /// (0x10000000 bit) are rejected outright (0x24). + /// CMotionInterp::DoInterpretedMotion (0x00528360, raw + /// 305575-305631, FULL BODY, R3-W5, closes J4). THE ONE + /// DoInterpretedMotion — merges the former legacy + /// (uint, float, bool) overload's state-management with the S2a + /// funnel's DispatchInterpretedMotion sink-dispatch backend. /// /// - /// R3-W2 producer (closes J1's DoInterpretedMotion leg): on the - /// success path (raw 305591-305610), retail computes the queue node's - /// jump_error_code via a DOUBLE-CHECK shape before calling - /// add_to_queue(context_id, arg2, eax_5): + /// Verbatim (raw 305577-305631): /// - /// if ((params->bitfield & 0x20000) == 0) { // NOT disable_jump_during_link - /// eax_5 = motion_allows_jump(arg2); - /// if (eax_5 == 0 && (arg2 & 0x10000000) == 0) // passed AND not action-class - /// eax_5 = motion_allows_jump(interpreted_state.forward_command); - /// } else { - /// eax_5 = 0x48; // DisableJumpDuringLink forces BLOCKED - /// } - /// add_to_queue(context_id, arg2, eax_5); + /// if (physics_obj == 0) return 8; + /// if (contact_allows_move(arg2)) { + /// if (standing_longjump != 0 + /// && (arg2 == WalkForward || arg2 == RunForward || arg2 == SideStepRight)) + /// goto label_528440; // StandingLongJump: state-only, no dispatch, no queue + /// if (arg2 == 0x40000011 /*Dead*/) RemoveLinkAnimations(physics_obj); + /// result = sink.ApplyMotion(arg2, arg3); // CPhysicsObj::DoInterpretedMotion stand-in + /// if (result == 0) { + /// if ((arg3->bitfield & 0x20000) == 0) { // NOT DisableJumpDuringLink + /// eax_5 = motion_allows_jump(arg2); + /// if (eax_5 == 0 && (arg2 & 0x10000000) == 0) + /// eax_5 = motion_allows_jump(interpreted_state.forward_command); + /// } else eax_5 = 0x48; // DisableJumpDuringLink FORCES blocked + /// add_to_queue(arg3->context_id, arg2, eax_5); + /// if (arg3->bitfield & 0x4000 /*ModifyInterpretedState*/) + /// InterpretedMotionState::ApplyMotion(arg2, arg3); + /// } + /// } else if ((arg2 & 0x10000000) == 0) { + /// label_528440: + /// if (arg3->bitfield & 0x4000) InterpretedMotionState::ApplyMotion(arg2, arg3); + /// result = 0; + /// } else result = 0x24; + /// if (physics_obj != 0 && physics_obj->cell == 0) RemoveLinkAnimations(physics_obj); + /// return result; /// - /// This funnel has no MovementParameters threaded through yet - /// ( takes only motion/ - /// speed — the params-carrying overloads land in W5 when - /// DoMotion/StopMotion/the legacy overloads merge into - /// this backend). TODO(W5): thread the real MovementParameters - /// through and restore the 0x20000 (DisableJumpDuringLink) leg; - /// until then this always takes the "bit not set" branch (the common - /// case — retail's own ctor default for the bit is CLEAR, W0-pins A4). - /// ContextId is 0 pending the same W5 plumbing (no - /// MovementParameters.ContextId reaches this call site yet) — - /// see the final-report contextId note for the evidence this funnel - /// genuinely has nothing else to pass. + /// + /// + /// + /// R3-W5 finding (not in the original W0 pins — discovered while + /// porting): 's + /// bool return IS retail's result == 0 test gating the + /// queue/state-write block. This is load-bearing, not cosmetic: the + /// VERY FIRST dispatch of every apply_interpreted_movement call + /// is the style/stance id (current_style, always + /// >= 0x80000000), which has no locomotion MotionData + /// entry in the dat — CMotionTable::DoObjectMotion genuinely + /// fails for it. If that failure isn't propagated (an earlier revision + /// of this port treated the sink as a void, always-succeeding + /// call), InterpretedMotionState::ApplyMotion's negative-motion + /// branch (arg2 < 0 → forward_command = 0x41000003) clobbers + /// BEFORE the very + /// next line reads it for the real forward dispatch — regressed 74/183 + /// cases of the live retail-observer conformance suite + /// (RetailObserverTraceConformanceTests) until the interface was + /// fixed to return the real result. + /// + /// + /// + /// Note the ACTUAL animation-table dispatch (the sink call) happens + /// through — the entity's persistent + /// IInterpretedMotionSink binding (R2-Q5's + /// MotionTableDispatchSink in production). This is the SAME sink + /// already routes through + /// for the apply_current_movement dual-dispatch tail — retail has + /// exactly one CPhysicsObj per CMotionInterp, so there is + /// only ever one dispatch target per entity, matching + /// 's single-binding shape. /// /// - public WeenieError DispatchInterpretedMotion(uint motion, float speed, IInterpretedMotionSink? sink) + /// Retail arg2. + /// Retail arg3. + public WeenieError DoInterpretedMotion(uint motion, MovementParameters p) + => DoInterpretedMotion(motion, p, DefaultSink); + + /// + /// Sink-parameterized core shared by the public + /// (which + /// always dispatches through , matching + /// retail's one-sink-per-CPhysicsObj shape) and the internal + /// + /// primitive (which needs an EXPLICIT per-call sink for + /// 's remote-entity callers). Same + /// verbatim body either way — only the dispatch target varies. + /// + private WeenieError DoInterpretedMotion(uint motion, MovementParameters p, IInterpretedMotionSink? sink) { - if (PhysicsObj is null) return WeenieError.NoPhysicsObject; + if (PhysicsObj is null) + return WeenieError.NoPhysicsObject; + + WeenieError result; if (contact_allows_move(motion)) { - // 0x40000011 (Dead) flushes queued link animations in retail - // (RemoveLinkAnimations) — S4 wires the sequencer hook. - sink?.ApplyMotion(motion, speed); + bool standingLongJumpStateOnly = StandingLongJump + && (motion == MotionCommand.WalkForward + || motion == MotionCommand.RunForward + || motion == MotionCommand.SideStepRight); - // add_to_queue(context, motion, jumpAllowed) — R3-W2 (closes J1). - // TODO(W5): params-bit 0x20000 (DisableJumpDuringLink) leg + real - // ContextId once MovementParameters flows through this funnel. - WeenieError jumpErr = MotionAllowsJump(motion); - if (jumpErr == WeenieError.None && (motion & 0x10000000u) == 0) - jumpErr = MotionAllowsJump(InterpretedState.ForwardCommand); - AddToQueue(contextId: 0, motion, (uint)jumpErr); + if (standingLongJumpStateOnly) + { + // label_528440 — state-only: no dispatch, no queue. + if (p.ModifyInterpretedState) + InterpretedState.ApplyMotion(motion, p); + return WeenieError.None; + } - return WeenieError.None; + if (motion == MotionCommand.Dead) + RemoveLinkAnimations?.Invoke(); + + // sink.ApplyMotion stands in for CPhysicsObj::DoInterpretedMotion + // — its bool return IS retail's `result == 0` (a null sink, no + // dispatch backend wired, is treated as succeeding — matches + // every call site that doesn't care about the animation-table + // backend, e.g. bare unit tests exercising queue/state behavior + // only). A FAILED dispatch (the motion table found no cycle for + // this id — genuinely the case for style/stance ids) skips BOTH + // the queue AND the state-write, exactly like the "blocked, + // non-action" apply-only path below — but WITHOUT writing state. + bool dispatchOk = sink?.ApplyMotion(motion, p.Speed) ?? true; + + if (!dispatchOk) + { + // Retail: `result = CPhysicsObj::DoInterpretedMotion(...)` is + // whatever the failed dispatch returned (raw 305591-305593; + // no `else` branch — `result` simply isn't overwritten by + // the `if (result == 0)` block), so DoInterpretedMotion's + // OWN return value on a failed sink dispatch is that + // nonzero code, NOT 0. The exact numeric value is sink- + // internal (MotionTableManagerError.MotionFailed = 0x43, + // not one of CMotionInterp's own WeenieError codes) — no + // CMotionInterp-level WeenieError constant maps to it + // 1:1, so this port surfaces the closest existing local + // analog (GeneralMovementFailure, 0x47) rather than + // inventing a new enum member for a sink-internal code + // that's never queued or otherwise observed by any + // CMotionInterp caller. Flagged as an ambiguity — no test + // in this repo asserts DoInterpretedMotion's return value + // on a failed dispatch (only the queue/state SIDE EFFECTS, + // which correctly do NOT run here). + result = WeenieError.GeneralMovementFailure; + } + else + { + WeenieError jumpErr; + if (!p.DisableJumpDuringLink) + { + jumpErr = MotionAllowsJump(motion); + if (jumpErr == WeenieError.None && (motion & 0x10000000u) == 0) + jumpErr = MotionAllowsJump(InterpretedState.ForwardCommand); + } + else + { + jumpErr = WeenieError.YouCantJumpFromThisPosition; // 0x48 — forced BLOCKED + } + + AddToQueue(p.ContextId, motion, (uint)jumpErr); + + if (p.ModifyInterpretedState) + InterpretedState.ApplyMotion(motion, p); + + result = WeenieError.None; + } + } + else if ((motion & 0x10000000u) == 0) + { + // label_528440 (reached without the StandingLongJump goto) — + // apply-only: state kept, no cycle change, no queue. This is + // retail's real mechanism behind "airborne remotes keep their + // Falling cycle". + if (p.ModifyInterpretedState) + InterpretedState.ApplyMotion(motion, p); + result = WeenieError.None; + } + else + { + result = WeenieError.NotGrounded; // 0x24 (A10) — blocked action-class motion } - if ((motion & 0x10000000u) == 0) - return WeenieError.None; // apply-only: state kept, no cycle change + if (PhysicsObj.CellPosition.ObjCellId == 0) + RemoveLinkAnimations?.Invoke(); - return WeenieError.NotGrounded; // retail 0x24 (A10) + return result; } + + /// + /// CMotionInterp::StopInterpretedMotion (0x00528470, raw + /// 305635-305670, FULL BODY, R3-W5, closes J4's StopInterpretedMotion + /// leg). THE ONE StopInterpretedMotion — merges the former legacy + /// overload with the funnel's bare sink.StopMotion call. + /// + /// + /// Verbatim (raw 305638-305670): + /// + /// if (physics_obj == 0) return 8; + /// if (contact_allows_move(arg2) == 0 + /// || (standing_longjump != 0 + /// && (arg2 == WalkForward || arg2 == RunForward || arg2 == SideStepRight))) { + /// if (arg3->bitfield & 0x4000) InterpretedMotionState::RemoveMotion(arg2); + /// result = 0; + /// } else { + /// result = sink.StopMotion(arg2, arg3); // CPhysicsObj::StopInterpretedMotion stand-in + /// if (result == 0) { + /// add_to_queue(arg3->context_id, 0x41000003, result); // result == 0 here + /// if (arg3->bitfield & 0x4000) InterpretedMotionState::RemoveMotion(arg2); + /// } + /// } + /// if (physics_obj != 0 && physics_obj->cell == 0) RemoveLinkAnimations(physics_obj); + /// return result; + /// + /// + /// + /// + /// R3-W5 (post-fix): + /// returns a real success signal (see that interface member's doc for + /// why a void sink broke the 183-case live retail conformance + /// suite on the analogous DoInterpretedMotion path) — a FAILED + /// stop dispatch skips both the add_to_queue node and the + /// InterpretedMotionState::RemoveMotion state clear, matching + /// retail's if (result == 0) { ... } gate exactly. On success, + /// the queued node's JumpErrorCode is always 0 (the stop's + /// own "return value" that becomes the queued error code is + /// result, which is 0 on that path). + /// + /// + /// Retail arg2. + /// Retail arg3. + public WeenieError StopInterpretedMotion(uint motion, MovementParameters p) + => StopInterpretedMotion(motion, p, DefaultSink); + + /// + /// Sink-parameterized core shared by the public + /// and the + /// internal primitive — same + /// split rationale as + /// . + /// + private WeenieError StopInterpretedMotion(uint motion, MovementParameters p, IInterpretedMotionSink? sink) + { + if (PhysicsObj is null) + return WeenieError.NoPhysicsObject; + + WeenieError result; + + bool standingLongJumpStateOnly = StandingLongJump + && (motion == MotionCommand.WalkForward + || motion == MotionCommand.RunForward + || motion == MotionCommand.SideStepRight); + + if (!contact_allows_move(motion) || standingLongJumpStateOnly) + { + if (p.ModifyInterpretedState) + InterpretedState.RemoveMotion(motion); + result = WeenieError.None; + } + else + { + // sink.StopMotion stands in for CPhysicsObj::StopInterpretedMotion + // — its bool return IS retail's `result == 0`. A null sink (no + // dispatch backend wired) is treated as succeeding, matching + // DoInterpretedMotion's identical posture. + bool dispatchOk = sink?.StopMotion(motion) ?? true; + + if (!dispatchOk) + { + // See DoInterpretedMotion's identical ambiguity note: retail + // returns the sink's own nonzero code here (raw 305652-305653, + // no `else` — `result` keeps whatever CPhysicsObj::StopInterpretedMotion + // returned); ported as the closest local analog. + result = WeenieError.GeneralMovementFailure; + } + else + { + result = WeenieError.None; + + AddToQueue(p.ContextId, MotionCommand.Ready, (uint)result); + + if (p.ModifyInterpretedState) + InterpretedState.RemoveMotion(motion); + } + } + + if (PhysicsObj.CellPosition.ObjCellId == 0) + RemoveLinkAnimations?.Invoke(); + + return result; + } + + /// + /// 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). + /// + 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/Physics/MotionInterpreterDoMotionFamilyTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterDoMotionFamilyTests.cs new file mode 100644 index 00000000..f767a887 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterDoMotionFamilyTests.cs @@ -0,0 +1,878 @@ +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +// ───────────────────────────────────────────────────────────────────────────── +// MotionInterpreterDoMotionFamilyTests — R3-W5 (closes J3, J4, J9, J14). +// +// Oracle: docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md +// §2 DoMotion 0x00528d20 @306159 +// §5a StopCompletely 0x00527e40 @305208 +// §5b StopMotion 0x00528530 @305674 +// §1a add_to_queue 0x00527b80 @305032 +// docs/research/2026-07-02-r3-motioninterp/W0-pins.md A4 (MovementParameters +// bit table), A9 (StopCompletely jump-snapshot quirk), A10 (combat-stance / +// depth-cap error codes). +// ───────────────────────────────────────────────────────────────────────────── + +/// Fake WeenieObject — IsThePlayer/IsCreature/CanJump independently +/// configurable, matching the ground-lifecycle test file's convention. +file sealed class FakeWeenie : IWeenieObject +{ + public bool IsThePlayerResult; + public bool IsCreatureResult = true; + public bool CanJumpResult = true; + + public bool InqJumpVelocity(float extent, out float vz) { vz = 10f; return true; } + public bool InqRunRate(out float rate) { rate = 1f; return true; } + public bool CanJump(float extent) => CanJumpResult; + public bool IsThePlayer() => IsThePlayerResult; + public bool IsCreature() => IsCreatureResult; +} + +public sealed class MotionInterpreterDoMotionFamilyTests +{ + // ── helpers ─────────────────────────────────────────────────────────────── + + private static PhysicsBody MakeGrounded() + { + var body = new PhysicsBody + { + State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, + }; + body.TransientState = TransientStateFlags.Contact + | TransientStateFlags.OnWalkable + | TransientStateFlags.Active; + return body; + } + + private static MotionInterpreter MakeInterp(PhysicsBody? body = null, IWeenieObject? weenie = null) + { + body ??= MakeGrounded(); + return new MotionInterpreter(body, weenie); + } + + // ========================================================================= + // DoMotion — 0x00528d20 @306159 + // ========================================================================= + + [Fact] + public void DoMotion_NullPhysicsObj_Returns8() + { + var interp = new MotionInterpreter(); + + var result = interp.DoMotion(MotionCommand.WalkForward, new MovementParameters()); + + Assert.Equal(WeenieError.NoPhysicsObject, result); + } + + [Fact] + public void DoMotion_CancelMoveToBit_FiresInterruptSeam() + { + var interp = MakeInterp(); + bool fired = false; + interp.InterruptCurrentMovement = () => fired = true; + var p = new MovementParameters { CancelMoveTo = true }; + + interp.DoMotion(MotionCommand.WalkForward, p); + + Assert.True(fired, "the sign-bit (CancelMoveTo) must fire InterruptCurrentMovement before adjust_motion"); + } + + [Fact] + public void DoMotion_CancelMoveToClear_DoesNotFireInterruptSeam() + { + var interp = MakeInterp(); + bool fired = false; + interp.InterruptCurrentMovement = () => fired = true; + var p = new MovementParameters { CancelMoveTo = false }; + + interp.DoMotion(MotionCommand.WalkForward, p); + + Assert.False(fired); + } + + [Fact] + public void DoMotion_SetHoldKeyBit_FiresSetHoldKey_WithCancelMoveToBitAsSecondArg() + { + // A4: @306188 SetHoldKey(hold_key_to_apply, ((__inner0_1 >> 0xf) & 1)) — + // the SECOND arg is the cancel_moveto (sign) bit, not a constant. + var interp = MakeInterp(); + var p = new MovementParameters + { + SetHoldKey = true, + CancelMoveTo = true, + HoldKeyToApply = HoldKey.Run, + }; + + interp.DoMotion(MotionCommand.WalkForward, p); + + Assert.Equal(HoldKey.Run, interp.RawState.CurrentHoldKey); + } + + [Fact] + public void DoMotion_SetHoldKeyBit_FiresBeforeAdjustMotion() + { + // SetHoldKey must land BEFORE adjust_motion runs so the new hold-key + // affects the walk/run reinterpretation in THIS same call (Run + // promotion of WalkForward). + var interp = MakeInterp(); + var p = new MovementParameters + { + SetHoldKey = true, + HoldKeyToApply = HoldKey.Run, + }; + + interp.DoMotion(MotionCommand.WalkForward, p); + + // adjust_motion promotes WalkForward -> RunForward only when the + // hold key is (already, as of THIS call) Run. + Assert.Equal(MotionCommand.RunForward, interp.InterpretedState.ForwardCommand); + } + + [Fact] + public void DoMotion_SetHoldKeyBitClear_DoesNotChangeHoldKey() + { + var interp = MakeInterp(); + interp.RawState.CurrentHoldKey = HoldKey.None; + var p = new MovementParameters + { + SetHoldKey = false, + HoldKeyToApply = HoldKey.Run, + }; + + interp.DoMotion(MotionCommand.WalkForward, p); + + Assert.Equal(HoldKey.None, interp.RawState.CurrentHoldKey); + } + + [Fact] + public void DoMotion_ParamsReDefaulted_CallerDistanceHeadingDiscarded() + { + // DoMotion re-derives a canonical local MovementParameters for the + // DoInterpretedMotion call — caller-supplied distance/heading fields + // must not leak into anything the interpreted call reads (the + // params ARE discarded per the decomp; this test pins that a + // caller passing bizarre distance/heading values doesn't change + // dispatch behavior vs the defaults). + var interp1 = MakeInterp(); + var interp2 = MakeInterp(); + var weird = new MovementParameters { DistanceToObject = 999f, DesiredHeading = 12345f, FailDistance = 1f }; + var plain = new MovementParameters(); + + var r1 = interp1.DoMotion(MotionCommand.WalkForward, weird); + var r2 = interp2.DoMotion(MotionCommand.WalkForward, plain); + + Assert.Equal(r2, r1); + Assert.Equal(interp2.InterpretedState.ForwardCommand, interp1.InterpretedState.ForwardCommand); + Assert.Equal(interp2.InterpretedState.ForwardSpeed, interp1.InterpretedState.ForwardSpeed); + } + + // ── combat-stance gate (A10: 0x3f/0x40/0x41/0x42) ────────────────────── + + [Theory] + [InlineData(MotionCommand.Crouch, WeenieError.CrouchInCombatStance)] + [InlineData(MotionCommand.Sitting, WeenieError.SitInCombatStance)] + [InlineData(MotionCommand.Sleeping, WeenieError.SleepInCombatStance)] + public void DoMotion_JumpChargeMotion_RejectedInCombatStance(uint motion, WeenieError expected) + { + var interp = MakeInterp(); + interp.InterpretedState.CurrentStyle = 0x80000001u; // any non-NonCombat stance + + var result = interp.DoMotion(motion, new MovementParameters()); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(MotionCommand.Crouch)] + [InlineData(MotionCommand.Sitting)] + [InlineData(MotionCommand.Sleeping)] + public void DoMotion_JumpChargeMotion_AllowedInNonCombatStance(uint motion) + { + var interp = MakeInterp(); + interp.InterpretedState.CurrentStyle = 0x8000003Du; // NonCombat + + var result = interp.DoMotion(motion, new MovementParameters()); + + Assert.NotEqual(WeenieError.CrouchInCombatStance, result); + Assert.NotEqual(WeenieError.SitInCombatStance, result); + Assert.NotEqual(WeenieError.SleepInCombatStance, result); + } + + [Fact] + public void DoMotion_ChatEmoteBit_RejectedInCombatStance() + { + // motion & 0x2000000 outside NonCombat -> 0x42. + var interp = MakeInterp(); + interp.InterpretedState.CurrentStyle = 0x80000001u; + uint motion = 0x10000000u | 0x2000000u; // action-class + chat-emote bit + + var result = interp.DoMotion(motion, new MovementParameters()); + + Assert.Equal(WeenieError.ChatEmoteOutsideNonCombat, result); + } + + [Fact] + public void DoMotion_ChatEmoteBit_AllowedInNonCombatStance() + { + var interp = MakeInterp(); + interp.InterpretedState.CurrentStyle = 0x8000003Du; // NonCombat + uint motion = 0x10000000u | 0x2000000u; + + var result = interp.DoMotion(motion, new MovementParameters()); + + Assert.NotEqual(WeenieError.ChatEmoteOutsideNonCombat, result); + } + + [Fact] + public void DoMotion_NonJumpChargeMotion_NotGatedByCombatStance() + { + // WalkForward (no 0x2000000 bit, not a jump-charge id) must pass + // through the combat-stance gate regardless of stance. + var interp = MakeInterp(); + interp.InterpretedState.CurrentStyle = 0x80000001u; // combat stance + + var result = interp.DoMotion(MotionCommand.WalkForward, new MovementParameters()); + + Assert.Equal(WeenieError.None, result); + } + + // ── action-depth gate (A10: 0x45, GetNumActions >= 6) ────────────────── + + [Fact] + public void DoMotion_ActionClassMotion_DepthBelowSix_Allowed() + { + var interp = MakeInterp(); + for (int i = 0; i < 5; i++) + interp.InterpretedState.AddAction(0x10000050u, 1f, (uint)i, false); + + uint actionMotion = 0x10000060u; // action-class bit set, not itself a jump-charge id + var result = interp.DoMotion(actionMotion, new MovementParameters()); + + Assert.NotEqual(WeenieError.ActionDepthExceeded, result); + } + + [Fact] + public void DoMotion_ActionClassMotion_DepthExactlySix_Rejected() + { + var interp = MakeInterp(); + for (int i = 0; i < 6; i++) + interp.InterpretedState.AddAction(0x10000050u, 1f, (uint)i, false); + + uint actionMotion = 0x10000060u; + var result = interp.DoMotion(actionMotion, new MovementParameters()); + + Assert.Equal(WeenieError.ActionDepthExceeded, result); + } + + [Fact] + public void DoMotion_ActionClassMotion_DepthFive_NotRejected() + { + var interp = MakeInterp(); + for (int i = 0; i < 5; i++) + interp.InterpretedState.AddAction(0x10000050u, 1f, (uint)i, false); + + uint actionMotion = 0x10000060u; + var result = interp.DoMotion(actionMotion, new MovementParameters()); + + Assert.NotEqual(WeenieError.ActionDepthExceeded, result); + } + + [Fact] + public void DoMotion_NonActionClassMotion_IgnoresDepthCap() + { + // Bit 0x10000000 clear -> the depth cap never applies, even with 6+ + // queued actions. + var interp = MakeInterp(); + for (int i = 0; i < 10; i++) + interp.InterpretedState.AddAction(0x10000050u, 1f, (uint)i, false); + + var result = interp.DoMotion(MotionCommand.WalkForward, new MovementParameters()); + + Assert.NotEqual(WeenieError.ActionDepthExceeded, result); + } + + // ── raw mirror discipline: raw gets ORIGINAL id, interpreted gets ADJUSTED ── + + [Fact] + public void DoMotion_ModifyRawStateBit_MirrorsOriginalId_NotAdjusted() + { + // WalkBackward (ORIGINAL) adjusts to WalkForward w/ negated speed + // (interpreted) but RawState.ApplyMotion must be called with the + // ORIGINAL WalkBackward id (ebp), not the adjusted WalkForward id. + var interp = MakeInterp(); + var p = new MovementParameters { ModifyRawState = true, Speed = 1.0f }; + + interp.DoMotion(MotionCommand.WalkBackward, p); + + Assert.Equal(MotionCommand.WalkBackward, interp.RawState.ForwardCommand); + // Interpreted state, meanwhile, adopted the ADJUSTED (WalkForward) id. + Assert.Equal(MotionCommand.WalkForward, interp.InterpretedState.ForwardCommand); + } + + [Fact] + public void DoMotion_ModifyRawStateBitClear_DoesNotMirror() + { + var interp = MakeInterp(); + var p = new MovementParameters { ModifyRawState = false }; + + interp.DoMotion(MotionCommand.WalkForward, p); + + // RawState.ForwardCommand must remain at its ctor default (Ready) — + // no mirror happened. + Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand); + } + + [Fact] + public void DoMotion_FailedDispatch_DoesNotMirrorToRawState() + { + // result == 0 is required for the mirror per @306184 ("if (result == + // 0 && bit0x2000)"). A combat-stance rejection must not mirror. + var interp = MakeInterp(); + interp.InterpretedState.CurrentStyle = 0x80000001u; + var p = new MovementParameters { ModifyRawState = true }; + + var result = interp.DoMotion(MotionCommand.Crouch, p); + + Assert.Equal(WeenieError.CrouchInCombatStance, result); + Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand); // untouched + } + + [Fact] + public void DoMotion_2Arg_AppOverload_StillCompiles_AndDispatches() + { + // App-facing compat overload: DoMotion(uint, float speed = 1.0f). + var interp = MakeInterp(); + + var result = interp.DoMotion(MotionCommand.WalkForward, 0.8f); + + Assert.Equal(WeenieError.None, result); + Assert.Equal(MotionCommand.WalkForward, interp.InterpretedState.ForwardCommand); + } + + // ========================================================================= + // StopMotion — 0x00528530 @305674 (mirror shape, no gates) + // ========================================================================= + + [Fact] + public void StopMotion_NullPhysicsObj_Returns8() + { + var interp = new MotionInterpreter(); + + var result = interp.StopMotion(MotionCommand.WalkForward, new MovementParameters()); + + Assert.Equal(WeenieError.NoPhysicsObject, result); + } + + [Fact] + public void StopMotion_NoCombatStanceGate_CrouchStopsEvenInCombatStance() + { + // Unlike DoMotion, StopMotion has NO combat-stance/depth gating. + var interp = MakeInterp(); + interp.InterpretedState.CurrentStyle = 0x80000001u; + interp.InterpretedState.ForwardCommand = MotionCommand.Crouch; + + var result = interp.StopMotion(MotionCommand.Crouch, new MovementParameters()); + + Assert.Equal(WeenieError.None, result); + } + + [Fact] + public void StopMotion_ModifyRawStateBit_RemovesOriginalId_NotAdjusted() + { + var interp = MakeInterp(); + interp.RawState.ForwardCommand = MotionCommand.WalkBackward; + interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward; + var p = new MovementParameters { ModifyRawState = true }; + + interp.StopMotion(MotionCommand.WalkBackward, p); + + // RemoveMotion(ORIGINAL WalkBackward) hits RawState.ForwardCommand + // via the forward-class branch (adjusted WalkForward would also + // match here in this simple case, but the ORIGINAL id is what must + // be passed — see the mismatch test below for a case that + // distinguishes them). + Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand); + } + + [Fact] + public void StopMotion_ModifyRawStateBitClear_DoesNotMirror() + { + var interp = MakeInterp(); + interp.RawState.ForwardCommand = MotionCommand.WalkForward; + var p = new MovementParameters { ModifyRawState = false }; + + interp.StopMotion(MotionCommand.WalkForward, p); + + Assert.Equal(MotionCommand.WalkForward, interp.RawState.ForwardCommand); // untouched + } + + [Fact] + public void StopMotion_2Arg_AppOverload_StillCompiles() + { + var interp = MakeInterp(); + interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward; + + var result = interp.StopMotion(MotionCommand.WalkForward); + + Assert.Equal(WeenieError.None, result); + Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand); + } + + // ========================================================================= + // StopCompletely — 0x00527e40 @305208 (A9 verbatim quirk) + // ========================================================================= + + [Fact] + public void StopCompletely_NullPhysicsObj_Returns8() + { + var interp = new MotionInterpreter(); + + var result = interp.StopCompletely(); + + Assert.Equal(WeenieError.NoPhysicsObject, result); + } + + [Fact] + public void StopCompletely_ZeroesForwardSidestepTurnCommands_OnBothStates() + { + var interp = MakeInterp(); + interp.RawState.ForwardCommand = MotionCommand.RunForward; + interp.RawState.SidestepCommand = MotionCommand.SideStepRight; + interp.RawState.TurnCommand = MotionCommand.TurnRight; + interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; + interp.InterpretedState.SideStepCommand = MotionCommand.SideStepRight; + interp.InterpretedState.TurnCommand = MotionCommand.TurnRight; + + interp.StopCompletely(); + + Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand); + Assert.Equal(1.0f, interp.RawState.ForwardSpeed); + Assert.Equal(0u, interp.RawState.SidestepCommand); + Assert.Equal(0u, interp.RawState.TurnCommand); + Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand); + Assert.Equal(1.0f, interp.InterpretedState.ForwardSpeed); + Assert.Equal(0u, interp.InterpretedState.SideStepCommand); + Assert.Equal(0u, interp.InterpretedState.TurnCommand); + } + + [Fact] + public void StopCompletely_DoesNotTouchSidestepOrTurnSpeeds_J9() + { + // A9/J9: retail touches ONLY forward cmd/speed + sidestep/turn + // COMMANDS — it does NOT write sidestep_speed or turn_speed. The + // pre-R3 acdream divergence (1.0 speed resets) must be gone. + var interp = MakeInterp(); + interp.RawState.SidestepSpeed = 2.5f; + interp.RawState.TurnSpeed = 3.5f; + interp.InterpretedState.SideStepSpeed = 4.5f; + interp.InterpretedState.TurnSpeed = 5.5f; + + interp.StopCompletely(); + + Assert.Equal(2.5f, interp.RawState.SidestepSpeed); + Assert.Equal(3.5f, interp.RawState.TurnSpeed); + Assert.Equal(4.5f, interp.InterpretedState.SideStepSpeed); + Assert.Equal(5.5f, interp.InterpretedState.TurnSpeed); + } + + [Fact] + public void StopCompletely_ZerosPhysicsBodyVelocity() + { + var body = MakeGrounded(); + body.set_velocity(new Vector3(5f, 3f, 0f)); + var interp = MakeInterp(body); + + interp.StopCompletely(); + + Assert.Equal(Vector3.Zero, body.Velocity); + } + + [Fact] + public void StopCompletely_InterruptsCurrentMovement() + { + var interp = MakeInterp(); + bool fired = false; + interp.InterruptCurrentMovement = () => fired = true; + + interp.StopCompletely(); + + Assert.True(fired); + } + + [Fact] + public void StopCompletely_QueuedNodeErrorCode_ReflectsPreStopForwardCommand() + { + // A9 golden: motion_allows_jump is evaluated on the OLD (pre-stop) + // interpreted forward_command, snapshotted BEFORE the overwrite, and + // that snapshot becomes the queued node's jump_error_code. Crouch + // (0x41000012) is in the motion_allows_jump BLOCKED range -> 0x48. + var interp = MakeInterp(); + interp.InterpretedState.ForwardCommand = MotionCommand.Crouch; + + interp.StopCompletely(); + + var node = Assert.Single(interp.PendingMotions); + Assert.Equal(0u, node.ContextId); + Assert.Equal(MotionCommand.Ready, node.Motion); + Assert.Equal((uint)WeenieError.YouCantJumpFromThisPosition, node.JumpErrorCode); + } + + [Fact] + public void StopCompletely_QueuedNodeErrorCode_ZeroWhenPreStopCommandAllowsJump() + { + var interp = MakeInterp(); + interp.InterpretedState.ForwardCommand = MotionCommand.Ready; // passes motion_allows_jump + + interp.StopCompletely(); + + var node = Assert.Single(interp.PendingMotions); + Assert.Equal(0u, node.JumpErrorCode); + } + + [Fact] + public void StopCompletely_CellNull_FiresRemoveLinkAnimations() + { + // CurCell proxy: PhysicsObj.CellPosition.ObjCellId == 0 (default/unseeded). + var interp = MakeInterp(); + bool fired = false; + interp.RemoveLinkAnimations = () => fired = true; + + interp.StopCompletely(); + + Assert.True(fired, "physics_obj->cell == 0 must fire RemoveLinkAnimations"); + } + + [Fact] + public void StopCompletely_CellNonNull_DoesNotFireRemoveLinkAnimations() + { + var body = MakeGrounded(); + body.SnapToCell(0x12340001u, Vector3.Zero, Vector3.Zero); + var interp = MakeInterp(body); + bool fired = false; + interp.RemoveLinkAnimations = () => fired = true; + + interp.StopCompletely(); + + Assert.False(fired); + } + + [Fact] + public void StopCompletely_AlwaysReturnsNone_OnceCallExecutes() + { + var interp = MakeInterp(); + interp.InterpretedState.ForwardCommand = MotionCommand.Crouch; // would fail motion_allows_jump + + var result = interp.StopCompletely(); + + Assert.Equal(WeenieError.None, result); // StopCompletely itself always succeeds + } + + // ========================================================================= + // StandingLongJump state-only branch: Walk/Run/SideStepRight while + // charging -> state write, NO dispatch, NO queue node. + // ========================================================================= + + private sealed class RecordingSink : IInterpretedMotionSink + { + public readonly List Calls = new(); + public bool ApplyMotion(uint motion, float speed) + { + Calls.Add($"APPLY {motion:x8}@{speed:F2}"); + return true; + } + public bool StopMotion(uint motion) + { + Calls.Add($"STOP {motion:x8}"); + return true; + } + } + + [Fact] + public void DoInterpretedMotion_StandingLongJumpCharging_WalkForward_StateOnly_NoDispatch_NoQueue() + { + var interp = MakeInterp(); + interp.StandingLongJump = true; + interp.DefaultSink = null; // no App-level sink wired directly on DoInterpretedMotion path + + var before = interp.PendingMotions.Count(); + var result = interp.DoInterpretedMotion(MotionCommand.WalkForward, new MovementParameters()); + + Assert.Equal(WeenieError.None, result); + Assert.Equal(MotionCommand.WalkForward, interp.InterpretedState.ForwardCommand); + Assert.Equal(before, interp.PendingMotions.Count()); + } + + [Fact] + public void DoInterpretedMotion_StandingLongJumpCharging_RunForward_StateOnly_NoDispatch_NoQueue() + { + var interp = MakeInterp(); + interp.StandingLongJump = true; + + var before = interp.PendingMotions.Count(); + var result = interp.DoInterpretedMotion(MotionCommand.RunForward, new MovementParameters()); + + Assert.Equal(WeenieError.None, result); + Assert.Equal(MotionCommand.RunForward, interp.InterpretedState.ForwardCommand); + Assert.Equal(before, interp.PendingMotions.Count()); + } + + [Fact] + public void DoInterpretedMotion_StandingLongJumpCharging_SideStepRight_StateOnly_NoDispatch_NoQueue() + { + var interp = MakeInterp(); + interp.StandingLongJump = true; + + var before = interp.PendingMotions.Count(); + var result = interp.DoInterpretedMotion(MotionCommand.SideStepRight, new MovementParameters()); + + Assert.Equal(WeenieError.None, result); + Assert.Equal(MotionCommand.SideStepRight, interp.InterpretedState.SideStepCommand); + Assert.Equal(before, interp.PendingMotions.Count()); + } + + [Fact] + public void DoInterpretedMotion_StandingLongJumpCharging_OtherMotion_DispatchesNormally() + { + // Only Walk/Run/SideStepRight get the state-only shortcut while + // charging — everything else dispatches through the normal path + // (including queueing). + var interp = MakeInterp(); + interp.StandingLongJump = true; + + var result = interp.DoInterpretedMotion(MotionCommand.TurnRight, new MovementParameters()); + + Assert.Equal(WeenieError.None, result); + Assert.Single(interp.PendingMotions); + } + + [Fact] + public void DoInterpretedMotion_NotCharging_WalkForward_DispatchesNormally_AndQueues() + { + var interp = MakeInterp(); + interp.StandingLongJump = false; + + var result = interp.DoInterpretedMotion(MotionCommand.WalkForward, new MovementParameters()); + + Assert.Equal(WeenieError.None, result); + Assert.Single(interp.PendingMotions); + } + + // ========================================================================= + // DisableJumpDuringLink forces the queued node's jump_error_code to 0x48 + // ========================================================================= + + [Fact] + public void DoInterpretedMotion_DisableJumpDuringLink_ForcesQueuedNodeTo0x48() + { + var interp = MakeInterp(); + interp.InterpretedState.ForwardCommand = MotionCommand.Ready; // would otherwise PASS motion_allows_jump + var p = new MovementParameters { DisableJumpDuringLink = true }; + + interp.DoInterpretedMotion(MotionCommand.WalkForward, p); + + var node = Assert.Single(interp.PendingMotions); + Assert.Equal((uint)WeenieError.YouCantJumpFromThisPosition, node.JumpErrorCode); + } + + [Fact] + public void DoInterpretedMotion_DisableJumpDuringLinkClear_UsesComputedErrorCode() + { + var interp = MakeInterp(); + interp.InterpretedState.ForwardCommand = MotionCommand.Ready; + var p = new MovementParameters { DisableJumpDuringLink = false }; + + interp.DoInterpretedMotion(MotionCommand.WalkForward, p); + + var node = Assert.Single(interp.PendingMotions); + Assert.Equal(0u, node.JumpErrorCode); // WalkForward + Ready both pass motion_allows_jump + } + + // ── Dead -> RemoveLinkAnimations ─────────────────────────────────────── + + [Fact] + public void DoInterpretedMotion_Dead_FiresRemoveLinkAnimations() + { + var interp = MakeInterp(); + bool fired = false; + interp.RemoveLinkAnimations = () => fired = true; + + interp.DoInterpretedMotion(MotionCommand.Dead, new MovementParameters()); + + Assert.True(fired); + } + + // ── ModifyInterpretedState param gates the state write ───────────────── + + [Fact] + public void DoInterpretedMotion_ModifyInterpretedStateClear_DoesNotWriteState() + { + var interp = MakeInterp(); + var p = new MovementParameters { ModifyInterpretedState = false }; + + interp.DoInterpretedMotion(MotionCommand.WalkForward, p); + + Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand); // untouched + } + + [Fact] + public void DoInterpretedMotion_ModifyInterpretedStateSet_WritesState() + { + var interp = MakeInterp(); + var p = new MovementParameters { ModifyInterpretedState = true, Speed = 1.5f }; + + interp.DoInterpretedMotion(MotionCommand.WalkForward, p); + + Assert.Equal(MotionCommand.WalkForward, interp.InterpretedState.ForwardCommand); + } + + // ── CurCell-null tail (proxy: CellPosition.ObjCellId == 0) ────────────── + // NOTE: this is the DoInterpretedMotion success-path CurCell check per + // the plan; the merged function fires RemoveLinkAnimations whenever the + // physics_obj has no cell, mirroring StopCompletely's identical tail. + + [Fact] + public void DoInterpretedMotion_ContactBlocked_ActionClass_Returns0x24() + { + var body = new PhysicsBody { State = PhysicsStateFlags.Gravity }; // airborne, no contact + var interp = MakeInterp(body); + + var result = interp.DoInterpretedMotion(0x10000060u, new MovementParameters()); + + Assert.Equal(WeenieError.NotGrounded, result); + } + + // ========================================================================= + // StopInterpretedMotion — merged verbatim pair + // ========================================================================= + + [Fact] + public void StopInterpretedMotion_NullPhysicsObj_Returns8() + { + var interp = new MotionInterpreter(); + + var result = interp.StopInterpretedMotion(MotionCommand.WalkForward, new MovementParameters()); + + Assert.Equal(WeenieError.NoPhysicsObject, result); + } + + [Fact] + public void StopInterpretedMotion_Success_EnqueuesReturnToNoneNode() + { + var interp = MakeInterp(); + interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward; + var p = new MovementParameters { ContextId = 42 }; + + interp.StopInterpretedMotion(MotionCommand.WalkForward, p); + + var node = Assert.Single(interp.PendingMotions); + Assert.Equal(42u, node.ContextId); + Assert.Equal(MotionCommand.Ready, node.Motion); + } + + [Fact] + public void StopInterpretedMotion_ModifyInterpretedStateClear_DoesNotWriteState() + { + var interp = MakeInterp(); + interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward; + var p = new MovementParameters { ModifyInterpretedState = false }; + + interp.StopInterpretedMotion(MotionCommand.WalkForward, p); + + Assert.Equal(MotionCommand.WalkForward, interp.InterpretedState.ForwardCommand); // untouched + } + + // ========================================================================= + // PerformMovement — zero-tick CheckForCompletedMotions flush (closes J14) + // ========================================================================= + + [Fact] + public void PerformMovement_RawCommand_FiresCheckForCompletedMotions() + { + var interp = MakeInterp(); + int flushCount = 0; + interp.CheckForCompletedMotions = () => flushCount++; + + interp.PerformMovement(new MovementStruct + { + Type = MovementType.RawCommand, + Motion = MotionCommand.WalkForward, + Speed = 1.0f, + }); + + Assert.Equal(1, flushCount); + } + + [Theory] + [InlineData(MovementType.RawCommand)] + [InlineData(MovementType.InterpretedCommand)] + [InlineData(MovementType.StopRawCommand)] + [InlineData(MovementType.StopInterpretedCommand)] + [InlineData(MovementType.StopCompletely)] + public void PerformMovement_EveryDispatchedOp_FiresFlushExactlyOnce(MovementType type) + { + var interp = MakeInterp(); + int flushCount = 0; + interp.CheckForCompletedMotions = () => flushCount++; + + interp.PerformMovement(new MovementStruct + { + Type = type, + Motion = MotionCommand.WalkForward, + Speed = 1.0f, + }); + + Assert.Equal(1, flushCount); + } + + [Fact] + public void PerformMovement_InvalidType_DoesNotFireFlush() + { + // The type-dispatch failure (0x47, bad MovementStruct.type) happens + // BEFORE any op is dispatched — no flush should fire for it. + var interp = MakeInterp(); + int flushCount = 0; + interp.CheckForCompletedMotions = () => flushCount++; + + var result = interp.PerformMovement(new MovementStruct { Type = (MovementType)99 }); + + Assert.Equal(WeenieError.GeneralMovementFailure, result); + Assert.Equal(0, flushCount); + } + + [Fact] + public void PerformMovement_FlushSeamUnset_DoesNotThrow() + { + var interp = MakeInterp(); + interp.CheckForCompletedMotions = null; + + var result = interp.PerformMovement(new MovementStruct + { + Type = MovementType.StopCompletely, + }); + + Assert.Equal(WeenieError.None, result); + } + + [Fact] + public void PerformMovement_StopCompletely_ResetsToReady_AndFlushes() + { + var interp = MakeInterp(); + interp.RawState.ForwardCommand = MotionCommand.WalkForward; + interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward; + int flushCount = 0; + interp.CheckForCompletedMotions = () => flushCount++; + + interp.PerformMovement(new MovementStruct { Type = MovementType.StopCompletely }); + + Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand); + Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand); + Assert.Equal(1, flushCount); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs index 93536ff3..52da4322 100644 --- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs +++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs @@ -19,10 +19,23 @@ public class MotionInterpreterFunnelTests private sealed class RecordingSink : IInterpretedMotionSink { public readonly List Calls = new(); - public void ApplyMotion(uint motion, float speed) - => Calls.Add($"DIM {motion:x8}@{speed:F2}"); - public void StopMotion(uint motion) - => Calls.Add($"STOP {motion:x8}"); + public bool ApplyMotion(uint motion, float speed) + { + Calls.Add($"DIM {motion:x8}@{speed:F2}"); + // R3-W5: a style/stance id (>= 0x80000000, i.e. negative as + // int32) has no locomotion MotionData entry in the dat — retail's + // real CMotionTable::DoObjectMotion genuinely fails for it + // (MotionTableManagerError.MotionFailed). The fake sink mirrors + // that so InterpretedMotionState.ForwardCommand isn't clobbered + // by ApplyMotion's negative-motion branch before the very next + // dispatch reads it — matches the live retail-observer trace. + return motion < 0x80000000u; + } + public bool StopMotion(uint motion) + { + Calls.Add($"STOP {motion:x8}"); + return true; + } } private static MotionInterpreter GroundedInterp() @@ -164,8 +177,26 @@ 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); - // The interpreted STATE still flat-copies (velocity uses it on landing). - Assert.Equal(0x44000007u, mi.InterpretedState.ForwardCommand); + // 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); } [Fact] diff --git a/tests/AcDream.Core.Tests/Physics/RetailObserverTraceConformanceTests.cs b/tests/AcDream.Core.Tests/Physics/RetailObserverTraceConformanceTests.cs index 8fab758b..d619d8f5 100644 --- a/tests/AcDream.Core.Tests/Physics/RetailObserverTraceConformanceTests.cs +++ b/tests/AcDream.Core.Tests/Physics/RetailObserverTraceConformanceTests.cs @@ -40,8 +40,18 @@ public class RetailObserverTraceConformanceTests private sealed class RecordingSink : IInterpretedMotionSink { public readonly List Applied = new(); - public void ApplyMotion(uint motion, float speed) => Applied.Add(motion); - public void StopMotion(uint motion) { } + public bool ApplyMotion(uint motion, float speed) + { + Applied.Add(motion); + // R3-W5: style/stance ids (>= 0x80000000) have no dat MotionData + // entry — retail's real motion-table lookup fails for them + // (see MotionInterpreterDoMotionFamilyTests / the interface + // doc on IInterpretedMotionSink.ApplyMotion for the full + // rationale). The fake sink mirrors that so the style dispatch + // doesn't clobber ForwardCommand before the next read. + return motion < 0x80000000u; + } + public bool StopMotion(uint motion) => true; } private static string TracePath()