- 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>
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:
MotionTableManager.PendingAnimations(LinkedList<AnimNode>) — driven byTable.DoObjectMotion/GetObjectSequence(the interpreted-command / high-level motion path). ItsAnimationDone/CheckForCompletedMotionsare the ones requested for this doc.MotionInterp.PendingMotions— a separate list insideMotionInterp.cs(not covered file, but referenced at MotionInterp.cs:210-231) that also has aMotionDone(bool success)method.MovementManager.MotionDonecallsMotionInterpreter.MotionDone(success), i.e. the raw motion-interp side, NOTMotionTableManager.AnimationDone.MotionTableManagerandMotionInterpare wired independently;MotionTableManagerlives underPartArray, whileMotionInterplives underMovementManager. Both ultimately get fed byPhysicsObj.MotionDonebut through different owner objects (PartArray.MotionTableManagervsMovementManager.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 assumeMotionTableManager.AnimationDoneis "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 == motionalready →true(no-op). - If
substate != currState.Substate: computemotionData = 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 upcycles = 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 == nullandcurrState.Style != motion: re-resolve viaDefaultStyle—link = get_link(currState.Style, substate, 1.0f, DefaultStyle, 1.0f), thenmotionData_ = 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]. Eachadd_motionno-ops silently on nullmotionData. - 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-1is because the queue entry itself represents completion of ONE playthrough of the LAST appended motion's cycle, not a raw anim count — cross-check againstadd_to_queuein MotionTableManager, which stores this count asAnimNode.NumAnims, i.e. the number of "hook" firings, one per queued sub-anim minus a terminal borrow).- Returns
true.
- If found:
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 toCycles[(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.SubstateANDsequence.HasAnims()ANDMath.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), updatecurrState.SubstateMod = speedMod, returntrue. 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 == nullOR the sign ofspeedModdiffers fromcurrState.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): appendlinkatcurrState.SubstateMod, thenmotionData_atspeedMod. - Else (direct link path):
newSpeedMod = speedMod, but flip sign ifcurrState.SubstateMod < 0 && speedMod > 0(i.e. reversing FROM negative — asymmetric handling, only corrects one direction of sign mismatch, not both) — appendlinkatnewSpeedMod. - Always append
motionData(the new cycle) atspeedMod. - Modifier carry-over (L170-176): if the OLD substate differs from the new
motionAND 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 viacurrState.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;, thenre_modify. numAnims = motionData.Anims.Count + link.Anims.Count + motionData_.Anims.Count - 1(nulls treated as 0).- Returns
true.
- Speed-only fast path (L132-139): if
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, appendlinkatspeedModthen re-appendmotionData(the ORIGINAL cycle, at the OLDcurrState.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 NEWmotionData(shadows the cycle lookup above). If that resolves:link = get_link(currState.Style, substate, 1.0f, motion, speedMod)(default substate → action), and re-fetchcycles = Cycles[cycleKey](original cycle again). Iflink != nullandcyclesfound: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 ordermotionData(→default),link(default→action),motionData_(default→back),cycles(resume original cycle atcurrState.SubstateMod).re_modify.numAnims = motionData.Anims.Count + link.Anims.Count + (motionData_ != null ? motionData.Anims.Count : 0)— NOTE: this last term readsmotionData.Anims.Countagain, NOTmotionData_.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 toModifiers[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 retryadd_modifier. If STILL fails, returnfalse.
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, notremove_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 viaGetObjectSequence(style, currState, sequence, 1.0f, ref numAnims, stopModifiers: true)(recursion into the main dispatcher withstopModifiers=true, which suppresses the early "already at default via modifier" guard at L73). Returnstrueunconditionally after this call (return value of the innerGetObjectSequenceis 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.Modifierslinked list for a node whose.ID == motion. On match:key = (currState.Style<<16)|(motion&0xFFFFFF);Modifiers[key], falling back toModifiers[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 thespeedparameter passed in —speedparam appears unused in this branch; only used implicitly via the SubState-case call above).currState.remove_modifier(modifier). Returnstrue.- 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 ifmotionData == null. Otherwisesequence.SetVelocity(motionData.Velocity * speed),sequence.SetOmega(motionData.Omega * speed)(REPLACES, not adds — "Set" not "Combine"), then for each anim inmotionData.Animswraps asnew AnimData(anim, speed)andsequence.append_animation(animData).combine_motion(sequence, motionData, speed): no-op if null. Otherwisesequence.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: ifsubstateMod~0 ANDspeedModis 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 < 0orsubstateSpeed < 0(reverse-direction transition): look upLinks[(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 throughStyleDefaults[style]andLinks[(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
nullif 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 toDatLoader.FileTypes.MotionTable.GetAttackFrames(not in this file). Returns cachedemptyListformotionTableId == 0.GetAnimationLength(motionTableId, stance, motion, speed=1)/ the 2-motion overload that also takescurrentMotion— the latter, ifmotionhas the Style bit set andcurrentMotion != Ready, first adds the Ready→currentMotion transition length, forcescurrentMotion = Ready, THEN addscurrentMotion→motion. Divides everything byspeed.GetCycleLength— dat passthrough / speed.GetRunSpeed(motionTableID)— cached in staticRunSpeeddict. Computes viaGetMotionData(id, MotionCommand.RunForward)→GetAnimDist(motionData).GetTurnSpeed(motionTableID)— cached in staticTurnSpeeddict.Math.Abs(GetMotionData(id, MotionCommand.TurnRight).Omega.Z).GetMotionData(motionTableID, motion, currentStyle=null)— resolvescurrentStyletomotionTable.DefaultStyleif unspecified, strips command bits (motion & 0xFFFFFF), looks upCycles[(style<<16)|motionID].GetLinkData(motionTableID, motion, currentStyle=null)— looks upLinks[(style<<16)|((int)MotionCommand.Ready & 0xFFFF)](NOTE: masks with0xFFFFhere, NOT0xFFFFFFlike everywhere else in this file — inconsistent mask width, likely harmless sinceMotionCommand.Ready's low bits fit in 16 bits, but worth flagging as an ACE inconsistency if porting verbatim), then.TryGetValue(motion, ...).GetAnimDist(motionData)— sumsframe.Originacross everyPosFramesframe of every anim inmotionData.Anims(reads eachAnimationfresh from dat byanim.AnimId), takes.Length()of the summed offset vector, divides bytotalFrames, multiplies bymotionData.Anims[0].Framerate→ "distance per second". Returns 0 if the vector length is 0.HasDefaultScript(motionTableID, motion, currentStyle)—GetLinkDatathen scans every anim'sPartFrames[*].HooksforAnimationHookType.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 queuedAnimNode'sNumAnimsthreshold).- Loop while
node != null:entry = node.Value. Ifentry.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 fromMotionState). 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 thePhysicsObj"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 outerdo...whileloop can complete MULTIPLE queue entries from ONEAnimationDonecall ifAnimationCounterstill exceeds the next entry'sNumAnims).- If
PendingAnimations.First != null→RemoveFirst()(dequeue the just-completed entry). - If
PhysicsObj.WeenieObj != null→WeenieObj.OnMotionDone(motionID, success)(separate weenie-level hook, forwards toWorldObject.HandleMotionDone— the game-logic-facing callback, distinct from the physics-levelPhysicsObj.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) → resetAnimationCounter = 0(defensive clamp — discards any leftover hook-credit once nothing remains queued, rather than carrying it forward to a futureadd_to_queuecall).
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 touchAnimationCounter, this is purely for immediate 0-anim entries). - Otherwise: pop the same way as
AnimationDone(Action-bit →RemoveActionHead,PhysicsObj.MotionDone(motionID, true)(alwayssuccess=truehere — nosuccessparam on this method), remove from list,WeenieObj.OnMotionDone(motionID, true). - Continues looping (could drain several consecutive 0-
NumAnimsentries in one call).
- If
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 returnsfalse→WeenieError.NoMtableData. Elseadd_to_queue(mvs.Motion, counter, seq), returnNone.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 underMotionCommand.Ready, NOTmvs.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), returnNoneunconditionally.- default:
WeenieError.None(comment// ??— ACE itself flags this as uncertain; otherMovementTypevalues likeRawCommand/StopRawCommandfall through here untouched by this manager, presumably handled instead byMotionInterp.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: callremove_redundant_links_inner(node, sequence, first: true); if it returnstrue→ return (done, something was truncated). - Else (a plain substate-cycle entry):
remove_redundant_links_inner(node, sequence, first: false); iftrue→ 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 rawCommandMask-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 inCommandMaskenum, 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.Previouseach iteration):- If
prevEntry.Motion == entry.MotionAND (firstis true, ORprevEntry.NumAnims != 0) → found a duplicate →trancuate_animation_list(prev, sequence), returntrue. - Else if
prevEntry.NumAnims != 0 && (prevEntry.Motion & motion) != 0→ an intervening entry of the "abort mask" class with pending anims blocks the search → returntrueWITHOUT truncating (stops the outer loop but did nothing — prevents redundancy removal across a still-animating Style/Action boundary). - Else continue.
- If
- If the walk exhausts (
prev == null) without matching → returnfalse(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)
- Action-branch numAnims miscalculation (
GetObjectSequenceL227): fallback Action path sumsmotionData.Anims.CountTWICE instead of once formotionDataand once formotionData_— looks like a copy-paste error in ACE's port, not necessarily retail-faithful. change_cycle_speedsilent no-op gap (L372-379): whensubstateMod ≈ 0ANDspeedModis nonzero, neither branch fires — no framerate adjustment applied. Check retail'schange_cycle_speedequivalent for a third branch.GetLinkDatamask width inconsistency (L562): uses& 0xFFFFwhere every other motion-ID mask in this file uses& 0xFFFFFF. Likely harmless (Ready's ID fits in 16 bits) but inconsistent.StopObjectCompletelyreturn-value semantics (L293-313): returnstrueunless the FINAL substate-stop call fails, in which case it falls back to whateversuccesswas from the modifier-draining loop — easy to misport if simplified to a single boolean accumulate.re_modifydouble-removal from bothpstateand a snapshotstate(L440-458): only makes sense ifMotionState's copy constructor shares the underlyingLinkedList<Motion>nodes with the source, which would make the secondstate.remove_modifier(...)call either redundant or (if lists are NOT shared) load-bearing to unlink from the snapshot's OWN list — not verifiable without readingMotionState.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.csfull file (only L190-260 read;PendingMotions,enter_default_state,apply_current_movement,get_leave_ground_velocitynot 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)CommandMaskenum exact bit values (Style/SubState/Action/Modifier)WeenieObject.HandleMotionDoneinAC.Server(game-logic side, outside Physics/)