acdream/tests/AcDream.Core.Tests/Physics/MotionInterpreterPendingMotionsTests.cs
Erik 371679915e feat(R3-W2): pending_motions lifecycle + the MotionDone consumer — the R2 seam lands (closes J1, J17)
MotionInterpreter implements IMotionDoneSink: pending_motions
(LinkedList<MotionNode>, AD-34 wording) + add_to_queue 0x00527b80 +
motions_pending 0x00527fe0 + MotionDone 0x00527ec0 verbatim (action-class
head → UnstickFromObject no-op seam (→R5 StickyManager) + BOTH states'
action-FIFO pops; head popped UNCONDITIONALLY — A7: never match-by-id,
params unread in this build) + HandleExitWorld 0x00527f30 (the raw
decompile's null-physics-obj branch would infinite-loop if translated
literally — adjudicated as retail dead code, documented on the method) +
is_standing_still 0x00527fa0 + MotionAllowsJump 0x005279e0 with the
A1-pinned literal blocklist (28-case boundary table test: Fallen blocks,
Falling passes — ACE's transposition NOT copied).

Queue producers wired into the funnel per the decomp anchors
(re-verified from the raw text): DispatchInterpretedMotion's success
path computes jump_error_code via retail's double-check shape
(motion_allows_jump(motion), then forward_command when non-action;
the DisableJumpDuringLink params leg is TODO(W5) — no MovementParameters
threaded yet) + add_to_queue; apply_interpreted_movement's turn-stop
re-queues the Ready node (@305766-305785 — the plan's producer #2 and #3
are the SAME site; StopInterpretedMotion's internal add_to_queue @305657
is W5 scope). ContextId=0 with TODO(W5): retail's own compiled code
reads an UNINITIALIZED local for it at this call site.

GameWindow: MotionDoneTarget now binds the entity's REAL consumer —
player via PlayerMovementController.Motion (new internal property),
remotes via RemoteMotion.Motion — resolved AT FIRE TIME (a remote's
RemoteMotion can be created after its first anim tick; an eager capture
would drop completions forever). Despawn runs BOTH layers' exit-world
drains (manager then interp) per §4. AD-36 narrowed (consumed for
creature-class; doors/statics recorder-only until R5).

51 new conformance cases incl. the end-to-end chain test:
MotionTableManager.AnimationDone → sink → interp pending head pops in
step. 183-case observer-trace + funnel suites green unchanged (queue
side effects additive). Full suite: 3,582 passed.

Implemented by a dedicated agent (MotionInterpreter side) against the
W0-pinned spec; producer placements + MotionAllowsJump branch algebra
independently reviewed against the raw decomp; GameWindow wiring by the
orchestrator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:24:56 +02:00

351 lines
13 KiB
C#

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;
/// <summary>
/// R3-W2 — <c>CMotionInterp::pending_motions</c> lifecycle + the
/// <c>MotionDone</c> 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.
/// </summary>
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 ──
/// <summary>In-memory <see cref="IAnimationLoader"/> that never resolves —
/// matches MotionTableManagerTests.cs's fixture convention.</summary>
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());
}
}