acdream/docs/research/2026-07-02-r2-motiontable/r2-ace-motiontable.md
Erik dc54a3e41f docs(R2-Q0): motion-table research base + ambiguity pins
- r2-motiontable-decomp.md: 1,603-line verbatim extraction — full
  CMotionTable (GetObjectSequence all 4 class branches, get_link,
  is_allowed, re_modify, StopSequenceMotion, SetDefaultState, wrappers),
  the free functions (add/combine/subtract_motion, change_cycle_speed,
  same_sign), all 16 MotionTableManager members (pending_animations,
  add_to_queue, remove_redundant_links with the 0xb0000000/0x70000000
  block masks, truncate, AnimationDone vs CheckForCompletedMotions,
  PerformMovement with the 0x41000003 stop sentinel), MotionState's
  full modifier-stack/action-FIFO cast, verbatim struct layouts +
  constants table. BN mistypings identified (SurfInfo lookups are
  style_defaults/links hashes).
- r2-ace-motiontable.md: ACE cross-ref with the two-tracker headline
  (MotionTableManager UPSTREAM of MotionInterp — never merged) + 5
  flagged ACE oddities.
- r2-port-plan.md: 17 gaps (H1-H17), keep list, Q0-Q6 commit sequence,
  the MotionDone->R3 boundary contract.
- Q0-pins.md: A1/A2 pinned to ACE's reading (three corroborations;
  cdb confirmation folds into the next live session), A3 outTicks
  decode, A4 ACE-oddity adjudications (the Action-branch double-count
  is an ACE bug — do not copy), A5 Bitfield check at Q2.

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

38 KiB

ACE MotionTable / MotionTableManager port — cross-reference map

Files read in full:

  • references/ACE/Source/ACE.Server/Physics/Animation/MotionTable.cs (615 lines)
  • references/ACE/Source/ACE.Server/Physics/Managers/MotionTableManager.cs (251 lines)
  • references/ACE/Source/ACE.Server/Physics/Animation/AnimNode.cs (16 lines)
  • references/ACE/Source/ACE.DatLoader/Entity/MotionData.cs (34 lines)
  • Cross-refs: PhysicsObj.cs (L296-300, L653-657, L899-902), MovementManager.cs (L118-121), MotionInterp.cs (L210-231, L260), PartArray.cs (L52-56, L72-76, L135, L253-293, L425-435, L577-586), WeenieObject.cs (L256-259)

NOTE (important for the parent report): ACE has TWO parallel "pending motion" trackers that both descend from a PhysicsObj, and they are easy to conflate:

  1. MotionTableManager.PendingAnimations (LinkedList<AnimNode>) — driven by Table.DoObjectMotion/GetObjectSequence (the interpreted-command / high-level motion path). Its AnimationDone/CheckForCompletedMotions are the ones requested for this doc.
  2. MotionInterp.PendingMotions — a separate list inside MotionInterp.cs (not covered file, but referenced at MotionInterp.cs:210-231) that also has a MotionDone(bool success) method. MovementManager.MotionDone calls MotionInterpreter.MotionDone(success), i.e. the raw motion-interp side, NOT MotionTableManager.AnimationDone. MotionTableManager and MotionInterp are wired independently; MotionTableManager lives under PartArray, while MotionInterp lives under MovementManager. Both ultimately get fed by PhysicsObj.MotionDone but through different owner objects (PartArray.MotionTableManager vs MovementManager.MotionInterpreter), and only one of the two is authoritative depending on whether the object is server-simulated purely by object-broadcast Motion commands (uses MotionTableManager via PartArray) vs. by the mover's own physics timestep + MoveToManager (uses MotionInterp). Do not assume MotionTableManager.AnimationDone is "the" MotionDone path for player-driven movement — cross-check which owner actually gets ticked for the acdream use case before porting.

MotionTable.cs (ACE.Server.Physics.Animation.MotionTable)

Fields

uint ID
Dictionary<uint,uint> StyleDefaults              // style -> default substate
Dictionary<uint,MotionData> Cycles               // key = (style<<16)|substate  -> cycle anim data
Dictionary<uint,MotionData> Modifiers            // key = (style<<16)|modifierID or just modifierID -> modifier anim data
Dictionary<uint,Dictionary<uint,MotionData>> Links   // key = (style<<16)|fromSubstate -> { toMotion -> transition MotionData }
uint DefaultStyle
static ConcurrentDictionary<uint,float> WalkSpeed / RunSpeed / TurnSpeed   // per-motionTableID cache

Constructed either empty (Allocator()) or from the dat-loaded DatLoader.FileTypes.MotionTable (straight field copy, no transform — L40-48).

DoObjectMotion(motion, currState, sequence, speedMod, ref numAnims)

