acdream/docs/research/2026-07-30-animation-parity-audit.md
2026-07-30 14:46:31 +02:00

48 KiB
Raw Blame History

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<T>)
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<T> 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<MotionTable>() 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_signSameSign (:77), change_cycle_speedChangeCycleSpeed (:88), add_motionAddMotion (:116), combine_motionCombineMotion (:143), subtract_motionSubtractMotion (: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.AddPickupChainToMoveToChainEnqueueBroadcastMotion(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=<player> ... 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 <text> 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 InboundMotionActions, 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 <text> — 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).

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.