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>
This commit is contained in:
parent
8664959152
commit
371679915e
5 changed files with 696 additions and 13 deletions
|
|
@ -0,0 +1,351 @@
|
|||
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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue