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

220 lines
8.5 KiB
Markdown

# 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)
```c
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`
```csharp
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**:
```c
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.