48 KiB
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.mddocs/research/2026-04-21-animation-audit.md,docs/research/2026-04-28-combat-animation-planner.mddocs/research/2026-07-02-r1-csequence/r1-acdream-sequencer.mddocs/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_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:
src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:216-241(OnMotion) — the first gate is a TIMESTAMP staleness check (_authorityGate.TryAcceptMotion), unrelated to self-echo.- 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-levelIsAutonomousbyte is set — the comment cites retailCPhysics::SetObjectMovement's autonomous gate (0x00509690 @0050972e, raw 271370-271431) and explains WHY: ACE reflects the client's own outboundMoveToStateback to the sender withIsAutonomous=1hardcoded (MovementData.cs:162,Player_Networking.cs:365in the ACE reference), so this gate exists specifically to drop THAT reflection, not every inbound packet addressed to the player. - If the packet survives, the local-player branch at lines 450-509 routes
through the SAME
RemoteInboundMotionDispatcher.Applyused for remotes (src/AcDream.App/Physics/RemoteInboundMotionDispatcher.cs), which for aMovementType == 0packet callsmotion.MoveToInterpretedState(interpreted, animationSink)(RemoteInboundMotionDispatcher.cs:108-112). MotionInterpreter.MoveToInterpretedState(src/AcDream.Core/Physics/MotionInterpreter.cs:2697-2743) replays each entry inims.Actions(the Commands[] one-shot list, populated byInboundInterpretedMotionFactory.Createfrom the wire'sCommandsfield —src/AcDream.App/Physics/InboundInterpretedMotionFactory.cs:46-63) throughDispatchInterpretedMotion, 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).
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:
-
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
GetObjectSequenceBranch 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.AddActionare 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 callsDoMotion/AddActionwith the right action ID andAutonomous=true, (c) confirming the existingRawMotionStatePackeremits it correctly (should already, per AD-57). -
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
AnimationDoneHookfires at capture time; every other hook type (CallPES/script-chain triggers, particle creation including blocking particles, sound, light, translucency-fade starts) is deferred toAnimationHookFrameQueue.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 likeAnimationDoneHook, keep the rest deferred — narrower than the full TS-51 refactor below. -
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
DispatchInterpretedMotionexactly 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. -
CMotionInterp::get_adjusted_max_speedunported (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 betweenget_max_speedand this sibling via a static toggle (InterpolationManager::fUseAdjustedSpeed_) whose default this audit could not resolve from static analysis alone. acdream always usesGetMaxSpeed()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. -
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. -
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) whetherCombatInputPlanner.GetDefaultCombatModeDecisionpicks the same weapon-style-to-CombatMode mapping as retail'sClientCombatSystem::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 whetherget_adjusted_max_speedneeds 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.