# 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`) — 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 StyleDefaults // style -> default substate Dictionary Cycles // key = (style<<16)|substate -> cycle anim data Dictionary Modifiers // key = (style<<16)|modifierID or just modifierID -> modifier anim data Dictionary> Links // key = (style<<16)|fromSubstate -> { toMotion -> transition MotionData } uint DefaultStyle static ConcurrentDictionary 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 == 0` → `false` (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) != 0` → `currState.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 `DefaultStyle` — `link = 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) != 0` → `currState.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(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) == 0` → `false` (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.EPSILON` → `sequence.multiply_cyclic_animation_framerate (speedMod/substateMod)` (rescale by the RATIO of new to old speed). Else-if `|speedMod| < EPSILON` → `multiply_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. ### `get_link(style, substate, substateSpeed, motion, speed)` — L395-426 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 PendingAnimations ``` `AnimNode { uint Motion; uint NumAnims; }` (trivial struct-like class, `AnimNode.cs`). ### `AnimationDone(bool success)` — L28-61 Called from `PartArray.AnimationDone` ← `PhysicsObj.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 > AnimationCounter` → **break** (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.MotionDone` → `MotionInterpreter.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 != null` → `RemoveFirst()` (dequeue the just-completed entry). - If `PhysicsObj.WeenieObj != null` → `WeenieObj.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.CheckForCompletedMotions` ← `PhysicsObj.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 != 0` → **return** (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 == null` → `WeenieError.NoAnimationTable`. - **`InterpretedCommand`:** `Table.DoObjectMotion(mvs.Motion, State, seq, mvs.Params.Speed, ref counter)`; if it returns `false` → `WeenieError.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 != null` → `Table.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). ### `remove_redundant_links(sequence)` — L179-205 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 `true` → **return** (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). ### `remove_redundant_links_inner(node, sequence, first)` — L207-231 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` 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/)