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

548 lines
38 KiB
Markdown

# 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 == 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<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) == 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<AnimNode> 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<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/)