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