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

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

299 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 P0P4 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 A1A5 (§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<AnimNode>;
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.