/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>
40 KiB
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(4230–4967),OnLivePositionUpdated(5334–~5773),ApplyServerControlledVelocityCycle/ApplyPlayerLocomotionRefinement(5112–5333),TickAnimations(9673–~10267+),RemoteMotionclass (401–~592).src/AcDream.Core/Physics/AnimationSequencer.cs(1583 lines) —SetCycle(401–788),Advance(862–957), 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) — retailCPhysicsObjposition-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
-
Wire parse.
AcDream.Core.Net.Messages.UpdateMotion.TryParse(UpdateMotion.cs:70-253) decodes opcode0xF74CintoParsed(uint Guid, CreateObject.ServerMotionState MotionState). Extracts:Guid,currentStyle(Stance), and — whenmovementType == 0(InterpretedMotionState) — optionalForwardCommand/ForwardSpeed/SideStepCommand/SideStepSpeed/TurnCommand/TurnSpeedplus aCommandslist (actions/emotes), gated by a 7-bit flags dword (UpdateMotion.cs:145-226). WhenmovementType is 6 or 7(MoveToObject/MoveToPosition), it instead parses aMoveToPathDatapayload viaTryParseMoveToPayload(UpdateMotion.cs:255-331): target guid (type 6 only), Origin cell+xyz,MovementParametersdword, 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 viapos += 6),isAutonomous(u8, same 6-byte skip — never separately read),_motionFlags(u8, read into a local but only used for the diagnostic dump atUpdateMotion.cs:111,115-122, otherwise discarded),distanceToObject/failDistance/walkRunThreshold/desiredHeadinginsideTryParseMoveToPayload(parsed atUpdateMotion.cs:305-306,309,313,315into locals, never placed onMoveToPathData— onlydistanceToObject(asDistanceToObject),minDistance,failDistance(discarded — not passed to the record),walkRunThresholdanddesiredHeadingARE passed toMoveToPathDatactor atUpdateMotion.cs:319-329butMoveToPathDatadoesn't exposeFailDistancein 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), optionalVelocity, optionalPlacementId,IsGroundedflag, and three of four trailing u16 sequence numbers (instSeq,teleSeq,forceSeq— the second one, "positionSequence", is explicitly skipped:UpdatePosition.cs:156comment "not tracked by movement"). None ofinstSeq/teleSeq/forceSeqare consulted anywhere downstream for freshness/ordering (comment atUpdatePosition.cs:29-31: "We don't currently check these for freshness but we must consume them to walk the buffer correctly").
- Fields parsed then discarded / not used for pose:
-
Session dispatch → GameWindow event. (Not directly inspected this pass, but referenced via
_liveSession.MotionUpdated += OnLiveMotionUpdated;atGameWindow.cs:2633.) The parsedUpdateMotion.Parsedbecomes anAcDream.Core.Net.WorldSession.EntityMotionUpdateand fires theMotionUpdatedevent. -
GameWindow.OnLiveMotionUpdated(GameWindow.cs:4230) is the sole consumer.- Early-outs:
_dats is null(4232), entity not found byupdate.Guidin_entitiesByServerGuid(4233), or noAnimatedEntityfor it in_animatedEntities(4234). - Stamps
rmStateForUm.LastUMTimeon the_remoteDeadReckon[update.Guid]entry if present (4243-4247) — used later to gate the velocity-refinement grace window (Q7 /UmGraceSeconds). - Reads
stance = update.MotionState.Stanceandcommand = update.MotionState.ForwardCommand(nullable) (4259-4260). - Sequencer path (the only path exercised in practice —
ae.Sequencer is not nullat 4327) reconstructs a full 32-bitfullMotioncommand from the wire's 16-bitForwardCommandviaMotionCommandResolver.ReconstructFullCommand(4372-4378), OR seeds a MoveTo-derived motion viaServerControlledLocomotion.PlanMoveToStartwhenIsServerControlledMoveToand no forward command (4356-4363), OR falls back to0x41000003(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 (elsebranch at 4493) path. - For a genuine remote: bulk-copies
fullMotion/speedModintoremoteMot.Motion.InterpretedState.ForwardCommand/ForwardSpeedUNCONDITIONALLY (4572-4598) — this is the direct feed intoMotionInterpreter.get_state_velocity()(Q5). Then classifies viaAnimationCommandRouter.Classify(fullMotion)intoforwardIsOverlay(Action/Modifier/ChatEmote — routed viaRouteFullCommand, an overlay call, 4626-4636) vs SubState (the normal walk/run/turn/ready path, 4637elseblock — this is Q2/Q3/Q4's territory).
- Early-outs:
Q2 — TRANSITION: walk↔run while already moving (no stop)
Frame-by-frame, for the SubState (non-overlay) branch at GameWindow.cs:4637-4859:
- Cycle selection (4648-4683):
animCycle = fullMotion,animSpeed = speedModby default. If the forward command is NOT run/walk/walkback (fwdIsRunWalkfalse), fall back to sidestep or turn cycle. For a plain walk↔run toggle,fullMotionIS run/walk, so this stays the forward cycle — no fallback triggered. - Airborne guard (4696): if
remoteIsAirborne, the cycle swap is SKIPPED entirely (Falling cycle preserved) — not relevant to a grounded walk↔run toggle. - 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. ae.Sequencer.SetCycle(fullStyle, cycleToPlay, animSpeed)is called (4769) — this is the entry intoAnimationSequencer.SetCycle(AnimationSequencer.cs:401).
Inside SetCycle (AnimationSequencer.cs:401-788) — what actually happens on Walk→Run or Run→Walk:
adjust_motionremap (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'sLinksdict (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) — zeroesCurrentVelocity/CurrentOmegabefore 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 = _firstCyclicdirectly 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'sadd_to_queuesequence for a Walk→Run direct transition never firestruncate_animation_listand 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 = firstNewat 636-640) — that's the smooth-transition path used elsewhere, just not for walk↔run. CurrentStyle/CurrentMotion/CurrentSpeedModupdated (682-684).- Velocity synthesis (686-754): because retail's Humanoid MotionData ships
Flags=0x00(noHasVelocity) for locomotion cycles,CurrentVelocityis unconditionally re-synthesized here from hardcoded constants (WalkAnimSpeed=3.12f,RunAnimSpeed=4.0f,SidestepAnimSpeed=1.25f— 793-795) timesadjustedSpeed, 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.CurrentVelocitysnaps to the new cycle's steady-state speed the instantSetCyclereturns, 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):
OnLiveMotionUpdatedfires →SetCycle(style, RunForward, speedMod)called synchronously within the same event handler call (not queued/deferred) → full rebuild → cyclic→cyclic special case detected → link dropped,_currNodesnapped directly to Run cycle's start frame,CurrentVelocitysynthesized toRunAnimSpeed × speedMod— ALL of this happens on the SAME UM-processing call, before the nextAdvance()/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 callsAdvance(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 insideSetCycle— no interpolation of speed either (the fast-path'sMultiplyCyclicFrameratesmooth-rescale only applies whenCurrentMotionis 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(aLinkedList<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 everySetCyclecall. There's no notion of a QUEUED but not-yet-active MOTION COMMAND (e.g. "play attack after current swing finishes") —SetCyclealways 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, aList<AnimationHook>accumulated duringAdvance()when integer frame boundaries are crossed (Advance, 862-957, callsExecuteHooksat 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 staticAnimationHookDir.Bothhook) is added to_pendingHookswhen a non-cyclic (link) node drains naturally and the sequence is about to wrap (948-951, insideAdvance). This is the closest thing to a "link finished" signal, but it's a generic hook fired into the SAME_pendingHookslist as animation-authored hooks (footsteps, damage triggers) — there's no separate consumer that reacts specifically to "this MOTION COMMAND completed" the way retail'spending_motions/MotionDonestate 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/DoInterpretedMotiondirectly and unconditionally; the ONLY "which motion wins" arbitration is (a) the SubState-vs-Overlay classification inOnLiveMotionUpdated(4626 vs 4637) and (b)SetCycle's own state (CurrentMotion/CurrentStylecomparison) — 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):
_currNodeis set to the FIRST NEWLY-ENQUEUED node (firstNew, 583, 636-640) — i.e. the link animation, if one exists, plays from ITSGetStartFramePosition()(583+639:_framePosition = _currNode.Value.GetStartFramePosition()), which for positive framerate isStartFrame(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 subsequentAdvance()calls,AdvanceToNextAnimation()(1344) walks to_currNode.Next, or if null, wraps to_firstCyclic— again restarting_framePositionatGetStartFramePosition()of that new node (1362-1363). - Locomotion-direct case (Walk↔Run and similar, per the "Fix B" special-case,
AnimationSequencer.cs:619-635):_currNodeis forced DIRECTLY onto_firstCyclic(the new cycle's looping node), with_framePosition = _firstCyclic.Value.GetStartFramePosition()(634) — i.e. frame index is reset to 0 (orEndFramefor 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" — everySetCycletransition, link or no link, hard-resets_framePositionto 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
AnimNodeplayback 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:
InterpolationManager.AdjustOffset(dt, currentBodyPosition, maxSpeed)(called atPositionManager.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), wherecatchUpSpeed = maxSpeedFromMinterp × MaxInterpolatedVelocityMod (2.0)or aMaxInterpolatedVelocity(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),AdjustOffsetreturnsVector3.Zero, andPositionManager.ComputeOffsetfalls through to animation root motion:seqVel × dt, rotated by body orientation, optionally slope-projected (PositionManager.cs:88-106).
- If the queue has waypoints AND the body is farther than
seqVelisae.Sequencer.CurrentVelocity(body-local, from the sequencer synthesis in Q2/Q4) — this is the ANIMATION-DERIVED velocity, NOTMotionInterpreter.get_state_velocity().omegaToApplyisrm.ObservedOmega(seeded formulaically from wire TurnCommand+TurnSpeed inOnLiveMotionUpdated,GameWindow.cs:4841-4847) preferred overseqOmega(GameWindow.cs:9842-9845).rm.Body.calc_acceleration()+rm.Body.UpdatePhysicsInternal(dt)still run (9878, 9881) for gravity/vertical integration, butResolveWithTransition(collision sweep) is INTENTIONALLY SKIPPED for this path (comment 9883-9890: "the server has already collision-resolved the broadcast position").- 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):
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false)(10074, the general branch) — callsMotionInterpreter.get_state_velocity()(MotionInterpreter.cs:639-688), which readsInterpretedState.ForwardCommand/ForwardSpeed(bulk-copied from the wire inOnLiveMotionUpdated, Q1/Q2) and returnsWalkAnimSpeed/RunAnimSpeed × ForwardSpeedUNLESS the sequencer'sCurrentVelocity.Yis non-zero (GetCycleVelocityaccessor, wired — see below), in which case it prefers that (MotionInterpreter.cs:653-668, "Option B — MotionData-sourced forward velocity"). Result is written directly toPhysicsBody.set_local_velocityviaapply_current_movement(MotionInterpreter.cs:905-909, gated onPhysicsObj.OnWalkable).- Alternative sub-branches at this same call site (
GameWindow.cs:9963-10076) select DIFFERENT velocity sources depending on state:- If
rm.HasServerVelocityand NOT a player guid (9968): usesrm.ServerVelocity(synthesized from consecutive UP position deltas, or wireVelocityfield when present) directly asrm.Body.Velocity— BYPASSINGapply_current_movement/get_state_velocityentirely — UNLESS the velocity is stale (velocityAge > ServerControlledVelocityStaleSeconds = 0.60s, 926), in which case it's zeroed andApplyServerControlledVelocityCycleis invoked to reset the cycle to Ready. - If
rm.ServerMoveToActive && rm.HasMoveToDestination(9987): usesRemoteMoveToDriver.Driveto steer orientation, thenapply_current_movementfor velocity magnitude (10042), clamped viaRemoteMoveToDriver.ClampApproachVelocity(10052-10059) to avoid overshoot. - Else (general fallback, 10074):
apply_current_movementas described in step 1.
- If
- Manual per-tick rotation integration (
GameWindow.cs:10097-10106) usingrm.ObservedOmega. rm.Body.UpdatePhysicsInternal(dt)(10128) — Euler integration.- 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 byInterpretedState.ForwardCommand/ForwardSpeedset from the wire) is the PRIMARY driver for NPCs without explicit server velocity; when the sequencer'sCurrentVelocity.Yis non-zero,get_state_velocityPREFERS it over the hardcoded WalkAnimSpeed/RunAnimSpeed constants (MotionInterpreter.cs:646-668"Option B"). Wire-synthesizedServerVelocityfrom position deltas takes priority over both when present and fresh. - Neither
apply_raw_movementnor the rawadjust_motion(ref motion, ref speed, holdKey) entry point is called anywhere in the inbound remote path (confirmed viagrep -rn "apply_raw_movement|get_state_velocity()"acrosssrc/outsideMotionInterpreter.cs— onlysrc/AcDream.App/Input/PlayerMovementController.cscalls them, exclusively for the LOCAL player's own body, lines 761/880/909/958/974/1009-1014). The D6.2apply_raw_movement/adjust_motionport (per CLAUDE.md's "D6.2a/D6.2b" commits) is LOCAL-PLAYER-ONLY; remote entities never route through it. Remotes instead getInterpretedState.ForwardCommand/ForwardSpeedbulk-copied directly (mirroring retail'scopy_movement_from, per the comment atGameWindow.cs:4536-4544,4552-4558), bypassingadjust_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):
- 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). - Landing transition (5527-5553): first grounded UP after airborne clears
Airborne, zeroes velocity, hard-snapsrmState.Body.Position = worldPosdirectly (no queue), clears the interpolation queue, and resets the sequencer cycle from Falling to whateverInterpretedState.ForwardCommandimplies. - Grounded routing (5555-5581): distance check against the LOCAL player's position (
MaxPhysicsDistance = 96f, 5556):dist > 96: hardSetPositionSimple-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)— feedsInterpolationManager.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 nextAdjustOffsetcall 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.
- Far branch (dist from last-tail/body-position to new target >
- Every-tick catch-up happens later in
TickAnimationsPath A viaPositionManager.ComputeOffset→InterpolationManager.AdjustOffset(Q5). There is a secondary "stall" detector insideAdjustOffset(InterpolationManager.cs:292-337): every 5 frames it checks cumulative progress againstMinDistanceToReachPosition = 0.20mand 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.
- Velocity-fallback cycle refinement (5582-5625): synthesizes velocity from consecutive server positions (
(worldPos - PrevServerPos) / dtPos) and feedsApplyServerControlledVelocityCycle→ApplyPlayerLocomotionRefinement(Q7-adjacent — see below) to correct the VISIBLE CYCLE (not just position) when retail's wire is silent on a HoldKey-only Shift toggle. entity.SetPosition(rmState.Body.Position)at the end (5633) — the render entity always mirrors the body, neverworldPosdirectly, 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:
serverVelocitysourced from wireupdate.Velocityif present, else synthesized from position delta ifrmState.LastServerPosTime > 0(5638-5646).rmState.Body.Position = worldPos— UNCONDITIONAL 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."rmState.Body.Orientation = rot— also hard-snapped unconditionally (5693, comment 5685-5692: rotation-rate between UPs instead comes from the formula-seededObservedOmega, 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).rmState.Body.Velocity = svelif wireVelocitypresent (5711-5713);svel.LengthSquared() < 0.04fis treated as an explicit stop signal — triggersrmState.Motion.StopCompletely()(Q7) PLUS a directSetCycleto Ready (5714-5731). This is the ONLY placeStopCompletely()is called anywhere in the inbound path.- Between UPs (Path B in
TickAnimations), NPCs get a client-sideResolveWithTransitioncollision 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/TickAnimationsPath 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 insideSetCycle, NOT fromMotionInterpreter.get_state_velocity()), used ONLY when the InterpolationManager queue is idle; when the queue is active, DR position instead comes fromInterpolationManager.AdjustOffset's catch-up formula (maxSpeedFromMinterp × 2.0, itself sourced fromMotionInterpreter.GetMaxSpeed()=RunAnimSpeed(4.0) × runRate). For NPCs (and airborne players), DR velocity comes fromMotionInterpreter.get_state_velocity()(which itself prefers the sequencer'sCurrentVelocity.Ywhen non-zero, else falls back toWalkAnimSpeed/RunAnimSpeed × InterpretedState.ForwardSpeed), OR directly fromrm.ServerVelocity(wireVelocityfield or position-delta synthesis) whenHasServerVelocityis true and fresh. - (b) When DR velocity changes after a new UM: SAME FRAME / same synchronous call —
OnLiveMotionUpdatedbulk-copiesInterpretedState.ForwardCommand/ForwardSpeedimmediately (GameWindow.cs:4590,4598) and callsSetCycleimmediately (4769), which synthesizesCurrentVelocityimmediately inside the same call (AnimationSequencer.cs:722-754). There is no queueing/deferral — the very nextTickAnimations/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,
PositionManagercan 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 toGetMaxSpeed()×2.0or 7.5 m/s fallback) regardless of what the currently-playing animation phase implies. (2) TheSetCyclecyclic-direct hard-cut (Q2/Q4) resets_framePositionto 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()zeroesPhysicsObj.set_velocity(Vector3.Zero)directly on the body AND resetsInterpretedState, 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 synthesizedCurrentVelocity(viaSetCycle's Ready branch, which is not a "locomotion low byte" so gets no velocity synthesis at all —CurrentVelocitysimply stays whatever it was set to byClearPhysics()at the top ofSetCycle, i.e.Vector3.Zero, UNLESS a link withHasVelocityoverrides it — Ready transitions DO get a link per Q2, and if that link's MotionData hasHasVelocityset,CurrentVelocitywould 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 clearsrmState.Interp's waypoint queue — so if the queue still holds unreached waypoints from before the stop signal,PositionManager.ComputeOffset/InterpolationManager.AdjustOffsetwill continue producing non-zero catch-up deltas each tick (up to the stall-detection kicking in after 5 frames /_failCountexceeding 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.2adjust_motion/apply_raw_movementport is entirely unused by the inbound remote path — remotes get server-pre-normalized commands bulk-copied directly intoInterpretedState, 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'sadd_to_queuebehavior — 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.