acdream/docs/research/2026-07-02-inbound-motion-maps/map-ace-port.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

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 — retail CMotionInterp (per-object motion-command interpreter; owns PendingMotions, RawState, InterpretedState)
  • Animation/Sequence.cs — retail CSequence (the actual animation cycle/frame player; owns AnimList, 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-object AnimationCounter/PendingAnimations bookkeeping + redundant-link truncation
  • Managers/MovementManager.cs — top dispatcher owning MotionInterpreter + MoveToManager
  • Managers/InterpolationManager.cs — remote/dead-reckoning position correction (network snap-to-position blending)
  • Managers/PositionManager.cs — thin composite wrapping InterpolationManager + StickyManager + ConstraintManager, called every tick from PhysicsObj.UpdatePositionInternal
  • PartArray.cs — glue between PhysicsObj and Sequence/MotionTableManager (DoInterpretedMotion, Update, StopInterpretedMotion)
  • PhysicsObj.cs — top-level per-entity physics object; update_object/UpdateObjectInternal/UpdatePositionInternal drive the whole thing every tick
  • Animation/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":

  1. The locally-controlled-by-network-input path: Player_Tick.OnMoveToState_ClientMethod / OnMoveToState_ServerMethod — this is literally the server acting as the retail client's CMotionInterp would for the connected player, driven by inbound MoveToState (0xF61C-family) packets. This is the best available proxy for "how does an incoming motion-command change drive the interpreter."
  2. 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' UpdatePosition broadcasts, and structurally mirrors what a retail client does when it receives another player's UpdatePosition).

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

  1. ACE.Server/Network/GameAction/Actions/GameActionMoveToState.cs:13 Handle(ClientMessage, Session) — the game-action handler for [GameAction(GameActionType.MoveToState)]. Parses payload into a MoveToState struct (Network/Structure/MoveToState.cs, not read in detail here) and stores session.Player.CurrentMoveToState = moveToState; (line 20).

  2. Line 29: session.Player.OnMoveToState(moveToState); — this is the entry into the physics/animation layer.

  3. Line 36: session.Player.BroadcastMovement(moveToState); (WorldObjects/Player_Networking.cs:309) — re-broadcasts the motion as GameMessageUpdateMotion to other clients (the wire-out side of this, not traced further — out of scope per the ACE-is-server caveat above).

  4. WorldObjects/Player_Tick.cs:176 OnMoveToState(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_formula property + StandingLongJump:
      • OnMoveToState_ServerMethod (retail-authoritative single-shot apply)
      • OnMoveToState_ClientMethod (retail client-style edge-triggered DoMotion/StopMotion calls)
  5. 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) into MotionInterp.RawState in one shot via RawMotionState.SetState (Animation/RawMotionState.cs:117-140), then calls MotionInterp.apply_raw_movement (Animation/MotionInterp.cs:506-523), which re-derives InterpretedState from RawState field-by-field and re-issues DoInterpretedMotion calls (see Q2 below). This is the retail CMotionInterp::apply_raw_movement — full re-derivation every call, not an incremental diff.

  6. OnMoveToState_ClientMethod (Player_Tick.cs:199-270) — an edge-triggered alternative (a client_movement_formula-flagged, non-default variant) that diffs rawState against prevState = LastMoveToState?.RawMotionState and calls PhysicsObj.DoMotion / PhysicsObj.StopMotion only 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 currentParams copy (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() >= 6TooManyActions.
  • Calls DoInterpretedMotion(currentMotion, currentParams) (line 152) — the actual interpreter entry (shared with Path C below).
  • If success and movementParams.ModifyRawState, calls RawState.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:

  1. contact_allows_move(motion) gate (line 57, see below) — blocks ground-locked motions while airborne.
  2. StandingLongJump special case (lines 59-63): if charging a standing long jump and motion is Walk/Run/SideStepRight, just updates InterpretedState without touching the physics animation (charge-jump doesn't reanimate).
  3. Otherwise (line 64-90):
    • Dead motion → 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.DoObjectMotionGetObjectSequence (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), then add_to_queue(movementParams.ContextID, motion, jump_error_code) (line 85) — pushes a MotionNode onto MotionInterp.PendingMotions (see Q3).
    • If movementParams.ModifyInterpretedState, calls InterpretedState.ApplyMotion(motion, movementParams) (line 88) to record the new forward/sidestep/turn/style/action state.
  4. If not contact-allowed (airborne): Action commands fail with YouCantJumpWhileInTheAir (line 94-95); non-action commands are recorded into InterpretedState only (no animation change) with ModifyInterpretedState (line 99-100).
  5. 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 animationget_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):
    PendingMotions.AddLast(new MotionNode(contextID, motion, jumpErrorCode));
    PhysicsObj.IsAnimating = true;
    
    Called from DoInterpretedMotion (line 85), StopCompletely (line 321), StopInterpretedMotion (line 348), and apply_interpreted_movement's turn-release branch (line 495).
  • Popped by: MotionInterp.MotionDone(bool success) (MotionInterp.cs:210-234):
    var 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;
        }
    }
    
    This is called ONLY from PhysicsObj.MotionDone(motion, success)MovementManager.MotionDone(motion, success)MotionInterpreter.MotionDone(success) (note: the motion parameter is dropped/ignored at this layer — it always pops PendingMotions.First, positionally, not by matching motion id).
  • Who calls PhysicsObj.MotionDone: MotionTableManager.AnimationDone (see Layer 2 below) and MotionTableManager.CheckForCompletedMotions.
  • motions_pending() / IsAnimating: MotionInterp.motions_pending() (line 784-787) just checks PendingMotions.Count > 0; the faster PhysicsObj.IsAnimating bool field is kept in sync at every add/remove (see above) as a cached shortcut — the doc comment explicitly says to prefer IsAnimating for perf.
  • HandleExitWorld (MotionInterp.cs:160-173): drains PendingMotions entirely 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 individual Animation sub-clips (as opposed to the higher-level MotionCommand) 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 from PerformMovement (line 127/134/139) with counter = the numAnims computed by MotionTable.GetObjectSequence/StopSequenceMotion/StopObjectCompletely (i.e. the total sub-clip count of link+cycle animations just appended to Sequence.AnimList for this motion). Also called by initialize_state (line 176, entry into default Ready state) with the numAnims from SetDefaultState.
    • Immediately after append: remove_redundant_links(sequence) (line 166) — walks PendingAnimations from the tail backward and, if it finds a duplicate motion id further up the chain whose animations haven't started playing yet, calls trancuate_animation_list to zero out the redundant entries' NumAnims and physically remove the corresponding still-unplayed link animations from Sequence.AnimList via sequence.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).
  • Popped/completed by TWO different drivers:
    1. AnimationDone(bool success) (MotionTableManager.cs:28-61) — driven by actual animation-hook completion signals (see Sequence.update_internal's AnimDoneHook, Q4). Increments AnimationCounter, then while the FRONT node's NumAnims <= AnimationCounter: subtracts entry.NumAnims back out of the counter, removes an Action-head from MotionState if the motion has the Action bit, calls PhysicsObj.MotionDone(motionID, success) (→ pops Layer-1 MotionInterp.PendingMotions, see above), removes the front PendingAnimations node, and fires PhysicsObj.WeenieObj.OnMotionDone(motionID, success) (the weenie/game-object-level callback — e.g. for scripted "on landed" or "on emote finished" logic).
    2. CheckForCompletedMotions() (MotionTableManager.cs:63-85) — a POLLING variant, called from PhysicsObj.CheckForCompletedMotions (PhysicsObj.cs:296-300) → PartArray.CheckForCompletedMotions (PartArray.cs:72-76), itself invoked once per MotionInterp.PerformMovement call (MotionInterp.cs:260, right after PhysicsObj.CheckForCompletedMotions() unconditionally at the end of every PerformMovement). It loops while PendingAnimations.First.Value.NumAnims == 0 (i.e. entries that were ALREADY reduced to zero, either by construction — an empty-cycle motion — or by a prior remove_redundant_links/trancuate_animation_list call), popping them exactly like AnimationDone does (same Action-head removal, PhysicsObj.MotionDone, OnMotionDone).
  • Actual per-sub-animation completion signal: Sequence.update_internal (Animation/Sequence.cs:351-443), when animDone is set true (a frame index walked off the end of the current AnimSequenceNode's HighFrame/LowFrame), fires:
    if (HookObj != null)
    {
        var node = AnimList.First;
        if (!node.Equals(FirstCyclic))
            HookObj.add_anim_hook(AnimationHook.AnimDoneHook);
    }
    
    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 via advance_to_next_animation's wraparound to FirstCyclic, see Q4). HookObj.add_anim_hook (defined on PhysicsObj, not read in detail — queues an FPHook/animation hook for later dispatch) is what eventually drives MotionTableManager.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)

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.

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.UpdateSequence.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 OnWalkablewhile 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_offsetVelocityType/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.StopObjectMotionStopSequenceMotion (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-996PartArray.HandleEnterWorldMotionTableManager.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).