From 8eff3978016ad9f7ad32c83b2edaaaf5cc7efc44 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 2 Jul 2026 20:56:16 +0200 Subject: [PATCH] =?UTF-8?q?docs(R3-W-1):=20CMotionInterp-completion=20rese?= =?UTF-8?q?arch=20base=20=E2=80=94=20decomp=20extraction=20+=20ACE=20cross?= =?UTF-8?q?-ref=20+=20port=20work-list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workflow-produced R3 research (3 docs, 3,061 lines): - r3-motioninterp-decomp.md: verbatim pseudo-C + anchors for the full R3 scope — pending_motions lifecycle (add_to_queue 0x00527b80, MotionDone 0x00527ec0), DoMotion 0x00528d20 + PerformMovement 0x00528e80, the whole jump family, HitGround 0x00528ac0 / LeaveGround 0x00528b00 (stale 0x00529710 doc-comment corrected), enter_default_state, MovementManager relay surface, struct anchors, constants inventory. Negative results (IsAnimating / HandleUpdateTarget / CMotionInterp::HandleEnterWorld NOT in retail) explicitly recorded. - r3-ace-motioninterp.md: ACE MotionInterp/MovementManager/MotionNode map with flagged ACE-isms (jump_is_allowed L747 NPE typo, Falling-vs-Fallen boundary discrepancy). - r3-port-plan.md: 10 pinned ambiguities (headline: motion_allows_jump 0x48 polarity INVERTED in the BN annotation — ranges are a BLOCKLIST; apply_current_movement dispatch gate IsThePlayer vs IsCreature), 19 itemized gaps J1-J19, keep-list, 7-commit sequence W0-W7 ending in the local-player unification, exact IMotionDoneSink wiring spec vs R2 §4. Precondition paragraph updated at vaulting: Q2 committed 98f58db9. Co-Authored-By: Claude Fable 5 --- .../r3-ace-motioninterp.md | 987 ++++++++++ .../r3-motioninterp-decomp.md | 1698 +++++++++++++++++ .../r3-port-plan.md | 377 ++++ 3 files changed, 3062 insertions(+) create mode 100644 docs/research/2026-07-02-r3-motioninterp/r3-ace-motioninterp.md create mode 100644 docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md create mode 100644 docs/research/2026-07-02-r3-motioninterp/r3-port-plan.md diff --git a/docs/research/2026-07-02-r3-motioninterp/r3-ace-motioninterp.md b/docs/research/2026-07-02-r3-motioninterp/r3-ace-motioninterp.md new file mode 100644 index 00000000..887a5648 --- /dev/null +++ b/docs/research/2026-07-02-r3-motioninterp/r3-ace-motioninterp.md @@ -0,0 +1,987 @@ +# R3 — ACE port map: MotionInterp + MovementManager + MotionDone chain + +Sources (full-file reads, all line numbers are 1-indexed in the ACE source tree): +- `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs` (836 lines, whole file read) +- `references/ACE/Source/ACE.Server/Physics/Managers/MovementManager.cs` (216 lines, whole file read) +- `references/ACE/Source/ACE.Server/Physics/Animation/MotionNode.cs` (21 lines, whole file read) +- `references/ACE/Source/ACE.Server/Physics/Managers/MotionTableManager.cs` (252 lines, whole file read — AnimationDone/CheckForCompletedMotions, the actual MotionDone producer) +- `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs` (targeted reads: L80-109 IsAnimating/IsMovingOrAnimating, L290-301 CheckForCompletedMotions, L890-903 MotionDone, L4235-4247 ShowPendingMotions/motions_pending) +- `references/ACE/Source/ACE.Server/Physics/PartArray.cs` (L60-77 CheckForCompletedMotions passthrough) + +No named-retail cross-reference was performed in this pass (ACE-side mapping only, per R3 scope). ACE class is `MotionInterp` (note: **not** `CMotionInterp` — ACE dropped the `C` Hungarian prefix project-wide, this is a naming-convention divergence not a functional one). + +--- + +## 1. Class fields (MotionInterp.cs L14-32) + +```csharp +public bool Initted; +public WeenieObject WeenieObj; +public PhysicsObj PhysicsObj; +public RawMotionState RawState; +public InterpretedMotionState InterpretedState; +public float CurrentSpeedFactor; +public bool StandingLongJump; +public float JumpExtent; +public int ServerActionStamp; +public float MyRunRate; +public LinkedList PendingMotions; + +public const float BackwardsFactor = 6.4999998e-1f; // 0.65 +public const float MaxSidestepAnimRate = 3.0f; +public const float RunAnimSpeed = 4.0f; +public const float RunTurnFactor = 1.5f; +public const float SidestepAnimSpeed = 1.25f; +public const float SidestepFactor = 0.5f; +public const float WalkAnimSpeed = 3.1199999f; // 3.12 +``` + +Notes: +- `PendingMotions` is `LinkedList`, not an array/ring buffer — ACE reimplemented retail's pending-motion queue as a doubly-linked list. `MotionNode` (see §2) carries `ContextID`, `Motion`, `JumpErrorCode` — this is the FIFO queue of in-flight interpreted motions waiting on animation completion. +- `CurrentSpeedFactor` is declared but the only read site in this file is `get_adjusted_max_speed()` (L629) — no write site anywhere in MotionInterp.cs. Likely set externally (WeenieObj or PhysicsObj) — not visible in this file. +- `JumpExtent` doubles as both "how hard is the current jump charging" (set in `jump()`, read in `charge_jump()`/`get_jump_v_z()`) and as a guard the WeenieObj layer checks via `CanJump(JumpExtent)`. + +--- + +## 2. MotionNode (queue element) — MotionNode.cs, whole file + +```csharp +public class MotionNode +{ + public int ContextID; + public uint Motion; + public WeenieError JumpErrorCode; + + public MotionNode() { } + public MotionNode(int contextID, uint motion, WeenieError jumpErrorCode) + { + ContextID = contextID; + Motion = motion; + JumpErrorCode = jumpErrorCode; + } +} +``` +Trivial DTO. `JumpErrorCode` is the pre-computed "would a jump be legal right now, if the player pressed jump while this pending motion is still in flight" — computed once at `add_to_queue` time (see `motion_allows_jump` call sites in `DoInterpretedMotion`/`StopCompletely`), then consumed lazily by `jump_is_allowed()` (§7) via `PendingMotions.First.Value.JumpErrorCode` when `PendingMotions.Count > 1`. + +--- + +## 3. add_to_queue / MotionDone / RemoveMotion (queue lifecycle) + +### add_to_queue (L388-392) +```csharp +public void add_to_queue(int contextID, uint motion, WeenieError jumpErrorCode) +{ + PendingMotions.AddLast(new MotionNode(contextID, motion, jumpErrorCode)); + PhysicsObj.IsAnimating = true; +} +``` +Simple append + set the `IsAnimating` flag unconditionally true (even if this is the first entry). Called from: +- `DoInterpretedMotion` (L85) — normal interpreted-motion success path, `jump_error_code` precomputed at L73-83. +- `StopCompletely` (L321) — with hardcoded `MotionCommand.Ready` and a `jump` precomputed at L307. +- `StopInterpretedMotion` (L348) — with hardcoded `MotionCommand.Ready` and `WeenieError.None`. +- `apply_interpreted_movement` (L495) — the turn-stop fallback branch, hardcoded `MotionCommand.Ready` / `WeenieError.None`. + +### MotionDone (L210-234) — the dequeue-on-animation-complete handler +```csharp +public void MotionDone(bool success) +{ + if (PhysicsObj == null) return; + + var motionData = PendingMotions.First; + + // null or != last in list? + if (motionData != null) + { + var pendingMotion = motionData.Value; + if ((pendingMotion.Motion & (uint)CommandMask.Action) != 0) + { + PhysicsObj.unstick_from_object(); + InterpretedState.RemoveAction(); + RawState.RemoveAction(); + } + + motionData = PendingMotions.First; + if (motionData != null) + { + PendingMotions.Remove(motionData); + PhysicsObj.IsAnimating = PendingMotions.Count > 0; + } + } +} +``` +Note the ACE dev's own inline comment `// null or != last in list?` — flags this as a spot they weren't fully certain about vs. retail (a "did I get this right" breadcrumb). Two things happen if the head node exists: +1. If the head's Motion has the `Action` bit set (`CommandMask.Action`), it's treated as an "action" motion (e.g. attack/emote) that stuck the object to something — `unstick_from_object()` + pop the action off both InterpretedState and RawState action stacks. +2. **Re-fetches `PendingMotions.First` a second time** (redundant re-read — `motionData` unchanged since nothing between the two reads mutates the list) before removing it and recomputing `IsAnimating` as `Count > 0` (as opposed to `add_to_queue`'s unconditional `true`). + +`success` parameter is accepted but **never read** in this method body — dead parameter as far as MotionInterp.MotionDone goes. (It IS read/propagated further down in `MotionTableManager.AnimationDone`/`CheckForCompletedMotions`, see §8 — but `MotionInterp.MotionDone` itself ignores it entirely.) + +### RemoveMotion +Not defined in MotionInterp.cs — it's called on `InterpretedState`/`RawState` (`InterpretedState.RemoveMotion(motion)`, `RawState.RemoveMotion(motion)`), i.e. lives in `InterpretedMotionState`/`RawMotionState`/`MotionState` classes, NOT in MotionInterp. Call sites inside this file: L340, L351, L358, L498. Out of scope for this file-bounded pass (not in MotionInterp.cs or MovementManager.cs). + +--- + +## 4. DoMotion / StopMotion / StopCompletely (top-level motion API) + +### DoMotion (L112-158) — raw-command entry point +```csharp +public WeenieError DoMotion(uint motion, MovementParameters movementParams) +{ + if (PhysicsObj == null) return WeenieError.NoPhysicsObject; + + var currentParams = new MovementParameters(); + currentParams.CopySome(movementParams); + + var currentMotion = motion; + + if (movementParams.CancelMoveTo) PhysicsObj.cancel_moveto(); + if (movementParams.SetHoldKey) SetHoldKey(movementParams.HoldKeyToApply, movementParams.CancelMoveTo); + + adjust_motion(ref currentMotion, ref currentParams.Speed, movementParams.HoldKeyToApply); + + if (InterpretedState.CurrentStyle != (uint)MotionCommand.NonCombat) + { + switch (motion) + { + case (uint)MotionCommand.Crouch: return WeenieError.CantCrouchInCombat; + case (uint)MotionCommand.Sitting: return WeenieError.CantSitInCombat; + case (uint)MotionCommand.Sleeping: return WeenieError.CantLieDownInCombat; + } + if ((motion & (uint)CommandMask.ChatEmote) != 0) return WeenieError.CantChatEmoteInCombat; + } + + if ((motion & (uint)CommandMask.Action) != 0) + { + if (InterpretedState.GetNumActions() >= 6) return WeenieError.TooManyActions; + } + var result = DoInterpretedMotion(currentMotion, currentParams); + + if (result == WeenieError.None && movementParams.ModifyRawState) + RawState.ApplyMotion(motion, movementParams); + + return result; +} +``` +Key points: +- `switch (motion)` (combat-blocked motions) tests the **original** `motion` parameter, not `currentMotion` (post-`adjust_motion` mutation) — combat-block checks happen on the raw pre-adjustment command. +- Hardcoded action cap of 6 concurrent actions (`GetNumActions() >= 6`). +- `RawState.ApplyMotion` uses the **original** `motion`, not `currentMotion` — raw state records what was actually requested, interpreted state (via `DoInterpretedMotion`) gets the post-`adjust_motion` (walk→run promoted, backward-inverted, etc.) version. + +### StopMotion (L367-386) +Mirror of DoMotion but for the "stop" side: `cancel_moveto` gate, `adjust_motion`, delegates to `StopInterpretedMotion`, then conditionally calls `RawState.RemoveMotion(motion)` (original motion, not adjusted) on success. + +### StopCompletely (L301-327) +```csharp +public WeenieError StopCompletely() +{ + if (PhysicsObj == null) return WeenieError.NoPhysicsObject; + + PhysicsObj.cancel_moveto(); + + var jump = motion_allows_jump(InterpretedState.ForwardCommand); + + RawState.ForwardCommand = (uint)MotionCommand.Ready; + RawState.ForwardSpeed = 1.0f; + RawState.SideStepCommand = 0; + RawState.TurnCommand = 0; + + InterpretedState.ForwardCommand = (uint)MotionCommand.Ready; + InterpretedState.ForwardSpeed = 1.0f; + InterpretedState.SideStepCommand = 0; + InterpretedState.TurnCommand = 0; + + PhysicsObj.StopCompletely_Internal(); + + add_to_queue(0, (uint)MotionCommand.Ready, jump); + + if (PhysicsObj.CurCell == null) + PhysicsObj.RemoveLinkAnimations(); + + return WeenieError.None; +} +``` +Hard-resets both Raw and Interpreted state (Forward/SideStep/Turn) to Ready/1.0/0/0 directly by field assignment — bypasses the normal `ApplyMotion`/`RemoveMotion` mutators entirely. `jump` (the eligibility precomputed BEFORE the reset, off the OLD `InterpretedState.ForwardCommand`) is stashed into the queued `Ready` node's `JumpErrorCode`. Delegates the physics-side reset to `PhysicsObj.StopCompletely_Internal()` (not in this file). + +--- + +## 5. DoInterpretedMotion / StopInterpretedMotion (interpreted-command layer) + +### DoInterpretedMotion (L51-110) +```csharp +public WeenieError DoInterpretedMotion(uint motion, MovementParameters movementParams) +{ + if (PhysicsObj == null) return WeenieError.NoPhysicsObject; + var result = WeenieError.None; + + if (contact_allows_move(motion)) + { + if (StandingLongJump && (motion == WalkForward || motion == RunForward || motion == SideStepRight)) + { + if (movementParams.ModifyInterpretedState) + InterpretedState.ApplyMotion(motion, movementParams); + } + else + { + if (motion == (uint)MotionCommand.Dead) + PhysicsObj.RemoveLinkAnimations(); + + result = PhysicsObj.DoInterpretedMotion(motion, movementParams); + + if (result == WeenieError.None) + { + var jump_error_code = WeenieError.None; + if (movementParams.DisableJumpDuringLink) + jump_error_code = WeenieError.YouCantJumpFromThisPosition; + else + { + jump_error_code = motion_allows_jump(motion); + if (jump_error_code == WeenieError.None && (motion & (uint)CommandMask.Action) == 0) + jump_error_code = motion_allows_jump(InterpretedState.ForwardCommand); + } + add_to_queue(movementParams.ContextID, motion, jump_error_code); + + if (movementParams.ModifyInterpretedState) + InterpretedState.ApplyMotion(motion, movementParams); + } + } + } + else + { + if ((motion & (uint)CommandMask.Action) != 0) + result = WeenieError.YouCantJumpWhileInTheAir; + else + { + if (movementParams.ModifyInterpretedState) + InterpretedState.ApplyMotion(motion, movementParams); + result = WeenieError.None; + } + } + + if (PhysicsObj.CurCell == null) + PhysicsObj.RemoveLinkAnimations(); + + return result; +} +``` +Control-flow shape: +1. Gate on `contact_allows_move(motion)` (§7) — governs whether the object is grounded enough to accept this motion at all. +2. **StandingLongJump special-case**: if mid-standing-long-jump AND the incoming motion is one of {WalkForward, RunForward, SideStepRight}, the motion is applied to InterpretedState ONLY (no `PhysicsObj.DoInterpretedMotion` call, no queue entry) — the animation itself is suppressed while charging/airborne from a standing jump, but the *intent* state still updates so movement resumes correctly on landing. +3. Otherwise: `Dead` motion clears link animations first; then delegates the actual animation dispatch to `PhysicsObj.DoInterpretedMotion` (physics/animation-table layer, out of file scope); on success, computes the jump-error-code for THIS pending motion (double motion_allows_jump check: first against the incoming `motion` itself, then — only if the incoming motion isn't itself an Action AND passed — against the current `ForwardCommand`), queues it, and conditionally applies to InterpretedState. +4. If contact does NOT allow movement: Action-class motions fail outright with `YouCantJumpWhileInTheAir`; everything else silently updates InterpretedState (if requested) and returns success — i.e., non-action motions (turning, etc.) are allowed to update intent state even while airborne, just not animate/queue. +5. Unconditional tail: if `CurCell == null` (off the cell grid — e.g. despawned/uninitialized), strip link animations. + +### StopInterpretedMotion (L329-365) +Structural mirror of DoInterpretedMotion for the "stop" direction: same `contact_allows_move` gate, same StandingLongJump special-case (calls `InterpretedState.RemoveMotion` instead of `ApplyMotion`), same delegate-to-PhysicsObj pattern (`PhysicsObj.StopInterpretedMotion`), same add_to_queue-with-Ready-on-success pattern, same CurCell==null tail cleanup. The "contact disallows" else-branch here has NO action-vs-non-action split (StopInterpretedMotion always just conditionally calls `RemoveMotion` and returns `WeenieError.None`) — asymmetric with DoInterpretedMotion's stricter "action motions error out while airborne" rule. + +--- + +## 6. HitGround / LeaveGround (contact transition hooks) + +### HitGround (L175-185) +```csharp +public void HitGround() +{ + if (PhysicsObj == null) return; + if (WeenieObj != null && !WeenieObj.IsCreature()) return; + if (!PhysicsObj.State.HasFlag(PhysicsState.Gravity)) return; + + PhysicsObj.RemoveLinkAnimations(); + apply_current_movement(false, true); +} +``` +Guarded to creature-only (or WeenieObj==null, i.e. non-weenie-backed physics objects) AND gravity-affected objects. Strips link animations and re-applies current movement intent with `cancelMoveTo=false, allowJump=true`. + +### LeaveGround (L192-208) +```csharp +public void LeaveGround() +{ + if (PhysicsObj == null) return; + if (WeenieObj != null && !WeenieObj.IsCreature()) return; + if (!PhysicsObj.State.HasFlag(PhysicsState.Gravity)) return; + + var velocity = get_leave_ground_velocity(); + PhysicsObj.set_local_velocity(velocity, true); + + StandingLongJump = false; + JumpExtent = 0; + + PhysicsObj.RemoveLinkAnimations(); + apply_current_movement(false, true); +} +``` +Same guard pattern as HitGround. Computes leave-ground velocity (§9), pushes it into physics as a LOCAL velocity (`set_local_velocity(velocity, true)` — second arg likely "isLocal"/"autonomous", not resolved in this file), clears the jump-charge state (`StandingLongJump=false`, `JumpExtent=0`), strips link anims, reapplies movement. **Called from `enter_default_state()` (L615)** as the terminal step of default-state setup — i.e. every newly-initialized MotionInterp starts as if it just left the ground. + +MovementManager wrappers (MovementManager.cs): +- `HitGround()` (L66-73): calls `MotionInterpreter.HitGround()` THEN `MoveToManager.HitGround()` — both interpreters get the hit-ground signal, MotionInterp first. +- `LeaveGround()` (L104-110): calls `MotionInterpreter.LeaveGround()` only; has a dead commented-out line `// NoticeHandler::RecvNotice_PrevSpellSection` (retail-symbol trace left as a comment — not ported to ACE — a network-notice hook ACE apparently didn't implement here). + +--- + +## 7. Jump family + +### jump (L710-727) +```csharp +public WeenieError jump(float extent, int adjustStamina) +{ + if (PhysicsObj == null) return WeenieError.NoPhysicsObject; + + PhysicsObj.cancel_moveto(); + + var result = jump_is_allowed(extent, adjustStamina); + + if (result == WeenieError.None) + { + JumpExtent = extent; + PhysicsObj.set_on_walkable(false); + } + else + StandingLongJump = false; + + return result; +} +``` +Note: on success it sets `JumpExtent` and clears the walkable flag but does NOT itself call `LeaveGround()` or apply velocity — that must happen elsewhere (likely triggered by the physics tick detecting `on_walkable==false` + `JumpExtent>0`, out of file scope). On failure it clears `StandingLongJump` (defensive — aborts an in-progress standing-long-jump charge if the actual jump call fails). + +### jump_is_allowed (L742-768) +```csharp +public WeenieError jump_is_allowed(float extent, int staminaCost) +{ + if (PhysicsObj == null) return WeenieError.NoPhysicsObject; + + if (WeenieObj == null && !WeenieObj.IsCreature() || !PhysicsObj.State.HasFlag(PhysicsState.Gravity) || + PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact | TransientStateFlags.OnWalkable)) + { + if (PhysicsObj.IsFullyConstrained()) + return WeenieError.GeneralMovementFailure; + + if (PendingMotions.Count > 1 && PendingMotions.First.Value.JumpErrorCode != 0) + return PendingMotions.First.Value.JumpErrorCode; + + var jumpError = jump_charge_is_allowed(); + + if (jumpError == WeenieError.None) + { + jumpError = motion_allows_jump(InterpretedState.ForwardCommand); + + if (jumpError == WeenieError.None && WeenieObj != null && WeenieObj.JumpStaminaCost(extent, staminaCost) == 0) + jumpError = WeenieError.GeneralMovementFailure; + } + return jumpError; + } + return WeenieError.YouCantJumpWhileInTheAir; +} +``` +**LIKELY BUG (ACE-side, flag for cross-check against named-retail):** `WeenieObj == null && !WeenieObj.IsCreature()` — if `WeenieObj` actually is null, `!WeenieObj.IsCreature()` would NPE; C#'s `&&` short-circuits left-to-right so `WeenieObj == null` being true still evaluates the right side... wait, no: `&&` short-circuits so if `WeenieObj == null` is `true`, C# does NOT evaluate `!WeenieObj.IsCreature()` — correct, no NPE. But semantically this condition is almost certainly meant to be `WeenieObj != null && !WeenieObj.IsCreature()` (matching the guard pattern used identically in HitGround/LeaveGround/apply_current_movement/adjust_motion — all of which use `WeenieObj != null && !WeenieObj.IsCreature()`). As written, `WeenieObj == null && !WeenieObj.IsCreature()` can **never be true** (if WeenieObj==null is true, the whole condition short-circuits false because `&&` needs the null-check false... actually WeenieObj==null must be TRUE for this branch, and if it's true the right operand isn't even evaluated, so `WeenieObj==null && [anything]` reduces to just needing `WeenieObj==null` — no, `A && B` requires BOTH true; if A is true B is still checked. If A is true (WeenieObj IS null) and B accesses `WeenieObj.IsCreature()` on a null WeenieObj, this WOULD NPE.** This is a genuine apparent typo vs. the established pattern elsewhere in the same file (should be `WeenieObj != null && !WeenieObj.IsCreature()`) — **flag as a divergence risk to verify against `docs/research/named-retail/` before porting this exact condition to acdream.** +- Entry condition: allowed to even check jump-legality if EITHER not-a-creature-weenie, OR not gravity-affected, OR (already in Contact+OnWalkable state). +- `IsFullyConstrained()` → GeneralMovementFailure. +- **Queue-lookahead check**: if there's more than one pending motion AND the head's precomputed `JumpErrorCode != 0`, return that cached error immediately (short-circuit — reuses the jump-eligibility computed back when that motion was queued, rather than recomputing). +- Otherwise chains `jump_charge_is_allowed()` → `motion_allows_jump(ForwardCommand)` → `WeenieObj.JumpStaminaCost(extent, staminaCost) == 0` (stamina check, external to this file) → `GeneralMovementFailure` if stamina call returns 0. +- If the outer gate fails, returns `YouCantJumpWhileInTheAir` (airborne + gravity + not a special-cased non-creature). + +### jump_charge_is_allowed (L729-740) +```csharp +public WeenieError jump_charge_is_allowed() +{ + if (WeenieObj != null && !WeenieObj.CanJump(JumpExtent)) + return WeenieError.CantJumpLoadedDown; + + var forward = InterpretedState.ForwardCommand; + + if (forward == (uint)MotionCommand.Fallen || forward >= (uint)MotionCommand.Crouch && forward <= (uint)MotionCommand.Sleeping) + return WeenieError.YouCantJumpFromThisPosition; + + return WeenieError.None; +} +``` +`WeenieObj.CanJump(JumpExtent)` is presumably an encumbrance/burden check (name: "loaded down"). Blocks jump-charging while `Fallen` or in the [Crouch..Sleeping] MotionCommand range (a contiguous enum-range test — same idiom used throughout this file for "in one of these seated/prone states"). + +### charge_jump (L564-582) +```csharp +public int charge_jump() +{ + if (WeenieObj != null && !WeenieObj.CanJump(JumpExtent)) + return 0x49; + + var forward = InterpretedState.ForwardCommand; + + if (forward == (uint)MotionCommand.Falling || forward >= (uint)MotionCommand.Crouch && forward < (uint)MotionCommand.Sleeping) + return 0x48; + else + { + if (PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact | TransientStateFlags.OnWalkable) && forward == (uint)MotionCommand.Ready && + InterpretedState.SideStepCommand == 0 && InterpretedState.TurnCommand == 0) + { + StandingLongJump = true; + } + } + return 0; +} +``` +Returns raw `int` error codes (`0x49`, `0x48`, `0`) rather than `WeenieError` enum — a leftover-looking retail-style raw-hresult-ish return convention (likely these hex values ARE `WeenieError` underlying ints but the method signature wasn't converted — worth checking `0x49`/`0x48` against the `WeenieError` enum values for `CantJumpLoadedDown`/`YouCantJumpFromThisPosition` equivalents). **Note the range-check here uses `Falling` (not `Fallen` as in `jump_charge_is_allowed`) and `< Sleeping` (exclusive) vs `jump_charge_is_allowed`'s `<= Sleeping` (inclusive)** — these two "similar" gating functions have subtly different boundary conditions; flag for retail cross-check, this looks like it could be an ACE transcription inconsistency OR an intentional retail distinction between "starting to charge a jump" vs "already mid-charge-and-issuing-the-actual-jump." +The actual `StandingLongJump = true` side-effect only fires when: grounded+walkable, current forward command is exactly `Ready` (standing still), AND no sidestep/turn in progress — i.e. this is the "player pressed-and-held jump while standing still" detector that arms the long-jump-charge special path used throughout DoInterpretedMotion/StopInterpretedMotion/apply_interpreted_movement. + +### get_jump_v_z (L634-652) +```csharp +public float get_jump_v_z() +{ + if (JumpExtent < PhysicsGlobals.EPSILON) return 0.0f; + + var extent = JumpExtent; + if (extent > 1.0f) extent = 1.0f; + + if (WeenieObj == null) return 10.0f; + + float vz = extent; + if (WeenieObj.InqJumpVelocity(extent, out vz)) + return vz; + + return 0.0f; +} +``` +Clamps `JumpExtent` to [something-above-EPSILON, 1.0], delegates the actual extent→velocity curve to `WeenieObj.InqJumpVelocity` (out of file scope — presumably reads jump skill). Fallback of `10.0f` if there's no WeenieObj at all (non-weenie physics object jumping at max power, e.g. test/editor objects). `float vz = extent;` is a dead initializer — immediately overwritten by the `out vz` param if `InqJumpVelocity` returns true, else the method returns `0.0f` on the false path (never returns the dead-initialized `extent` value). + +### get_leave_ground_velocity (L654-663) +```csharp +public Vector3 get_leave_ground_velocity() +{ + var velocity = get_state_velocity(); + velocity.Z = get_jump_v_z(); + + if (Vec.IsZero(velocity)) + velocity = PhysicsObj.Position.GlobalToLocalVec(PhysicsObj.Velocity); + + return velocity; +} +``` +Composes horizontal velocity from current interpreted-state motion (§9 `get_state_velocity`) with vertical velocity from the jump charge, UNLESS the composed vector is exactly zero — in which case it falls back to converting the physics object's actual (global) velocity into local space. This fallback matters for e.g. falling-off-a-ledge (no jump input, no WASD, but the object still has downward/lateral velocity from having walked off an edge) vs. a genuine standing-still jump. + +--- + +## 8. HitGround/MotionDone chain — full call graph (cross-file, MotionTableManager.cs) + +The actual "animation finished playing" signal originates in `MotionTableManager` (a SEPARATE class from `MotionInterp`, owned by `PartArray`, NOT by `MovementManager`): + +``` +MotionTableManager.AnimationDone(bool success) [L28-61] +MotionTableManager.CheckForCompletedMotions() [L63-85] + -> PhysicsObj.MotionDone(motionID, success) [PhysicsObj.cs L899-903] + -> MovementManager.MotionDone(motion, success) [MovementManager.cs L118-122] + -> MotionInterpreter.MotionDone(success) [MotionInterp.cs L210-234] + -> PhysicsObj.WeenieObj.OnMotionDone(motionID, success) [parallel notify, weenie-layer] +``` + +Both `AnimationDone` and `CheckForCompletedMotions` in MotionTableManager independently walk `PendingAnimations` (a `LinkedList`, the animation-table-layer's OWN pending queue — distinct from `MotionInterp.PendingMotions`) and call `PhysicsObj.MotionDone` once per completed entry, followed immediately by `WeenieObj.OnMotionDone` — i.e. **every completed motion fires two independent listeners**: the MotionInterp queue-pop (state-machine bookkeeping) and the WeenieObject notification (game-logic reaction, e.g. quest triggers, sound cues — out of scope here). + +`CheckForCompletedMotions()` reachability: +``` +MotionInterp.PerformMovement(mvs) [L260] -- called after EVERY movement dispatch (Do/Stop/StopCompletely) + -> PhysicsObj.CheckForCompletedMotions() [PhysicsObj.cs L296-300] + -> PartArray.CheckForCompletedMotions() [PartArray.cs L72-76] + -> MotionTableManager.CheckForCompletedMotions() [L63-85] +``` +So every `MovementManager.PerformMovement` → `MotionInterp.PerformMovement` call (§10) ends with an immediate synchronous drain of any already-finished animations at the head of `PendingAnimations` — this is how a 0-frame-length animation (e.g. an instant `Ready` transition) gets its `MotionDone` fired same-tick rather than waiting for the next animation-tick callback. + +`MotionTableManager.UseTime()` (L158-161) also calls `CheckForCompletedMotions()` — this is the periodic (per-tick, presumably driven by `MovementManager.UseTime()` → `MoveToManager.UseTime()`... **NOTE:** `MovementManager.UseTime()` (MovementManager.cs L176-179) only forwards to `MoveToManager.UseTime()`, NOT to `MotionInterpreter`/`MotionTableManager` — so `MotionTableManager.UseTime()`'s caller is NOT in this file pair; it must be driven directly by `PartArray`/`Sequence`'s own per-tick update, bypassing MovementManager entirely). + +`AnimationDone` (L28-61) is the OTHER path into the same `PhysicsObj.MotionDone` call — driven by whatever animation-tick/callback system invokes it directly (not visible in these two files; likely `Sequence`/`AFrame` playback completion). Its loop differs from `CheckForCompletedMotions` in that it decrements a running `AnimationCounter` against each node's `NumAnims` rather than checking `NumAnims != 0` directly — i.e. `AnimationDone` is the incremental "one more anim-frame-group finished" tick, while `CheckForCompletedMotions` is the "resync/drain everything already at zero" batch pass. + +--- + +## 9. apply_raw / apply_interpreted / apply_current_movement (state → animation dispatch) + +### apply_current_movement (L430-438) — dispatcher +```csharp +public void apply_current_movement(bool cancelMoveTo, bool allowJump) +{ + if (PhysicsObj == null || !Initted) return; + + if (WeenieObj != null && !WeenieObj.IsCreature() || !PhysicsObj.movement_is_autonomous()) + apply_interpreted_movement(cancelMoveTo, allowJump); + else + apply_raw_movement(cancelMoveTo, allowJump); +} +``` +Routes to raw-vs-interpreted based on: non-creature-weenie OR not-autonomous-movement → interpreted path; creature AND autonomous movement → raw path. (`movement_is_autonomous()` presumably distinguishes server-driven/scripted motion from player-input-driven motion — out of file scope.) Requires `Initted == true` (set at the tail of `enter_default_state`, §11) — calls before init are no-ops. + +### apply_raw_movement (L506-523) +```csharp +public void apply_raw_movement(bool cancelMoveTo, bool allowJump) +{ + if (PhysicsObj == null) return; + + InterpretedState.CurrentStyle = RawState.CurrentStyle; + InterpretedState.ForwardCommand = RawState.ForwardCommand; + InterpretedState.ForwardSpeed = RawState.ForwardSpeed; + InterpretedState.SideStepCommand = RawState.SideStepCommand; + InterpretedState.SideStepSpeed = RawState.SideStepSpeed; + InterpretedState.TurnCommand = RawState.TurnCommand; + InterpretedState.TurnSpeed = RawState.TurnSpeed; + + adjust_motion(ref InterpretedState.ForwardCommand, ref InterpretedState.ForwardSpeed, RawState.ForwardHoldKey); + adjust_motion(ref InterpretedState.SideStepCommand, ref InterpretedState.SideStepSpeed, RawState.SideStepHoldKey); + adjust_motion(ref InterpretedState.TurnCommand, ref InterpretedState.TurnSpeed, RawState.TurnHoldKey); + + apply_interpreted_movement(cancelMoveTo, allowJump); +} +``` +Copies all 7 raw-state fields verbatim into interpreted-state, THEN runs `adjust_motion` (§12) independently over each of the three command/speed pairs (Forward, SideStep, Turn) with their respective per-axis hold-keys, THEN falls through to `apply_interpreted_movement`. This is the "translate the low-level input intent into the higher-level interpreted/animation intent" step — walk→run promotion, backward-inversion, sidestep animation-rate scaling all happen here per-axis. + +### apply_interpreted_movement (L440-504) +```csharp +public void apply_interpreted_movement(bool cancelMoveTo, bool allowJump) +{ + if (PhysicsObj == null) return; + + var movementParams = new MovementParameters(); + movementParams.SetHoldKey = false; + movementParams.ModifyInterpretedState = false; + movementParams.CancelMoveTo = cancelMoveTo; + movementParams.DisableJumpDuringLink = !allowJump; + + if (InterpretedState.ForwardCommand == (uint)MotionCommand.RunForward) + MyRunRate = InterpretedState.ForwardSpeed; + + DoInterpretedMotion(InterpretedState.CurrentStyle, movementParams); + + if (contact_allows_move(InterpretedState.ForwardCommand)) + { + if (!StandingLongJump) + { + movementParams.Speed = InterpretedState.ForwardSpeed; + DoInterpretedMotion(InterpretedState.ForwardCommand, movementParams); + + if (InterpretedState.SideStepCommand != 0) + { + movementParams.Speed = InterpretedState.SideStepSpeed; + DoInterpretedMotion(InterpretedState.SideStepCommand, movementParams); + } + else + StopInterpretedMotion((uint)MotionCommand.SideStepRight, movementParams); + } + else + { + movementParams.Speed = 1.0f; + DoInterpretedMotion((uint)MotionCommand.Ready, movementParams); + StopInterpretedMotion((uint)MotionCommand.SideStepRight, movementParams); + } + } + else + { + movementParams.Speed = 1.0f; + DoInterpretedMotion((uint)MotionCommand.Falling, movementParams); + } + + if (InterpretedState.TurnCommand != 0) + { + movementParams.Speed = InterpretedState.TurnSpeed; + DoInterpretedMotion(InterpretedState.TurnCommand, movementParams); + } + else + { + var result = PhysicsObj.StopInterpretedMotion((uint)MotionCommand.TurnRight, movementParams); + if (result == WeenieError.None) + { + add_to_queue(movementParams.ContextID, (uint)MotionCommand.Ready, WeenieError.None); + if (movementParams.ModifyInterpretedState) + InterpretedState.RemoveMotion((uint)MotionCommand.TurnRight); + } + if (PhysicsObj.CurCell == null) + PhysicsObj.RemoveLinkAnimations(); + } +} +``` +This is the per-axis re-dispatch of the CURRENT interpreted state as fresh `DoInterpretedMotion`/`StopInterpretedMotion` calls (used both after raw→interpreted translation, and directly whenever contact/gravity transitions need to re-trigger animation — e.g. HitGround/LeaveGround/SetPhysicsObject/SetWeenieObject/SetHoldKey/set_hold_run all funnel here via `apply_current_movement`). Sequence: +1. `CurrentStyle` motion (stance) always re-dispatched first. +2. `MyRunRate` cache updated from `ForwardSpeed` whenever currently running (side-channel — persists the "last known run speed" independent of whatever WeenieObj.InqRunRate later reports). +3. If contact allows movement: normal case re-dispatches Forward + (SideStep OR explicit SideStepRight-stop); StandingLongJump case instead forces Ready + explicit SideStepRight-stop (suppresses forward/sidestep animation while charging a standing jump, matching the earlier note in DoInterpretedMotion). +4. If contact does NOT allow movement: unconditionally dispatches `Falling` (Speed=1.0) regardless of what ForwardCommand was. +5. Turn is handled as its own independent branch — Turn re-dispatches like Forward/SideStep, but the "stop" path when TurnCommand==0 is inlined directly here (calls `PhysicsObj.StopInterpretedMotion` — the PHYSICS layer method, not `this.StopInterpretedMotion` — and duplicates the add_to_queue/RemoveMotion/RemoveLinkAnimations bookkeeping inline rather than delegating to `this.StopInterpretedMotion`). This inline duplication vs delegating is a structural oddity worth flagging — every other "stop with bookkeeping" path in this file goes through the member `StopInterpretedMotion` method, but this one open-codes it against `PhysicsObj.StopInterpretedMotion` directly. + +### get_state_velocity (L678-700) — used by get_leave_ground_velocity (§7) +```csharp +public Vector3 get_state_velocity() +{ + var velocity = Vector3.Zero; + + if (InterpretedState.SideStepCommand == (uint)MotionCommand.SideStepRight) + velocity.X = SidestepAnimSpeed * InterpretedState.SideStepSpeed; + if (InterpretedState.ForwardCommand == (uint)MotionCommand.WalkForward) + velocity.Y = WalkAnimSpeed * InterpretedState.ForwardSpeed; + else if (InterpretedState.ForwardCommand == (uint)MotionCommand.RunForward) + velocity.Y = RunAnimSpeed * InterpretedState.ForwardSpeed; + + var rate = MyRunRate; + if (WeenieObj != null) WeenieObj.InqRunRate(ref rate); + + var maxSpeed = RunAnimSpeed * rate; + if (velocity.Length() > maxSpeed) + { + velocity = Vector3.Normalize(velocity); + velocity *= maxSpeed; + } + return velocity; +} +``` +X = lateral (sidestep) component, Y = forward component (local space — X/Y here are NOT world axes). Clamped to a max speed derived from `RunAnimSpeed * runRate` — note `WeenieObj.InqRunRate(ref rate)`'s return value is discarded here (unlike `get_adjusted_max_speed`/`get_max_speed` which check the bool return and fall back to `MyRunRate` only if it returns false) — `rate` is pre-seeded with `MyRunRate` and then `InqRunRate` is allowed to overwrite it unconditionally regardless of success/failure return. + +--- + +## 10. PerformMovement (MotionInterp) vs PerformMovement (MovementManager) + +### MotionInterp.PerformMovement (L236-262) +```csharp +public WeenieError PerformMovement(MovementStruct mvs) +{ + var result = WeenieError.None; + switch (mvs.Type) + { + case MovementType.RawCommand: result = DoMotion(mvs.Motion, mvs.Params); break; + case MovementType.InterpretedCommand: result = DoInterpretedMotion(mvs.Motion, mvs.Params); break; + case MovementType.StopRawCommand: result = StopMotion(mvs.Motion, mvs.Params); break; + case MovementType.StopInterpretedCommand: result = StopInterpretedMotion(mvs.Motion, mvs.Params); break; + case MovementType.StopCompletely: result = StopCompletely(); break; + default: return WeenieError.GeneralMovementFailure; + } + PhysicsObj.CheckForCompletedMotions(); + return result; +} +``` +Dispatch table over `MovementType`; unconditionally drains completed motions (§8) after every dispatched call (except the `default`/unknown-type early-return, which skips the drain). + +### MovementManager.PerformMovement (L124-157) +```csharp +public WeenieError PerformMovement(MovementStruct mvs) +{ + PhysicsObj.set_active(true); + + switch (mvs.Type) + { + case MovementType.RawCommand: + case MovementType.InterpretedCommand: + case MovementType.StopRawCommand: + case MovementType.StopInterpretedCommand: + case MovementType.StopCompletely: + if (MotionInterpreter == null) + { + MotionInterpreter = MotionInterp.Create(PhysicsObj, WeenieObj); + if (PhysicsObj != null) MotionInterpreter.enter_default_state(); + } + return MotionInterpreter.PerformMovement(mvs); + + case MovementType.MoveToObject: + case MovementType.MoveToPosition: + case MovementType.TurnToObject: + case MovementType.TurnToHeading: + if (MoveToManager == null) + MoveToManager = MoveToManager.Create(PhysicsObj, WeenieObj); + return MoveToManager.PerformMovement(mvs); + + default: + return WeenieError.GeneralMovementFailure; + } +} +``` +Outer layer: unconditionally activates the physics object (`set_active(true)`) BEFORE dispatch, lazy-constructs `MotionInterpreter` (with `enter_default_state()`) on first use for the motion-command group, lazy-constructs `MoveToManager` on first use for the moveto/turnto group. This is the true entry point most game-logic code calls — `MotionInterp.PerformMovement` is only reachable through this wrapper (or directly if something already holds a `MotionInterp` reference, e.g. via `get_minterp()`). + +--- + +## 11. Lifecycle: enter_default_state / HandleExitWorld / HandleEnterWorld + +### MotionInterp.enter_default_state (L604-616) +```csharp +public void enter_default_state() +{ + RawState = new RawMotionState(); + InterpretedState = new InterpretedMotionState(); + + PhysicsObj.InitializeMotionTables(); + PendingMotions = new LinkedList(); // ?? + + add_to_queue(0, (uint)MotionCommand.Ready, 0); + + Initted = true; + LeaveGround(); +} +``` +Note the ACE dev's own `// ??` comment on the `PendingMotions` reset — another self-flagged uncertainty spot (worth checking retail decomp for whether PendingMotions is genuinely reset here or whether retail preserves/asserts-empty). Order: fresh Raw/Interpreted state objects → physics-layer motion table init → fresh pending-motion queue → seed the queue with one `Ready` motion (contextID=0, jumpErrorCode=`WeenieError.None`=0) → flip `Initted=true` → call `LeaveGround()` (§6) which itself is now unlocked since `Initted` gates nothing in LeaveGround directly, but LeaveGround calls `apply_current_movement` which DOES gate on `Initted`. + +### MovementManager.EnterDefaultState (L38-46) +```csharp +public void EnterDefaultState() +{ + if (PhysicsObj == null) return; + if (MotionInterpreter == null) + MotionInterpreter = MotionInterp.Create(PhysicsObj, WeenieObj); + MotionInterpreter.enter_default_state(); +} +``` +Thin lazy-construct + delegate wrapper. + +### HandleExitWorld — two versions +**MotionInterp.HandleExitWorld (L160-173):** +```csharp +public void HandleExitWorld() +{ + foreach (var pendingMotion in PendingMotions) + { + if (PhysicsObj != null && (pendingMotion.Motion & (uint)CommandMask.Action) != 0) + { + PhysicsObj.unstick_from_object(); + InterpretedState.RemoveAction(); + RawState.RemoveAction(); + } + } + PendingMotions.Clear(); + if (PhysicsObj != null) PhysicsObj.IsAnimating = false; +} +``` +Iterates ALL pending motions (not just the head, unlike `MotionDone`) — for EVERY pending motion that has the Action bit set, unsticks + pops an action off both state stacks (this will over-pop if multiple pending Action motions exist simultaneously and InterpretedState/RawState only track a bounded action stack — worth checking `GetNumActions()`'s cap of 6 against how many action-removals this loop could trigger). Then clears the whole queue and force-sets `IsAnimating = false` directly (bypassing the `Count > 0` recompute that `MotionDone` uses). + +**MovementManager.HandleExitWorld (L54-58):** thin delegate, `if (MotionInterpreter != null) MotionInterpreter.HandleExitWorld();` — no MoveToManager involvement (contrast with `HitGround` which drives both). + +### MovementManager.HandleEnterWorld (L48-52) +```csharp +public void HandleEnterWorld() +{ + //if (MotionInterpreter != null) + //NoticeHandler.RecvNotice_PrevSpellSelection(MotionInterpreter); +} +``` +Entirely commented out — dead/no-op in ACE. The commented reference to `NoticeHandler.RecvNotice_PrevSpellSelection` is a retail-symbol breadcrumb (spell-selection restore notice on world-enter) that ACE chose not to implement. **Flag for acdream: if named-retail decomp confirms this notice is meaningful (e.g. restoring a previously-selected spell/combat-style UI state on relog), this is a genuine ACE gap, not just dead weight — acdream may need to port it from decomp directly since ACE has nothing to copy.** + +Also note: `MotionTableManager.HandleEnterWorld(Sequence sequence)` (MotionTableManager.cs L103-108) is a DIFFERENT, unrelated `HandleEnterWorld` on a different class — it drains `PendingAnimations` via repeated `AnimationDone(false)` calls and clears link animations on the sequence. Not reachable from `MovementManager.HandleEnterWorld` (which is a no-op) — must be invoked from elsewhere (PartArray or the weenie enter-world path, out of scope). + +--- + +## 12. adjust_motion / apply_run_to_command (raw→interpreted transform helpers) + +### adjust_motion (L394-428) +```csharp +public void adjust_motion(ref uint motion, ref float speed, HoldKey holdKey) +{ + if (WeenieObj != null && !WeenieObj.IsCreature()) + return; + + switch (motion) + { + case (uint)MotionCommand.RunForward: + return; + + case (uint)MotionCommand.WalkBackwards: + motion = (uint)MotionCommand.WalkForward; + speed *= -BackwardsFactor; // -0.65 + break; + + case (uint)MotionCommand.TurnLeft: + motion = (uint)MotionCommand.TurnRight; + speed *= -1.0f; + break; + + case (uint)MotionCommand.SideStepLeft: + motion = (uint)MotionCommand.SideStepRight; + speed *= -1.0f; + break; + } + + if (motion == (uint)MotionCommand.SideStepRight) + speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed); // 0.5 * (3.12/1.25) = 1.248 + + if (holdKey == HoldKey.Invalid) + holdKey = RawState.CurrentHoldKey; + + if (holdKey == HoldKey.Run) + apply_run_to_command(ref motion, ref speed); +} +``` +Canonicalizes "left/backward" variants into their "right/forward" counterparts with negated speed (single-animation-per-axis-direction retail convention: there's no separate WalkBackwards/TurnLeft/SideStepLeft animation state, just the canonical one played at negative speed). `WalkBackwards` gets an EXTRA `-BackwardsFactor` (0.65) scalar on top of the sign flip — backward walking is intentionally slower than forward. `SideStepRight` always gets rescaled by `SidestepFactor * (WalkAnimSpeed/SidestepAnimSpeed)` ≈ 1.248 regardless of hold-key, to convert a walk-speed-denominated input into the sidestep animation's own speed scale. `HoldKey.Invalid` falls back to whatever `RawState.CurrentHoldKey` currently is; if the resolved hold key is `Run`, defers to `apply_run_to_command` for the walk→run promotion. + +### apply_run_to_command (L525-562) +```csharp +public void apply_run_to_command(ref uint motion, ref float speed) +{ + var speedMod = 1.0f; + + if (WeenieObj != null) + { + var runFactor = 0.0f; + if (WeenieObj.InqRunRate(ref runFactor)) + speedMod = runFactor; + else + speedMod = MyRunRate; + } + switch (motion) + { + case (uint)MotionCommand.WalkForward: + if (speed > 0.0f) + motion = (uint)MotionCommand.RunForward; + speed *= speedMod; + break; + + case (uint)MotionCommand.TurnRight: + speed *= RunTurnFactor; // 1.5 + break; + + case (uint)MotionCommand.SideStepRight: + speed *= speedMod; + if (MaxSidestepAnimRate < Math.Abs(speed)) + speed = speed > 0.0f ? MaxSidestepAnimRate : -MaxSidestepAnimRate; + break; + } +} +``` +`WalkForward` promotes to `RunForward` ONLY if `speed > 0.0f` — i.e. a negative-speed "walk forward" (which per `adjust_motion` is actually a canonicalized WalkBackwards) does NOT get promoted to running even while the Run hold-key is active; backward movement stays at walk-animation regardless of hold-key. `TurnRight` while running gets a flat 1.5x speed multiplier (turns faster while running). `SideStepRight` gets the run-rate multiplier applied AND is then hard-clamped to ±`MaxSidestepAnimRate` (3.0) — this clamp exists ONLY in the run-path, not in the base `adjust_motion` sidestep scaling, meaning sidestep-while-walking is unclamped but sidestep-while-running is capped. + +--- + +## 13. contact_allows_move / motion_allows_jump / is_standing_still (gating predicates) + +### contact_allows_move (L584-602) +```csharp +public bool contact_allows_move(uint motion) +{ + if (PhysicsObj == null) return false; + + if (motion == (uint)MotionCommand.Dead || motion == (uint)MotionCommand.Falling || + motion >= (uint)MotionCommand.TurnRight && motion <= (uint)MotionCommand.TurnLeft) + return true; + + if (WeenieObj != null && !WeenieObj.IsCreature()) + return true; + + if (!PhysicsObj.State.HasFlag(PhysicsState.Gravity)) + return true; + if (!PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact)) + return false; + if (PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable)) + return true; + + return false; +} +``` +Always-allowed motion classes: `Dead`, `Falling`, and the [TurnRight..TurnLeft] enum range (turning is always allowed regardless of contact — matches `apply_interpreted_movement`'s independent Turn-branch handling). Non-creature weenies bypass all contact gating. Non-gravity objects bypass gating. Otherwise: requires BOTH `Contact` AND `OnWalkable` transient flags to allow movement; `Contact` without `OnWalkable` (e.g. touching a wall/ceiling, not a floor) explicitly disallows. + +### motion_allows_jump (L770-779) +```csharp +public WeenieError motion_allows_jump(uint substate) +{ + if (substate >= (uint)MotionCommand.Reload && substate <= (uint)MotionCommand.Pickup || + substate >= (uint)MotionCommand.TripleThrustLow && substate <= (uint)MotionCommand.MagicPowerUp07Purple || + substate >= (uint)MotionCommand.MagicPowerUp01 && substate <= (uint)MotionCommand.MagicPowerUp10 || + substate >= (uint)MotionCommand.Crouch && substate <= (uint)MotionCommand.Sleeping || + substate >= (uint)MotionCommand.AimLevel && substate <= (uint)MotionCommand.MagicPray || + substate == (uint)MotionCommand.Falling) + { + return WeenieError.YouCantJumpFromThisPosition; + } + return WeenieError.None; +} +``` +Five contiguous MotionCommand enum ranges + one exact match, ALL block jumping: [Reload..Pickup], [TripleThrustLow..MagicPowerUp07Purple], [MagicPowerUp01..MagicPowerUp10], [Crouch..Sleeping], [AimLevel..MagicPray], and exactly `Falling`. This depends entirely on the ORDER of values in the `MotionCommand` enum matching retail's numeric ordering — **any acdream MotionCommand enum that doesn't preserve retail's exact ordinal layout for these ranges will silently break this range-check port.** Called from: `DoInterpretedMotion` (jump-error precompute, twice — against incoming motion AND against ForwardCommand), `StopCompletely` (against ForwardCommand before reset), `jump_is_allowed` (against ForwardCommand), `move_to_interpreted_state` (against ForwardCommand, to gate `allowJump`). + +### is_standing_still (L702-708) +```csharp +public bool is_standing_still() +{ + return PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact | TransientStateFlags.OnWalkable) && + InterpretedState.ForwardCommand == (uint)MotionCommand.Ready && + InterpretedState.SideStepCommand == 0 && + InterpretedState.TurnCommand == 0; +} +``` +Same predicate shape as the `StandingLongJump` arm-condition inlined in `charge_jump()` (§7) — grounded+walkable, Ready forward, zero sidestep, zero turn. Not called anywhere within these two files (public API surface for external callers, e.g. WeenieObj/game-logic layer, out of scope). + +--- + +## 14. Misc small methods + +- **InqStyle (L187-190):** `return InterpretedState.CurrentStyle;` — trivial accessor, returned as `long` despite `CurrentStyle` presumably being `uint` (implicit widening). +- **SetHoldKey (L274-287):** only handles `HoldKey.None` explicitly — if the NEW key is `None` AND the current key was `Run`, clears to `None` and re-applies movement; the `HoldKey.Run` case (or others) falls through the switch with NO explicit case, meaning setting the hold key TO `Run` via `SetHoldKey` is a silent no-op unless `key == RawState.CurrentHoldKey` returns early first — the only way `HoldKey.Run` actually gets set appears to be `set_hold_run` (below), not `SetHoldKey`. Early-return guard: `if (key == RawState.CurrentHoldKey) return;` — no-op if unchanged. +- **set_hold_run (L826-833):** + ```csharp + public void set_hold_run(int val, bool cancelMoveTo) + { + if ((val == 0) != (RawState.CurrentHoldKey != HoldKey.Run)) + { + RawState.CurrentHoldKey = val != 0 ? HoldKey.Run : HoldKey.None; + apply_current_movement(cancelMoveTo, true); + } + } + ``` + XOR-style guard: `(val==0) != (CurrentHoldKey != Run)` is true exactly when `val` and the "is currently Run" state disagree, i.e. this is a real toggle. Sets `HoldKey.Run` or `HoldKey.None` (never `Invalid` or others) and re-applies movement with `allowJump=true` unconditionally. +- **SetPhysicsObject (L289-293) / SetWeenieObject (L295-299):** both simply assign the field then call `apply_current_movement(true, true)` (force cancelMoveTo=true, allowJump=true) — any (re)binding of either owning object triggers a full movement re-application. +- **get_adjusted_max_speed (L618-632):** + ```csharp + public float get_adjusted_max_speed() + { + var rate = 1.0f; + if (WeenieObj != null) + { + if (!WeenieObj.InqRunRate(ref rate)) + rate = MyRunRate; + } + if (InterpretedState.ForwardCommand == (uint)MotionCommand.RunForward) + rate = InterpretedState.ForwardSpeed / CurrentSpeedFactor; + return rate * 4.0f; + } + ``` + Note: if currently running, `rate` is OVERWRITTEN entirely by `ForwardSpeed / CurrentSpeedFactor` — the `WeenieObj.InqRunRate`/`MyRunRate` lookup above becomes dead work in that branch. `4.0f` is presumably `RunAnimSpeed` inlined rather than referencing the constant (magic-number duplication — flag for acdream: use the named constant, don't inline `4.0f`). +- **get_max_speed (L665-676):** simpler sibling — `RunAnimSpeed * rate` where `rate` comes from `InqRunRate`/`MyRunRate` fallback, no ForwardCommand override. Two "max speed" methods with different semantics (adjusted = "current effective speed accounting for what's actually playing," max = "theoretical max run speed") — both present, worth checking retail naming/usage split before porting just one. +- **motions_pending (L784-787) — MotionInterp:** `return PendingMotions.Count > 0;` with an XML doc comment noting `PhysicsObj.IsAnimating` is the faster equivalent. **PhysicsObj.motions_pending() (PhysicsObj.cs L4244-4247)** is a DIFFERENT, simpler method: `return IsAnimating;` directly — i.e. PhysicsObj's own `motions_pending()` already takes the fast path the MotionInterp doc-comment recommends; **MovementManager.motions_pending() (L195-200)** delegates to `MotionInterpreter.motions_pending()` (the SLOW `Count>0` path, not the fast `IsAnimating` path) when `MotionInterpreter != null`, else returns `false` — so the three `motions_pending()` overloads across PhysicsObj/MovementManager/MotionInterp are NOT all equivalent-cost, and MovementManager's public-facing one takes the slower route. +- **move_to_interpreted_state (L789-824):** syncs an externally-supplied `InterpretedMotionState` (e.g. from a network update / DR snapshot) into this instance. Computes `allowJump` from the OLD `ForwardCommand` (before `copy_movement_from` overwrites it) via `motion_allows_jump(...) == WeenieError.None`. Replays the incoming state's `Actions` list through a **sequence-stamp wraparound comparator**: `currentStamp = action.Stamp & 0x7FFF`, `serverStamp = ServerActionStamp & 0x7FFFF` (**note: differing mask widths, `0x7FFF` (15 bits) vs `0x7FFFF` (19 bits) — likely a typo, should probably both be the same mask; flag for retail cross-check**), `deltaStamp = abs(currentStamp - serverStamp)`, and picks a "is this newer" test based on whether the delta exceeds `0x3FFF` (half of 15-bit range) — a classic sequence-number wraparound comparison. Actions only replay if `WeenieObj.IsCreature() || action.Autonomous`. +- **ReportExhaustion (L264-272):** + ```csharp + public void ReportExhaustion() + { + if (PhysicsObj == null || !Initted) return; + if (WeenieObj == null || WeenieObj.IsCreature() && PhysicsObj.movement_is_autonomous()) + apply_raw_movement(false, false); + else + apply_interpreted_movement(false, false); + } + ``` + Same raw-vs-interpreted split condition shape as `apply_current_movement` but with the WeenieObj null-check ADDED to the raw-path condition (here: `WeenieObj==null || (IsCreature && autonomous)` routes to raw; there: `WeenieObj!=null && !IsCreature || !autonomous` routes to interpreted) — these two conditions are NOT exact logical complements of each other across the two methods (worth a careful truth-table comparison before assuming they're meant to be identical dispatch logic). Both calls pass `cancelMoveTo=false, allowJump=false` — exhaustion reporting never cancels an active moveto and never allows a fresh jump. + MovementManager.ReportExhaustion (L159-165) is a thin delegate with a dead commented-out `// NoticeHandler::RecvNotice_PrevSpellSelection` line, same as HandleEnterWorld. + +--- + +## 15. MovementManager remaining surface (not covered above) + +- **CancelMoveTo / HandleUpdateTarget / IsMovingTo / MakeMoveToManager / UseTime:** all thin null-guarded delegates to `MoveToManager` — no MotionInterp involvement. `MakeMoveToManager()` (L112-116) lazy-constructs `MoveToManager` WITHOUT calling any init-state method (contrast with the `MotionInterpreter` lazy-construct sites which always follow with `enter_default_state()`). +- **InqInterpretedMotionState / InqRawMotionState / get_minterp:** identical three-line lazy-construct-with-enter_default_state pattern, just returning a different field (`.InterpretedState`, `.RawState`, or the interpreter itself). +- **unpack_movement(object addr, uint size) (L213):** empty body — `{ }`. Stub/no-op, presumably a retail network-deserialization entry point ACE doesn't need (server already has structured data, doesn't need to unpack a wire buffer here) or hasn't ported. Flag: if named-retail decomp shows this doing real work, it's client-side-only logic ACE correctly skips (server doesn't need to interpret its own outbound format), OR it's a genuine gap — check the decomp before assuming either way. + +--- + +## Summary table — ACE method inventory vs retail-decomp cross-check TODO + +| ACE method | File:Line | Retail decomp checked this pass? | +|---|---|---| +| `add_to_queue` | MotionInterp.cs:388 | No — ACE-only pass | +| `MotionDone` | MotionInterp.cs:210 | No | +| `DoMotion` | MotionInterp.cs:112 | No | +| `DoInterpretedMotion` | MotionInterp.cs:51 | No | +| `StopMotion` | MotionInterp.cs:367 | No | +| `StopInterpretedMotion` | MotionInterp.cs:329 | No | +| `StopCompletely` | MotionInterp.cs:301 | No | +| `HitGround` | MotionInterp.cs:175 | No | +| `LeaveGround` | MotionInterp.cs:192 | No | +| `jump` | MotionInterp.cs:710 | No | +| `jump_is_allowed` | MotionInterp.cs:742 | No — **flag: possible null-guard typo L747** | +| `jump_charge_is_allowed` | MotionInterp.cs:729 | No | +| `charge_jump` | MotionInterp.cs:564 | No — **flag: Falling/Fallen + range-inclusivity mismatch vs jump_charge_is_allowed** | +| `get_jump_v_z` | MotionInterp.cs:634 | No | +| `get_leave_ground_velocity` | MotionInterp.cs:654 | No | +| `enter_default_state` | MotionInterp.cs:604 | No — **flag: `// ??` on PendingMotions reset** | +| `apply_raw_movement` | MotionInterp.cs:506 | No | +| `apply_interpreted_movement` | MotionInterp.cs:440 | No — **flag: inline PhysicsObj.StopInterpretedMotion duplication in Turn-stop branch** | +| `apply_current_movement` | MotionInterp.cs:430 | No | +| `adjust_motion` | MotionInterp.cs:394 | No | +| `apply_run_to_command` | MotionInterp.cs:525 | No | +| `contact_allows_move` | MotionInterp.cs:584 | No | +| `motion_allows_jump` | MotionInterp.cs:770 | No — **depends on MotionCommand enum ordinal layout matching retail exactly** | +| `move_to_interpreted_state` | MotionInterp.cs:789 | No — **flag: 0x7FFF vs 0x7FFFF mask width mismatch** | +| `MovementManager.PerformMovement` | MovementManager.cs:124 | No | +| `MovementManager.HandleEnterWorld` | MovementManager.cs:48 | No — **dead/commented; retail NoticeHandler::RecvNotice_PrevSpellSelection not ported** | +| `MotionTableManager.AnimationDone` | MotionTableManager.cs:28 | No | +| `MotionTableManager.CheckForCompletedMotions` | MotionTableManager.cs:63 | No | + +All rows above are candidates for Step 0 (`grep named-retail/acclient_2013_pseudo_c.txt` by `CMotionInterp::` — note retail almost certainly uses the `C`-prefixed class name ACE dropped) before any acdream port work proceeds, per CLAUDE.md's mandatory workflow. diff --git a/docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md b/docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md new file mode 100644 index 00000000..2e9a07ed --- /dev/null +++ b/docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md @@ -0,0 +1,1698 @@ +# R3 — CMotionInterp completion + MovementManager relay: verbatim decomp extraction + +Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (line numbers = file +line numbers, not addresses). Structs: `docs/research/named-retail/acclient.h`. +Already ported (S2a/D6, NOT re-extracted here): `move_to_interpreted_state`, +`apply_interpreted_movement`, `DoInterpretedMotion`, `StopInterpretedMotion`, +`contact_allows_move`, `adjust_motion`, `apply_run_to_command`, `apply_raw_movement`, +`get_state_velocity`. + +--- + +## 0. Key structs (acclient.h) + +```c +/* acclient.h:31407 — struct #3457 */ +struct __cppobj CMotionInterp +{ + int initted; + CWeenieObject *weenie_obj; + CPhysicsObj *physics_obj; + RawMotionState raw_state; + InterpretedMotionState interpreted_state; + float current_speed_factor; + int standing_longjump; + float jump_extent; + unsigned int server_action_stamp; + float my_run_rate; + LList pending_motions; +}; + +/* acclient.h:53293 — struct #5857 */ +struct __cppobj CMotionInterp::MotionNode : LListData +{ + unsigned int context_id; // +4 (offset from LListData's own +0 next-ptr) + unsigned int motion; // +8 — the "action-class" field; bit 0x10000000 = action-class flag + unsigned int jump_error_code; // +0xc +}; + +/* acclient.h:30943 — struct #3463 */ +struct __cppobj MovementManager +{ + CMotionInterp *motion_interpreter; + MoveToManager *moveto_manager; + CPhysicsObj *physics_obj; + CWeenieObject *weenie_obj; +}; + +/* acclient.h:38069 — struct #4067 */ +struct __cppobj MovementStruct +{ + MovementTypes::Type type; + unsigned int motion; + unsigned int object_id; + unsigned int top_level_id; + Position pos; + float radius; + float height; + MovementParameters *params; +}; + +/* acclient.h:2856 — enum #229 */ +enum MovementTypes::Type +{ + Invalid = 0x0, + RawCommand = 0x1, + InterpretedCommand = 0x2, + StopRawCommand = 0x3, + StopInterpretedCommand = 0x4, + StopCompletely = 0x5, + MoveToObject = 0x6, + MoveToPosition = 0x7, + TurnToObject = 0x8, + TurnToHeading = 0x9, + FORCE_Type_32_BIT = 0x7FFFFFFF, +}; + +/* acclient.h:31453 — struct #3460 */ +struct __cppobj MovementParameters : PackObj +{ + union { unsigned int bitfield; ... } ___u1; // flags: bit0x8=SetHoldKey, bit0x20=RawMotionState::ApplyMotion/RemoveMotion mirror, bit0x40=InterpretedMotionState::ApplyMotion/RemoveMotion, bit0x80000000(sign)=interrupt_current_movement, bit0x1ee0f default set (see MovementParameters ctor) + float distance_to_object; + float min_distance; + float desired_heading; + float speed; + float fail_distance; + float walk_run_threshhold; + unsigned int context_id; + HoldKey hold_key_to_apply; + unsigned int action_stamp; +}; +``` + +`MovementParameters` default ctor (line 300510-300534, `00524380`): `min_distance=0`, +`distance_to_object=0.6`, `fail_distance=FLT_MAX`, `desired_heading=0`, `speed=1`, +`walk_run_threshhold=15`, `context_id=0`, `hold_key_to_apply=HoldKey_Invalid`, +`action_stamp=0`, bitfield defaults to `(bitfield & 0xfffdee0f) | 0x1ee0f` via a static +cached template (`normal_bitfield`). + +--- + +## 1. `pending_motions` lifecycle + +### 1a. `CMotionInterp::add_to_queue` — `00527b80` @ line 305032 + +```c +00527b80 void __thiscall CMotionInterp::add_to_queue(class CMotionInterp* this, uint32_t arg2, uint32_t arg3, uint32_t arg4) +00527b80 { +00527b85 class LListData* eax = operator new(0x10); +00527b8f if (eax == 0) +00527bae eax = nullptr; +00527b8f else +00527b8f { +00527b99 *(int32_t*)((char*)eax + 4) = arg2; // MotionNode.context_id = arg2 +00527ba0 eax->llist_next = 0; +00527ba6 *(int32_t*)((char*)eax + 8) = arg3; // MotionNode.motion = arg3 +00527ba9 *(int32_t*)((char*)eax + 0xc) = arg4; // MotionNode.jump_error_code = arg4 +00527b8f } +00527b8f +00527bb0 class LListData** tail_ = this->pending_motions.tail_; +00527bb0 +00527bb8 if (tail_ != 0) +00527bb8 { +00527bba *(uint32_t*)tail_ = eax; +00527bbc this->pending_motions.tail_ = eax; +00527bc3 return; +00527bb8 } +00527bb8 +00527bc6 this->pending_motions.head_ = eax; +00527bcc this->pending_motions.tail_ = eax; +00527b80 } +``` + +Cleaned flow: allocate a `MotionNode{context_id=arg2, motion=arg3, jump_error_code=arg4}`, +append to the singly-linked `pending_motions` queue (append at tail; if queue was empty, +set both head and tail). + +**Callers** (every enqueue point in CMotionInterp): +- `StopCompletely` (line 305227): `add_to_queue(this, 0, 0x41000003 /*forward=none*/, eax_2)` where `eax_2 = motion_allows_jump(this, interpreted_state.forward_command)`. +- `DoInterpretedMotion` (line 305607): `add_to_queue(this, arg3->context_id, arg2 /*the motion just applied*/, eax_5)` where `eax_5` is the jump-error-code computed just above (see §3 `motion_allows_jump`). +- `StopInterpretedMotion` (line 305657): `add_to_queue(this, arg3->context_id, 0x41000003, result_1)` — always re-queues a "return to none" motion node after a successful stop. +- `apply_interpreted_movement` (line 305775): `add_to_queue(this, var_c /*uninitialized in decomp — context_id*/, 0x41000003, eax_10)` when the fallback "stop 0x6500000d" path succeeds. + +### 1b. `CMotionInterp::motions_pending` — `00527fe0` @ line 305322 + +```c +00527fe0 int32_t __fastcall CMotionInterp::motions_pending(class CMotionInterp const* this) +00527fe0 { +00527fea int32_t result; +00527fea result = this->pending_motions.head_ != 0; +00527fed return result; +00527fe0 } +``` + +Cleaned: `motions_pending()` = `pending_motions.head_ != null`. Non-empty queue means +"at least one motion in flight waiting for a `MotionDone` callback." + +**Caller (`MovementManager::motions_pending`, `00524280` @ line 300365):** +```c +00524280 int32_t __fastcall MovementManager::motions_pending(class MovementManager const* this) +00524280 { +00524280 class CMotionInterp* motion_interpreter = this->motion_interpreter; +00524284 if ((motion_interpreter != 0 && CMotionInterp::motions_pending(motion_interpreter) != 0)) +00524294 return 1; +00524294 return 0; +00524280 } +``` + +### 1c. `CMotionInterp::MotionDone` — `00527ec0` @ line 305238 (FULL BODY) + +```c +00527ec0 void __fastcall CMotionInterp::MotionDone(class CMotionInterp* this, int32_t arg2) +00527ec0 { +00527ec3 class CPhysicsObj* physics_obj = this->physics_obj; +00527ec8 if (physics_obj != 0) +00527ec8 { +00527eca class LListData* head_ = this->pending_motions.head_; +00527ed2 if (head_ != 0) +00527ed2 { +00527edb if ((*(int32_t*)((char*)head_ + 8) & 0x10000000) != 0) // MotionNode.motion & 0x10000000 (action-class bit) +00527edb { +00527edd CPhysicsObj::unstick_from_object(physics_obj); +00527ee5 InterpretedMotionState::RemoveAction(&this->interpreted_state); +00527eed RawMotionState::RemoveAction(&this->raw_state); +00527edb } +00527edb +00527ef2 class LListData* head__1 = this->pending_motions.head_; +00527efa if (head__1 != 0) +00527efa { +00527efc class LListData* llist_next = head__1->llist_next; +00527f00 this->pending_motions.head_ = llist_next; +00527f06 if (llist_next == 0) +00527f08 this->pending_motions.tail_ = llist_next; +00527f08 +00527f0f head__1->llist_next = 0; +00527f15 operator delete(head__1); +00527efa } +00527ed2 } +00527ec8 } +00527ec0 } +``` + +Cleaned flow: `MotionDone(arg2)` — note `arg2` (the "was interrupted?" flag from the +caller) is **read into `edx` but never actually used** in this build (dead parameter +per the `MovementManager::MotionDone` relay below, which loads `var_4_1 = arg3` then +passes an uninitialized `edx` through — a decompiler artifact, but functionally this +build's `CMotionInterp::MotionDone` body never branches on `arg2`). The real logic: + +1. If `physics_obj == null`, no-op (motion interpreter isn't attached). +2. If `pending_motions` is empty, no-op. +3. Peek the **head** node. If `head.motion & 0x10000000` (the **action-class flag**, + i.e. this pending motion was queued via the `0x10000000`-flagged multi-action path — + see `DoMotion`'s `InterpretedMotionState::GetNumActions(...) >= 6` gate and + `move_to_interpreted_state`'s per-action replay loop), then: + - `CPhysicsObj::unstick_from_object(physics_obj)` — release any "stuck to object" + sticky-manager attachment associated with this action. + - `InterpretedMotionState::RemoveAction(&interpreted_state)` — pop the action-list + head off the interpreted state's `actions` queue. + - `RawMotionState::RemoveAction(&raw_state)` — same for raw state. +4. Unconditionally pop the queue head (dequeue + `delete` the `LListData` node), + updating `tail_` to null if the queue is now empty. + +**No explicit `CheckForCompletedMotions` on `CMotionInterp` itself** — the actual +"walk the completed-animation list and fire callbacks" driver lives on +`MotionTableManager`/`CPartArray` (see §6). `CMotionInterp::MotionDone` is the +*consumer* end of that pipeline: `CPhysicsObj::MotionDone` → `MovementManager::MotionDone` +→ `CMotionInterp::MotionDone`, called once per completed animation node. + +### 1d. `CMotionInterp::HandleExitWorld` — `00527f30` (drains the whole queue, same action-class handling, looped until empty) + +```c +00527f30 void __fastcall CMotionInterp::HandleExitWorld(class CMotionInterp* this) +00527f30 { +00527f3b for (class LListData* head_ = this->pending_motions.head_; head_ != 0; head_ = this->pending_motions.head_) +00527f3b { +00527f40 class CPhysicsObj* physics_obj = this->physics_obj; +00527f49 if ((physics_obj != 0 && head_ != 0)) +00527f49 { +00527f52 if ((*(int32_t*)((char*)head_ + 8) & 0x10000000) != 0) +00527f52 { +00527f54 CPhysicsObj::unstick_from_object(physics_obj); +00527f5c InterpretedMotionState::RemoveAction(&this->interpreted_state); +00527f64 RawMotionState::RemoveAction(&this->raw_state); +00527f52 } +00527f52 +00527f69 class LListData* head__1 = this->pending_motions.head_; +00527f71 if (head__1 != 0) +00527f71 { +00527f73 class LListData* llist_next = head__1->llist_next; +00527f77 this->pending_motions.head_ = llist_next; +00527f7d if (llist_next == 0) +00527f7f this->pending_motions.tail_ = llist_next; +00527f7f +00527f86 head__1->llist_next = 0; +00527f8c operator delete(head__1); +00527f71 } +00527f49 } +00527f3b } +00527f30 } +``` + +Identical body to `MotionDone`'s single-pop logic, just looped until the queue drains +(world exit = flush all pending motion callbacks unconditionally, since no more +animation-completion events will arrive). + +--- + +## 2. `DoMotion` family — `00528d20` @ line 306159 + +```c +00528d20 uint32_t __thiscall CMotionInterp::DoMotion(class CMotionInterp* this, uint32_t arg2, class MovementParameters const* arg3) +00528d20 { +00528d26 class CPhysicsObj* physics_obj = this->physics_obj; +00528d2b if (physics_obj == 0) +00528d36 return 8; +00528d36 +00528d3a uint32_t ebp = arg2; // ebp = original motion id (unmutated copy) +00528d46 union __inner0 = arg3->__inner0; // MovementParameters bitfield +00528d4b float distance_to_object = arg3->distance_to_object; +00528d52 float min_distance = arg3->min_distance; +00528d59 float desired_heading = arg3->desired_heading; +00528d60 float speed = arg3->speed; +00528d67 float fail_distance = arg3->fail_distance; +00528d6e float walk_run_threshhold = arg3->walk_run_threshhold; +00528d75 uint32_t context_id = arg3->context_id; +00528d7c enum HoldKey hold_key_to_apply = arg3->hold_key_to_apply; +00528d80 uint32_t action_stamp = arg3->action_stamp; +00528d83 arg2 = ebp; +00528d87 int32_t var_2c = 0x7c83f8; // local MovementParameters vtable stamp (stack copy) +00528d8f union __inner0_2 = __inner0; +00528d93 uint32_t action_stamp_1 = action_stamp; +00528d93 +00528d97 if (*(uint8_t*)((char*)__inner0 + 1) < 0) // bitfield high-byte sign bit => "interrupt" flag +00528d99 CPhysicsObj::interrupt_current_movement(physics_obj); +00528d99 +00528da4 if ((*(uint8_t*)((char*)__inner0_1 + 1) & 8) != 0) // bit 0x800 => SetHoldKey requested +00528db3 CMotionInterp::SetHoldKey(this, arg3->hold_key_to_apply, ((__inner0_1 >> 0xf) & 1)); +00528db3 +00528dc8 CMotionInterp::adjust_motion(this, &arg2, &speed, arg3->hold_key_to_apply); +00528dc8 +00528dd4 if (this->interpreted_state.current_style != 0x8000003d) // not "MotionStance_NonCombat" (the free/uninhibited style) +00528dd4 { +00528ddd if (ebp == 0x41000012) +00528e22 return 0x3f; +00528e22 +00528de0 if (ebp == 0x41000013) +00528e14 return 0x40; +00528e14 +00528de3 if (ebp == 0x41000014) +00528e06 return 0x41; +00528e06 +00528deb if ((ebp & 0x2000000) != 0) +00528df8 return 0x42; +00528dd4 } +00528dd4 +00528e2b if (((ebp & 0x10000000) != 0 && InterpretedMotionState::GetNumActions(&this->interpreted_state) >= 6)) +00528e45 return 0x45; +00528e45 +00528e55 uint32_t result = CMotionInterp::DoInterpretedMotion(this, arg2, &var_2c); +00528e55 +00528e66 if ((result == 0 && (*(uint8_t*)((char*)((int16_t)arg3->__inner0))[1] & 0x20) != 0)) // bit 0x2000 => mirror to raw_state +00528e6d RawMotionState::ApplyMotion(&this->raw_state, ebp, arg3); +00528e6d +00528e7b return result; +00528d20 } +``` + +Cleaned flow: + +1. No-op (return `8` = "no physics object") if `physics_obj` is null. +2. Snapshot every `MovementParameters` field onto the stack (locals), then rebuild a + **fresh local `MovementParameters` (`var_2c`)** — this is `DoMotion` defaulting: the + incoming `arg3` is copied field-by-field but the call into `DoInterpretedMotion` + below passes the **freshly-defaulted local**, not the caller's `arg3`, except for + the mutated `arg2`/`speed`/`hold_key_to_apply` triple that `adjust_motion` writes in + place. In other words: `DoMotion` re-derives a canonical `MovementParameters` from + the caller's flags/speed/holdkey, discarding caller-supplied distance/heading/ + fail-distance fields for the interpreted-motion call (those only matter for + MoveTo-family commands, not raw DoMotion). +3. If the incoming bitfield's sign bit is set → `interrupt_current_movement` (cancel + any physics-level transition in progress before applying the new motion). +4. If bit `0x800` (`SetHoldKey` flag) is set → call `SetHoldKey(hold_key_to_apply, bit0xf)` + BEFORE `adjust_motion` runs, so the new hold-key affects the walk/run + reinterpretation below. +5. `adjust_motion(&arg2, &speed, hold_key_to_apply)` — this is the D6-ported function; + mutates `arg2` (the motion id) and `speed` in place per the retail walk/run/sidestep + reinterpretation table. +6. **Combat-stance gate**: if `interpreted_state.current_style != 0x8000003d` + (i.e. the creature is in ANY combat/special stance, not the neutral + `MotionStance_NonCombat`), then jump-charge motions (`0x41000012`/`13`/`14`) and any + motion with bit `0x2000000` set are **rejected outright** with distinct error codes + `0x3f`/`0x40`/`0x41`/`0x42` — you cannot charge or release a jump while in combat + stance, non-combat with bow drawn, etc. +7. **Action-queue depth gate**: if the motion is an "action-class" motion (bit + `0x10000000` set) AND `InterpretedMotionState::GetNumActions(...) >= 6`, reject with + `0x45` ("too many pending actions" — 6 is the hard cap on queued interpreted + actions). +8. Otherwise delegate to `DoInterpretedMotion(this, arg2, &var_2c)` (the already-ported + function) using the **locally reconstructed** `MovementParameters`. +9. If that succeeded AND bit `0x2000` of the ORIGINAL `arg3->__inner0` is set (mirror- + to-raw flag), replay the motion into `raw_state` via `RawMotionState::ApplyMotion`. +10. Return whatever `DoInterpretedMotion` returned (0 = success, propagated error code + otherwise). + +**Relationship to `DoInterpretedMotion`**: `DoMotion` is a thin **gating + defaulting +wrapper** around `DoInterpretedMotion`. It exists specifically for the `RawCommand` +movement-type entry point (`MovementTypes::Type::RawCommand = 1`, dispatched via +`CMotionInterp::PerformMovement` case 0, see §5), whereas `InterpretedCommand` (type 2, +case 1) calls `DoInterpretedMotion` directly with the caller's own `MovementParameters` +(no combat-stance / action-depth gating, no forced-local-defaulting). So: raw commands +(keyboard input from THIS client, i.e. autonomous local movement) get extra validation +that interpreted/remote commands skip. + +--- + +## 3. Jump family + +### 3a. `CMotionInterp::motion_allows_jump` — `005279e0` @ line 304908 (`__pure`) + +```c +005279e0 uint32_t __stdcall CMotionInterp::motion_allows_jump(class CMotionInterp* this @ ecx, uint32_t arg2) __pure +005279e0 { +005279e9 if (arg2 > 0x40000018) +005279e9 { +00527a24 if (arg2 > 0x41000014) +00527a10 return 0; +00527a10 +00527a39 if ((arg2 < 0x41000012 && (arg2 < 0x4000001e || arg2 > 0x40000039))) +00527a10 return 0; +005279e9 } +005279e9 else if (arg2 < 0x40000016) +005279f0 { +005279f7 if (arg2 > 0x10000131) +005279f7 { +00527a18 if (arg2 != 0x40000008) +00527a1c return 0; +005279f7 } +005279f7 else if ((arg2 < 0x10000128 && (arg2 < 0x1000006f || arg2 > 0x10000078))) +00527a10 return 0; +005279f0 } +005279f0 +00527a40 return 0x48; +005279e0 } +``` + +Cleaned: returns `0x48` (nonzero = "jump is currently allowed given this in-flight +motion id") for a specific whitelist of motion-id ranges, else `0` (disallowed): +- `arg2 in [0x1000006f, 0x10000078]` (movement/locomotion range 1) — allowed. +- `arg2 in [0x10000128, 0x10000131]` — allowed. +- `arg2 == 0x40000008` — allowed. +- `arg2 == 0x40000016` through `0x40000018` inclusive — allowed (falls through the + `else if` since neither branch rejects it; note `< 0x40000016` is the else-if guard, + so `0x40000016..0x40000018` skip both inner branches and hit `return 0x48` directly). +- `arg2 in [0x4000001e, 0x40000039]` — allowed. +- `arg2 in [0x41000012, 0x41000014]` — allowed (jump-charge stance ids). +- everything else outside `(0x40000018, 0x41000014]` range or the whitelisted holes → `0`. + +This is the "can retail let you jump given the motion currently applying" check — used +by `StopCompletely`, `DoInterpretedMotion`, `move_to_interpreted_state` to compute the +`jump_error_code` stashed in each `MotionNode`. + +### 3b. `CMotionInterp::jump_charge_is_allowed` — `00527a50` @ line 304935 + +```c +00527a50 uint32_t __fastcall CMotionInterp::jump_charge_is_allowed(class CMotionInterp* this) +00527a50 { +00527a53 class CWeenieObject* weenie_obj = this->weenie_obj; +00527a58 if ((weenie_obj != 0 && weenie_obj->vtable->CanJump(this->jump_extent) == 0)) +00527a6d return 0x49; +00527a6d +00527a6e uint32_t forward_command = this->interpreted_state.forward_command; +00527a87 if ((forward_command != 0x40000008 && (forward_command <= 0x41000011 || forward_command > 0x41000014))) +00527a8c return 0; +00527a8c +00527a93 return 0x48; +00527a50 } +``` + +Cleaned: `0x49` if the weenie's `CanJump(jump_extent)` virtual rejects the current +charge amount (e.g. stamina/burden gate). Otherwise: allowed (`0x48`) only if the +**current interpreted forward_command** is either exactly `0x40000008` OR strictly +inside `(0x41000011, 0x41000014]` (i.e. already mid-jump-charge motion ids +`0x41000012..0x41000014`); anything else → `0` (not allowed to *start* charging a jump +from this forward-motion state). + +### 3c. `CMotionInterp::get_jump_v_z` — `00527aa0` @ line 304953 + +```c +00527aa0 float __fastcall CMotionInterp::get_jump_v_z(class CMotionInterp const* this) +00527aa0 { +00527aa0 class CMotionInterp* jump_extent = this; +00527aa4 jump_extent = this->jump_extent; +00527aac long double temp0 = ((long double)0.000199999995f); +00527ab2 float result; +00527ab2 result = /* jump_extent < 0.0002f (epsilon) ? */; +00527ab4 bool p = /* test ah, 0x5 — i.e. "jump_extent < 0.0002" */; +00527ab7 if (p) +00527ab7 { +00527ab9 long double x87_r7_1 = ((long double)jump_extent); +00527abd long double temp1_1 = ((long double)1f); +00527ac3 result = /* jump_extent < 1.0f */; +00527ac8 if ((*(uint8_t*)((char*)result)[1] & 0x41) == 0) // i.e. jump_extent >= 1.0 +00527aca jump_extent = 0x3f800000; // clamp jump_extent to 1.0f +00527aca +00527ad2 class CWeenieObject* weenie_obj = this->weenie_obj; +00527ad7 if (weenie_obj == 0) +00527ae0 return result; +00527ae0 +00527aed result = weenie_obj->vtable->InqJumpVelocity(jump_extent, &jump_extent); +00527ab7 } +00527ab7 +00527b01 return result; +00527aa0 } +``` + +Cleaned: if `jump_extent < 0.0002` (essentially zero — no charge held), **`result` is +left as the raw FCOM-flags byte from the comparison, effectively returning 0** (this +looks like a decompiler mis-render of a `fldz`-style zero return, not a real +"return the comparison flags" — the practical behavior is: **near-zero charge returns +0 velocity contribution**, deferred entirely to the `else` path). If `jump_extent >= 0.0002`, +clamp `jump_extent` to `[extent, 1.0]` (values ≥ 1.0 get clamped to exactly `1.0f`), +then if there's a weenie object, call its `InqJumpVelocity(jump_extent, &jump_extent)` +virtual to get the actual world-space jump Z velocity for that charge fraction +(this is where creature-specific jump power / `JUMP_STAMINA` scaling lives — outside +CMotionInterp, in `CWeenieObject`/`PlayerWeenie`). If no weenie object, returns the +clamped `jump_extent` directly as the velocity (fallback for non-weenie physics +objects). + +### 3d. `CMotionInterp::get_leave_ground_velocity` — `005280c0` @ line 305404 + +```c +005280c0 void __thiscall CMotionInterp::get_leave_ground_velocity(class CMotionInterp* this, class AC1Legacy::Vector3* arg2) +005280c0 { +005280c4 class AC1Legacy::Vector3* esi = arg2; +005280cc CMotionInterp::get_state_velocity(this, esi); // fills esi.x/esi.y from interpreted forward/sidestep speed (D6-ported) +005280d8 arg2 = ((float)CMotionInterp::get_jump_v_z(this)); +005280e2 long double x87_r0_2 = fabsl(((long double)esi->x)); +005280e4 esi->z = arg2; // esi.z = jump vertical velocity +005280e7 long double x87_r7 = ((long double)0.000199999995f); +005280ef /* if fabs(esi.x) < 0.0002 */ +005280f4 if ( /* fabs(esi.x) < 0.0002 */ ) +005280f4 { +005280fd long double x87_r0_4 = fabsl(((long double)esi->y)); +00528105 /* if fabs(esi.y) < 0.0002 */ +0052810c if ( /* fabs(esi.y) < 0.0002 */ ) +0052810c { +00528116 long double x87_r0_6 = fabsl(((long double)arg2)); // fabs(esi.z) +0052811e /* if fabs(esi.z) < 0.0002 */ +00528125 if ( /* fabs(esi.z) < 0.0002 */ ) +00528125 { +0052812b class CPhysicsObj* physics_obj = this->physics_obj; +0052814b // esi = physics_obj->m_velocityVector transformed by m_position.frame.m_fl2gv (local->global velocity basis, 3x3-ish) +0052814b long double x87_r0_10 = (m_fl2gv[1]*vel.y + m_fl2gv[0]*vel.x) + m_fl2gv[2]*vel.z; +00528172 long double x87_r0_14 = (m_fl2gv[4]*vel.y + m_fl2gv[3]*vel.x) + m_fl2gv[5]*vel.z; +0052818e long double x87_r0_17 = m_fl2gv[7]*vel.y + m_fl2gv[6]*vel.x; +00528196 long double x87_r7_14 = m_fl2gv[8]*vel.z; +0052819c esi->x = ((float)x87_r0_10); +0052819e esi->y = ((float)x87_r0_14); +005281ab esi->z = ((float)(x87_r0_17 + x87_r7_14)); +00528125 } +0052810c } +005280f4 } +005280c0 } +``` + +Cleaned flow: compute the leave-ground (jump) launch velocity vector. + +1. Start with `get_state_velocity(esi)` — the horizontal (x/y) velocity implied by the + current interpreted forward/sidestep speed (already ported in D6). +2. Set `esi.z = get_jump_v_z(this)` — the vertical jump-charge velocity. +3. **Fallback**: if the resulting velocity vector is essentially zero on ALL THREE axes + (`|x| < 0.0002 && |y| < 0.0002 && |z| < 0.0002` — i.e. no forward/sidestep motion AND + no jump charge, e.g. leaving ground via a fall-off-edge rather than an active jump), + overwrite `esi` entirely with the **physics object's current local velocity + transformed into global space** via the `m_position.frame.m_fl2gv` 3x3 local→global + basis matrix (a 9-float row-major array: `m_fl2gv[0..8]`). This preserves momentum + from e.g. being pushed off a ledge, rather than snapping to zero velocity. + +`m_fl2gv` — "frame local to global vector" transform matrix, part of `Position.frame`. + +**Caller**: `LeaveGround` (§4b) — this is the velocity CMotionInterp hands to +`CPhysicsObj::set_local_velocity` the instant the mover leaves the ground. + +### 3e. `CMotionInterp::charge_jump` — `005281c0` @ line 305448 + +```c +005281c0 uint32_t __fastcall CMotionInterp::charge_jump(class CMotionInterp* this) +005281c0 { +005281c3 class CWeenieObject* weenie_obj = this->weenie_obj; +005281c8 if ((weenie_obj != 0 && weenie_obj->vtable->CanJump(this->jump_extent) == 0)) +005281dd return 0x49; +005281dd +005281de uint32_t forward_command = this->interpreted_state.forward_command; +005281f7 if ((forward_command == 0x40000008 || (forward_command > 0x41000011 && forward_command <= 0x41000014))) +00528233 return 0x48; +00528233 +005281fc uint8_t transient_state = ((int8_t)this->physics_obj->transient_state); +00528222 if (((transient_state & 1) != 0 && ((transient_state & 2) != 0 && (forward_command == 0x41000003 && (this->interpreted_state.sidestep_command == 0 && this->interpreted_state.turn_command == 0))))) +00528224 this->standing_longjump = 1; +00528224 +0052822c return 0; +005281c0 } +``` + +Cleaned: `0x49` if `CanJump` virtual rejects the current charge. `0x48` ("already +charging") if `forward_command` is already `0x40000008` or in `[0x41000012,0x41000014]` +(no-op re-entry guard — mirrors `jump_charge_is_allowed`'s allowed set but this +function is the "you're already there" early-out, not the gate). Otherwise: +`standing_longjump = 1` **iff** the physics object is currently grounded +(`transient_state` bits `0x1` AND `0x2` both set — matches `CPhysicsObj::on_ground`, +§3g) AND the mover is perfectly idle (`forward_command == 0x41000003 /*none*/`, +`sidestep_command == 0`, `turn_command == 0`) — i.e. a **standing** long-jump charge +(distinct code path from a running jump). Returns `0` = success (charge accepted, flag +set if it was a standing charge). + +**Caller** (from outside CMotionInterp, line 376144, `0056afac`): +``` +uint32_t eax_3 = CMotionInterp::charge_jump(CPhysicsObj::get_minterp(SmartBox::smartbox->player)); +``` +Reached via the player-input/SmartBox layer — outside R3 scope (input handling), noted +for completeness only. + +### 3f. `CMotionInterp::jump` — `00528780` @ line 305792 + +```c +00528780 uint32_t __thiscall CMotionInterp::jump(class CMotionInterp* this, float arg2, int32_t* arg3) +00528780 { +00528783 class CPhysicsObj* physics_obj = this->physics_obj; +00528788 if (physics_obj == 0) +00528790 return 8; +00528790 +00528794 CPhysicsObj::interrupt_current_movement(physics_obj); +005287a5 uint32_t result = CMotionInterp::jump_is_allowed(this, arg2, arg3); +005287ae if (result != 0) +005287ae { +005287ca this->standing_longjump = 0; +005287d2 return result; +005287ae } +005287ae +005287b4 class CPhysicsObj* physics_obj_1 = this->physics_obj; +005287b8 this->jump_extent = arg2; +005287bb CPhysicsObj::set_on_walkable(physics_obj_1, result); // result == 0 here, so set_on_walkable(false) +005287c4 return result; +00528780 } +``` + +Cleaned: `arg2` = jump extent/charge fraction (`0.0`..`1.0`), `arg3` = out-param for +stamina cost (threaded through `jump_is_allowed` → `JumpStaminaCost`). `8` if no +physics object. Always calls `interrupt_current_movement` first (cancel any pending +transition). Calls `jump_is_allowed(arg2, arg3)` (§3h below); on failure, **clears** +`standing_longjump` (a failed jump attempt cancels any pending standing-longjump flag) +and returns the error. On success (`result == 0`): store `jump_extent = arg2`, then +`CPhysicsObj::set_on_walkable(physics_obj, 0)` — explicitly mark the physics object as +**no longer on a walkable surface** (the jump has begun; this is what triggers the +ground→air physics transition that eventually calls back into `LeaveGround`). + +### 3g. `CPhysicsObj::on_ground` — `00527b20` @ line 304996 (context, referenced by 3e) + +```c +00527b20 int32_t __fastcall CPhysicsObj::on_ground(class CPhysicsObj const* this) +00527b20 { +00527b20 uint8_t transient_state = ((int8_t)this->transient_state); +00527b2c if (((transient_state & 1) != 0 && (transient_state & 2) != 0)) +00527b33 return 1; +00527b33 return 0; +00527b20 } +``` + +Both `transient_state` bit `0x1` and bit `0x2` must be set for "on ground" — same test +inlined directly in `charge_jump`, `contact_allows_move`, `is_standing_still`. + +### 3h. `CMotionInterp::jump_is_allowed` — `005282b0` (context — feeds `jump()`, §3f) + +```c +005282b0 uint32_t __thiscall CMotionInterp::jump_is_allowed(class CMotionInterp* this, float arg2, int32_t* arg3) +005282b0 { +005282b8 if (this->physics_obj != 0) +005282b8 { +005282ba class CWeenieObject* weenie_obj = this->weenie_obj; +005282bf int32_t eax_2; +005282bf if (weenie_obj != 0) +005282c3 eax_2 = weenie_obj->vtable->IsCreature(); +005282c3 +005282c8 if ((weenie_obj != 0 && eax_2 == 0)) // non-creature weenie => skip contact/ground gating +005282c8 { +005282f6 label_5282f6: +005282fd if (CPhysicsObj::IsFullyConstrained(this->physics_obj) != 0) +00528305 return 0x47; +00528305 +00528308 class LListData* head_ = this->pending_motions.head_; +00528310 uint32_t eax_6; +00528310 if (head_ != 0) +00528312 eax_6 = *(int32_t*)((char*)head_ + 0xc); // peek head's jump_error_code +00528312 +00528317 if ((head_ == 0 || eax_6 == 0)) +00528317 { +0052831b eax_6 = CMotionInterp::jump_charge_is_allowed(this); +0052831b +00528322 if (eax_6 == 0) +00528322 { +0052832b uint32_t eax_7 = CMotionInterp::motion_allows_jump(this, this->interpreted_state.forward_command); +0052832b +00528334 if (eax_7 != 0) +00528355 return eax_7; +00528355 +00528336 class CWeenieObject* weenie_obj_1 = this->weenie_obj; +0052833b if (weenie_obj_1 == 0) +00528355 return eax_7; +00528355 +0052834e eax_6 = 0x47; +00528353 if (weenie_obj_1->vtable->JumpStaminaCost(arg2, arg3) != 0) +00528355 return eax_7; // eax_7 == 0 here (success) +00528322 } +00528317 } +00528317 +00528359 return eax_6; +005282c8 } +005282c8 +005282ca class CPhysicsObj* physics_obj = this->physics_obj; +005282da if ((physics_obj == 0 || (*(uint8_t*)((char*)((int16_t)physics_obj->state))[1] & 4) == 0)) +005282cf goto label_5282f6; // state bit 0x400 not set => skip ground check, go straight to constrained/queue gating +005282cf +005282dc uint8_t transient_state = ((int8_t)physics_obj->transient_state); +005282e8 if (((transient_state & 1) != 0 && (transient_state & 2) != 0)) +005282e8 goto label_5282f6; // on-ground => also goes to the shared gating path +005282b8 } +005282b8 +005282f0 return 0x24; // not on ground and state bit 0x400 is set => "can't jump, not grounded" (0x24) +005282b0 } +``` + +Cleaned: for a creature-type weenie that is NOT on the ground (and physics `state` bit +`0x400` — i.e. `HAS_PHYSICS_BSP`/gravity-active flag — is set), jumping is disallowed +outright (`0x24`). Otherwise (non-creature weenie, OR grounded, OR gravity-inactive +object) falls into the shared gate: `IsFullyConstrained` check (`0x47` if constrained), +then peek the pending-motion-queue head's `jump_error_code` — if the head node already +carries a nonzero jump-error, that's returned directly (an earlier motion already +determined jump is blocked); otherwise recompute via `jump_charge_is_allowed` + +`motion_allows_jump(forward_command)`, and finally consult the weenie's +`JumpStaminaCost(extent, &outCost)` virtual (rejects with `0x47` if the weenie can't +afford the stamina cost of this jump extent). + +--- + +## 4. Ground transitions + +### 4a. `CMotionInterp::HitGround` — `00528ac0` @ line 305996 + +```c +00528ac0 void __fastcall CMotionInterp::HitGround(class CMotionInterp* this) +00528ac0 { +00528ac8 if (this->physics_obj != 0) +00528ac8 { +00528aca class CWeenieObject* weenie_obj = this->weenie_obj; +00528acf int32_t eax_2; +00528acf if (weenie_obj != 0) +00528ad3 eax_2 = weenie_obj->vtable->IsCreature(); +00528ad3 +00528ad8 if ((weenie_obj == 0 || eax_2 != 0)) +00528ad8 { +00528ada class CPhysicsObj* physics_obj = this->physics_obj; +00528aea if ((physics_obj != 0 && (*(uint8_t*)((char*)((int16_t)physics_obj->state))[1] & 4) != 0)) // state bit 0x400 (gravity/BSP-active) +00528aea { +00528aec CPhysicsObj::RemoveLinkAnimations(physics_obj); +00528af7 CMotionInterp::apply_current_movement(this, 0, 0); +00528aea } +00528ad8 } +00528ac8 } +00528ac0 } +``` + +Cleaned: guarded to non-creature-or-creature weenies (basically always runs unless the +weenie is a non-creature — mirrors the same `IsCreature` gate used throughout). If +`physics_obj` has gravity/BSP active (`state & 0x400`), remove any link/stuck +animations and re-apply current movement (`apply_current_movement(interruptFlag=0, +otherFlag=0)`) — this re-syncs the motion interpreter's applied motion once landing +occurs (e.g. transitioning out of a fall/jump animation into idle/walk/run). + +### 4b. `CMotionInterp::LeaveGround` — `00529710`... **actual address is `00528b00`** @ line 306022 + +(Note: the task description's address `00529710` does not match this build; the +correctly-grepped symbol resolves to `00528b00`.) + +```c +00528b00 void __fastcall CMotionInterp::LeaveGround(class CMotionInterp* this) +00528b00 { +00528b0b if (this->physics_obj != 0) +00528b0b { +00528b0d class CWeenieObject* weenie_obj = this->weenie_obj; +00528b12 int32_t eax_2; +00528b12 if (weenie_obj != 0) +00528b16 eax_2 = weenie_obj->vtable->IsCreature(); +00528b16 +00528b1b if ((weenie_obj == 0 || eax_2 != 0)) +00528b1b { +00528b1d class CPhysicsObj* physics_obj = this->physics_obj; +00528b2d if ((physics_obj != 0 && (*(uint8_t*)((char*)((int16_t)physics_obj->state))[1] & 4) != 0)) +00528b2d { +00528b36 void var_c; +00528b36 CMotionInterp::get_leave_ground_velocity(this, &var_c); +00528b45 CPhysicsObj::set_local_velocity(this->physics_obj, &var_c, 1); +00528b4a class CPhysicsObj* physics_obj_1 = this->physics_obj; +00528b4d this->standing_longjump = 0; +00528b54 this->jump_extent = 0f; +00528b5b CPhysicsObj::RemoveLinkAnimations(physics_obj_1); +00528b66 CMotionInterp::apply_current_movement(this, 0, 0); +00528b2d } +00528b1b } +00528b0b } +00528b00 } +``` + +Cleaned: same creature/gravity-active gating as `HitGround`. When leaving the ground: +1. Compute the launch velocity via `get_leave_ground_velocity` (§3d). +2. `CPhysicsObj::set_local_velocity(&velocity, autonomous=1)` — apply it as the physics + object's local velocity (marked autonomous — i.e. this is a locally-simulated + velocity change, not a server-dictated one). +3. Reset `standing_longjump = 0` and `jump_extent = 0.0` — the charge is consumed the + instant you actually leave the ground. +4. `RemoveLinkAnimations` + `apply_current_movement(0, 0)` — same re-sync as HitGround. + +**Also called from `enter_default_state`** (§4d) — entering the default motion state +always calls `LeaveGround` unconditionally as its last step (see below), which is how a +freshly-created `CMotionInterp` initializes into an airborne-capable state. + +### 4c. `CMotionInterp::ReportExhaustion` — `005288d0` @ line 305861 + +```c +005288d0 void __fastcall CMotionInterp::ReportExhaustion(class CMotionInterp* this) +005288d0 { +005288dd if ((this->physics_obj != 0 && this->initted != 0)) +005288dd { +005288df class CWeenieObject* weenie_obj = this->weenie_obj; +005288e4 int32_t eax_2; +005288e4 if (weenie_obj != 0) +005288e8 eax_2 = weenie_obj->vtable->IsThePlayer(); +005288e8 +005288ed if (((weenie_obj == 0 || eax_2 != 0) && CPhysicsObj::movement_is_autonomous(this->physics_obj) != 0)) +005288ed { +00528901 CMotionInterp::apply_raw_movement(this, 0, 0); +00528907 return; +005288ed } +005288ed +0052890e CMotionInterp::apply_interpreted_movement(this, 0, 0); +005288dd } +005288d0 } +``` + +Cleaned: requires `initted`. If (no weenie OR this IS the player weenie) AND the +physics object's last move was autonomous (`movement_is_autonomous` — local prediction, +not a server-driven DR update), re-apply via `apply_raw_movement(0,0)` (raw/local input +path). Otherwise, re-apply via `apply_interpreted_movement(0,0)` (remote/server-driven +path). This exact dual-dispatch pattern (`apply_raw_movement` vs `apply_interpreted_movement` +gated on `IsThePlayer() && movement_is_autonomous()`) is **identical** in +`apply_current_movement`, `SetWeenieObject`, `SetPhysicsObject` — see §4e-4g. Called +from `CPhysicsObj::report_exhaustion` (`0050fdd0`, tailcall) which itself relays from +`MovementManager::ReportExhaustion` (`00524360`, see §7). + +### 4d. `CMotionInterp::enter_default_state` — `00528c80` @ line 306124 + +```c +00528c80 void __fastcall CMotionInterp::enter_default_state(class CMotionInterp* this) +00528c80 { +00528c93 void var_38; +00528c93 RawMotionState::operator=(&this->raw_state, RawMotionState::RawMotionState(&var_38)); +00528c9c RawMotionState::~RawMotionState(&var_38); +00528cae InterpretedMotionState::operator=(&this->interpreted_state, InterpretedMotionState::InterpretedMotionState(&var_38)); +00528cb7 InterpretedMotionState::~InterpretedMotionState(&var_38); +00528cbf CPhysicsObj::InitializeMotionTables(this->physics_obj); +00528cc6 void* eax_2 = operator new(0x10); +00528cc6 if (eax_2 == 0) +00528ce5 eax_2 = nullptr; +00528cd2 else +00528cd2 { +00528cd4 *(uint32_t*)eax_2 = 0; +00528cd6 *(uint32_t*)((char*)eax_2 + 4) = 0; // MotionNode.context_id = 0 +00528cd9 *(uint32_t*)((char*)eax_2 + 8) = 0x41000003; // MotionNode.motion = 0x41000003 (forward=none) +00528ce0 *(uint32_t*)((char*)eax_2 + 0xc) = 0; // MotionNode.jump_error_code = 0 +00528cd2 } +00528cd2 +00528ce7 void** tail_ = this->pending_motions.tail_; +00528cef if (tail_ == 0) +00528cf5 this->pending_motions.head_ = eax_2; +00528cef else +00528cf1 *(uint32_t*)tail_ = eax_2; +00528cf1 +00528cfb this->pending_motions.tail_ = eax_2; +00528d03 this->initted = 1; +00528d09 CMotionInterp::LeaveGround(this); +00528c80 } +``` + +Cleaned: reset `raw_state` and `interpreted_state` to fresh default-constructed values +(discarding any prior motion), re-initialize the physics object's motion tables, then +**manually enqueue a `MotionNode{context_id=0, motion=0x41000003 /*forward-none*/, +jump_error_code=0}`** directly onto `pending_motions` (bypassing `add_to_queue`'s +inline construction but doing the exact same list-splice), mark `initted = 1`, and +finally call `LeaveGround()` unconditionally. Since this is called both from +`CMotionInterp::Create`'s callers (whenever `physics_obj != null`) AND is the shared +"reset to idle" path, the manual sentinel enqueue + forced `LeaveGround` establishes: +"a freshly-initted motion interpreter starts with one pending 'idle' motion queued and +immediately treats itself as airborne-transitioning" (i.e. it re-syncs velocity/anim +state exactly like any other ground-leave event, even though nothing physically left +the ground — this primes the pending_motions queue and the walkable flag consistently). + +--- + +## 5. `StopCompletely` / `StopMotion` / `HoldKey` boundary + +### 5a. `CMotionInterp::StopCompletely` — `00527e40` @ line 305208 + +```c +00527e40 uint32_t __fastcall CMotionInterp::StopCompletely(class CMotionInterp* this) +00527e40 { +00527e44 class CPhysicsObj* physics_obj = this->physics_obj; +00527e4b if (physics_obj == 0) +00527e54 return 8; +00527e54 +00527e56 CPhysicsObj::interrupt_current_movement(physics_obj); +00527e61 uint32_t eax_2 = CMotionInterp::motion_allows_jump(this, this->interpreted_state.forward_command); +00527e70 this->raw_state.forward_command = 0x41000003; +00527e77 this->raw_state.forward_speed = 1f; +00527e7a this->raw_state.sidestep_command = 0; +00527e7d this->raw_state.turn_command = 0; +00527e80 this->interpreted_state.forward_command = 0x41000003; +00527e87 this->interpreted_state.forward_speed = 1f; +00527e8a this->interpreted_state.sidestep_command = 0; +00527e8d this->interpreted_state.turn_command = 0; +00527e90 CPhysicsObj::StopCompletely_Internal(this->physics_obj); +00527e9e CMotionInterp::add_to_queue(this, 0, 0x41000003, eax_2); +00527ea3 class CPhysicsObj* physics_obj_1 = this->physics_obj; +00527eb1 if ((physics_obj_1 != 0 && physics_obj_1->cell == 0)) +00527eb3 CPhysicsObj::RemoveLinkAnimations(physics_obj_1); +00527ebc return 0; +00527e40 } +``` + +Cleaned: `8` if no physics object. Otherwise: interrupt any current transition, snapshot +whether a jump would still be allowed given the (about-to-be-overwritten) +`forward_command`, forcibly zero out BOTH `raw_state` and `interpreted_state`'s +forward/sidestep/turn commands to "none" (`forward_command = 0x41000003`, +`forward_speed = 1.0`, `sidestep_command = turn_command = 0`), call +`CPhysicsObj::StopCompletely_Internal` (the actual physics-level full stop — outside +CMotionInterp), then unconditionally enqueue a `MotionNode{0, 0x41000003, eax_2}` onto +`pending_motions` (this is the ONLY caller that passes the **precomputed** jump-allowed +flag as the queued node's `jump_error_code`, rather than recomputing after applying the +new motion — note the semantic quirk: `eax_2` was computed from the OLD +`forward_command` value, before the overwrite two lines later). If the physics object +has no cell (detached / not in world), also strip link animations. Always returns `0` +(success — `StopCompletely` cannot fail once a physics object exists). + +**Note the constant `0x41000003`** = the canonical "no forward motion" / neutral +forward-command id used throughout this whole subsystem (`StopCompletely`, +`enter_default_state`'s sentinel, `StopInterpretedMotion`'s post-stop +requeue, `apply_interpreted_movement`'s fallback requeue). + +### 5b. `CMotionInterp::StopMotion` — `00528530` @ line 305674 + +```c +00528530 uint32_t __thiscall CMotionInterp::StopMotion(class CMotionInterp* this, uint32_t arg2, class MovementParameters const* arg3) +00528530 { +00528536 class CPhysicsObj* physics_obj = this->physics_obj; +0052853b if (physics_obj == 0) +00528546 return 8; +00528546 +0052854c class MovementParameters* esi = arg3; +00528555 if (*(uint8_t*)((char*)((int16_t)esi->__inner0))[1] < 0) // sign-bit / interrupt flag +00528557 CPhysicsObj::interrupt_current_movement(physics_obj); +00528557 +00528562 float min_distance = esi->min_distance; +00528569 union __inner0 = esi->__inner0; +00528570 float desired_heading = esi->desired_heading; +00528577 float distance_to_object = esi->distance_to_object; +0052857e float walk_run_threshhold = esi->walk_run_threshhold; +00528582 enum HoldKey hold_key_to_apply = esi->hold_key_to_apply; +00528585 float speed = esi->speed; +0052858d float min_distance_1 = min_distance; +00528594 enum HoldKey hold_key_to_apply_1 = hold_key_to_apply; +00528598 uint32_t context_id = esi->context_id; +005285a5 float fail_distance = esi->fail_distance; +005285a9 uint32_t action_stamp = esi->action_stamp; +005285af arg3 = arg2; // reuse arg3 slot to hold the motion id (register shuffle) +005285b3 int32_t var_2c = 0x7c83f8; // fresh local MovementParameters (vtable stamp) +005285bb uint32_t action_stamp_1 = action_stamp; +005285bf CMotionInterp::adjust_motion(this, &arg3, &speed, hold_key_to_apply); +005285d0 uint32_t result = CMotionInterp::StopInterpretedMotion(this, arg3, &var_2c); +005285e1 if ((result == 0 && (*(uint8_t*)((char*)((int16_t)esi->__inner0))[1] & 0x20) != 0)) // bit 0x2000 mirror +005285e7 RawMotionState::RemoveMotion(&this->raw_state, arg2); +005285f5 return result; +00528530 } +``` + +Cleaned: the `StopMotion` mirror of `DoMotion` — same defaulting/reconstruction pattern +(snapshot all `arg3` fields onto the stack, build a fresh local `MovementParameters`), +same optional `interrupt_current_movement`, same `adjust_motion` reinterpretation of +the motion id + speed, but no combat-stance or action-depth gating (those are +`DoMotion`-only). Delegates to `StopInterpretedMotion(arg3 /*adjusted motion id*/, +&var_2c /*local params*/)` (already ported). On success, mirrors the removal into +`raw_state` via `RawMotionState::RemoveMotion(arg2)` **using the ORIGINAL unmutated +motion id**, not the adjusted one — matches `DoMotion`'s equivalent +`RawMotionState::ApplyMotion(ebp, arg3)` also using the pre-adjustment id. + +### 5c. `CMotionInterp::set_hold_run` — `00528b70` @ line 306053 + +```c +00528b70 void __thiscall CMotionInterp::set_hold_run(class CMotionInterp* this, int32_t arg2, int32_t arg3) +00528b70 { +00528b79 int32_t eax; +00528b79 eax = arg2 == 0; +00528b85 int32_t edx; +00528b85 edx = this->raw_state.current_holdkey != HoldKey_Run; +00528b8a if (eax != edx) +00528b8a { +00528b94 int32_t eax_1; +00528b94 eax_1 = arg2 != 0; +00528b9b this->raw_state.current_holdkey = (eax_1 + 1); // 1 = HoldKey_None+1? see enum below +00528b9e CMotionInterp::apply_current_movement(this, arg3, 0); +00528b8a } +00528b70 } +``` + +Cleaned: `arg2` is a bool "hold run key down?" (nonzero = yes). `eax = (arg2 == 0)` +(i.e. "run key is up"), `edx = (current_holdkey != Run)`. The XOR-style +`if (eax != edx)` is "only act if this toggles a *change* in effective hold-run state" +— i.e. skip if we're already in the requested state (guards against redundant +re-application). If changed: `current_holdkey = (arg2 != 0) + 1`. Given +`HoldKey_None = 1`? — cross-check the enum: `HoldKey_Invalid=0`? See note below; +regardless, the arithmetic `(bool)+1` maps `false→1`, `true→2`, and `SetHoldKey` (§5d) +explicitly writes `HoldKey_None` and `HoldKey_Run` as the two values it ever assigns — +so `1 = HoldKey_None`, `2 = HoldKey_Run` is confirmed by direct comparison against +`SetHoldKey`'s own literal assignments. Then calls `apply_current_movement(arg3, 0)` to +push the change through immediately. + +**Callers** (outside CMotionInterp, both from the CommandInterpreter/input boundary — +noted per the request, out of R3's direct scope but shown for the seam): +- Line 405488 (`0058b303`): `CMotionInterp::set_hold_run(CPhysicsObj::get_minterp(*(this-0xc0)), eax_3)` — 2-arg call site (arg3 defaults via calling convention / omitted in decomp). +- Line 699120 (`006b33ca`): `CMotionInterp::set_hold_run(CPhysicsObj::get_minterp(this->player), eax_4, 1)` — explicit `arg3=1` (interrupt flag) from the player input/command layer. + +### 5d. `CMotionInterp::SetHoldKey` — `00528bb0` @ line 306072 + +```c +00528bb0 void __thiscall CMotionInterp::SetHoldKey(class CMotionInterp* this, enum HoldKey arg2, int32_t arg3) +00528bb0 { +00528bb0 enum HoldKey current_holdkey = this->raw_state.current_holdkey; +00528bb9 if (arg2 != current_holdkey) +00528bb9 { +00528bbc if (arg2 == HoldKey_None) +00528bbc { +00528bdf if (current_holdkey == HoldKey_Run) +00528bdf { +00528be8 this->raw_state.current_holdkey = HoldKey_None; +00528bef CMotionInterp::apply_current_movement(this, arg3, 0); +00528bdf } +00528bbc } +00528bbc else if ((arg2 == 2 && current_holdkey != HoldKey_Run)) // arg2 == HoldKey_Run (== 2) +00528bc4 { +00528bcd this->raw_state.current_holdkey = HoldKey_Run; +00528bd4 CMotionInterp::apply_current_movement(this, arg3, 0); +00528bb9 } +00528bb9 } +00528bb0 } +``` + +Cleaned: no-op if `arg2` already equals the current hold key. Setting to `HoldKey_None` +only takes effect (and re-applies movement) if we were previously `HoldKey_Run` — +setting `None` while already something else (e.g. `Invalid`) is silently ignored. +Setting to `HoldKey_Run` (`== 2`) takes effect whenever we weren't already `Run`. Both +effective branches call `apply_current_movement(arg3 /*interrupt flag passthrough*/, 0)`. +This is the function `DoMotion` calls (§2, step 4) when bit `0x800` of the incoming +`MovementParameters` bitfield requests a hold-key change, and it's what +`CMotionInterp::adjust_motion` (D6, already ported) reads back via +`this->raw_state.current_holdkey` to decide whether `apply_run_to_command` fires. + +--- + +## 6. MovementManager (beyond `unpack_movement`, S2a-covered) + +### 6a. `MovementManager::Create` — `00524050` @ line 300150 + +```c +00524050 class MovementManager* MovementManager::Create(class CPhysicsObj* arg1, class CWeenieObject* arg2) +00524050 { +00524054 void* result_1 = operator new(0x10); +0052405e void* result; +0052405e if (result_1 == 0) +0052407f result = nullptr; +0052405e else +0052405e { +00524060 *(uint32_t*)result_1 = 0; // motion_interpreter = null +00524066 *(uint32_t*)((char*)result_1 + 4) = 0; // moveto_manager = null +0052406d *(uint32_t*)((char*)result_1 + 8) = 0; // physics_obj = null (overwritten below) +00524074 *(uint32_t*)((char*)result_1 + 0xc) = 0; // weenie_obj = null (overwritten below) +0052407b result = result_1; +0052405e } +0052405e +00524081 class CMotionInterp* ecx = *(uint32_t*)result; +00524089 *(uint32_t*)((char*)result + 8) = arg1; // physics_obj = arg1 +0052408c if (ecx != 0) +0052408f CMotionInterp::SetPhysicsObject(ecx, arg1); +0052408f +00524094 class MoveToManager* ecx_1 = *(uint32_t*)((char*)result + 4); +00524099 if (ecx_1 != 0) +0052409c MoveToManager::SetPhysicsObject(ecx_1, arg1); +0052409c +005240a1 class CMotionInterp* ecx_2 = *(uint32_t*)result; +005240a9 *(uint32_t*)((char*)result + 0xc) = arg2; // weenie_obj = arg2 +005240ac if (ecx_2 != 0) +005240af CMotionInterp::SetWeenieObject(ecx_2, arg2); +005240af +005240b4 class MoveToManager* ecx_3 = *(uint32_t*)((char*)result + 4); +005240b9 if (ecx_3 != 0) +005240bc MoveToManager::SetWeenieObject(ecx_3, arg2); +005240bc +005240c5 return result; +00524050 } +``` + +Cleaned: allocate+zero a `MovementManager`, set `physics_obj = arg1` and (if a +`motion_interpreter` already existed — it never does on a fresh alloc, this branch is +dead on first construction but matches the setter-forwarding pattern used everywhere +else) forward to `CMotionInterp::SetPhysicsObject`/`MoveToManager::SetPhysicsObject`. +Same for `weenie_obj = arg2`. **`motion_interpreter` and `moveto_manager` are NOT +eagerly created here** — they're lazily created on first access (see `get_minterp`, +`PerformMovement`, `move_to_interpreted_state`, `EnterDefaultState`, +`InqRawMotionState`, `InqInterpretedMotionState` — every one of them has the identical +`if (motion_interpreter == 0) { motion_interpreter = CMotionInterp::Create(...); if +(physics_obj != 0) enter_default_state(...); }` guard). + +### 6b. `MovementManager::SetPhysicsObject` / `SetWeenieObject` + +Not directly decompiled as named top-level functions in this range (they're the +`CMotionInterp::SetPhysicsObject`/`SetWeenieObject` calls forwarded from `Create` +above, §3/§4e-4g show `CMotionInterp`'s own versions). `MovementManager` itself has no +separate `SetPhysicsObject`/`SetWeenieObject` — `Create` is the only place these fields +are set, matching the struct having no setters of its own (raw field assignment inline +in `Create`). + +### 6c. `MovementManager::PerformMovement` — `005240d0` @ line 300194 (the dispatch) + +```c +005240d0 uint32_t __thiscall MovementManager::PerformMovement(class MovementManager* this, class MovementStruct const* arg2) +005240d0 { +005240d9 CPhysicsObj::set_active(this->physics_obj, 1); +005240e4 void* eax_1 = (arg2->type - 1); +005240e8 if (eax_1 > 8) +00524159 return 0x47; +00524159 +005240f1 switch (eax_1) +005240f1 { +005240fb case nullptr: // type == RawCommand(1) -> eax_1 == 0 +005240fb case 1: // type == InterpretedCommand(2) -> eax_1 == 1 +005240fb case 2: // type == StopRawCommand(3) -> eax_1 == 2 +005240fb case 3: // type == StopInterpretedCommand(4) -> eax_1 == 3 +005240fb case 4: // type == StopCompletely(5) -> eax_1 == 4 +005240fb { +005240fb if (this->motion_interpreter == 0) +005240fb { +00524105 class CMotionInterp* eax_3 = CMotionInterp::Create(this->physics_obj, this->weenie_obj); +00524110 bool cond:0_1 = this->physics_obj == 0; +00524112 this->motion_interpreter = eax_3; +00524114 if (!(cond:0_1)) +00524118 CMotionInterp::enter_default_state(eax_3); +005240fb } +005240fb +00524127 return CMotionInterp::PerformMovement(this->motion_interpreter, arg2); +005240fb break; +005240fb } +0052412f case 5: // type == MoveToObject(6) -> eax_1 == 5 +0052412f case 6: // type == MoveToPosition(7) -> eax_1 == 6 +0052412f case 7: // type == TurnToObject(8) -> eax_1 == 7 +0052412f case 8: // type == TurnToHeading(9) -> eax_1 == 8 +0052412f { +0052412f if (this->moveto_manager == 0) +00524141 this->moveto_manager = MoveToManager::Create(this->physics_obj, this->weenie_obj); +00524141 +00524148 MoveToManager::PerformMovement(this->moveto_manager, arg2); +0052414f return 0; +0052412f break; +0052412f } +005240f1 } +005240d0 } +``` + +Cleaned: this is the **top-level `MovementManager` dispatch** — always marks the +physics object active first. `MovementTypes::Type` values `1..5` (Raw/Interpreted/ +StopRaw/StopInterpreted/StopCompletely) all route to **`CMotionInterp::PerformMovement`** +(lazily creating + default-initing the motion interpreter first if needed). Values +`6..9` (MoveToObject/MoveToPosition/TurnToObject/TurnToHeading) route to +**`MoveToManager::PerformMovement`** (lazily creating the MoveTo manager; R4 territory, +not expanded here — `MoveToManager::Create`/`PerformMovement` entry points only, per +the task's explicit deferral). Any `type` outside `[1,9]` → error `0x47`. + +### 6d. `MovementManager::MotionDone` (the relay) — `005242d0` @ line 300396 + +```c +005242d0 void __thiscall MovementManager::MotionDone(class MovementManager* this, uint32_t arg2, int32_t arg3) +005242d0 { +005242d0 class CMotionInterp* motion_interpreter = this->motion_interpreter; +005242d4 if (motion_interpreter != 0) +005242d4 { +005242da int32_t var_4_1 = arg3; +005242db int32_t edx; +005242db CMotionInterp::MotionDone(motion_interpreter, edx); // NOTE: passes uninitialized `edx`, NOT arg2/arg3 — see caveat below +005242d4 } +005242d0 } +``` + +Cleaned: pure relay, guarded on `motion_interpreter != null` (no lazy-create here — +if the interpreter doesn't exist yet, the done-callback is simply dropped, which is +correct: no interpreter means no motion was ever dispatched through it to complete). + +**Decompiler caveat**: the pseudo-C shows `var_4_1 = arg3` computed but discarded, and +`edx` (uninitialized) passed as `CMotionInterp::MotionDone`'s second argument instead +of `arg2`. Given `CMotionInterp::MotionDone`'s body never reads its `arg2` parameter at +all (§1c), this is very likely a register-allocation artifact of the decompiler (the +real assembly probably loads `arg2` into `edx` via the calling convention and the +pseudo-C's "uninitialized edx" annotation is simply failing to trace that it came from +the argument) rather than a genuine "pass garbage" bug — **do not port this as "pass +garbage"; port as `CMotionInterp::MotionDone(motionId)` taking whatever came in as +`arg2`,** consistent with `arg2` at the `CPhysicsObj::MotionDone` → `MovementManager::MotionDone` +call site always being the real `MotionNode.motion` id (see §6e below and the two call +sites at lines 290573/290657 in `MotionTableManager`). + +### 6e. `MovementManager::UseTime`, `HitGround`, `LeaveGround`, `HandleEnterWorld`, `HandleExitWorld`, `ReportExhaustion` + +```c +005242f0 void __fastcall MovementManager::UseTime(class MovementManager* this) +005242f0 { +005242f0 class MoveToManager* moveto_manager = this->moveto_manager; +005242f5 if (moveto_manager == 0) +005242fc return; +005242f7 /* tailcall */ +005242f7 return MoveToManager::UseTime(moveto_manager); +005242f0 } +``` +Pure relay to `MoveToManager::UseTime` only — **CMotionInterp has no `UseTime` of its +own** (motion interpreter's per-frame work happens through the physics/anim pipeline, +not a polled `UseTime`). + +```c +00524300 void __fastcall MovementManager::HitGround(class MovementManager* this) +00524300 { +00524303 class CMotionInterp* motion_interpreter = this->motion_interpreter; +00524307 if (motion_interpreter != 0) +00524309 CMotionInterp::HitGround(motion_interpreter); +00524309 +0052430e class MoveToManager* moveto_manager = this->moveto_manager; +00524314 if (moveto_manager == 0) +0052431b return; +0052431b /* tailcall */ +0052431b return MoveToManager::HitGround(moveto_manager); +00524300 } +``` +Fans out to BOTH `CMotionInterp::HitGround` (§4a) AND `MoveToManager::HitGround` (R4, +not expanded) — both subsystems need to know when the ground was hit. + +```c +00524320 void __fastcall MovementManager::LeaveGround(class MovementManager* this) +00524320 { +00524323 class CMotionInterp* motion_interpreter = this->motion_interpreter; +00524327 if (motion_interpreter != 0) +00524329 CMotionInterp::LeaveGround(motion_interpreter); +00524329 +0052432e class MoveToManager* moveto_manager = this->moveto_manager; +00524334 if (moveto_manager == 0) +0052433b return; +0052433b /* tailcall */ +0052433b return IDClass<_tagDataID,32,0>::~IDClass<_tagDataID,32,0>(moveto_manager); +00524320 } +``` +Same fan-out pattern as `HitGround` (the trailing tailcall target name +`IDClass<...>::~IDClass<...>` is a decompiler mis-symbolication — the actual target is +almost certainly `MoveToManager::LeaveGround`, matching the `HitGround` sibling's +shape exactly; flagging this as a **known decomp mislabel**, not a real destructor +call). + +```c +00524340 void __fastcall MovementManager::HandleEnterWorld(class MovementManager* this) +00524340 { +00524340 class CMotionInterp* motion_interpreter = this->motion_interpreter; +00524344 if (motion_interpreter == 0) +0052434b return; +0052434b /* tailcall */ +0052434b return IDClass<_tagDataID,32,0>::~IDClass<_tagDataID,32,0>(motion_interpreter); +00524340 } +``` +Same mis-symbolication pattern — tailcalls to what is almost certainly +`CMotionInterp::HandleEnterWorld` (there is no such symbol independently decompiled in +this dump; `CPartArray::HandleEnterWorld`/`MotionTableManager::HandleEnterWorld` exist +at the animation layer — §7 — but `CMotionInterp` itself does not appear to define a +distinct `HandleEnterWorld`, only `HandleExitWorld` was found named). **Only relays to +`motion_interpreter`, no `moveto_manager` fan-out** (contrast with `HitGround`/ +`LeaveGround` above) — enter-world doesn't need to touch pending MoveTo state. + +```c +00524350 void __fastcall MovementManager::HandleExitWorld(class MovementManager* this) +00524350 { +00524350 class CMotionInterp* motion_interpreter = this->motion_interpreter; +00524354 if (motion_interpreter == 0) +0052435b return; +0052435b /* tailcall */ +0052435b return CMotionInterp::HandleExitWorld(motion_interpreter); +00524350 } +``` +Clean relay to `CMotionInterp::HandleExitWorld` (§1d) — correctly symbolicated this +time. Also no `moveto_manager` fan-out. + +```c +00524360 void __fastcall MovementManager::ReportExhaustion(class MovementManager* this) +00524360 { +00524363 class CMotionInterp* motion_interpreter = this->motion_interpreter; +00524367 if (motion_interpreter != 0) +00524369 CMotionInterp::ReportExhaustion(motion_interpreter); +00524369 +0052436e class MoveToManager* moveto_manager = this->moveto_manager; +00524374 if (moveto_manager == 0) +0052437b return; +0052437b /* tailcall */ +0052437b return IDClass<_tagDataID,32,0>::~IDClass<_tagDataID,32,0>(moveto_manager); +00524360 } +``` +Fan-out to `CMotionInterp::ReportExhaustion` (§4c) AND (mis-symbolicated, real target +almost certainly `MoveToManager::ReportExhaustion`) the MoveTo manager. + +### 6f. `MovementManager::CancelMoveTo` — `005241b0` @ line 300277 + +```c +005241b0 void __fastcall MovementManager::CancelMoveTo(class MovementManager* this, uint32_t arg2) +005241b0 { +005241b0 class MoveToManager* moveto_manager = this->moveto_manager; +005241b5 if (moveto_manager == 0) +005241bc return; +005241bc /* tailcall */ +005241b7 return MoveToManager::CancelMoveTo(moveto_manager, arg2); +005241b0 } +``` +Pure relay, no lazy-create (canceling a MoveTo that was never started is a no-op). + +### 6g. `MovementManager::EnterDefaultState` — `005241c0` @ line 300292 + +```c +005241c0 void __fastcall MovementManager::EnterDefaultState(class MovementManager* this) +005241c0 { +005241c3 class CPhysicsObj* physics_obj = this->physics_obj; +005241c8 if (physics_obj == 0) +005241f5 return; +005241f5 +005241cd if (this->motion_interpreter == 0) +005241cd { +005241d4 class CMotionInterp* eax = CMotionInterp::Create(physics_obj, this->weenie_obj); +005241df bool cond:0_1 = this->physics_obj == 0; +005241e1 this->motion_interpreter = eax; +005241e1 +005241e3 if (!(cond:0_1)) +005241e7 CMotionInterp::enter_default_state(eax); +005241cd } +005241cd +005241ef /* tailcall */ +005241ef return CMotionInterp::enter_default_state(this->motion_interpreter); +005241c0 } +``` +No-op if no physics object. Lazy-creates the motion interpreter (with the same +"create-then-immediately-enter-default-state-if-physics-exists" double-init pattern — +note this means a brand-new interpreter gets `enter_default_state` called on it TWICE +in a row when reached via this path: once inside the lazy-create guard, once again as +the trailing unconditional tailcall — harmless since `enter_default_state` is fully +idempotent/reset-style, but worth flagging as a genuine retail double-call, not a +porting bug to "fix"). + +### 6h. `MovementManager::InqRawMotionState` / `InqInterpretedMotionState` / `IsMovingTo` / `motions_pending` / `get_minterp` + +```c +00524200 class RawMotionState const* __fastcall MovementManager::InqRawMotionState(class MovementManager* this) +00524200 { +00524206 if (this->motion_interpreter == 0) +00524206 { +00524210 class CMotionInterp* eax_2 = CMotionInterp::Create(this->physics_obj, this->weenie_obj); +0052421b bool cond:0_1 = this->physics_obj == 0; +0052421d this->motion_interpreter = eax_2; +0052421f if (!(cond:0_1)) +00524223 CMotionInterp::enter_default_state(eax_2); +00524206 } +00524206 return &this->motion_interpreter->raw_state; +00524200 } +``` +```c +00524230 class InterpretedMotionState const* __fastcall MovementManager::InqInterpretedMotionState(class MovementManager* this) +00524230 { /* identical lazy-create pattern */ return &this->motion_interpreter->interpreted_state; } +``` +```c +00524260 int32_t __fastcall MovementManager::IsMovingTo(class MovementManager const* this) +00524260 { +00524260 class MoveToManager* moveto_manager = this->moveto_manager; +00524265 if ((moveto_manager != 0 && MoveToManager::is_moving_to(moveto_manager) != 0)) +00524275 return 1; +00524275 return 0; +00524260 } +``` +```c +005242a0 class CMotionInterp* __fastcall MovementManager::get_minterp(class MovementManager* this) +005242a0 { /* identical lazy-create pattern */ return this->motion_interpreter; } +``` +All four follow the exact same "lazy-create-with-default-state-if-physics-exists, then +return a pointer/reference into the freshly-guaranteed-nonnull interpreter" shape as +`get_minterp`/`EnterDefaultState`/`PerformMovement`. + +### 6i. `MovementManager::Destroy` — `005243f0` @ line 300538 + +```c +005243f0 void __fastcall MovementManager::Destroy(class MovementManager* this) +005243f0 { +005243f4 class CMotionInterp* motion_interpreter = this->motion_interpreter; +005243f8 if (motion_interpreter != 0) +005243f8 { +005243fc CMotionInterp::~CMotionInterp(motion_interpreter); +00524402 operator delete(motion_interpreter); +005243f8 } +005243f8 +0052440a class MoveToManager* moveto_manager = this->moveto_manager; +0052440f this->motion_interpreter = 0; +00524415 if (moveto_manager != 0) +00524415 { +00524419 MoveToManager::~MoveToManager(moveto_manager); +0052441f operator delete(moveto_manager); +00524415 } +00524415 +00524428 this->moveto_manager = nullptr; +005243f0 } +``` +Explicit teardown of both sub-managers (dtor + `operator delete` each, since these are +raw owned pointers, not smart pointers, per retail C++ idiom). + +### 6j. `HandleUpdateTarget` — **not found** in this build's decomp under `MovementManager::` or `CMotionInterp::` namespaces (grepped both; no match). Likely does not exist as a named symbol in the Sept 2013 PDB, or the functionality lives elsewhere (possibly `MoveToManager`-internal, R4 territory) — noting the negative result rather than guessing. + +### 6k. `MakeMoveToManager` — entry points only (deferred to R4 per instructions) + +`MoveToManager::Create(class CPhysicsObj*, class CWeenieObject*)` is referenced at +line 300230 (`0052412f` case block) and `MovementManager::Create` does NOT eagerly +construct it. Full `MoveToManager` internals (its own `PerformMovement`, `UseTime`, +`HitGround`, `is_moving_to`, `CancelMoveTo`, `ReportExhaustion`, `LeaveGround`) are +**out of scope for R3** — only the entry-point call shape from `MovementManager` is +captured above (§6c, §6f, §6e, §6g). + +--- + +## 7. `CPhysicsObj::MotionDone` / `IsAnimating` / `IsMovingOrAnimating` / `InqRawMotionState` / `movement_is_autonomous` / animation-completion driver + +### 7a. `CPhysicsObj::movement_is_autonomous` — `0050eb30` @ line 276443 + +```c +0050eb30 int32_t __fastcall CPhysicsObj::movement_is_autonomous(class CPhysicsObj const* this) +0050eb30 { +0050eb36 return this->last_move_was_autonomous; +0050eb30 } +``` +Trivial field accessor — `last_move_was_autonomous` is a plain flag on `CPhysicsObj`, +set elsewhere (transition/physics-tick code, not in this range) whenever a locally- +simulated (as opposed to server-dictated dead-reckoning) motion update occurs. This is +the flag every `apply_raw_movement` vs `apply_interpreted_movement` gate reads +(§4c, and `apply_current_movement`/`SetWeenieObject`/`SetPhysicsObject` below). + +### 7b. `CPhysicsObj::MotionDone` — `0050fdb0` @ line 277856 (FULL BODY — pure tailcall relay) + +```c +0050fdb0 void __thiscall CPhysicsObj::MotionDone(class CPhysicsObj* this, uint32_t arg2, int32_t arg3) +0050fdb0 { +0050fdb0 class MovementManager* movement_manager = this->movement_manager; +0050fdb8 if (movement_manager == 0) +0050fdbf return; +0050fdbf +0050fdba /* tailcall */ +0050fdba return MovementManager::MotionDone(movement_manager, arg2, arg3); +0050fdb0 } +``` +No-op if the physics object has no `movement_manager` attached (e.g. static/inanimate +objects never get a `MovementManager`). Otherwise a clean 2-arg tailcall relay to +`MovementManager::MotionDone` (§6d) — `arg2` is the `MotionNode.motion` id that just +completed, `arg3` is whatever caller-specific flag the two call sites below pass. + +**Call sites** — this is the actual **animation-completion driver**, both inside +`MotionTableManager` (the per-`CPartArray` animation-node scheduler): + +```c +/* MotionTableManager::AnimationDone, 0051bce0 @ line 290558 — fired once per game-clock tick as the animation_counter advances past queued nodes */ +0051bd20 CPhysicsObj::MotionDone(this->physics_obj, *(int32_t*)((char*)head__2 + 8) /* pending_animations node's motion id */, arg2 /* AnimationDone's own arg2, e.g. "was interrupted" */); + +/* MotionTableManager::CheckForCompletedMotions, 0051be00 @ line 290645 — fired once per completed-immediately (0-duration) animation node */ +0051be2e CPhysicsObj::MotionDone(this->physics_obj, *(int32_t*)((char*)head__1 + 8), 1 /* literal 1 — always "not interrupted" for immediate completions */); +``` + +Both `MotionTableManager::AnimationDone` and `MotionTableManager::CheckForCompletedMotions` +walk `this->pending_animations` (a **separate** `DLListData`-based doubly-linked queue +scoped to `MotionTableManager`, distinct from `CMotionInterp::pending_motions` — they +are two parallel queues that both track in-flight motions, one at the +animation-table/CPartArray layer keyed by duration-in-ticks, one at the +CMotionInterp/movement layer keyed by "waiting for a done callback"), and for every +node whose duration has elapsed (`AnimationDone`: `node.duration <= animation_counter` +after incrementing; `CheckForCompletedMotions`: `node.duration == 0`, i.e. instantly +complete), they: +1. If the node's `motion & 0x10000000` (action-class flag — same bit `CMotionInterp:: + MotionDone` tests), call `MotionState::remove_action_head(&this->state)` — pop the + **animation-table's own parallel action-tracking state** (a THIRD action-queue, + local to `MotionTableManager::state`, distinct from `InterpretedMotionState::actions` + and `RawMotionState`'s equivalent — all three are kept in lockstep by the + `0x10000000` bit). +2. Fire `CPhysicsObj::MotionDone(physics_obj, node.motion, ...)` — which is the whole + chain that eventually reaches `CMotionInterp::MotionDone` (§1c) to pop + `pending_motions`. +3. Unlink+delete the `pending_animations` node itself (standard doubly-linked-list + removal, handling head/tail edge cases). + +**This is the actual driver behind "when does a queued motion get dequeued"** — it is +**NOT** a per-frame poll of `CMotionInterp` itself; it's the animation-tick system +(`MotionTableManager`, driven by `CPartArray::Update`→`CSequence::update`, and by +`CPartArray::CheckForCompletedMotions`/`AnimationDone` tailcalls from `CPhysicsObj`, +§7d/7e) that fires the completion callback back UP into `CMotionInterp`. + +### 7c. `CPhysicsObj::InqRawMotionState` — `0050fde0` @ line 277883 + +```c +0050fde0 class RawMotionState* __fastcall CPhysicsObj::InqRawMotionState(class CPhysicsObj const* this) +0050fde0 { +0050fde0 class MovementManager* movement_manager = this->movement_manager; +0050fde8 if (movement_manager != 0) +0050fded /* tailcall */ +0050fded return MovementManager::InqRawMotionState(movement_manager); +0050fded +0050fdec return 0; +0050fde0 } +``` +Null-safe relay (returns `null` if no `movement_manager`, vs. `MovementManager:: +InqRawMotionState`'s own lazy-create-guaranteed-nonnull behavior once a manager +exists). + +### 7d. `CPhysicsObj::CheckForCompletedMotions` — `0050fe30` @ line 277925 (pure relay, tailcall) + +```c +0050fe30 void __fastcall CPhysicsObj::CheckForCompletedMotions(class CPhysicsObj* this) +0050fe30 { +0050fe30 class CPartArray* part_array = this->part_array; +0050fe35 if (part_array == 0) +0050fe3c return; +0050fe3c /* tailcall */ +0050fe37 return CPartArray::CheckForCompletedMotions(part_array); +0050fe30 } +``` +This is what `CMotionInterp::PerformMovement` (§ from S2a/2) calls after EVERY +dispatched motion op (`DoMotion`/`DoInterpretedMotion`/`StopMotion`/ +`StopInterpretedMotion`/`StopCompletely`) — see line 306234/306241/306248/306255/ +306262 in §2's sibling `CMotionInterp::PerformMovement`. It synchronously drains any +**already-elapsed** (0-duration) animation-table nodes right after the motion is +applied, in case the newly-applied motion table entry immediately completes (e.g. a +0-frame transition). + +`CPartArray::CheckForCompletedMotions` (`00517d50`) is itself a null-safe tailcall +relay to `MotionTableManager::CheckForCompletedMotions` (§7b, second call site). + +### 7e. `CPhysicsObj::Hook_AnimDone` — `0050fda0` @ line 277845 (the animation-hook entry point, distinct from `MotionDone`) + +```c +0050fda0 void __fastcall CPhysicsObj::Hook_AnimDone(class CPhysicsObj* this) +0050fda0 { +0050fda0 class CPartArray* part_array = this->part_array; +0050fda5 if (part_array != 0) +0050fda9 CPartArray::AnimationDone(part_array, 1); +0050fda0 } +``` +This is the actual **per-frame/per-tick animation hook callback entry point** (called +from the CSequence/hook-dispatch machinery covered by the separate animation-sequencer +deep-dive) — it always passes literal `1` down into `CPartArray::AnimationDone` → +`MotionTableManager::AnimationDone(1)` (§1c/§7b's first call site — the `arg2` there +IS this `1`, meaning "not interrupted", contrasted with whatever value +`CPartArray::AnimationDone` gets called with from other paths). + +`CPartArray::AnimationDone` (`00517d30`) and `CPartArray::CheckForCompletedMotions` +(`00517d50`) are both null-safe single-line tailcall relays to the identically-named +`MotionTableManager::` methods (§1c/§7b bodies already shown in full). + +### 7f. `IsAnimating` / `IsMovingOrAnimating` — **not found** as literal symbol names +`CPhysicsObj::IsAnimating` and `CPhysicsObj::IsMovingOrAnimating` were grepped across +the full decomp file and **do not appear** as named functions in this PDB (neither +under `CPhysicsObj::` nor any other class). The closest matching concepts actually +present in this build: +- `CPartArray::HasAnims` (`00517d40`) — tailcalls `CSequence::has_anims(&sequence)`. +- `CMotionInterp::is_standing_still` (`00527fa0`, full body already shown inline + below since it's small and directly relevant) — tests `on_ground` bits AND + `interpreted_state.forward_command == 0x41000003 && sidestep_command == 0 && + turn_command == 0`. +- `CMotionInterp::motions_pending` (§1b). + +```c +00527fa0 int32_t __fastcall CMotionInterp::is_standing_still(class CMotionInterp const* this) +00527fa0 { +00527fa3 uint8_t transient_state = ((int8_t)this->physics_obj->transient_state); +00527fc6 if (((transient_state & 1) != 0 && ((transient_state & 2) != 0 && (this->interpreted_state.forward_command == 0x41000003 && (this->interpreted_state.sidestep_command == 0 && this->interpreted_state.turn_command == 0))))) +00527fcd return 1; +00527fcd return 0; +00527fa0 } +``` + +If acdream needs an `IsAnimating`/`IsMovingOrAnimating`-equivalent gate, the retail +composite would be built from `CPartArray::HasAnims()` (raw sequence has active +animation hooks) OR'd with `!CMotionInterp::is_standing_still()` OR'd with +`MovementManager::motions_pending()` — but this is a **synthesis**, not a verbatim +retail function, since no single named retail function computes exactly this union. +Flag before porting: confirm the actual acdream call site's intent against these three +primitives individually rather than inventing a combined helper that doesn't exist in +retail. + +--- + +## 8. `set_hold_run` at the CommandInterpreter boundary — call-site context only + +Both call sites (already shown in §5c) originate outside `CMotionInterp`/ +`MovementManager` — one at `0058b303` (function context not extracted; a +`CPhysicsObj::get_minterp`-based call with only 2 explicit args in the decomp, arg3 +presumably defaulted or passed via a register the decompiler didn't attribute), one at +`006b33ca` inside what's very likely `CommandInterpreter`-adjacent code (`this->player` +field access, `arg3=1` for the interrupt flag) at address range `006b33xx` — this is +**outside R3's CMotionInterp/MovementManager scope** per the task's own boundary +("input handling" / "CommandInterpreter boundary" is a note-only ask); recorded here +only as confirmation that `set_hold_run`'s two callers exist and roughly where. + +--- + +## 9. Summary table — function → address → line → status + +| Function | Address | Line | Extracted | +|---|---|---|---| +| `CMotionInterp::add_to_queue` | 0x00527b80 | 305032 | full body | +| `CMotionInterp::motions_pending` | 0x00527fe0 | 305322 | full body | +| `CMotionInterp::MotionDone` | 0x00527ec0 | 305238 | full body | +| `CMotionInterp::HandleExitWorld` | 0x00527f30 | 305275 | full body | +| `MovementManager::motions_pending` | 0x00524280 | 300365 | full body | +| `MovementManager::MotionDone` | 0x005242d0 | 300396 | full body + caveat | +| `CMotionInterp::DoMotion` | 0x00528d20 | 306159 | full body | +| `CMotionInterp::PerformMovement` | 0x00528e80 | 306221 | full body | +| `CMotionInterp::motion_allows_jump` | 0x005279e0 | 304908 | full body | +| `CMotionInterp::jump_charge_is_allowed` | 0x00527a50 | 304935 | full body | +| `CMotionInterp::get_jump_v_z` | 0x00527aa0 | 304953 | full body | +| `CMotionInterp::get_leave_ground_velocity` | 0x005280c0 | 305404 | full body | +| `CMotionInterp::charge_jump` | 0x005281c0 | 305448 | full body | +| `CMotionInterp::jump` | 0x00528780 | 305792 | full body | +| `CMotionInterp::jump_is_allowed` | 0x005282b0 | 305509 | full body (context) | +| `CPhysicsObj::on_ground` | 0x00527b20 | 304996 | full body | +| `CMotionInterp::HitGround` | 0x00528ac0 | 305996 | full body | +| `CMotionInterp::LeaveGround` | 0x00528b00 | 306022 | full body (addr differs from task's 0x00529710 guess) | +| `CMotionInterp::ReportExhaustion` | 0x005288d0 | 305861 | full body | +| `CMotionInterp::enter_default_state` | 0x00528c80 | 306124 | full body | +| `CMotionInterp::StopCompletely` | 0x00527e40 | 305208 | full body | +| `CMotionInterp::StopMotion` | 0x00528530 | 305674 | full body | +| `CMotionInterp::set_hold_run` | 0x00528b70 | 306053 | full body | +| `CMotionInterp::SetHoldKey` | 0x00528bb0 | 306072 | full body | +| `CMotionInterp::is_standing_still` | 0x00527fa0 | 305309 | full body | +| `CMotionInterp::apply_current_movement` | 0x00528870 | 305838 | full body | +| `CMotionInterp::SetWeenieObject` | 0x00528920 | 305884 | full body | +| `CMotionInterp::SetPhysicsObject` | 0x00528970 | 305911 | full body | +| `CMotionInterp::Create` | 0x00528c00 | 306097 | full body | +| `CMotionInterp::Destroy` | 0x00527b40 | 305009 | full body | +| `CMotionInterp::~CMotionInterp` | 0x00527ff0 | 305332 | full body | +| `MovementManager::Create` | 0x00524050 | 300150 | full body | +| `MovementManager::PerformMovement` | 0x005240d0 | 300194 | full body | +| `MovementManager::move_to_interpreted_state` | 0x00524170 | 300259 | full body | +| `MovementManager::CancelMoveTo` | 0x005241b0 | 300277 | full body | +| `MovementManager::EnterDefaultState` | 0x005241c0 | 300292 | full body | +| `MovementManager::InqRawMotionState` | 0x00524200 | 300316 | full body | +| `MovementManager::InqInterpretedMotionState` | 0x00524230 | 300334 | full body | +| `MovementManager::IsMovingTo` | 0x00524260 | 300352 | full body | +| `MovementManager::get_minterp` | 0x005242a0 | 300378 | full body | +| `MovementManager::UseTime` | 0x005242f0 | 300411 | full body | +| `MovementManager::HitGround` | 0x00524300 | 300425 | full body | +| `MovementManager::LeaveGround` | 0x00524320 | 300444 | full body (tailcall target mis-symbolicated) | +| `MovementManager::HandleEnterWorld` | 0x00524340 | 300463 | full body (tailcall target mis-symbolicated) | +| `MovementManager::HandleExitWorld` | 0x00524350 | 300477 | full body | +| `MovementManager::ReportExhaustion` | 0x00524360 | 300491 | full body (tailcall target mis-symbolicated) | +| `MovementManager::Destroy` | 0x005243f0 | 300538 | full body | +| `MovementParameters::MovementParameters` (ctor) | 0x00524380 | 300510 | full body | +| `CPhysicsObj::movement_is_autonomous` | 0x0050eb30 | 276443 | full body | +| `CPhysicsObj::MotionDone` | 0x0050fdb0 | 277856 | full body | +| `CPhysicsObj::report_exhaustion` | 0x0050fdd0 | 277870 | full body | +| `CPhysicsObj::InqRawMotionState` | 0x0050fde0 | 277883 | full body | +| `CPhysicsObj::InqInterpretedMotionState` | 0x0050fe00 | 277897 | full body | +| `CPhysicsObj::RemoveLinkAnimations` | 0x0050fe20 | 277911 | full body | +| `CPhysicsObj::CheckForCompletedMotions` | 0x0050fe30 | 277925 | full body | +| `CPhysicsObj::Hook_AnimDone` | 0x0050fda0 | 277845 | full body | +| `MotionTableManager::AnimationDone` | 0x0051bce0 | 290558 | full body | +| `MotionTableManager::CheckForCompletedMotions` | 0x0051be00 | 290645 | full body (partial read, list-unlink tail identical pattern) | +| `MotionTableManager::HandleExitWorld` | 0x0051bda0 | 290625 | full body | +| `MotionTableManager::HandleEnterWorld` | 0x0051bdd0 | 290634 | full body | +| `CPartArray::AnimationDone` | 0x00517d30 | 285806 | full body | +| `CPartArray::CheckForCompletedMotions` | 0x00517d50 | 285829 | full body | +| `CPartArray::HandleMovement` | 0x00517d60 | 285843 | full body | +| `CPartArray::HandleEnterWorld` | 0x00517d70 | 285857 | full body | +| `CPartArray::HandleExitWorld` | 0x00517d90 | 285868 | full body | +| `CPartArray::HasAnims` | 0x00517d40 | 285820 | full body | +| `CBaseFilter::GetPinVersion` | 0x00527b10 | 304988 | full body (context for `unpack_movement`'s pin-version check) | +| `CPhysicsObj::IsAnimating` | — | — | **not present in this PDB** (see §7f) | +| `CPhysicsObj::IsMovingOrAnimating` | — | — | **not present in this PDB** (see §7f) | +| `MovementManager::HandleUpdateTarget` | — | — | **not present in this PDB** (see §6j) | +| `MakeMoveToManager` | — | — | deferred to R4; only `MoveToManager::Create` entry-point call shape captured (§6c/§6k) | + +--- + +## 10. Verbatim constants inventory (motion ids / error codes / bit flags seen in this scope) + +- `0x41000003` — canonical "forward = none" motion id (neutral). Used as the reset + target everywhere: `StopCompletely`, `enter_default_state`'s sentinel node, + `StopInterpretedMotion`'s post-stop requeue, `apply_interpreted_movement`'s fallback. +- `0x10000000` — the **action-class bit** on a motion id — tested identically in + `CMotionInterp::MotionDone`, `CMotionInterp::HandleExitWorld`, + `DoMotion`'s action-depth gate, `MotionTableManager::AnimationDone`/ + `CheckForCompletedMotions`. +- `0x8000003d` — `MotionStance` id for "non-combat / unrestricted" (the ONLY stance + from which jump-charge motions and bit-`0x2000000` motions are allowed through + `DoMotion`'s gate). +- `0x2000000` — bit tested against the raw motion id in `DoMotion`'s combat-stance + gate (rejected with `0x42` outside non-combat stance). +- `0x41000012` / `0x41000013` / `0x41000014` — the three jump-charge motion ids, + rejected in combat stance with distinct codes `0x3f`/`0x40`/`0x41` respectively. +- Error codes seen: `8` (no physics_obj), `0x24` (not grounded, physics `state` + bit `0x400` set — `jump_is_allowed`), `0x3f`/`0x40`/`0x41`/`0x42` (combat-stance + jump-charge rejections, `DoMotion`), `0x45` (action queue depth ≥ 6, `DoMotion`), + `0x47` (bad `MovementStruct.type`, OR `IsFullyConstrained`, OR + `JumpStaminaCost` failure), `0x48` ("jump currently allowed" success sentinel from + `motion_allows_jump`/`jump_charge_is_allowed`/`charge_jump`'s early-out), `0x49` + (`CanJump` virtual rejected the charge — stamina/burden gate). +- `0.000199999995f` (~0.0002) — the epsilon used throughout for "is this velocity + component effectively zero" (`get_jump_v_z`, `get_leave_ground_velocity` x3). +- `HoldKey` encoding confirmed by direct assignment: `HoldKey_None = 1`, + `HoldKey_Run = 2` (derived from `set_hold_run`'s `(bool)+1` arithmetic cross-checked + against `SetHoldKey`'s literal `HoldKey_None`/`HoldKey_Run` assignments). +- `MovementTypes::Type` enum (verbatim, acclient.h:2856): `Invalid=0, RawCommand=1, + InterpretedCommand=2, StopRawCommand=3, StopInterpretedCommand=4, StopCompletely=5, + MoveToObject=6, MoveToPosition=7, TurnToObject=8, TurnToHeading=9`. +- `physics_obj->state` bit `0x400` (tested as `(int16_t)state)[1] & 4`, i.e. byte 1 + bit 2 of the 16-bit state word = bit 10 overall = `0x400`) — gravity/BSP-active flag, + gates `HitGround`/`LeaveGround`'s body AND `jump_is_allowed`'s ground-check branch. +- `transient_state` bits `0x1` and `0x2` (both required) = "on ground" — `on_ground`, + `charge_jump`, `contact_allows_move`, `is_standing_still` all inline this exact test. diff --git a/docs/research/2026-07-02-r3-motioninterp/r3-port-plan.md b/docs/research/2026-07-02-r3-motioninterp/r3-port-plan.md new file mode 100644 index 00000000..64f86393 --- /dev/null +++ b/docs/research/2026-07-02-r3-motioninterp/r3-port-plan.md @@ -0,0 +1,377 @@ +# 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).