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

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