acdream/docs/research/2026-07-02-inbound-motion-maps/map-acdream-inbound.md
Erik fb3ee0544a docs(L.2g): inbound motion deviation map + campaign registration
/investigate deliverable for the inbound (remote-entity) animation+position
retail-parity effort. 10 deviations (DEV-1..10) mapped and adversarially
verified against the named retail decomp + ACE port + current code (9
confirmed, 1 refuted-and-corrected).

Headline: the #39-era UP-pace->cycle inference layer's premise ('wire goes
silent on Shift toggle') is refuted at both oracles — retail sends a fresh
MoveToState on HoldRun toggle while moving (0x006b37a8) and ACE rebroadcasts
every MoveToState unconditionally (GameActionMoveToState.cs:36); retail has
NO pace->animation adaptation anywhere (position error is absorbed solely by
the InterpolationManager chase, already ported verbatim in L.3).

Registers sub-lane L.2g in the roadmap: port the CMotionInterp inbound funnel
verbatim for all remote entity classes, slices S0-S6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:20:46 +02:00

40 KiB
Raw Blame History

acdream CURRENT inbound remote-entity motion/animation pipeline — as-is map

Repo: C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad Scope: REPORT-ONLY. Describes what the code does TODAY, not what it should do.


0. File inventory (all line numbers verified against current worktree)

  • src/AcDream.Core.Net/Messages/UpdateMotion.cs (333 lines) — inbound 0xF74C parser.
  • src/AcDream.Core.Net/Messages/UpdatePosition.cs (177 lines) — inbound 0xF748 parser.
  • src/AcDream.Core.Net/Messages/MoveToState.cs (108 lines) — OUTBOUND ONLY (0xF61C, client→server). Not part of the inbound path; included by the task's starting-point list but has no inbound role.
  • src/AcDream.App/Rendering/GameWindow.cs (13,759 lines) — OnLiveMotionUpdated (42304967), OnLivePositionUpdated (5334~5773), ApplyServerControlledVelocityCycle/ApplyPlayerLocomotionRefinement (51125333), TickAnimations (9673~10267+), RemoteMotion class (401~592).
  • src/AcDream.Core/Physics/AnimationSequencer.cs (1583 lines) — SetCycle (401788), Advance (862957), helpers.
  • src/AcDream.Core/Physics/MotionInterpreter.cs (1255 lines) — DoInterpretedMotion/StopInterpretedMotion/StopCompletely/get_state_velocity/adjust_motion/apply_raw_movement/apply_current_movement/GetMaxSpeed.
  • src/AcDream.Core/Physics/RemoteMoveToDriver.cs (340 lines) — per-tick steering for MoveToObject/MoveToPosition (movementType 6/7) remotes.
  • src/AcDream.Core/Physics/ServerControlledLocomotion.cs (87 lines) — velocity→cycle classifier for non-player remotes (PlanFromVelocity) and MoveTo-start seed (PlanMoveToStart).
  • src/AcDream.Core/Physics/InterpolationManager.cs (389 lines) — retail CPhysicsObj position-waypoint queue port (FIFO cap 20, catch-up, stall detection, blip-to-tail).
  • src/AcDream.Core/Physics/PositionManager.cs (108 lines) — per-frame REPLACE combiner (queue-catchup vs anim-root-motion), used only for grounded, non-airborne PLAYER remotes.

