using System.Collections.Generic; using System.Linq; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using DatReaderWriter.DBObjs; using Xunit; namespace AcDream.Core.Tests.Physics; /// /// R3-W2 — CMotionInterp::pending_motions lifecycle + the /// MotionDone consumer (closes J1, J17). /// /// Oracle: docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md /// §1 (add_to_queue 0x00527b80 @305032, MotionDone 0x00527ec0 @305238, /// motions_pending 0x00527fe0, HandleExitWorld 0x00527f30, /// is_standing_still 0x00527fa0 @305309) + §3a (motion_allows_jump /// 0x005279e0 @304908) + W0-pins.md A1/A7. /// public class MotionInterpreterPendingMotionsTests { private static MotionInterpreter GroundedInterp() { var body = new PhysicsBody(); body.State |= PhysicsStateFlags.Gravity; body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable | TransientStateFlags.Active; return new MotionInterpreter(body); } // ── AddToQueue / MotionsPending — §1a/§1b verbatim ───────────────────── [Fact] public void AddToQueue_AppendsToTail_InOrder() { var mi = new MotionInterpreter(); mi.AddToQueue(1, 0x41000003u, 0u); mi.AddToQueue(2, 0x44000007u, 0x48u); mi.AddToQueue(3, 0x45000005u, 0u); Assert.Equal( new[] { new MotionNode(1, 0x41000003u, 0u), new MotionNode(2, 0x44000007u, 0x48u), new MotionNode(3, 0x45000005u, 0u), }, mi.PendingMotions.ToArray()); } [Fact] public void MotionsPending_FalseWhenEmpty_TrueWhenNonEmpty() { var mi = new MotionInterpreter(); Assert.False(mi.MotionsPending()); mi.AddToQueue(0, MotionCommand.Ready, 0); Assert.True(mi.MotionsPending()); } // ── MotionDone — §1c verbatim (unconditional head pop, A7) ──────────── [Fact] public void MotionDone_PopsHead_RegardlessOfMotionIdOrSuccessFlag() { // A7: motion + success are IGNORED — positional protocol, never // match-by-id. A queue with a head that does NOT match the // `motion` parameter must still pop that head. §1c gates the whole // body on physics_obj != null (covered separately by // MotionDone_NoPhysicsObj_NoOp), so this needs a grounded interp. var mi = GroundedInterp(); mi.AddToQueue(1, 0x44000007u /* RunForward */, 0u); mi.AddToQueue(2, 0x45000005u /* WalkForward */, 0u); // Call MotionDone with a motion id that matches NEITHER queued // node, and success=false — retail still pops the actual head. mi.MotionDone(0xDEADBEEFu, false); Assert.Single(mi.PendingMotions); Assert.Equal(0x45000005u, mi.PendingMotions.First().Motion); } [Fact] public void MotionDone_EmptyQueue_NoOp() { var mi = new MotionInterpreter(); // Should not throw. mi.MotionDone(0, true); Assert.False(mi.MotionsPending()); } [Fact] public void MotionDone_NoPhysicsObj_NoOp() { // §1c: `if (physics_obj != 0) { ... }` — the whole body is // gated on a non-null physics_obj. A queued node must survive // MotionDone when PhysicsObj is null. var mi = new MotionInterpreter(); mi.AddToQueue(0, MotionCommand.Ready, 0); mi.MotionDone(MotionCommand.Ready, true); Assert.True(mi.MotionsPending()); } [Fact] public void MotionDone_ActionClassHead_FiresUnstickSeam_AndPopsBothActionFifos() { // §1c: head.motion & 0x10000000 != 0 -> unstick_from_object + // InterpretedState.RemoveAction() + RawState.RemoveAction(), // THEN the unconditional head pop. var mi = GroundedInterp(); mi.InterpretedState.AddAction(0x10000062u, 1.0f, 5, false); mi.RawState.AddAction(0x10000062u, 1.0f, 5, false); bool unstickFired = false; mi.UnstickFromObject = () => unstickFired = true; mi.AddToQueue(0, 0x10000062u /* action-class bit 0x10000000 set */, 0u); mi.MotionDone(0x10000062u, true); Assert.True(unstickFired); Assert.Equal(0u, mi.InterpretedState.GetNumActions()); Assert.Empty(mi.RawState.Actions); Assert.False(mi.MotionsPending()); } [Fact] public void MotionDone_NonActionClassHead_DoesNotFireUnstick_OrPopActionFifos() { var mi = GroundedInterp(); mi.InterpretedState.AddAction(0x10000062u, 1.0f, 5, false); mi.RawState.AddAction(0x10000062u, 1.0f, 5, false); bool unstickFired = false; mi.UnstickFromObject = () => unstickFired = true; // Non-action-class motion (no 0x10000000 bit). mi.AddToQueue(0, MotionCommand.Ready, 0u); mi.MotionDone(MotionCommand.Ready, true); Assert.False(unstickFired); Assert.Equal(1u, mi.InterpretedState.GetNumActions()); Assert.Single(mi.RawState.Actions); } [Fact] public void MotionDone_ActionClassHead_UnstickSeamUnset_DoesNotThrow() { // UnstickFromObject is a no-op seam (register row -> R5 StickyManager) // until wired — null must not throw. Needs a grounded interp so // MotionDone's physics_obj != null gate actually pops (§1c). var mi = GroundedInterp(); mi.AddToQueue(0, 0x10000062u, 0u); mi.MotionDone(0x10000062u, true); Assert.False(mi.MotionsPending()); } [Fact] public void MotionDone_MultipleCalls_PopInFifoOrder() { // §1c gates the pop on physics_obj != null. var mi = GroundedInterp(); mi.AddToQueue(1, 0x41000003u, 0u); mi.AddToQueue(2, 0x44000007u, 0u); mi.AddToQueue(3, 0x45000005u, 0u); mi.MotionDone(0, true); Assert.Equal(new uint[] { 2, 3 }, mi.PendingMotions.Select(n => n.ContextId)); mi.MotionDone(0, true); Assert.Equal(new uint[] { 3 }, mi.PendingMotions.Select(n => n.ContextId)); mi.MotionDone(0, true); Assert.Empty(mi.PendingMotions); Assert.False(mi.MotionsPending()); } // ── HandleExitWorld — §1d, same body looped ──────────────────────────── [Fact] public void HandleExitWorld_DrainsWholeQueue() { var mi = new MotionInterpreter(); mi.AddToQueue(1, 0x41000003u, 0u); mi.AddToQueue(2, 0x44000007u, 0u); mi.AddToQueue(3, 0x10000062u, 0u); // action-class mi.HandleExitWorld(); Assert.Empty(mi.PendingMotions); Assert.False(mi.MotionsPending()); } [Fact] public void HandleExitWorld_ActionClassNodes_PopActionFifosForEach() { var mi = GroundedInterp(); mi.InterpretedState.AddAction(0x10000062u, 1f, 1, false); mi.InterpretedState.AddAction(0x10000063u, 1f, 2, false); mi.RawState.AddAction(0x10000062u, 1f, 1, false); mi.RawState.AddAction(0x10000063u, 1f, 2, false); mi.AddToQueue(0, 0x10000062u, 0u); mi.AddToQueue(0, 0x10000063u, 0u); mi.HandleExitWorld(); Assert.Equal(0u, mi.InterpretedState.GetNumActions()); Assert.Empty(mi.RawState.Actions); } [Fact] public void HandleExitWorld_EmptyQueue_NoOp() { var mi = new MotionInterpreter(); mi.HandleExitWorld(); Assert.False(mi.MotionsPending()); } // ── is_standing_still — §... 0x00527fa0 verbatim predicate ───────────── [Theory] [InlineData(true, true, MotionCommand.Ready, 0u, 0u, true)] // grounded + idle -> true [InlineData(true, true, MotionCommand.Ready, 0x6500000fu, 0u, false)] // sidestep set -> false [InlineData(true, true, MotionCommand.Ready, 0u, 0x6500000du, false)] // turn set -> false [InlineData(true, true, MotionCommand.RunForward, 0u, 0u, false)] // not Ready -> false [InlineData(true, false, MotionCommand.Ready, 0u, 0u, false)] // OnWalkable false -> false [InlineData(false, true, MotionCommand.Ready, 0u, 0u, false)] // Contact false -> false public void IsStandingStill_TruthTable( bool contact, bool onWalkable, uint fwd, uint side, uint turn, bool expected) { var body = new PhysicsBody(); body.State |= PhysicsStateFlags.Gravity; if (contact) body.TransientState |= TransientStateFlags.Contact; if (onWalkable) body.TransientState |= TransientStateFlags.OnWalkable; var mi = new MotionInterpreter(body) { InterpretedState = { ForwardCommand = fwd, SideStepCommand = side, TurnCommand = turn, }, }; Assert.Equal(expected, mi.IsStandingStill()); } [Fact] public void IsStandingStill_NoPhysicsObj_False() { var mi = new MotionInterpreter(); Assert.False(mi.IsStandingStill()); } // ── MotionAllowsJump — A1 pinned blocklist, exact boundaries ─────────── [Theory] // [0x1000006f, 0x10000078] MagicPowerUp01..10 -> BLOCKED [InlineData(0x1000006fu, true)] [InlineData(0x10000070u, true)] [InlineData(0x10000078u, true)] [InlineData(0x1000006eu, false)] // just below lower bound -> pass [InlineData(0x10000079u, false)] // just above upper bound -> pass // [0x10000128, 0x10000131] -> BLOCKED [InlineData(0x10000128u, true)] [InlineData(0x10000130u, true)] [InlineData(0x10000131u, true)] [InlineData(0x10000127u, false)] [InlineData(0x10000132u, false)] // 0x40000008 (Fallen) exact -> BLOCKED [InlineData(0x40000008u, true)] // Falling 0x40000015 -> PASS (the A1 polarity-corrected divergence from ACE) [InlineData(0x40000015u, false)] // [0x40000016, 0x40000018] Reload/Unload/Pickup -> BLOCKED [InlineData(0x40000016u, true)] [InlineData(0x40000017u, true)] [InlineData(0x40000018u, true)] [InlineData(0x40000019u, false)] // [0x4000001e, 0x40000039] AimLevel..MagicPray -> BLOCKED [InlineData(0x4000001eu, true)] [InlineData(0x40000030u, true)] [InlineData(0x40000039u, true)] [InlineData(0x4000001du, false)] [InlineData(0x4000003au, false)] // [0x41000012, 0x41000014] Crouch/Sitting/Sleeping -> BLOCKED [InlineData(0x41000012u, true)] [InlineData(0x41000013u, true)] [InlineData(0x41000014u, true)] [InlineData(0x41000011u, false)] [InlineData(0x41000015u, false)] // Everything else -> pass [InlineData(MotionCommand.Ready, false)] [InlineData(MotionCommand.Dead, false)] [InlineData(0x6500000du, false)] // TurnRight [InlineData(0x6500000fu, false)] // SideStepRight [InlineData(0x44000007u, false)] // RunForward public void MotionAllowsJump_BlocklistTable(uint motion, bool blocked) { var expected = blocked ? WeenieError.YouCantJumpFromThisPosition : WeenieError.None; Assert.Equal(expected, MotionInterpreter.MotionAllowsJump(motion)); } // ── End-to-end chain: MotionTableManager.AnimationDone -> sink -> interp pops ── /// In-memory that never resolves — /// matches MotionTableManagerTests.cs's fixture convention. private sealed class NullLoader : IAnimationLoader { public Animation? LoadAnimation(uint id) => null; } [Fact] public void EndToEnd_ManagerAnimationDone_PopsInterpPendingHead_Synchronously() { // §4 diagram: MotionTableManager (IMotionDoneSink) -> // MotionInterpreter.MotionDone -> pops the interp's OWN // pending_motions queue head. Two DIFFERENT queues (manager's // pending_animations vs interp's pending_motions) kept in lockstep // only via this callback — never merged. MotionDone's body is // gated on physics_obj != null (§1c), so the interp needs one. var interp = GroundedInterp(); var state = new MotionState(); var sequence = new CSequence(new NullLoader()); var manager = new MotionTableManager(table: null, state, sequence, sink: interp); // The interp independently queued its own motion node (as a real // DispatchInterpretedMotion call would via AddToQueue). interp.AddToQueue(contextId: 7, motion: 0x44000007u, jumpErrorCode: 0); // The manager separately queues a pending animation with a 1-tick // duration, matching retail's decrementing countdown chain. manager.AddToQueue(0x44000007u, ticks: 1); Assert.True(interp.MotionsPending()); manager.AnimationDone(success: true); // The manager's own queue drained AND, via the sink callback, the // interp's independent queue popped in the same call. Assert.Empty(manager.PendingAnimations); Assert.False(interp.MotionsPending()); } }