Trivial forwarder: return GetObjectSequence(motion, currState, sequence, speedMod, ref numAnims, stopModifiers: false); (L55-58)

GetObjectSequence(motion, currState, sequence, speedMod, ref numAnims, stopModifiers) — L60-257

The core state-transition dispatcher. numAnims is reset to 0 up front.

Early-out: if currState.Style == 0 || currState.Substate == 0false (uninitialized state).

Looks up substate = StyleDefaults[currState.Style] (the default substate for the current style, e.g. "Standing" under style "NonCombat").

Guard (L73-74): if motion == substate (i.e. caller is asking to enter the style's own default substate) AND !stopModifiers AND current substate already has the Modifier bit set (CommandMask.Modifier) → return true immediately (no-op — already effectively there via a modifier).

Then four command-mask branches, checked with plain OR semantics — NOT else-if. Each branch can independently fire and return true from inside if it succeeds; falling out of a branch (motionData null, is_allowed false, etc.) falls through to the next mask check.

CommandMask.Style branch (L76-120) — motion requests a stance/style change (e.g. switch combat stance):

  • If currState.Style == motion already → true (no-op).
  • If substate != currState.Substate: compute motionData = get_link(currState.Style, currState.Substate, currState.SubstateMod, substate, speedMod) — the transition INTO the new style's default substate from the current substate.
  • If substate != 0: look up cycles = Cycles[(motion<<16)|substate] — the cycle anim for the new style's default substate.
    • If found:
      • (cycles.Bitfield & 1) != 0currState.clear_modifiers() (bit 1 = "clears modifiers on entry", e.g. entering a stance that can't carry over swimming/etc modifiers).
      • link = get_link(currState.Style, substate, currState.SubstateMod, motion, speedMod) — transition from (old style, NEW style's default substate) to the target style itself.
      • Fallback chain if link == null and currState.Style != motion: re-resolve via DefaultStylelink = get_link(currState.Style, substate, 1.0f, DefaultStyle, 1.0f), then motionData_ = get_link(DefaultStyle, StyleDefaults[DefaultStyle], 1.0f, motion, 1.0f). This is the "no direct link exists, route through the global default style" path (e.g. Standing).
      • sequence.clear_physics(); sequence.remove_cyclic_anims(); — wipe outstanding velocity/ omega contributions and any looping cycle anims before splicing in the new chain.
      • Append in strict order: add_motion(motionData), add_motion(link), add_motion(motionData_), add_motion(cycles) — i.e. [transition-to-default-substate] → [transition-to-target-style] → [fallback-via-default-style, usually null] → [new cycle]. Each add_motion no-ops silently on null motionData.
      • Commits state: currState.Substate = substate; currState.Style = motion; currState.SubstateMod = speedMod;
      • re_modify(sequence, currState) — replays any still-active modifiers (see below) on top of the newly spliced sequence.
      • numAnims = sum of each non-null MotionData's Anims.Count, minus 1 (the -1 is because the queue entry itself represents completion of ONE playthrough of the LAST appended motion's cycle, not a raw anim count — cross-check against add_to_queue in MotionTableManager, which stores this count as AnimNode.NumAnims, i.e. the number of "hook" firings, one per queued sub-anim minus a terminal borrow).
      • Returns true.

CommandMask.SubState branch (L121-188) — motion requests a substate change within the current style (e.g. Standing → Crouching), OR a coalesced-speed no-op update to the CURRENT cycle:

  • motionID = motion & 0xFFFFFF (strip command-class bits).
  • motionData = Cycles[(currState.Style<<16)|motionID], falling back to Cycles[(DefaultStyle<<16)|motionID] if the current style doesn't define that substate.
  • If found and is_allowed(motion, motionData, currState) (see below):
    • Speed-only fast path (L132-139): if motion == currState.Substate AND sequence.HasAnims() AND Math.Sign(speedMod) == Math.Sign(currState.SubstateMod) — i.e. caller is re-issuing the SAME substate at a new speed, same direction of travel (fwd vs reverse) — then: change_cycle_speed (rescale the already-playing cyclic anim's framerate), subtract_motion (remove the old velocity/omega contribution at the old speed), combine_motion (add back at the new speed), update currState.SubstateMod = speedMod, return true. No new anims queued, no sequence splice — this is the "already running, just changing speed" branch (e.g. walk→run without breaking stride).
    • Otherwise (new substate, or sign flip == direction reversal):
      • (motionData.Bitfield & 1) != 0currState.clear_modifiers().
      • link = get_link(currState.Style, currState.Substate, currState.SubstateMod, motion, speedMod) — direct transition from current substate to target substate.
      • Fallback (L145-151): if link == null OR the sign of speedMod differs from currState.SubstateMod (direction reversal, e.g. forward↔backward) — route through the style's default substate: link = get_link(currState.Style, currState.Substate, currState.SubstateMod, defaultMotion, 1.0f) (transition out to default), motionData_ = get_link(currState.Style, defaultMotion, 1.0f, motion, speedMod) (transition from default into target).
      • sequence.clear_physics(); sequence.remove_cyclic_anims();
      • If motionData_ != null (fallback path taken): append link at currState.SubstateMod, then motionData_ at speedMod.
      • Else (direct link path): newSpeedMod = speedMod, but flip sign if currState.SubstateMod < 0 && speedMod > 0 (i.e. reversing FROM negative — asymmetric handling, only corrects one direction of sign mismatch, not both) — append link at newSpeedMod.
      • Always append motionData (the new cycle) at speedMod.
      • Modifier carry-over (L170-176): if the OLD substate differs from the new motion AND the old substate had the Modifier bit set, AND the old substate isn't just the style's default motion, re-add it as an active modifier via currState.add_modifier_no_check (i.e. modifiers riding on a substate survive a substate change unless they equal the new target or the style default).
      • Commit currState.SubstateMod = speedMod; currState.Substate = motion;, then re_modify.
      • numAnims = motionData.Anims.Count + link.Anims.Count + motionData_.Anims.Count - 1 (nulls treated as 0).
      • Returns true.

CommandMask.Action branch (L189-233) — one-shot action motions (attacks, jumps, etc, things layered on TOP of a cycle without replacing it):

  • cycleKey = (currState.Style<<16)|(currState.Substate & 0xFFFFFF); motionData = Cycles[cycleKey] (the CURRENT cycle, must exist).
  • If found:
    • link = get_link(currState.Style, currState.Substate, currState.SubstateMod, motion, speedMod).
    • If link != null (direct action link exists): currState.add_action(motion, speedMod) (push onto the action stack — see MotionState, not read this pass), clear physics/cyclic, append link at speedMod then re-append motionData (the ORIGINAL cycle, at the OLD currState.SubstateMod — i.e. resume the cycle after the action plays), re_modify, numAnims = link.Anims.Count (note: NOT including motionData's count here — only the action-link portion counts toward completion tracking, the re-appended base cycle is presumably a normal looping anim not subject to the same "done" semantics).
    • Else (no direct action link — fallback via style default, L209-231): motionData = get_link(currState.Style, currState.Substate, currState.SubstateMod, substate, 1.0f) (transition current substate → style's own default substate) as a NEW motionData (shadows the cycle lookup above). If that resolves: link = get_link(currState.Style, substate, 1.0f, motion, speedMod) (default substate → action), and re-fetch cycles = Cycles[cycleKey] (original cycle again). If link != null and cycles found: motionData_ = get_link(currState.Style, substate, 1.0f, currState.Substate, currState.SubstateMod) (default substate → back to original substate, for resuming after). currState.add_action(...), clear physics/cyclic, append in order motionData (→default), link (default→action), motionData_ (default→back), cycles (resume original cycle at currState.SubstateMod). re_modify. numAnims = motionData.Anims.Count + link.Anims.Count + (motionData_ != null ? motionData.Anims.Count : 0)NOTE: this last term reads motionData.Anims.Count again, NOT motionData_.Anims.Count — looks like a copy- paste bug in ACE's port (compare to the Style/SubState branches which correctly sum each distinct MotionData). Flag this explicitly when cross-checking against 2013 retail — likely a genuine ACE divergence, not a retail behavior to replicate.

CommandMask.Modifier branch (L234-255) — continuous modifiers layered on the current cycle (e.g. sneaking, aiming overlay) that don't interrupt it:

  • styleKey = currState.Style<<16; cycles = Cycles[styleKey|(currState.Substate&0xFFFFFF)] (current cycle must exist).
  • If found AND (cycles.Bitfield & 1) == 0 (cycle does NOT forbid modifiers):
    • motionData = Modifiers[styleKey|(motion&0xFFFFFF)], falling back to Modifiers[motion&0xFFFFFF] (style-agnostic modifier) if not found.
    • If found:
      • if (!currState.add_modifier(motion, speedMod)) — if the state rejects adding this modifier (e.g. already present / list full):
        • StopSequenceMotion(motion, 1.0f, currState, sequence, ref numAnims) — force-remove it first, then retry add_modifier. If STILL fails, return false.
      • combine_motion(sequence, motionData, speedMod) — layer the modifier's velocity/omega + anims onto the sequence WITHOUT clearing physics or cyclic anims (additive, unlike the other three branches which splice/replace).
      • Returns true. No numAnims write here — stays whatever it was reset to (0) at entry, i.e. modifiers are not tracked for animation-completion purposes.

Falls through all four branches unmatched → return false.

Get(uint motionTableID) — static factory, L259-264

Directly DatManager.PortalDat.ReadFromDat<FileTypes.MotionTable>(id) wrapped in new MotionTable(...). Comment //return ObjCache.GetMotionTable(mtableID); — retail apparently caches motion tables by ID; ACE re-reads from dat every call (no caching layer here, though the dat reader itself likely caches file reads elsewhere).

SetDefaultState(state, sequence, ref numAnims) — L266-291

Resets to the table's global default (style, substate) pair:

  • defaultSubstate = StyleDefaults[DefaultStyle]; if missing → false.
  • state.clear_modifiers(); state.clear_actions();
  • cycle = (DefaultStyle<<16)|defaultSubstate; motionData = Cycles[cycle]; if missing → false.
  • numAnims = motionData.Anims.Count - 1.
  • state.Style = DefaultStyle; state.Substate = defaultSubstate; state.SubstateMod = 1.0f;
  • sequence.clear_physics(); sequence.clear_animations(); (note: clear_animations, not remove_cyclic_anims — a harder reset, used only here and presumably at spawn/enter-world).
  • add_motion(sequence, motionData, 1.0f).

StopObjectCompletely(currState, sequence, ref numAnims) — L293-313

Iterates and removes ALL active modifiers via StopSequenceMotion (loop drains currState.Modifiers.First until empty — relies on StopSequenceMotion removing the head each call), tracking whether ANY modifier stop succeeded (success). Then stops the current substate itself: StopSequenceMotion(currState.Substate, currState.SubstateMod, ...). Returns true if EITHER the substate-stop succeeded OR any earlier modifier-stop succeeded (success || substateStopSucceeded, though written as an if/else that returns true whenever the final substate-stop call returns non-... — re-read: if (!StopSequenceMotion(...)) return success; else return true; — i.e. final result is true unless the LAST stop call fails, in which case it falls back to whatever success was from the modifier loop).

StopObjectMotion / StopSequenceMotion — L315-356

StopObjectMotion is a trivial forwarder to StopSequenceMotion.

StopSequenceMotion(motion, speed, currState, sequence, ref numAnims):

  • numAnims = 0.
  • SubState case: if (motion & CommandMask.SubState) != 0 && currState.Substate == motion — i.e. caller wants to stop the CURRENT substate → resolve the style's default substate and re-enter it via GetObjectSequence(style, currState, sequence, 1.0f, ref numAnims, stopModifiers: true) (recursion into the main dispatcher with stopModifiers=true, which suppresses the early "already at default via modifier" guard at L73). Returns true unconditionally after this call (return value of the inner GetObjectSequence is discarded).
  • Non-modifier, non-matching-substate case: if (motion & CommandMask.Modifier) == 0false (nothing to stop — motion isn't a substate-stop or a modifier).
  • Modifier case: linear-scan currState.Modifiers linked list for a node whose .ID == motion. On match:
    • key = (currState.Style<<16)|(motion&0xFFFFFF); Modifiers[key], falling back to Modifiers[motion&0xFFFFFF]. If neither resolves → false.
    • subtract_motion(sequence, motionData, modifier.Value.SpeedMod) — reverse the modifier's velocity/omega contribution using its ORIGINAL speed (not the speed parameter passed in — speed param appears unused in this branch; only used implicitly via the SubState-case call above). currState.remove_modifier(modifier). Returns true.
    • No match found after scanning entire list → false.

add_motion / combine_motion / subtract_motion / change_cycle_speed — L358-393

  • add_motion(sequence, motionData, speed): no-op if motionData == null. Otherwise sequence.SetVelocity(motionData.Velocity * speed), sequence.SetOmega(motionData.Omega * speed) (REPLACES, not adds — "Set" not "Combine"), then for each anim in motionData.Anims wraps as new AnimData(anim, speed) and sequence.append_animation(animData).
  • combine_motion(sequence, motionData, speed): no-op if null. Otherwise sequence.CombinePhysics(motionData.Velocity * speed, motionData.Omega * speed) — additive variant used by the Modifier branch (doesn't touch the anim queue at all, only velocity/omega).
  • subtract_motion(sequence, motionData, speed): no-op if null. sequence.subtract_physics (motionData.Velocity * speed, motionData.Omega * speed) — inverse of combine, used when removing a modifier or an old-speed cycle contribution.
  • change_cycle_speed(sequence, motionData, substateMod, speedMod): if |substateMod| > PhysicsGlobals.EPSILONsequence.multiply_cyclic_animation_framerate (speedMod/substateMod) (rescale by the RATIO of new to old speed). Else-if |speedMod| < EPSILONmultiply_cyclic_animation_framerate(0) (freeze the anim — old speed was ~0 so no ratio is definable, new speed is also ~0). Gap: if substateMod ~0 AND speedMod is NON-zero, neither branch fires — no framerate change is applied. Worth checking against retail whether this is a real edge case (resuming a stopped cycle at nonzero speed without a ratio) — could be a silent no-op bug carried from retail or an ACE gap.

Direction-aware transition lookup with two symmetric lookup CHAINS depending on sign of the speeds:

  • If EITHER speed < 0 or substateSpeed < 0 (reverse-direction transition): look up Links[(style<<16)|(motion&0xFFFFFF)] (keyed by DESTINATION motion) then .TryGetValue (substate, ...) (indexed by SOURCE substate) — i.e. reversed key order vs the forward case. If that fails, fall back through StyleDefaults[style] and Links[(style<<16)|(substate&0xFFFFFF)].TryGetValue(defaultMotion, ...).
  • Else (forward / non-negative speeds): look up Links[(style<<16)|(substate&0xFFFFFF)] (keyed by SOURCE substate) then .TryGetValue(motion, ...) (indexed by DESTINATION). Fallback: Links[style<<16] (style-wide, substate-agnostic) → .TryGetValue(motion, ...).
  • Returns null if nothing resolves in either chain. This encodes retail's dat-side Links table having asymmetric (from,to) vs (to,from) storage depending on animation reversibility — reverse playback (e.g. walking backward) reuses the FORWARD anim's link table keyed the other way around rather than storing a mirrored copy.

is_allowed(motion, motionData, state) — L428-438

false if motionData == null. true if (motionData.Bitfield & 2) == 0 (bit 2 = "always allowed" flag) OR motion == state.Substate (re-entering the same substate is always allowed regardless of the bit). Otherwise: only allowed if the CURRENT substate IS the style's own default substate (StyleDefaults[state.Style] == state.Substate) — i.e. bit-2-gated substates can only be entered FROM the style's default/neutral substate, not chained from an arbitrary other substate.

re_modify(sequence, pstate) — L440-458

No-op if pstate.Modifiers.First == null. Otherwise snapshots pstate into a NEW MotionState state = new MotionState(pstate) (deep-ish copy incl. its own Modifiers list), then drains pstate's modifier list one node at a time: pops speedMod/motion off pstate.Modifiers.First, removes the SAME logical entry from BOTH pstate and the snapshot state (odd double-removal — removing from the snapshot copy seems purposeless unless MotionState's copy ctor shares the underlying list nodes, in which case this is defensive against aliasing), then calls GetObjectSequence(motion, pstate, sequence, speedMod, ref numAnims, stopModifiers: false) to RE-APPLY each modifier on top of the now-current (post-transition) pstate. This is how the Style/SubState branches "carry forward" active modifiers across a cycle-changing transition — after splicing the new base cycle in, re_modify walks the still-active modifier list (which at that point is whatever the branch didn't already clear) and re-runs each one through the full dispatcher so its layered anims/physics get re-appended onto the NEW sequence.

Static helpers (L460-613)

  • GetAttackFrames(motionTableId, stance, motion) — dat-lookup passthrough to DatLoader.FileTypes.MotionTable.GetAttackFrames (not in this file). Returns cached emptyList for motionTableId == 0.
  • GetAnimationLength(motionTableId, stance, motion, speed=1) / the 2-motion overload that also takes currentMotion — the latter, if motion has the Style bit set and currentMotion != Ready, first adds the Ready→currentMotion transition length, forces currentMotion = Ready, THEN adds currentMotion→motion. Divides everything by speed.
  • GetCycleLength — dat passthrough / speed.
  • GetRunSpeed(motionTableID) — cached in static RunSpeed dict. Computes via GetMotionData(id, MotionCommand.RunForward)GetAnimDist(motionData).
  • GetTurnSpeed(motionTableID) — cached in static TurnSpeed dict. Math.Abs(GetMotionData(id, MotionCommand.TurnRight).Omega.Z).
  • GetMotionData(motionTableID, motion, currentStyle=null) — resolves currentStyle to motionTable.DefaultStyle if unspecified, strips command bits (motion & 0xFFFFFF), looks up Cycles[(style<<16)|motionID].
  • GetLinkData(motionTableID, motion, currentStyle=null) — looks up Links[(style<<16)|((int)MotionCommand.Ready & 0xFFFF)] (NOTE: masks with 0xFFFF here, NOT 0xFFFFFF like everywhere else in this file — inconsistent mask width, likely harmless since MotionCommand.Ready's low bits fit in 16 bits, but worth flagging as an ACE inconsistency if porting verbatim), then .TryGetValue(motion, ...).
  • GetAnimDist(motionData) — sums frame.Origin across every PosFrames frame of every anim in motionData.Anims (reads each Animation fresh from dat by anim.AnimId), takes .Length() of the summed offset vector, divides by totalFrames, multiplies by motionData.Anims[0].Framerate → "distance per second". Returns 0 if the vector length is 0.
  • HasDefaultScript(motionTableID, motion, currentStyle)GetLinkData then scans every anim's PartFrames[*].Hooks for AnimationHookType.DefaultScript.

MotionTableManager.cs (ACE.Server.Physics.Managers.MotionTableManager) — owned by PartArray

Fields

PhysicsObj PhysicsObj
MotionTable Table
MotionState State
uint AnimationCounter
LinkedList<AnimNode> PendingAnimations

AnimNode { uint Motion; uint NumAnims; } (trivial struct-like class, AnimNode.cs).

AnimationDone(bool success) — L28-61

Called from PartArray.AnimationDonePhysicsObj.Hook_AnimDone() (fired when a per-frame animation HOOK signals completion — the frame-based Hook_AnimDone callback, NOT a polling check). Logic:

  • If PendingAnimations.First == null → no-op (nothing pending).
  • AnimationCounter++ (one hook fired, counts toward the FIRST queued AnimNode's NumAnims threshold).
  • Loop while node != null:
    • entry = node.Value. If entry.NumAnims > AnimationCounterbreak (head entry hasn't accumulated enough hook-fires yet — stop, this increment wasn't enough to finish it).
    • If entry's motion has the Action bit set → State.remove_action_head() (pop the action stack — the completed queue entry was an action, so remove its tracking entry from MotionState).
    • motionID = entry.Motion; PhysicsObj.MotionDone(motionID, success) — fires the completion callback chain (→ MovementManager.MotionDoneMotionInterpreter.MotionDone, see note at top: this is the OTHER pending-motion tracker, decoupled from this one except by sharing the PhysicsObj "owner").
    • AnimationCounter -= entry.NumAnims (consume the threshold — any EXCESS hooks beyond this entry's requirement roll over to satisfy the NEXT queued entry, hence the outer do...while loop can complete MULTIPLE queue entries from ONE AnimationDone call if AnimationCounter still exceeds the next entry's NumAnims).
    • If PendingAnimations.First != nullRemoveFirst() (dequeue the just-completed entry).
    • If PhysicsObj.WeenieObj != nullWeenieObj.OnMotionDone(motionID, success) (separate weenie-level hook, forwards to WorldObject.HandleMotionDone — the game-logic-facing callback, distinct from the physics-level PhysicsObj.MotionDone).
    • node = PendingAnimations.First (re-check for another completable entry).
  • After the loop: if AnimationCounter != 0 && node == null (queue fully drained but counter still has leftover) → reset AnimationCounter = 0 (defensive clamp — discards any leftover hook-credit once nothing remains queued, rather than carrying it forward to a future add_to_queue call).

CheckForCompletedMotions() — L63-85

Called from PartArray.CheckForCompletedMotionsPhysicsObj.CheckForCompletedMotions() — this one is POLLED (search found no evidence of a per-frame automatic caller within these two files; likely ticked once per physics update from PhysicsObj.UpdateObjectInternal or similar, not confirmed in this pass — flag for follow-up if the caller chain matters). Distinct from AnimationDone: this drains any HEAD entries whose NumAnims == 0 already (i.e. entries that never needed any hook fires to complete — e.g. zero-length transitions), NOT counter-driven:

  • Loop while PendingAnimations.First != null:
    • If pendingAnimation.Value.NumAnims != 0return (head isn't a zero-length entry — stop, do NOT touch AnimationCounter, this is purely for immediate 0-anim entries).
    • Otherwise: pop the same way as AnimationDone (Action-bit → RemoveActionHead, PhysicsObj.MotionDone(motionID, true) (always success=true here — no success param on this method), remove from list, WeenieObj.OnMotionDone(motionID, true).
    • Continues looping (could drain several consecutive 0-NumAnims entries in one call).

PerformMovement(mvs, seq) — L116-145

Dispatches on mvs.Type (MovementStruct.Type):

  • Table == nullWeenieError.NoAnimationTable.
  • InterpretedCommand: Table.DoObjectMotion(mvs.Motion, State, seq, mvs.Params.Speed, ref counter); if it returns falseWeenieError.NoMtableData. Else add_to_queue(mvs.Motion, counter, seq), return None.
  • StopInterpretedCommand: Table.StopObjectMotion(mvs.Motion, mvs.Params.Speed, State, seq, ref counter); failure → NoMtableData. Success → add_to_queue((uint)MotionCommand.Ready, counter, seq) (note: queues under MotionCommand.Ready, NOT mvs.Motion — a stop always enqueues completion-tracking keyed to Ready, regardless of what was stopped).
  • StopCompletely: Table.StopObjectCompletely(State, seq, ref counter) (return value ignored), add_to_queue((uint)MotionCommand.Ready, counter, seq), return None unconditionally.
  • default: WeenieError.None (comment // ?? — ACE itself flags this as uncertain; other MovementType values like RawCommand/StopRawCommand fall through here untouched by this manager, presumably handled instead by MotionInterp.PerformMovement).

SetMotionTableID(mtableID)Table = MotionTable.Get(mtableID); return Table != null;

UseTime()CheckForCompletedMotions(); — the manager's per-tick entry point (called

from PartArray.cs:265, itself presumably ticked from PhysicsObj's per-update pass — same follow-up caveat as above).

add_to_queue(motion, num_anims, sequence) — L163-167

PendingAnimations.AddLast(new AnimNode(motion, num_anims)), then immediately remove_redundant_links(sequence) — every enqueue triggers a redundancy pass over the WHOLE list (not just checking the new tail against its immediate predecessor blindly; see below).

initialize_state(sequence) — L169-177

numAnims = 0; if Table != nullTable.SetDefaultState(State, sequence, ref numAnims). Always add_to_queue((uint)MotionCommand.Ready, numAnims, sequence) regardless of whether SetDefaultState succeeded (numAnims stays 0 on failure, so this enqueues a same-tick-complete Ready entry).

Walks PendingAnimations from tail to head (node = PendingAnimations.Last, then node.Previous). For each entry with NumAnims != 0:

  • If the entry's motion does NOT have the SubState bit set, OR it DOES have the Modifier bit set (i.e. it's a Style/Action/Modifier-class entry, not a plain substate cycle): only proceed if it also has the Style bit set (entry.Motion & CommandMask.Style), else return (stop scanning entirely — non-style, non-substate-eligible entries block further redundancy checking). If Style-bit set: call remove_redundant_links_inner(node, sequence, first: true); if it returns truereturn (done, something was truncated).
  • Else (a plain substate-cycle entry): remove_redundant_links_inner(node, sequence, first: false); if true → return.
  • Otherwise continue to node = node.Previous (walk further back toward the head).

Scans BACKWARD from node.Previous looking for an earlier entry with the SAME Motion value:

  • motion = first ? 0x70000000 : 0xB0000000 — these are raw CommandMask-shaped bit patterns used as an early-abort guard (0x70000000 covers Style|SubState|Action bits per typical AC command-mask layout; 0xB0000000 a different combination — exact bit semantics live in CommandMask enum, not read this pass, but functionally: differing abort masks depending on whether we're scanning for a Style-class or SubState-class duplicate).
  • While walking back (prev = prev.Previous each iteration):
    • If prevEntry.Motion == entry.Motion AND (first is true, OR prevEntry.NumAnims != 0) → found a duplicate → trancuate_animation_list(prev, sequence), return true.
    • Else if prevEntry.NumAnims != 0 && (prevEntry.Motion & motion) != 0 → an intervening entry of the "abort mask" class with pending anims blocks the search → return true WITHOUT truncating (stops the outer loop but did nothing — prevents redundancy removal across a still-animating Style/Action boundary).
    • Else continue.
  • If the walk exhausts (prev == null) without matching → return false (caller's outer loop continues scanning further back from the ORIGINAL node).

trancuate_animation_list(node, sequence) — L233-249 (note: "trancuate" — misspelling of

"truncate" preserved verbatim from ACE source, matches the mismatched name used at the call site remove_redundant_links_inner) Walks from PendingAnimations.Last backward toward (but not including) node: sums every visited entry's NumAnims into totalAnims, then zeroes each entry's NumAnims in place (entry.Value.NumAnims = 0 — does NOT remove them from the list, just neuters their completion-tracking contribution). Finally sequence.remove_link_animations(totalAnims) — tells the Sequence to physically drop that many trailing "link" animations from its playback queue (the actual anim-clip splice), while PendingAnimations keeps the now-inert AnimNode entries in place with NumAnims=0 (they'll be silently skipped/instantly-completed by CheckForCompletedMotions's 0-check, or bypass AnimationDone's counter entirely since their threshold is unreachable-but-trivially-satisfied at 0... actually re-check: AnimationDone's break condition is entry.NumAnims > AnimationCounter; with NumAnims == 0 this is never true, so a zeroed entry always immediately qualifies for completion processing on the NEXT AnimationDone call, consistent with CheckForCompletedMotions also draining 0-count heads).


MotionDone hook chain (full call graph, both trackers)

PhysicsObj.Hook_AnimDone()                         [per-frame anim-hook fire from Sequence/AFrame dispatch]
  -> PartArray.AnimationDone(true)
     -> MotionTableManager.AnimationDone(true)      [drains PendingAnimations by counter]
        -> PhysicsObj.MotionDone(motionID, success)
           -> MovementManager.MotionDone(motion, success)
              -> MotionInterpreter.MotionDone(success)   [SEPARATE tracker: MotionInterp.PendingMotions]
        -> PhysicsObj.WeenieObj.OnMotionDone(motionID, success)
           -> WorldObject.HandleMotionDone(motionID, success)   [game-logic level]

PhysicsObj.CheckForCompletedMotions()              [polled, presumably per physics tick]
  -> PartArray.CheckForCompletedMotions()
     -> MotionTableManager.CheckForCompletedMotions()  [drains 0-NumAnims heads only]
        -> (same PhysicsObj.MotionDone / WeenieObj.OnMotionDone chain as above)

MotionInterp.MotionDone(success)  [the OTHER path — NOT reached via MotionTableManager]
  -> pops MotionInterp.PendingMotions.First
  -> if Action bit set: PhysicsObj.unstick_from_object(), InterpretedState.RemoveAction(),
     RawState.RemoveAction()
  -> PhysicsObj.IsAnimating = PendingMotions.Count > 0

Key divergence risk for acdream: MovementManager.MotionDone ALWAYS routes to MotionInterpreter.MotionDone, never to MotionTableManager. The two trackers are fed by DIFFERENT owners (PartArray.MotionTableManager vs MovementManager.MotionInterpreter) and PhysicsObj.MotionDone(motion, success) only calls the MovementManager one (PhysicsObj.cs:899-902). MotionTableManager.AnimationDone/CheckForCompletedMotions DO call PhysicsObj.MotionDone internally (feeding MotionInterp), but nothing in these two files calls BACK from MotionInterp into MotionTableManager — i.e. MotionTableManager is upstream of MotionInterp in the completion-notification chain, not a peer. When porting to acdream's CMotionInterp-based unification (per the D6.2a work already landed), confirm which ACE class maps to which acdream responsibility — likely MotionTableManager ≈ acdream's discrete-command completion queue (attached per-PartArray/per-object), MotionInterp ≈ acdream's CMotionInterp-normalized local-player path (attached per-MovementManager). Do not assume they merge into one queue — ACE keeps them structurally separate even though both trace back to the same PhysicsObj.

Flagged ACE-side oddities (verify against 2013 retail decomp before porting verbatim)

  1. Action-branch numAnims miscalculation (GetObjectSequence L227): fallback Action path sums motionData.Anims.Count TWICE instead of once for motionData and once for motionData_ — looks like a copy-paste error in ACE's port, not necessarily retail-faithful.
  2. change_cycle_speed silent no-op gap (L372-379): when substateMod ≈ 0 AND speedMod is nonzero, neither branch fires — no framerate adjustment applied. Check retail's change_cycle_speed equivalent for a third branch.
  3. GetLinkData mask width inconsistency (L562): uses & 0xFFFF where every other motion-ID mask in this file uses & 0xFFFFFF. Likely harmless (Ready's ID fits in 16 bits) but inconsistent.
  4. StopObjectCompletely return-value semantics (L293-313): returns true unless the FINAL substate-stop call fails, in which case it falls back to whatever success was from the modifier-draining loop — easy to misport if simplified to a single boolean accumulate.
  5. re_modify double-removal from both pstate and a snapshot state (L440-458): only makes sense if MotionState's copy constructor shares the underlying LinkedList<Motion> nodes with the source, which would make the second state.remove_modifier(...) call either redundant or (if lists are NOT shared) load-bearing to unlink from the snapshot's OWN list — not verifiable without reading MotionState.cs's copy ctor (out of scope this pass).

Files NOT read this pass (would need separate grep if pursued)

  • MotionState.cs (referenced constantly: .Modifiers, .add_modifier, .add_modifier_no_check, .remove_modifier, .clear_modifiers, .add_action, .remove_action_head, .clear_actions, copy constructor semantics)
  • MotionInterp.cs full file (only L190-260 read; PendingMotions, enter_default_state, apply_current_movement, get_leave_ground_velocity not traced)
  • Sequence.cs (SetVelocity, SetOmega, CombinePhysics, subtract_physics, append_animation, clear_physics, remove_cyclic_anims, clear_animations, remove_link_animations, multiply_cyclic_animation_framerate, HasAnims, remove_all_link_animations)
  • CommandMask enum exact bit values (Style/SubState/Action/Modifier)
  • WeenieObject.HandleMotionDone in AC.Server (game-logic side, outside Physics/)