No pending_motions / MotionDone lifecycle exists anywhere in the codebase. grep -rn "pending_motion|MotionDone|PendingMotion" src/ returns zero matches. Confirmed as a genuine gap (matches the animation-sequencer-deep-dive research doc's "missing pending_motions HIGH" finding).


Q1 — INBOUND ENTRY: wire → motion interpreter, for a REMOTE object

  1. Wire parse. AcDream.Core.Net.Messages.UpdateMotion.TryParse (UpdateMotion.cs:70-253) decodes opcode 0xF74C into Parsed(uint Guid, CreateObject.ServerMotionState MotionState). Extracts: Guid, currentStyle (Stance), and — when movementType == 0 (InterpretedMotionState) — optional ForwardCommand/ForwardSpeed/SideStepCommand/SideStepSpeed/TurnCommand/TurnSpeed plus a Commands list (actions/emotes), gated by a 7-bit flags dword (UpdateMotion.cs:145-226). When movementType is 6 or 7 (MoveToObject/MoveToPosition), it instead parses a MoveToPathData payload via TryParseMoveToPayload (UpdateMotion.cs:255-331): target guid (type 6 only), Origin cell+xyz, MovementParameters dword, distance/min/fail distances, speed, walkRunThreshold, desiredHeading, runRate.

    • Fields parsed then discarded / not used for pose: instanceSequence (u16, UpdateMotion.cs:85-87, "tracked but not used for pose" — actually not even stored, just skipped), movementSequence+serverControlSequence (u16+u16 inside the 6-byte MovementData header, UpdateMotion.cs:89-106, skipped wholesale via pos += 6), isAutonomous (u8, same 6-byte skip — never separately read), _motionFlags (u8, read into a local but only used for the diagnostic dump at UpdateMotion.cs:111,115-122, otherwise discarded), distanceToObject/failDistance/walkRunThreshold/desiredHeading inside TryParseMoveToPayload (parsed at UpdateMotion.cs:305-306,309,313,315 into locals, never placed on MoveToPathData — only distanceToObject(as DistanceToObject), minDistance, failDistance(discarded — not passed to the record), walkRunThreshold and desiredHeading ARE passed to MoveToPathData ctor at UpdateMotion.cs:319-329 but MoveToPathData doesn't expose FailDistance in the earlier local list — check record fields separately if exact retention matters).
    • Similarly, UpdatePosition.TryParse (UpdatePosition.cs:77-175) decodes 0xF748 into guid, ServerPosition (cellId+xyz+quat), optional Velocity, optional PlacementId, IsGrounded flag, and three of four trailing u16 sequence numbers (instSeq, teleSeq, forceSeq — the second one, "positionSequence", is explicitly skipped: UpdatePosition.cs:156 comment "not tracked by movement"). None of instSeq/teleSeq/forceSeq are consulted anywhere downstream for freshness/ordering (comment at UpdatePosition.cs:29-31: "We don't currently check these for freshness but we must consume them to walk the buffer correctly").
  2. Session dispatch → GameWindow event. (Not directly inspected this pass, but referenced via _liveSession.MotionUpdated += OnLiveMotionUpdated; at GameWindow.cs:2633.) The parsed UpdateMotion.Parsed becomes an AcDream.Core.Net.WorldSession.EntityMotionUpdate and fires the MotionUpdated event.

  3. GameWindow.OnLiveMotionUpdated (GameWindow.cs:4230) is the sole consumer.

    • Early-outs: _dats is null (4232), entity not found by update.Guid in _entitiesByServerGuid (4233), or no AnimatedEntity for it in _animatedEntities (4234).
    • Stamps rmStateForUm.LastUMTime on the _remoteDeadReckon[update.Guid] entry if present (4243-4247) — used later to gate the velocity-refinement grace window (Q7 / UmGraceSeconds).
    • Reads stance = update.MotionState.Stance and command = update.MotionState.ForwardCommand (nullable) (4259-4260).
    • Sequencer path (the only path exercised in practice — ae.Sequencer is not null at 4327) reconstructs a full 32-bit fullMotion command from the wire's 16-bit ForwardCommand via MotionCommandResolver.ReconstructFullCommand (4372-4378), OR seeds a MoveTo-derived motion via ServerControlledLocomotion.PlanMoveToStart when IsServerControlledMoveTo and no forward command (4356-4363), OR falls back to 0x41000003 (Ready) when the command is null/0 and not a MoveTo (4364-4367 — "retail stop signal").
    • Branches on update.Guid == _playerServerGuid: the LOCAL player's own echoed UM is NOT applied to the sequencer (comment 4401-4416: "UpdatePlayerAnimation is the authoritative driver ... skip the wire-echo SetCycle"). Everything from here on (Q2-Q7) describes the REMOTE (else branch at 4493) path.
    • For a genuine remote: bulk-copies fullMotion/speedMod into remoteMot.Motion.InterpretedState.ForwardCommand/ForwardSpeed UNCONDITIONALLY (4572-4598) — this is the direct feed into MotionInterpreter.get_state_velocity() (Q5). Then classifies via AnimationCommandRouter.Classify(fullMotion) into forwardIsOverlay (Action/Modifier/ChatEmote — routed via RouteFullCommand, an overlay call, 4626-4636) vs SubState (the normal walk/run/turn/ready path, 4637 else block — this is Q2/Q3/Q4's territory).

Q2 — TRANSITION: walk↔run while already moving (no stop)

Frame-by-frame, for the SubState (non-overlay) branch at GameWindow.cs:4637-4859:

  1. Cycle selection (4648-4683): animCycle = fullMotion, animSpeed = speedMod by default. If the forward command is NOT run/walk/walkback (fwdIsRunWalk false), fall back to sidestep or turn cycle. For a plain walk↔run toggle, fullMotion IS run/walk, so this stays the forward cycle — no fallback triggered.
  2. Airborne guard (4696): if remoteIsAirborne, the cycle swap is SKIPPED entirely (Falling cycle preserved) — not relevant to a grounded walk↔run toggle.
  3. Cycle-existence fallback chain (4728-4757): if the MotionTable lacks (fullStyle, cycleToPlay), falls back RunForward→WalkForward→Ready→(no-op, leave sequencer alone). Only matters if the creature's table is missing the target cycle; for the player Humanoid table both Walk and Run exist.
  4. ae.Sequencer.SetCycle(fullStyle, cycleToPlay, animSpeed) is called (4769) — this is the entry into AnimationSequencer.SetCycle (AnimationSequencer.cs:401).

Inside SetCycle (AnimationSequencer.cs:401-788) — what actually happens on Walk→Run or Run→Walk:

  • adjust_motion remap (407-423): TurnLeft/SideStepLeft/WalkBackward get mirrored to their positive counterpart with negated speed. RunForward(0x07)/WalkForward(0x05) pass through unchanged (adjustedMotion = motion).
  • Fast-path check (440-468): fires only if CurrentStyle == style && CurrentMotion == motion (same exact motion command) AND sign of speedMod matches. On an actual Walk→Run toggle, CurrentMotion (0x45000005 WalkForward) != motion (0x44000007 RunForward), so the fast path does NOT fire — this is a FULL REBUILD, not a framerate-only rescale. (The fast path only fires when the SAME command re-arrives with a different speed, e.g. a run-rate echo while already running.)
  • Full-rebuild path (470-788):
    • GetLink(style, CurrentMotion=WalkForward, CurrentSpeedMod, adjustedMotion=RunForward, adjustedSpeed) looks up a transition-link MotionData from the MotionTable's Links dict (470-480, method at 1159-1203). Both speeds positive here, so it uses the forward-direction lookup: Links[(style<<16)|WalkForward][RunForward].
    • ClearCyclicTail() (511) — removes the OLD cycle's looping node(s) from the queue; non-cyclic (link) frames not yet played are left alone.
    • ClearPhysics() (529) — zeroes CurrentVelocity/CurrentOmega before rebuild.
    • Enqueues the link's MotionData (non-looping) THEN the new cycle's MotionData (looping) via EnqueueMotionData (547-554).
    • Direct cyclic→cyclic transition special-case (585-635, "Fix B" 2026-05-06): if BOTH the previous motion (WalkForward) AND the new motion (RunForward) are recognized locomotion low-bytes (IsLocomotionCycleLowByte, checks against 0x05/0x06/0x07/0x0F/0x10 — AnimationSequencer.cs:1509-1513), the code DROPS the just-enqueued transition link from the queue entirely (629-632) and forces _currNode = _firstCyclic directly onto the new RunForward cycle (633-634), skipping the link animation and its frame-position start entirely. This is explicitly a divergence from the general link-transition mechanism, justified by a live cdb trace (609-618 comment) showing retail's add_to_queue sequence for a Walk→Run direct transition never fires truncate_animation_list and just appends the new cycle directly — i.e., acdream's current behavior for walk↔run is: NO visible link/blend animation, instant hard-cut cycle swap to the new cycle's start frame (_framePosition = _firstCyclic.Value.GetStartFramePosition(), 634).
    • If NOT both locomotion (e.g. Ready→WalkForward), the link IS played (_currNode = firstNew at 636-640) — that's the smooth-transition path used elsewhere, just not for walk↔run.
    • CurrentStyle/CurrentMotion/CurrentSpeedMod updated (682-684).
    • Velocity synthesis (686-754): because retail's Humanoid MotionData ships Flags=0x00 (no HasVelocity) for locomotion cycles, CurrentVelocity is unconditionally re-synthesized here from hardcoded constants (WalkAnimSpeed=3.12f, RunAnimSpeed=4.0f, SidestepAnimSpeed=1.25f — 793-795) times adjustedSpeed, based on the NEW motion's low byte. This happens regardless of whether the link or the cycle is what's currently playing — i.e. CurrentVelocity snaps to the new cycle's steady-state speed the instant SetCycle returns, even in the general (non-locomotion-direct) case where a link animation is still visually playing.
    • Omega synthesis (756-787) similarly, only for turn commands (not relevant to walk/run).

Frame-by-frame summary for Walk→Run toggle while moving:

  • Frame N (UM arrives): OnLiveMotionUpdated fires → SetCycle(style, RunForward, speedMod) called synchronously within the same event handler call (not queued/deferred) → full rebuild → cyclic→cyclic special case detected → link dropped, _currNode snapped directly to Run cycle's start frame, CurrentVelocity synthesized to RunAnimSpeed × speedMod — ALL of this happens on the SAME UM-processing call, before the next Advance()/tick.
  • Frame N (same tick, later in loop): TickAnimations (GameWindow.cs:9673) runs later that frame (or next frame depending on event/tick ordering) and calls Advance(dt) on the sequencer, which now plays from the Run cycle's first frame — no partial-frame blend from the old Walk pose.
  • Is there blending? NO cross-fade/blend for the walk↔run case specifically — only the (skipped) link animation would have provided a transitional pose, and it's explicitly dropped for this case.
  • Is the new motion appended/queued, or does it replace? It REPLACES: ClearCyclicTail() always removes the old cycle before the new one is enqueued (511), and for the walk↔run case the link itself is also stripped from the queue (629-632) so only the new cycle remains.
  • Speed: CurrentVelocity/animation framerate both change immediately and atomically inside SetCycle — no interpolation of speed either (the fast-path's MultiplyCyclicFramerate smooth-rescale only applies when CurrentMotion is unchanged, i.e. NOT for an actual Walk↔Run motion-command swap, only for a same-command speed update).

Q3 — PENDING/DONE (pending_motions + MotionDone lifecycle)

NONE. There is no pending_motions queue and no MotionDone callback/event anywhere in the codebase (confirmed via grep -rn "pending_motion|MotionDone|PendingMotion" src/ — zero hits).

What DOES exist that's adjacent:

  • AnimationSequencer._queue (a LinkedList<AnimNode>, AnimationSequencer.cs:297) holds link-then-cycle nodes, but it is not a "pending motion command" queue in the retail sense — it's purely an animation-frame playback queue (link frames + one looping tail node), rebuilt wholesale on every SetCycle call. There's no notion of a QUEUED but not-yet-active MOTION COMMAND (e.g. "play attack after current swing finishes") — SetCycle always either fast-paths (same command) or fully rebuilds (different command) synchronously the instant the wire event arrives.
  • AnimationSequencer.ConsumePendingHooks() (964-972) exists and drains _pendingHooks, a List<AnimationHook> accumulated during Advance() when integer frame boundaries are crossed (Advance, 862-957, calls ExecuteHooks at 913/939). This is a HOOK dispatch mechanism (audio/VFX/damage triggers baked into animation frames), NOT a motion-command completion signal.
  • AnimationDoneSentinel (AnimationSequencer.cs:1128-1129, a static AnimationHookDir.Both hook) is added to _pendingHooks when a non-cyclic (link) node drains naturally and the sequence is about to wrap (948-951, inside Advance). This is the closest thing to a "link finished" signal, but it's a generic hook fired into the SAME _pendingHooks list as animation-authored hooks (footsteps, damage triggers) — there's no separate consumer that reacts specifically to "this MOTION COMMAND completed" the way retail's pending_motions/MotionDone state machine would (e.g. to know when to pop the next queued attack, or to notify the wire layer that a swing animation finished so the next command can be sent).
  • Consequence for callers: nobody appends anything to a "next motion" queue and nobody pops one on completion. Every UM (or per-tick refinement call) calls SetCycle/DoInterpretedMotion directly and unconditionally; the ONLY "which motion wins" arbitration is (a) the SubState-vs-Overlay classification in OnLiveMotionUpdated (4626 vs 4637) and (b) SetCycle's own state (CurrentMotion/CurrentStyle comparison) — there's no notion of "motion command A is still pending completion, defer motion command B."

Q4 — CYCLE SWAP mechanics: does frame index carry over?

Governed entirely by AnimationSequencer.SetCycle (see Q2 for the walk→run specific path) and AdvanceToNextAnimation (AnimationSequencer.cs:1344-1364):

  • General case (non-locomotion-direct, e.g. Ready→WalkForward): _currNode is set to the FIRST NEWLY-ENQUEUED node (firstNew, 583, 636-640) — i.e. the link animation, if one exists, plays from ITS GetStartFramePosition() (583+639: _framePosition = _currNode.Value.GetStartFramePosition()), which for positive framerate is StartFrame (frame 0 of the link) — frame index does NOT carry over from the old cycle; it restarts at the link's (or cycle's, if no link) start frame. When the link naturally exhausts during subsequent Advance() calls, AdvanceToNextAnimation() (1344) walks to _currNode.Next, or if null, wraps to _firstCyclic — again restarting _framePosition at GetStartFramePosition() of that new node (1362-1363).
  • Locomotion-direct case (Walk↔Run and similar, per the "Fix B" special-case, AnimationSequencer.cs:619-635): _currNode is forced DIRECTLY onto _firstCyclic (the new cycle's looping node), with _framePosition = _firstCyclic.Value.GetStartFramePosition() (634) — i.e. frame index is reset to 0 (or EndFrame for negative-speed cycles) of the NEW cycle, NOT carried over from the OLD cycle's current frame position. There is no "keep the current phase, just swap animation data" — every SetCycle transition, link or no link, hard-resets _framePosition to a start boundary of whichever node becomes current.
  • No "link animation between the two loops" mechanism for locomotion-direct swaps — the whole point of the "Fix B" branch is to SKIP the link (i.e. skip the transitional pose entirely) for cyclic→cyclic transitions, per the cdb-trace-derived retail-matching behavior documented in the comment (609-618).
  • Net: cycle swap between two looping cycles is an instant hard-cut with no phase continuity and (for locomotion-direct transitions) no transitional/link animation. This differs from a "true" retail per-limb blend if such a thing exists in the more complete engine — acdream's sequencer is a single active AnimNode playback engine, not a blend-tree.

Q5 — POSITION DRIVE between inbound position packets

Two structurally DIFFERENT paths exist, selected in TickAnimations (GameWindow.cs:9717-9722) by:

if (ae.Sequencer is not null && serverGuid != 0 && serverGuid != _playerServerGuid
    && _remoteDeadReckon.TryGetValue(serverGuid, out var rm))
{
    if (IsPlayerGuid(serverGuid) && !rm.Airborne)   // ← Path A: grounded PLAYER remotes
    else                                              // ← Path B: NPCs + airborne player remotes (legacy)
}

Path A — grounded player remotes (GameWindow.cs:9722-9920)

Uses PositionManager.ComputeOffset (PositionManager.cs:51-107), which is explicitly a REPLACE, not additive combiner:

  1. InterpolationManager.AdjustOffset(dt, currentBodyPosition, maxSpeed) (called at PositionManager.cs:84) is tried FIRST.
    • If the queue has waypoints AND the body is farther than DesiredDistance (0.05 m, InterpolationManager.cs:79) from the head waypoint, it returns a non-zero "catch-up" delta: direction × min(catchUpSpeed × dt, dist), where catchUpSpeed = maxSpeedFromMinterp × MaxInterpolatedVelocityMod (2.0) or a MaxInterpolatedVelocity (7.5 m/s) fallback (InterpolationManager.cs:262-362). This REPLACES animation root motion entirely for that frame (PositionManager.cs:84-86: if (correction.LengthSquared() > 0f) return correction;).
    • If the queue is empty OR the head has been reached (dist ≤ 0.05m, which pops the head via NodeCompleted), AdjustOffset returns Vector3.Zero, and PositionManager.ComputeOffset falls through to animation root motion: seqVel × dt, rotated by body orientation, optionally slope-projected (PositionManager.cs:88-106).
  2. seqVel is ae.Sequencer.CurrentVelocity (body-local, from the sequencer synthesis in Q2/Q4) — this is the ANIMATION-DERIVED velocity, NOT MotionInterpreter.get_state_velocity().
  3. omegaToApply is rm.ObservedOmega (seeded formulaically from wire TurnCommand+TurnSpeed in OnLiveMotionUpdated, GameWindow.cs:4841-4847) preferred over seqOmega (GameWindow.cs:9842-9845).
  4. rm.Body.calc_acceleration() + rm.Body.UpdatePhysicsInternal(dt) still run (9878, 9881) for gravity/vertical integration, but ResolveWithTransition (collision sweep) is INTENTIONALLY SKIPPED for this path (comment 9883-9890: "the server has already collision-resolved the broadcast position").
  5. Dominance: for grounded player remotes, the InterpolationManager queue DOMINATES whenever it's non-empty and the body hasn't caught up; the sequencer's CurrentVelocity (animation root motion) only drives position when the queue is idle/caught-up.

Path B — NPCs and airborne player remotes (legacy path, GameWindow.cs:9933-10267+)

This is the ORIGINAL / "unchanged until Task 8 cleanup" path (comment 9923):

  1. rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false) (10074, the general branch) — calls MotionInterpreter.get_state_velocity() (MotionInterpreter.cs:639-688), which reads InterpretedState.ForwardCommand/ForwardSpeed (bulk-copied from the wire in OnLiveMotionUpdated, Q1/Q2) and returns WalkAnimSpeed/RunAnimSpeed × ForwardSpeed UNLESS the sequencer's CurrentVelocity.Y is non-zero (GetCycleVelocity accessor, wired — see below), in which case it prefers that (MotionInterpreter.cs:653-668, "Option B — MotionData-sourced forward velocity"). Result is written directly to PhysicsBody.set_local_velocity via apply_current_movement (MotionInterpreter.cs:905-909, gated on PhysicsObj.OnWalkable).
  2. Alternative sub-branches at this same call site (GameWindow.cs:9963-10076) select DIFFERENT velocity sources depending on state:
    • If rm.HasServerVelocity and NOT a player guid (9968): uses rm.ServerVelocity (synthesized from consecutive UP position deltas, or wire Velocity field when present) directly as rm.Body.Velocity — BYPASSING apply_current_movement/get_state_velocity entirely — UNLESS the velocity is stale (velocityAge > ServerControlledVelocityStaleSeconds = 0.60s, 926), in which case it's zeroed and ApplyServerControlledVelocityCycle is invoked to reset the cycle to Ready.
    • If rm.ServerMoveToActive && rm.HasMoveToDestination (9987): uses RemoteMoveToDriver.Drive to steer orientation, then apply_current_movement for velocity magnitude (10042), clamped via RemoteMoveToDriver.ClampApproachVelocity (10052-10059) to avoid overshoot.
    • Else (general fallback, 10074): apply_current_movement as described in step 1.
  3. Manual per-tick rotation integration (GameWindow.cs:10097-10106) using rm.ObservedOmega.
  4. rm.Body.UpdatePhysicsInternal(dt) (10128) — Euler integration.
  5. Collision sweep IS run for Path B (_physicsEngine.ResolveWithTransition, 10156-10181) — unlike Path A, NPCs/airborne players DO get a client-side sweep between server updates (with a fixed sphere radius/height, step-up/down heights).

Which dominates?

  • Path A (player remotes, grounded): InterpolationManager queue dominates when active; sequencer's synthesized CurrentVelocity (animation root motion) is the fallback when the queue is idle.
  • Path B (NPCs / airborne players): MotionInterpreter.get_state_velocity() (fed by InterpretedState.ForwardCommand/ForwardSpeed set from the wire) is the PRIMARY driver for NPCs without explicit server velocity; when the sequencer's CurrentVelocity.Y is non-zero, get_state_velocity PREFERS it over the hardcoded WalkAnimSpeed/RunAnimSpeed constants (MotionInterpreter.cs:646-668 "Option B"). Wire-synthesized ServerVelocity from position deltas takes priority over both when present and fresh.
  • Neither apply_raw_movement nor the raw adjust_motion(ref motion, ref speed, holdKey) entry point is called anywhere in the inbound remote path (confirmed via grep -rn "apply_raw_movement|get_state_velocity()" across src/ outside MotionInterpreter.cs — only src/AcDream.App/Input/PlayerMovementController.cs calls them, exclusively for the LOCAL player's own body, lines 761/880/909/958/974/1009-1014). The D6.2 apply_raw_movement/adjust_motion port (per CLAUDE.md's "D6.2a/D6.2b" commits) is LOCAL-PLAYER-ONLY; remote entities never route through it. Remotes instead get InterpretedState.ForwardCommand/ForwardSpeed bulk-copied directly (mirroring retail's copy_movement_from, per the comment at GameWindow.cs:4536-4544,4552-4558), bypassing adjust_motion's left→right/backward-forward normalization and run-hold-key promotion — that normalization already happened SERVER-SIDE (ACE) before the wire command arrived, per the code comments.

Q6 — CORRECTION (how inbound UpdatePosition corrects accumulated error)

Handled in OnLivePositionUpdated (GameWindow.cs:5334-~5773). The correction strategy differs by whether the remote is a PLAYER or not, and whether it's airborne:

Player remotes (IsPlayerGuid(update.Guid) branch, GameWindow.cs:5460-5635)

Ports retail's CPhysicsObj::MoveOrTeleport (0x00516330):

  1. Airborne no-op (5512-5521): if !update.IsGrounded, body position/velocity are NOT touched by the UP at all — gravity integration continues locally; only the render entity is synced to the current body position (undoing the unconditional top-of-function hard-snap).
  2. Landing transition (5527-5553): first grounded UP after airborne clears Airborne, zeroes velocity, hard-snaps rmState.Body.Position = worldPos directly (no queue), clears the interpolation queue, and resets the sequencer cycle from Falling to whatever InterpretedState.ForwardCommand implies.
  3. Grounded routing (5555-5581): distance check against the LOCAL player's position (MaxPhysicsDistance = 96f, 5556):
    • dist > 96: hard SetPositionSimple-style snap — rmState.Interp.Clear(); rmState.Body.Position = worldPos; (no smoothing at all).
    • dist <= 96: rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo:false, currentBodyPosition:rmState.Body.Position) — feeds InterpolationManager.Enqueue (InterpolationManager.cs:170-231), which itself has three sub-branches:
      • Far branch (dist from last-tail/body-position to new target > AutonomyBlipDistance = 100m): enqueues AND pre-arms an immediate blip (_failCount = StallFailCountThreshold + 1 = 4) so the very next AdjustOffset call snaps straight to the tail.
      • Already-close branch (body-to-target ≤ DesiredDistance = 0.05m): wipes the queue entirely, no enqueue (Clear()).
      • Near/not-close branch: tail-prune loop collapses consecutive stale entries within 0.05m of the new target, caps queue at 20 (drops head if full), then appends.
    • Every-tick catch-up happens later in TickAnimations Path A via PositionManager.ComputeOffsetInterpolationManager.AdjustOffset (Q5). There is a secondary "stall" detector inside AdjustOffset (InterpolationManager.cs:292-337): every 5 frames it checks cumulative progress against MinDistanceToReachPosition = 0.20m and a secondary ratio check (cumulative/progressQuantum/dt >= 0.30); repeated stall failures (_failCount > StallFailCountThreshold = 3) trigger a hard SNAP to the tail waypoint (InterpolationManager.cs:345-350) and clear the queue.
  4. Velocity-fallback cycle refinement (5582-5625): synthesizes velocity from consecutive server positions ((worldPos - PrevServerPos) / dtPos) and feeds ApplyServerControlledVelocityCycleApplyPlayerLocomotionRefinement (Q7-adjacent — see below) to correct the VISIBLE CYCLE (not just position) when retail's wire is silent on a HoldKey-only Shift toggle.
  5. entity.SetPosition(rmState.Body.Position) at the end (5633) — the render entity always mirrors the body, never worldPos directly, for player remotes within the near-branch (queue chase still in progress).

Non-player remotes (GameWindow.cs:5637-5767)

NO InterpolationManager/PositionManager usage at all — this is the older "legacy" correction:

  1. serverVelocity sourced from wire update.Velocity if present, else synthesized from position delta if rmState.LastServerPosTime > 0 (5638-5646).
  2. rmState.Body.Position = worldPosUNCONDITIONAL HARD SNAP, every single UpdatePosition, no soft-lerp, no distance check (5657). Comment at 5679-5682 confirms: "Retail authoritatively hard-snaps cell membership here too."
  3. rmState.Body.Orientation = rot — also hard-snapped unconditionally (5693, comment 5685-5692: rotation-rate between UPs instead comes from the formula-seeded ObservedOmega, not from UP deltas — an earlier attempt to derive omega from UP deltas caused a "halved observed rate" bug on the first UP after a turn).
  4. rmState.Body.Velocity = svel if wire Velocity present (5711-5713); svel.LengthSquared() < 0.04f is treated as an explicit stop signal — triggers rmState.Motion.StopCompletely() (Q7) PLUS a direct SetCycle to Ready (5714-5731). This is the ONLY place StopCompletely() is called anywhere in the inbound path.
  5. Between UPs (Path B in TickAnimations), NPCs get a client-side ResolveWithTransition collision sweep — see Q5.

Named thresholds used for correction

Constant Value File:line Purpose
SnapHardSnapThreshold 20.0 m GameWindow.cs:614 (declared, comment references retail GetAutonomyBlipDistance; NOT directly read in the reviewed OnLivePositionUpdated flow for non-player remotes — hard snap there is unconditional every UP)
MaxPhysicsDistance 96 m GameWindow.cs:5556 Player-remote far-vs-near routing (SetPositionSimple vs Interp.Enqueue)
InterpolationManager.AutonomyBlipDistance 100 m InterpolationManager.cs:105 Far-branch pre-arm blip in Enqueue
InterpolationManager.DesiredDistance 0.05 m InterpolationManager.cs:79 "Reached"/"already close" radius
InterpolationManager.MinDistanceToReachPosition 0.20 m InterpolationManager.cs:73 5-frame stall-check primary threshold
InterpolationManager.StallFailCountThreshold 3 (fires on >3, i.e. 4th fail) InterpolationManager.cs:98 Snap-to-tail trigger
ServerControlledVelocityStaleSeconds 0.60 s GameWindow.cs:926 NPC server-velocity staleness → zero + Ready
RemoteMoveToDriver.StaleDestinationSeconds 1.5 s RemoteMoveToDriver.cs:136 MoveTo destination staleness → stand down

Q7 — STOP (motion → Ready/stand)

There are THREE distinct, independently-triggered stop paths in the current code, none of which are unified:

1. UM-driven stop (OnLiveMotionUpdated, the "retail stop signal")

When the wire's ForwardCommand flag is absent or explicitly 0 AND it's not a MoveTo packet (GameWindow.cs:4347,4364-4367): fullMotion = 0x41000003u (Ready) is used directly as the SetCycle target. This flows through the normal SetCycle full-rebuild path (Q2/Q4) — Ready is NOT flagged as a "locomotion" low-byte (IsLocomotionCycleLowByte only covers 0x05/0x06/0x07/0x0F/0x10, AnimationSequencer.cs:1509-1513), so a Run→Ready transition DOES get the link animation this time (the "Fix B" cyclic-direct bypass only applies when BOTH old and new are locomotion). remoteMot.Motion.InterpretedState.ForwardCommand/ForwardSpeed are ALSO bulk-copied to fullMotion/speedMod = Ready/1.0 UNCONDITIONALLY at the same time (4590,4598) — so MotionInterpreter.get_state_velocity() will subsequently return Vector3.Zero (neither WalkForward nor RunForward matches Ready) for Path B (NPC) velocity. No explicit StopCompletely()/StopInterpretedMotion() call happens in this path — it's purely a state overwrite via the same "InterpretedState = wire's forward/speed" bulk-copy used for every other UM.

2. UP-driven stop for non-player remotes (near-zero wire velocity)

GameWindow.cs:5711-5731: only fires when the wire's UpdatePosition.Velocity field is explicitly present AND its length² < 0.04 (i.e. |v| < 0.2 m/s). Calls rmState.Motion.StopCompletely() (MotionInterpreter.cs:562-587) which resets BOTH RawState and InterpretedState to Ready/1.0/0/0/0/1.0 across all three axes (forward/sidestep/turn) and calls PhysicsObj.set_velocity(Vector3.Zero) directly — this IS an explicit velocity zero. Additionally forces ae.Sequencer.SetCycle(curStyle, readyCmd) (5729) so the visible cycle snaps to Ready too, independent of whatever UM might arrive later. Comment (5700-5710) notes this UP-absent-velocity path was previously a bug source ("fired StopCompletely every UP → intermittent run") and is now scoped ONLY to the explicit-near-zero case, NOT absent-velocity.

3. Player-remote UM stop (no distinct path — same as #1, but note the render/position split)

For player remotes, OnLiveMotionUpdated's player branch (4417-4492) explicitly SKIPS the sequencer entirely for the LOCAL player's OWN echoed UM. For OTHER players (genuine remotes), the same #1 mechanism applies. Position, however, is decoupled: even after a Ready UM zeroes InterpretedState/switches the sequencer to Ready, the InterpolationManager queue (Path A, Q5) may still hold unconsumed waypoints from the last few UPs sent WHILE the player was still moving (the network is asynchronous — UM and UP for the same "I stopped" event may arrive in either order or with a gap). Since PositionManager.ComputeOffset prefers the queue's catch-up delta over seqVel whenever the queue is non-empty (PositionManager.cs:84-86), the body can continue sliding toward the last few queued waypoints via InterpolationManager.AdjustOffset's catch-up motion EVEN AFTER the sequencer has already switched to a zero-velocity Ready cycle — because the queue-driven correction is independent of CurrentVelocity. This is a structural potential for residual sliding: the stop signal (UM→Ready) zeroes ANIMATION velocity but does NOT clear rmState.Interp's queue; the queue only self-empties via normal NodeCompleted head-pop as the body reaches each remaining waypoint (InterpolationManager.cs:278-282), or by explicit Clear() calls which only happen at: (a) far-branch enqueue is NOT a clear, (b) already-close enqueue (0.05m gate), (c) AdjustOffset's stall-blip clear, (d) the landing-transition block (5534, airborne case only). There is no explicit rm.Interp.Clear() call anywhere in the UM-driven stop path (#1) or in the near-zero-UP stop path (#2) — confirmed by reading GameWindow.cs:4230-4967 (OnLiveMotionUpdated, no Interp.Clear call) and GameWindow.cs:5711-5731 (the UP near-zero-velocity StopCompletely block, no Interp.Clear call there either — only the airborne-landing block at 5534 calls rmState.Interp.Clear()).

Residual-sliding mechanisms explicitly present elsewhere (not stop-specific)

  • SnapResidualDecayRate = 8.0f (1/sec, GameWindow.cs:597) is DECLARED with a comment describing an exponential decay of "soft-snap residual" (100ms half-life) — but this constant is not exercised in any of the code paths read in this pass (OnLivePositionUpdated/OnLiveMotionUpdated/TickAnimations Path A/B); it may be vestigial or used in a renderer-only smoothing pass not covered by this trace. Flagging as unconfirmed — did not find a live call site using this constant during this investigation.

Summary answers

  • (a) Where remote DR velocity comes from: for grounded PLAYER remotes, AnimationSequencer.CurrentVelocity (synthesized constants × speedMod, set inside SetCycle, NOT from MotionInterpreter.get_state_velocity()), used ONLY when the InterpolationManager queue is idle; when the queue is active, DR position instead comes from InterpolationManager.AdjustOffset's catch-up formula (maxSpeedFromMinterp × 2.0, itself sourced from MotionInterpreter.GetMaxSpeed() = RunAnimSpeed(4.0) × runRate). For NPCs (and airborne players), DR velocity comes from MotionInterpreter.get_state_velocity() (which itself prefers the sequencer's CurrentVelocity.Y when non-zero, else falls back to WalkAnimSpeed/RunAnimSpeed × InterpretedState.ForwardSpeed), OR directly from rm.ServerVelocity (wire Velocity field or position-delta synthesis) when HasServerVelocity is true and fresh.
  • (b) When DR velocity changes after a new UM: SAME FRAME / same synchronous call — OnLiveMotionUpdated bulk-copies InterpretedState.ForwardCommand/ForwardSpeed immediately (GameWindow.cs:4590,4598) and calls SetCycle immediately (4769), which synthesizes CurrentVelocity immediately inside the same call (AnimationSequencer.cs:722-754). There is no queueing/deferral — the very next TickAnimations/Advance() call (next frame) will observe the new velocity.
  • (c) Can animation phase and DR velocity disagree, for how long: YES, structurally, in at least two ways: (1) For player remotes, PositionManager can be driving position via the InterpolationManager QUEUE (catch-up toward stale waypoints) while the SEQUENCER has already advanced to a new cycle/phase from a fresher UM — these two systems are not coupled; the queue drains independently at its own catch-up rate (up to GetMaxSpeed()×2.0 or 7.5 m/s fallback) regardless of what the currently-playing animation phase implies. (2) The SetCycle cyclic-direct hard-cut (Q2/Q4) resets _framePosition to a start boundary instantly on every walk↔run toggle, so the visible LEG PHASE has zero continuity with the body's actual accumulated forward distance at the moment of the toggle — there's no phase-matching/re-sync logic. Duration of disagreement is unbounded in principle (bounded in practice only by how quickly the InterpolationManager queue catches up, or by the next UM/UP hard-snap).
  • (d) How stop zeroes velocity, what can leave residual sliding: StopCompletely() zeroes PhysicsObj.set_velocity(Vector3.Zero) directly on the body AND resets InterpretedState, but this ONLY fires from the UP near-zero-velocity path (#2 above) — the far more common UM-driven Ready transition (#1) only zeroes the ANIMATION's synthesized CurrentVelocity (via SetCycle's Ready branch, which is not a "locomotion low byte" so gets no velocity synthesis at all — CurrentVelocity simply stays whatever it was set to by ClearPhysics() at the top of SetCycle, i.e. Vector3.Zero, UNLESS a link with HasVelocity overrides it — Ready transitions DO get a link per Q2, and if that link's MotionData has HasVelocity set, CurrentVelocity would be non-zero for the DURATION of that link before wrapping to the (velocity-less, non-locomotion) Ready cycle). Meanwhile, for player remotes specifically, NEITHER of the two stop paths clears rmState.Interp's waypoint queue — so if the queue still holds unreached waypoints from before the stop signal, PositionManager.ComputeOffset/InterpolationManager.AdjustOffset will continue producing non-zero catch-up deltas each tick (up to the stall-detection kicking in after 5 frames / _failCount exceeding 3), producing exactly the "residual sliding after stop" symptom the task is asking about. This is a genuine, code-confirmed gap: stop-signal handling and interpolation-queue clearing are two independent systems that are not wired together outside of the airborne-landing special case.

Cross-cutting notes for synthesis

  • Two entirely separate motion-drive architectures coexist: the newer InterpolationManager/PositionManager "queue + REPLACE" model (grounded player remotes only) vs. the older MotionInterpreter.apply_current_movement/get_state_velocity "continuous velocity from InterpretedState" model (NPCs + airborne remotes). They share almost no code and have different stop/correction semantics.
  • MotionInterpreter's D6.2 adjust_motion/apply_raw_movement port is entirely unused by the inbound remote path — remotes get server-pre-normalized commands bulk-copied directly into InterpretedState, bypassing the retail-faithful raw→interpreted normalization pipeline that exists in the codebase but is wired to the LOCAL player only (PlayerMovementController.cs).
  • No unified "stop" concept: three independent triggers (UM Ready, UP near-zero-velocity StopCompletely, airborne-landing zero) each partially zero different pieces of state (animation velocity only / full body+interpreted-state velocity / position+velocity+cycle), and none of them touch the InterpolationManager queue except the airborne-landing case.
  • SetCycle's cyclic-locomotion-direct special case (Q2/Q4) is itself a documented, intentional divergence from the general link-transition mechanism, justified in-code by a cdb trace of retail's add_to_queue behavior — i.e. per CLAUDE.md's divergence-register discipline, this specific behavior claims retail-fidelity (not an acknowledged approximation), though this investigation did not independently verify the cdb trace's conclusiveness.