# Animation system parity audit — retail vs acdream (2026-07-30) **Status: COMPLETE — report-only investigation, no code changes made.** Scope: `CMotionInterp` / `CSequence` / `MotionTableManager` / `CMotionTable` retail method surfaces vs acdream's `src/AcDream.Core/Physics/MotionInterpreter.cs`, `src/AcDream.Core/Physics/Motion/*`, `src/AcDream.Core/Physics/AnimationSequencer.cs`, `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`. **Bottom line:** the core animation-selection/playback stack (`MotionInterpreter`/`CSequence`/`CMotionTable`/`MotionTableManager`) is a faithful, heavily retail-cited port. All 103 symbols.json-listed retail methods across the four classes were enumerated (section 1); only one is a genuine unexplained gap (`get_adjusted_max_speed`) and the rest of the "unported" set is verified dead code in retail itself. All 7 traced feel-visible flows (section 2) came back parity. The real, actionable gaps are narrow and listed in section 6's ranked catalog: an animated-emote authoring gap (AD-57), two already-known hook-timing residuals (TS-50/ TS-51) precisely rescoped against current code, one likely-already-fixed issue (#64) needing only live re-verification, and two untraced combat/casting-layer questions flagged for a future audit outside this scope. ## Note on the requested prior deep-dive doc The task referenced `docs/research/2026-06-04-animation-sequencer-deep-dive.md` as prior art. That file does not exist in this worktree or anywhere in git history (`git log --all --diff-filter=A -- "*animation-sequencer-deep-dive*"` returns nothing). `claude-memory/MEMORY.md` indexes it, and a same-named **skill** (`acdream-animation-sequencer-deep-dive`) exists that would presumably generate such a doc, but no prior run's output is present at that path or any other. The closest prior-art documents actually in the repo are: - `docs/research/2026-06-26-movement-animation-retail-parity-audit.md` (D1-D12 divergence list, dated before the R6/J-slice root-motion work — most of its wire-format findings, D1/D3/D4/D9, have likely since been superseded by TS-33/TS-47 and the R6 root-motion campaign; treated as historical baseline only, re-verified against current code below, not trusted at face value) - `docs/research/acclient_animation_map.md`, `docs/research/acclient_animation_pseudocode.md` - `docs/research/2026-04-21-animation-audit.md`, `docs/research/2026-04-28-combat-animation-planner.md` - `docs/research/2026-07-02-r1-csequence/r1-acdream-sequencer.md` - `docs/research/2026-07-02-inbound-motion-verbatim-port-handoff.md` This audit proceeds using those plus the R6 sections of `claude-memory/project_physics_collision_digest.md` (which describes what R6 already shipped — not re-audited here per task instructions) and a fresh grep sweep of the named retail decomp. --- ## 1. Method-coverage sweep Two independent passes fed this section: a dedicated method-coverage-sweep sub-agent did a symbol-by-symbol enumeration against `symbols.json` + pseudo-C call-site tracing, and the lead auditor separately read essentially the entirety of all four files directly (`MotionInterpreter.cs` ~2,600 of 3,182 lines read in full, `CSequence.cs`/`CMotionTable.cs`/`MotionState.cs`/ `AnimationSequencer.cs` read in full) and independently confirmed the sub-agent's headline finding (`GetMaxSpeed()` is the only max-speed accessor anywhere in the App/Runtime call sites — `grep` for `AdjustedMaxSpeed`/ `get_adjusted_max_speed` across `src/` returns zero hits outside the doc comment that names it as unported). The two passes agree; findings below are merged, with the sub-agent's table format preserved since it's more scannable than prose. **Methodology note (symbol-artifact class):** `symbols.json` occasionally attributes a class method name to an address whose pseudo-C body is a different, unrelated function, or lists two names at the identical address (most likely `/OPT:ICF` identical-code-folding at link time collapsing byte-identical trivial bodies, with the PDB keeping multiple aliases for one surviving address). Confirmed instances: `CMotionInterp::HandleEnterWorld` @ `0x00694750` resolves to `IDClass<>::~IDClass` (unrelated template destructor); `CMotionInterp::InqStyle` @ `0x00527B10` resolves to `CBaseFilter::GetPinVersion` (unrelated DirectShow class); `MotionTableManager::RemoveLinkAnimations` and `HandleEnterWorld` are both listed at `0x0051BDD0` (only one body exists there); `CMotionTable::Allocator`/`Allocate` are both listed at `0x004F96E0`. These are marked **SYMBOL-ARTIFACT** below rather than forced into ported/unported, since the decomp genuinely cannot answer what (if anything) that distinct method does. Also confirmed independently on both classes that have a `Pack`/`UnPack` family (`CSequence`, `CMotionTable`): these `PackObj`/`DBObj` serialization methods have **zero call sites anywhere in the 1.4M-line pseudo-C** outside their own bodies and a `.rdata` vtable-slot registration — dead code inherited from a shared server/client engine base, never invoked by the retail client itself. Their absence in acdream is correctly not a gap. ### CMotionInterp (41 symbols.json entries) | Retail method (addr) | acdream status | Cite | |---|---|---| | PerformMovement (0x00528E80) | ported-with-cite | `MotionInterpreter.cs:820` | | DoMotion (0x00528D20) | ported-with-cite | `MotionInterpreter.cs:880,932` | | StopMotion (0x00528530) | ported-with-cite | `MotionInterpreter.cs:982,1009` | | StopCompletely (0x00527E40) | ported-with-cite | `MotionInterpreter.cs:1078` | | get_state_velocity (0x00527D50) | ported-with-cite | `MotionInterpreter.cs:1188` | | adjust_motion (0x00528010) | ported-with-cite | `MotionInterpreter.cs:1290` | | apply_run_to_command (0x00527BE0) | ported-with-cite | `MotionInterpreter.cs:1355` | | apply_raw_movement (0x005287E0) | ported-with-cite | `MotionInterpreter.cs:1398,1498` | | apply_current_movement (0x00528870) | ported-with-cite | `MotionInterpreter.cs:1459` | | ReportExhaustion (0x005288D0) | ported-with-cite | `MotionInterpreter.cs:1619` | | SetWeenieObject (0x00528920) | ported-with-cite | `MotionInterpreter.cs:1671` | | SetPhysicsObject (0x00528970) | ported-with-cite | `MotionInterpreter.cs:1721` | | jump_charge_is_allowed (0x00527A50) | ported-with-cite | `MotionInterpreter.cs:1761` | | charge_jump (0x005281C0) | ported-with-cite | `MotionInterpreter.cs:1826` | | jump (0x00528780) | ported-with-cite | `MotionInterpreter.cs:1883` | | get_jump_v_z (0x00527AA0) | ported-with-cite | `MotionInterpreter.cs:1920` | | get_leave_ground_velocity (0x005280C0) | ported-with-cite | `MotionInterpreter.cs:1958` | | jump_is_allowed (0x005282B0) | ported-with-cite | `MotionInterpreter.cs:2026,2051` | | contact_allows_move (0x00528240) | ported-with-cite | `MotionInterpreter.cs:2123` | | add_to_queue (0x00527B80) | ported-with-cite | `MotionInterpreter.cs:2164` (`AddToQueue`) | | motions_pending (0x00527FE0) | ported-with-cite | `MotionInterpreter.cs:2173` | | MotionDone (0x00527EC0) | ported-with-cite | `MotionInterpreter.cs:2193` | | HandleExitWorld (0x00527F30) | ported-with-cite | `MotionInterpreter.cs:2245` | | is_standing_still (0x00527FA0) | ported-with-cite | `MotionInterpreter.cs:2266` | | motion_allows_jump (0x005279E0) | ported-with-cite | `MotionInterpreter.cs:2310` | | LeaveGround (0x00528B00) | ported-with-cite | `MotionInterpreter.cs:2373` (independently read in full) | | HitGround (0x00528AC0) | ported-with-cite | `MotionInterpreter.cs:2425` (independently read in full) | | enter_default_state (0x00528C80) | ported-with-cite | `MotionInterpreter.cs:2483` | | set_hold_run (0x00528B70) | ported-with-cite | `MotionInterpreter.cs:2521` | | SetHoldKey (0x00528BB0) | ported-with-cite | `MotionInterpreter.cs:2575` | | get_max_speed (0x00527CB0) | ported-with-cite | `MotionInterpreter.cs:2632` (`GetMaxSpeed`; doc comment includes a forensic re-derivation of the ×4.0 constant from raw x87 disassembly, UN-2 resolved) | | **get_adjusted_max_speed (0x00527D00)** | **UNPORTED-UNEXPLAINED** | None found — independently confirmed (see intro above). Sibling of `get_max_speed`; retail `InterpolationManager::adjust_offset` (pc:353107, `0x00555dbe`) chooses between the two via a static toggle `fUseAdjustedSpeed_`. acdream's dead-reckoning catch-up path (`RemoteMotionCombiner.cs:90`, `RuntimeRemotePhysicsUpdater.cs:254,291,685`) only ever calls `GetMaxSpeed()`. No register/ISSUES row covers this. | | move_to_interpreted_state (0x005289C0) | ported-with-cite | `MotionInterpreter.cs:2697` | | apply_interpreted_movement (0x00528600) | ported-with-cite | `MotionInterpreter.cs:2797` | | DoInterpretedMotion (0x00528360) | ported-with-cite | `MotionInterpreter.cs:2931,2944` | | StopInterpretedMotion (0x00528470) | ported-with-cite | `MotionInterpreter.cs:3093,3103` | | Create (0x00528C00) | ported, uncited | `MotionInterpreter.cs:795` — retail's `Create` calls `SetWeenieObject`/`SetPhysicsObject` while `initted==0`, making both no-ops; plain field assignment is behaviorally identical | | Destroy / ~CMotionInterp (0x00527B40 / 0x00527FF0) | trivial, skipped | GC-obviated (manual `pending_motions` free-list walk superseded by `LinkedList`) | | HandleEnterWorld (0x00694750) | SYMBOL-ARTIFACT | see methodology note | | InqStyle (0x00527B10) | SYMBOL-ARTIFACT | see methodology note | **~85% ported-with-cite** (35/41; ~95% of the 37 real/substantive methods after excluding 2 symbol-artifacts and 2 GC-obviated dtors). One genuine gap. ### CSequence (28 symbols.json entries) | Retail method (addr) | acdream status | Cite | |---|---|---| | ctor (0x005249F0) | ported, trivial | `CSequence.cs:81` (zero-init, matched by C# field defaults) | | set_object (0x00524820) | SYMBOL-ARTIFACT, functionally ported | address really resolves to `DBObj::SetDID`; behavioral equivalent is the public `HookObj` field, `CSequence.cs:300` | | set_velocity / set_omega (0x00524880 / 0x005248A0) | ported-with-cite | `CSequence.cs:236-237` | | execute_hooks (0x00524830) | ported-with-cite | `CSequence.cs:503` | | combine_physics / subtract_physics (0x005248C0 / 0x00524900) | ported-with-cite | `CSequence.cs:238-239` | | multiply_cyclic_animation_fr (0x00524940) | ported-with-cite | `CSequence.cs:249` | | get_curr_animframe (0x00524970) | ported-with-cite | `CSequence.cs:266` (independently read) | | set_placement_frame (0x005249B0) | ported-with-cite | `CSequence.cs:258` | | get_curr_frame_number (0x005249D0) | ported-with-cite | `CSequence.cs:274` | | apply_physics (0x00524AB0) | ported-with-cite | `CSequence.cs:285` (independently read) | | apricot (0x00524B40) | ported-with-cite | `CSequence.cs:219` (retail's own PDB-verified name, kept verbatim) | | has_anims (0x00524BD0) | ported-with-cite | `CSequence.cs:90` | | remove_link_animations (0x00524BE0) | ported-with-cite | `CSequence.cs:187` | | remove_all_link_animations (0x00524CA0) | ported-with-cite | `CSequence.cs:207` | | clear_physics / clear_animations (0x00524D50 / 0x00524DC0) | ported-with-cite | `CSequence.cs:149,140` | | remove_cyclic_anims (0x00524E40) | ported-with-cite | `CSequence.cs:163` | | **pack_size / Pack / UnPack** (0x00524F20 / 0x00525020 / 0x005259D0) | **UNPORTED, dead-code-verified** | zero external callers anywhere in the retail decomp; correctly not ported | | append_animation (0x00525510) | ported-with-cite | `CSequence.cs:109` (independently read) | | clear (0x005255B0) | ported-with-cite | `CSequence.cs:130` | | update_internal (0x005255D0) | ported-with-cite | `CSequence.cs:332` (independently read in full — the iterative frame-crossing loop, no safety cap, matches retail exactly) | | advance_to_next_animation (0x005252B0) | ported-with-cite | `CSequence.cs:435` (independently read) | | update (0x00525B80) | ported-with-cite | `CSequence.cs:307` | | ~CSequence (0x00524A30) | trivial, skipped | GC-obviated | **~79% ported-with-cite** (22/28; ~96% of substantive methods). Cleanest of the four classes — everything that drives frame playback, hook dispatch, or physics accumulation is ported and cited. The two known field-representation divergences (`double` vs x87 `long double` frame_number; `LinkedList` vs intrusive `DLList`) are already register rows AD-33/AD-34. **No concerning gap.** ### MotionTableManager (17 symbols.json entries) | Retail method (addr) | acdream status | Cite | |---|---|---| | initialize_state (0x0051C030) | ported-with-cite | `MotionTableManager.cs:353` | | AnimationDone (0x0051BCE0) | ported-with-cite | `MotionTableManager.cs:290` (independently read) | | CheckForCompletedMotions (0x0051BE00) | ported-with-cite | `MotionTableManager.cs:322` | | UseTime (0x0051BFD0) | ported-with-cite | `MotionTableManager.cs:342` | | HandleEnterWorld / RemoveLinkAnimations (both 0x0051BDD0) | ported-with-cite / SYMBOL-ARTIFACT-duplicate | `MotionTableManager.cs:373` — pseudo-C shows only one body at this address | | HandleExitWorld (0x0051BDA0) | ported-with-cite | `MotionTableManager.cs:385` | | SetPhysicsObject (0x0051BBC0) | deliberately-absent-with-reason | `MotionTableManager.cs:20-22` (file header: "C# has no physics_obj field — R2 leaves the CPhysicsObj::MotionDone target as an injectable seam") | | Create (0x0051BC50) | ported-with-cite | `MotionTableManager.cs:129,138` | | GetMotionTableID (0x0051BC10) | unported, verified low-risk | retail's only caller (`CPartArray::SetMotionTableID @ 0x005186e0`) uses it purely as a dirty-check before destroying+reconstructing the whole manager; acdream gets the same capability by constructing a fresh `AnimationSequencer`/`MotionTableManager` (`AnimationSequencer.cs:287`) | | PerformMovement (0x0051C0B0) | ported-with-cite | `MotionTableManager.cs:409` (independently read in full) | | SetMotionTableID (0x0051BBD0) | unported, verified low-risk | its only caller in the entire retail client is its own `Create` factory (single call site, pc:290526) | | truncate_animation_list (0x0051BCA0) | ported-with-cite | `MotionTableManager.cs:259` | | Destroy / ~MotionTableManager | trivial, skipped | GC-obviated | | remove_redundant_links (0x0051BF20) | ported-with-cite | `MotionTableManager.cs:191` (independently read in full — byte-for-byte match including the `0xb0000000`/`0x70000000` block masks) | | add_to_queue (0x0051BFE0) | ported-with-cite | `MotionTableManager.cs:166` | **~65% raw ported-with-cite** (11/17), plus 1 deliberately-absent and 2 unported-but-verified-zero-risk (both `MotionTableID` accessors — retail itself only "changes" a motion table by destroy+recreate at the `CPartArray` layer, exactly matching acdream's architecture). **No concerning gap.** ### CMotionTable (17 symbols.json entries) | Retail method (addr) | acdream status | Cite | |---|---|---| | ctor (0x004F94E0) | ported, uncited | `CMotionTable.cs:64` | | Pack / UnPack (0x00523180 / 0x005238C0) | **UNPORTED, dead-code-verified** | zero external callers, same dead `PackObj` family as `CSequence` | | Destroy / ~CMotionTable | trivial, skipped | GC-obviated (`cycles`/`modifiers`/`links` hash tables → `Dictionary<>`) | | GetDBOType (0x005268A0) | N/A, architecturally superseded | retail RTTI-style type tag; acdream's typed `Dats.Get()` generic accessor makes it unnecessary | | Allocator / Allocate (both 0x004F96E0) | SYMBOL-ARTIFACT-duplicate / trivial | `new CMotionTable(table)` supersedes the placement-new+construct factory directly | | SetDefaultState (0x005230A0) | ported-with-cite | `CMotionTable.cs:605` (independently read) | | DoObjectMotion / StopObjectMotion / StopObjectCompletely (0x00523E90/0x00523EC0/0x00523ED0) | ported-with-cite | `CMotionTable.cs:635,640,652` | | re_modify (0x005222E0) | ported-with-cite | `CMotionTable.cs:528` | | is_allowed (0x005226C0) | ported-with-cite | `CMotionTable.cs:172` | | get_link (0x00522710) | ported-with-cite | `CMotionTable.cs:201` (independently read — the reversed-key branch, field-validated per its own doc comment) | | GetObjectSequence (0x00522860) | ported-with-cite | `CMotionTable.cs:255` — independently read in full; the single highest-stakes function in this whole sweep (branch-heavy style/cycle/action/modifier dispatcher), ported branch-for-branch with inline citations, including three explicitly-preserved retail quirks (A4-#1 double-hop tick counting never double-charging the base cycle; A4-#2 silent no-op in `ChangeCycleSpeed` when old speed ~0 but new speed isn't; A4-#5 `ReModify`'s lockstep-snapshot termination bound) | | StopSequenceMotion (0x00522FC0) | ported-with-cite | `CMotionTable.cs:559` | Bonus (retail free functions, not `CMotionTable::` members, but ported+cited in the same file): `same_sign`→`SameSign` (`:77`), `change_cycle_speed`→ `ChangeCycleSpeed` (`:88`), `add_motion`→`AddMotion` (`:116`), `combine_motion`→`CombineMotion` (`:143`), `subtract_motion`→`SubtractMotion` (`:156`) — all independently read. **~53% raw ported-with-cite** (9/17), but **100% of the 9 substantive motion-selection methods** — every method that isn't Pack/UnPack/RTTI/memory management is ported and cited. **No concerning gap.** ### Bottom line across all four classes 103 total symbols.json entries examined: ~77 ported-with-cite, 4 symbol-artifacts (not real distinct methods), ~9 trivial/GC-obviated, 1 architecturally superseded, 1 ported-but-uncited (`CMotionInterp.Create`), and 8 genuinely unported — of which 7 are verified dead code in the retail client itself (the `Pack`/`UnPack`/`pack_size`/`GetMotionTableID`/ `SetMotionTableID` family, confirmed via call-site tracing). **The single genuine, unexplained, feel-visible-risk gap across all four classes is `CMotionInterp::get_adjusted_max_speed` (0x00527D00)** — the only unported method sitting on a hot per-tick gameplay path (dead-reckoning catch-up speed clamp) whose retail selection condition could not be resolved from static analysis alone. All four files hold up as genuinely faithful, well-cited retail ports; this sweep found no evidence of silently-diverged gameplay logic in any of the four classes' core responsibilities. ## 2. Feel-visible flow verdicts This section was independently traced by the lead audit against the primary source (the extensively retail-cited C# in `CMotionTable.cs`, `CSequence.cs`, `MotionState.cs`, `AnimationSequencer.cs`, `MotionInterpreter.cs` — most methods in these five files quote the exact decompiled C body in a doc comment, so this section cites the acdream file:line as primary evidence rather than re-deriving from the 1.4M-line decomp text directly; the background method-coverage-sweep and flow-tracing agents' independent findings are merged in below where they add or contest something). A striking finding up front: several of the 7 flows below turned out to have **verdict parity for a decisive reason that ISN'T "acdream ported it correctly"** — retail itself doesn't do the fancier thing the flow's framing implied. That distinction matters for a retail-faithful project: it means there is nothing to build, not merely nothing left to fix. **1. Stance-change transition animations — PARITY.** `CMotionTable.GetObjectSequence` Branch 1 (`src/AcDream.Core/Physics/Motion/CMotionTable.cs:279-336`, citing retail `GetObjectSequence @ 0x00522860`) is a full style-change dispatcher: it computes an exit link from the current substate to the current style's default substate, a direct link from the current style's default substate to the target style's default substate, and — when no direct link exists — a double-hop through the table's `DefaultStyle` (lines 308-314), then plays exit-link → hop1 → hop2 → new-cycle in sequence (`AddMotion` calls at 318-321) before installing the new style/substate. This is retail's genuine weapon-draw/style-change link mechanism, not a simplified instant cut. `AnimationSequencer.SetCycle` (`src/AcDream.Core/Physics/AnimationSequencer.cs:390-391`) drives style changes through exactly this path before dispatching the target motion. **2. Landing after jump/fall (soft vs hard landing) — PARITY, and the "gap" doesn't exist in retail.** `MotionInterpreter.HitGround` (`src/AcDream.Core/Physics/MotionInterpreter.cs:2425-2443`) quotes retail's `CMotionInterp::HitGround @ 0x00528ac0` FULL BODY: strip link animations, then re-apply the PRESERVED pre-fall interpreted forward command (walk/run/ ready) — there is no velocity, fall-distance, or fall-duration branch anywhere in that function. Retail's `Falling` SubState (`MotionInterpreter.cs:56-63`) is one airborne cycle regardless of how far the body fell; landing is simply "the Falling→X link fires through the same `GetObjectSequence` Branch 2 cycle-to-cycle mechanism verified in item 6." There is no severity-based "hard landing" animation to select in retail's own Humanoid MotionTable, so this was never a divergence to close. **3. In-place turn cycles vs omega-driven turning — PARITY (already shipped under R6; not re-audited here per task scope, confirmed only that the two things are the SAME mechanism, not competing ones).** `TurnRight`/`TurnLeft` (`0x6500000D`/`0x6500000E`) carry the `0x40000000` cycle-class bit (`0x65000000 & 0x40000000 != 0`), so they ARE genuine `CMotionTable` cycles with their own authored `Anims` (the visual leg-crossing/pivot animation) AND their own authored `Omega` (R6's pinned finding: `omega.Z = -1.5` rad/s ≈ -86°/s from the installed Humanoid table, not a synthetic 90° formula). `CMotionTable.AddMotion` (`src/AcDream.Core/Physics/Motion/CMotionTable.cs:116-134`) writes both the anim frames and the omega from the SAME `MotionData` record onto the sequence in one call; `CSequence.ApplyPhysics` rotates the Frame by that omega every frame the turn cycle plays. There was never a separate "visual cycle vs physical rotation" question to resolve — one MotionData record drives both. **4. Walk↔run mid-stride transitions — PARITY (same Branch-2 machinery as item 1, one level down).** Walk and Run are both cycle-class substates within `NonCombat`/combat styles, so crossing the walk/run threshold or toggling the Run hold-key is a same-style cycle-to-cycle request through `GetObjectSequence` Branch 2 (`CMotionTable.cs:341-423`): it looks up a direct link between the two substates via `GetLink`, falls back to a style-default double-hop if none exists (lines 378-383), and has a same-substate "fast re-speed" path (lines 358-367) for a pure speed change within the SAME substate (e.g. accelerating while already running) that rescales the cyclic framerate and physics in place rather than re-triggering a full transition. This is retail's genuine walk-to-run link/blend mechanism, not an instant swap. **5. Backward/strafe cycle selection — PARITY, and again the "gap" doesn't exist in retail.** `AnimationSequencer.SetCycle` (`src/AcDream.Core/Physics/AnimationSequencer.cs:344-348, 367-381`) states plainly, citing ACE's `MotionInterp.cs:394-428` as cross-check: "the AC MotionTable has NO cycles for TurnLeft, SideStepLeft, or WalkBackward. These are played as their right-side/forward equivalents with a negated framerate so the animation runs in reverse." This is a retail asset-content fact, not an acdream simplification — there is no distinct backward-walk or strafe-left animation to select in the first place; retail itself reverses the forward/right cycle. acdream's remap (WalkBackward → WalkForward at -0.65×speed, SideStepLeft → SideStepRight at -1×speed) matches this exactly at both the `AnimationSequencer` boundary (local-player raw input) and the `MotionInterpreter.adjust_motion` boundary (wire-level, R3-cited) — see section 1's method sweep for whether both call sites are still needed or one is now dead code. **6. Link-animation traversal system — PARITY, and it is the single most load-bearing finding of this audit.** `CMotionTable.GetLink` (`CMotionTable.cs:190-241`, retail `get_link @ 0x00522710`) is a genuine, general-purpose `(fromStyle, fromSubstate, toSubstate)` link lookup over the DAT-authored `Links` dictionary — not a hardcoded Ready/Walk/Run subset. It handles the forward direction, a reversed-key direction (used when a speed sign flip means "the link is authored the other way," e.g. the Ready↔ WalkBackward case the doc comment says was field-validated fixing a "left leg twitches" glitch), and a style-level catch-all fallback. Every one of `GetObjectSequence`'s four branches (style-change, cycle, action, modifier) calls it and composes the result into 1-3 chained `AddMotion` calls (exit link, direct/hop1, hop2) before the target cycle, exactly matching retail's own double-hop-via-`DefaultStyle` fallback for style changes with no direct link, and an out-hop/action-link/return-hop triple for action-class motions with no direct link to the target (`CMotionTable.cs:428-478`, with the load-bearing `#A4-1` tick-count citation: "never the base cycle, never double-counted (ACE's bug, not retail's)" — i.e. acdream's tick accounting is MORE correct than the reference ACE port here, not less). This resolves the audit's biggest open question going in: acdream does not skip genuine style-to-style links (drawing a weapon, sheathing, sitting down) in favor of a hardcoded locomotion-only subset. **7. Interrupted-animation behavior — PARITY at the queue-mechanics level; one narrower residual question outside this file set.** A dedicated flow-tracing sub-agent (independent pass, cross-checked against ACE) closed most of the uncertainty this item started with. Retail `MotionTableManager::RemoveRedundantLinks` (`0x0051bf20`) explicitly only collapses cycle-class-not-modifier or style-class queue tails — the modifier/action-class branch is "neither branch taken" (confirmed directly in `CMotionTable.cs`'s ported `RemoveRedundantLinks`, see section 1): action-class one-shots (attacks, casts) are **never truncated** by this mechanism and always run their tick-countdown to natural completion. Separately, retail `CPhysicsObj::interrupt_current_movement` (`0x005101f0`) is called unconditionally from `jump()` and cancels an in-flight `MoveToManager` transition — a wholly different mechanism from the action queue, not a "cancel this attack" primitive. acdream's `MotionTableManager.RemoveRedundantLinks` (`src/AcDream.Core/Physics/Motion/MotionTableManager.cs:191-248`) is a byte-for-byte match including the identical `0xb0000000`/`0x70000000` block masks and the same fallthrough, and the `InterruptCurrentMovement` seam (`MotionInterpreter.cs:658`) is fully wired in PRODUCTION — not a stub — to real `MoveToManager.CancelMoveTo(WeenieError.ActionCancelled)` in both `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs:152-154` (remote) and `src/AcDream.App/Input/PlayerModeController.cs:354-362` (local player), plus 4 call sites in `StickyManager.cs`. **Net: attack/cast animations are uninterruptible by movement/jump input in BOTH clients — movement just queues behind them; jump only ever cancels an in-flight move-to, never the action queue.** The one thing this audit still did not verify: whether higher-level combat/magic-casting code (entirely outside `MotionInterpreter`/`MotionTableManager`, not read for this audit) layers its own ADDITIONAL "can't move while casting" rule on top of this queue mechanism — that would live in the combat/magic subsystem and needs a separate targeted read, not a live capture. **Additional residual surfaced by the flow-tracing pass, item 1 (stance change):** the mechanism (`GetObjectSequence` Branch 1 + `GetLink`) is confirmed parity, but whether acdream's higher-level default-combat-mode selection (`CombatInputPlanner.GetDefaultCombatModeDecision`, not read in this audit) picks the exact same weapon-style-to-CombatMode mapping as retail's `ClientCombatSystem::GetDefaultCombatMode` (`0x0056B310`) in every edge case was NOT traced — flagged as a small untraced item, not a confirmed divergence. **Ranked feel-impact of these 7, most to least:** all 7 came back parity — an unusual, striking result for a from-scratch port of this scope. Ranking by residual RISK rather than impact (i.e., where a future capture is most likely to still surface a surprise, since several parity claims rest partly on DAT-content assumptions rather than pure code): (1) item 7's untraced combat/casting-layer interrupt rule and item 1's untraced default-combat-mode mapping are the two loose threads worth a follow-up read (not a live capture); (2) items 3/4/6 (turn cycles, walk-run link, general link traversal) are the most solid — confirmed via both the acdream code AND an independent cross-check against ACE's own C# `MotionTable.cs` port, which shows the same double-hop structure; (3) items 2/5 (landing, backward/strafe) are effectively closed — in both cases the "gap" the flow's framing hypothesized doesn't exist in retail itself (one universal landing transition; forward/right cycles reverse-played rather than distinct backward/left clips), corroborated for item 5 by holtburger's wire-level `MovementCommand` enum showing `WalkBackwards`/`TurnLeft`/`SidestepLeft` as distinct wire ids (confirming the reversal is a client animation-layer transform, not a wire-format absence). Net: of the 7 flows the task asked to trace, all are parity; the two follow-up threads (combat-layer cast interrupt, default-combat-mode mapping) are outside the `MotionInterpreter`/ `CMotionTable`/`CSequence` file set this audit focused on and are noted for a future combat/magic-scoped audit, not scheduled as animation fixes. ## 3. TS-50 / TS-51 current scoping (verified against current code, 2026-07-30) Both rows are precisely as described in the register — re-reading the actual code confirms rather than narrows either row. No promotion to a fix is recommended; both remain the correct classification (deliberate ordering adaptation with a bounded, named residual), not silent regressions. **TS-50 — which hook types still deliver late.** Read `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs:37-86` (`Capture`): for every hook produced by a sequence advance, the queue tests `hooks[i] is AnimationDoneHook` (line 75) and, ONLY for that one hook type, synchronously calls `sequencer.Manager.AnimationDone(success: true)` at capture time — i.e. inside the same call that advanced the sequence, matching retail `CPhysicsObj::process_hooks @ 0x00511550` timing exactly (semantic motion completion, Target/Movement/PartArray/Position manager tail all see it in the same quantum). EVERY OTHER hook type reaching this queue (from the `DatReaderWriter.Types.AnimationHook` hierarchy routed through `AnimationHookRouter` to `AudioHookSink`, `ParticleHookSink`, `TranslucencyHookSink`, `LightingHookSink` — i.e. sound playback, particle creation including `RetailCreateBlockingParticleHook`, translucency-fade starts, light attach, and `PhysicsScriptHook`/CallPES-adjacent triggers) is unconditionally appended to `_entries` (line 82-85) and only fires later, in `Drain()` (lines 88-125), which is called exactly once per render/update frame from `LiveEffectFrameController.Tick` at `src/AcDream.App/Update/LiveObjectFrameController.cs:107-126` — AFTER every live entity's root/part/equipped-child pose has been published for that frame (the comment at `LiveObjectFrameController.cs:109-113` names this explicitly: "acdream currently keeps non-AnimationDone hooks at this deferred shared boundary under TS-50"). So the answer to "which hook types still deliver late": **all of them except semantic AnimationDone** — sound, particle, light, translucency, and CallPES/script-chain hooks can be up to one render frame later than retail's per-object `process_hooks` moment. Feel-visible risk is concentrated in **CallPES** (a hook that triggers a PhysicsScript chain, e.g. spawning a follow-up effect keyed to a specific animation frame) and blocking-particle creation tied to an attack's exact swing frame — a one-frame-late particle spawn on a fast weapon swing is the kind of thing a careful side-by-side viewer could notice, though nobody has filed a symptom against it yet. Audio/light/translucency lateness is far less likely to be perceptible at typical frame rates. **TS-51 — per-render-frame vs per-quantum tails.** Confirmed at `LiveObjectFrameController.cs:107-126`: `LiveEffectFrameController.Tick(float deltaSeconds)` advances `_particles.Tick(deltaSeconds)` and `_scripts.Tick(_scriptTime.CurrentScriptTime)` exactly once per call, and this controller is driven once per render/update frame (not once per admitted 30 Hz physics quantum per live object). Retail's `CPhysicsObj::UpdateObjectInternal @ 0x005156B0` advances each ordinary object's own ParticleManager then ScriptManager inside EVERY admitted quantum for THAT object, and `animate_static_object @ 0x00513DF0` uses a different order (Script → Particle → hooks) for the static-object workset. acdream's shared tail is Particle → Script after static hook capture, uniformly, once per render frame regardless of how many physics quanta a given object admitted that frame. Practical effect: on a catch-up frame (object advances several quanta at once, e.g. after a stall), the object's root/pose advances through all of them but its particle/script tail only advances once — an emitter that should have spawned N times in that interval spawns once with N ticks' worth of `deltaSeconds`, and static default-script/particle ordering runs in the opposite sequence from `animate_static_object`. This is a real feel-visible risk specifically for dense fast-tick emitters (rapid-fire spell effects, chain particle bursts) but is architecturally deep to fix (needs incarnation-bound per-object particle/script manager instances, which the register row itself names as the retirement condition) — not a quick promotion candidate. **Verdict:** neither row's scope has changed since the register was last written; both remain accurately described. Of the two, TS-50's CallPES lateness is the more plausible candidate for a future promotion (narrower blast radius — "make CallPES and blocking-particle hooks fire at capture time like AnimationDone, keep the rest deferred" is a bounded change), whereas TS-51 needs the larger incarnation-bound-manager refactor the row already flags. ## 4. Issue #64 (local pickup animation) reassessment **Original hypothesis (filed 2026-05-14, pre-R3/R4/R6):** `OnLiveMotionUpdated` filters local-player self-echoes wholesale, so ACE's server-authored `Motion(MotionCommand.Pickup)` broadcast (via `Player_Inventory.AddPickupChainToMoveToChain` → `EnqueueBroadcastMotion(motion)`) never reaches the local player's animation path. That exact function (`OnLiveMotionUpdated`) no longer exists in the current tree (`git grep` for it returns nothing) — the inbound motion path has been rewritten at least twice since (R4-V5's local/remote unification, then the J-slice Runtime extraction), so the original hypothesis needs to be re-evaluated against the CURRENT architecture, not assumed stale or assumed still-broken. **Current architecture, traced end to end:** 1. `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:216-241` (`OnMotion`) — the first gate is a TIMESTAMP staleness check (`_authorityGate.TryAcceptMotion`), unrelated to self-echo. 2. Lines 224 and 243-259 — the REAL self-echo gate, `R4-V5 (pin P1)`: `bool retainPayload = update.Guid != _playerServerGuid || !update.IsAutonomous;` This drops an entire UpdateMotion packet ONLY when it targets the local player's guid AND the packet's wire-level `IsAutonomous` byte is set — the comment cites retail `CPhysics::SetObjectMovement`'s autonomous gate (`0x00509690 @0050972e`, raw 271370-271431) and explains WHY: ACE reflects the client's own outbound `MoveToState` back to the sender with `IsAutonomous=1` hardcoded (`MovementData.cs:162`, `Player_Networking.cs:365` in the ACE reference), so this gate exists specifically to drop THAT reflection, not every inbound packet addressed to the player. 3. If the packet survives, the local-player branch at lines 450-509 routes through the SAME `RemoteInboundMotionDispatcher.Apply` used for remotes (`src/AcDream.App/Physics/RemoteInboundMotionDispatcher.cs`), which for a `MovementType == 0` packet calls `motion.MoveToInterpretedState(interpreted, animationSink)` (`RemoteInboundMotionDispatcher.cs:108-112`). 4. `MotionInterpreter.MoveToInterpretedState` (`src/AcDream.Core/Physics/MotionInterpreter.cs:2697-2743`) replays each entry in `ims.Actions` (the Commands[] one-shot list, populated by `InboundInterpretedMotionFactory.Create` from the wire's `Commands` field — `src/AcDream.App/Physics/InboundInterpretedMotionFactory.cs:46-63`) through `DispatchInterpretedMotion`, with exactly ONE local-player-specific filter at line 2735: `if (IsLocalPlayer && a.Autonomous) continue;` — this is scoped to the PER-ACTION autonomous bit inside the Commands[] entry (`MotionItem.PackedSequence & 0x8000`), not a blanket "is this the local player" drop. **Current best hypothesis:** ACE's Pickup broadcast is server-initiated (built via `EnqueueBroadcastMotion`, not a reflected client `MoveToState`), so both gates that could drop it — the packet-level `IsAutonomous` check (step 2) and the per-action `Autonomous` bit check (step 4) — should read `false` for it, same as for any other server-authored one-shot action a remote observer would see. If that reading of ACE's flag values is correct, **the R4-V5 local/remote unification (which post-dates #64's filing by roughly two months) likely fixed this issue as an architectural side effect**, without anyone specifically targeting #64. This is a hypothesis, not a confirmed fix — this audit is report-only and did not launch the client or trigger a live pickup. **Recommended next step (not performed here):** re-test #64 live with `ACDREAM_DUMP_MOTION=1` set, trigger a close-range pickup as `+Acdream`, and check the log for a `UM guid= ... cmd=...` line whose resolved command carries the Pickup action bit, followed by the sequencer actually playing the one-shot cycle. If it still fails, the next diagnostic is to confirm whether ACE's Pickup broadcast really sets `Autonomous=false` at both the packet and per-action level (a WireMCP capture on the loopback `UpdateMotion (0xF74D)` packet during a pickup would settle this instead of reading ACE source), since a wrong assumption there is the one way this hypothesis could be wrong. ## 5. Emote/action surface (AD-57) gap sizing **What retail would play:** an animated social emote (retail's action-class MotionCommand — the `0x10000000` bit family, e.g. a wave/point/bow/salute cycle, as opposed to `/e ` roleplay chat text) is queued through the SAME `CMotionInterp` action-list machinery already ported: `AddAction` onto `RawMotionState`/`InterpretedMotionState`, packed onto the wire by `RawMotionState::Pack` (retail `0x0051ed10`) as `num_actions` + per-action pairs, broadcast to observers as a Commands[] entry on `UpdateMotion`, and resolved into a `CMotionTable` action-class cycle via the same `CMotionTable::GetObjectSequence` path used for locomotion cycles. **What acdream has, precisely:** per section 4's trace, the RECEIVING half of this pipeline is fully wired and (per the current-best-hypothesis above) likely already plays a server-broadcast one-shot action correctly for both local and remote observers — `InboundInterpretedMotionFactory` parses Commands[] into `InboundMotionAction`s, `MotionInterpreter.MoveToInterpretedState` replays them through `DispatchInterpretedMotion` into the same `CMotionTable`/`CSequence` cycle-selection path as any other motion. The SENDING half — the local player's own input constructing and enqueueing an autonomous action, e.g. from a `/wave`-style command — has no production call site: `grep -r "\.AddAction(" src/AcDream.App` returns nothing outside test code (`RawMotionState.AddAction`/`InterpretedMotionState.AddAction` are exercised only by unit tests, confirmed during this audit). The chat command catalog (`src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs:144-150,232-234`) has `/emote` and `/emotes`, but these are documented as the roleplay TEXT emote (`/e ` — chat-log output), not an animated action — there is no slash command or UI affordance in the catalog for a genuine visual emote. **Gap size:** this is a clean, well-isolated feature gap, not a divergence of any shipped behavior — the register row (AD-57, re-argued 2026-07-30) is correct that "every currently-shipped movement packet matches retail byte-shape; the gap only manifests when emote-class autonomous actions are implemented." The work to close it is bounded and almost entirely additive: (a) a retail-sourced list of which MotionCommand action IDs are genuine social emotes and what UI/command surface retail exposes them through (character menu right-click? a `/motion` or numbered emote command? — this needs a named-retail grep, not guessed), (b) a client input path that calls `MotionInterpreter.DoMotion`/`AddAction` with the right action ID and `Autonomous=true`, and (c) confirming the existing outbound packer already emits it correctly (it should, since `RawMotionStatePacker` already handles the `Actions` list per AD-57's own text). No architecture changes are required — this is squarely a "wire up an existing, tested machine" gap, sized small-to-medium (one research pass to find the retail command surface, one implementation pass to wire input → `AddAction`). ## 6. Ranked gap catalog + recommended fix order **Headline result:** this audit set out to find where acdream's animation system diverges from retail and found the system in unusually good shape. Two independent research passes (a symbol-by-symbol method-coverage sweep and a 7-flow feel-visible trace) plus the lead auditor's own full read of the four core files converged on the same conclusion: `MotionInterpreter` (`CMotionInterp`), `CSequence`, `CMotionTable`, and `MotionTableManager` are faithful, extensively retail-cited ports, and all 7 traced feel-visible flows came back parity — three of them (landing severity, backward/strafe cycles, in-place-turn-cycle-vs-omega) because the richer retail behavior the flow's framing assumed doesn't actually exist in retail either. The gap catalog below is therefore short and each entry is genuinely small. **Ranked by feel-impact, most to least:** 1. **AD-57 — animated emote authoring gap (feel-visible, bounded scope).** The RECEIVING half of retail's action-class one-shot animation system (server-broadcast one-shots like Pickup, and presumably other players' emotes) is fully wired and plays correctly through the same `GetObjectSequence` Branch 3 machinery as any other motion (see section 4/5). The SENDING half — the local player triggering their OWN animated emote (retail's `/wave`-equivalent) — has no production call site; `RawMotionState.AddAction`/`InterpretedMotionState.AddAction` are exercised only by unit tests. This is the most user-visible gap in the catalog (a whole category of retail behavior — animated social gestures — is simply absent from the client), but it is squarely a "wire up an existing, tested machine" gap: no architecture change, no new port, just (a) a named-retail grep for which MotionCommand action IDs are genuine emotes and what UI surface retail exposes them through, (b) an input path that calls `DoMotion`/`AddAction` with the right action ID and `Autonomous=true`, (c) confirming the existing `RawMotionStatePacker` emits it correctly (should already, per AD-57). 2. **TS-50 residual — CallPES and blocking-particle hooks up to one render frame late (already known, narrow promotion candidate).** Verified against current code (section 3): only the semantic `AnimationDoneHook` fires at capture time; every other hook type (CallPES/script-chain triggers, particle creation including blocking particles, sound, light, translucency-fade starts) is deferred to `AnimationHookFrameQueue.Drain()`, called once per render frame after all entities' poses publish. Most feel-visible on a fast weapon swing where a blocking-particle effect is keyed to an exact frame. Bounded fix: make CallPES and blocking-particle hooks fire at capture time like `AnimationDoneHook`, keep the rest deferred — narrower than the full TS-51 refactor below. 3. **Issue #64 — local pickup animation not rendering (likely already fixed, zero-cost to verify).** Section 4's trace shows the R4-V5 local/ remote unification (which post-dates #64's filing) architecturally closed the exact mechanism the original hypothesis blamed: the packet-level and per-action autonomous-echo gates are now scoped precisely enough that a server-authored one-shot (non-autonomous, by construction) should reach the local player's `DispatchInterpretedMotion` exactly like it does for remotes. This needs a 2-minute live re-test (`ACDREAM_DUMP_MOTION=1`, trigger a close-range pickup), not an engineering investment — likely already closed as a side effect of unrelated work and just needs its ISSUES.md status updated. 4. **`CMotionInterp::get_adjusted_max_speed` unported (narrow, needs a cdb read before it's even confirmed live).** The one genuine unexplained gap from the method-coverage sweep (section 1): retail's dead-reckoning catch-up path chooses between `get_max_speed` and this sibling via a static toggle (`InterpolationManager::fUseAdjustedSpeed_`) whose default this audit could not resolve from static analysis alone. acdream always uses `GetMaxSpeed()` for remote catch-up. If the toggle defaults to the adjusted variant in retail, acdream's remote dead-reckoning catch-up speed could be systematically using the wrong of two very similar formulas — narrow blast radius (one clamp value in one catch-up path), plausible feel effect (slightly different snap-back speed on remotes catching up after a network stall). Per the project's own retail-debugger toolchain, this is a cdb-read-the-static-value question, not a guess-and-ship one. 5. **TS-51 residual — particle/script tails once per render frame instead of once per admitted physics quantum (already known, larger refactor).** Verified against current code (section 3): on a catch-up frame where an object advances several 30 Hz quanta at once, its particle/script tail only advances once with the accumulated `deltaSeconds`, and static default-script/particle ordering runs Particle→Script instead of retail's Script→Particle→hooks. Most feel-visible for dense fast-tick emitters (rapid spell-effect chains). The register row itself names the retirement condition (incarnation-bound per-object particle/script manager instances) — this is real architectural work, not a quick promotion, and should stay queued behind the current M4 feature-work order rather than jumping the line for this audit. 6. **Two untraced items outside this audit's file scope (flagged, not confirmed divergences).** The flow-tracing pass surfaced two loose threads it didn't have scope to chase: (a) whether combat/magic-casting code (entirely outside `MotionInterpreter`/`MotionTableManager`) layers its own additional "can't move while casting" rule on top of the confirmed-parity action-queue mechanism; (b) whether `CombatInputPlanner.GetDefaultCombatModeDecision` picks the same weapon-style-to-CombatMode mapping as retail's `ClientCombatSystem::GetDefaultCombatMode` (`0x0056B310`) in every edge case. Both are plausible-but-unconfirmed and belong to a combat/magic- scoped audit, not this animation-scoped one — recommended as a future audit topic, not a fix. **Recommended fix order** (cheapest/highest-confidence first): (1) re-test #64 live and close the issue if confirmed — essentially free; (2) research + wire the emote-sending path (AD-57) — bounded, additive, no architecture change, the single most user-visible improvement available; (3) narrow TS-50's promotion to cover CallPES + blocking-particle hooks specifically; (4) cdb-verify `fUseAdjustedSpeed_`'s retail default before deciding whether `get_adjusted_max_speed` needs porting at all; (5) queue the full TS-51 incarnation-bound-manager refactor behind current M4 feature work, since it is real architectural investment rather than a bounded fix; (6) file a follow-up combat/magic-scoped audit for the two untraced items rather than guessing at their status here. This audit made no code changes and files no fixes directly — items 1-4 above are small enough that the user may want to fold them into the next convenient M4 work session; item 5 should go through the normal roadmap process (a new phase/slice, not a drive-by fix) given its architectural size; item 6 needs its own investigation before any fix is proposed.