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:
Erik 2026-07-02 22:24:56 +02:00
parent 8664959152
commit 371679915e
5 changed files with 696 additions and 13 deletions

View file

@ -98,7 +98,7 @@ accepted-divergence entries (#96, #49, #50).
| AD-33 | `CSequence.frame_number` at C# `double` (64-bit); retail is x87 `long double` (80-bit extended) — every frame-boundary comparison ran at extended precision on retail (Phase R1, 2026-07-02) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`FrameNumber`) | `double` is the widest C# float type; the R1 port removes ACE-style boundary epsilons so comparisons are exact-int against bare boundaries, minimizing ULP sensitivity (ACE's `float` is far worse) | A frame landing within 1 double-ULP of an integer boundary could classify differently than retail's 80-bit compare — sub-frame timing skew at pathological framerate×dt combinations | `acclient.h:30747` (`long double frame_number`) |
| AD-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList<T>`s; node identity via `LinkedListNode<>` references (Phase R1 anim list, 2026-07-02; extended R2-Q3 to `MotionTableManager.pending_animations`) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`_animList`); `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`_pendingAnimations`) | Same topology + cursor semantics (curr_anim/first_cyclic/tail-anchored scans are node references); unlink/delete becomes `Remove(node)`; conformance tests pin the surgery state tables | Any retail behavior depending on the 4 pointer adjustment or node memory reuse (none observed in the decomp) would diverge | `acclient.h` DLListBase; `r1-csequence-decomp.md` §0; `r2-motiontable-decomp.md` §11 |
| AD-35 | `MotionTableManager.PerformMovement`'s unhandled-type default case returns the named sentinel `0xFFFFFFFF` (`MotionTableManagerError.NotHandled`); retail's compiled code leaks the `CSequence*` pointer reinterpreted as the return code (BN-confirmed artifact, dead/unreachable — callers gate on type first) (R2-Q3, 2026-07-02) | `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`PerformMovement` default case) | No retail caller consults the return value for unhandled types (RawCommand/StopRawCommand/MoveTo\*/TurnTo\* route elsewhere); returning a stable non-zero sentinel preserves the only observable contract (non-zero = not success) without fabricating a pointer-shaped number | If a future port wires a caller that passes unhandled types AND branches on the exact return value, it would see `0xFFFFFFFF` where retail saw an arbitrary pointer — flag at that port | `PerformMovement` 0x0051c0b0 (`r2-motiontable-decomp.md` §11 default-case note) |
| AD-36 | `IMotionDoneSink.MotionDone` is OBSERVED, not consumed: R2-Q4 binds the seam to an ACDREAM_DUMP_MOTION diagnostic recorder (`GameWindow` TickAnimations bind site); retail's chain continues `CPhysicsObj::MotionDone``MovementManager::MotionDone``CMotionInterp::MotionDone` popping `pending_motions` (R2-Q4, 2026-07-02) | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`MotionDoneTarget`) | Stage boundary: `CMotionInterp.pending_motions` does not exist until R3-W2, which rebinds the seam to `MotionInterpreter.MotionDone` (r3-port-plan.md §4) | Until R3, nothing tracks queued-motion completion on the interp side — jump_is_allowed's pending-head peek and action RemoveAction mirrors are inert (they don't exist yet either) | `CPhysicsObj::MotionDone` 0x0050fdb0; r2-port-plan.md §4 |
| AD-36 | `IMotionDoneSink.MotionDone` consumed for CREATURE-class entities only: R3-W2 binds the seam to the entity's `MotionInterpreter.MotionDone` (player via `PlayerMovementController.Motion`, remotes via `RemoteMotion.Motion`, resolved at fire time); interp-less entities (doors, statics) keep a diagnostic-recorder-only target — retail gives every CPhysicsObj a MovementManager/CMotionInterp (R2-Q4 seam, narrowed R3-W2, 2026-07-02) | `src/AcDream.App/Rendering/GameWindow.cs` (TickAnimations MotionDoneTarget bind) | Door/static motion (On/Off cycles) completes via the manager queue alone; nothing consumes their MotionDone until R5 gives every entity class the full MovementSystem pipeline | A door behavior depending on pending_motions bookkeeping (none known — doors have no jump/action semantics) would silently no-op | `CPhysicsObj::MotionDone` 0x0050fdb0; retire in R5 (MovementSystem for all classes) |
---

View file

@ -360,6 +360,15 @@ public sealed class PlayerMovementController
_weenie.SetSkills(runSkill, jumpSkill);
}
/// <summary>
/// R3-W2 (r3-port-plan.md §4): the player's <see cref="MotionInterpreter"/>
/// — GameWindow binds the player sequencer's MotionDone seam to it so the
/// pending_motions queue pops in step with animation completion, same
/// path remotes use. R3-W6 widens this into the full local-player
/// unification.
/// </summary>
internal MotionInterpreter Motion => _motion;
/// <summary>
/// Wire the player's AnimationSequencer current cycle velocity into
/// <see cref="MotionInterpreter.GetCycleVelocity"/>. When attached,

View file

@ -4183,12 +4183,15 @@ public sealed class GameWindow : IDisposable
_worldState.RemoveEntityByServerGuid(serverGuid);
_worldGameState.RemoveById(existingEntity.Id);
// R2-Q5: retail's exit-world drain — every pending queue entry fires
// MotionDone(success:false) before the object goes away
// (MotionTableManager::HandleExitWorld 0x0051bda0; R3 adds the
// CMotionInterp-side drain alongside per r3-port-plan §4).
// R2-Q5 + R3-W2: retail runs BOTH layers' exit-world drains
// independently (r3-port-plan §4): the manager's (each pending
// animation fires MotionDone(success:false) → the bound interp pops
// in step) THEN the interp's own (flushes any remainder —
// CMotionInterp::HandleExitWorld 0x00527f30).
if (_animatedEntities.TryGetValue(existingEntity.Id, out var aeGone))
aeGone.Sequencer?.Manager.HandleExitWorld();
if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmGone))
rmGone.Motion.HandleExitWorld();
_animatedEntities.Remove(existingEntity.Id);
_classificationCache.InvalidateEntity(existingEntity.Id);
_physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
@ -9876,17 +9879,37 @@ public sealed class GameWindow : IDisposable
}
seqFrames = ae.Sequencer.Advance(dt);
// R2-Q4: bind the MotionDone diagnostic recorder once per
// sequencer (the IMotionDoneSink seam's interim consumer —
// R3 binds MotionInterpreter.MotionDone here instead).
// R3-W2: bind the MotionDone seam once per sequencer to the
// entity's REAL consumer — the MotionInterpreter's
// pending_motions pop (retail CPhysicsObj::MotionDone
// 0x0050fdb0 → MovementManager → CMotionInterp::MotionDone
// 0x00527ec0; r3-port-plan.md §4). Remotes bind their
// RemoteMotion interp; the local player binds the
// controller's; interp-less entities (doors, statics) keep
// the diagnostic recorder only.
if (ae.Sequencer.MotionDoneTarget is null)
{
uint mdGuid = serverGuid;
// Resolve the interp AT FIRE TIME, not bind time: a
// remote's RemoteMotion is created on its first UM/UP,
// which can arrive AFTER the first anim tick — an
// eagerly-captured null would silently drop completions
// forever. MotionDone fires per completed motion (rare),
// so the dictionary lookup is negligible.
ae.Sequencer.MotionDoneTarget = (m, ok) =>
{
AcDream.Core.Physics.MotionInterpreter? interp = null;
if (mdGuid != 0 && mdGuid == _playerServerGuid)
interp = _playerController?.Motion;
else if (mdGuid != 0
&& _remoteDeadReckon.TryGetValue(mdGuid, out var rmFire))
interp = rmFire.Motion;
interp?.MotionDone(m, ok);
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
System.Console.WriteLine(
$"[MOTIONDONE] guid={mdGuid:X8} motion=0x{m:X8} success={ok}");
$"[MOTIONDONE] guid={mdGuid:X8} motion=0x{m:X8} success={ok} "
+ $"pending={(interp?.MotionsPending() ?? false)}");
};
}

View file

@ -450,8 +450,21 @@ public interface IWeenieObject
///
/// Owns the raw and interpreted motion states for a physics object and
/// translates network movement commands into PhysicsBody velocity calls.
///
/// <para>
/// <b>R3-W2 (closes J1, J17):</b> implements <see cref="IMotionDoneSink"/> —
/// the entity's <c>MotionTableManager</c> (R2-Q3) binds its animation-done
/// callback here, standing in for retail's null-guarded relay chain
/// <c>CPhysicsObj::MotionDone</c> 0x0050fdb0 → <c>MovementManager::MotionDone</c>
/// 0x005242d0 → <c>CMotionInterp::MotionDone</c> 0x00527ec0 (r3-port-plan.md
/// §4). This adds the <c>pending_motions</c> queue (retail
/// <c>CMotionInterp::pending_motions</c>, a singly-linked <c>LList</c> —
/// ported as a managed <see cref="LinkedList{T}"/> per the AD-34 managed-list
/// register wording) that the funnel's dispatch/stop producers populate and
/// <see cref="MotionDone"/>/<see cref="HandleExitWorld"/> drain.
/// </para>
/// </summary>
public sealed class MotionInterpreter
public sealed class MotionInterpreter : IMotionDoneSink
{
// ── animation speed constants (from ACE / confirmed by decompile globals) ─
/// <summary>Walk animation base speed. Retail-exact (.rdata 0x007c891c =
@ -540,6 +553,38 @@ public sealed class MotionInterpreter
/// <summary>True when crouching-in-place for a standing long jump (offset +0x70).</summary>
public bool StandingLongJump;
/// <summary>
/// R3-W2 — retail <c>CMotionInterp::pending_motions</c> (offset last in
/// the struct, a singly-linked <c>LList&lt;MotionNode&gt;</c>). Ported as
/// a managed <see cref="LinkedList{T}"/> — the AD-34 register wording
/// ("retail's intrusive DLList/LList is ported as a managed LinkedList of
/// value nodes; node identity semantics are preserved via
/// LinkedListNode&lt;T&gt; references rather than raw prev/next
/// pointers") applies here exactly as it does to
/// <c>MotionTableManager._pendingAnimations</c> — this is the OTHER,
/// never-merged queue (movement side, waiting for a MotionDone callback,
/// vs the manager's animation side, waiting for a tick countdown; see
/// r3-port-plan.md §4 rule 1). Exposed read-only; mutated only through
/// <see cref="AddToQueue"/> / <see cref="MotionDone"/> /
/// <see cref="HandleExitWorld"/>.
/// </summary>
private readonly LinkedList<MotionNode> _pendingMotions = new();
/// <summary>Read-only inspection surface (tests + future callers): the
/// pending motion queue in head-to-tail order.</summary>
public IEnumerable<MotionNode> PendingMotions => _pendingMotions;
/// <summary>
/// R3-W2 no-op seam standing in for retail <c>CPhysicsObj::unstick_from_object</c>
/// (called from <see cref="MotionDone"/>/<see cref="HandleExitWorld"/> when
/// the popped queue head is action-class, i.e. <c>Motion &amp; 0x10000000</c>
/// is set). Register row: releases a "stuck to object" sticky-manager
/// attachment — R5 wires the real StickyManager; until then this is an
/// optional callback the App layer may bind, matching the existing
/// <c>Action?</c> seam convention (see <c>MotionTableDispatchSink.TurnStopped</c>).
/// </summary>
public Action? UnstickFromObject { get; set; }
/// <summary>
/// Optional accessor for the owning entity's current animation cycle
/// velocity (AnimationSequencer.CurrentVelocity, i.e. MotionData.Velocity
@ -1356,6 +1401,203 @@ public sealed class MotionInterpreter
return true;
}
// ── R3-W2 — pending_motions lifecycle ─────────────────────────────────
// docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md §1.
/// <summary>
/// <c>CMotionInterp::add_to_queue</c> (0x00527b80, decomp §1a @305032):
/// allocate a <see cref="MotionNode"/> and append it to the tail of
/// <see cref="PendingMotions"/> (retail: append at tail; if the queue was
/// empty, head and tail both point at the new node — the C#
/// <see cref="LinkedList{T}"/> gives this for free via
/// <c>AddLast</c>).
/// </summary>
/// <param name="contextId">Retail <c>arg2</c> — <c>MotionNode.context_id</c>.</param>
/// <param name="motion">Retail <c>arg3</c> — <c>MotionNode.motion</c>.</param>
/// <param name="jumpErrorCode">Retail <c>arg4</c> — <c>MotionNode.jump_error_code</c>.</param>
public void AddToQueue(uint contextId, uint motion, uint jumpErrorCode)
{
_pendingMotions.AddLast(new MotionNode(contextId, motion, jumpErrorCode));
}
/// <summary>
/// <c>CMotionInterp::motions_pending</c> (0x00527fe0, decomp §1b
/// @305322): <c>pending_motions.head_ != null</c>.
/// </summary>
public bool MotionsPending() => _pendingMotions.First is not null;
/// <summary>
/// <c>CMotionInterp::MotionDone</c> (0x00527ec0, decomp §1c @305238,
/// FULL BODY). A7 (W0-pins.md): <paramref name="motion"/> and
/// <paramref name="success"/> are read into locals by the decompiled
/// relay chain but NEVER actually used by this build's body — the queue
/// head is popped UNCONDITIONALLY, never matched by motion id. Params
/// are kept for R5 signature parity with the real
/// <c>MovementManager::MotionDone</c> relay.
///
/// <para>
/// Body (verbatim): no-op if <see cref="PhysicsObj"/> is null or the
/// queue is empty. Peek the HEAD: if <c>head.Motion &amp; 0x10000000</c>
/// (the action-class bit) is set, fire
/// <see cref="UnstickFromObject"/> then pop the head of BOTH
/// <see cref="InterpretedState"/>'s and <see cref="RawState"/>'s action
/// FIFOs. Then unconditionally dequeue the pending_motions head.
/// </para>
/// </summary>
public void MotionDone(uint motion, bool success)
{
if (PhysicsObj is null)
return;
var head = _pendingMotions.First;
if (head is null)
return;
if ((head.Value.Motion & 0x10000000u) != 0)
{
UnstickFromObject?.Invoke();
InterpretedState.RemoveAction();
RawState.RemoveAction();
}
// Re-peek per retail's structure (head__1 = this->pending_motions.head_,
// a fresh read after the action-class branch above) — functionally
// the same node since nothing else can mutate the queue mid-call.
var head1 = _pendingMotions.First;
if (head1 is not null)
_pendingMotions.RemoveFirst();
}
/// <summary>
/// <c>CMotionInterp::HandleExitWorld</c> (0x00527f30, decomp §1d):
/// identical body to <see cref="MotionDone"/>'s single-pop logic, looped
/// until <see cref="PendingMotions"/> drains — world exit flushes all
/// pending motion callbacks unconditionally, since no more
/// animation-completion events will arrive.
///
/// <para>
/// <b>Ambiguity resolved (not in W0-pins.md — found while porting):</b>
/// the raw decompile's loop condition is <c>head_ != 0</c>
/// (re-read every iteration), but the pop only happens inside a NESTED
/// <c>if (physics_obj != 0 &amp;&amp; head_ != 0)</c> guard — a literal
/// translation would infinite-loop if <see cref="PhysicsObj"/> were null
/// with a non-empty queue (the loop variable never advances). This is
/// dead code in retail: a live <c>CMotionInterp::HandleExitWorld</c> call
/// only ever happens on an interp already bound to a <c>physics_obj</c>
/// (retail's <c>CMotionInterp</c> lifetime is physics-object-scoped) —
/// the null check exists defensively, not as a real "still drain, but
/// skip the pop" branch. Porting a genuine infinite loop is not
/// "faithful", it's a bug retail never actually exercises; this port
/// drains unconditionally regardless of <see cref="PhysicsObj"/> (same
/// choice <see cref="AddToQueue"/>/<see cref="MotionsPending"/> already
/// make — they too are called before a physics_obj may exist during
/// interp construction/testing).
/// </para>
/// </summary>
public void HandleExitWorld()
{
while (_pendingMotions.First is not null)
{
var head = _pendingMotions.First!;
if ((head.Value.Motion & 0x10000000u) != 0)
{
UnstickFromObject?.Invoke();
InterpretedState.RemoveAction();
RawState.RemoveAction();
}
_pendingMotions.RemoveFirst();
}
}
/// <summary>
/// <c>CMotionInterp::is_standing_still</c> (0x00527fa0, decomp @305309):
/// on-ground (Contact + OnWalkable) AND ForwardCommand == Ready AND
/// SideStepCommand == 0 AND TurnCommand == 0.
/// </summary>
public bool IsStandingStill()
{
if (PhysicsObj is null)
return false;
bool grounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact)
&& PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
if (!grounded)
return false;
return InterpretedState.ForwardCommand == MotionCommand.Ready
&& InterpretedState.SideStepCommand == 0
&& InterpretedState.TurnCommand == 0;
}
/// <summary>
/// <c>CMotionInterp::motion_allows_jump</c> (0x005279e0, decomp §3a
/// @304908, <c>__pure</c>). A1 (W0-pins.md, adversarially verified):
/// PINNED as a BLOCKLIST — <c>0</c> = jump allowed (pass), <c>0x48</c> =
/// jump BLOCKED. Ported as literal uint range comparisons mirroring the
/// decomp's exact branch algebra (NOT enum-ordinal ranges — the whole
/// point of A1 is that ACE's enum-order dependence is fragile).
///
/// <para>
/// Blocklist (definitive table, W0-pins.md §A1):
/// <list type="bullet">
/// <item><c>[0x1000006f, 0x10000078]</c> — MagicPowerUp01..MagicPowerUp10.</item>
/// <item><c>[0x10000128, 0x10000131]</c> — TripleThrustLow..MagicPowerUp07Purple.</item>
/// <item><c>0x40000008</c> exact — Fallen (NOT Falling; ACE mis-transcribed this).</item>
/// <item><c>[0x40000016, 0x40000018]</c> — Reload, Unload, Pickup.</item>
/// <item><c>[0x4000001e, 0x40000039]</c> — AimLevel..MagicPray.</item>
/// <item><c>[0x41000012, 0x41000014]</c> — Crouch, Sitting, Sleeping.</item>
/// </list>
/// Everything else — including Falling <c>0x40000015</c>, Ready
/// <c>0x41000003</c>, Dead <c>0x40000011</c>, all turn/sidestep ids — PASSES.
/// </para>
///
/// <para>
/// This is a pure function (no instance state) — retail's <c>this</c>
/// parameter is unused in the body (the decomp's <c>__pure</c>
/// attribute confirms it doesn't read <c>this</c>).
/// </para>
/// </summary>
/// <param name="motion">Retail <c>arg2</c> — the motion id to test.</param>
public static WeenieError MotionAllowsJump(uint motion)
{
// Verbatim branch algebra from raw 304908-304931 (W0-pins.md §A1):
// if (arg2 > 0x40000018) {
// if (arg2 > 0x41000014) return 0;
// if (arg2 < 0x41000012 && (arg2 < 0x4000001e || arg2 > 0x40000039)) return 0;
// } else if (arg2 < 0x40000016) {
// if (arg2 > 0x10000131) {
// if (arg2 != 0x40000008) return 0;
// } else if (arg2 < 0x10000128 && (arg2 < 0x1000006f || arg2 > 0x10000078)) return 0;
// }
// return 0x48;
//
// The middle band [0x40000016, 0x40000018] satisfies NEITHER outer
// branch (not > 0x40000018, not < 0x40000016) and falls straight to
// `return 0x48` — a genuine "no early return" gap in the middle.
if (motion > 0x40000018u)
{
if (motion > 0x41000014u)
return WeenieError.None;
if (motion < 0x41000012u && (motion < 0x4000001eu || motion > 0x40000039u))
return WeenieError.None;
}
else if (motion < 0x40000016u)
{
if (motion > 0x10000131u)
{
if (motion != 0x40000008u)
return WeenieError.None;
}
else if (motion < 0x10000128u && (motion < 0x1000006fu || motion > 0x10000078u))
{
return WeenieError.None;
}
}
return WeenieError.YouCantJumpFromThisPosition;
}
// ── FUN_00529710 — LeaveGround ────────────────────────────────────────────
/// <summary>
@ -1560,6 +1802,23 @@ public sealed class MotionInterpreter
/// then dispatch style / forward-or-Falling / sidestep-or-stop /
/// turn-or-stop in retail order. A non-zero turn EARLY-RETURNS (no
/// turn-stop, no idle bookkeeping).
///
/// <para>
/// <b>R3-W2 producer (closes J1's apply_interpreted_movement leg):</b>
/// the tail turn-stop call (raw 305766-305785) is retail's
/// <c>CPhysicsObj::StopInterpretedMotion(physics_obj, 0x6500000d, &amp;var_2c)</c>
/// — our <see cref="IInterpretedMotionSink.StopMotion"/> call below IS
/// that dispatch. On success (<c>eax_10 == 0</c>, i.e. the sink accepted
/// the stop — this port has no failure signal from
/// <see cref="IInterpretedMotionSink.StopMotion"/>, so it is treated as
/// always-succeeding, matching every real sink implementation which is
/// void), retail immediately re-queues the canonical "return to none"
/// node: <c>add_to_queue(this, var_c /*uninitialized local in the raw
/// decompile — see the final-report contextId note*/, 0x41000003, eax_10)</c>
/// where <c>eax_10</c> (the stop's own return value) becomes the queued
/// node's <c>jump_error_code</c> — since the stop succeeded, that value
/// is <c>0</c>.
/// </para>
/// </summary>
public void ApplyInterpretedMovement(uint currentStyle, IInterpretedMotionSink? sink)
{
@ -1596,9 +1855,13 @@ public sealed class MotionInterpreter
InterpretedState.TurnCommand, InterpretedState.TurnSpeed, sink);
return; // retail early return — no idle-stop this call
}
sink?.StopMotion(MotionCommand.TurnRight);
// Idle add_to_queue(Ready) bookkeeping lands with S3
// (pending_motions / MotionDone).
// 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);
}
/// <summary>
@ -1610,6 +1873,35 @@ public sealed class MotionInterpreter
/// 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).
///
/// <para>
/// <b>R3-W2 producer (closes J1's DoInterpretedMotion leg):</b> on the
/// success path (raw 305591-305610), retail computes the queue node's
/// <c>jump_error_code</c> via a DOUBLE-CHECK shape before calling
/// <c>add_to_queue(context_id, arg2, eax_5)</c>:
/// <code>
/// if ((params-&gt;bitfield &amp; 0x20000) == 0) { // NOT disable_jump_during_link
/// eax_5 = motion_allows_jump(arg2);
/// if (eax_5 == 0 &amp;&amp; (arg2 &amp; 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);
/// </code>
/// This funnel has no <c>MovementParameters</c> threaded through yet
/// (<see cref="DispatchInterpretedMotion"/> takes only <c>motion</c>/
/// <c>speed</c> — the params-carrying overloads land in W5 when
/// <c>DoMotion</c>/<c>StopMotion</c>/the legacy overloads merge into
/// this backend). TODO(W5): thread the real <c>MovementParameters</c>
/// through and restore the <c>0x20000</c> (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).
/// <c>ContextId</c> is <c>0</c> pending the same W5 plumbing (no
/// <c>MovementParameters.ContextId</c> reaches this call site yet) —
/// see the final-report contextId note for the evidence this funnel
/// genuinely has nothing else to pass.
/// </para>
/// </summary>
public WeenieError DispatchInterpretedMotion(uint motion, float speed, IInterpretedMotionSink? sink)
{
@ -1620,7 +1912,15 @@ public sealed class MotionInterpreter
// 0x40000011 (Dead) flushes queued link animations in retail
// (RemoveLinkAnimations) — S4 wires the sequencer hook.
sink?.ApplyMotion(motion, speed);
// add_to_queue(context, motion, jumpAllowed) — S3 bookkeeping.
// 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);
return WeenieError.None;
}

View file

@ -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());
}
}