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

713 lines
48 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# 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_sign``SameSign` (`:77`), `change_cycle_speed`
`ChangeCycleSpeed` (`:88`), `add_motion``AddMotion` (`:116`),
`combine_motion``CombineMotion` (`:143`), `subtract_motion``SubtractMotion`
(`:156`) — all independently read.
**~53% raw ported-with-cite** (9/17), but **100% of the 9 substantive
motion-selection methods** — every method that isn't Pack/UnPack/RTTI/memory
management is ported and cited. **No concerning gap.**
### Bottom line across all four classes
103 total symbols.json entries examined: ~77 ported-with-cite, 4
symbol-artifacts (not real distinct methods), ~9 trivial/GC-obviated, 1
architecturally superseded, 1 ported-but-uncited (`CMotionInterp.Create`),
and 8 genuinely unported — of which 7 are verified dead code in the retail
client itself (the `Pack`/`UnPack`/`pack_size`/`GetMotionTableID`/
`SetMotionTableID` family, confirmed via call-site tracing). **The single
genuine, unexplained, feel-visible-risk gap across all four classes is
`CMotionInterp::get_adjusted_max_speed` (0x00527D00)** — the only unported
method sitting on a hot per-tick gameplay path (dead-reckoning catch-up
speed clamp) whose retail selection condition could not be resolved from
static analysis alone. All four files hold up as genuinely faithful,
well-cited retail ports; this sweep found no evidence of silently-diverged
gameplay logic in any of the four classes' core responsibilities.
## 2. Feel-visible flow verdicts
This section was independently traced by the lead audit against the primary
source (the extensively retail-cited C# in `CMotionTable.cs`, `CSequence.cs`,
`MotionState.cs`, `AnimationSequencer.cs`, `MotionInterpreter.cs` — most
methods in these five files quote the exact decompiled C body in a doc
comment, so this section cites the acdream file:line as primary evidence
rather than re-deriving from the 1.4M-line decomp text directly; the
background method-coverage-sweep and flow-tracing agents' independent
findings are merged in below where they add or contest something). A
striking finding up front: several of the 7 flows below turned out to have
**verdict parity for a decisive reason that ISN'T "acdream ported it
correctly"** — retail itself doesn't do the fancier thing the flow's framing
implied. That distinction matters for a retail-faithful project: it means
there is nothing to build, not merely nothing left to fix.
**1. Stance-change transition animations — PARITY.**
`CMotionTable.GetObjectSequence` Branch 1 (`src/AcDream.Core/Physics/Motion/CMotionTable.cs:279-336`,
citing retail `GetObjectSequence @ 0x00522860`) is a full style-change
dispatcher: it computes an exit link from the current substate to the
current style's default substate, a direct link from the current style's
default substate to the target style's default substate, and — when no
direct link exists — a double-hop through the table's `DefaultStyle` (lines
308-314), then plays exit-link → hop1 → hop2 → new-cycle in sequence
(`AddMotion` calls at 318-321) before installing the new style/substate.
This is retail's genuine weapon-draw/style-change link mechanism, not a
simplified instant cut. `AnimationSequencer.SetCycle`
(`src/AcDream.Core/Physics/AnimationSequencer.cs:390-391`) drives style
changes through exactly this path before dispatching the target motion.
**2. Landing after jump/fall (soft vs hard landing) — PARITY, and the "gap"
doesn't exist in retail.** `MotionInterpreter.HitGround`
(`src/AcDream.Core/Physics/MotionInterpreter.cs:2425-2443`) quotes retail's
`CMotionInterp::HitGround @ 0x00528ac0` FULL BODY: strip link animations,
then re-apply the PRESERVED pre-fall interpreted forward command (walk/run/
ready) — there is no velocity, fall-distance, or fall-duration branch
anywhere in that function. Retail's `Falling` SubState
(`MotionInterpreter.cs:56-63`) is one airborne cycle regardless of how far
the body fell; landing is simply "the Falling→X link fires through the same
`GetObjectSequence` Branch 2 cycle-to-cycle mechanism verified in item 6."
There is no severity-based "hard landing" animation to select in retail's
own Humanoid MotionTable, so this was never a divergence to close.
**3. In-place turn cycles vs omega-driven turning — PARITY (already shipped
under R6; not re-audited here per task scope, confirmed only that the two
things are the SAME mechanism, not competing ones).** `TurnRight`/`TurnLeft`
(`0x6500000D`/`0x6500000E`) carry the `0x40000000` cycle-class bit
(`0x65000000 & 0x40000000 != 0`), so they ARE genuine `CMotionTable` cycles
with their own authored `Anims` (the visual leg-crossing/pivot animation)
AND their own authored `Omega` (R6's pinned finding: `omega.Z = -1.5`
rad/s ≈ -86°/s from the installed Humanoid table, not a synthetic 90°
formula). `CMotionTable.AddMotion`
(`src/AcDream.Core/Physics/Motion/CMotionTable.cs:116-134`) writes both the
anim frames and the omega from the SAME `MotionData` record onto the
sequence in one call; `CSequence.ApplyPhysics` rotates the Frame by that
omega every frame the turn cycle plays. There was never a separate
"visual cycle vs physical rotation" question to resolve — one MotionData
record drives both.
**4. Walk↔run mid-stride transitions — PARITY (same Branch-2 machinery as
item 1, one level down).** Walk and Run are both cycle-class substates
within `NonCombat`/combat styles, so crossing the walk/run threshold or
toggling the Run hold-key is a same-style cycle-to-cycle request through
`GetObjectSequence` Branch 2 (`CMotionTable.cs:341-423`): it looks up a
direct link between the two substates via `GetLink`, falls back to a
style-default double-hop if none exists (lines 378-383), and has a
same-substate "fast re-speed" path (lines 358-367) for a pure speed change
within the SAME substate (e.g. accelerating while already running) that
rescales the cyclic framerate and physics in place rather than re-triggering
a full transition. This is retail's genuine walk-to-run link/blend
mechanism, not an instant swap.
**5. Backward/strafe cycle selection — PARITY, and again the "gap" doesn't
exist in retail.** `AnimationSequencer.SetCycle`
(`src/AcDream.Core/Physics/AnimationSequencer.cs:344-348, 367-381`) states
plainly, citing ACE's `MotionInterp.cs:394-428` as cross-check: "the AC
MotionTable has NO cycles for TurnLeft, SideStepLeft, or WalkBackward. These
are played as their right-side/forward equivalents with a negated
framerate so the animation runs in reverse." This is a retail asset-content
fact, not an acdream simplification — there is no distinct backward-walk or
strafe-left animation to select in the first place; retail itself reverses
the forward/right cycle. acdream's remap (WalkBackward → WalkForward at
-0.65×speed, SideStepLeft → SideStepRight at -1×speed) matches this exactly
at both the `AnimationSequencer` boundary (local-player raw input) and the
`MotionInterpreter.adjust_motion` boundary (wire-level, R3-cited) — see
section 1's method sweep for whether both call sites are still needed or
one is now dead code.
**6. Link-animation traversal system — PARITY, and it is the single most
load-bearing finding of this audit.** `CMotionTable.GetLink`
(`CMotionTable.cs:190-241`, retail `get_link @ 0x00522710`) is a genuine,
general-purpose `(fromStyle, fromSubstate, toSubstate)` link lookup over the
DAT-authored `Links` dictionary — not a hardcoded Ready/Walk/Run subset. It
handles the forward direction, a reversed-key direction (used when a speed
sign flip means "the link is authored the other way," e.g. the Ready↔
WalkBackward case the doc comment says was field-validated fixing a
"left leg twitches" glitch), and a style-level catch-all fallback. Every one
of `GetObjectSequence`'s four branches (style-change, cycle, action,
modifier) calls it and composes the result into 1-3 chained `AddMotion`
calls (exit link, direct/hop1, hop2) before the target cycle, exactly
matching retail's own double-hop-via-`DefaultStyle` fallback for style
changes with no direct link, and an out-hop/action-link/return-hop triple
for action-class motions with no direct link to the target (`CMotionTable.cs:428-478`,
with the load-bearing `#A4-1` tick-count citation: "never the base cycle,
never double-counted (ACE's bug, not retail's)" — i.e. acdream's tick
accounting is MORE correct than the reference ACE port here, not less).
This resolves the audit's biggest open question going in: acdream does not
skip genuine style-to-style links (drawing a weapon, sheathing, sitting
down) in favor of a hardcoded locomotion-only subset.
**7. Interrupted-animation behavior — PARITY at the queue-mechanics level;
one narrower residual question outside this file set.** A dedicated
flow-tracing sub-agent (independent pass, cross-checked against ACE) closed
most of the uncertainty this item started with. Retail
`MotionTableManager::RemoveRedundantLinks` (`0x0051bf20`) explicitly only
collapses cycle-class-not-modifier or style-class queue tails — the
modifier/action-class branch is "neither branch taken" (confirmed directly
in `CMotionTable.cs`'s ported `RemoveRedundantLinks`, see section 1): action-class
one-shots (attacks, casts) are **never truncated** by this mechanism and
always run their tick-countdown to natural completion. Separately, retail
`CPhysicsObj::interrupt_current_movement` (`0x005101f0`) is called
unconditionally from `jump()` and cancels an in-flight `MoveToManager`
transition — a wholly different mechanism from the action queue, not a
"cancel this attack" primitive. acdream's `MotionTableManager.RemoveRedundantLinks`
(`src/AcDream.Core/Physics/Motion/MotionTableManager.cs:191-248`) is a
byte-for-byte match including the identical `0xb0000000`/`0x70000000` block
masks and the same fallthrough, and the `InterruptCurrentMovement` seam
(`MotionInterpreter.cs:658`) is fully wired in PRODUCTION — not a stub — to
real `MoveToManager.CancelMoveTo(WeenieError.ActionCancelled)` in both
`src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs:152-154`
(remote) and `src/AcDream.App/Input/PlayerModeController.cs:354-362` (local
player), plus 4 call sites in `StickyManager.cs`. **Net: attack/cast
animations are uninterruptible by movement/jump input in BOTH clients —
movement just queues behind them; jump only ever cancels an in-flight
move-to, never the action queue.** The one thing this audit still did not
verify: whether higher-level combat/magic-casting code (entirely outside
`MotionInterpreter`/`MotionTableManager`, not read for this audit) layers
its own ADDITIONAL "can't move while casting" rule on top of this queue
mechanism — that would live in the combat/magic subsystem and needs a
separate targeted read, not a live capture.
**Additional residual surfaced by the flow-tracing pass, item 1 (stance
change):** the mechanism (`GetObjectSequence` Branch 1 + `GetLink`) is
confirmed parity, but whether acdream's higher-level default-combat-mode
selection (`CombatInputPlanner.GetDefaultCombatModeDecision`, not read in
this audit) picks the exact same weapon-style-to-CombatMode mapping as
retail's `ClientCombatSystem::GetDefaultCombatMode` (`0x0056B310`) in every
edge case was NOT traced — flagged as a small untraced item, not a
confirmed divergence.
**Ranked feel-impact of these 7, most to least:** all 7 came back parity —
an unusual, striking result for a from-scratch port of this scope. Ranking
by residual RISK rather than impact (i.e., where a future capture is most
likely to still surface a surprise, since several parity claims rest partly
on DAT-content assumptions rather than pure code): (1) item 7's untraced
combat/casting-layer interrupt rule and item 1's untraced default-combat-mode
mapping are the two loose threads worth a follow-up read (not a live
capture); (2) items 3/4/6 (turn cycles, walk-run link, general link
traversal) are the most solid — confirmed via both the acdream code AND an
independent cross-check against ACE's own C# `MotionTable.cs` port, which
shows the same double-hop structure; (3) items 2/5 (landing, backward/strafe)
are effectively closed — in both cases the "gap" the flow's framing
hypothesized doesn't exist in retail itself (one universal landing
transition; forward/right cycles reverse-played rather than distinct
backward/left clips), corroborated for item 5 by holtburger's wire-level
`MovementCommand` enum showing `WalkBackwards`/`TurnLeft`/`SidestepLeft` as
distinct wire ids (confirming the reversal is a client animation-layer
transform, not a wire-format absence). Net: of the 7 flows the task asked to
trace, all are parity; the two follow-up threads (combat-layer cast
interrupt, default-combat-mode mapping) are outside the `MotionInterpreter`/
`CMotionTable`/`CSequence` file set this audit focused on and are noted for
a future combat/magic-scoped audit, not scheduled as animation fixes.
## 3. TS-50 / TS-51 current scoping (verified against current code, 2026-07-30)
Both rows are precisely as described in the register — re-reading the actual
code confirms rather than narrows either row. No promotion to a fix is
recommended; both remain the correct classification (deliberate ordering
adaptation with a bounded, named residual), not silent regressions.
**TS-50 — which hook types still deliver late.** Read
`src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs:37-86` (`Capture`):
for every hook produced by a sequence advance, the queue tests
`hooks[i] is AnimationDoneHook` (line 75) and, ONLY for that one hook type,
synchronously calls `sequencer.Manager.AnimationDone(success: true)` at
capture time — i.e. inside the same call that advanced the sequence, matching
retail `CPhysicsObj::process_hooks @ 0x00511550` timing exactly (semantic
motion completion, Target/Movement/PartArray/Position manager tail all see it
in the same quantum). EVERY OTHER hook type reaching this queue (from the
`DatReaderWriter.Types.AnimationHook` hierarchy routed through
`AnimationHookRouter` to `AudioHookSink`, `ParticleHookSink`,
`TranslucencyHookSink`, `LightingHookSink` — i.e. sound playback, particle
creation including `RetailCreateBlockingParticleHook`, translucency-fade
starts, light attach, and `PhysicsScriptHook`/CallPES-adjacent triggers) is
unconditionally appended to `_entries` (line 82-85) and only fires later, in
`Drain()` (lines 88-125), which is called exactly once per render/update
frame from `LiveEffectFrameController.Tick` at
`src/AcDream.App/Update/LiveObjectFrameController.cs:107-126` — AFTER every
live entity's root/part/equipped-child pose has been published for that
frame (the comment at `LiveObjectFrameController.cs:109-113` names this
explicitly: "acdream currently keeps non-AnimationDone hooks at this
deferred shared boundary under TS-50"). So the answer to "which hook types
still deliver late": **all of them except semantic AnimationDone** — sound,
particle, light, translucency, and CallPES/script-chain hooks can be up to
one render frame later than retail's per-object `process_hooks` moment.
Feel-visible risk is concentrated in **CallPES** (a hook that triggers a
PhysicsScript chain, e.g. spawning a follow-up effect keyed to a specific
animation frame) and blocking-particle creation tied to an attack's exact
swing frame — a one-frame-late particle spawn on a fast weapon swing is the
kind of thing a careful side-by-side viewer could notice, though nobody has
filed a symptom against it yet. Audio/light/translucency lateness is far
less likely to be perceptible at typical frame rates.
**TS-51 — per-render-frame vs per-quantum tails.** Confirmed at
`LiveObjectFrameController.cs:107-126`: `LiveEffectFrameController.Tick(float
deltaSeconds)` advances `_particles.Tick(deltaSeconds)` and
`_scripts.Tick(_scriptTime.CurrentScriptTime)` exactly once per call, and
this controller is driven once per render/update frame (not once per
admitted 30 Hz physics quantum per live object). Retail's
`CPhysicsObj::UpdateObjectInternal @ 0x005156B0` advances each ordinary
object's own ParticleManager then ScriptManager inside EVERY admitted
quantum for THAT object, and `animate_static_object @ 0x00513DF0` uses a
different order (Script → Particle → hooks) for the static-object workset.
acdream's shared tail is Particle → Script after static hook capture,
uniformly, once per render frame regardless of how many physics quanta a
given object admitted that frame. Practical effect: on a catch-up frame
(object advances several quanta at once, e.g. after a stall), the object's
root/pose advances through all of them but its particle/script tail only
advances once — an emitter that should have spawned N times in that
interval spawns once with N ticks' worth of `deltaSeconds`, and static
default-script/particle ordering runs in the opposite sequence from
`animate_static_object`. This is a real feel-visible risk specifically for
dense fast-tick emitters (rapid-fire spell effects, chain particle bursts)
but is architecturally deep to fix (needs incarnation-bound per-object
particle/script manager instances, which the register row itself names as
the retirement condition) — not a quick promotion candidate.
**Verdict:** neither row's scope has changed since the register was last
written; both remain accurately described. Of the two, TS-50's CallPES
lateness is the more plausible candidate for a future promotion (narrower
blast radius — "make CallPES and blocking-particle hooks fire at capture
time like AnimationDone, keep the rest deferred" is a bounded change),
whereas TS-51 needs the larger incarnation-bound-manager refactor the row
already flags.
## 4. Issue #64 (local pickup animation) reassessment
**Original hypothesis (filed 2026-05-14, pre-R3/R4/R6):** `OnLiveMotionUpdated`
filters local-player self-echoes wholesale, so ACE's server-authored
`Motion(MotionCommand.Pickup)` broadcast (via
`Player_Inventory.AddPickupChainToMoveToChain`
`EnqueueBroadcastMotion(motion)`) never reaches the local player's animation
path. That exact function (`OnLiveMotionUpdated`) no longer exists in the
current tree (`git grep` for it returns nothing) — the inbound motion path
has been rewritten at least twice since (R4-V5's local/remote unification,
then the J-slice Runtime extraction), so the original hypothesis needs to be
re-evaluated against the CURRENT architecture, not assumed stale or assumed
still-broken.
**Current architecture, traced end to end:**
1. `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:216-241`
(`OnMotion`) — the first gate is a TIMESTAMP staleness check
(`_authorityGate.TryAcceptMotion`), unrelated to self-echo.
2. Lines 224 and 243-259 — the REAL self-echo gate, `R4-V5 (pin P1)`: `bool
retainPayload = update.Guid != _playerServerGuid || !update.IsAutonomous;`
This drops an entire UpdateMotion packet ONLY when it targets the local
player's guid AND the packet's wire-level `IsAutonomous` byte is set — the
comment cites retail `CPhysics::SetObjectMovement`'s autonomous gate
(`0x00509690 @0050972e`, raw 271370-271431) and explains WHY: ACE reflects
the client's own outbound `MoveToState` back to the sender with
`IsAutonomous=1` hardcoded (`MovementData.cs:162`,
`Player_Networking.cs:365` in the ACE reference), so this gate exists
specifically to drop THAT reflection, not every inbound packet addressed
to the player.
3. If the packet survives, the local-player branch at lines 450-509 routes
through the SAME `RemoteInboundMotionDispatcher.Apply` used for remotes
(`src/AcDream.App/Physics/RemoteInboundMotionDispatcher.cs`), which for a
`MovementType == 0` packet calls `motion.MoveToInterpretedState(interpreted,
animationSink)` (`RemoteInboundMotionDispatcher.cs:108-112`).
4. `MotionInterpreter.MoveToInterpretedState`
(`src/AcDream.Core/Physics/MotionInterpreter.cs:2697-2743`) replays each
entry in `ims.Actions` (the Commands[] one-shot list, populated by
`InboundInterpretedMotionFactory.Create` from the wire's `Commands`
field — `src/AcDream.App/Physics/InboundInterpretedMotionFactory.cs:46-63`)
through `DispatchInterpretedMotion`, with exactly ONE local-player-specific
filter at line 2735: `if (IsLocalPlayer && a.Autonomous) continue;` — this
is scoped to the PER-ACTION autonomous bit inside the Commands[] entry
(`MotionItem.PackedSequence & 0x8000`), not a blanket "is this the local
player" drop.
**Current best hypothesis:** ACE's Pickup broadcast is server-initiated (built
via `EnqueueBroadcastMotion`, not a reflected client `MoveToState`), so both
gates that could drop it — the packet-level `IsAutonomous` check (step 2) and
the per-action `Autonomous` bit check (step 4) — should read `false` for it,
same as for any other server-authored one-shot action a remote observer would
see. If that reading of ACE's flag values is correct, **the R4-V5 local/remote
unification (which post-dates #64's filing by roughly two months) likely
fixed this issue as an architectural side effect**, without anyone
specifically targeting #64. This is a hypothesis, not a confirmed fix — this
audit is report-only and did not launch the client or trigger a live pickup.
**Recommended next step (not performed here):** re-test #64 live with
`ACDREAM_DUMP_MOTION=1` set, trigger a close-range pickup as `+Acdream`, and
check the log for a `UM guid=<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 `InboundMotionAction`s, `MotionInterpreter.MoveToInterpretedState`
replays them through `DispatchInterpretedMotion` into the same
`CMotionTable`/`CSequence` cycle-selection path as any other motion. The
SENDING half — the local player's own input constructing and enqueueing an
autonomous action, e.g. from a `/wave`-style command — has no production call
site: `grep -r "\.AddAction(" src/AcDream.App` returns nothing outside test
code (`RawMotionState.AddAction`/`InterpretedMotionState.AddAction` are
exercised only by unit tests, confirmed during this audit). The chat command
catalog (`src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs:144-150,232-234`)
has `/emote` and `/emotes`, but these are documented as the roleplay TEXT
emote (`/e <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:**
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.