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
|
|
@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}");
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MotionNode></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<T> 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 & 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 & 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 && 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, &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->bitfield & 0x20000) == 0) { // NOT disable_jump_during_link
|
||||
/// eax_5 = motion_allows_jump(arg2);
|
||||
/// if (eax_5 == 0 && (arg2 & 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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