# R3 port work-list — CMotionInterp completion + local-player unification Inputs: `r3-motioninterp-decomp.md` (verbatim retail extraction, same dir), `r3-ace-motioninterp.md` (ACE cross-ref, same dir), plan of record `docs/plans/2026-07-02-retail-motion-animation-rewrite.md` (stage R3), R2 boundary contract `docs/research/2026-07-02-r2-motiontable/r2-port-plan.md` §4, current code `src/AcDream.Core/Physics/MotionInterpreter.cs` (1426 lines, post-D6/S2a), `src/AcDream.App/Input/PlayerMovementController.cs` (1521 lines), `src/AcDream.Core/Physics/InterpretedMotionFunnel.cs`, GameWindow K-fix18 sites. **Precondition / state at R3 start (updated at vaulting, 2026-07-02):** R1 complete (`a987cad1` P6). R2 is IN FLIGHT: Q0 (`dc54a3e4`) + Q1 MotionState (`2345da30`) + Q2 `Motion/CMotionTable.cs` (`98f58db9`, 44 tests) committed; Q3 (MotionTableManager + `IMotionDoneSink`) in flight, Q4 (adapter cutover + queue-drain wiring) and Q5 (RemoteMotionSink deletion) NOT started. `RemoteMotionSink.cs` still exists (217 lines). **R3 commits W2+ hard-depend on R2 Q3/Q4/Q5 being shipped** (the `IMotionDoneSink` seam, the sentinel→`AnimationDone` drain, and funnel-direct `PerformMovement` dispatch are R2 deliverables R3 consumes). W0/W1 are independent and can start while R2 finishes. --- ## 0. DECOMP AMBIGUITIES TO PIN before porting (the W0 pseudocode commit) | # | Ambiguity | Evidence each way | Pin method | |---|---|---|---| | A1 | **`motion_allows_jump` 0x48 polarity is INVERTED in the BN extraction's "cleaned" note.** r3-motioninterp-decomp §3a annotates `0x48` = "jump allowed" and the range set as a whitelist. But the CALLER `jump_is_allowed` (0x005282b0, §3h) does `eax_7 = motion_allows_jump(fwd); if (eax_7 != 0) return eax_7;` — nonzero is returned AS THE ERROR, so **0x48 = "YouCantJumpFromThisPosition"-class BLOCK and the ranges are the blocklist** (matches ACE MotionInterp.cs:770-779: five blocked ranges + one exact id, return None otherwise). Under the corrected polarity the retail blocklist is: `[0x1000006f,0x10000078]`, `[0x10000128,0x10000131]`, `0x40000008 (Fallen)`, `[0x40000016,0x40000018]`, `[0x4000001e,0x40000039]`, `[0x41000012,0x41000014] (Crouch..Sleeping)`; everything else (incl. **0x40000015 Falling → 0 = pass**) falls through. ACE instead blocks exactly `Falling` and never `Fallen` — one of the two mis-transcribed a constant. The same wrong "0x48 = allowed" annotation propagates into the decomp doc's §10 constants inventory. | decomp §3a/§3h vs ACE L770-779; acdream MotionCommand.cs: Fallen=0x40000008, Falling=0x40000015 | Re-read raw pseudo-C @305 line 304908 for the exact comparison chain; map every boundary constant through the dat MotionCommand table (does 0x40000015 really pass?); decisive cdb: bp `acclient!CMotionInterp::motion_allows_jump` log arg2+eax while user jumps during walk/run/mid-fall/crouch. **Port the pinned blocklist as literal uint ranges, NOT enum-ordinal ranges** (ACE's enum-order dependence is the flagged fragility). | | A2 | **`jump_is_allowed` pending-head peek gate.** Retail (§3h full body): peek `pending_motions.head_` whenever non-empty; if `head.jump_error_code != 0` return it. ACE (L756): `PendingMotions.Count > 1 && First.JumpErrorCode != 0` — the `Count > 1` has no retail counterpart in this decomp. | decomp §3h (no count check) vs ACE L756 | Retail decomp is full-body and unambiguous → port retail (head peek whenever queue non-empty). Note in pseudocode why ACE differs (their head = currently-playing heuristic). ACE's L747 `WeenieObj == null && !WeenieObj.IsCreature()` NPE-typo is CONFIRMED wrong vs retail's `weenie != 0 && IsCreature()==0` gate — do not copy. | | A3 | **`apply_current_movement` / `SetWeenieObject` / `SetPhysicsObject` dual-dispatch gate: `IsThePlayer` or `IsCreature`?** The decomp extraction (§4c) states ReportExhaustion gates on `IsThePlayer() && movement_is_autonomous()` and claims the identical pattern in apply_current_movement/SetWeenieObject/SetPhysicsObject. ACE apply_current_movement (L430-438) gates on `IsCreature`. These are NOT the same predicate: a remote player is a creature but not the player. Client-side, `IsThePlayer` is the coherent reading (raw_state is only meaningful for the locally-controlled object); ACE is a server where its own physics objects are all "the player". Getting this wrong sends remotes down `apply_raw_movement` and corrupts their interpreted state from an empty raw state. | decomp §4c text vs ACE L430/L497 | Re-read raw pseudo-C at 0x00528870 (line 305838, apply_current_movement), 0x00528920 (SetWeenieObject), 0x00528970 (SetPhysicsObject) — the vtable slot called distinguishes `IsThePlayer` from `IsCreature`. If still ambiguous, cdb bp with `this` dump on a remote-visible session. Default: **IsThePlayer-based for apply_current_movement/ReportExhaustion** (decomp's explicit ReportExhaustion body shows IsThePlayer; §4c says the others are identical). | | A4 | **MovementParameters bit numbering.** DoMotion reads byte-1 bits: `&8` → SetHoldKey (=0x800 overall), `&0x20` → raw mirror (=0x2000); acclient.h's union comment says "bit0x8=SetHoldKey, bit0x20=Raw mirror, bit0x40=Interpreted mirror" (byte-relative). Sign bit = interrupt. Ctor default = `(bitfield & 0xfffdee0f) | 0x1ee0f`. | decomp §0/§2 vs acclient.h:31453 comment | Derive the absolute bit positions from the ctor default 0x1ee0f + ACE MovementParameters property names (CancelMoveTo/SetHoldKey/ModifyRawState/ModifyInterpretedState/DisableJumpDuringLink/…) which ACE mapped from the same bitfield. Document the full flag table in the pseudocode doc; the C# port uses named bool properties + a `ToBitfield()` only if the wire ever needs it. | | A5 | **`get_jump_v_z` no-weenie fallback + near-zero path.** BN garbles the `extent < 0.0002` path (x87 flag noise) and reads the no-weenie return as "clamped extent"; ACE: `< EPSILON → 0.0f`, no-weenie → `10.0f`, InqJumpVelocity false → `0.0f`. Current acdream uses ACE's shape but with epsilon **0.001** (wrong — retail literal is `0.000199999995f`). | decomp §3c vs ACE L634-652 vs MotionInterpreter.cs:278 | Textual pin at 0x00527aa0 raw. Low stakes (creatures always have a weenie); fix the epsilon regardless. | | A6 | **`get_leave_ground_velocity` zero-fallback matrix direction.** BN reads `m_fl2gv` (local→global) applied to the velocity; ACE does `GlobalToLocalVec(Velocity)`. Since the result feeds `set_local_velocity` (a LOCAL vector) and `m_velocityVector` is global, global→local is the only coherent reading — BN row/column confusion. Current `Quaternion.Inverse(Orientation)` transform (MotionInterpreter.cs:1024) IS global→local: keep. Epsilon must become 0.0002 (currently 0.001 via JumpExtentEpsilon). | decomp §3d vs ACE L654-663 vs current :1011-1029 | Adjudicate textually in pseudocode; conformance test: walk-off-ledge with nonzero world velocity preserves momentum direction. | | A7 | **`MovementManager::MotionDone` uninit-`edx` relay + dead `arg2` in `CMotionInterp::MotionDone`.** Decompiler artifact (adjudicated in the decomp doc §6d). Port as `MotionDone(uint motion, bool success)` passing the completed node's motion id; the interp body reads NEITHER param in this build — **pop the HEAD unconditionally, never match by motion id**. Keep the params for R5 signature parity. | decomp §1c/§6d | Documented adjudication only. | | A8 | **`enter_default_state` does NOT clear `pending_motions`.** Retail (§4d): resets raw+interp state, InitializeMotionTables, then APPENDS the `{0, 0x41000003, 0}` sentinel to whatever queue exists — no drain. ACE resets `PendingMotions = new LinkedList` with the author's own `// ??`. Port retail (append, no clear); note the MovementManager lazy-create path calls enter_default_state TWICE on first touch (retail double-call, §6g) — genuine retail, do not "fix", but R3's direct-bind construction calls it once explicitly. | decomp §4d/§6g vs ACE L604-616 | Documented adjudication; test asserts pre-existing nodes survive an enter_default_state. | | A9 | **`StopCompletely` jump-snapshot quirk**: `motion_allows_jump` is evaluated on the OLD forward_command, then the state is overwritten and the OLD result is stashed in the queued Ready node. Port verbatim including the quirk (ACE L301-327 agrees). | decomp §5a + ACE | Textual pin only; golden asserts the queued node's error code reflects the pre-stop command. | | A10 | **Error-code renumber vs current acdream `WeenieError`.** Retail numeric usage: `8`=no physics obj, `0x24`=not grounded (jump_is_allowed airborne), `0x3f/0x40/0x41`=combat-stance crouch/sit/sleep rejects, `0x42`=combat-stance chat-emote-bit reject, `0x45`=action depth ≥6, `0x47`=constrained OR stamina OR bad MovementStruct.type, `0x48`=motion/position blocks jump, `0x49`=CanJump refused. acdream's current enum (MotionInterpreter.cs:113-127) has the NAMES shuffled (0x24 named GeneralMovementFailure but used where retail uses 0x48-class; jump_is_allowed returns 0x48 for airborne where retail returns 0x24). | decomp §10 vs MotionInterpreter.cs:113-127, :1064 | W1 fixes the enum to retail's numeric semantics (add 0x3f/0x40/0x41/0x42/0x45); W3/W5 use them. Codes are local-only (never on the wire) — safe rename. | **W0 cdb capture (one session serves all of R3):** bp `CMotionInterp::MotionDone` / `add_to_queue` / `jump` / `jump_is_allowed` / `jump_charge_is_allowed` / `charge_jump` / `motion_allows_jump` / `HitGround` / `LeaveGround` / `StopCompletely` / `set_hold_run` / `SetHoldKey` / `enter_default_state` with arg+ret logging (pattern: tools/cdb/l2g-observer.cdb). User protocol: idle / walk / run / shift-toggle mid-walk / tap-jump / full-charge jump / running jump / standing long-jump (charge while idle, then W mid-charge — the StandingLongJump suppression) / jump attempt while crouched (blocked codes) / jump attempt in combat stance (0x3f-0x42 family) / walk off a ledge (LeaveGround without jump — the momentum fallback) / land / emote-while-running (action-class node through MotionDone) / 7 rapid emotes (0x45 depth cap) / logout (HandleExitWorld drain). --- ## 1. ITEMIZED GAPS — current vs retail (R3 scope) Severity: **BLOCKER** = R3's conformance harness meaningless without it; **HIGH** = visible behavior wrongness / blocks R4-R6; **MED** = edge-visible; **LOW** = textual. | # | Retail behavior acdream lacks/diverges on | Retail anchor | Current-code anchor | Severity | |---|---|---|---|---| | J1 | **No `pending_motions` queue** — no MotionNode, no `add_to_queue` (append `{context_id, motion, jump_error_code}`), no `MotionDone` (head action-class 0x10000000 → unstick + Interpreted+Raw `RemoveAction`, then unconditional head pop), no `motions_pending`, no `HandleExitWorld` drain loop. The R2 `IMotionDoneSink` signal dead-ends in a diagnostic recorder (per r2-port-plan Q4). | add_to_queue 0x00527b80 @305032; MotionDone 0x00527ec0 @305238; motions_pending 0x00527fe0; HandleExitWorld 0x00527f30; ACE MotionInterp.cs:210-234/388-392/160-173 | `MotionInterpreter.cs:1394` ("Idle add_to_queue(Ready) bookkeeping lands with S3") + `:1417` ("add_to_queue … S3 bookkeeping") — both TODO comments; no queue field exists | **BLOCKER** | | J2 | **No action FIFO on the states** — retail `RawMotionState`/`InterpretedMotionState` each carry an actions list (`ApplyMotion`/`RemoveMotion`/`AddAction`/`RemoveAction`/`GetNumActions`); MotionDone's action-class pop and DoMotion's ≥6 depth cap operate on it. acdream's `InterpretedMotionState` (MotionInterpreter.cs:185-210) + `LegacyRawMotionState` (:148-179) are flat 6/7-field structs; the outbound `RawMotionState` packs an always-empty action list (register TS-24). | acclient.h RawMotionState/InterpretedMotionState; DoMotion @306159 GetNumActions≥6; MotionDone RemoveAction pair | `MotionInterpreter.cs:148-210`; `Core/Physics/RawMotionState.cs` (packer type, no actions) | **BLOCKER** | | J3 | **DoMotion is a 12-line approximation.** Retail 0x00528d20: interrupt on sign bit; `SetHoldKey` on bit-SetHoldKey BEFORE `adjust_motion`; fresh local `MovementParameters` re-default (caller's distance/heading fields discarded for the interpreted call); combat-stance gate (`current_style != 0x8000003d` rejects 0x41000012/13/14 → 0x3f/0x40/0x41 and `motion & 0x2000000` → 0x42 — tested on the ORIGINAL id); action-depth cap → 0x45; delegate `DoInterpretedMotion(adjusted, localParams)`; mirror `RawState.ApplyMotion(ORIGINAL id)` on the mirror bit. Current: writes RawState.Forward\* then calls the legacy DoInterpretedMotion — no params, no gates, no mirror discipline. | 0x00528d20 @306159; ACE L112-158 | `MotionInterpreter.cs:448-462` | **BLOCKER** | | J4 | **TWO parallel DoInterpretedMotion implementations, both partial.** The legacy overload (:473-491) hand-applies state + apply_current_movement; the S2a funnel `DispatchInterpretedMotion` (:1408-1425) has the verbatim contact gate + sink dispatch but no StandingLongJump special-case (Walk/Run/SideStepRight while charging → state-only, NO dispatch, NO queue), no `Dead → RemoveLinkAnimations`, no jump_error_code compute (double `motion_allows_jump` check: incoming motion, then ForwardCommand if non-action) + `add_to_queue`, no `ModifyInterpretedState` param, no `CurCell==null → RemoveLinkAnimations` tail. Same story for StopInterpretedMotion (:527-553 vs the funnel's bare `sink.StopMotion`): missing post-stop `add_to_queue(ctx, Ready, None)` + raw `RemoveMotion` mirror. They must MERGE into one verbatim pair. | DoInterpretedMotion 0x00528360 (S2a base) + queue callers @305607/305657/305775; ACE L51-110/329-365 | `MotionInterpreter.cs:473-491`, `:498-553`, `:1408-1425`, `:1358-1396` | **BLOCKER** | | J5 | **Jump gate family absent**: no `motion_allows_jump` (0x005279e0 literal-range blocklist, A1), no `jump_charge_is_allowed` (0x00527a50: CanJump → 0x49; Fallen/Crouch..Sleeping → 0x48), no `charge_jump` (0x005281c0: arms `standing_longjump` only when on-ground + fwd==Ready + no sidestep/turn). `jump_is_allowed` (:1053-1071) is a 15-line approximation missing the creature/state-0x400 entry shape, IsFullyConstrained → 0x47, the pending-head jump_error_code peek (A2), the charge→motion→stamina chain, and `JumpStaminaCost` (not on IWeenieObject). Wrong airborne code (0x48, retail 0x24 — A10). | §3a/3b/3e/3h; ACE L729-779 | `MotionInterpreter.cs:1053-1071`; IWeenieObject :239-256 (no JumpStaminaCost/IsThePlayer) | **BLOCKER** | | J6 | **StandingLongJump armed in the WRONG function** — the S2a-flagged misattribution: acdream arms it as a side effect of `contact_allows_move` (:1139-1148, explicitly marked "PRE-EXISTING acdream side effect (not part of 0x00528240)"); retail arms it ONLY in `charge_jump`. Consequence today: every grounded idle contact check flips the flag, so the funnel's StandingLongJump branch (:1371-1375) can fire without any jump charge. | charge_jump 0x005281c0 @305448 | `MotionInterpreter.cs:1139-1148` | **HIGH** | | J7 | **jump() missing `interrupt_current_movement`** (retail always cancels the in-flight transition first; ACE `cancel_moveto`). And the CONTROLLER manually calls `LeaveGround()` + pre-captures `get_jump_v_z` at key-release (PlayerMovementController.cs:996-1028) — retail's LeaveGround fires from the physics layer detecting ground departure (`set_on_walkable(false)` → transition → MovementManager::LeaveGround). Walk-off-a-ledge never calls LeaveGround at all today (the get_leave_ground_velocity momentum fallback is dead code). | jump 0x00528780 @305792; LeaveGround 0x00528b00 (NOT the doc-comment's 0x00529710) | `MotionInterpreter.cs:943-958`; PlayerMovementController.cs:996-1028, :1226-1257 (transition block calls HitGround but never LeaveGround) | **HIGH** | | J8 | **HitGround/LeaveGround bodies incomplete**: both missing the creature gate (`weenie==null OR IsCreature`) and `RemoveLinkAnimations`; LeaveGround missing the state-0x400 gate, `apply_current_movement(0,0)` re-sync, and the autonomous flag on `set_local_velocity(v, 1)`. **This is the retail mechanism K-fix18 fakes**: leaving ground strips pending link animations, so Falling engages instantly — no `skipTransitionLink` needed. | HitGround 0x00528ac0 @305996; LeaveGround 0x00528b00 @306022 | `MotionInterpreter.cs:1166-1198`; K-fix18 sites: AnimationSequencer.cs:305/313/390/401/433, GameWindow.cs:4817-4831, :10194-10224 | **HIGH** | | J9 | **StopCompletely missing** interrupt, the A9 jump snapshot, `add_to_queue(0, Ready, jumpErr)`, and cell-null `RemoveLinkAnimations`; extra divergence: resets sidestep/turn SPEEDS to 1.0 (retail writes only fwd cmd/speed + zeroes sidestep/turn COMMANDS). `set_velocity(Zero)` stands in for `StopCompletely_Internal` (acceptable, keep + note). | 0x00527e40 @305208 | `MotionInterpreter.cs:577-602` | **HIGH** | | J10 | **No `enter_default_state` / `initted`**: no state reset + InitializeMotionTables + Ready-sentinel enqueue (A8) + `initted=1` + unconditional LeaveGround; nothing gates `apply_current_movement`/`ReportExhaustion` on initted. | 0x00528c80 @306124 | constructors at `MotionInterpreter.cs:386-398` only | **BLOCKER** | | J11 | **`apply_current_movement` is a direct-velocity approximation**, not the retail dual dispatch (`initted` gate → IsThePlayer/autonomous (A3) → `apply_raw_movement(cancelMoveTo, allowJump)` else `apply_interpreted_movement(...)`). The grounded `set_local_velocity(get_state_velocity())` write is an acdream adaptation of root-motion-driven velocity — survives R3 (register row), retires in R6. Also: no `(cancelMoveTo, allowJump)` param plumbing anywhere — `DisableJumpDuringLink = !allowJump` feeds the jump_error_code path (ACE L538/L232). | 0x00528870 @305838 (A3 pin); ACE L430-438 | `MotionInterpreter.cs:905-925`; funnel `ApplyInterpretedMovement` :1358 (no params) | **HIGH** | | J12 | **No `ReportExhaustion`** (initted gate + IsThePlayer/autonomous dual dispatch, both `(0,0)` args); no `movement_is_autonomous` flag on PhysicsBody; no `IsThePlayer` on IWeenieObject. Retail relay chain: MovementManager::ReportExhaustion → CMotionInterp (+ MoveToManager, R4). The dead ACE `NoticeHandler.RecvNotice_PrevSpellSelection` breadcrumb is NOT R3 scope (spell-UI notice) — file as a note, don't port. | 0x005288d0 @305861 | nothing | MED-HIGH (needed for stamina-exhaustion demotes; wire caller lands with server stamina events) | | J13 | **No `SetHoldKey`/`set_hold_run`** — retail's Shift edge handling (XOR toggle guard, None-only-meaningful-from-Run, both re-apply movement). acdream fakes it by rebuilding the whole RawMotionState every frame (J15). `HoldKey_None=1 / HoldKey_Run=2` encoding already matches acdream's HoldKey enum usage. | set_hold_run 0x00528b70 @306053; SetHoldKey 0x00528bb0 @306072 | `MotionInterpreter.cs:340` (bare CurrentHoldKey field, written by apply_raw_movement only) | **HIGH** | | J14 | **No `CMotionInterp::PerformMovement` 5-way dispatch** with `CheckForCompletedMotions` after EVERY op (the synchronous zero-tick drain — retail calls it at 306234-306262 after each of DoMotion/DoInterpretedMotion/StopMotion/StopInterpretedMotion/StopCompletely). Current PerformMovement (:416-430) dispatches to the approximations and has a comment explicitly skipping the flush. Needs a seam to the entity's MotionTableManager (R2-Q3). | 0x00528e80 @306221; CPhysicsObj::CheckForCompletedMotions 0x0050fe30 | `MotionInterpreter.cs:400-430` | **BLOCKER** | | J15 | **Local player motion state machine lives in PlayerMovementController, level-triggered.** Per-frame RawMotionState rebuild from held keys (:887-910) instead of retail's edge-driven DoMotion/StopMotion + set_hold_run through PerformMovement; jump charge/fire block calls `_motion.jump` + manual LeaveGround (:986-1028); outbound wire commands re-derived from `input` (:1264-1328) instead of read from the raw state; `LocalAnimationCommand`/`LocalAnimationSpeed` synthesized (:1284-1296, :1418-1456) instead of the interpreted state driving the sequencer through the same funnel path remotes use. This is the plan-of-record's "motion half of PlayerMovementController" — R3's unification target. | DoMotion/StopMotion callers = CommandInterpreter edges; set_hold_run call sites §5c (0x0058b303 / 0x006b33ca) | PlayerMovementController.cs:887-910, :986-1028, :1264-1359, :1418-1456; GameWindow.cs:10062-10234 (UpdatePlayerAnimation fallback chain + SetCycle drive) | **BLOCKER** for the stage's named deliverable | | J16 | **Epsilons + codes**: `JumpExtentEpsilon = 0.001` used in get_jump_v_z AND get_leave_ground_velocity — retail is `0.000199999995f` in both (§10); WeenieError names shuffled vs retail numerics (A10). | §3c/§3d/§10 | `MotionInterpreter.cs:278`, :978, :1020; :113-127 | MED | | J17 | **`is_standing_still` absent** (on-ground + Ready + no sidestep/turn) — trivial; needed by callers (input layer, R7 cadence) and by the decomp's documented substitute set for the not-in-retail `IsAnimating`/`IsMovingOrAnimating`. **Do NOT invent a combined IsAnimating helper** — retail has none (decomp §7f negative result); ACE's `PhysicsObj.IsAnimating` flag is an ACE-ism (retail queries `motions_pending`). | 0x00527fa0 @305309 | nothing | LOW | | J18 | **`adjust_motion` creature guard unwired** (register TS-34): `IWeenieObject.IsCreature()` exists (default true) but adjust_motion ignores it (comment ":758 Creature guard unwired"). One-line fix; retires TS-34. | adjust_motion @305343 gate | `MotionInterpreter.cs:756-758` | LOW | | J19 | **VectorUpdate remote-jump path bypasses the interp** (GameWindow.cs:4782-4840): sets body velocity/airborne + forces the Falling cycle via `SetCycle(..., skipTransitionLink:true)` (K-fix18 site 1). Retail: the remote's physics leaves ground → LeaveGround → RemoveLinkAnimations + apply_current_movement → contact-blocked funnel dispatches Falling. After J8, this becomes `rm.Motion.LeaveGround()` (+ the funnel's existing Falling dispatch). | SmartBox::DoVectorUpdate 0x004521C0 → set_velocity/set_omega; LeaveGround chain | GameWindow.cs:4802-4833 | **HIGH** | --- ## 2. KEEP LIST — already matching retail (do not re-port) | Behavior | Retail anchor | acdream anchor | |---|---|---| | Inbound funnel dispatch order: `MoveToInterpretedState` (flat copy + 15-bit stamp gate + local-echo skip) / `ApplyInterpretedMovement` axis order (style → fwd-or-Falling → sidestep(-stop) → turn(-stop, early return)) + 183-case live-trace suite | 0x005289c0 / 0x00528600 (S2a, `7b0cbbda`) | `MotionInterpreter.cs:1312-1396` — W5 merges the Dispatch backend into verbatim DoInterpretedMotion WITHOUT touching dispatch order; suite is the parity bar | | `contact_allows_move` verbatim body (turns/Falling/Dead always-allowed, non-creature bypass, gravity bypass, Contact+OnWalkable) | 0x00528240 (S2a rewrite) | `MotionInterpreter.cs:1096-1151` — MINUS the :1139-1148 StandingLongJump side effect (J6 deletes it) | | `adjust_motion` / `apply_run_to_command` / `apply_raw_movement` / `get_state_velocity` incl. the RunForward-early-return, sign-gated promotion, ±3.0 sidestep clamp, max-speed clamp | D6/D6.2a (`0f099bb6`) | `MotionInterpreter.cs:654-888` — untouched except J18's one-line guard | | `GetMaxSpeed` ×4.0 (UN-2 byte-verified vs the BN x87 dropout) | 0x00527cb0 | `MotionInterpreter.cs:1235-1245` | | `GetCycleVelocity` Option-B forward-velocity source (MotionData.Velocity over the constant) | adaptation, r03 §1.3 | `MotionInterpreter.cs:382`, :668-683 — keep; verify register row exists, retire when R6 root motion drives the body | | `StopCompletely_Internal` stand-in = `PhysicsObj.set_velocity(Zero)` | 0x0050f5a0 is CPhysicsObj-side | `MotionInterpreter.cs:599` — keep + register note (full port is physics-layer scope) | | Jump charge accumulation UI timing (2.0 extent/s, AP-24) + extent→height formula | GetPowerBarLevel 0x0056ADE0 illegible | PlayerMovementController.cs:167-178 — charge STAYS controller-side (retail's charge lives at the SmartBox/input boundary too, §3e caller 0x0056afac); AP-24 row survives | | Physics tick gate (30 Hz MinQuantum), Resolve/bounce/landing detection, AP diff cadence, PortalSpace, SetPosition/SnapToCell, NotePositionSent | L.5/L.2c/B.6 anchors in-file | PlayerMovementController.cs sections 4-5, 8 — R3 does not touch; R6 owns tick order | | Auto-walk (B.6) block incl. `DoMotion(Ready)`/`DoMotion(WalkForward)` drive | MovementManager case 6 stand-in | PlayerMovementController.cs:254-766 — survives R3 verbatim (calls route through the new DoMotion transparently); REPLACED in R4 by MoveToManager | | Outbound wire packers (RawMotionState::Pack, MoveToStatePack, JumpPack) + dual command catalogs | L.2b/D6.2b/L.1b | untouched; W6 changes only WHERE the packed values are read from (raw state instead of re-derivation) | | `MotionSequenceGate` (S1), `InterpolationManager` (L.3), R1 CSequence core, R2 CMotionTable/MotionState/MotionTableManager | plan "absorbed" + R2 | untouched — R3 sits between MovementManager(-to-be) and the R2 queue | | `0x41000003` Ready sentinel / `0x10000000` action-class bit / `0x8000003d` NonCombat conventions | §10 | already the file-wide constants | --- ## 3. COMMIT SEQUENCE — dependency-sorted, each ONE commit, tests-first New code target: extend `src/AcDream.Core/Physics/MotionInterpreter.cs` in place (it IS the CMotionInterp port; plan rule 4's `Motion/` dir holds the R2 table/queue classes it talks to). Tests: `tests/AcDream.Core.Tests/Physics/` (existing MotionInterpreter suites + `Motion/` for new fixtures). Every commit: build+test green, register rows added/retired in-commit. **W0 — pseudocode + ambiguity pinning (docs only).** `docs/research/2026-07-0x-motioninterp-pseudocode.md` from r3-motioninterp-decomp.md, resolving A1-A10 (§0) — the A1 polarity fix and A3 dispatch-gate pin are load-bearing; correct the decomp doc's §3a/§10 annotations in the same commit. Run the ONE cdb session (§0 protocol) — feeds W2/W3/W5/W6 goldens. Fixture source: **cdb** (live retail). Deps: none (parallel with R2 completion). **W1 — retail state completion: action FIFO + MovementParameters + WeenieError renumber.** (closes J2, J16-codes/A10) `InterpretedMotionState`/`RawMotionState` gain the actions list (`AddAction`/`RemoveAction`/`GetNumActions`/`ApplyMotion`/`RemoveMotion` per retail member set; the outbound packer's empty-list TS-24 comment updates to "populated by CMotionInterp"); fold `LegacyRawMotionState` into the full-field `AcDream.Core.Physics.RawMotionState` (one raw-state type; delete the legacy struct); `MovementParameters` verbatim class (ctor defaults: distance_to_object=0.6, fail_distance=FLT_MAX, speed=1, walk_run_threshhold=15, hold_key=Invalid, bitfield 0x1ee0f per A4 pin; named bool properties); `MotionNode {ContextId, Motion, JumpErrorCode}`; WeenieError renumbered to retail (add 0x3f/40/41/42/45, fix 0x24/0x47/0x48 semantics). Tests first: FIFO discipline; params defaults vs ctor decomp; existing suites green under the raw-state fold. Fixture source: **synthetic**. Deps: W0. **W2 — pending_motions lifecycle + the MotionDone consumer (R2 seam lands).** (closes J1, J17; §4 below is this commit's wiring spec) `MotionInterpreter` gains `PendingMotions` (LinkedList — register note reuses AD-34 wording), `add_to_queue` 0x00527b80, `motions_pending` 0x00527fe0, `MotionDone(uint motion, bool success)` 0x00527ec0 (head-peek action-class → unstick seam (no-op Action? callback until R5 StickyManager — register row) + `InterpretedState.RemoveAction()` + `RawState.RemoveAction()`; unconditional head pop; NEVER match by motion id per A7), `HandleExitWorld` 0x00527f30 (loop), `is_standing_still` 0x00527fa0, and `motion_allows_jump` 0x005279e0 (pure fn, pinned per A1 — needed here because every add_to_queue caller computes jump_error_code). Wire the queue's PRODUCERS into the funnel: `DispatchInterpretedMotion` success path computes jump_error_code (double-check per ACE L231-239 shape pinned at W0) + `add_to_queue(ctx, motion, err)`; `ApplyInterpretedMovement`'s turn-stop adds `add_to_queue(ctx, Ready, None)` (@305775); funnel StopMotion path adds the post-stop Ready node (@305657). GameWindow: replace R2-Q4's diagnostic `IMotionDoneSink` binding with the real per-entity bind (§4); teleport/despawn/exit paths call BOTH `manager.HandleExitWorld()` (R2) and `interp.HandleExitWorld()` (retail has both layers' drains). Tests first: queue tables (enqueue per successful dispatch incl. Ready stops; pop order; action-class pops from BOTH states + fires unstick; exit drain); S2a 183-case suite green (dispatch unchanged, queue side effects asserted additively); end-to-end chain test: MotionTableManager.AnimationDone → sink → interp pops (§4 diagram). Fixture source: **synthetic + W0 cdb goldens** (add_to_queue arg conformance). Deps: W1 + **R2 Q3/Q4 shipped**. **W3 — jump family verbatim.** (closes J5, J6, J7-interp-side, J16-epsilons) `jump_charge_is_allowed` 0x00527a50; `charge_jump` 0x005281c0 (arms StandingLongJump; **DELETE the contact_allows_move side effect :1139-1148** — J6); `get_jump_v_z` (epsilon → 0.000199999995f; A5 fallback pinned); `get_leave_ground_velocity` (same epsilon; A6 direction confirmed); `jump_is_allowed` 0x005282b0 full chain (creature/ state-0x400 entry shape → 0x24; `IsFullyConstrained` seam on PhysicsBody (stub false + register row); pending-head peek per A2; charge → motion_allows_jump(fwd) → `JumpStaminaCost` (new IWeenieObject member; PlayerWeenie implements — real gating stays TS-5-deferred, row survives)); `jump` 0x00528780 (+`interrupt_current_movement` seam — no-op Action? until R4 cancel_moveto exists; register row). IWeenieObject gains `IsThePlayer()` (default false; PlayerWeenie true) for W4's A3 dispatch. Tests first: per-error-code gate tables from W0 goldens; StandingLongJump arming matrix (grounded+idle only, cleared on failed jump); head-peek short-circuit; blocked-motion jump attempts (crouch range per pinned A1 table). Fixture source: **W0 cdb goldens + synthetic**. Deps: W2. **W4 — ground transitions + lifecycle: HitGround/LeaveGround/enter_default_state/ReportExhaustion/hold keys; K-fix18 DELETED.** (closes J8, J10, J11-shape, J12, J13, J19; J18 one-liner rides along) `HitGround` 0x00528ac0 verbatim (creature gate + Gravity + `RemoveLinkAnimations` seam + `apply_current_movement(false, true)`); `LeaveGround` 0x00528b00 verbatim (velocity + autonomous flag arg on set_local_velocity + resets + RemoveLinkAnimations + apply_current_movement); `RemoveLinkAnimations` seam = `Action?` on MotionInterpreter, App binds to the entity's AnimationSequencer (CSequence.RemoveAllLinkAnimations per CPhysicsObj::RemoveLinkAnimations 0x0050fe20 → CPartArray → sequence); `enter_default_state` 0x00528c80 (A8: append sentinel, no clear; `Initted` field; LeaveGround tail); `Initted` gates apply_current_movement + ReportExhaustion; `apply_current_movement` becomes the verbatim dual dispatch per A3 (the direct grounded-velocity write MOVES to the controller-side call site unchanged — register row: velocity-from-state adaptation until R6 root motion); `ReportExhaustion` 0x005288d0 (A3 gate; `movement_is_autonomous` flag added to PhysicsBody, set true at the local-player chokepoint, false on DR-applied updates); `SetHoldKey` 0x00528bb0 + `set_hold_run` 0x00528b70; `SetPhysicsObject`/`SetWeenieObject` re-apply pattern. **PlayerMovementController transition wiring:** grounded→airborne edge (section 5, :1245-1257) now calls `_motion.LeaveGround()`; the jump-fire block stops calling LeaveGround manually — `jump()` sets on_walkable(false) and the SAME frame's transition detection fires LeaveGround (ordering test); JumpAction wire velocity read from the LeaveGround-computed vector (get_leave_ground_velocity at fire time — byte-identical values, source unified). **K-fix18 DELETION (both sites + the parameter):** GameWindow :4817-4833 remote VectorUpdate → `rm.Motion.LeaveGround()` replaces the forced SetCycle (J19); GameWindow :10194-10224 skipLink computation + arg deleted; `AnimationSequencer.SetCycle`'s `skipTransitionLink` parameter + its post-dispatch link-clear (whatever shape R2-Q4 left it in) deleted; K-fix18 register row (r2-port-plan keep-list says "register row kept → R3") retired — if no row exists, the sweep note records it was comment-only. Tests first: HitGround/LeaveGround call RemoveLinkAnimations + re-apply (seam mock); ledge walk-off momentum fallback (A6); jump-fire → LeaveGround same-tick ordering; enter_default_state seeds queue + initted + LeaveGround; ReportExhaustion dispatch truth table (A3 pin); hold-key toggle tables (set_hold_run XOR guard; SetHoldKey None-only-from-Run). Visual acceptance carried to W6's pass: jump engages Falling instantly WITHOUT K-fix18. Fixture source: **synthetic + W0 cdb goldens (LeaveGround/HitGround arg traces)**. Deps: W3 (+ R2-Q4 committed for the sequencer seam shape). **W5 — DoMotion/StopMotion/StopCompletely verbatim + the ONE DoInterpretedMotion + PerformMovement flush.** (closes J3, J4, J9, J14) `DoMotion` 0x00528d20 (interrupt bit; SetHoldKey bit BEFORE adjust_motion; params re-default; combat-stance gate on ORIGINAL id → 0x3f/0x40/0x41/0x42; depth cap 0x45; raw mirror ORIGINAL id); `StopMotion` 0x00528530 (mirror shape, no gates, raw RemoveMotion ORIGINAL id); `StopCompletely` 0x00527e40 (A9 verbatim incl. quirk + exact-fields-only reset + add_to_queue + cell-null RemoveLinkAnimations); **merge** legacy `DoInterpretedMotion`/`StopInterpretedMotion` overloads with the funnel Dispatch backend into ONE verbatim `DoInterpretedMotion(uint, MovementParameters)` / `StopInterpretedMotion(uint, MovementParameters)` (StandingLongJump state-only branch; Dead → RemoveLinkAnimations; jump_error_code + add_to_queue (moves here from W2's interim funnel site); ModifyInterpretedState; CurCell-null tail — CurCell proxy = `PhysicsObj.CellId == 0`); **DELETE** `ApplyMotionToInterpretedState` (:1253-1282) + the legacy overloads; `PerformMovement` gains the per-op `CheckForCompletedMotions` seam call (bound to the entity's MotionTableManager UseTime/CheckForCompletedMotions — R2-Q3 object) after every dispatch. Tests first: DoMotion gate tables (each combat-stance code, depth cap, mirror discipline: raw gets ORIGINAL id, interpreted gets adjusted) from W0 goldens; S2a 183-case suite green under the merged backend (assertions unchanged — dispatch order is funnel-owned); zero-tick flush test (stop-with-no-link completes same call). Fixture source: **W0 cdb goldens + suite**. Deps: W2+W4 (+ R2-Q5 so the funnel's sink IS PerformMovement dispatch — otherwise the merge has two consumers). **W6 — LOCAL PLAYER UNIFICATION (the GameWindow + controller commit; do NOT fan out — feedback_dont_parallelize_coupled_plan_slices).** (closes J15) PlayerMovementController sheds the motion state machine; edge-driven retail input: - **DIES in the controller:** per-frame RawMotionState rebuild + `apply_raw_movement` call (:878-910); the manual LeaveGround + get_jump_v_z pre-capture (done in W4); `localAnimCmd`/`LocalAnimationSpeed` synthesis (:1283-1296, :1340-1359 change detection on localAnimCmd, :1412-1456 K-fix5 block + auto-walk anim overrides — the sequencer is now driven by the interp's own dispatch, not by UpdatePlayerAnimation); `MovementResult.LocalAnimationCommand/LocalAnimationSpeed` fields (and GameWindow consumers). - **MOVES to MotionInterpreter calls (edge-driven):** input edges → `PerformMovement`: W/S press → `DoMotion(WalkForward/WalkBackward, params)`; release → `StopMotion(same)`; strafe/turn keys likewise; Shift edge → `set_hold_run(run, interrupt:true)` (retail 0x006b33ca shape); jump release → `jump(extent)` (charge accumulation STAYS controller-side, AP-24). The controller keeps a tiny prev-key-state edge detector — that IS retail's CommandInterpreter altitude. - **STAYS in the controller (input/camera/physics/wire):** Yaw + mouse turn (:938-946); keyboard-turn Yaw integration reading `_motion.InterpretedState.TurnCommand/TurnSpeed` (:932-937, unchanged); grounded velocity write from `get_state_velocity` (:966-976 — the R6-deferred adaptation); physics integrate/Resolve/bounce/landing (sections 4-5); heartbeat/AP (section 8); auto-walk (B.6, until R4); SetPosition/PortalSpace (the :825 `DoMotion(Ready)` arrival-idle becomes `StopCompletely()` — retail's teleport idle is a full stop; note in commit); outbound section 6 now READS `_motion.RawState` (commands + per-axis hold keys) instead of re-deriving from `input` — wire bytes identical (golden-byte packer tests prove it). - **GameWindow call sites that change:** `UpdatePlayerAnimation(result)` (:10062-10234) DELETED — the player's AnimatedEntity sequencer/manager is bound to the player's MotionInterpreter via the same funnel/PerformMovement path remotes use (R2-Q5 sink shape); the airborne-Falling override falls out of contact_allows_move + apply_current_movement (retail mechanism, already live in the funnel); the #45 sidestep animSpeed factor + ACDREAM_ANIM_SPEED_SCALE knob move into (or are re-verified against) the CMotionTable-driven speedMod path — expected-diff annotations required; :4344 `ApplyServerRunRate` retargets to `my_run_rate` + `apply_current_movement` (same effect, no InterpretedState poke); :7953-7971 JumpAction build reads the W4-unified launch vector; :7975 call deleted with UpdatePlayerAnimation. Tests first: FULL suite green; pre-cutover recorded local-player SetCycle/dispatch traces (captured BEFORE this commit under the W0 protocol) replayed → same cycle identities + speeds, expected-diffs documented (Falling engage now via link-strip; sidestep factor source); outbound golden-byte parity (MoveToState/AP/Jump byte-identical for the same input script); live smoke ACDREAM_DUMP_MOTION + observer view; **ONE user visual pass** (walk/run/toggle/strafe/turn/jump instant Falling/landing/emote-mid-run — the stage acceptance). Fixture source: **pre-cutover recorded traces + golden-byte + suite**. Deps: W5. **W7 — register sweep + roadmap + digest (docs/cleanup only).** Retired rows: MotionDone-unconsumed (R2's), K-fix18, TS-34 (adjust_motion guard), plus the S2a StandingLongJump-misattribution note. Surviving rows (verify/add): TS-5 (CanJump always-true → now ALSO JumpStaminaCost stub), AP-24 (charge rate), TS-24 updated (actions now populated locally — packing next), TS-21/TS-23/AP-30 (re-anchor line numbers after controller edits), NEW rows: unstick_from_object no-op seam (→R5 StickyManager), interrupt_current_movement/cancel_moveto no-op (→R4), grounded velocity-from-state write (→R6 root motion), managed LinkedList vs LList (AD-34 wording), A5/A6 pinned-reading notes if either resolves against ACE. Roadmap stage table (R3 shipped); milestones "currently working toward" check; memory digest note (animation-sequencer deep-dive: pending_motions gap CLOSED end-to-end). Deps: W6. Parallelization: W0∥W1 only. W2→W3→W4→W5→W6 are sequential-coupled (queue → gates using queue → transitions using gates → dispatchers using all → cutover). W4 and W6 both touch GameWindow — single-agent each. --- ## 4. IMotionDoneSink → MotionInterpreter.MotionDone — exact wiring vs R2's seam R2's contract (r2-port-plan §4) delivers: `CSequence` G5 gate → `AnimationDoneSentinel` → GameWindow anim tick drain (:9876-9890 area) → `manager.AnimationDone(true)` per sentinel + `manager.UseTime()` once per tick → `MotionTableManager` countdown pop → action-class head pops the MANAGER's own `MotionState.RemoveActionHead()` (R2-OWNED) → `IMotionDoneSink.MotionDone(uint motion, bool success)` — bound in R2-Q4 to a diagnostic recorder. R3-W2 replaces the recorder: ``` MotionTableManager (per entity) [R2-Q3, exists] └─ IMotionDoneSink.MotionDone(motion, success) [R2 seam — signature UNCHANGED] └─ R3: the entity's MotionInterpreter.MotionDone(motion, success) [stands in for retail CPhysicsObj::MotionDone 0x0050fdb0 → MovementManager::MotionDone 0x005242d0 → CMotionInterp::MotionDone 0x00527ec0 — both intermediates are null-guarded relays with ZERO logic; R5 inserts MovementManager between manager and interp WITHOUT changing this behavior] body: head-peek pending_motions; head.Motion & 0x10000000 → unstick seam (no-op → R5) + InterpretedState.RemoveAction() + RawState.RemoveAction(); pop head UNCONDITIONALLY (A7: motion id + success are IGNORED — positional protocol, never match-by-id, never branch on success) ``` Binding points (App, per entity, at creation): - **Remote:** where `RemoteMotion` is constructed (each remote already owns a `MotionInterpreter` — `remoteMot.Motion`, GameWindow.cs:4646): the same construction that gives the entity its MotionTableManager (R2-Q4/Q5) passes `sink: remoteMot.Motion` (MotionInterpreter implements IMotionDoneSink directly). - **Local player:** `PlayerMovementController` exposes its `MotionInterpreter` (new internal property `Motion`); GameWindow binds the player entity's manager to it when the player's AnimatedEntity/sequencer is created (same site as `AttachCycleVelocityAccessor`, :384). Two structural rules carried from R2 §4 (decomp-confirmed in r3 extraction §7b): 1. **Two queues, never merged**: `MotionTableManager.pending_animations` (CPartArray side, duration-in-ticks) vs `CMotionInterp.pending_motions` (movement side, waiting-for-callback). THREE action trackers stay in lockstep via the 0x10000000 bit: manager's MotionState action FIFO (popped in R2's AnimationDone), InterpretedState.actions + RawState.actions (popped in R3's MotionDone). 2. **Tick placement provisional until R6**: the sentinel drain + UseTime stay at the R2-documented GameWindow drain point (G6 seam). R3 adds only the SYNCHRONOUS flush: `CMotionInterp.PerformMovement` calls the manager's CheckForCompletedMotions after every op (W5), which fires MotionDone same-call for zero-tick nodes. Success-flag adjudication (closes r2-plan §4's warning): retail `CMotionInterp::MotionDone` never reads the flag in this build — R3's jump/HitGround logic does NOT key off it after all. The flag's only real consumers are server/weenie-side (`OnMotionDone`) and the R2 manager's own countdown semantics. Preserve pass-through (true from Hook_AnimDone, false from world drains, hardcoded true from CheckForCompletedMotions) for R5 signature parity; document as inert at the interp. Enter/exit world: exit paths call `manager.HandleExitWorld()` (drains pending_animations, each firing sink.MotionDone(motion, false) → interp pops in step) THEN `interp.HandleExitWorld()` (drains any remainder — retail runs both layers' drains via CPartArray::HandleExitWorld and MovementManager::HandleExitWorld independently). Enter-world: manager-side only (`remove_all_link_animations` + drain); `MovementManager::HandleEnterWorld`'s interp relay is the mis-symbolicated tailcall (decomp §6e) — CMotionInterp has NO HandleEnterWorld; do not invent one (negative result, decomp §6e/§1d). --- ## 5. NEGATIVE RESULTS / DO-NOT-INVENT (binding on subagents) - `CPhysicsObj::IsAnimating` / `IsMovingOrAnimating` — **not in this PDB** (decomp §7f). ACE's `PhysicsObj.IsAnimating` bool is an ACE-ism. acdream queries `motions_pending()` / `is_standing_still()` / `CPartArray.HasAnims` individually. - `MovementManager::HandleUpdateTarget` — not in this PDB (§6j); likely MoveToManager-internal (R4). Do not stub. - `CMotionInterp::HandleEnterWorld` — does not exist (§6e); enter-world work is manager/CPartArray-side only. - `MakeMoveToManager` / MoveToManager internals — R4. R3 leaves the B.6 auto-walk + `interrupt_current_movement`/`cancel_moveto` as registered no-op seams. - `NoticeHandler::RecvNotice_PrevSpellSelection` (HandleEnterWorld/ReportExhaustion relays) — retail spell-UI notice ACE never ported; out of R3 scope; file a research note only (no ACE reference exists — decomp-direct if ever needed). - `LeaveGround` address is **0x00528b00** — the 0x00529710 in MotionInterpreter.cs's doc comment (:1153, :1159) is a stale Ghidra-chunk guess; W4 fixes the citations (several header comments cite old FUN_ addresses — sweep them to named symbols).