- 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>
33 KiB
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_signdouble-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_modify0x005222e0,StopSequenceMotion0x00522fc0,SetDefaultState0x005230a0 (clear_animationshard reset),DoObjectMotion/StopObjectMotion/StopObjectCompletely0x00523e90/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);PerformMovement0x0051c0b0 (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 yieldsadd_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.