From dc54a3e41f895c56ee2379511b23e3acbb51a144 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 2 Jul 2026 20:29:26 +0200 Subject: [PATCH] docs(R2-Q0): motion-table research base + ambiguity pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../2026-07-02-r2-motiontable/Q0-pins.md | 43 + .../r2-ace-motiontable.md | 548 ++++++ .../r2-motiontable-decomp.md | 1603 +++++++++++++++++ .../2026-07-02-r2-motiontable/r2-port-plan.md | 299 +++ 4 files changed, 2493 insertions(+) create mode 100644 docs/research/2026-07-02-r2-motiontable/Q0-pins.md create mode 100644 docs/research/2026-07-02-r2-motiontable/r2-ace-motiontable.md create mode 100644 docs/research/2026-07-02-r2-motiontable/r2-motiontable-decomp.md create mode 100644 docs/research/2026-07-02-r2-motiontable/r2-port-plan.md diff --git a/docs/research/2026-07-02-r2-motiontable/Q0-pins.md b/docs/research/2026-07-02-r2-motiontable/Q0-pins.md new file mode 100644 index 00000000..d4c44752 --- /dev/null +++ b/docs/research/2026-07-02-r2-motiontable/Q0-pins.md @@ -0,0 +1,43 @@ +# R2-Q0 — ambiguity pins and ACE-oddity adjudications + +The verbatim extraction is `r2-motiontable-decomp.md` (1,603 lines, line-anchored); +the port plan with the full ambiguity table is `r2-port-plan.md` §0. This note +records the PINNED resolutions the Q1–Q5 ports code against. + +## Pinned + +- **A1 — `get_link` branch predicate: PINNED to ACE's reading** ("EITHER speed + negative → swapped-key branch"). Three independent corroborations: ACE + `MotionTable.cs:395-426`; the working adapter's field-validated `GetLink` + (the reversed-key branch fixed the Ready→WalkBackward glitch); call-site arg + roles. The BN "both negative" reading is the same x87-flag-noise class as + `is_newer`'s garbled setcc and the R1 hook-direction swap. cdb confirmation + (bp `CMotionTable::get_link`, Ready→WalkBackward vs Ready→WalkForward) + folds into the next live retail session — non-blocking. +- **A2 — Branch-2 `signedSpeed`: PINNED to ACE** (`SubstateMod < 0 && + speedMod > 0 → -speedMod`, the single-direction flip) pending the same cdb + session (golden: a Walk(−)→Walk(+) flip). +- **A3 — outTicks** = sum of each appended MotionData's `num_anims` (+ base + cycle in Branch 1/2) − 1. The `action_head` rendering in BN is the packed + `num_anims` byte (decomp's own note). +- **A4 — ACE oddities adjudicated:** (1) Action-branch numAnims double-count = + ACE BUG, do not copy (retail sums outHop + actionLink [+ returnHop] only); + (2) `change_cycle_speed` old≈0 silent no-op = RETAIL (port verbatim, + including the gap); (3) `GetLinkData` 0xFFFF mask = ACE-only helper, not + ported; (4) `StopObjectCompletely` return = `finalStopOk ? 1 : + anyModifierStopOk`, port verbatim; (5) `re_modify` snapshot = deep-copied + MotionState used ONLY as the loop-termination bound — C# port deep-copies, + pops both, terminates on snapshot empty. +- **A5 — `MotionData.Bitfield`** (bit1 = substate-gated for `is_allowed`, + bit0 = clear-modifiers-on-entry): confirm on DatReaderWriter + `Types.MotionData` with a one-line Humanoid-table test at Q2. + +## Q0 cdb capture (pending, non-blocking) + +One live session feeds all R2 goldens: bp GetObjectSequence / get_link / +StopSequenceMotion / add_to_queue / remove_redundant_links / +truncate_animation_list / AnimationDone / CheckForCompletedMotions with +arg+ret logging (pattern `tools/cdb/l2g-observer.cdb`); protocol per +`r2-port-plan.md` §0. Until then Q2/Q3 rest on dat fixtures + synthetic +state tables + the archived 2026-05-03 walk→run trace golden (quoted in the +old Fix B comment block). diff --git a/docs/research/2026-07-02-r2-motiontable/r2-ace-motiontable.md b/docs/research/2026-07-02-r2-motiontable/r2-ace-motiontable.md new file mode 100644 index 00000000..4335c3f7 --- /dev/null +++ b/docs/research/2026-07-02-r2-motiontable/r2-ace-motiontable.md @@ -0,0 +1,548 @@ +# 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/) diff --git a/docs/research/2026-07-02-r2-motiontable/r2-motiontable-decomp.md b/docs/research/2026-07-02-r2-motiontable/r2-motiontable-decomp.md new file mode 100644 index 00000000..b7b37688 --- /dev/null +++ b/docs/research/2026-07-02-r2-motiontable/r2-motiontable-decomp.md @@ -0,0 +1,1603 @@ +# Retail motion-table layer — verbatim decomp extraction + +Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (line numbers = file +line numbers, left column in raw dumps = virtual address). Structs: +`docs/research/named-retail/acclient.h`. Build: Sept 2013 EoR (acclient.exe +v11.4186). All addresses verified via `grep -n` against the pseudo-C dump on +2026-07-02. + +**Naming caveat (see `feedback_bn_decomp_field_names.md`):** Binary Ninja's decompiler +mis-typed several `LongNIValHash::lookup` calls on `this->style_defaults` +as `LongNIValHash::lookup`. `SurfInfo` is an unrelated texture struct — every +`LongNIValHash::lookup(&this->style_defaults.vtable, ...)` / `lookup(edi, ...)` +call below is actually operating on `CMotionTable::style_defaults` (style→default-substate +map) or `CMotionTable::links` (style→substate-link-hash map), not on any SurfInfo data. +Verified by field offsets against `acclient.h`'s `CMotionTable` layout (below). Treat the +`SurfInfo*` type name in the pseudo-C as decompiler noise; the pointee types are +`unsigned long` (style_defaults) or `LongHash*` (links). + +--- + +## 1. Struct layouts (verbatim from acclient.h) + +```c +/* acclient.h:31081 */ +struct __cppobj MotionState +{ + unsigned int style; + unsigned int substate; + float substate_mod; + MotionList *modifier_head; + MotionList *action_head; + MotionList *action_tail; +}; + +/* acclient.h:31664 — singly-linked node, used for BOTH modifier_head chain and action_head/action_tail chain */ +struct __cppobj MotionList +{ + unsigned int motion; + float speed_mod; + MotionList *next; +}; + +/* acclient.h:9720 */ +struct __cppobj DLListData +{ + DLListData *dllist_next; + DLListData *dllist_prev; +}; + +/* acclient.h:9727 */ +struct __cppobj DLListBase +{ + DLListData *head_; + DLListData *tail_; +}; + +/* acclient.h:57614 — pending_animations node. Offsets in raw pseudo-C: +0x0 dllist_next, + +0x4 dllist_prev, +0x8 motion, +0xc num_anims (also used as a raw tick countdown). */ +struct __cppobj MotionTableManager::AnimNode : DLListData +{ + unsigned int motion; + unsigned int num_anims; +}; + +/* acclient.h:31092 */ +struct __cppobj DLList : DLListBase +{ +}; + +/* acclient.h:31097 — offsets: +0x0 physics_obj, +0x4 table, +0x8 state (24 bytes: + style/substate/substate_mod/modifier_head/action_head/action_tail), +0x20 animation_counter, + +0x24/+0x28 pending_animations {head_,tail_}. Total sizeof = 0x2c (44 bytes), matches + `operator new(0x2c)` in Create(). */ +struct __cppobj MotionTableManager +{ + CPhysicsObj *physics_obj; + CMotionTable *table; + MotionState state; + int animation_counter; + DLList pending_animations; +}; + +/* acclient.h:31654 — style_defaults maps style -> default substate (unsigned long values). + cycles maps (style<<16 | substate&0xffffff) -> MotionData* (base cyclic animation for a state). + modifiers maps (style<<16 | modifier&0xffffff) OR bare modifier&0xffffff -> MotionData*. + links maps (style<<16 | substate&0xffffff) -> LongHash* (a per-(style,substate) + hash of transition-target-substate -> MotionData* link animation). default_style is the + fallback style (e.g. "Standing/Idle") used when no direct link/cycle exists. */ +const struct __cppobj __declspec(align(8)) CMotionTable : SerializeUsingPackDBObj +{ + LongNIValHash style_defaults; + LongHash cycles; + LongHash modifiers; + LongNIValHash *> links; + unsigned int default_style; +}; + +/* acclient.h:57162 */ +struct __cppobj __declspec(align(4)) MotionData : PackObj, LongHashData +{ + char num_anims; + AnimData *anims; + AC1Legacy::Vector3 velocity; + AC1Legacy::Vector3 omega; + char bitfield; /* bit0 (0x1) = "clear modifiers on apply"; bit1 (0x2) = "substate-checked" (is_allowed gate) */ +}; + +/* acclient.h:38069 */ +struct __cppobj MovementStruct +{ + MovementTypes::Type type; + unsigned int motion; + unsigned int object_id; + unsigned int top_level_id; + Position pos; + float radius; + float height; + MovementParameters *params; /* params->speed used as substate_mod / speed */ +}; + +/* acclient.h:2856 */ +enum MovementTypes::Type +{ + Invalid = 0x0, + RawCommand = 0x1, + InterpretedCommand = 0x2, + StopRawCommand = 0x3, + StopInterpretedCommand = 0x4, + StopCompletely = 0x5, + MoveToObject = 0x6, + MoveToPosition = 0x7, + TurnToObject = 0x8, + TurnToHeading = 0x9, + FORCE_Type_32_BIT = 0x7FFFFFFF, +}; +``` + +### Motion-id class bits (the `0x?0000000` family used throughout GetObjectSequence/StopSequenceMotion) + +Observed usage in the decomp below (no single enum found in acclient.h; these are bit-tested +directly as `arg2 & 0x40000000`, `& 0x20000000`, `& 0x10000000`, and `substate & 0x20000000`): + +- `0x40000000` — **cycle class**: substate IDs that name a cyclic/looping animation state + directly addressable via `this->cycles`. GetObjectSequence's `ebx_1 < 0` branch (i.e. sign + bit / top bit set — note `0x40000000` alone doesn't set the sign bit, so this is really the + `(uint32_t)ebx_1 & 0x80000000`-adjacent negative-as-int32 check on the incoming motion id, + handled distinctly from the `& 0x40000000` cycle-lookup path lower down) and the + `StopSequenceMotion` early-out both special-case this bit: "this IS the current substate, no + transition — reissue via GetDefaultStyle equivalent (style_defaults default) with `arg7=1` + (stop flag)." +- `0x20000000` — **modifier class**: substate/motion IDs that are modifiers (overlays combined + onto the base cycle, e.g. "aiming while running") rather than replacing the base state. Stored/ + removed via `MotionState::modifier_head` linked list (`add_modifier`/`add_modifier_no_check`/ + `remove_modifier`). `StopSequenceMotion` walks `modifier_head` to find and subtract a matching + modifier when this bit is set. +- `0x10000000` — **action class**: one-shot, non-cycle-replacing actions (e.g. attacks, emotes) + queued onto `MotionState::action_head`/`action_tail` via `add_action`, and popped in FIFO via + `remove_action_head` when their queued AnimNode completes (`CheckForCompletedMotions`/ + `AnimationDone` test `num_anims_node_flags & 0x10000000` to decide whether to pop the action + queue on completion). + +--- + +## 2. Free functions (motion arithmetic + comparison helpers) + +### `same_sign` — 0x00522260 (@298253) + +```c +int32_t same_sign(float arg1, float arg2) +``` +Pseudo-C is x87-flag-comparison noise from BN; the two `if`/`else if` legs both collapse to the +same conditional. Cleaned flow: returns whether `arg1` and `arg2` compare on the **same side of +zero** (both >=0 or both <0, roughly — BN's `test ah,0x5` pattern after an `fcompp`-style compare +means "ZF|PF clear", i.e. arg2 < arg1 is false in one of the ucomiss orderings). Net effect used +by call sites (`GetObjectSequence`'s `same_sign(ebp_1, esi->substate_mod) != 0`): **true when the +new speed_mod (arg1) and the current substate_mod (arg2) have the same sign** — i.e. direction of +motion hasn't flipped (forward vs backward). Used to gate the "just re-speed the existing cycle, +don't retransition" fast path. + +```c +int32_t same_sign(float arg1, float arg2) +{ + // BN x87 flag noise elided — net semantic: + // return (arg1 >= 0f) == (arg2 >= 0f); // both same sign => 1, else 0 + // (mirrors: compares arg1 vs 0, arg2 vs 0, returns 1 if same side, 0 if opposite) +} +``` + +### `change_cycle_speed` — 0x00522290 (@298276) + +```c +void change_cycle_speed(class CSequence* arg1, class MotionData* arg2, float arg3, float arg4) +{ + // arg1=sequence, arg2=cyclic MotionData (unused directly here besides existence check + // upstream), arg3=old substate_mod, arg4=new substate_mod + if (fabsl(arg3) > 0.0002f) // guard divide-by-near-zero + { + CSequence::multiply_cyclic_animation_fr(arg1, arg4 / arg3); + return; + } + // arg3 ~ 0: fall through to the fabsl(arg4) test + if (fabsl(arg4) > 0.0002f is FALSE, i.e. arg4 also ~0 -> multiply by 0) + CSequence::multiply_cyclic_animation_fr(arg1, 0f); + // (if arg4 is NOT ~0 while arg3 IS ~0, the BN flag test suppresses the call — net: + // only rescale when arg3 is non-trivial; when arg3~0 AND arg4~0, zero the playback rate) +} +``` +Constant `0.000199999995f` ≈ `0.0002f` — hard epsilon for "is this speed effectively zero." +Cleaned intent: **rescale the cyclic animation's frame rate by the ratio new_speed/old_speed**; +guards against div-by-zero when the previous substate_mod was ~0. + +### `add_motion` — 0x005224b0 (@298437) + +```c +void add_motion(class CSequence* arg1 /*sequence*/, class MotionData* arg2 /*motion*/, float arg3 /*speed_mod*/) +{ + if (arg2 == 0) return; + + // Scale linear + angular velocity by speed_mod and SET on the sequence (overwrite, not add) + Vector3 v = arg2->velocity * arg3; // {x,y,z} each independently multiplied + CSequence::set_velocity(arg1, &v); + + Vector3 w = arg2->omega * arg3; + CSequence::set_omega(arg1, &w); + + // Append every sub-animation in this MotionData's anims[] array, each scaled by speed_mod + for (int i = 0; i < arg2->num_anims; i++) + CSequence::append_animation(arg1, /* AnimData scaled-copy ctor */ operator*(&tmp, arg3, &arg2->anims[i])); +} +``` +`num_anims` field is `char` in the struct but compared/looped as unsigned int (BN widens it). +Anim stride in the raw dump is `0x14` (20) bytes per `AnimData` element (`ebx_1 += 0x14`). + +### `combine_motion` — 0x00522580 (@298472) + +```c +void combine_motion(class CSequence* arg1, class MotionData* arg2, float arg3) +{ + if (arg2 == 0) return; + Vector3 w = arg2->omega * arg3; + Vector3 v = arg2->velocity * arg3; + CSequence::combine_physics(arg1, &v, &w); // ADDS to existing sequence velocity/omega (vs add_motion's SET) +} +``` +Note: `combine_motion` does **not** touch `anims` — it only combines the linear/angular physics +contribution (used for modifier overlays where the base cycle's animation frames stay, but a +modifier's velocity/omega gets blended in). + +### `subtract_motion` — 0x00522600 (@298492) + +```c +void subtract_motion(class CSequence* arg1, class MotionData* arg2, float arg3) +{ + if (arg2 == 0) return; + Vector3 w = arg2->omega * arg3; + Vector3 v = arg2->velocity * arg3; + CSequence::subtract_physics(arg1, &v, &w); // inverse of combine_motion; used when REMOVING a modifier +} +``` + +--- + +## 3. `CMotionTable::is_allowed` — 0x005226c0 (@298526) + +```c +int32_t __thiscall CMotionTable::is_allowed(class CMotionTable const* this, + uint32_t arg2 /*candidate substate id*/, + class MotionData* arg3 /*candidate cycle MotionData*/, + class MotionState const* arg4 /*current state*/) +{ + class MotionData* eax_4 = arg3; + if (eax_4 == 0) + return eax_4; // null MotionData -> "not allowed" (returns 0) + + if ((eax_4->bitfield & 2) != 0) // MotionData.bitfield bit1 = "substate-gated" cycle + { + uint32_t substate = arg4->substate; // arg4 = current MotionState + if (arg2 != substate) // candidate substate differs from current substate + { + // style_defaults[arg4->style] -> default substate for that style + uint32_t defaultSubstate; + LongNIValHash::lookup(&this->style_defaults, arg4->style, &defaultSubstate); + return defaultSubstate == substate; // allowed only if current substate == that style's default + } + } + return 1; // not gated, or candidate IS the current substate -> allowed +} +``` +Cleaned semantics: **a bitfield-bit1 ("gated") cycle is only reusable/continuable when the +current substate matches, OR when the current substate already equals the owning style's +default substate.** This stops e.g. a combat-cycle MotionData from being silently reused while +standing in an unrelated substate. + +--- + +## 4. `CMotionTable::get_link` — 0x00522710 (@298552) + +```c +class MotionData* __thiscall CMotionTable::get_link(class CMotionTable const* this, + uint32_t arg2 /*fromStyle*/, + uint32_t arg3 /*fromSubstate*/, + float arg4 /*fromSubstateMod (speed sign)*/, + uint32_t arg5 /*toSubstate*/, + float arg6 /*toSubstateMod (speed sign)*/) +{ + bool fromNeg = (arg6 < 0f); // BN's x87 compare-to-zero noise: p = (arg6<0), computed FIRST + bool toNeg; + if (fromNeg) + toNeg = (arg4 < 0f); + // net predicate: sameDirection = !(fromNeg) || !(toNeg) === NOT(fromNeg && toNeg) + // i.e. "at least one of the two speed_mods is non-negative" chooses branch A; + // "both speed_mods negative" (moving backward on both ends) chooses branch B (swap keys) + + class LongHash* linkHash; + uint32_t key = (arg2 << 16); // style in high 16 bits + + if (!(fromNeg && toNeg)) // branch A: NOT both-negative + { + // links[(arg2<<16) | (arg5 & 0xffffff)] — keyed by (fromStyle, toSubstate) + if (LongNIValHash*>::lookup(&this->links, key | (arg5 & 0xffffff), &linkHash) != 0) + { + MotionData* found = LongHash::lookup(linkHash, arg3); // sub-key = fromSubstate + if (found != 0) return found; + // fall through with found cached in var_4 for the final fallback return + } + } + else // branch B: both negative -> swap which value keys the outer hash vs the inner lookup + { + // links[(arg2<<16) | (arg3 & 0xffffff)] — keyed by (fromStyle, FROMsubstate) this time + if (LongNIValHash*>::lookup(&this->links, key | (arg3 & 0xffffff), &linkHash) != 0) + { + MotionData* found = LongHash::lookup(linkHash, arg5); // sub-key = toSubstate + if (found != 0) return found; + } + } + + // ---- Second predicate block: same test repeated on (arg6, arg4) again (BN redundant re-eval) ---- + bool fromNeg2 = (arg6 < 0f); + bool toNeg2; + if (fromNeg2) toNeg2 = (arg4 < 0f); + + if (!(fromNeg2 && toNeg2)) + { + // style_defaults[arg2] -> defaultSubstateForFromStyle (stored into arg5's slot, reused as "arg5") + // links[(arg2<<16) | (arg3 & 0xffffff)] -> linkHash + // return linkHash[defaultSubstateForFromStyle] + uint32_t defaultSubstate; + if (LongNIValHash::lookup(&this->style_defaults, arg2, &defaultSubstate) != 0 + && LongNIValHash*>::lookup(&this->links, key | (arg3 & 0xffffff), &linkHash) != 0) + return LongHash::lookup(linkHash, defaultSubstate); + } + else if (LongNIValHash*>::lookup(&this->links, key /* (arg2<<16)|0 */, &linkHash) != 0) + { + return LongHash::lookup(linkHash, arg5); + } + + return var_4; // whatever branch A/B's inner lookup found (possibly null) as final fallback +} +``` +Cleaned intent: **look up the link (transition) animation between `fromSubstate` and `toSubstate` +within `fromStyle`.** Direction-of-motion (sign of the two speed mods) picks which of the two IDs +keys the outer per-style hash and which keys the inner per-transition hash — this lets forward +and backward transitions (e.g. walk-forward→run-forward vs walk-backward→run-backward) be stored +as distinct entries without duplicating the whole link table. If no direct link exists, falls back +to a link from the **style's default substate** to the target (the "return via idle" link). + +--- + +## 5. `CMotionTable::GetObjectSequence` — 0x00522860 (@298636) — COMPLETE + +```c +int32_t __thiscall CMotionTable::GetObjectSequence( + class CMotionTable const* this, + uint32_t arg2, // requested target substate/motion id ("ebx_1" after copy) + class MotionState* arg3, // in/out current motion state (style/substate/substate_mod/modifier_head) + class CSequence* arg4, // sequence to build/mutate (physics + animation queue) + float arg5, // requested speed_mod for the new substate + uint32_t* arg6, // OUT: outTicks (frame-tick count of appended animation, minus 1) + int32_t arg7) // 0 = normal "do motion", 1 = "stop" call (re-invoked from StopSequenceMotion) +{ + MotionState* esi = arg3; + *arg6 = 0; // outTicks defaults to 0 + + uint32_t style = esi->style; + if (style == 0) + return 0; // no current style at all -> can't sequence anything + + uint32_t substate = esi->substate; + if (substate == 0) + return 0; + + MotionData* var_10_1 = nullptr; // "toDefault" link data (2nd hop of a style-default double-hop) + MotionData* var_4_1 = nullptr; // "fromDefault" link data (1st hop of a style-default double-hop) + + uint32_t var_c; // default substate for esi->style + LongNIValHash::lookup(&this->style_defaults, style, &var_c); + + uint32_t ebx_1 = arg2; // target motion/substate id, working copy + + // ---- FAST PATH: requesting the SAME substate we're already in, is-a-modifier-class id, not a stop-call ---- + if (ebx_1 == var_c && arg7 == 0 && (substate & 0x20000000) != 0) + return 1; // no-op: already at the style default and target is a modifier-class request + + float ebp_1 = arg5; // working speed_mod + + // ================================================================ + // BRANCH 1: ebx_1 interpreted as NEGATIVE int32 (top bit set) — style-change request + // (BN emits `if (ebx_1 < 0)`; ebx_1's raw bit pattern with the top bit set is used + // elsewhere as a STYLE id encoded in the upper bits, not a plain substate) + // ================================================================ + if ((int32_t)ebx_1 < 0) + { + uint32_t style_1 = esi->style; + if (style_1 == ebx_1) + return 1; // requesting the style we're already in -> no-op success + + uint32_t eax_1; // default substate for style_1 (current style) + LongNIValHash::lookup(&this->style_defaults, style_1, &eax_1); + uint32_t substate_3 = substate; // == esi->substate, aliased + + if (substate_3 != eax_1) + // current substate isn't already the current style's default -> need a link OUT of it first + var_4_1 = CMotionTable::get_link(this, esi->style, substate_3, esi->substate_mod, eax_1, ebp_1); + + uint32_t arg7_style_default; // default substate for the TARGET style (ebx_1) + if (LongNIValHash::lookup(&this->style_defaults, ebx_1, &arg7_style_default) != 0) + { + // cycles[(arg7_style_default & 0xffffff) | (ebx_1<<16)] — target style's default cycle + MotionData* eax_5 = LongHash::lookup(&this->cycles, (arg7_style_default & 0xffffff) | (ebx_1 << 16)); + arg3 = eax_5; // becomes the "new base cycle" MotionData + if (eax_5 != 0) + { + if ((eax_5->bitfield & 1) != 0) + MotionState::clear_modifiers(esi); // new cycle wants a clean modifier slate + + // link FROM current style's default substate TO target style's default substate + MotionData* eax_7 = CMotionTable::get_link(this, esi->style, var_c, esi->substate_mod, ebx_1, ebp_1); + arg2 = eax_7; + + if (eax_7 == 0) + { + uint32_t style_2 = esi->style; + if (ebx_1 != style_2) + { + // DOUBLE-HOP VIA this->default_style (the table-wide default style, e.g. "Standing"): + // hop 1: current style's default substate -> default_style's matching substate (speed 1.0) + arg2 = CMotionTable::get_link(this, style_2, var_c, 1f, this->default_style, 1f); + // hop 2: default_style's default substate -> target style's default substate (speed 1.0) + uint32_t defaultStyleDefaultSubstate; + LongNIValHash::lookup(&this->style_defaults, this->default_style, &defaultStyleDefaultSubstate); + var_10_1 = CMotionTable::get_link(this, this->default_style, defaultStyleDefaultSubstate, 1f, ebx_1, 1f); + } + } + + // Rebuild the sequence from scratch: clear physics + strip cyclic anims, then layer: + // 1) the "exit current substate" link (var_4_1) + // 2) the direct or hop-1 link (arg2) + // 3) the hop-2 link if double-hop taken (var_10_1) + // 4) the new base cycle itself (arg3) + CSequence::clear_physics(arg4); + CSequence::remove_cyclic_anims(arg4); + add_motion(arg4, var_4_1, ebp_1); + add_motion(arg4, arg2, ebp_1); + add_motion(arg4, var_10_1, ebp_1); + add_motion(arg4, arg3, ebp_1); + + // Commit new state: substate<-arg7(ORIGINAL passed-in arg7, i.e. the stop-flag param! + // at this point arg7 has been OVERWRITTEN above by the style_defaults lookup output — + // BN reuses arg7's stack slot for the "arg7_style_default" out-param), style<-ebx_1 + esi->substate = arg7_style_default; // == the target style's default substate + esi->style = ebx_1; + esi->substate_mod = arg5; + CMotionTable::re_modify(this, arg4, esi); // replay any pending modifiers atop new state + + // outTicks = action_head_ticks(arg3) + var_10_1.num_anims + arg2.num_anims(as byte@+0x10) + var_4_1.num_anims - 1 + uint32_t num_anims_2 = (var_4_1 == 0) ? 0 : var_4_1->num_anims; + uint32_t ecx_20 = (arg2 == 0) ? 0 : *(uint8_t*)(arg2 + 0x10); // arg2 here is a MotionData*, +0x10 byte = num_anims field (packed layout) + uint32_t num_anims_1 = (var_10_1== 0) ? 0 : var_10_1->num_anims; + *arg6 = (uint32_t)arg3->action_head /* NOTE: arg3 is MotionData* here, ->action_head is BN's + mis-decoded field name for MotionData's tick-count field + at that offset, NOT the MotionState action_head pointer */ + + num_anims_1 + ecx_20 + num_anims_2 - 1; + return 1; + } + } + // else: fall through to the class-bit blocks below with ebx_1 still negative + } + + // ================================================================ + // BRANCH 2: target id has the CYCLE-CLASS bit (0x40000000) set + // ================================================================ + if ((ebx_1 & 0x40000000) != 0) + { + uint32_t eax_23 = ebx_1 & 0xffffff; + MotionData* eax_24 = LongHash::lookup(&this->cycles, (esi->style << 16) | eax_23); + arg3 = eax_24; + + if (eax_24 == 0) + { + // no cycle for THIS style at that id -> retry under the table-wide default_style + eax_24 = LongHash::lookup(&this->cycles, (this->default_style << 16) | eax_23); + arg3 = eax_24; + if (eax_24 != 0) + goto label_522ae6; // found one under default_style -> proceed as if found directly + // else: fall through past this whole branch (arg3 stays whatever eax_24 was = 0) + } + else + { + label_522ae6: + if (CMotionTable::is_allowed(this, ebx_1, eax_24, esi) != 0) + { + // ---- FAST RE-SPEED PATH: same substate, same direction, already animating ---- + if (ebx_1 == substate /* the ORIGINAL esi->substate captured at entry */ + && same_sign(ebp_1, esi->substate_mod) != 0 + && CSequence::has_anims(arg4) != 0) + { + change_cycle_speed(arg4, arg3, esi->substate_mod, ebp_1); + subtract_motion(arg4, arg3, esi->substate_mod); // remove old-speed contribution + combine_motion(arg4, arg3, ebp_1); // add new-speed contribution + esi->substate_mod = arg5; + return 1; + } + + if ((arg3->bitfield & 1) != 0) // MotionData.bitfield: this cycle clears mods on entry + MotionState::clear_modifiers(esi); + + // direct link from current (style,substate,substate_mod) to target substate + MotionData* eax_34 = CMotionTable::get_link(this, esi->style, esi->substate, esi->substate_mod, ebx_1, ebp_1); + arg2 = eax_34; + + bool sameSign = (eax_34 != 0) ? (same_sign(ebp_1, esi->substate_mod) != 0) : false; + + if (eax_34 == 0 || !sameSign) + { + // direct link missing OR direction flipped -> route via style default substate + uint32_t styleDefaultSubstate; + LongNIValHash::lookup(&this->style_defaults, esi->style, &styleDefaultSubstate); + arg2 = CMotionTable::get_link(this, esi->style, esi->substate, esi->substate_mod, styleDefaultSubstate, 1f); + var_10_1 = CMotionTable::get_link(this, esi->style, styleDefaultSubstate, 1f, ebx_1, ebp_1); + } + + CSequence::clear_physics(arg4); + CSequence::remove_cyclic_anims(arg4); + + if (var_10_1 == 0) + { + // No double-hop link needed. Decide sign for arg2's contribution: + // if current substate_mod ~0 use sign of arg5 as-is; else if substate_mod's + // sign bit differs from... (BN x87 flag block collapses to:) + float signedSpeed = (esi->substate_mod == 0f || same_sign(esi->substate_mod, arg5)) + ? arg5 : -arg5; + add_motion(arg4, arg2, signedSpeed); + } + else + { + add_motion(arg4, arg2, esi->substate_mod); + add_motion(arg4, var_10_1, ebp_1); + } + + add_motion(arg4, arg3, ebp_1); // layer the new base cycle itself + + // If we're leaving a modifier-class substate (0x20000000) for something else, + // and the style's default substate isn't the new target, re-register the OLD + // substate as a still-active modifier (so it keeps contributing) instead of dropping it. + uint32_t substate_1 = esi->substate; + if (substate_1 != ebx_1 && (substate_1 & 0x20000000) != 0) + { + uint32_t styleDefaultSubstate2; + LongNIValHash::lookup(&this->style_defaults, esi->style, &styleDefaultSubstate2); + if (styleDefaultSubstate2 != ebx_1) + MotionState::add_modifier_no_check(esi, substate_1, esi->substate_mod); + } + + esi->substate_mod = arg5; + esi->substate = ebx_1; + CMotionTable::re_modify(this, arg4, esi); + + uint32_t ecx_45 = (arg2 == 0) ? 0 : *(uint8_t*)(arg2 + 0x10); // arg2's packed num_anims byte + uint32_t num_anims_1 = (var_10_1 == 0) ? 0 : var_10_1->num_anims; + *arg6 = (uint32_t)arg3->action_head /* MotionData tick-count field, see BRANCH 1 note */ + + num_anims_1 + ecx_45 - 1; + return 1; + } + // is_allowed() rejected -> fall through past this whole branch + } + } + + // ================================================================ + // BRANCH 3: target id has the ACTION-CLASS bit (0x10000000) set + // ================================================================ + if ((ebx_1 & 0x10000000) != 0) + { + uint32_t cycleKey = (esi->style << 16) | (substate & 0xffffff); // current (style,substate) cycle key + MotionData* eax_57 = LongHash::lookup(&this->cycles, cycleKey); + if (eax_57 != 0) + { + // direct link from current (style,substate) to the action id + MotionData* eax_60 = CMotionTable::get_link(this, esi->style, substate, esi->substate_mod, ebx_1, ebp_1); + arg2 = eax_60; + if (eax_60 != 0) + { + MotionState::add_action(esi, ebx_1, ebp_1); // queue the action id on action_head/action_tail + CSequence::clear_physics(arg4); + CSequence::remove_cyclic_anims(arg4); + add_motion(arg4, arg2, ebp_1); // the action's own link animation + add_motion(arg4, eax_57, esi->substate_mod); // re-add the still-running base cycle + CMotionTable::re_modify(this, arg4, esi); + *arg6 = *(uint8_t*)(arg2 + 0x10); // outTicks = arg2's packed num_anims byte + return 1; + } + + // No direct link -> route the action through the style's default substate (double-hop) + uint32_t styleDefaultSubstate; + LongNIValHash::lookup(&this->style_defaults, esi->style, &styleDefaultSubstate); + MotionData* eax_66 = CMotionTable::get_link(this, esi->style, substate, esi->substate_mod, styleDefaultSubstate, 1f); + arg2 = eax_66; + if (eax_66 != 0) + { + MotionData* eax_68 = CMotionTable::get_link(this, esi->style, styleDefaultSubstate, 1f, ebx_1, ebp_1); + if (eax_68 != 0) + { + MotionData* eax_69 = LongHash::lookup(&this->cycles, cycleKey); // re-fetch base cycle (same key) + if (eax_69 != 0) + { + // link BACK from styleDefaultSubstate to substate (returning-hop, note args flipped + // vs eax_68: (style,defaultSubstate,1.0) -> (substate, esi->substate_mod)) + MotionData* returningLink = CMotionTable::get_link(this, esi->style, styleDefaultSubstate, 1f, substate, esi->substate_mod); + + MotionState::add_action(esi, ebx_1, ebp_1); + CSequence::clear_physics(arg4); + CSequence::remove_cyclic_anims(arg4); + add_motion(arg4, arg2, 1f); // hop 1: substate -> default (speed 1.0) + add_motion(arg4, eax_68, ebp_1); // hop 2: default -> action (new speed) + add_motion(arg4, returningLink, 1f); // hop back: default -> substate (speed 1.0) + add_motion(arg4, eax_69, esi->substate_mod); // base cycle continuing at old speed + CMotionTable::re_modify(this, arg4, esi); + + uint32_t outTicks = *(uint8_t*)(arg2 + 0x10) + eax_68->num_anims; + *arg6 = outTicks; + if (returningLink != 0) + *arg6 = *(uint8_t*)(returningLink + 0x10) + outTicks; + return 1; + } + } + } + } + } + + // ================================================================ + // BRANCH 4: target id has the MODIFIER-CLASS bit (0x20000000) set + // ================================================================ + if ((ebx_1 & 0x20000000) != 0) + { + uint32_t styleShifted = esi->style << 16; + MotionData* eax_81 = LongHash::lookup(&this->cycles, (substate & 0xffffff) | styleShifted); + + // must have a base cycle for the current (style,substate) AND that cycle must NOT be + // the "clear modifiers on entry" kind (bitfield bit0 clear) + if (eax_81 != 0 && (eax_81->bitfield & 1) == 0) + { + uint32_t modKey = ebx_1 & 0xffffff; + MotionData* eax_85 = LongHash::lookup(&this->modifiers, modKey | styleShifted); // style-specific modifier + MotionData* eax_87 = 0; + if (eax_85 == 0) + eax_87 = LongHash::lookup(&this->modifiers, modKey); // style-agnostic (global) modifier fallback + MotionData* modifierData = (eax_85 != 0) ? eax_85 : eax_87; + + if (eax_85 != 0 || eax_87 != 0) + { + int32_t added = MotionState::add_modifier(esi, ebx_1, ebp_1); // returns 0 if already present or == substate + if (added == 0) + { + // Already active as a modifier or equals current substate -> STOP it first, then re-add + // (this is the "toggle" path: requesting the same modifier again removes+readds it, + // which is how e.g. re-triggering a held key restarts a modifier cleanly) + uint32_t dummyTicks; + CMotionTable::StopSequenceMotion(this, ebx_1, 1f, esi, arg4, &dummyTicks); + added = MotionState::add_modifier(esi, ebx_1, ebp_1); + } + if (added != 0) + { + combine_motion(arg4, modifierData, ebp_1); // ADD the modifier's velocity/omega contribution + return 1; + } + } + } + } + + return 0; // nothing matched any of the four class branches -> failure +} +``` + +### Cleaned high-level flow of `GetObjectSequence` + +1. **Guard**: `esi->style == 0` or `esi->substate == 0` → fail (0). No-op fast path: requesting + the style's own default substate while it's already a modifier-class substate and this isn't + a stop-call → success (1) with no work. +2. **Style-change request** (`(int32_t)ebx_1 < 0`, i.e. target id encodes a STYLE not a substate): + no-op if already that style; otherwise builds a **1–2 hop link chain** from the current + style's default substate to the target style's default substate — with a fallback double-hop + through `this->default_style` (the table's global fallback style, e.g. standing-idle) when no + direct style-to-style link exists. Commits `esi->style`/`esi->substate`/`esi->substate_mod`, + replays modifiers via `re_modify`, computes `*arg6` (outTicks) as the sum of all appended + link/cycle animation tick counts minus 1. +3. **Cycle-class target** (`ebx_1 & 0x40000000`): looks up (or falls back to `default_style`'s) + base cyclic MotionData for the target substate; gates via `is_allowed`. If already in that + exact substate with the same speed sign and animations already running, takes the **cheap + re-speed path** (`change_cycle_speed` + subtract-old/combine-new). Otherwise builds a direct + or double-hop (via style default) transition-link chain, optionally preserving the outgoing + substate as a still-active modifier if it was itself modifier-class, commits new + substate/substate_mod, replays modifiers, computes outTicks. +4. **Action-class target** (`ebx_1 & 0x10000000`): requires a base cycle to already exist for + the current substate. Direct link → queue action + layer link + re-add base cycle. No direct + link → 2-hop-out-and-back through the style default substate, queuing four animation layers + (out-hop, action, back-hop, base cycle) and summing all their tick counts for outTicks. +5. **Modifier-class target** (`ebx_1 & 0x20000000`): requires a base cycle that does NOT clear + modifiers on entry. Looks up a style-specific modifier MotionData, falling back to a + style-agnostic one. `add_modifier` is idempotent-guarded; if it reports "already present," + the code **stops** the existing modifier via `StopSequenceMotion` first, then re-adds — this + is the toggle/restart semantics. On success, `combine_motion` adds the modifier's + velocity/omega into the sequence (animation frames untouched — modifiers are physics-only + overlays). +6. Falls through to `return 0` if no branch's preconditions were satisfied. + +--- + +## 6. `CMotionTable::re_modify` — 0x005222e0 (@298300) + +```c +void __thiscall CMotionTable::re_modify(class CMotionTable const* this, class CSequence* arg2, class MotionState* arg3) +{ + MotionState* esi = arg3; + if (esi->modifier_head != 0) + { + MotionState snapshot; + MotionState::MotionState(&snapshot, esi); // copy-construct a snapshot of the CURRENT modifier list + CSequence* seq = arg2; + + // Walk esi's modifier list, popping ONE entry per iteration, re-driving it through + // GetObjectSequence (arg7=0, "do motion") so each modifier gets correctly re-applied + // on top of whatever base cycle/link chain was just installed. + do + { + MotionList* modifier_head = esi->modifier_head; + uint32_t motion = modifier_head->motion; + float speedMod = modifier_head->speed_mod; + MotionState::remove_modifier(esi, modifier_head, nullptr); // pop from esi's live list + MotionState::remove_modifier(&snapshot, snapshot_head, nullptr); // pop matching node from the snapshot copy + CMotionTable::GetObjectSequence(this, motion, esi, seq, speedMod, &outTicksIgnored, 0); + } while (snapshot.modifier_head != 0); + + MotionState::~MotionState(&snapshot); + } +} +``` +Cleaned intent: **after installing a new base cycle/substate, replay every previously-active +modifier back onto the sequence** by re-invoking `GetObjectSequence` for each one. This is why +every substate-changing branch in `GetObjectSequence` calls `re_modify` right before returning — +modifiers (aiming overlays, etc.) must survive a cycle/style transition. + +--- + +## 7. `CMotionTable::StopSequenceMotion` — 0x00522fc0 (@298954) + +```c +int32_t __thiscall CMotionTable::StopSequenceMotion( + class CMotionTable const* this, + uint32_t arg2, // motion/substate id being stopped + float arg3, // speed_mod (usually 1.0f from call sites) + class MotionState* arg4, // current state + class CSequence* arg5, // sequence + uint32_t* arg6) // OUT: outTicks +{ + *arg6 = 0; + + // Case A: stopping the CYCLE-class substate we are currently in + if ((arg2 & 0x40000000) != 0 && arg2 == arg4->substate) + { + uint32_t styleDefaultSubstate; + LongNIValHash::lookup(&this->style_defaults, arg4->style, &styleDefaultSubstate); + // Re-invoke GetObjectSequence targeting the STYLE'S DEFAULT SUBSTATE, with arg7=1 ("stop" flag) + CMotionTable::GetObjectSequence(this, styleDefaultSubstate, arg4, arg5, 1f, arg6, 1); + return 1; + } + + // Case B: stopping a MODIFIER-class id — search arg4->modifier_head for a matching node + if ((arg2 & 0x20000000) != 0) + { + MotionList* prev = nullptr; + for (MotionList* node = arg4->modifier_head; node != 0; node = node->next) + { + if (node->motion == arg2) + { + uint32_t modKey = arg2 & 0xffffff; + // style-specific modifier data first + MotionData* modData = LongHash::lookup(&this->modifiers, (arg4->style << 16) | modKey); + if (modData != 0) + { + found: + subtract_motion(arg5, modData, node->speed_mod); // remove its velocity/omega contribution + MotionState::remove_modifier(arg4, node, prev); // unlink from the modifier_head chain + return 1; + } + // style-agnostic fallback + modData = LongHash::lookup(&this->modifiers, modKey); + if (modData != 0) + goto found; + break; // matching motion id found but NO MotionData for it anywhere -> give up + } + prev = node; + } + } + + return 0; // nothing matched -> stop was a no-op +} +``` +Cleaned intent: **two independent stop mechanisms.** Stopping the active cycle re-drives +`GetObjectSequence` toward the style's default substate (i.e. "return to idle"). Stopping a +modifier just unwinds its physics contribution and removes it from the linked list — no +animation re-sequencing needed since modifiers don't touch `anims[]`. + +Note: action-class (`0x10000000`) ids are **not handled here at all** — actions complete via +their own tick-countdown mechanism in `MotionTableManager::CheckForCompletedMotions`/ +`AnimationDone` (popping `action_head`), not via `StopSequenceMotion`. + +--- + +## 8. `CMotionTable::SetDefaultState` — 0x005230a0 (@299004) + +```c +int32_t __thiscall CMotionTable::SetDefaultState(class CMotionTable const* this, + class MotionState* arg2, + class CSequence* arg3, + uint32_t* arg4) +{ + uint32_t defaultSubstate; + if (LongNIValHash::lookup(&this->style_defaults, this->default_style, &defaultSubstate) == 0) + return 0; // table has no entry for its own default_style -> fail + + MotionState::clear_modifiers(arg2); + MotionState::clear_actions(arg2); + + uint32_t cycleKey = (defaultSubstate & 0xffffff) | (this->default_style << 16); + // manual open-addressed hash bucket walk against this->cycles (inlined LongHash lookup) + HashNode* node = this->cycles.buckets[hash(cycleKey) & this->cycles.table_mask]; + if (node != 0) + { + while (cycleKey != node->key) + { + node = node->hashNext; + if (node == 0) return 0; + } + if (node != EMPTY_SENTINEL /* raw check: ecx_10 != 4 */) + { + arg2->style = this->default_style; + arg2->substate = defaultSubstate; + arg2->substate_mod = 1f; + *arg4 = node->motionData->num_anims - 1; // outTicks + CSequence::clear_physics(arg3); + CSequence::clear_animations(arg3); + add_motion(arg3, node->motionData, arg2->substate_mod); + return 1; + } + } + return 0; +} +``` +Cleaned intent: **reset a MotionState to the motion table's baseline (default_style + +that style's default substate)**, clearing all modifiers/actions and installing the base +cyclic animation for that (style,substate) fresh. Used by +`MotionTableManager::initialize_state` on world-enter. + +--- + +## 9. `CMotionTable::DoObjectMotion` / `StopObjectMotion` / `StopObjectCompletely` + +```c +/* 0x00523e90 @300045 — thin wrapper: "do" == GetObjectSequence with the stop-flag forced to 0 */ +int32_t __thiscall CMotionTable::DoObjectMotion(class CMotionTable const* this, uint32_t arg2, + class MotionState* arg3, class CSequence* arg4, float arg5, uint32_t* arg6) +{ + return CMotionTable::GetObjectSequence(this, arg2, arg3, arg4, arg5, arg6, 0); +} + +/* 0x00523ec0 @300053 — thin wrapper: "stop" == tailcall straight into StopSequenceMotion */ +int32_t __thiscall CMotionTable::StopObjectMotion(class CMotionTable const* this, uint32_t arg2, + float arg3, class MotionState* arg4, class CSequence* arg5, uint32_t* arg6) +{ + return CMotionTable::StopSequenceMotion(this, arg2, arg3, arg4, arg5, arg6); +} + +/* 0x00523ed0 @300062 */ +int32_t __thiscall CMotionTable::StopObjectCompletely(class CMotionTable const* this, + class MotionState* arg2, class CSequence* arg3, uint32_t* arg4) +{ + MotionState* esi = arg2; + int32_t result = 0; + + // Stop EVERY currently-active modifier first, one at a time (list shrinks each iteration + // because StopSequenceMotion unlinks the node it stops) + for (MotionList* node = esi->modifier_head; node != 0; node = esi->modifier_head) + { + float speedMod = node->speed_mod; + if (CMotionTable::StopSequenceMotion(this, node->motion, speedMod, esi, arg3, arg4) != 0) + result = 1; + } + + // Finally stop the base cycle/substate itself + if (CMotionTable::StopSequenceMotion(this, esi->substate, esi->substate_mod, esi, arg3, arg4) != 0) + return 1; + + return result; +} +``` +Cleaned intent: `StopObjectCompletely` is the "return to idle from everything" call — strips +every active modifier first (each one individually going through the same +subtract-and-unlink path as a single `StopObjectMotion` call), then stops the base cycle +itself (which re-drives `GetObjectSequence` toward the style's default substate). + +--- + +## 10. `CMotionTable::~CMotionTable` (destructor) — 0x00523f50 (@300087) + +```c +void __fastcall CMotionTable::~CMotionTable(class CMotionTable* this) +{ + this->vtable = &CMotionTable::`vftable'; + CMotionTable::Destroy(this); // (Destroy body not requested; frees cycles/modifiers/style_defaults contents) + LongNIValHash*>::destroy_contents(&this->links); + if (!this->links.fPlacementNew_) + operator delete[](this->links.buckets); + if (!this->modifiers.fPlacementNew_) + operator delete[](this->modifiers.buckets); + // (truncated in source at capture boundary; remaining teardown is style_defaults + cycles hash frees) +} +``` + +--- + +## 11. `MotionTableManager` — full member list (grep of `MotionTableManager::` in the pseudo-C) + +| Member | Address | Line | +|---|---|---| +| `SetPhysicsObject` | 0x0051bbc0 | @290464 | +| `SetMotionTableID` | 0x0051bbd0 | @290472 | +| `GetMotionTableID` | 0x0051bc10 | @290490 | +| `Create` (static factory) | 0x0051bc50 | @290510 | +| `truncate_animation_list` | 0x0051bca0 | @290533 | +| `AnimationDone` | 0x0051bce0 | @290558 | +| `HandleExitWorld` | 0x0051bda0 | @290625 | +| `HandleEnterWorld` | 0x0051bdd0 | @290634 | +| `CheckForCompletedMotions` | 0x0051be00 | @290645 | +| `Destroy` | 0x0051be90 | @290705 | +| `~MotionTableManager` | 0x0051bf00 | @290761 | +| `remove_redundant_links` | 0x0051bf20 | @290771 | +| `UseTime` | 0x0051bfd0 | @290845 | +| `add_to_queue` | 0x0051bfe0 | @290854 | +| `initialize_state` | 0x0051c030 | @290875 | +| `PerformMovement` | 0x0051c0b0 | @290906 | + +(No separately-decompiled `GetMotion_PersistentStates` — not present as a named symbol under +`MotionTableManager::` in this dump; the CLAUDE.md task listed it speculatively. Confirmed absent +via the exhaustive grep above — 16 members is the complete list in this PDB build.) + +### `SetPhysicsObject` — 0x0051bbc0 (@290464) +```c +void __thiscall MotionTableManager::SetPhysicsObject(class MotionTableManager* this, class CPhysicsObj* arg2) +{ + this->physics_obj = arg2; +} +``` + +### `SetMotionTableID` — 0x0051bbd0 (@290472) +```c +int32_t __thiscall MotionTableManager::SetMotionTableID(class MotionTableManager* this, class IDClass<_tagDataID,32,0> arg2) +{ + if (this->table != 0) + this->table->vtable->Release(); // drop existing CMotionTable ref + + class CMotionTable* loaded = DBObj::Get(QualifiedDataID(arg2, /*type tag*/ 0xe)); + int32_t result = (loaded != 0); + this->table = loaded; + return result; +} +``` +Type-tag `0xe` identifies the DBObj category for motion tables in `DBObj::Get`'s qualified-ID +dispatch. + +### `GetMotionTableID` — 0x0051bc10 (@290490) +```c +class IDClass<_tagDataID,32,0>* __thiscall MotionTableManager::GetMotionTableID( + class MotionTableManager const* this, class IDClass<_tagDataID,32,0>* __return) +{ + if (this->table != 0) + { + *(uint32_t*)__return = this->table->m_DID.id; + return __return; + } + *(uint32_t*)__return = INVALID_DID.id; + return __return; +} +``` + +### `Create` (static factory) — 0x0051bc50 (@290510) +```c +class MotionTableManager* MotionTableManager::Create(class IDClass<_tagDataID,32,0> arg1) +{ + void* result = operator new(0x2c); // sizeof(MotionTableManager) == 44 bytes + if (result == 0) return 0; + + result->physics_obj = 0; + result->table = 0; + MotionState::MotionState((char*)result + 8); // placement-construct the embedded `state` member + result->animation_counter = 0; // offset +0x20 + result->pending_animations.head_ = 0; // +0x24 + result->pending_animations.tail_ = 0; // +0x28 + + if (arg1 != INVALID_DID.id) + MotionTableManager::SetMotionTableID(result, arg1); + + return result; +} +``` +Offset map confirms the struct layout above: `physics_obj`@0x0, `table`@0x4, `state`@0x8 +(24 bytes: style/substate/substate_mod/modifier_head/action_head/action_tail = 4+4+4+4+4+4=24, +i.e. 0x8..0x1f), `animation_counter`@0x20, `pending_animations`@0x24 (head_@0x24, tail_@0x28). +Total 0x2c = 44 bytes, matches `operator new(0x2c)`. + +### `truncate_animation_list` — 0x0051bca0 (@290533) +```c +void __thiscall MotionTableManager::truncate_animation_list(class MotionTableManager* this, + class MotionTableManager::AnimNode const* arg2 /* stop-at node, exclusive */, class CSequence* arg3) +{ + if (arg2 == 0) return; + + // Walk pending_animations.tail_ BACKWARD toward head_, zeroing each node's num_anims (tick + // count) field and accumulating the total ticks removed, UNTIL we reach arg2 (not included). + DLListData* node = this->pending_animations.tail_; + uint32_t removedTicks = 0; + while (node != arg2) + { + if (node == 0) return; // arg2 wasn't actually in the list -> abort quietly + removedTicks += ((AnimNode*)node)->num_anims; + ((AnimNode*)node)->num_anims = 0; // neuter the tick count (node stays in the list though!) + node = node->dllist_prev; + } + + CSequence::remove_link_animations(arg3, removedTicks); // strip that many ticks' worth of link anims from the sequence +} +``` +Note: this does **not unlink** the neutered nodes from `pending_animations` — it only zeroes +their `num_anims` tick-countdown so `CheckForCompletedMotions`/`AnimationDone` treat them as +"already complete" and pop them without firing `CPhysicsObj::MotionDone` for their real motion +(since the countdown-based completion loop tests `num_anims == 0` and immediately dequeues+frees +them, but WITHOUT the `0x10000000` action-head-pop / `MotionDone` callback semantics being +meaningfully attached to a zeroed node — effectively "cancel these queued transitions silently"). + +### `AnimationDone` — 0x0051bce0 (@290558) +```c +void __thiscall MotionTableManager::AnimationDone(class MotionTableManager* this, int32_t arg2 /* success flag passed to CPhysicsObj::MotionDone */) +{ + DLListData* head = this->pending_animations.head_; + if (head == 0) return; + + this->animation_counter += 1; + + // Pop every head node whose tick-countdown (num_anims) is <= the running animation_counter + while (((AnimNode*)head)->num_anims <= this->animation_counter) + { + if ((((AnimNode*)head)->motion & 0x10000000) != 0) // action-class node + MotionState::remove_action_head(&this->state); // pop the matching queued action + + CPhysicsObj::MotionDone(this->physics_obj, ((AnimNode*)head)->motion, arg2); + this->animation_counter -= ((AnimNode*)head)->num_anims; + + // Manual DLList unlink-and-free of `head` (standard doubly-linked-list node removal, + // handling both-null / single-neighbor / has-neighbor cases for prev and next pointers) + DLListUnlinkAndDelete(&this->pending_animations, head); + + head = this->pending_animations.head_; + if (head == 0) break; + } + + if (this->animation_counter != 0 && head == 0) + this->animation_counter = 0; // list drained -> reset counter to avoid drift +} +``` +Cleaned intent: **advance the animation clock by one tick and fire `MotionDone` for every +queued motion whose duration has elapsed.** `num_anims` on a pending-queue node doubles as a +**relative tick-duration** (not an absolute deadline) — that's why it's subtracted from +`animation_counter` after firing (a decrementing countdown chain, not absolute timestamps). + +### `HandleExitWorld` — 0x0051bda0 (@290625) +```c +void __fastcall MotionTableManager::HandleExitWorld(class MotionTableManager* this, class CSequence* arg2) +{ + while (this->pending_animations.head_ != 0) + MotionTableManager::AnimationDone(this, 0); // drain the whole queue, signaling "failure/aborted" (arg2=0) +} +``` + +### `HandleEnterWorld` — 0x0051bdd0 (@290634) +```c +void __thiscall MotionTableManager::HandleEnterWorld(class MotionTableManager* this, class CSequence* arg2) +{ + CSequence::remove_all_link_animations(arg2); // strip any stale link anims from the sequence + while (this->pending_animations.head_ != 0) + MotionTableManager::AnimationDone(this, 0); // drain the whole queue (same as ExitWorld) +} +``` +Both handlers fully drain `pending_animations` — the difference is `HandleEnterWorld` also +scrubs the live `CSequence`'s link animations first (fresh-world-state guarantee). + +### `CheckForCompletedMotions` — 0x0051be00 (@290645) +```c +void __fastcall MotionTableManager::CheckForCompletedMotions(class MotionTableManager* this) +{ + DLListData* head = this->pending_animations.head_; + if (head == 0) return; + + // Pop every head node whose tick-countdown is EXACTLY 0 already (no counter increment here, + // unlike AnimationDone — this is the "did anything finish with zero remaining ticks" poll) + while (((AnimNode*)head)->num_anims == 0) + { + if ((((AnimNode*)head)->motion & 0x10000000) != 0) + MotionState::remove_action_head(&this->state); + + CPhysicsObj::MotionDone(this->physics_obj, ((AnimNode*)head)->motion, 1); // success flag = 1 (hardcoded) + + DLListUnlinkAndDelete(&this->pending_animations, head); + + head = this->pending_animations.head_; + if (head == 0) break; + } +} +``` +Distinction from `AnimationDone`: `CheckForCompletedMotions` never increments +`animation_counter` and only pops nodes that are ALREADY at `num_anims == 0` (e.g. zero-tick +entries, or ones neutered by `truncate_animation_list`), always signaling success (`arg2=1` +hardcoded). `AnimationDone` is the real per-tick clock; `CheckForCompletedMotions`/`UseTime` is +a lightweight "sweep any already-zero entries" pass called every frame via `UseTime`. + +### `Destroy` — 0x0051be90 (@290705) +```c +void __fastcall MotionTableManager::Destroy(class MotionTableManager* this) +{ + // Unconditionally unlink+delete every node in pending_animations (no MotionDone callbacks fired) + while (this->pending_animations.head_ != 0) + DLListUnlinkAndDelete(&this->pending_animations, this->pending_animations.head_); + + if (this->table != 0) + { + this->table->vtable->Release(); + this->table = nullptr; + } +} +``` + +### `~MotionTableManager` — 0x0051bf00 (@290761) +```c +void __fastcall MotionTableManager::~MotionTableManager(class MotionTableManager* this) +{ + MotionTableManager::Destroy(this); + /* tailcall */ + return MotionState::~MotionState(&this->state); // frees modifier_head + action_head/tail chains +} +``` + +### `remove_redundant_links` — 0x0051bf20 (@290771) +```c +void __thiscall MotionTableManager::remove_redundant_links(class MotionTableManager* this, class CSequence* arg2) +{ + DLListData* tail = this->pending_animations.tail_; + if (tail == 0) return; + + // Skip backward over any trailing zero-tick nodes (already-neutered/instant entries) + while (((AnimNode*)tail)->num_anims == 0) + { + tail = tail->dllist_prev; + if (tail == 0) return; + } + + uint32_t motion = ((AnimNode*)tail)->motion; + + if ((motion & 0x40000000) != 0 && (motion & 0x20000000) == 0) + { + // CYCLE-class (and not also modifier-class) tail node: look backward for an EARLIER + // node with the SAME motion id that also has a non-zero tick count — if found, every + // node between them is a now-redundant intermediate link that can be truncated, UNLESS + // an intervening node has a HIGH-priority class bit (0xb0000000 = cycle|action|... mask) + // with a non-zero tick count, in which case we bail (can't safely collapse through it). + DLListData* scan = tail->dllist_prev; + while (scan != 0) + { + uint32_t scanMotion = ((AnimNode*)scan)->motion; + if (scanMotion == motion && ((AnimNode*)scan)->num_anims != 0) + break; // found the earlier matching node -> collapse everything after it + if (((AnimNode*)scan)->num_anims != 0 && (scanMotion & 0xb0000000) != 0) + return; // blocked by an intervening "important" non-zero node -> can't collapse + scan = scan->dllist_prev; + if (scan == 0) return; + } + if (scan != 0) + MotionTableManager::truncate_animation_list(this, scan, arg2); + } + else if ((int32_t)motion < 0) + { + // STYLE-class tail node (top bit set): same backward scan, but the "block" mask is + // wider (0x70000000 = cycle|action|modifier) and the match test is exact-equality only + // (no separate not-modifier-class requirement on the match itself). + DLListData* scan = tail->dllist_prev; + while (scan != 0) + { + uint32_t scanMotion = ((AnimNode*)scan)->motion; + if (scanMotion == motion) + break; + if (((AnimNode*)scan)->num_anims != 0 && (scanMotion & 0x70000000) != 0) + return; + scan = scan->dllist_prev; + if (scan == 0) return; + } + if (scan != 0) + MotionTableManager::truncate_animation_list(this, scan, arg2); + } +} +``` +Cleaned intent: **collapse redundant back-to-back re-entries of the same motion in the pending +queue** — e.g. if the player rapidly re-issues the same run cycle or the same style change +several times before the first one's link animation finishes, everything between the earlier +matching entry and the new tail entry is provably superseded and gets truncated (ticks zeroed), +UNLESS a higher-priority class of motion (action/modifier for cycle-tails; action/modifier/cycle +for style-tails) sits in between with real remaining ticks, in which case the collapse is +unsafe and skipped. + +### `UseTime` — 0x0051bfd0 (@290845) +```c +void __fastcall MotionTableManager::UseTime(class MotionTableManager* this) +{ + /* tailcall */ + return MotionTableManager::CheckForCompletedMotions(this); +} +``` +Per-frame entry point (called from `CSequence`'s owning object's per-tick update, per the +earlier `MotionTableManager::UseTime(motion_table_manager)` call site at 0x00517d67/@285852) +that just sweeps already-complete queue entries. + +### `add_to_queue` — 0x0051bfe0 (@290854) +```c +void __thiscall MotionTableManager::add_to_queue(class MotionTableManager* this, uint32_t arg2 /*motion*/, uint32_t arg3 /*ticks*/, class CSequence* arg4) +{ + AnimNode* node = (AnimNode*)operator new(0x10); // sizeof(AnimNode) == 16: dllist_next+prev (8) + motion+num_anims (8) + if (node != 0) + { + node->dllist_next = 0; + node->dllist_prev = 0; + node->motion = arg2; + node->num_anims = arg3; // NOTE: here num_anims holds the TICK COUNT (outTicks from GetObjectSequence), not an anim array length + } + DLListBase::InsertAfter(&this->pending_animations, node, this->pending_animations.tail_); + MotionTableManager::remove_redundant_links(this, arg4); // opportunistically collapse right after insert +} +``` + +### `initialize_state` — 0x0051c030 (@290875) +```c +void __thiscall MotionTableManager::initialize_state(class MotionTableManager* this, class CSequence* arg2) +{ + uint32_t outTicks = 0; + if (this->table != 0) + CMotionTable::SetDefaultState(this->table, &this->state, arg2, &outTicks); + // else outTicks stays 0 + + AnimNode* node = (AnimNode*)operator new(0x10); + if (node != 0) + { + node->dllist_next = 0; + node->dllist_prev = 0; + node->motion = 0x41000003; // sentinel motion id: "initial default state installed" marker + node->num_anims = outTicks; + } + DLListBase::InsertAfter(&this->pending_animations, node, this->pending_animations.tail_); + MotionTableManager::remove_redundant_links(this, arg2); +} +``` +`0x41000003` recurs as the sentinel motion id for "stop completely / reset to default" queue +entries — also used verbatim in `PerformMovement`'s `StopCompletely` and +`StopInterpretedCommand`-failure paths below. + +### `PerformMovement` — 0x0051c0b0 (@290906) — COMPLETE + +```c +uint32_t __thiscall MotionTableManager::PerformMovement(class MotionTableManager* this, + class MovementStruct const* arg2, class CSequence* arg3) +{ + if (this->table == 0) + return 7; // error code: no motion table loaded + + uint32_t outTicks = 0; + + switch (arg2->type) + { + case MovementTypes::InterpretedCommand: // 0x2 + if (CMotionTable::DoObjectMotion(this->table, arg2->motion, &this->state, arg3, + arg2->params->speed, &outTicks) != 0) + { + MotionTableManager::add_to_queue(this, arg2->motion, outTicks, arg3); + return 0; // success + } + // fall through to the trailing `return 0x43` below (BN control flow: no explicit + // else-branch for the failed-DoObjectMotion case within InterpretedCommand) + break; + + case 4: // MovementTypes::StopInterpretedCommand + if (CMotionTable::StopObjectMotion(this->table, arg2->motion, arg2->params->speed, + &this->state, arg3, &outTicks) != 0) + { + MotionTableManager::add_to_queue(this, 0x41000003, outTicks, arg3); // sentinel motion id on stop-success + return 0; + } + break; // falls through to 0x43 on failure, same as above + + case 5: // MovementTypes::StopCompletely + CMotionTable::StopObjectCompletely(this->table, &this->state, arg3, &outTicks); + MotionTableManager::add_to_queue(this, 0x41000003, outTicks, arg3); + return 0; + + default: // anything else (RawCommand, StopRawCommand, MoveToObject, MoveToPosition, + // TurnToObject, TurnToHeading, Invalid) is NOT handled by MotionTableManager at all + return arg3; // BN artifact: returns the CSequence pointer reinterpreted as uint32_t — + // effectively "not my job, caller's arg3 pointer value leaks out as the + // return code" for these unhandled types. (Likely dead/unreachable in + // practice since callers gate on type before reaching here, or this + // return value is never consulted for those types.) + } + + return 0x43; // shared failure code: DoObjectMotion / StopObjectMotion returned false +} +``` +Cleaned high-level flow: **`PerformMovement` is the single chokepoint between the wire-level +`MovementStruct` (interpreted command / stop / stop-completely) and the motion-table state +machine.** `InterpretedCommand` drives `DoObjectMotion` → `GetObjectSequence(...,arg7=0)`. +`StopInterpretedCommand` (type 4) drives `StopObjectMotion` → `StopSequenceMotion`. +`StopCompletely` (type 5) drives `StopObjectCompletely` (strip all modifiers + stop base cycle), +**unconditionally** queuing the `0x41000003` sentinel regardless of return value (unlike the +other two cases which only queue on success). Every successful path funnels the resulting +tick count into `add_to_queue`, which itself opportunistically calls +`remove_redundant_links` to collapse superseded queue entries. + +--- + +## 12. Where `MotionTableManager` is driven from `CPhysicsObj`/`CSequence` (call-site context, not full functions — for orientation only) + +``` +0x00517d1d @285801 MotionTableManager::initialize_state(motion_table_manager, &this->sequence) +0x00517d37 @285815 return MotionTableManager::AnimationDone(motion_table_manager, arg2) +0x00517d57 @285838 return MotionTableManager::CheckForCompletedMotions(motion_table_manager) +0x00517d67 @285852 return MotionTableManager::UseTime(motion_table_manager) +0x00517d7d @285863 MotionTableManager::HandleEnterWorld(motion_table_manager, &this->sequence) +0x00517d9d @285877 MotionTableManager::HandleExitWorld(motion_table_manager, edx) +0x00518722 @286744 class MotionTableManager* eax_2 = MotionTableManager::Create(ebx) +0x00518737 @286750 MotionTableManager::SetPhysicsObject(eax_2, this->owner) +0x005186ed @286753 else if (MotionTableManager::GetMotionTableID(motion_table_manager, &arg2)->id != ebx) +0x00518707 @286759 MotionTableManager::~MotionTableManager(motion_table_manager_1) +0x005187e9 @286793 return MotionTableManager::PerformMovement(motion_table_manager, &var_64, &this->sequence) +0x00518889 @286819 return MotionTableManager::PerformMovement(motion_table_manager, &var_64, &this->sequence) +0x00518917 @286843 return MotionTableManager::PerformMovement(motion_table_manager, &var_64, &this->sequence) +0x005192ad @287430 MotionTableManager::~MotionTableManager(motion_table_manager) +``` +These are all inside a `CPhysicsObj`-scoped wrapper (the surrounding function at ~0x00517xxx +is a `CPhysicsObj` member that owns `motion_table_manager` and `this->sequence`; three separate +call sites to `PerformMovement` at 0x005187e9/0x00518889/0x00518917 correspond to three distinct +public `CPhysicsObj` motion-request entry points that all funnel into the same +`PerformMovement(this, &localMovementStruct, &this->sequence)` pattern). Not expanded further — +out of scope for the motion-table layer itself per the task (these are `CPhysicsObj`, not +`CMotionTable`/`MotionTableManager`/free-function territory). + +--- + +## 13. `MotionState` member functions (state the motion table reads/writes — full support cast) + +### `MotionState::MotionState` (default ctor) — 0x00525fd0 (@302759) +```c +void __fastcall MotionState::MotionState(class MotionState* this) +{ + this->style = 0; + this->substate = 0; + this->substate_mod = 1f; + this->modifier_head = nullptr; + this->action_head = nullptr; + this->action_tail = nullptr; +} +``` + +### `MotionState::MotionState` (copy ctor) — 0x00526790 (@303432) — signature only (body not required by task scope; used by `re_modify`'s snapshot copy) +```c +void __thiscall MotionState::MotionState(class MotionState* this, class MotionState const* arg2) +``` + +### `MotionState::add_modifier_no_check` — 0x00525ff0 (@302772) +```c +void __thiscall MotionState::add_modifier_no_check(class MotionState* this, uint32_t arg2 /*motion*/, float arg3 /*speed_mod*/) +{ + MotionList* node = (MotionList*)operator new(0xc); // sizeof(MotionList) == 12: motion(4)+speed_mod(4)+next(4) + if (node != 0) + { + node->motion = 0; + node->speed_mod = 1f; + node->next = nullptr; + } + node->motion = arg2; + node->speed_mod = arg3; + node->next = this->modifier_head; // push-front onto modifier_head + this->modifier_head = node; +} +``` + +### `MotionState::add_modifier` — 0x00526340 (@303081) +```c +int32_t __thiscall MotionState::add_modifier(class MotionState* this, uint32_t arg2, float arg3) +{ + for (MotionList* i = this->modifier_head; i != 0; i = i->next) + if (i->motion == arg2) + return 0; // already present as a modifier -> caller must Stop-then-Add to "refresh" + + if (this->substate == arg2) + return 0; // already IS the current base substate -> refuse (no-op) + + MotionState::add_modifier_no_check(this, arg2, arg3); + return 1; +} +``` + +### `MotionState::remove_modifier` — 0x00526040 (@302794) +```c +void __thiscall MotionState::remove_modifier(class MotionState* this, class MotionList* arg2 /*node to remove*/, class MotionList* arg3 /*predecessor, or null if arg2 is head*/) +{ + if (arg3 != 0) + { + arg3->next = arg2->next; + operator delete(arg2); + return; + } + this->modifier_head = arg2->next; + operator delete(arg2); +} +``` + +### `MotionState::clear_modifiers` — 0x00526070 (@302810) +```c +void __fastcall MotionState::clear_modifiers(class MotionState* this) +{ + for (MotionList* i = this->modifier_head; i != 0; ) + { + MotionList* next = i->next; + operator delete(i); + i = next; + } + this->modifier_head = nullptr; +} +``` + +### `MotionState::add_action` — 0x005260a0 (@302828) +```c +void __thiscall MotionState::add_action(class MotionState* this, uint32_t arg2, float arg3) +{ + MotionList* node = (MotionList*)operator new(0xc); + if (node != 0) { node->motion=0; node->speed_mod=1f; node->next=nullptr; } + node->motion = arg2; + node->speed_mod = arg3; + node->next = nullptr; + + if (this->action_head == 0) + this->action_head = node; + + if (this->action_tail != 0) + this->action_tail->next = node; // append to tail (FIFO queue, unlike modifier's push-front stack) + + this->action_tail = node; +} +``` + +### `MotionState::clear_actions` — 0x005260f0 (@302859) +```c +void __fastcall MotionState::clear_actions(class MotionState* this) +{ + for (MotionList* i = this->action_head; i != 0; ) + { + MotionList* next = i->next; + operator delete(i); + i = next; + } + this->action_head = nullptr; + this->action_tail = nullptr; +} +``` + +### `MotionState::remove_action_head` — 0x00526120 (@302877) +```c +uint32_t __fastcall MotionState::remove_action_head(class MotionState* this) +{ + MotionList* head = this->action_head; + if (head == 0) return 0; + + MotionList* next = head->next; + uint32_t motion = head->motion; + this->action_head = next; + if (next == 0) + this->action_tail = next; // list now empty -> clear tail too + + operator delete(head); + return motion; // returns the popped action's motion id (caller mostly ignores it) +} +``` + +### `MotionState::~MotionState` — 0x00526330 (@303072) — signature only (frees both chains; body not dumped, trivially `clear_modifiers`+`clear_actions` equivalent based on ctor/field symmetry) +```c +void __fastcall MotionState::~MotionState(class MotionState* this) +``` + +--- + +## 14. Cross-reference: MotionList — one struct, two independent chains + +`MotionState` owns **two separate singly-linked `MotionList` chains** that never intermix: +- `modifier_head` — a **stack** (push-front via `add_modifier_no_check`, arbitrary removal via + `remove_modifier` given a node+predecessor pointer). Represents currently-active modifier + overlays (0x20000000-class motions). +- `action_head`/`action_tail` — a **FIFO queue** (append-tail via `add_action`, pop-front via + `remove_action_head`). Represents queued one-shot actions (0x10000000-class motions) waiting + for their turn to fire / complete. + +Both chains use the identical 12-byte `MotionList{motion, speed_mod, next}` node — only the +insertion/removal discipline differs, matching how the two motion classes behave semantically +(modifiers overlay/toggle in any order; actions execute in submission order). + +--- + +## 15. Constants summary (verbatim, all confirmed via grep against this dump) + +| Constant | Meaning | Source | +|---|---|---| +| `0x40000000` | cycle-class motion id bit | GetObjectSequence, StopSequenceMotion, remove_redundant_links | +| `0x20000000` | modifier-class motion id bit | GetObjectSequence, StopSequenceMotion, remove_redundant_links | +| `0x10000000` | action-class motion id bit | GetObjectSequence, AnimationDone, CheckForCompletedMotions | +| `0xb0000000` | combined cycle\|action\|(bit29 unset variant) block-mask used in remove_redundant_links's cycle-tail scan | remove_redundant_links | +| `0x70000000` | combined cycle\|action\|modifier block-mask used in remove_redundant_links's style-tail scan | remove_redundant_links | +| `0x41000003` | sentinel "stop-completely / default-state-installed" motion id queued to pending_animations | initialize_state, PerformMovement (StopInterpretedCommand success + StopCompletely) | +| `0.000199999995f` (~0.0002f) | epsilon for "speed is effectively zero" in change_cycle_speed | change_cycle_speed | +| `0xe` | DBObj qualified-type-tag for motion table assets | SetMotionTableID → DBObj::Get(QualifiedDataID(id, 0xe)) | +| `0x2c` (44 bytes) | sizeof(MotionTableManager) | Create's `operator new(0x2c)` | +| `0xc` (12 bytes) | sizeof(MotionList) | add_modifier_no_check / add_action's `operator new(0xc)` | +| `0x10` (16 bytes) | sizeof(MotionTableManager::AnimNode) | add_to_queue / initialize_state's `operator new(0x10)` | +| `0x14` (20 bytes) | AnimData element stride within MotionData::anims[] | add_motion's `ebx_1 += 0x14` | +| `0x2` (bit1) | MotionData.bitfield: "substate-gated" (is_allowed requires exact/default-substate match) | is_allowed | +| `0x1` (bit0) | MotionData.bitfield: "clear modifiers on entry" | GetObjectSequence (both cycle-class and style-change branches) | +| PerformMovement error codes | `7` = no table loaded; `0x43` = DoObjectMotion/StopObjectMotion returned failure; `0` = success | PerformMovement | +| `MovementTypes::Type` values | Invalid=0, RawCommand=1, InterpretedCommand=2, StopRawCommand=3, StopInterpretedCommand=4, StopCompletely=5, MoveToObject=6, MoveToPosition=7, TurnToObject=8, TurnToHeading=9 | acclient.h:2856 | + +--- + +## 16. Function inventory checklist (against the task's requested coverage) + +- [x] `CMotionTable::GetObjectSequence` 0x00522860 @298636 — COMPLETE, all 4 class branches + fast paths +- [x] `CMotionTable::get_link` 0x00522710 @298552 — COMPLETE +- [x] `CMotionTable::is_allowed` 0x005226c0 @298526 — COMPLETE +- [x] `CMotionTable::GetDefaultStyle`/`GetDefaultMotion` — **NOT PRESENT as named symbols** in this + PDB build (grepped `CMotionTable::` exhaustively — 22 members total, see full grep output + below). The task's "GetDefaultStyle/GetDefaultMotion" likely refer to the `style_defaults` + hash lookups (`LongNIValHash::lookup(&this->style_defaults, style, &out)`) + that appear inline throughout `GetObjectSequence`/`get_link`/`SetDefaultState`/`is_allowed` + — there is no separate named accessor method; it's always an inline hash lookup. +- [x] `CMotionTable::DoObjectMotion` 0x00523e90 @300045 — COMPLETE (thin wrapper) +- [x] `CMotionTable::StopObjectMotion` 0x00523ec0 @300053 — COMPLETE (thin wrapper) +- [x] `CMotionTable::StopSequenceMotion` 0x00522fc0 @298954 — COMPLETE +- [x] `CMotionTable::re_modify` 0x005222e0 @298300 — COMPLETE +- [x] `CMotionTable::StopObjectCompletely` 0x00523ed0 @300062 — COMPLETE (bonus, referenced by PerformMovement) +- [x] `CMotionTable::SetDefaultState` 0x005230a0 @299004 — COMPLETE (bonus, referenced by initialize_state) +- [x] `add_motion` 0x005224b0 @298437 — COMPLETE +- [x] `combine_motion` 0x00522580 @298472 — COMPLETE +- [x] `subtract_motion` 0x00522600 @298492 — COMPLETE +- [x] `change_cycle_speed` 0x00522290 @298276 — COMPLETE +- [x] `same_sign` 0x00522260 @298253 — COMPLETE (BN x87-flag noise cleaned) +- [x] `MotionTableManager` ctor/Create — COMPLETE (0x0051bc50 @290510) +- [x] `MotionTableManager::SetPhysicsObject` — COMPLETE (0x0051bbc0 @290464) +- [x] `MotionTableManager::PerformMovement` — COMPLETE, all 3 handled MovementTypes + default (0x0051c0b0 @290906) +- [x] `MotionTableManager::add_to_queue` 0x0051bfe0 @290854 — COMPLETE +- [x] `MotionTableManager::remove_redundant_links` 0x0051bf20 @290771 — COMPLETE +- [x] `MotionTableManager::truncate_animation_list` — COMPLETE (0x0051bca0 @290533) +- [x] `MotionTableManager::CheckForCompletedMotions` 0x0051be00 @290645(~290645) — COMPLETE +- [x] `MotionTableManager::AnimationDone` — COMPLETE (0x0051bce0 @290558) +- [x] `MotionTableManager::UseTime` — COMPLETE (0x0051bfd0 @290845) +- [x] `MotionTableManager::HandleEnterWorld`/`HandleExitWorld` — COMPLETE (0x0051bdd0/0x0051bda0) +- [x] `MotionTableManager::GetMotion_PersistentStates` — **NOT PRESENT** in this PDB build (confirmed + via exhaustive grep of `MotionTableManager::` — 16 total members, listed in section 11) +- [x] `MotionStruct`/state types (`MotionState`, `MotionList`, `MotionData`, `AnimNode`, + `MovementStruct`, `MovementTypes::Type`) — struct layouts + all reader/writer member + functions (section 13) COMPLETE +- [x] Grep of full `CMotionTable::` member list performed (22 total members: CMotionTable ctor, + scalar/vector deleting destructors, Allocate, re_modify, is_allowed, get_link, + GetObjectSequence, StopSequenceMotion, SetDefaultState, Pack, UnPack, Destroy, + DoObjectMotion, StopObjectMotion, StopObjectCompletely, ~CMotionTable — Pack/UnPack/Destroy + bodies not expanded here as out-of-scope serialization plumbing, not motion-selection logic) +- [x] Grep of full `MotionTableManager::` member list performed (16 total members, all covered above) diff --git a/docs/research/2026-07-02-r2-motiontable/r2-port-plan.md b/docs/research/2026-07-02-r2-motiontable/r2-port-plan.md new file mode 100644 index 00000000..a7018e89 --- /dev/null +++ b/docs/research/2026-07-02-r2-motiontable/r2-port-plan.md @@ -0,0 +1,299 @@ +# R2 port work-list — GetObjectSequence + MotionTableManager + +Inputs: `r2-motiontable-decomp.md` (verbatim retail extraction, this scratchpad), +`r2-ace-motiontable.md` (ACE cross-ref, this scratchpad), plan of record +`docs/plans/2026-07-02-retail-motion-animation-rewrite.md` (stage R2), R1 gap map +`docs/research/2026-07-02-r1-csequence/r1-gap-map.md`, R1 core +`src/AcDream.Core/Physics/Motion/{CSequence,AnimSequenceNode,FrameOps}.cs`, adapter +`src/AcDream.Core/Physics/AnimationSequencer.cs`, S2b sink +`src/AcDream.App/Rendering/RemoteMotionSink.cs`. + +**Precondition / state at R2 start:** R1 commits P0–P4 are landed (`1371c2a1`, +`778744bf`, `5138b8fb`, `658b91d8`); the P5 adapter cutover is IN the working tree +(AnimationSequencer already rehosted on `_core: CSequence`) but **uncommitted**, and +P6 (root-motion wiring + register sweep) is not started. **R2 depends on P5+P6 being +committed first.** Every CSequence primitive R2 needs already exists in the R1 core: +`AppendAnimation` (first_cyclic-slides, G10), `RemoveCyclicAnims`, +`RemoveLinkAnimations(count)`, `RemoveAllLinkAnimations`, `ClearPhysics`, +`Combine/SubtractPhysics`, `MultiplyCyclicAnimationFramerate` (framerates-only, G13), +`HasAnims`, and the `IAnimHookQueue.AddAnimDoneHook` seam (G5). + +--- + +## 0. Decomp ambiguities to pin BEFORE porting (the Q0 pseudocode commit) + +| # | Ambiguity | Evidence each way | Pin method | +|---|---|---|---| +| A1 | **`get_link` branch predicate is likely INVERTED in the BN extraction.** r2-motiontable-decomp §4 reads "both speed_mods negative → branch B (swap keys)". But under that reading the NORMAL forward case (both positive) takes branch A = outer hash keyed by **to**-substate, which contradicts (a) the dat Links layout the working adapter uses (outer key = `(style<<16)\|fromSubstate`, `AnimationSequencer.cs:989`), (b) ACE `MotionTable.cs:395-426` ("EITHER negative → reversed keys"), and (c) the call sites' arg roles (`get_link(style, currentSubstate, mod, targetSubstate, speed)`). If the predicate is "**either** negative → swapped-key branch", retail branch A == ACE's reverse branch and branch B == ACE's forward branch, and BOTH fallback blocks align exactly (either-neg fallback = style_defaults hop = ACE reverse fallback; both-pos fallback = `links[style<<16][to]` = ACE forward fallback). Same x87-flag-noise class as `same_sign` (§2) and the r1 gap-map hook-direction fix. | BN §4 vs ACE L395-426 + adapter GetLink (user-validated in the field: the reversed-key branch fixed the "left leg twitches" Ready→WalkBackward glitch) | Re-read the raw pseudo-C at @298552 for the flag test; if still ambiguous, cdb bp `acclient!CMotionTable::get_link` logging args + which hash bucket is probed during a Ready→WalkBackward start vs Ready→WalkForward start. Default resolution if cdb unavailable: ACE's reading (three independent corroborations). | +| A2 | **Branch-2 `signedSpeed` block** (no-double-hop leg): decomp cleans it as `(substate_mod==0 \|\| same_sign(substate_mod,arg5)) ? arg5 : -arg5` (§5 line ~550); ACE flips only one direction (`SubstateMod < 0 && speedMod > 0 → -speedMod`, MotionTable.cs:155-166). | BN x87 noise vs ACE asymmetry | Re-read raw @~0x00522bXX; conformance test both readings against a cdb golden of a Walk(-)→Walk(+) flip. | +| A3 | **outTicks field decode**: BN renders one term as `arg3->action_head` — actually MotionData's packed `num_anims` byte (decomp's own note, §5). All outTicks arithmetic = sum of each appended MotionData's `num_anims` (link chains) [+ base-cycle num_anims in Branch 1/2] − 1. | decomp note + ACE numAnims sums | Textual pin only; assert in tests: outTicks(walk→run direct) == link.num_anims + cycle.num_anims − 1. | +| A4 | **Adjudicate the 5 flagged ACE oddities** (r2-ace-motiontable "Flagged ACE-side oddities") against the decomp: (1) Action-branch numAnims double-count (ACE L227) — retail sums `outHop + actionLink [+ returnHop]` (never the base cycle, never one MotionData twice, §5 Branch 3) → **ACE bug, do not copy**; (2) `change_cycle_speed` silent no-op when old≈0 & new≠0 — retail §2 has the SAME structure → **port verbatim including the gap** (it's retail); (3) `GetLinkData` 0xFFFF mask — ACE-only helper, not ported; (4) `StopObjectCompletely` return = `finalStopOk ? 1 : anyModifierStopOk` (§9) — port verbatim; (5) `re_modify` snapshot double-removal — retail copy-ctor deep-copies the chains, the snapshot exists ONLY as the loop-termination bound (§6) → C# port: deep-copy MotionState, pop both, terminate on snapshot empty. | — | Documented adjudications in the pseudocode doc. | +| A5 | **DatReaderWriter `MotionData.Bitfield` availability** — `is_allowed` needs bit1 ("substate-gated") and Branches 1/2/4 need bit0 ("clear modifiers on entry"). `Bitfield` string is present in the DLL (`chorizite.datreaderwriter/1.0.0`); confirm it's on `Types.MotionData` (not just GfxObj etc.) at Q2 time. | DLL strings grep | One-line test against the Humanoid table (a known bit-2-gated cycle, e.g. TurnRight). | + +**Q0 cdb capture (one session serves all of R2):** bp +`CMotionTable::GetObjectSequence` / `get_link` / `StopSequenceMotion`, +`MotionTableManager::add_to_queue` / `remove_redundant_links` / +`truncate_animation_list` / `AnimationDone` / `CheckForCompletedMotions` with arg+ret +logging (pattern: `tools/cdb/l2g-observer.cdb`), user protocol: walk / run / shift-toggle +(fast path) / backward / turn-in-place / run-while-turning / sidestep / emote-while-running +/ attack / stop / stance change / rapid W-tapping (redundant-link collapse) / jump. + +--- + +## 1. ITEMIZED GAPS — current vs retail (R2 scope) + +Severity: **BLOCKER** = R2's conformance harness meaningless without it; **HIGH** = +visible animation wrongness / blocks R3; **MED** = edge-visible; **LOW** = textual. + +| # | Retail behavior acdream lacks/diverges on | Retail anchor | Current-code anchor | Severity | +|---|---|---|---|---| +| H1 | **No `CMotionTable::GetObjectSequence` — motion selection is a partial adapter hybrid.** Retail (0x00522860, decomp §5) is one dispatcher with 4 class branches (style `(int)id<0` / cycle `0x40000000` / action `0x10000000` / modifier `0x20000000`), plain-OR fallthrough, `is_allowed` gating, style-default double-hop link routing, modifier replay (`re_modify`), and outTicks. acdream's `SetCycle` implements only a subset of Branch 2 and its inventions (below) stand in for the rest. | decomp §5 @298636 | `AnimationSequencer.SetCycle` (:321-636), `PlayAction` (:791-888) | **BLOCKER** | +| H2 | **No `MotionState`** — no style/substate/substate_mod struct, no modifier stack (push-front `add_modifier_no_check` / dup-guarded `add_modifier` / `remove_modifier(node,prev)` / `clear_modifiers`), no action FIFO (`add_action` tail-append / `remove_action_head`). The adapter's `CurrentStyle/CurrentMotion/CurrentSpeedMod` is a 3-field flat approximation; modifiers/actions have no bookkeeping at all. | acclient.h:31081; decomp §13/§14 (all 9 members, 0x00525fd0-0x00526340) | `AnimationSequencer.cs` CurrentStyle/CurrentMotion/CurrentSpeedMod props; `RemoteMotionSink.cs:39-45` (per-UM axis duplicates) | **BLOCKER** | +| H3 | **No `MotionTableManager`** — no `pending_animations` DLList, no `animation_counter`, no `add_to_queue` (0x0051bfe0), no `remove_redundant_links` (0x0051bf20, 0xb0000000/0x70000000 block masks), no `truncate_animation_list` (0x0051bca0, zero-in-place + `remove_link_animations(ticks)`), no `AnimationDone` (0x0051bce0, counter-driven countdown-chain multi-pop + action-head pop + MotionDone), no `CheckForCompletedMotions`/`UseTime` (0x0051be00/0x0051bfd0, zero-tick sweep, success=1 hardcoded), no `initialize_state` (0x0051c030, `0x41000003` sentinel), no `HandleEnterWorld/HandleExitWorld` drains, no `PerformMovement` (0x0051c0b0, error codes 7/0x43/0). | decomp §11 (all 16 members) | nothing — the AnimationDoneSentinel the R1 core queues (CSequence.cs:413-418 → AnimationSequencer.cs:925) is consumed by NOBODY (gap map API table: "RemoteMotionSink/GameWindow don't consume AnimDone yet") | **BLOCKER** | +| H4 | **RemoteMotionSink's single-cycle pick** — axis collection (`_substate/_sidestep/_turn`), priority pick (fwd > sidestep > turn), `Commit()` one-SetCycle resolution. Retail: each funnel dispatch is its own `DoObjectMotion` → GetObjectSequence; turn/sidestep-while-moving resolve via `is_allowed` rejection of the (bitfield&2-gated) turn cycle → Branch 4 modifier combine (physics-only overlay), run cycle keeps playing; turn-in-place resolves via Branch 2 (cycle exists, allowed from the style default). Register row **AP-73** (retail-divergence-register.md:181). | decomp §3 (is_allowed) + §5 Branch 2/4 | `RemoteMotionSink.cs:141-216` (`Commit`), `:55-119` (`ApplyMotion` classify/collect) | **HIGH** — plan of record: "single-cycle pick DELETED — GetObjectSequence decides" | +| H5 | **HasCycle probe + Run→Walk→Ready missing-cycle fallback chain.** Existed because SetCycle clears the cyclic tail BEFORE knowing the cycle resolves ("torso on the ground"). Retail never has the problem: GetObjectSequence resolves ALL MotionData first, `clear_physics`/`remove_cyclic_anims` happen only inside a success path, missing cycles retry under `default_style` (Branch 2 second lookup) and otherwise `return 0` leaving the sequence untouched (PerformMovement → 0x43). | decomp §5 Branch 2 lines ~494-505 + return-0 tail | `RemoteMotionSink.cs:169-204`, `AnimationSequencer.HasCycle` (:281-295), GameWindow spawn fallbacks :3723-3825 | **HIGH** | +| H6 | **Fix B locomotion link-skip** (cyclic→cyclic transitions call `RemoveAllLinkAnimations` for the locomotion low-byte subset). Retail mechanism = `remove_redundant_links` on the PENDING QUEUE (tail-anchored backward scan for an earlier same-motion node; collapse via `truncate_animation_list` → zero ticks + `remove_link_animations(removedTicks)`; blocked by intervening non-zero 0xb0000000-class (cycle-tail scan) / 0x70000000-class (style-tail scan) nodes). The 2026-05-03 cdb trace in the Fix B comment block is the golden: cyclic→cyclic = `add_to_queue(45000005)` + `add_to_queue(44000007)`, truncate NOT firing — Fix B's outcome falls out of retail's structure (the link nodes the queue would truncate never get double-enqueued once GetObjectSequence owns link selection). | decomp §11 remove_redundant_links @290771 + truncate @290533 | `AnimationSequencer.cs:468-514` (Fix B block + `IsLocomotionCycleLowByte`) | **HIGH** — delete in favor of the queue mechanism | +| H7 | **Stop-anim fallback** (SetCycle low-byte remap WalkBackward→WalkForward etc. when linkData null). Retail: `adjust_motion` normalization happens UPSTREAM in CMotionInterp (already ported, D6.2a `0f099bb6`) so GetObjectSequence receives 0x05/0x0D/0x0F + signed speed; direction flips route via Branch 2's `link==null \|\| !same_sign` style-default double-hop (`get_link(...,styleDefault,1f)` + `get_link(styleDefault,1f,target,speed)`). | decomp §5 Branch 2 lines ~533-540 | `AnimationSequencer.cs:402-423` | **HIGH** | +| H8 | **Adapter fast path ≠ retail fast path.** Retail (§5 Branch 2): gate = `target==substate && same_sign(newSpeed, substate_mod) && has_anims()`, then `change_cycle_speed` (ratio, 0.0002f epsilon guards) + `subtract_motion(old)` + `combine_motion(new)` + commit substate_mod. acdream: gate keyed on Current* fields with its own 1e-4/1e-6 epsilons, and `MultiplyCyclicFramerate` folds a velocity/omega rescale composite in (the G13 stand-in, `AnimationSequencer.cs:673-686`). | decomp §5 lines ~513-522, §2 change_cycle_speed @298276 | `AnimationSequencer.cs:345-388`, `:673-686` | **HIGH** — G13/G17 retire here | +| H9 | **`add_motion` velocity gate (G17 adapter half).** Core `EnqueueMotionData` still gates `SetVelocity/SetOmega` on `MotionDataFlags.HasVelocity/HasOmega`; retail `add_motion` (0x005224b0) sets unconditionally (dat-silent MotionData carries zero → replace-with-zero), safe once modifiers route through `combine_motion` (Branch 4) instead of `add_motion`. | decomp §2 @298437 | `AnimationSequencer.cs:1044-1058` (documented "R2 stand-in") + `PlayAction:836-839,872-875` | MED — mechanical once Branch 4 exists | +| H10 | **PlayAction inventions**: (a) actions resolved via `GetLink` direct only — no default-substate out-and-back 4-layer chain (outHop@1.0 → actionLink@speed → returnHop@1.0 → base cycle@old substate_mod); (b) no `add_action` FIFO bookkeeping (so no completion pop, H3); (c) no `clear_physics`/`remove_cyclic_anims` rebuild — nodes are INSERTED before the cyclic tail + an invented cursor-jump (`AnimationSequencer.cs:877-887`); retail REBUILDS (base cycle restarts after the action); (d) modifier-class ids ENQUEUE ANIMS from the Modifiers dict — retail Branch 4 is **physics-only** (`combine_motion` velocity/omega; anims untouched) + `add_modifier` bookkeeping with the stop-then-re-add toggle. | decomp §5 Branch 3 (~591-647) / Branch 4 (~652-687) | `AnimationSequencer.PlayAction` :791-888 | **HIGH** | +| H11 | **No stop machinery**: `StopSequenceMotion` (0x00522fc0: cycle-stop = re-drive GetObjectSequence toward the style default with arg7=1; modifier-stop = `subtract_motion` + unlink), `StopObjectCompletely` (strip all modifiers then stop the substate), `SetDefaultState` (0x005230a0: full baseline reset, `clear_animations` not remove_cyclic). "Stop" today is whoever calls SetCycle(Ready 0x41000003). | decomp §7/§8/§9 | funnel `StopMotion` → `RemoteMotionSink.cs:121-131` (ObservedOmega zero only); GameWindow SetCycle(Ready) sites | **BLOCKER** for stop conformance | +| H12 | **The GetObjectSequence guard set**: entry guards (`style==0 \|\| substate==0` → 0), the modifier-class no-op fast path (`target==styleDefault && !stopCall && (substate & 0x20000000)` → 1), Branch 2's "leaving a modifier-class substate re-registers it as a modifier" (`add_modifier_no_check`) — none exist anywhere in acdream. | decomp §5 lines ~384-404, ~565-572 | — | HIGH (part of H1, called out because each is an easy silent omission) | +| H13 | **`re_modify` modifier replay across transitions** — every substate/style-changing branch replays the active modifier stack through recursive GetObjectSequence calls. This is DEV-9 / AP-73's "retail BLENDS modifiers over the substate cycle". | decomp §6 @298300 | — | **HIGH** — the plan of record's "(modifier blend — retires AP-73)" | +| H14 | **Style-change transitions absent** (Branch 1): stance switches never play the exit-link + style-to-style link + double-hop-via-default_style chain; RemoteMotionSink just stores `_style` and SetCycle keys the cycle dict with it. | decomp §5 Branch 1 (~411-487) | `RemoteMotionSink.cs:57-61`; `SetCycle` (style used only as key material) | MED-HIGH (visible on combat-stance changes) | +| H15 | **Spawn baseline**: retail `initialize_state` → `SetDefaultState` + queue `0x41000003` sentinel; enter/exit-world drain the queue (`AnimationDone(0)` loop; enter also `remove_all_link_animations`). acdream: RenderBootstrap 3-tier fallback + GameWindow SetCycle(Ready)/HasCycle chains. | decomp §11 initialize_state/HandleEnterWorld/HandleExitWorld | GameWindow :3723-3825; `RenderBootstrap.SequencerFactory` | MED | +| H16 | **MotionDone signal dead-ends.** Retail chain: CSequence AnimDone gate → AnimDoneHook singleton → `Hook_AnimDone` → `CPartArray::AnimationDone(1)` → `MotionTableManager::AnimationDone` → countdown pop → `CPhysicsObj::MotionDone(motion, success)` → (R3) CMotionInterp pending_motions. acdream stops at `AnimationDoneSentinel` in `_pendingHooks`; nothing counts it. | decomp §11 AnimationDone @290558 + gap map G5 | `AnimationSequencer.cs:918-931`; GameWindow :9882 drain ignores the sentinel type | **HIGH** — R2's named deliverable; see §4 below | +| H17 | **ObservedOmega side-write** — the sink seeds `RemoteMotion.ObservedOmega` from wire turns (`RemoteMotionSink.cs:95-101`) so remote rotation starts same-tick. Retail: turn omega enters the sequence via Branch 2 add_motion (turn cycle omega + adapter synthesis) or Branch 4 combine_motion, and body rotation comes from `CSequence.omega` through apply_physics per tick (R6 tick-order territory). | decomp §2 combine_motion; plan R6 | `RemoteMotionSink.cs:95-101`, `:127` | MED — carry the side-write into the replacement sink verbatim (register row), retire in R6 | + +--- + +## 2. KEEP LIST — already matching retail (do not re-port) + +| Behavior | Retail anchor | acdream anchor | +|---|---|---| +| `get_link` two-branch sign-aware lookup + both fallbacks (pending A1 pin, which almost certainly CONFIRMS it) | 0x00522710 §4 (predicate per A1) | `AnimationSequencer.GetLink` :961-1005 — re-home into CMotionTable verbatim, do not rewrite | +| Cycle/link/modifier hash keying incl. 32-bit `<<16` truncation of full command words (`(0x8000003D<<16)==0x003D0000`) | §5 key math throughout | `SetCycle`/`HasCycle`/`PlayAction` key builds (`:426,293,824`) — carry the full-command-word convention into CMotionTable | +| `AnimData` speed scaling: framerate only (`AnimData::operator*`) | 0x00525d00 (r1 §25) | `BuildNode` :1014-1024 / `EnqueueMotionData` append path | +| `append_animation` first_cyclic-slides-to-tail + curr_anim seed (the structural base every add_motion depends on) | 0x00525510 (r1 §24) | `CSequence.AppendAnimation` (Motion/CSequence.cs:109-123) | +| Full remove-family + apricot + combine/subtract_physics + multiply_cyclic_animation_fr (framerates-only) | r1 §5-§14 | `CSequence` :163-253 — R2 calls these, zero changes | +| AnimDone LIST-STRUCTURE gate (head != first_cyclic) + IAnimHookQueue seam | 0x00525943 (r1 G5) | `CSequence.UpdateInternal` :407-422 — R2 only adds the CONSUMER | +| adjust_motion normalization upstream of dispatch (left→right / backward→forward + sign) | CMotionInterp (D6.2a port) | `MotionInterpreter` normalization (commit `0f099bb6`) — stays upstream; Q4 verifies single-site and deletes the SetCycle-head duplicate | +| Inbound funnel: `MoveToInterpretedState` / `ApplyInterpretedMovement` / `DispatchInterpretedMotion` / `contact_allows_move` + 183-case conformance suite | S2a (7b0cbbda) | `MotionInterpreter.cs:1312-1420` — R2 sits BELOW it (replaces only the sink) | +| `MotionSequenceGate` (S1), `InterpolationManager` (L.3), outbound packers | plan "absorbed" list | untouched | +| K-fix18 `skipTransitionLink` instant-engage (jump/Falling) | none — invented; retires in **R3** jump family | `AnimationSequencer.cs:321,398,441-444`; GameWindow :4817-4831, :10224 — SURVIVES R2 at adapter, byte-identical, register row kept | +| Locomotion velocity synthesis (Walk 3.12 / Run 4.0 / Side 1.25 m·s⁻¹) + turn omega synthesis (π/2 rad·s⁻¹) | retail `get_state_velocity` (R3 scope) | `SetCycle` :539-635 — SURVIVES R2 (runs after PerformMovement), register rows kept | +| Retail slerp + BuildBlendedFrame render blend | FUN_005360d0 | `SlerpRetailClient` / `BuildBlendedFrame` — untouched | +| `0x41000003` == full-word MotionCommand.Ready == retail's stop/default sentinel | decomp §15 | adapter already uses 0x41000003 as the Ready id | + +--- + +## 3. COMMIT SEQUENCE — dependency-sorted, each ONE commit, tests-first + +New code target: `src/AcDream.Core/Physics/Motion/` (plan rule 4). Tests: +`tests/AcDream.Core.Tests/Physics/Motion/` (pattern: R1's +`AnimSequenceNodeTests`/`CSequenceTests`). Every commit: build+test green, register +rows added/retired in-commit. + +**Q0 — pseudocode + ambiguity pinning (docs only).** +`docs/research/2026-07-0x-motiontable-pseudocode.md` from r2-motiontable-decomp.md, +resolving A1–A5 (§0 above) and adjudicating the 5 ACE oddities (A4). Run the ONE cdb +capture session (protocol in §0) — it feeds Q2/Q3/Q4 goldens. +Fixture source: **cdb** (live retail, l2g-observer.cdb pattern). +Deps: none (R1-P5/P6 committed is a precondition for Q4+, not Q0). + +**Q1 — `MotionState` verbatim.** (closes H2) +`Motion/MotionState.cs`: `Style/Substate/SubstateMod(=1f)` + modifier STACK +(`AddModifierNoCheck` push-front 0x00525ff0; `AddModifier` dup-guard + `substate==id` +refuse 0x00526340; `RemoveModifier(node, prev)` 0x00526040; `ClearModifiers` +0x00526070) + action FIFO (`AddAction` tail-append 0x005260a0; `RemoveActionHead` +0x00526120; `ClearActions` 0x005260f0) + deep-copy ctor (A4-#5: chains copied, not +shared — re_modify's snapshot is a termination bound). +Tests first: stack-vs-FIFO discipline tables; AddModifier rejection (already-present / +equals-substate); copy independence (mutate original, snapshot unchanged). +Fixture source: **synthetic**. +Deps: Q0. + +**Q2 — `CMotionTable` verbatim (pure selection logic, no queue).** (closes H1, H5-resolve-side, H7-routing, H8-core, H10-core, H11, H12, H13, H14; A1/A2/A5 land here) +`Motion/CMotionTable.cs` wrapping the DatReaderWriter `MotionTable` DBObj +(style_defaults/cycles/modifiers/links/default_style; keys = `(style<<16)|(id&0xFFFFFF)` +full-command-word convention). Free functions in the same file (retail free fns): +`add_motion` 0x005224b0 (**unconditional** SetVelocity/SetOmega — G17 core — + +AppendAnimation per speed-scaled AnimData), `combine_motion` 0x00522580 / +`subtract_motion` 0x00522600 (CombinePhysics/SubtractPhysics only — never anims), +`change_cycle_speed` 0x00522290 (0.0002f epsilons, verbatim incl. the A4-#2 gap), +`same_sign` 0x00522260. Members: `get_link` 0x00522710 (per A1 pin — expected: the +adapter's existing port re-homed), `is_allowed` 0x005226c0 (`Bitfield & 2` gate), +`GetObjectSequence` 0x00522860 (ALL of: entry guards; modifier-class no-op fast path; +Branch 1 style-change with exit-link + direct link + default_style double-hop + commit ++ re_modify + outTicks; Branch 2 with default_style cycle retry, is_allowed, re-speed +fast path (change_cycle_speed + subtract + combine), clear-modifiers bit0, direct-link +vs `!same_sign` double-hop, A2 signedSpeed, outgoing-modifier-substate re-registration, +re_modify, outTicks; Branch 3 action direct + 4-layer out-and-back, add_action, +outTicks; Branch 4 modifier physics-only combine + stop-then-re-add toggle), +`re_modify` 0x005222e0, `StopSequenceMotion` 0x00522fc0, `SetDefaultState` 0x005230a0 +(`clear_animations` hard reset), `DoObjectMotion`/`StopObjectMotion`/ +`StopObjectCompletely` 0x00523e90/ec0/ed0 (A4-#4 return semantics). +Tests first — the R2 conformance harness core: +(a) **dat fixtures** (Humanoid MotionTable via DatCollection, R1-P1 pattern): +Ready→Walk link+cycle chain shape; walk↔run re-speed fast path (framerates rescaled, +velocity = subtract-old+combine-new, NO list change); Walk→WalkBackward-normalized +(0x05, −speed) sign-flip → style-default double-hop; stance change → Branch 1 chain; +emote-while-running → 4-layer out-and-back with base cycle re-added at OLD +substate_mod; turn-in-place → Branch 2 cycle; **run-while-turning → is_allowed rejects +the gated turn cycle → Branch 4 physics-only combine, run anims untouched** (the AP-73 +mechanism test); modifier stop = subtract + unlink; StopObjectCompletely drains +modifiers then re-drives to style default; missing cycle → return 0, sequence +UNTOUCHED (H5); outTicks values per A3. +(b) **cdb goldens from Q0**: GetObjectSequence arg/ret + resolved-MotionData-key +conformance for the captured protocol. +Deps: Q1 (MotionState), R1 core. + +**Q3 — `MotionTableManager` + pending_animations.** (closes H3, H15-core) +`Motion/MotionTableManager.cs` + `AnimNode {Motion, NumAnims}`. Fields per `Create` +0x0051bc50: table, state, animation_counter, pending queue (LinkedList; +register row: managed list vs intrusive DLList — reuse the R1 AD-34 wording), plus an +`IMotionDoneSink` seam (stands in for `CPhysicsObj::MotionDone`; see §4). Methods: +`add_to_queue` 0x0051bfe0 (append + immediate `remove_redundant_links`), +`remove_redundant_links` 0x0051bf20 — **port retail's tail-anchored single scan** +(skip trailing zero-tick nodes; cycle-class-not-modifier tail: match earlier same-motion +non-zero node, blocked by intervening non-zero `0xb0000000`-class; style-class +(`(int)motion<0`) tail: exact match, blocked by non-zero `0x70000000`-class), NOT +ACE's restructured outer loop; `truncate_animation_list` 0x0051bca0 (zero `NumAnims` +in place — nodes stay queued — + `CSequence.RemoveLinkAnimations(removedTicks)`); +`AnimationDone(success)` 0x0051bce0 (counter += 1; pop every head with +`NumAnims <= counter`, action-class → `state.RemoveActionHead()`, fire +`sink.MotionDone(motion, success)`, counter −= NumAnims; drained-list counter reset); +`CheckForCompletedMotions` 0x0051be00 (zero-tick heads only, success=1, no counter +touch) + `UseTime` alias; `initialize_state` 0x0051c030 (SetDefaultState + queue +`0x41000003`/outTicks + redundancy pass); `HandleEnterWorld` (remove_all_link_animations ++ drain via AnimationDone(0)) / `HandleExitWorld` (drain only); `PerformMovement` +0x0051c0b0 (InterpretedCommand → DoObjectMotion → add_to_queue(motion, outTicks); +StopInterpretedCommand → StopObjectMotion → add_to_queue(**0x41000003**, outTicks) on +success; StopCompletely → StopObjectCompletely + **unconditional** +add_to_queue(0x41000003); error codes 7 / 0x43 / 0; other MovementTypes untouched). +Tests first: countdown-chain tables (one AnimationDone completing MULTIPLE entries via +counter rollover; leftover-counter reset on drain); truncate blocked/allowed matrices +for both masks; zero-tick sweep vs counter sweep distinction; enter/exit-world drains +fire MotionDone(success=0) for every queued motion; rapid same-motion re-issue → +collapse (the Fix B replacement proof); the Q0 golden: cyclic→cyclic walk→run yields +`add_to_queue(0x45000005)` + `add_to_queue(0x44000007)` with truncate NOT firing +(2026-05-03 trace, quoted in the Fix B comment block). +Fixture source: **synthetic** + **cdb goldens from Q0**. +Deps: Q2. + +**Q4 — adapter cutover: SetCycle/PlayAction rehosted on PerformMovement; DELETE Fix B + stop-anim fallback + fast-path composite + G17 gate; wire the queue drain.** (closes H6, H7, H8, H9, H10-adapter, H16-wiring; H12 guards live via Q2) +`AnimationSequencer` gains a `MotionTableManager` (constructed with the same +DatReaderWriter table + the CSequence core). `SetCycle(style, motion, speedMod, +skipTransitionLink)` becomes: dispatch style-class id then motion through +`PerformMovement(InterpretedCommand)` (MotionState now OWNS style/substate/ +substate_mod; `CurrentStyle/CurrentMotion/CurrentSpeedMod` become read-only mirrors — +GameWindow :3723/4827/4915/4919 + sink ctor keep compiling). `PlayAction` → the same +dispatch (action/modifier ids hit Branch 3/4). K-fix18 preserved byte-identical at +adapter: `skipTransitionLink` → post-dispatch `ClearAnimations`-of-links exactly as +today (register row survives → R3). Velocity/omega synthesis blocks run AFTER dispatch, +unchanged (→ R3). **DELETE:** the SetCycle-head adjust_motion duplicate (verify every +caller pre-normalizes via MotionInterpreter; if any GameWindow raw call site doesn't, +normalize at the adapter boundary ONCE and note it), the adapter fast-path block +(:345-388), the stop-anim low-byte fallback (:402-423), Fix B + +`IsLocomotionCycleLowByte` (:468-514), `MultiplyCyclicFramerate`'s velocity-rescale +composite (:681-686 — change_cycle_speed+subtract/combine are now real; G13 row +retired), `EnqueueMotionData`'s HasVelocity/HasOmega gate (:1055-1058 — add_motion +unconditional; G17 row retired), PlayAction's insert-before-tail + cursor-jump + +modifier-anim-enqueue (:836-887). **Queue drain wiring (same commit — the queue must +not grow unbounded):** GameWindow anim tick (:9876-9890) counts drained +`AnimationDoneSentinel` instances → `manager.AnimationDone(true)` per sentinel, and +calls `manager.UseTime()` once per tick (zero-tick completions: stop-with-no-link, +fast-path re-speed outTicks=0). `IMotionDoneSink` bound to a diagnostic recorder +(ACDREAM_DUMP_MOTION line) — consumed for real in R3 (register row: MotionDone +observed-not-consumed, retire R3). +Tests first: FULL existing suite green (parity bar) + pre-cutover recorded +SetCycle-sequence traces (captured BEFORE this commit) replayed → same selected +cycle/link identities + same hook stream, with EXPECTED-DIFF annotations documented +per case (known intentional changes: action overlays now rebuild → base cycle restarts; +direction flips route the double-hop) + #61 boundary-flash re-check under the new link +path. Empty-MotionTable tier: PerformMovement returns 7, sequencer stays do-nothing +(RenderBootstrap invariant, r1 API table). +Fixture source: **pre-cutover recorded adapter traces (synthetic harness)** + suite. +Deps: Q2+Q3 (+ R1-P5/P6 committed). + +**Q5 — RemoteMotionSink DELETED; funnel dispatches straight into PerformMovement; spawn/world lifecycle; AP-73 retired.** (closes H4, H5-callers, H11-callers, H15, H17-carry) +Replace `RemoteMotionSink` with a thin Core `IInterpretedMotionSink` implementation +(`Motion/MotionTableDispatchSink.cs` or direct on the entity's manager): +`ApplyMotion(motion, speed)` → `PerformMovement(InterpretedCommand{motion, speed})`; +`StopMotion(motion)` → `PerformMovement(StopInterpretedCommand{motion, 1f})`. No axis +collection, no priority pick, no Commit, no HasCycle probe, no Run→Walk→Ready chain — +GetObjectSequence + is_allowed decide (H4/H5). The ObservedOmega turn seed (H17) moves +verbatim into the new sink (register row updated: retire in R6 when apply_physics +drives remote rotation). GameWindow: `RemoteMotionSink` ctor sites (:4643-4646) swap +to the new sink; spawn/fallback SetCycle(Ready)/HasCycle chains (:3723-3825) → +`manager.initialize_state`; teleport/despawn/enter-world → HandleEnterWorld/ +HandleExitWorld. Delete `AnimationSequencer.HasCycle` if caller-free after this (else +keep as diagnostic, note it). +Tests first: S2a 183-case funnel conformance suite green with sink assertions +re-targeted (dispatch order is funnel-owned and unchanged; assertions move from +"collected axes" to "PerformMovement call sequence"); MotionInterpreterFunnelTests +green; live-protocol smoke via ACDREAM_DUMP_MOTION + ACDREAM_REMOTE_VEL_DIAG +(walk/run/toggle/backward/turn/circle/sidestep/emote/attack/stop, player+NPC+monster); +**ONE user visual pass** (walk↔run stride continuity, turn-while-running legs, emote +overlay, stop settle — the stage acceptance). +Registers: **AP-73 DELETED in this commit**; rows added for ObservedOmega side-write +(if not already covered) and any adapter boundary normalization from Q4. +Deps: Q4. + +**Q6 — register sweep + roadmap + digest (docs/cleanup only).** +Grep-sweep dead code (IsLocomotionCycleLowByte remnants, HasCycle callers, fallback +chains, SCFAST/SCFULL diag re-anchoring); reconcile every touched register row +(retired: AP-73, Fix B, stop-anim fallback, G13, G17; surviving with rows: K-fix18→R3, +velocity/omega synthesis→R3, ObservedOmega seed→R6, MotionDone-unconsumed→R3, +managed-LinkedList-vs-DLList); roadmap stage table (R2 shipped); memory digest note +(animation sequencer deep-dive cross-link: pending_animations gap CLOSED). +Deps: Q5. + +Parallelization note: Q1 and the Q0 cdb capture are independent; Q2/Q3 are +sequential-coupled (Q3 calls Q2); Q4/Q5 each touch GameWindow — do NOT fan out +(feedback_dont_parallelize_coupled_plan_slices). + +--- + +## 4. MotionDone → S2-funnel pending_motions (the R3 boundary contract) + +What R2 ships and where R3 plugs in: + +``` +CSequence.UpdateInternal [R1, shipped] + └─ G5 gate (head != first_cyclic) → IAnimHookQueue.AddAnimDoneHook + └─ AdapterHookQueue → AnimationDoneSentinel into _pendingHooks [R1-P5] +GameWindow anim tick (:9876-9890) [R2-Q4 wiring] + ├─ per drained AnimationDoneSentinel → manager.AnimationDone(success: true) + │ [retail: AnimDoneHook::Execute 0x00526c20 → Hook_AnimDone 0x0050fda0 + │ → CPartArray::AnimationDone(1) — one call per queued hook] + └─ once per tick → manager.UseTime() ≡ CheckForCompletedMotions() + [retail call sites 0x00517d57/0x00517d67; drains zero-tick entries: + stops-without-links, fast-path re-speeds (outTicks=0), truncated nodes] +MotionTableManager.AnimationDone / CheckForCompletedMotions [R2-Q3] + ├─ action-class head (0x10000000) → MotionState.RemoveActionHead() [R2-OWNED — + │ the action FIFO pop lives HERE, not in R3] + └─ IMotionDoneSink.MotionDone(uint motion, bool success) [R2 seam] + R2: diagnostic recorder only (register row: unconsumed until R3) + R3: MotionInterpreter.MotionDone — pops CMotionInterp.pending_motions, + action-class → RemoveAction from raw+interpreted state, recomputes + IsAnimating (ACE MotionInterp.cs:210-231; MovementManager.MotionDone + relay). The funnel's own note at MotionInterpreter.cs:1395 marks the + attachment point. +``` + +Two structural facts R3 must respect (from r2-ace-motiontable's headline finding, +decomp-confirmed): **MotionTableManager is UPSTREAM of CMotionInterp in the +completion chain, never a peer** — retail keeps TWO pending trackers +(`MotionTableManager.pending_animations` under CPartArray vs +`CMotionInterp.pending_motions` under MovementManager) and `CPhysicsObj::MotionDone` +feeds only the interp side; do not merge the queues. And the per-tick PLACEMENT of +both the sentinel drain and UseTime is provisional until **R6** installs retail's +`UpdateObjectInternal` order (process_hooks LAST; MovementManager.UseTime/ +CPartArray.HandleMovement mid-tick) — R2 documents the current GameWindow drain point +as the G6 seam, unchanged. + +Success-flag semantics to preserve: AnimationDone passes the CALLER's flag (true from +Hook_AnimDone; false from enter/exit-world drains); CheckForCompletedMotions hardcodes +true. R3's jump/HitGround logic keys off this flag — getting it wrong is invisible +until R3.