acdream/docs/research/2026-04-21-animation-audit.md
Erik 7007758293 docs(research): animation-pipeline decompile audit — no real gaps
Ground-truth audit of acdream's animation pipeline against retail
decompile (chunk_*.c), cross-referenced line-by-line with our code.
Previous audit relied on ACE and got wiring claims wrong (said our
PlayAction path was orphaned when it's wired via OnLiveMotionUpdated).

Findings:
 - PerformMovement dispatcher (FUN_00529a90) matches our MotionInterpreter.
 - apply_current_movement cycle priority (FUN_00529210) matches our
   OnLiveMotionUpdated sequencer path.
 - Commands list → PlayAction wiring matches retail.
 - Falling / Jump / Dead substate routing matches.
 - Frame-timing epsilon + negative-speed playback matches.

The agent's "hit-react missing" claim turned out to be wrong: the
referenced FUN_0048d760 call passes 32-bit IDs shaped like MotionCommand
values but user-confirmed retail shows NO body animation on damage, so
vtable +0x9c is almost certainly emit-effect / play-sound / spawn-
particle — not a motion play. Not an animation gap.

Open follow-up: CreateObject initial Commands list is parsed but not
replayed when the entity hydrates (minor; rare case).

Not a follow-up: on-hit combat feedback (particles, damage numbers).
That's a separate feature, not an animation pipeline concern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:18:45 +02:00

8.5 KiB

Animation Pipeline Audit — 2026-04-21

Decompile-first audit of acdream's animation pipeline against retail. Previous audit on this topic relied on ACE and got wiring claims wrong; this one cites chunk_*.c:LINE FUN_XXXXXXXX for every claim and cross-references our code line-for-line.

Bottom line

Our animation pipeline is complete and faithful to retail for every packet-driven path verified. The agent flagged one "gap" (hit-react autonomous trigger) that turned out to be a misinterpretation of FUN_0048d760 — the user confirmed retail does not play a body animation on hit, so whatever FUN_0048d760 does, it isn't animation. Most likely on-hit particle / damage-number / sound effect — that's a separate "combat feedback" feature, not an animation gap.

Section A — PerformMovement dispatcher

Retail source: chunk_00520000.c:7627 FUN_00529a90 (PerformMovement)

switch(*param_1) {
case 1: FUN_00529930(...);  // DoMotion
case 2: FUN_00528f70(...);  // DoInterpretedMotion
case 3: FUN_00529140(...);  // StopMotion
case 4: FUN_00529080(...);  // StopInterpretedMotion
case 5: FUN_00528a50();     // StopCompletely
}

Our code: src/AcDream.Core/Physics/MotionInterpreter.cs:10-99

MovementType enum (RawCommand / InterpretedCommand / StopRawCommand / StopInterpretedCommand / StopCompletely) mirrors the 5 cases exactly. GameWindow.OnLiveMotionUpdated routes inbound motion through DoInterpretedMotion.

Verdict: MATCHES.

Section B — Core animation dispatcher (apply_current_movement)

Retail source: chunk_00520000.c:7134 FUN_00529210

Resolves the InterpretedMotionState into an active animation cycle. Priority: forward if valid substate, else sidestep, else turn, else 0x40000015 Falling as the default fallback.

Our code: src/AcDream.App/Rendering/GameWindow.cs:1758-1832 (OnLiveMotionUpdated sequencer path)

Same priority: WalkForward/RunForward/WalkBackward → sidestep → turn → Ready. Uses MotionCommandResolver.ReconstructFullCommand to lift the wire's 16-bit command to the full 32-bit MotionCommand. Calls AnimationSequencer.SetCycle with the resolved cycle.

Verdict: MATCHES semantically. Different syntax (we check low-byte vs. retail's is_motion_substate) but equivalent output.

Section C — Commands list (one-shot emotes / attacks / actions)

Retail source: chunk_00510000.c:13957 FUN_0051F260 — bulk field copy for InterpretedMotionState, consumed downstream into the animation sequencer's action queue.

Our code: src/AcDream.App/Rendering/GameWindow.cs:1879-1917

foreach (var item in cmds) {
    uint fullCmd = MotionCommandResolver.ReconstructFullCommand(item.Command);
    uint cls = fullCmd & 0xFF000000u;
    if ((cls & 0x10000000u) != 0 || (cls & 0x20000000u) != 0
        || cls == 0x12000000u || cls == 0x13000000u) {
        ae.Sequencer.PlayAction(fullCmd, item.Speed);
    } else if ((cls & 0x40000000u) != 0) {
        ae.Sequencer.SetCycle(fullStyle, fullCmd, item.Speed);
    }
}

AnimationSequencer.PlayAction (lines 744+) resolves via Links dict for Action-class, Modifiers dict for Modifier-class, inserts the resulting nodes before _firstCyclic so the one-shot plays before returning to the active cycle. Cursor-on-cyclic rewind logic keeps actions from being delayed behind a cycle wrap.

Verdict: MATCHES. Previous audit's claim that this path is "orphaned" was wrong.

Section D — Hit-react on damage (claimed gap, not real)

Agent claim: chunk_00480000.c:8202 FUN_0048d760 calls vtable +0x9c with 0x10000055 on damage → "retail triggers StaggerBackward locally".

Verification of the claim:

piVar2 = (int *)FUN_00463c00(0x1000051a);           // table lookup
if (piVar2 != 0) {
    piVar2 = (**(code **)(*piVar2 + 0x94))(0xc);    // vtable +0x94
    if (piVar2 != 0) {
        FUN_0046a740(param_1);                       // setup
        FUN_00460530(0x10000085, param_1);           // register ID on entity
        if (param_3 != 0) {
            (**(code **)(*piVar2 + 0x9c))(0x10000054);
            return 1;
        }
        (**(code **)(*piVar2 + 0x9c))(0x10000055);
        return 1;
    }
}

Why the claim is unverified:

  1. 0x10000054 and 0x10000055 are Action-class-shaped 32-bit IDs. The agent assumed they're MotionCommand.StaggerForward/StaggerBackward because ACE's enum labels them that way. That's ACE's labeling, not retail's — and ACE's enum is itself partly guesswork for values not in shipping motion tables.
  2. Vtable slot +0x9c was never identified. Could be play-animation; could just as easily be emit-effect, play-sound, set-state, notify-listeners, register-target. The decompile name is unknown.
  3. FUN_00463c00(0x1000051a) is a table lookup by 32-bit key — again not necessarily the motion-table. Could be a particle-effect table, sound-effect table, or generic resource table.
  4. User confirms retail shows NO body animation on hit. If FUN_0048d760 plays animation, this contradicts the observation. Therefore it plays something else — most likely on-hit particle, damage flash, sound, or damage-number UI spawn.

Verdict: Agent's claim is WRONG. FUN_0048d760 does something on damage — likely non-animation (particles / feedback UI) — but it is NOT an animation gap in our port.

Section E — Jump / Falling / Death substates

Retail source: defined in MotionCommand enum (per docs/research/deepdives/r03-motion-animation.md §3.2):

  • Jumpup = 0x1000004B (Action — not used by humanoid player)
  • FallDown = 0x10000050 (Action — same story)
  • Falling = 0x40000015 (SubState — the airborne cycle)
  • Dead = 0x40000011 (SubState)
  • Jump = 0x2500003B (Modifier — jump root-motion overlay)

FUN_00529210 auto-falls back to Falling when is_motion_substate rejects the current command.

Our code: src/AcDream.Core/Physics/MotionInterpreter.cs:30-81 defines the same constants. SetCycle path in OnLiveMotionUpdated handles SubState routing.

Verdict: MATCHES.

Section F — Frame timing + playback quirks

Retail source: _DAT_007c92b4 = 1e-5 used in FUN_00526880 / FUN_005268B0 (frame-position helpers) to place the cursor "just before" a frame boundary.

Our code: src/AcDream.Core/Physics/AnimationSequencer.cs:171 FrameEpsilon = 1e-5. Same value; same logic in GetStartFramePosition / GetEndFramePosition including negative-speed start/end swap.

Verdict: MATCHES.

Overall summary

Verified correct (matches retail per decompile)

  • PerformMovement 5-case dispatcher (RawCommand / InterpretedCommand / StopRawCommand / StopInterpretedCommand / StopCompletely).
  • DoInterpretedMotion routing.
  • apply_current_movement cycle-selection priority (forward → sidestep → turn → Ready, with Falling as invalid-substate fallback).
  • Commands list → PlayAction (Link/Modifier lookup, insert-before-cyclic).
  • Class-byte reconstruction from 16-bit wire commands.
  • SubState routing for Falling / Jump / Dead.
  • Frame-timing epsilon and negative-speed playback.

Verified divergent

  • None found.

Verified missing

  • None found (agent's hit-react claim does not survive scrutiny).

Couldn't pin down

  • FUN_0048d760 purpose: called from damage-application code, invokes vtable +0x9c with 0x10000054 / 0x10000055. Probably on-hit feedback (particle, sound, damage number). Confirming its exact purpose requires identifying the vtable class — not done in this audit. Not an animation pipeline concern.

Open follow-ups (not gaps)

  • On-hit feedback: whatever FUN_0048d760 really does, retail clients get some visible feedback on damage (damage numbers, particles) that we don't have. Particle infrastructure is partly scaffolded (session notes mention E.3 data layer) but not wired to GL. Separate feature, medium-scope project.

  • CreateObject initial Commands list: parsed by CreateObject.cs:619 but not replayed when the entity spawns. Minor edge case — rare that an entity is spawned mid-action. Flag but don't prioritize.

Conclusion

Animation pipeline for acdream is complete and retail-faithful as of this session. The historical "whack-a-mole animation bugs" frustration was genuine bugs in previous sessions (remote-motion wire-format, MovementStateFlag bit layout, stop-detection via absent ForwardCommand, etc.) — all fixed in commits before 2026-04-21. No further animation work is required at the pipeline level; next visible-animation improvements are particles / combat feedback (separate feature) or specific bug reports with a reproduction.