/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>
51 KiB
Map of ACE's C# port of retail CMotionInterp / CSequence / MovementManager
Source root: C:/Users/erikn/source/repos/acdream/references/ACE/Source/ACE.Server/Physics
Key files (all confirmed to exist):
Animation/MotionInterp.cs— retailCMotionInterp(per-object motion-command interpreter; ownsPendingMotions,RawState,InterpretedState)Animation/Sequence.cs— retailCSequence(the actual animation cycle/frame player; ownsAnimList,CurrAnim,FrameNumber,Velocity/Omega)Animation/MotionTable.cs— retail motion-table graph walker (GetObjectSequence= the core cycle-swap / link-animation logic)Managers/MotionTableManager.cs— retail per-objectAnimationCounter/PendingAnimationsbookkeeping + redundant-link truncationManagers/MovementManager.cs— top dispatcher owningMotionInterpreter+MoveToManagerManagers/InterpolationManager.cs— remote/dead-reckoning position correction (network snap-to-position blending)Managers/PositionManager.cs— thin composite wrapping InterpolationManager + StickyManager + ConstraintManager, called every tick fromPhysicsObj.UpdatePositionInternalPartArray.cs— glue betweenPhysicsObjandSequence/MotionTableManager(DoInterpretedMotion,Update,StopInterpretedMotion)PhysicsObj.cs— top-level per-entity physics object;update_object/UpdateObjectInternal/UpdatePositionInternaldrive the whole thing every tickAnimation/AnimSequenceNode.cs,Animation/MotionState.cs,Animation/RawMotionState.cs,Animation/InterpretedMotionState.cs,Animation/MotionNode.cs,Animation/AnimNode.cs,Animation/MovementParameters.cs
IMPORTANT CAVEAT: ACE is the server. There is no "remote entity rendering" concept in ACE — every PhysicsObj on the server is locally-simulated and authoritative; ACE broadcasts UpdatePosition/UpdateMotion wire messages outward to clients, it does not consume them for dead-reckoning of other players (the actual client-side interpolation-of-remote-entities logic lives in the retail client, not in ACE). The closest ACE analogs to "a remote object changing walk<->run while moving":
- The locally-controlled-by-network-input path:
Player_Tick.OnMoveToState_ClientMethod/OnMoveToState_ServerMethod— this is literally the server acting as the retail client'sCMotionInterpwould for the connected player, driven by inboundMoveToState(0xF61C-family) packets. This is the best available proxy for "how does an incoming motion-command change drive the interpreter." - The dead-reckoning/position-correction path for other entities as seen by a given observer would be
PhysicsObj.MoveOrTeleport+PositionManager.InterpolateTo+InterpolationManager— this is ACE's port of the retail smartbox/dead-reckoning blend (used e.g. for monsters'UpdatePositionbroadcasts, and structurally mirrors what a retail client does when it receives another player'sUpdatePosition).
Both paths are documented below since the questions blend "wire-to-interpreter" (best answered by path 1) and "position correction" (best answered by path 2).
Q1 — INBOUND ENTRY: wire message → motion interpreter
Path A: motion-command message (MoveToState, retail wire opcode family 0xF61C) → MotionInterp
-
ACE.Server/Network/GameAction/Actions/GameActionMoveToState.cs:13Handle(ClientMessage, Session)— the game-action handler for[GameAction(GameActionType.MoveToState)]. Parses payload into aMoveToStatestruct (Network/Structure/MoveToState.cs, not read in detail here) and storessession.Player.CurrentMoveToState = moveToState;(line 20). -
Line 29:
session.Player.OnMoveToState(moveToState);— this is the entry into the physics/animation layer. -
Line 36:
session.Player.BroadcastMovement(moveToState);(WorldObjects/Player_Networking.cs:309) — re-broadcasts the motion asGameMessageUpdateMotionto other clients (the wire-out side of this, not traced further — out of scope per the ACE-is-server caveat above). -
WorldObjects/Player_Tick.cs:176OnMoveToState(MoveToState moveToState):- Guards on
FastTick(line 178). - Line 187-188: if not already moving/animating, resets
PhysicsObj.UpdateTime = PhysicsTimer.CurrentTime(this re-arms the per-tick delta-time accumulator so a stale UpdateTime doesn't produce a huge first quantum). - Line 190-193: branches on
client_movement_formulaproperty +StandingLongJump:OnMoveToState_ServerMethod(retail-authoritative single-shot apply)OnMoveToState_ClientMethod(retail client-style edge-triggered DoMotion/StopMotion calls)
- Guards on
-
OnMoveToState_ServerMethod(Player_Tick.cs:272-288) — the retail-CMotionInterp-faithful path:var minterp = PhysicsObj.get_minterp(); minterp.RawState.SetState(moveToState.RawMotionState); if (moveToState.StandingLongJump) { minterp.RawState.ForwardCommand = Ready; minterp.RawState.SideStepCommand = 0; } var allowJump = minterp.motion_allows_jump(minterp.InterpretedState.ForwardCommand) == WeenieError.None; minterp.apply_raw_movement(true, allowJump);This copies the ENTIRE wire-level
RawMotionState(Network/Structure/RawMotionState) intoMotionInterp.RawStatein one shot viaRawMotionState.SetState(Animation/RawMotionState.cs:117-140), then callsMotionInterp.apply_raw_movement(Animation/MotionInterp.cs:506-523), which re-derivesInterpretedStatefromRawStatefield-by-field and re-issuesDoInterpretedMotioncalls (see Q2 below). This is the retailCMotionInterp::apply_raw_movement— full re-derivation every call, not an incremental diff. -
OnMoveToState_ClientMethod(Player_Tick.cs:199-270) — an edge-triggered alternative (aclient_movement_formula-flagged, non-default variant) that diffsrawStateagainstprevState = LastMoveToState?.RawMotionStateand callsPhysicsObj.DoMotion/PhysicsObj.StopMotiononly on actual key-press/key-release transitions (ForwardCommand changed / went Invalid, same for Sidestep/Turn). This is closer to how the retail client itself processes raw keyboard edges, and is explicitly commented (lines 360-371) as an attempt to fix desync bugs vs. the always-full-reapply server method.
Path B: PhysicsObj.DoMotion (entry used by both client-edge method and any direct-call site)
PhysicsObj.cs:340-346:
public WeenieError DoMotion(uint motion, MovementParameters movementParams)
{
LastMoveWasAutonomous = true;
if (MovementManager == null) return WeenieError.NoAnimationTable;
var mvs = new MovementStruct(MovementType.RawCommand, motion, movementParams);
return MovementManager.PerformMovement(mvs);
}
→ MovementManager.PerformMovement (Managers/MovementManager.cs:124-157) lazily constructs MotionInterpreter if null, then dispatches MovementType.RawCommand to MotionInterpreter.PerformMovement(mvs) (Animation/MotionInterp.cs:236-262), which for RawCommand calls DoMotion(mvs.Motion, mvs.Params) (MotionInterp.cs:112-158).
MotionInterp.DoMotion (line 112):
- Builds a local
currentParamscopy (CopySome, MotionInterp.cs:119). adjust_motion(ref currentMotion, ref currentParams.Speed, movementParams.HoldKeyToApply)(line 129) — this is where WalkForward auto-promotes to RunForward if HoldKey==Run (see Q2).- Combat-stance gating (lines 131-145): rejects Crouch/Sitting/Sleeping/ChatEmote while
CurrentStyle != NonCombat. - Action-count gating (line 147-151):
GetNumActions() >= 6→TooManyActions. - Calls
DoInterpretedMotion(currentMotion, currentParams)(line 152) — the actual interpreter entry (shared with Path C below). - If success and
movementParams.ModifyRawState, callsRawState.ApplyMotion(motion, movementParams)(line 155) to record the original, un-adjusted motion into RawState (so Run-promotion doesn't get baked into RawState).
Path C: MotionInterp.DoInterpretedMotion — the true funnel point (both paths above and the direct-interpreted-command callers converge here)
Animation/MotionInterp.cs:51-110:
contact_allows_move(motion)gate (line 57, see below) — blocks ground-locked motions while airborne.StandingLongJumpspecial case (lines 59-63): if charging a standing long jump and motion is Walk/Run/SideStepRight, just updatesInterpretedStatewithout touching the physics animation (charge-jump doesn't reanimate).- Otherwise (line 64-90):
Deadmotion →PhysicsObj.RemoveLinkAnimations()first (line 66-67).result = PhysicsObj.DoInterpretedMotion(motion, movementParams)(line 69) →PartArray.DoInterpretedMotion(PartArray.cs:130-136) →MotionTableManager.PerformMovement(mvs, Sequence)(Managers/MotionTableManager.cs:116-145) →Table.DoObjectMotion→GetObjectSequence(Animation/MotionTable.cs:55-257, the actual cycle-graph logic, see Q2/Q4).- On success: computes
jump_error_code(lines 73-83, gates jump based on DisableJumpDuringLink / motion_allows_jump), thenadd_to_queue(movementParams.ContextID, motion, jump_error_code)(line 85) — pushes aMotionNodeontoMotionInterp.PendingMotions(see Q3). - If
movementParams.ModifyInterpretedState, callsInterpretedState.ApplyMotion(motion, movementParams)(line 88) to record the new forward/sidestep/turn/style/action state.
- If not contact-allowed (airborne): Action commands fail with
YouCantJumpWhileInTheAir(line 94-95); non-action commands are recorded into InterpretedState only (no animation change) withModifyInterpretedState(line 99-100). - Line 106-107: if
PhysicsObj.CurCell == null,RemoveLinkAnimations()— an object that's not in a cell (e.g. being carried, or between transitions) never plays link animations.
contact_allows_move (MotionInterp.cs:584-602):
Dead / Falling / TurnRight..TurnLeft range → always true
non-creature WeenieObj → always true
!PhysicsState.Gravity → true
!TransientState.Contact → false // airborne, no ground contact
TransientState.OnWalkable → true // standing on walkable floor
else → false
Q2 — TRANSITION: walk↔run while already moving (no full stop)
Frame 0: the wire triggers apply_raw_movement (server method) or a direct DoMotion(RunForward, ...) (client-edge method / direct caller)
MotionInterp.apply_raw_movement (Animation/MotionInterp.cs:506-523):
InterpretedState.CurrentStyle = RawState.CurrentStyle;
InterpretedState.ForwardCommand = RawState.ForwardCommand; // e.g. WalkForward (0x45000005)
InterpretedState.ForwardSpeed = RawState.ForwardSpeed;
InterpretedState.SideStepCommand = RawState.SideStepCommand;
...
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);
adjust_motion (MotionInterp.cs:394-428) is where Walk→Run promotion actually happens:
public void adjust_motion(ref uint motion, ref float speed, HoldKey holdKey)
{
if (WeenieObj != null && !WeenieObj.IsCreature()) return;
switch (motion)
{
case RunForward: return; // already run, no-op
case WalkBackwards: motion = WalkForward; speed *= -BackwardsFactor; break; // -0.65
case TurnLeft: motion = TurnRight; speed *= -1.0f; break;
case SideStepLeft: motion = SideStepRight; speed *= -1.0f; break;
}
if (motion == SideStepRight)
speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed); // 0.5 * (3.12/1.25)
if (holdKey == HoldKey.Invalid) holdKey = RawState.CurrentHoldKey;
if (holdKey == HoldKey.Run) apply_run_to_command(ref motion, ref speed);
}
apply_run_to_command (MotionInterp.cs:525-562):
case WalkForward:
if (speed > 0.0f) motion = RunForward; // <-- THE ACTUAL SWAP: WalkForward substate id → RunForward substate id
speed *= speedMod; // speedMod = WeenieObj.InqRunRate() or MyRunRate (server run-skill-derived rate)
break;
case TurnRight: speed *= RunTurnFactor; break; // 1.5
case SideStepRight:
speed *= speedMod;
if (MaxSidestepAnimRate < Math.Abs(speed)) // clamp to 3.0
speed = Math.Sign(speed) * MaxSidestepAnimRate;
break;
So the "walk vs run" decision is NOT a separate motion command on the wire — it's the client's HoldKey (Run) bit combined with WalkForward, and the interpreter (not the network layer) decides to swap the motion id from WalkForward (0x45000005) to RunForward (0x44000007) before it ever reaches the animation graph. This matches the CLAUDE.md-documented wire quirk: acdream sends WalkForward + HoldKey.Run, and ACE relays it as RunForward to observers — that relay behavior mirrors exactly this adjust_motion/apply_run_to_command swap.
Frame 1: apply_interpreted_movement re-issues DoInterpretedMotion for the (possibly swapped) ForwardCommand
MotionInterp.apply_interpreted_movement (MotionInterp.cs:440-504):
if (InterpretedState.ForwardCommand == RunForward)
MyRunRate = InterpretedState.ForwardSpeed; // cache the run-rate for future adjust_motion calls
DoInterpretedMotion(InterpretedState.CurrentStyle, movementParams); // re-assert combat/non-combat stance
if (contact_allows_move(InterpretedState.ForwardCommand))
{
if (!StandingLongJump)
{
movementParams.Speed = InterpretedState.ForwardSpeed;
DoInterpretedMotion(InterpretedState.ForwardCommand, movementParams); // <-- e.g. DoInterpretedMotion(RunForward, speed)
...sidestep, turn similarly...
Frame 2: DoInterpretedMotion(RunForward, ...) reaches MotionTable.GetObjectSequence — THIS is where the cycle actually swaps
GetObjectSequence (Animation/MotionTable.cs:60-257). WalkForward (0x45000005) and RunForward (0x44000007) both have the CommandMask.SubState bit (0x40000000) set, so both hit the SubState branch (line 121 onward), NOT the "same substate, just re-speed" fast path — because motion (RunForward id) != currState.Substate (currently WalkForward id). The relevant excerpt:
if ((motion & CommandMask.SubState) != 0)
{
var motionID = motion & 0xFFFFFF;
Cycles.TryGetValue(currState.Style << 16 | motionID, out motionData); // look up the RUN cycle's MotionData
...
if (is_allowed(motion, motionData, currState))
{
if (motion == currState.Substate && sequence.HasAnims() && Math.Sign(speedMod) == Math.Sign(currState.SubstateMod))
{
// FAST PATH — same substate, just a speed change (does NOT apply to Walk<->Run,
// since WalkForward != RunForward numerically)
change_cycle_speed(sequence, motionData, currState.SubstateMod, speedMod);
subtract_motion(sequence, motionData, currState.SubstateMod);
combine_motion(sequence, motionData, speedMod);
currState.SubstateMod = speedMod;
return true;
}
// GENERAL PATH — actually taken for Walk->Run:
if ((motionData.Bitfield & 1) != 0) currState.clear_modifiers();
var link = get_link(currState.Style, currState.Substate, currState.SubstateMod, motion, speedMod);
// link = the WALK-cycle -> RUN-cycle transition/blend animation from the motion table's Links dict,
// keyed by (style<<16 | fromSubstate) -> Dictionary<toSubstate, MotionData>
if (link == null || Math.Sign(speedMod) != Math.Sign(currState.SubstateMod))
{
// fallback: go through the style's default substate as an intermediate hop
uint defaultMotion; StyleDefaults.TryGetValue(currState.Style, out defaultMotion);
link = get_link(currState.Style, currState.Substate, currState.SubstateMod, defaultMotion, 1.0f);
motionData_ = get_link(currState.Style, defaultMotion, 1.0f, motion, speedMod);
}
sequence.clear_physics(); // Sequence.Velocity = Omega = Vector3.Zero
sequence.remove_cyclic_anims(); // truncate AnimList back to the CURRENT (in-flight) anim node — see Q4
if (motionData_ != null)
{
add_motion(sequence, link, currState.SubstateMod); // append walk->default link anim
add_motion(sequence, motionData_, speedMod); // append default->run link anim
}
else
{
add_motion(sequence, link, newSpeedMod); // append walk->run direct link anim (typical case)
}
add_motion(sequence, motionData, speedMod); // APPEND the run cycle itself (loops forever)
currState.SubstateMod = speedMod;
currState.Substate = motion; // RunForward is now the tracked substate
re_modify(sequence, currState); // re-apply any active modifiers on top
numAnims = (motionData?.Anims.Count ?? 0) + (link?.Anims.Count ?? 0) + (motionData_?.Anims.Count ?? 0) - 1;
return true;
}
}
Answer to Q2: the new motion is APPENDED to the sequence's AnimList, not a hard replace. sequence.remove_cyclic_anims() first prunes any already-cyclic (looping) anim nodes that are BEYOND the currently-playing node — i.e. it removes stale queued loop segments but does NOT touch the in-flight anim — then add_motion calls append the link (transition) animation followed by the new cycle (Run) animation onto the tail of AnimList (Sequence.append_animation, Sequence.cs:203-216). The currently-playing Walk frame keeps playing to its natural end (or loop boundary); once Sequence.update_internal walks off the end of the current node it advances into the queued link animation, then into the new Run cycle (see Q4 for exact frame semantics). There IS a blend/link animation — get_link(style, fromSubstate, fromSpeed, toSubstate, toSpeed) looks up a purpose-built transition clip from MotionTable.Links[(style<<16)|fromSubstate][toSubstate]; this is retail's canonical "walk-to-run" or "run-to-walk" link/transition motion. Speed change is NOT instantaneous/immediate — the running cycle only takes effect once the sequence actually plays through to it; only Sequence.Velocity/Sequence.Omega (the linear/angular displacement-per-quantum baked into the currently active MotionData) change per node, and those are set fresh by each add_motion call via sequence.SetVelocity(motionData.Velocity * speed) (MotionTable.cs:362) which OVERWRITES (not blends) Sequence.Velocity — but that overwrite only takes effect for the currently-active node once the sequence has walked into it.
One exception worth flagging: SetVelocity/SetOmega inside add_motion (line 362-363) is called once per add_motion(sequence, motionData, speed) invocation and each call overwrites sequence.Velocity/sequence.Omega — so by the time GetObjectSequence returns, Sequence.Velocity already reflects the LAST add_motion call (the new Run cycle's velocity), even though the currently-playing frame is still mid-Walk-cycle. This means the per-tick displacement (Q5) can jump to the new cycle's velocity before the visual animation frame has caught up to the new cycle — a subtlety worth testing for in acdream's port (verify our Sequence.Update doesn't apply the "new" velocity to the frames that are still visually inside the "old" walk cycle, or confirm retail genuinely does this).
Q3 — PENDING/DONE lifecycle: pending_motions + MotionDone
Two SEPARATE pending-lists exist at two SEPARATE layers, easy to conflate:
Layer 1: MotionInterp.PendingMotions (List<MotionNode>, MotionInterp.cs:24)
- Type:
LinkedList<MotionNode>.MotionNode(Animation/MotionNode.cs):ContextID(int),Motion(uint),JumpErrorCode(WeenieError). - Appended by:
MotionInterp.add_to_queue(contextID, motion, jumpErrorCode)(MotionInterp.cs:388-392):
Called fromPendingMotions.AddLast(new MotionNode(contextID, motion, jumpErrorCode)); PhysicsObj.IsAnimating = true;DoInterpretedMotion(line 85),StopCompletely(line 321),StopInterpretedMotion(line 348), andapply_interpreted_movement's turn-release branch (line 495). - Popped by:
MotionInterp.MotionDone(bool success)(MotionInterp.cs:210-234):
This is called ONLY fromvar motionData = PendingMotions.First; if (motionData != null) { var pendingMotion = motionData.Value; if ((pendingMotion.Motion & CommandMask.Action) != 0) { PhysicsObj.unstick_from_object(); InterpretedState.RemoveAction(); RawState.RemoveAction(); } motionData = PendingMotions.First; // re-fetch (defensive against re-entrancy) if (motionData != null) { PendingMotions.Remove(motionData); PhysicsObj.IsAnimating = PendingMotions.Count > 0; } }PhysicsObj.MotionDone(motion, success)→MovementManager.MotionDone(motion, success)→MotionInterpreter.MotionDone(success)(note: themotionparameter is dropped/ignored at this layer — it always popsPendingMotions.First, positionally, not by matching motion id). - Who calls
PhysicsObj.MotionDone:MotionTableManager.AnimationDone(see Layer 2 below) andMotionTableManager.CheckForCompletedMotions. motions_pending()/IsAnimating:MotionInterp.motions_pending()(line 784-787) just checksPendingMotions.Count > 0; the fasterPhysicsObj.IsAnimatingbool field is kept in sync at every add/remove (see above) as a cached shortcut — the doc comment explicitly says to preferIsAnimatingfor perf.HandleExitWorld(MotionInterp.cs:160-173): drainsPendingMotionsentirely on world-exit, unsticking any queued Action motions and clearing the list without ever calling MotionDone on each (a hard flush, not a graceful drain).
Layer 2: MotionTableManager.PendingAnimations (LinkedList<AnimNode>, Managers/MotionTableManager.cs:13)
- Type:
AnimNode(Animation/AnimNode.cs):Motion(uint),NumAnims(uint) — the count of individualAnimationsub-clips (as opposed to the higher-levelMotionCommand) that must finish playing before this queue entry is considered done. - Appended by:
MotionTableManager.add_to_queue(motion, num_anims, sequence)(MotionTableManager.cs:163-167), called fromPerformMovement(line 127/134/139) withcounter= thenumAnimscomputed byMotionTable.GetObjectSequence/StopSequenceMotion/StopObjectCompletely(i.e. the total sub-clip count of link+cycle animations just appended toSequence.AnimListfor this motion). Also called byinitialize_state(line 176, entry into default Ready state) with the numAnims fromSetDefaultState.- Immediately after append:
remove_redundant_links(sequence)(line 166) — walksPendingAnimationsfrom the tail backward and, if it finds a duplicate motion id further up the chain whose animations haven't started playing yet, callstrancuate_animation_listto zero out the redundant entries'NumAnimsand physically remove the corresponding still-unplayed link animations fromSequence.AnimListviasequence.remove_link_animations(totalAnims)(Sequence.cs:324-340). This is the mechanism that prevents animation-graph bloat when motion commands arrive faster than they can finish playing (e.g. rapid walk/run toggling).
- Immediately after append:
- Popped/completed by TWO different drivers:
AnimationDone(bool success)(MotionTableManager.cs:28-61) — driven by actual animation-hook completion signals (seeSequence.update_internal'sAnimDoneHook, Q4). IncrementsAnimationCounter, then while the FRONT node'sNumAnims <= AnimationCounter: subtractsentry.NumAnimsback out of the counter, removes an Action-head fromMotionStateif the motion has the Action bit, callsPhysicsObj.MotionDone(motionID, success)(→ pops Layer-1MotionInterp.PendingMotions, see above), removes the frontPendingAnimationsnode, and firesPhysicsObj.WeenieObj.OnMotionDone(motionID, success)(the weenie/game-object-level callback — e.g. for scripted "on landed" or "on emote finished" logic).CheckForCompletedMotions()(MotionTableManager.cs:63-85) — a POLLING variant, called fromPhysicsObj.CheckForCompletedMotions(PhysicsObj.cs:296-300) →PartArray.CheckForCompletedMotions(PartArray.cs:72-76), itself invoked once perMotionInterp.PerformMovementcall (MotionInterp.cs:260, right afterPhysicsObj.CheckForCompletedMotions()unconditionally at the end of everyPerformMovement). It loops whilePendingAnimations.First.Value.NumAnims == 0(i.e. entries that were ALREADY reduced to zero, either by construction — an empty-cycle motion — or by a priorremove_redundant_links/trancuate_animation_listcall), popping them exactly likeAnimationDonedoes (same Action-head removal,PhysicsObj.MotionDone,OnMotionDone).
- Actual per-sub-animation completion signal:
Sequence.update_internal(Animation/Sequence.cs:351-443), whenanimDoneis set true (a frame index walked off the end of the currentAnimSequenceNode's HighFrame/LowFrame), fires:
i.e. only fires the AnimDoneHook while the completing node is a NON-cyclic (link) node — the looping cycle node itself never signals "done" this way (it loops forever viaif (HookObj != null) { var node = AnimList.First; if (!node.Equals(FirstCyclic)) HookObj.add_anim_hook(AnimationHook.AnimDoneHook); }advance_to_next_animation's wraparound toFirstCyclic, see Q4).HookObj.add_anim_hook(defined onPhysicsObj, not read in detail — queues anFPHook/animation hook for later dispatch) is what eventually drivesMotionTableManager.AnimationDone(true)on the consuming side.
Summary of the 3-tier queue relationship
Sequence.AnimList (LinkedList<AnimSequenceNode>) — the actual playable clips, link+cyclic
↑ pushed onto tail by add_motion() during GetObjectSequence
MotionTableManager.PendingAnimations (LinkedList<AnimNode>) — motion-id + sub-clip COUNT bookkeeping
↑ pushed by MotionTableManager.add_to_queue(), consumed on AnimDoneHook / by polling
MotionInterp.PendingMotions (LinkedList<MotionNode>) — high-level motion-command queue (1:1 per DoInterpretedMotion/Stop call)
↑ pushed by MotionInterp.add_to_queue(), popped 1-for-1 whenever a PendingAnimations
entry at Layer 2 fully completes (positionally FIFO, NOT id-matched)
Q4 — CYCLE SWAP mechanics: does frame index carry over, restart, or transition via link?
Answer: transitions via link animation; NEVER blends frame index/phase between cycles; the incoming cycle always restarts its own frame counter from its own starting frame.
Sequence.append_animation (Sequence.cs:203-216)
public void append_animation(AnimData animData)
{
var node = new AnimSequenceNode(animData);
if (!node.has_anim()) return;
AnimList.AddLast(node);
FirstCyclic = AnimList.Last; // <-- EVERY append moves the "first cyclic" marker to the newest tail node
if (CurrAnim == null)
{
CurrAnim = AnimList.First;
FrameNumber = CurrAnim.Value.get_starting_frame();
}
}
Each add_motion call in GetObjectSequence does one append_animation PER sub-Anim in the MotionData.Anims list (MotionTable.cs:358-370, add_motion). So for a Walk→Run swap the append order is: [any remaining not-yet-pruned Walk-cycle tail] → link anim clip(s) → new Run cycle clip(s). FirstCyclic ends up pointing at the LAST-appended node, i.e. the start of the freshly-appended Run cycle segment — this is the "loop point": once the sequence plays past the tail, advance_to_next_animation wraps back to FirstCyclic, not to AnimList.First.
Sequence.remove_cyclic_anims (Sequence.cs:303-322) — called BEFORE the new link+cycle are appended
public void remove_cyclic_anims()
{
var node = FirstCyclic;
while (node != null)
{
if (CurrAnim.Equals(node))
{
CurrAnim = node.Previous;
if (CurrAnim != null) FrameNumber = CurrAnim.Value.get_ending_frame();
else FrameNumber = 0.0f;
}
var next = node.Next;
AnimList.Remove(node.Value);
node = next;
}
FirstCyclic = AnimList.Last;
}
This walks from the OLD FirstCyclic (the start of the previously-active loop segment) to the tail, removing every node in that range. If the currently-playing node (CurrAnim) happens to BE the old cyclic loop node being removed, CurrAnim is rewound to node.Previous with FrameNumber snapped to that previous node's get_ending_frame() — i.e. if you were mid-loop when the swap request came in, the in-flight loop iteration is truncated at its END boundary (not its current mid-loop position) and the freshly-appended link/cycle nodes play from there. In practice this means: any partially-played Walk loop iteration doesn't get interrupted mid-stride — the current node finishes to its designated "ending frame" (for forward playback, HighFrame + 1 - EPSILON, AnimSequenceNode.cs:31-37), THEN the link animation begins from ITS OWN starting frame (LowFrame for forward playback, AnimSequenceNode.cs:72-78). There is no frame-phase carry-over from Walk into the link or from the link into Run — every AnimSequenceNode transition resets frameNum to that node's get_starting_frame() inside advance_to_next_animation (Sequence.cs:145-201, specifically line 165 frameNum = currAnim.get_starting_frame(); for forward playback, or line 191 frameNum = currAnim.get_ending_frame(); for the reverse-playback branch).
advance_to_next_animation (Sequence.cs:145-201) — the actual node-to-node walk
Forward-playback branch (timeElapsed >= 0.0f):
// subtract the outgoing node's pos-frame delta from the running offset frame (undo its contribution)
if (frame != null && currAnim.Framerate < 0.0f) { frame.Subtract(currAnim.get_pos_frame((int)frameNum)); apply_physics(...); }
animNode = animNode.Next ?? FirstCyclic; // <-- walk forward one node, OR wrap to FirstCyclic if at tail
currAnim = animNode.Value;
frameNum = currAnim.get_starting_frame(); // <-- ALWAYS restart at the node's own starting frame — never carries phase
if (frame != null && currAnim.Framerate > 0.0f) { frame = AFrame.Combine(frame, currAnim.get_pos_frame((int)frameNum)); apply_physics(...); }
So the answer is explicit: frame index restarts per-node (each AnimSequenceNode — link clip or cycle clip — always begins at its own LowFrame/HighFrame boundary), and the only "carry-over" concept is which NODE plays next, driven by the AnimList linked-list order that GetObjectSequence/add_motion built. The mechanism for reaching Run from Walk is exclusively via inserting the retail motion table's dedicated link animation (MotionTable.get_link, MotionTable.cs:395-426) between them — never a numeric blend/crossfade of frame data. Sequence.apply_physics (Sequence.cs:221-230) applies Velocity/Omega translation+rotation per-quantum on top of whatever pos-frame deltas the current clip has (see Q5), so even the perceived motion "smoothness" across the swap comes only from the animation authoring of the link clip, not from any interpolation logic in Sequence/MotionTable code.
apricot() (Sequence.cs:232-243) — garbage-collects consumed-but-still-present nodes
public void apricot()
{
var node = AnimList.First;
while (!node.Equals(CurrAnim))
{
if (node.Equals(FirstCyclic)) break;
AnimList.Remove(node);
node = AnimList.First;
}
}
Called every Sequence.Update tick (Sequence.cs:132-143, right after update_internal) — trims fully-played-past nodes off the FRONT of AnimList up to (but never past) FirstCyclic, keeping the linked list from growing unbounded as animations complete. (Yes, the function name really is apricot in ACE's port — presumably a literal/garbled decompiled symbol name; the CLAUDE.md workflow note about verifying against named-retail symbols applies here if a real name needs to be recovered.)
Q5 — POSITION DRIVE between inbound packets: what advances position?
Both, combined additively in one offset frame, gated by ground contact. Sequence order (PhysicsObj.UpdatePositionInternal, PhysicsObj.cs:1862-1879):
public void UpdatePositionInternal(double quantum, ref AFrame newFrame)
{
var offsetFrame = new AFrame();
if (!State.HasFlag(PhysicsState.Hidden))
{
if (PartArray != null) PartArray.Update(quantum, ref offsetFrame); // 1. animation-driven delta
if (TransientState.HasFlag(TransientStateFlags.OnWalkable))
offsetFrame.Origin *= Scale; // 2. scale only applied while grounded
else
offsetFrame.Origin *= 0.0f; // 3. AIRBORNE: animation displacement is ZEROED OUT
}
if (PositionManager != null)
PositionManager.AdjustOffset(offsetFrame, quantum); // 4. network-correction nudge added on top
newFrame = AFrame.Combine(Position.Frame, offsetFrame);
...
}
Step 1 — PartArray.Update → Sequence.Update (PartArray.cs:589-592, Sequence.cs:132-143):
public void Update(float quantum, ref AFrame offsetFrame)
{
if (AnimList.First != null)
{
update_internal(quantum, ref CurrAnim, ref FrameNumber, ref offsetFrame);
apricot();
}
else if (offsetFrame != null)
apply_physics(offsetFrame, quantum, quantum); // no anims queued: pure Velocity/Omega integration
}
Inside update_internal (Sequence.cs:351-443), for every whole frame boundary crossed this tick:
if (currAnim.Anim.PosFrames != null)
frame = AFrame.Combine(frame, currAnim.get_pos_frame(lastFrame)); // per-frame authored translation/rotation delta (baked into the .anim asset — "embedded per-frame deltas")
if (Math.Abs(framerate) > PhysicsGlobals.EPSILON)
apply_physics(frame, 1.0f / framerate, timeElapsed); // Sequence.Velocity * quantum + Omega rotate (the "velocity from interpreted motion state" contribution)
So within a single Sequence.Update call, BOTH sources are combined: (a) any authored PosFrames deltas baked directly into the .anim asset for that specific frame (this is what CLAUDE.md's "per-frame deltas embedded in the animation" refers to), combined via AFrame.Combine, AND (b) Sequence.apply_physics (Sequence.cs:221-230) which does frame.Origin += Velocity * quantum; frame.Rotate(Omega * quantum); where Velocity/Omega are the CURRENT node's MotionData.Velocity/.Omega (set by add_motion's sequence.SetVelocity(motionData.Velocity * speed) — i.e. this IS "velocity from the interpreted motion state", since speed traces back to InterpretedState.ForwardSpeed/get_state_velocity()-derived values). Which dominates depends entirely on the specific .anim asset — most locomotion cycles (walk/run) in retail author their forward displacement via Velocity (constant per-cycle linear speed) rather than per-frame PosFrames, while special motions (jump arcs, some emotes) can carry PosFrames deltas. ACE's code treats them uniformly and additively — there's no priority/exclusivity logic; whichever the .anim DAT asset defines gets applied.
Step 3 is critical: offsetFrame.Origin *= 0.0f when NOT OnWalkable — while airborne, animation-driven translation is completely discarded; only PositionManager.AdjustOffset (network correction) and gravity/velocity integration elsewhere (UpdatePhysicsInternal, PhysicsObj.cs:1832-1860, a SEPARATE code path used for free-flight/projectile-style objects, not locomotion) contribute. This means grounded creature locomotion is animation-driven displacement (walk/run cycle velocity), NOT physics-integrator velocity — a key retail-faithfulness point: ground movement speed is whatever the .anim asset's baked Velocity says for that cycle at that speed multiplier, not a free-form physics velocity vector.
Step 4 — PositionManager.AdjustOffset (PositionManager.cs:20-28) chains InterpolationManager.adjust_offset + StickyManager.adjust_offset + ConstraintManager.adjust_offset onto the SAME offsetFrame that Step 1 already populated — i.e. network position-correction is an ADDITIVE nudge layered on top of animation-driven movement in the same tick, not a replacement. See Q6 for its exact behavior.
Q6 — CORRECTION: how do inbound position updates fix accumulated error?
Entry point for "another entity's authoritative position arrived": PhysicsObj.MoveOrTeleport(Position pos, int timestamp, bool contact, Vector3 velocity) (PhysicsObj.cs:905-934):
public bool MoveOrTeleport(Position pos, int timestamp, bool contact, Vector3 velocity)
{
... staleness/sequence-number check against UpdateTimes[4] ...
if (CurCell == null || newer_event(PhysicsTimeStamp.Teleport, timestamp))
{
teleport_hook(true);
SetPosition(new SetPosition(pos, SetPositionFlags.Teleport | SetPositionFlags.DontCreateCells)); // HARD SNAP — no blending at all
return true;
}
else
{
if (!contact) return false;
if (PlayerDistance < 96.0f)
InterpolateTo(pos, IsMovingTo()); // <-- SOFT correction path
else
{
PositionManager?.StopInterpolating();
SetPositionSimple(pos, true); // far away: just snap, no interpolation needed (nobody's looking closely)
}
}
return true;
}
96.0f (units, ~yards) is the visibility-distance cutoff deciding hard-snap vs. soft-interpolate.
PositionManager.InterpolateTo (PositionManager.cs:55-61) lazily creates an InterpolationManager and calls InterpolationManager.InterpolateTo(position, keepHeading).
InterpolationManager.InterpolateTo (InterpolationManager.cs:36-84)
var dest = (queue has a pending PositionType tail node) ? that node's position : PhysicsObj.Position;
var dist = dest.Distance(position);
if (PhysicsObj.GetAutonomyBlipDistance() >= dist) // "small enough error to smooth, not blip"
{
if (PhysicsObj.Position.Distance(position) > 0.05f) // still meaningfully off from CURRENT actual position
{
// dedupe: drop trailing queued nodes that are already close (<0.05) to the new target
while (queue.Count > 0 && last-is-PositionType-and-within-0.05) queue.RemoveLast();
while (queue.Count >= 20) queue.RemoveFirst(); // cap queue depth at 20
enqueue new PositionType node (optionally overriding heading to current heading if keepHeading)
}
else
{
if (!keepHeading) PhysicsObj.set_heading(position.Frame.get_heading(), true);
StopInterpolating(); // close enough already — snap heading only, clear queue
}
}
else
{
// error too large to smooth ("blip") — enqueue anyway but arm NodeFailCounter = 4
enqueue new PositionType node; NodeFailCounter = 4;
}
Per-tick blending: InterpolationManager.adjust_offset(AFrame frame, double quantum) (InterpolationManager.cs:199-258) — called from PositionManager.AdjustOffset every UpdatePositionInternal tick
if (queue.Count == 0 || PhysicsObj == null || !TransientState.Contact) return; // only corrects while grounded
var first = queue.First.Value;
if (first.Type is Jump or Velocity) return; // those are handled in UseTime(), not here
var dist = PhysicsObj.Position.Distance(first.Position);
if (dist < 0.05f) { NodeCompleted(true); return; } // SNAP-DONE THRESHOLD: 0.05 units
var maxSpeed = minterp.get_adjusted_max_speed() * 2.0f; // (UseAdjustedSpeed==true by default) — 2x the entity's normal run speed
if (maxSpeed < EPSILON) maxSpeed = MaxInterpolatedVelocity; // fallback constant 7.5f
var delta = OriginalDistance - dist;
ProgressQuantum += quantum; FrameCounter++;
if (FrameCounter < 5 || (sticky-object-attached || (delta > EPSILON && delta/ProgressQuantum/maxSpeed >= 0.3f)))
{
// "making good enough progress" — keep smoothing
if (FrameCounter >= 5) { FrameCounter = 0; ProgressQuantum = 0; OriginalDistance = dist; } // reset the progress-rate tracking window every 5 ticks
var offset = first.Position.Subtract(PhysicsObj.Position);
var maxQuantum = maxSpeed * quantum; // this tick's max allowed correction distance
var distance = offset.Origin.Length();
if (distance <= 0.05f) NodeCompleted(true);
if (distance > maxQuantum) offset.Origin *= maxQuantum / distance; // CLAMP correction speed to maxSpeed*2
if (KeepHeading) offset.set_heading(0.0f);
frame = offset; // <-- this IS the offsetFrame passed up into UpdatePositionInternal — added on top of animation displacement
return;
}
NodeFailCounter++;
NodeCompleted(false); // giving up smoothing this node — falls through to UseTime()'s blip/snap logic next tick
Constants (verbatim, InterpolationManager.cs:18-19):
LargeDistance = 999999.0f
MaxInterpolatedVelocity = 7.5f
UseAdjustedSpeed = true (static, defaults on)
Snap/completion threshold: dist < 0.05f appears three times (queue-dedupe threshold at enqueue time, per-tick node-completion check, and the "close enough, just correct heading and stop" branch in InterpolateTo) — this is retail's canonical "close enough, stop interpolating" epsilon for position correction, distinct from PhysicsGlobals.EPSILON (0.0002f) which is the general floating-point/animation epsilon.
Progress-rate abandonment rule: if less than 30% of the max-possible correction speed's worth of distance was closed over the last 5-tick window (delta / ProgressQuantum / maxSpeed >= 0.3f failing), NodeFailCounter increments; once NodeFailCounter > 3 the UseTime() method (InterpolationManager.cs:142-197) takes over and does a hard SetPositionSimple snap (with fallback velocity-carry logic scanning back through the queue for the last PositionType node) instead of continuing to smooth — this is the "blip" behavior (a visible teleport-style correction) that happens when an entity has drifted too far/too fast for smooth catch-up.
NodeCompleted(bool success) (InterpolationManager.cs:91-126) pops the front queue node, resets FrameCounter/ProgressQuantum, and recomputes OriginalDistance against the NEXT queued node (or LargeDistance if the queue is now empty) — this baseline is used by the 30%-progress-rate check on the following node.
No InterpolationNode "Velocity" playback happens inside adjust_offset — VelocityType/JumpType queue nodes are explicitly skipped there (InterpolationManager.cs:205) and are instead consumed synchronously and immediately inside UseTime() (InterpolationManager.cs:185-196, case VelocityType: PhysicsObj.set_velocity(first.Velocity, true); NodeCompleted(true); break;), i.e. velocity-hint queue entries bypass smoothing entirely and are applied as an immediate physics-velocity set.
Q7 — STOP: motion → Ready/stand
Two distinct stop mechanisms in MotionInterp
(a) StopInterpretedMotion(uint motion, MovementParameters) (MotionInterp.cs:329-365) — stop ONE specific ongoing motion (e.g. release the forward key while still turning):
if (contact_allows_move(motion))
{
if (StandingLongJump && motion in {WalkForward, RunForward, SideStepRight})
InterpretedState.RemoveMotion(motion); // charging-jump special case: state only, no anim change
else
{
result = PhysicsObj.StopInterpretedMotion(motion, movementParams); // -> PartArray -> MotionTableManager.PerformMovement(StopInterpretedCommand)
if (result == WeenieError.None)
{
add_to_queue(movementParams.ContextID, (uint)MotionCommand.Ready, WeenieError.None); // <-- queues a "Ready" MotionNode, NOT the stopped motion's id
if (movementParams.ModifyInterpretedState) InterpretedState.RemoveMotion(motion);
}
}
}
else { if (ModifyInterpretedState) InterpretedState.RemoveMotion(motion); } // airborne: state only
if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations();
MotionTableManager.PerformMovement's StopInterpretedCommand case (MotionTableManager.cs:130-135):
if (!Table.StopObjectMotion(mvs.Motion, mvs.Params.Speed, State, seq, ref counter)) return NoMtableData;
add_to_queue((uint)MotionCommand.Ready, counter, seq);
MotionTable.StopObjectMotion → StopSequenceMotion (MotionTable.cs:315-356):
if ((motion & CommandMask.SubState) != 0 && currState.Substate == motion)
{
uint style; StyleDefaults.TryGetValue(currState.Style, out style);
GetObjectSequence(style, currState, sequence, 1.0f, ref numAnims, true); // <-- re-enters the SAME cycle-swap machinery as Q2/Q4, targeting the STYLE's default substate (e.g. NonCombat's Ready)
return true;
}
if ((motion & CommandMask.Modifier) == 0) return false;
// else: find + subtract the matching Modifier motion's physics contribution and remove it from currState.Modifiers
So stopping Walk/Run is implemented as "transition to the style's DEFAULT substate" — i.e. the exact same link-animation-append machinery from Q2/Q4, just targeting Ready/idle instead of another locomotion cycle. This means a stop-from-run gets its own dedicated STOP/idle transition link animation (looked up via the same get_link(style, fromSubstate, fromSpeed, StyleDefaults[style], 1.0f) call inside the re-entered GetObjectSequence), not an instant cut to a standing pose.
(b) StopCompletely() (MotionInterp.cs:301-327) — full stop, e.g. server-forced idle:
PhysicsObj.cancel_moveto();
var jump = motion_allows_jump(InterpretedState.ForwardCommand);
RawState.ForwardCommand = Ready; RawState.ForwardSpeed = 1.0f; RawState.SideStepCommand = 0; RawState.TurnCommand = 0;
InterpretedState.ForwardCommand = Ready; InterpretedState.ForwardSpeed = 1.0f; InterpretedState.SideStepCommand = 0; InterpretedState.TurnCommand = 0;
PhysicsObj.StopCompletely_Internal(); // -> PartArray.StopCompletelyInternal -> MotionTableManager.PerformMovement(StopCompletely)
add_to_queue(0, (uint)MotionCommand.Ready, jump);
if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations();
MotionTable.StopObjectCompletely (MotionTable.cs:293-313):
// first stop every active Modifier motion (in stack order, e.g. crouch-while-moving compound states)
while (currState.Modifiers.First != null)
StopSequenceMotion(modifier.ID, modifier.SpeedMod, currState, sequence, ref numAnims);
// then stop the current Substate (Walk/Run/etc.) itself
StopSequenceMotion(currState.Substate, currState.SubstateMod, currState, sequence, ref numAnims);
Velocity zeroing
Velocity/Omega on the Sequence are NOT explicitly zeroed by the stop call itself — they get overwritten naturally the next time add_motion runs during the GetObjectSequence(style, ..., true) re-entry inside StopSequenceMotion (line 327): sequence.SetVelocity(motionData.Velocity * speed) where motionData is now the Ready/idle cycle's MotionData (MotionTable.cs:362, inside add_motion), and Ready/idle cycles are authored with zero linear Velocity in the DAT motion table (not verified directly here — inferred from the fact that idle poses don't translate the character; flagged as a place to cross-check against docs/research/named-retail/ if exactness matters). Additionally, sequence.clear_physics() (Sequence.cs:256-260, sets Velocity = Omega = Vector3.Zero) is called unconditionally at the TOP of every GetObjectSequence SubState-branch invocation (MotionTable.cs:152 inside the branch reached from StopSequenceMotion's re-entry) — so Sequence.Velocity/Omega ARE explicitly zeroed at the moment of transition, then immediately re-set by the subsequent add_motion calls for the link+Ready-cycle, meaning during the STOP LINK ANIMATION itself, whatever Velocity/Omega that specific link clip's MotionData carries is what plays (some stop/skid animations may carry a decelerating velocity baked in — this is exactly the "residual sliding prevention" mechanism, see below).
Residual-slide prevention
There is no separate "clamp velocity to zero over N frames" logic in this code — the retail approach (as ported here) is: (1) the stop transition ALWAYS goes through get_link(...) to a dedicated stop/deceleration animation clip whose MotionData.Velocity is authored to taper naturally to zero by its final frame (asset-level responsibility, not code-level), and (2) once the sequence reaches the idle/Ready cyclic node, that cycle's MotionData.Velocity should be Vector3.Zero so continued looping produces zero displacement per Q5's apply_physics(frame, quantum, quantum) math. Physical/gravity velocity (PhysicsObj.Velocity, used only by the free-flight UpdatePhysicsInternal path, PhysicsObj.cs:1832-1860) is separately damped via calc_friction(quantum, velocity_mag2) (not read in detail; referenced at PhysicsObj.cs:1849) plus a hard clamp:
if (velocity_mag2 - PhysicsGlobals.SmallVelocitySquared < PhysicsGlobals.EPSILON) Velocity = Vector3.Zero;
SmallVelocity = 0.25f (PhysicsGlobals.cs:34), SmallVelocitySquared = 0.0625f (line 36), EPSILON = 0.0002f (line 9) — i.e. once ground-friction has decayed PhysicsObj.Velocity to within sqrt(0.0625 + 0.0002) ≈ 0.2504 units/sec of zero (this branch is really checking velocity_mag2 <= SmallVelocitySquared + EPSILON), it's hard-snapped to exactly zero. This is the free-body-motion velocity floor, separate from (but complementary to) the animation-cycle-driven Q5 displacement mechanism that governs actual grounded locomotion stop.
RemoveLinkAnimations on cell-exit
Both StopInterpretedMotion and StopCompletely end with if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations(); (PhysicsObj.cs:992-996 → PartArray.HandleEnterWorld → MotionTableManager.HandleEnterWorld (MotionTableManager.cs:103-108) → sequence.remove_all_link_animations() (Sequence.cs:289-301) + drains PendingAnimations via repeated AnimationDone(false) calls). remove_all_link_animations differs from remove_cyclic_anims (Q4) — it strips every node BEFORE FirstCyclic (i.e. the non-looping link/transition segment), snapping CurrAnim straight to FirstCyclic (the cyclic/looping node) if the currently-playing node was one of the removed link nodes. This is the "an object was pulled out of the world mid-transition — skip straight to the loop, don't leave a dangling half-played link clip" cleanup, and per the code it applies specifically when CurCell == null, i.e. anytime the object is not actually placed in the world (in transit between cells, being carried, etc.) — matching the general "no link animations while not resident in a cell" rule already seen at the end of DoInterpretedMotion/StopInterpretedMotion (Q1/Q3).