From 371679915e3efe3a07df26ca516f4b7fecb486e7 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 2 Jul 2026 22:24:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(R3-W2):=20pending=5Fmotions=20lifecycle=20?= =?UTF-8?q?+=20the=20MotionDone=20consumer=20=E2=80=94=20the=20R2=20seam?= =?UTF-8?q?=20lands=20(closes=20J1,=20J17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MotionInterpreter implements IMotionDoneSink: pending_motions (LinkedList, 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 --- .../retail-divergence-register.md | 2 +- .../Input/PlayerMovementController.cs | 9 + src/AcDream.App/Rendering/GameWindow.cs | 39 +- src/AcDream.Core/Physics/MotionInterpreter.cs | 308 ++++++++++++++- .../MotionInterpreterPendingMotionsTests.cs | 351 ++++++++++++++++++ 5 files changed, 696 insertions(+), 13 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/MotionInterpreterPendingMotionsTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index acf206a3..a80a02cd 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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`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) | --- diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 0f52d4c7..a0cfa7fd 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -360,6 +360,15 @@ public sealed class PlayerMovementController _weenie.SetSkills(runSkill, jumpSkill); } + /// + /// R3-W2 (r3-port-plan.md §4): the player's + /// — 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. + /// + internal MotionInterpreter Motion => _motion; + /// /// Wire the player's AnimationSequencer current cycle velocity into /// . When attached, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 0b979ab1..396b662f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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)}"); }; } diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index c2be087f..cdecabf4 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -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. +/// +/// +/// R3-W2 (closes J1, J17): implements — +/// the entity's MotionTableManager (R2-Q3) binds its animation-done +/// callback here, standing in for retail's null-guarded relay chain +/// CPhysicsObj::MotionDone 0x0050fdb0 → MovementManager::MotionDone +/// 0x005242d0 → CMotionInterp::MotionDone 0x00527ec0 (r3-port-plan.md +/// §4). This adds the pending_motions queue (retail +/// CMotionInterp::pending_motions, a singly-linked LList — +/// ported as a managed per the AD-34 managed-list +/// register wording) that the funnel's dispatch/stop producers populate and +/// / drain. +/// /// -public sealed class MotionInterpreter +public sealed class MotionInterpreter : IMotionDoneSink { // ── animation speed constants (from ACE / confirmed by decompile globals) ─ /// Walk animation base speed. Retail-exact (.rdata 0x007c891c = @@ -540,6 +553,38 @@ public sealed class MotionInterpreter /// True when crouching-in-place for a standing long jump (offset +0x70). public bool StandingLongJump; + /// + /// R3-W2 — retail CMotionInterp::pending_motions (offset last in + /// the struct, a singly-linked LList<MotionNode>). Ported as + /// a managed — 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 + /// MotionTableManager._pendingAnimations — 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 + /// / / + /// . + /// + private readonly LinkedList _pendingMotions = new(); + + /// Read-only inspection surface (tests + future callers): the + /// pending motion queue in head-to-tail order. + public IEnumerable PendingMotions => _pendingMotions; + + /// + /// R3-W2 no-op seam standing in for retail CPhysicsObj::unstick_from_object + /// (called from / when + /// the popped queue head is action-class, i.e. Motion & 0x10000000 + /// 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 + /// Action? seam convention (see MotionTableDispatchSink.TurnStopped). + /// + public Action? UnstickFromObject { get; set; } + /// /// 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. + + /// + /// CMotionInterp::add_to_queue (0x00527b80, decomp §1a @305032): + /// allocate a and append it to the tail of + /// (retail: append at tail; if the queue was + /// empty, head and tail both point at the new node — the C# + /// gives this for free via + /// AddLast). + /// + /// Retail arg2MotionNode.context_id. + /// Retail arg3MotionNode.motion. + /// Retail arg4MotionNode.jump_error_code. + public void AddToQueue(uint contextId, uint motion, uint jumpErrorCode) + { + _pendingMotions.AddLast(new MotionNode(contextId, motion, jumpErrorCode)); + } + + /// + /// CMotionInterp::motions_pending (0x00527fe0, decomp §1b + /// @305322): pending_motions.head_ != null. + /// + public bool MotionsPending() => _pendingMotions.First is not null; + + /// + /// CMotionInterp::MotionDone (0x00527ec0, decomp §1c @305238, + /// FULL BODY). A7 (W0-pins.md): and + /// 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 + /// MovementManager::MotionDone relay. + /// + /// + /// Body (verbatim): no-op if is null or the + /// queue is empty. Peek the HEAD: if head.Motion & 0x10000000 + /// (the action-class bit) is set, fire + /// then pop the head of BOTH + /// 's and 's action + /// FIFOs. Then unconditionally dequeue the pending_motions head. + /// + /// + 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(); + } + + /// + /// CMotionInterp::HandleExitWorld (0x00527f30, decomp §1d): + /// identical body to 's single-pop logic, looped + /// until drains — world exit flushes all + /// pending motion callbacks unconditionally, since no more + /// animation-completion events will arrive. + /// + /// + /// Ambiguity resolved (not in W0-pins.md — found while porting): + /// the raw decompile's loop condition is head_ != 0 + /// (re-read every iteration), but the pop only happens inside a NESTED + /// if (physics_obj != 0 && head_ != 0) guard — a literal + /// translation would infinite-loop if were null + /// with a non-empty queue (the loop variable never advances). This is + /// dead code in retail: a live CMotionInterp::HandleExitWorld call + /// only ever happens on an interp already bound to a physics_obj + /// (retail's CMotionInterp 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 (same + /// choice / already + /// make — they too are called before a physics_obj may exist during + /// interp construction/testing). + /// + /// + 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(); + } + } + + /// + /// CMotionInterp::is_standing_still (0x00527fa0, decomp @305309): + /// on-ground (Contact + OnWalkable) AND ForwardCommand == Ready AND + /// SideStepCommand == 0 AND TurnCommand == 0. + /// + 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; + } + + /// + /// CMotionInterp::motion_allows_jump (0x005279e0, decomp §3a + /// @304908, __pure). A1 (W0-pins.md, adversarially verified): + /// PINNED as a BLOCKLIST — 0 = jump allowed (pass), 0x48 = + /// 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). + /// + /// + /// Blocklist (definitive table, W0-pins.md §A1): + /// + /// [0x1000006f, 0x10000078] — MagicPowerUp01..MagicPowerUp10. + /// [0x10000128, 0x10000131] — TripleThrustLow..MagicPowerUp07Purple. + /// 0x40000008 exact — Fallen (NOT Falling; ACE mis-transcribed this). + /// [0x40000016, 0x40000018] — Reload, Unload, Pickup. + /// [0x4000001e, 0x40000039] — AimLevel..MagicPray. + /// [0x41000012, 0x41000014] — Crouch, Sitting, Sleeping. + /// + /// Everything else — including Falling 0x40000015, Ready + /// 0x41000003, Dead 0x40000011, all turn/sidestep ids — PASSES. + /// + /// + /// + /// This is a pure function (no instance state) — retail's this + /// parameter is unused in the body (the decomp's __pure + /// attribute confirms it doesn't read this). + /// + /// + /// Retail arg2 — the motion id to test. + 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 ──────────────────────────────────────────── /// @@ -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). + /// + /// + /// R3-W2 producer (closes J1's apply_interpreted_movement leg): + /// the tail turn-stop call (raw 305766-305785) is retail's + /// CPhysicsObj::StopInterpretedMotion(physics_obj, 0x6500000d, &var_2c) + /// — our call below IS + /// that dispatch. On success (eax_10 == 0, i.e. the sink accepted + /// the stop — this port has no failure signal from + /// , 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: add_to_queue(this, var_c /*uninitialized local in the raw + /// decompile — see the final-report contextId note*/, 0x41000003, eax_10) + /// where eax_10 (the stop's own return value) becomes the queued + /// node's jump_error_code — since the stop succeeded, that value + /// is 0. + /// /// 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); } /// @@ -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). + /// + /// + /// R3-W2 producer (closes J1's DoInterpretedMotion leg): on the + /// success path (raw 305591-305610), retail computes the queue node's + /// jump_error_code via a DOUBLE-CHECK shape before calling + /// add_to_queue(context_id, arg2, eax_5): + /// + /// 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); + /// + /// This funnel has no MovementParameters threaded through yet + /// ( takes only motion/ + /// speed — the params-carrying overloads land in W5 when + /// DoMotion/StopMotion/the legacy overloads merge into + /// this backend). TODO(W5): thread the real MovementParameters + /// through and restore the 0x20000 (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). + /// ContextId is 0 pending the same W5 plumbing (no + /// MovementParameters.ContextId reaches this call site yet) — + /// see the final-report contextId note for the evidence this funnel + /// genuinely has nothing else to pass. + /// /// 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; } diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterPendingMotionsTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterPendingMotionsTests.cs new file mode 100644 index 00000000..ac23a5f0 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterPendingMotionsTests.cs @@ -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; + +/// +/// R3-W2 — CMotionInterp::pending_motions lifecycle + the +/// MotionDone 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. +/// +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 ── + + /// In-memory that never resolves — + /// matches MotionTableManagerTests.cs's fixture convention. + 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()); + } +}