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()