P0 — research + pins: full CSequence-family verbatim extraction (1756 lines, per-function raw pseudo-C + cleaned flow, decomp line anchors), ACE cross-reference (9 ranked divergences; headline: retail frame_number is x87 long double — ACE's float is the worst case, our double the best available; ACE's frame-boundary epsilon is an ACE fabrication, NOT retail), current-sequencer map, and the R1 gap map (20 gaps, 13 keeps, P0-P6 port order). Pinned the one decomp ambiguity (leftover-time carry after advance_to_next_animation — ACE reading adopted; cdb confirmation protocol recorded, non-blocking). P1 — AnimSequenceNode verbatim (gap G1/G2/G16/G18): - direction-aware BARE-INT boundary pair (get_starting_frame 0x00525c80 / get_ending_frame 0x00525cb0): reverse starts at high+1, ends at low — NO epsilon; - multiply_framerate (0x00525be0) swaps low/high on negative factor; - set_animation_id (0x00525d60) retail clamp order (high<0 -> num-1; low>=num -> num-1; high>=num -> num-1; low>high -> high=low); - ctors with retail defaults (30f/-1/-1; AnimData copy + clamp); - get_pos_frame null out-of-range (retail; ACE returns identity), floor double overload; get_part_frame same discipline; - NO per-node IsLooping/Velocity/Omega — loop membership is list structure, physics accumulators live on the sequence (G16). 22 conformance tests (clamp table, boundary mirror table, swap round-trip, bounds/floor semantics). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
377 lines
36 KiB
Markdown
377 lines
36 KiB
Markdown
# acdream AnimationSequencer — current-state map (R1)
|
||
|
||
File: `src/AcDream.Core/Physics/AnimationSequencer.cs` (1584 lines, read whole).
|
||
Companions: `AnimationCommandRouter.cs` (98 lines), `AnimationHookRouter.cs` (95
|
||
lines), `IAnimationHookSink.cs` (89 lines) — all read whole.
|
||
|
||
## 0. Type inventory in AnimationSequencer.cs
|
||
|
||
- `IAnimationLoader` — abstraction (`LoadAnimation(uint id) : Animation?`) so
|
||
the sequencer can be unit-tested without a real `DatCollection`.
|
||
- `DatCollectionLoader : IAnimationLoader` — production impl, wraps
|
||
`DatCollection.Get<Animation>(id)`.
|
||
- `PartTransform` (readonly struct) — `Vector3 Origin`, `Quaternion
|
||
Orientation`. Output unit of `Advance`.
|
||
- `AnimNode` (internal sealed class) — one queue entry:
|
||
`Animation Anim; double Framerate; int StartFrame; int EndFrame; bool
|
||
IsLooping; bool HasPosFrames; Vector3 Velocity; Vector3 Omega`.
|
||
Methods: `MultiplyFramerate(factor)`, `GetStartFramePosition()`,
|
||
`GetEndFramePosition()`. `FrameEpsilon = 1e-5` (mirrors retail
|
||
`_DAT_007c92b4`).
|
||
- `AnimationSequencer` (public sealed class) — the engine itself, one
|
||
instance per entity.
|
||
|
||
## (a) Feature matrix — AnimationSequencer vs retail CSequence concepts
|
||
|
||
| Retail concept | Retail anchor cited in acdream | acdream implementation | Notes / fidelity |
|
||
|---|---|---|---|
|
||
| Node list (AnimSequenceNode queue) | FUN_00525EB0 `advance_to_next_animation`; ACE `Sequence.cs` | `LinkedList<AnimNode> _queue`, `_currNode`, `_firstCyclic` pointers | Doubly-linked list mirrored with .NET `LinkedList<T>`. Non-cyclic head (link frames) + looping tail (`_firstCyclic..end`) invariant maintained explicitly rather than via a node flag walked at runtime. |
|
||
| Link resolution (`get_link`) | ACE `MotionTable.cs:395-426`; retail `CMotionTable::GetObjectSequence` 0x00522860 | `GetLink(style, substate, substateSpeed, motion, speed)` private method | Forward-direction path: `Links[(style<<16)|substate][motion]`, then style-level catch-all `Links[style<<16][motion]`. **Reversed-direction branch** added (comment cites "K-fix6"): when either speed is negative, looks up `Links[(style<<16)|motion][substate]` instead (link FROM motion TO substate, played reversed) — handles WalkBackward/SideStepLeft/TurnLeft transitions. Also a `StyleDefaults` fallback under the reversed branch. This 2-branch structure is **acdream's own generalization**, not a literal 1:1 decompile citation (no direct FUN_xxx cited for the branch split itself, only for its constituent parts via ACE line numbers). |
|
||
| `adjust_motion` (TurnLeft/SideStepLeft/WalkBackward → mirror) | ACE `MotionInterp.cs:394-428` | Inlined at top of `SetCycle` + duplicated in `HasCycle`: 0x000E→0x000D (negate speed), 0x0010→0x000F (negate speed), 0x0006→0x0005 (negate speed × 0.65 `BackwardsFactor`) | Faithful port; the `0x65` low-byte switch is applied to `motion & 0xFFFFu`, i.e. matched purely on the low 16 bits regardless of class byte. |
|
||
| Fast-path re-speed (no restart on same motion) | ACE `MotionTable.cs:132-139` | `SetCycle` early-return branch: if `CurrentStyle==style && CurrentMotion==motion && sign(speedMod)==sign(CurrentSpeedMod)` → `MultiplyCyclicFramerate` instead of rebuild | Faithful. Explicit **sign-flip exception** documented: when adjust_motion flips speedMod's sign while `motion` value itself stays the same (WalkForward with negative speed = backward), the fast path is bypassed by the sign check so a full restart occurs — comment marks this as an acdream-observed necessity (2026-05-02), not literally cited to a retail address. |
|
||
| `multiply_cyclic_animation_framerate` | FUN_00525CE0; ACE `Sequence.cs L277-L287` | `MultiplyCyclicFramerate(float factor)` — walks `_firstCyclic..end`, calls `AnimNode.MultiplyFramerate` on each, and also scales `CurrentVelocity *= factor; CurrentOmega *= factor` | Faithful for node framerates. The velocity/omega scaling is justified by algebraic equivalence to ACE's `subtract_motion(old)+combine_motion(new)` (`MotionTable.change_cycle_speed`, `MotionTable.cs L372-L379`) rather than being separately decompiled — an acdream derivation, not a citation of a specific retail scaling line. |
|
||
| `GetStartFramePosition` / `GetEndFramePosition` | FUN_00526880 / FUN_005268B0 | `AnimNode.GetStartFramePosition()/GetEndFramePosition()` | Faithful 1:1 including the `EPSILON = _DAT_007c92b4` (hardcoded here as `1e-5` — the retail exact float constant was **not** independently verified against the binary in this file's comments; it's asserted equal). |
|
||
| `multiply_framerate` (StartFrame↔EndFrame swap for negative speed) | FUN_005267E0 | `AnimNode.MultiplyFramerate(factor)` | **Explicitly documented divergence**: retail swaps StartFrame↔EndFrame for negative factor; acdream keeps `StartFrame ≤ EndFrame` as an invariant and encodes direction purely via `Framerate`'s sign, compensating in the `Advance` loop's boundary checks instead. Comment states this is valid "because the callers we care about... only ever pass positive factors" — i.e. an acknowledged simplification with a stated (unverified beyond code review) precondition. |
|
||
| `update_internal` (per-frame advance loop) | FUN_005261D0; ACE `Sequence.cs:351-443` | `Advance(float dt)` | Faithful structurally: `while (timeRemaining>0 && _currNode!=null)` loop (capped `safety=64` — **acdream-invented safety valve**, no retail citation, guards against a "degenerate motion table" infinite loop). Computes `delta = rate*timeRemaining`; forward vs reverse branches each: (1) detect boundary overflow, (2) clamp `_framePosition` to boundary-epsilon, (3) walk every integer frame crossed applying `ApplyPosFrame` + `ExecuteHooks`, (4) on wrap, call `advance_to_next_animation` (`AdvanceToNextAnimation()`) and continue with leftover `overflow` time. |
|
||
| `advance_to_next_animation` (node wrap) | FUN_00525EB0 | `AdvanceToNextAnimation()` — moves `_currNode` to `.Next`, or wraps to `_firstCyclic` if null, else holds on last node if no cyclic tail exists | Faithful. Resets `_framePosition` via `GetStartFramePosition()` on transition. |
|
||
| `execute_hooks` | ACE `Sequence.cs:262-270` | `ExecuteHooks(node, frameIndex, playbackDir)` — fires hook if `hook.Direction == Both \|\| hook.Direction == playbackDir` | Faithful 1:1 port of the direction-match condition. |
|
||
| Root motion (`AFrame.Combine` / `frame.Subtract`) | ACE (`AFrame.Combine`/`Subtract`, cited generically, no line #) | `ApplyPosFrame(node, frameIndex, reverse)` — forward: `_rootMotionPos += Rotate(pf.Origin, _rootMotionRot); _rootMotionRot = Normalize(_rootMotionRot * pf.Orientation)`; reverse: conjugate-then-subtract | Faithful port of the two composition directions. Accumulated into `_rootMotionPos`/`_rootMotionRot`, drained via `ConsumeRootMotionDelta()`. |
|
||
| AnimationDone hook on link drain | ACE `PhysicsObj.add_anim_hook(AnimationHook.AnimDoneHook)` (cited generically) | `AnimationDoneSentinel` (static `AnimationDoneHook{Direction=Both}`) pushed to `_pendingHooks` in `Advance` just before `AdvanceToNextAnimation()`, gated on `!_currNode.Value.IsLooping` | Faithful concept; the sentinel object is a single shared static instance (not per-fire), meaning downstream code cannot distinguish *which* motion completed purely from hook identity — must correlate via entity + timing. Not flagged as a bug in the file; simply a design note for consumers. |
|
||
| Quaternion slerp | FUN_005360d0 (chunk_00530000.c:4799-4846) | `SlerpRetailClient(q1, q2, t)` static method | Faithful port including retail's odd step-5 validation-then-linear-fallback quirk (`SlerpEpsilon = 1e-4f` near-parallel check, then acos/sin slerp, then re-validate blend weights ∈[0,1] before trusting the slerp result — falls back to linear otherwise). Explicitly documented as differing from "the standard formula" only in this validation step. |
|
||
| Frame-boundary blend (`BuildBlendedFrame`) | Not directly cited to a single retail FUN — described as producing "the current blended keyframe" | `BuildBlendedFrame()` — clamps `frameIdx` to `[rangeLo,rangeHi]`; computes `nextIdx` stepping in playback direction; **wraps only if `curr.IsLooping`**, else holds boundary frame | **Explicitly acdream-motivated fix, not retail-cited**: comment for issue **#61** ("door swing-open flap; run-stop twitch") states holding the boundary frame instead of wrapping-blending into frame 0 for one-shot (non-looping) nodes avoids "a brief flash through the anim's starting pose at the link→cycle boundary." This is the **frame-swap / link→cycle boundary flash class of bug** the deep-dive skill targets — currently patched empirically here, not derived from a decompiled retail boundary-hold mechanism. |
|
||
| Placement / root frames | `AnimData.AnimId`, `Animation.PartFrames`, `Animation.PosFrames`, `Frame.Origin/Orientation` (DatReaderWriter types) | `LoadAnimNode` reads `anim.PartFrames.Count` for frame bounds; `hasPosFrames = anim.Flags.HasFlag(PosFrames) && anim.PosFrames.Count >= numFrames` | Sentinel resolution for `HighFrame == -1` ("all frames") ported from `MotionResolver.GetIdleCycle` (cited by name, not address). `if (low > high) high = low` guards a degenerate AnimData. |
|
||
| "Fix B" (#39, 2026-05-06) — cyclic→cyclic direct transition skips link | cdb live trace 2026-05-03 of a Walk→Run transition: `add_to_queue(45000005, looping=1)` then `add_to_queue(44000007, looping=1)` with `truncate_animation_list` never firing | In `SetCycle`: `IsLocomotionCycleLowByte` check on both `CurrentMotion` and new `motion`'s low byte (0x05/0x06/0x07/0x0F/0x10); if both are locomotion AND `_firstCyclic` exists, the just-enqueued link node is **removed from the queue** and `_currNode` is forced directly onto `_firstCyclic` | This is the most heavily-commented divergence-turned-fix in the file. Explicit warning left in comments: "Commit c06b6c5 (reverted in a2ae2ae) demonstrated that unconditionally skipping the link breaks all of these [Idle→cycle, Falling→Ready, pose-change links, combat substates]" — i.e. the fix is deliberately **scoped only to the locomotion-cycle subset**, confirmed retail-faithful via a live cdb capture (grep-named-first / attach-cdb workflow), not guessed. |
|
||
| K-fix18 — `skipTransitionLink` | Not a retail citation — an acdream product decision, explicitly labeled "K-fix18" in the doc comment | `SetCycle(..., bool skipTransitionLink = false)` param. When true: `linkData` forced null, AND (separately) the entire `_queue` is cleared / `_currNode`/`_firstCyclic` reset to null before rebuild | **Confirmed acdream-invented deviation** (not retail behavior) — used only for Falling-on-jump-start (`GameWindow.cs:4830`, `10201`) to avoid a ~100ms "stop running" pose delay before the fall animation engages. This should have (or needs to be checked against) a `retail-divergence-register.md` row per CLAUDE.md's mandatory bookkeeping rule — the file itself doesn't reference the register. |
|
||
| Stop-anim fallback (direction-agnostic settle) | Not retail-cited; acdream reasoning: "settle anim is direction-agnostic" | In `SetCycle`, if `linkData is null` after the primary lookup, retries `GetLink` with `CurrentMotion`'s low byte remapped 0x06→0x05, 0x10→0x0F, 0x0E→0x0D (peer forward/right substate) | Acknowledged as an acdream workaround for a `null linkData` gap when stopping from WalkBackward/SideStepLeft/TurnLeft — motivated by an observed visible glitch ("left leg twitches forward two times") rather than by a decompiled retail fallback path. |
|
||
| Stale-head handling | Comment: "For remote entities receiving many bundled UMs over time, this stale-head build-up was the root cause of..." | `preEnqueueTail` snapshot before `EnqueueMotionData` calls; after enqueue, `firstNew = preEnqueueTail?.Next ?? _queue.First`; `_currNode` is **force-set** onto `firstNew` (or `_firstCyclic` under the Fix-B branch) rather than left wherever it was | Acdream-diagnosed and acdream-authored fix (no retail citation) — addresses a structural bug where `_currNode` could remain parked on old non-cyclic head frames left over from a previous `SetCycle` call, which visually manifested as "transitions between cycles don't visibly switch the leg pose" for remote entities. Local player was unaffected because `PlayerMovementController` calls `SetCycle` frequently enough to keep the queue clean — this is called out explicitly as a per-entity-class asymmetry. |
|
||
| Velocity/Omega synthesis for locomotion & turn cycles | `CMotionInterp::get_state_velocity` (FUN_00528960 cited by address); `MotionInterpreter.RunAnimSpeed` etc (decompiled from `_DAT_007c96e0/e4/e8`); ACE's `omega.z = ±(π/2)×turnSpeed` (cross-checked against holtburger `motion_resolution.rs`) | Post-`EnqueueMotionData` block in `SetCycle`: switches on `motion & 0xFFu` for 0x05/0x06/0x07/0x0F/0x10, sets `CurrentVelocity` from `WalkAnimSpeed=3.12f` / `RunAnimSpeed=4.0f` / `SidestepAnimSpeed=1.25f` constants × `adjustedSpeed`; separately, if `CurrentOmega` is ~zero, synthesizes turn omega `±(π/2)*adjustedSpeed` for 0x0D/0x0E | **Explicitly justified divergence from literal dat content**: "the Humanoid motion table ships every locomotion MotionData with Flags=0x00 (no HasVelocity)" — i.e. the dat itself is silent on velocity for these cycles, and retail's *actual* body-physics source (`get_state_velocity`) is a separate C++ function not expressed as sequencer/dat state at all. acdream folds that separate retail mechanism into `AnimationSequencer.CurrentVelocity` as a pragmatic single surface for consumers (dead-reckoning, `get_state_velocity` Option-B). Comment explicitly documents an earlier bug: a gate of `if (CurrentVelocity.LengthSquared() < 1e-9f)` previously let dat-baked link velocity "win" over synthesis, breaking walk→run transitions — now **unconditionally overwritten** for known locomotion low-bytes. |
|
||
| `clear_physics` before rebuild | ACE `Sequence.cs L256-L260` | `ClearPhysics()` — zeroes `CurrentVelocity`/`CurrentOmega`, called from `SetCycle` before the enqueue chain | Faithful; matches retail's `sequence.clear_physics()` call before each `add_motion` chain (`MotionTable.cs L100-L101, L152-L153`). |
|
||
| `add_motion` velocity/omega replace-not-accumulate semantics | ACE `MotionTable.add_motion` (`MotionTable.cs L358-L370`) | `EnqueueMotionData`: `if (motionData.Flags.HasFlag(HasVelocity)) CurrentVelocity = vel;` (replace, not add) similarly for Omega | Faithful — comment explicitly notes this REPLACES not accumulates, and that the final value after link+cycle enqueue is the cycle's (last write wins), which dead-reckoning consumers rely on. |
|
||
| PlayAction / Modifier & Action-class overlay | ACE `MotionTable.GetObjectSequence` line 189-207 (Action, mask 0x10) and line 234-242 (Modifier, mask 0x20) | `PlayAction(uint motionCommand, float speedMod=1f)` — tries `GetLink` (Links dict) first for Action-mask commands, then falls back to `_mtable.Modifiers` dict (styled key then plain key) for Modifier-mask commands | Faithful dual-path lookup. Notes Jump (`0x2500003B`) has both Action+Modifier-looking bits but ACE treats it via the Modifier path specifically — cited as a cross-check against ACE, not an independent retail decompile of Jump's own class byte. Inserts new non-looping nodes via `_queue.AddBefore(_firstCyclic, n)` (or `AddLast` if no cyclic tail), and force-jumps `_currNode` to the first inserted node if the cursor was currently on the cyclic tail (so overlay actions play immediately instead of waiting for a cycle wrap). |
|
||
| `Sequence.Velocity`/`Sequence.Omega` sequence-wide mirror | ACE `Sequence.cs L127-130`; `MotionTable.add_motion` L358-370 | `CurrentVelocity`/`CurrentOmega` public properties, described in the doc comment as "not per-node" — reflects the **most recently added** MotionData's velocity×speedMod, even while an earlier link node is still playing visually | Faithful semantic port — explicitly documented gotcha: "while a link animation plays, the surfaced velocity is still the cycle's velocity." |
|
||
| `HasCycle` (retail cycle-existence probe) | Not a literal retail function name — acdream utility mirroring the head of `SetCycle`'s adjust_motion + cycle-key lookup | `HasCycle(uint style, uint motion) : bool` — duplicates the adjust_motion switch (0x000E/0x0010/0x0006) then checks `_mtable.Cycles.ContainsKey(cycleKey)` | Acdream-invented consumer helper (doc comment explains it exists so callers can fall back to a known-good motion instead of hitting `SetCycle`'s unconditional `ClearCyclicTail` on a missing cycle, which "leaves the body without any animation tail" — visible as "torso on the ground"). Not retail-cited as its own function. |
|
||
| Reset | Not retail-cited | `Reset()` — clears queue, hooks, root motion, resets Current* fields to defaults | Utility method for entity despawn/respawn recycling; no retail anchor given. |
|
||
| Diagnostics: `CurrentNodeDiag`, `FirstCyclicAnimRefHash`, `[SCFAST]/[SCFULL]/[SCNULLFALLBACK]` logging | N/A — acdream-only | `CurrentNodeDiag` tuple property (AnimRefHash via `RuntimeHelpers.GetHashCode`, IsLooping, Framerate, StartFrame, EndFrame, FramePosition, QueueCount); `FirstCyclicAnimRefHash`; three `Console.WriteLine` blocks gated on `ACDREAM_REMOTE_VEL_DIAG=1`, throttled to 0.5s via `_lastSetCycleDiagTime` | Entirely acdream-invented instrumentation ("Commit A 2026-05-03" / D2/D3/D4 diagnostic labels) for the remote-motion debugging campaign. Per CLAUDE.md rule 5 (diagnostic owner classes), this is a per-call-site `Environment.GetEnvironmentVariable` read pattern flagged in CLAUDE.md itself as "tech debt; do not add more" — these predate that rule and haven't been migrated to a diagnostic-owner class. |
|
||
|
||
## (b) Public API surface — what consumers depend on
|
||
|
||
### Constructor
|
||
```
|
||
AnimationSequencer(Setup setup, MotionTable motionTable, IAnimationLoader loader)
|
||
```
|
||
All three args are `ArgumentNullException.ThrowIfNull`'d. Consumers must
|
||
supply a non-null `Setup`/`MotionTable`/loader — `RenderBootstrap.cs`'s
|
||
`SequencerFactory` local function has a 3-tier fallback chain (real
|
||
setup+mtable → real setup + empty `MotionTable()` → fully-empty
|
||
`Setup()`+`MotionTable()`+`NullAnimLoader()`) precisely because the ctor
|
||
won't accept nulls.
|
||
|
||
### Methods
|
||
- `bool HasCycle(uint style, uint motion)` — probe before calling `SetCycle`
|
||
to avoid the "torso on ground" missing-cycle collapse. Called from
|
||
`GameWindow.cs:3723/3728/3732/3824` (initial spawn cycle selection with
|
||
Run→Walk→Ready fallback chain) and `RemoteMotionSink.Commit()` (same
|
||
fallback pattern for remote UM-driven cycles, plus an `ACDREAM_REMOTE_VEL_DIAG`
|
||
`[HASCYCLE]` diagnostic).
|
||
- `void SetCycle(uint style, uint motion, float speedMod = 1f, bool
|
||
skipTransitionLink = false)` — the primary state-transition entry point.
|
||
Callers across the codebase:
|
||
- `GameWindow.cs:3751` — initial spawn cycle.
|
||
- `GameWindow.cs:3825` — initial cycle after a fallback check.
|
||
- `GameWindow.cs:4830` — Falling-on-jump-start with `skipTransitionLink:
|
||
true` (K-fix18).
|
||
- `GameWindow.cs:4936` (`ApplyServerControlledVelocityCycle`) — NPC/monster
|
||
remotes still on the legacy `ServerControlledLocomotion.PlanFromVelocity`
|
||
path (pre-S6 unification; comment says this path stays until "S6 unifies
|
||
all entity classes onto the CMotionInterp funnel").
|
||
- `GameWindow.cs:5155/5309/9817` — landing cycle / stop-to-ready transitions.
|
||
- `GameWindow.cs:10223` — local player's own `SetCycle` call (with sidestep
|
||
speed-scaling correction factor `WalkAnimSpeed/SidestepAnimSpeed*0.5`
|
||
documented against ACE's `MovementData.cs:124-131` wire formula, #45).
|
||
- `RemoteMotionSink.Commit()` (`src/AcDream.App/Rendering/RemoteMotionSink.cs:215`)
|
||
— remote **player** entities via the L.2g S2b `CMotionInterp` funnel path
|
||
(see below); this is the modern replacement for the legacy path above for
|
||
player remotes specifically.
|
||
- `AnimationCommandRouter.RouteFullCommand` — routes `SubState`-class
|
||
commands here (see router section below).
|
||
- `void MultiplyCyclicFramerate(float factor)` — not called directly outside
|
||
`AnimationSequencer.cs` in the searched consumer set (invoked internally
|
||
by `SetCycle`'s fast-path re-speed branch); no external call sites found
|
||
in `src/`.
|
||
- `IReadOnlyList<PartTransform> Advance(float dt)` — per-frame tick. Sole
|
||
call site: `GameWindow.cs:9876` inside the animated-entity tick loop
|
||
(`seqFrames = ae.Sequencer.Advance(dt)`), guarded by an
|
||
`ae.Sequencer is not null` branch; entities without a sequencer fall to a
|
||
"legacy path" (`ae.CurrFrame += dt * ae.Framerate` manual slerp, line
|
||
~9896).
|
||
- `IReadOnlyList<AnimationHook> ConsumePendingHooks()` — drained immediately
|
||
after `Advance` at `GameWindow.cs:9882`, fanned out per-hook to
|
||
`_hookRouter.OnHook(ae.Entity.Id, worldPos, hook)` (`AnimationHookRouter`,
|
||
which further fans out to registered `IAnimationHookSink`s).
|
||
- `(Vector3 Position, Quaternion Rotation) ConsumeRootMotionDelta()` — no
|
||
call sites found in the searched `src/` consumer grep; root motion
|
||
(PosFrames) accumulation exists in the sequencer but nothing currently
|
||
drains/consumes it in production code (search of `ConsumeRootMotionDelta`
|
||
and `\.PlayAction\(` etc. above did not surface a caller — **this looks
|
||
like dead/unwired API surface**, worth flagging for the deep-dive).
|
||
- `void PlayAction(uint motionCommand, float speedMod = 1f)` — called from
|
||
`AnimationCommandRouter.RouteFullCommand` for `Action`/`Modifier`/
|
||
`ChatEmote` route kinds, and directly from `RemoteMotionSink.ApplyMotion`
|
||
for the same route kinds (overlay dispatch for remote entities).
|
||
- `void Reset()` — no external call site found in the searched consumer set.
|
||
|
||
### Properties (read by consumers)
|
||
- `CurrentStyle`, `CurrentMotion`, `CurrentSpeedMod` (uint/uint/float,
|
||
private-set) — read by `GameWindow.cs` (style fallback defaults `0x8000003Du`
|
||
NonCombat at lines 3723/4827/4919 etc.), `RemoteMotionSink` ctor (seeds
|
||
`_style` from `sequencer.CurrentStyle`), `RemoteMotionSink.Commit()`
|
||
(`_sequencer.CurrentMotion`/`CurrentSpeedMod` for diagnostic comparison),
|
||
`GameWindow.cs:4915` (`ApplyServerControlledVelocityCycle`'s
|
||
`IsRemoteLocomotion(currentMotion)` gate).
|
||
- `CurrentVelocity` / `CurrentOmega` (`Vector3`, private-set) — the most
|
||
cross-cutting output surface:
|
||
- `GameWindow.cs:9331-9334` — remote player per-tick body translation
|
||
(`seqVel`/`seqOmega` feed `PositionManager`-style catch-up/anim
|
||
composition; extensive comment block on lines 9310-9330 walks through
|
||
retail's `CPartArray::Update` → `PositionManager::adjust_offset` →
|
||
`Frame::combine` per-tick pipeline this is meant to mirror).
|
||
- `PlayerMovementController.AttachCycleVelocityAccessor(() =>
|
||
playerSeq.CurrentVelocity)` (`GameWindow.cs:12917`) — wires the local
|
||
player's sequencer velocity into `MotionInterpreter.GetCycleVelocity`
|
||
as an override of the decompiled constant path (`RunAnimSpeed *
|
||
ForwardSpeed`), used because arbitrary creatures' MotionTables don't all
|
||
bake `Velocity=4.0` on RunForward the way Humanoid does.
|
||
- `MotionInterpreter.cs` — comments describe `CurrentVelocity` as "already
|
||
`MotionData.Velocity * speedMod`" (body-local) and reference it as the
|
||
accessor payload described above; also cross-checks
|
||
`AnimationSequencer.CurrentVelocity`/`CurrentOmega` doc pointers at
|
||
lines 347/376/632/662.
|
||
- `QueueCount` (int) — diagnostic only (`CurrentNodeDiag` tuple's last
|
||
field also duplicates it).
|
||
- `HasCurrentNode` (bool) — diagnostic; no external call site found in
|
||
the searched set beyond the type itself.
|
||
- `CurrentNodeDiag` / `FirstCyclicAnimRefHash` — consumed by
|
||
`GameWindow.cs:9863-9871` inside an `ACDREAM_REMOTE_VEL_DIAG`-gated
|
||
`[CURRNODE]` log block that compares `AnimRefHash` against
|
||
`FirstCyclicAnimRefHash` to detect whether the visible animation is
|
||
actually on the intended cyclic tail.
|
||
|
||
### Nested/companion API — `IAnimationLoader` / `DatCollectionLoader`
|
||
- `IAnimationLoader.LoadAnimation(uint id) : Animation?` — implemented in
|
||
production by `DatCollectionLoader` (wraps `DatCollection.Get<Animation>`),
|
||
constructed once in `RenderBootstrap.cs:138` (`animLoader`) and passed by
|
||
reference into every `SequencerFactory`-built sequencer; a
|
||
`NullAnimLoader` (referenced but not shown in this file — defined
|
||
elsewhere) is the last-resort fallback in `RenderBootstrap`'s factory.
|
||
|
||
## AnimationCommandRouter.cs — companion routing layer
|
||
|
||
Static class, no state. Cited retail anchors:
|
||
`CMotionTable::GetObjectSequence` 0x00522860,
|
||
`CMotionInterp::DoInterpretedMotion` 0x00528360, plus
|
||
`docs/research/deepdives/r03-motion-animation.md` section 3.
|
||
|
||
- `Classify(uint fullCommand) : AnimationCommandRouteKind` — classifies by
|
||
class-mask bits: `0x12000000`/`0x13000000` class → `ChatEmote`;
|
||
`ModifierMask=0x20000000` → `Modifier`; `ActionMask=0x10000000` →
|
||
`Action`; `SubStateMask=0x40000000` → `SubState`; `0` → `None`; else
|
||
`Ignored`. (Note: checks Modifier before Action, meaning any command with
|
||
both bits set classifies as Modifier — matches the `PlayAction` comment
|
||
about Jump 0x2500003B having both bits but ACE treating it as Modifier.)
|
||
- `RouteWireCommand(sequencer, currentStyle, ushort wireCommand, speedMod)` —
|
||
reconstructs the full 32-bit command via
|
||
`MotionCommandResolver.ReconstructFullCommand(wireCommand)` then delegates
|
||
to `RouteFullCommand`.
|
||
- `RouteFullCommand(sequencer, currentStyle, fullCommand, speedMod)` —
|
||
dispatch table: `Action`/`Modifier`/`ChatEmote` → `sequencer.PlayAction(fullCommand,
|
||
speedMod)`; `SubState` → `sequencer.SetCycle(currentStyle, fullCommand,
|
||
speedMod)`. Returns the classified `AnimationCommandRouteKind` to the
|
||
caller for logging/branching.
|
||
|
||
`AnimationCommandRouteKind` enum: `None, Action, Modifier, ChatEmote,
|
||
SubState, Ignored`.
|
||
|
||
Consumers: `RemoteMotionSink.ApplyMotion` calls `AnimationCommandRouter.Classify`
|
||
directly (to special-case Turn/Sidestep before falling through) and
|
||
`RouteFullCommand` for the overlay branch. No other call sites found in the
|
||
searched consumer set for `AnimationCommandRouter` itself (GameWindow.cs
|
||
appears to call `Sequencer.SetCycle`/`PlayAction` directly rather than
|
||
through the router in most of the enumerated call sites above — the router
|
||
is primarily the remote-entity/funnel-driven path's dispatch layer).
|
||
|
||
## AnimationHookRouter.cs / IAnimationHookSink.cs — hook fan-out layer
|
||
|
||
- `IAnimationHookSink.OnHook(uint entityId, Vector3 entityWorldPosition,
|
||
AnimationHook hook)` — single-method interface. `AnimationHook` and its
|
||
subclasses (`SoundHook`, `SoundTableHook`, `SoundTweakedHook`,
|
||
`CreateParticleHook`, `DestroyParticleHook`, `StopParticleHook`,
|
||
`CallPESHook`, `DefaultScriptHook`, `DefaultScriptPartHook`,
|
||
`AttackHook`, `ReplaceObjectHook`, `TransparentHook`, `LuminousHook`,
|
||
`DiffuseHook`, `ScaleHook`, `NoDrawHook`, `SetOmegaHook`,
|
||
`TextureVelocityHook`, `SetLightHook`, `AnimationDoneHook`) are all
|
||
**external types from `DatReaderWriter.Types`**, not defined in
|
||
acdream's own tree — they're dat-format hook payload classes. The doc
|
||
comment enumerates intended downstream routing (Phase E.2 audio / E.3
|
||
particles / E.4 combat dispatcher / renderer state mutation / UI
|
||
notifications) but this file itself contains no subsystem logic — it's
|
||
purely the interface contract + a `NullAnimationHookSink` no-op
|
||
singleton for tests/headless.
|
||
- `AnimationHookRouter : IAnimationHookSink` — composite/fan-out
|
||
implementation. `Register(sink)` / `Unregister(sink)` are lock-protected
|
||
(`_gate`), copy-on-write into a `IAnimationHookSink[]` array (idempotent
|
||
register — checks `ReferenceEquals` before adding). `OnHook` iterates a
|
||
snapshot array **without locking** (explicitly commented "no lock in the
|
||
hot path (render thread)") and wraps each sink's `OnHook` call in a
|
||
`try/catch` that **silently swallows all exceptions** ("one misbehaving
|
||
sink must not take down the entire animation tick... individual
|
||
subsystems can log their own errors internally") — this is a blanket
|
||
catch-and-discard, worth flagging against CLAUDE.md's "no workarounds"
|
||
spirit if a sink is silently failing (per the user's `feedback_logger_injection_for_silent_catches.md`
|
||
memory note: libs that silently catch+return null usually should inject
|
||
an `ILogger`; this router has no logger injection point at all).
|
||
`Sinks` property exposes a read-only snapshot for diagnostics/tests.
|
||
|
||
Only one production sink registration path was located via the router's own
|
||
file (none) — sink registration call sites (`_hookRouter.Register(...)`)
|
||
were not enumerated in this pass; the single confirmed dispatch call site
|
||
is `GameWindow.cs:9890` (`_hookRouter.OnHook(ae.Entity.Id, worldPos, hook)`).
|
||
|
||
## How MotionData/anim dat structures reach the sequencer
|
||
|
||
- **Setup** (`DatReaderWriter.DBObjs.Setup`) — supplies `Parts.Count` (used
|
||
by `Advance`/`BuildBlendedFrame`/`BuildIdentityFrame` to size the
|
||
`PartTransform[]` output) and `DefaultMotionTable` id (used by
|
||
`RenderBootstrap.SequencerFactory` to look up the paired `MotionTable`).
|
||
- **MotionTable** (`DatReaderWriter.DBObjs.MotionTable`) — three
|
||
dictionaries consumed directly:
|
||
- `Cycles : Dictionary<int, MotionData>` keyed by
|
||
`(style<<16)|(adjustedMotion&0xFFFFFF)` — the looping tail source,
|
||
read in `SetCycle`/`HasCycle`.
|
||
- `Links : Dictionary<int, MotionCommandData>` where
|
||
`MotionCommandData.MotionData : Dictionary<int, MotionData>` — the
|
||
transition-frame source, read in `GetLink` (both forward and
|
||
reversed-key branches) and in `PlayAction`'s Action-mask lookup.
|
||
- `Modifiers : Dictionary<int, MotionData>` — the overlay/action source
|
||
for Modifier-mask commands in `PlayAction`, tried with a styled key
|
||
first then a plain (unstyled) key.
|
||
- `StyleDefaults : Dictionary<MotionCommand, MotionCommand>` — used only
|
||
inside `GetLink`'s reversed-direction fallback branch.
|
||
- **MotionData** (per Cycles/Links/Modifiers entry) — `Anims :
|
||
List<AnimData>`; `Velocity`/`Omega : Vector3`; `Flags :
|
||
MotionDataFlags` (`HasVelocity=0x01`, `HasOmega=0x02`) gates whether
|
||
`Velocity`/`Omega` are applied or zeroed in `EnqueueMotionData` and
|
||
`PlayAction`.
|
||
- **AnimData** — `AnimId : QualifiedDataId<Animation>` (cast to `uint` for
|
||
`IAnimationLoader.LoadAnimation`), `LowFrame`/`HighFrame` (sentinel
|
||
`HighFrame == -1` meaning "all frames", resolved in `LoadAnimNode`),
|
||
`Framerate` (float, multiplied by `speedMod` to produce the `double
|
||
Framerate` stored on `AnimNode`).
|
||
- **Animation** (loaded via `IAnimationLoader.LoadAnimation(animId)`) —
|
||
`PartFrames : List<AnimationFrame>` (drives `numFrames` bounds-clamping
|
||
in `LoadAnimNode` and the per-part lookup in `BuildBlendedFrame`);
|
||
`PosFrames : List<Frame>` (root motion, gated by `Flags.HasFlag(PosFrames)`
|
||
AND `PosFrames.Count >= numFrames`); `Flags : AnimationFlags`.
|
||
- **AnimationFrame** — `Frames : List<Frame>` (one `Frame` per Setup part —
|
||
indexed by part index in `BuildBlendedFrame`'s `f0Parts`/`f1Parts`
|
||
lookups, defaulting to identity for parts beyond the frame's list length),
|
||
`Hooks : List<AnimationHook>` (read in `ExecuteHooks`).
|
||
- **Frame** — `Origin : Vector3`, `Orientation : Quaternion` — the raw
|
||
per-part or per-posframe pose sample, lerped/slerped in
|
||
`BuildBlendedFrame` or combined in `ApplyPosFrame`.
|
||
|
||
Entry point that ties Setup+MotionTable+Loader together for a live entity:
|
||
`RenderBootstrap.cs`'s `SequencerFactory(WorldEntity e)` local function
|
||
(lines ~147-174, moved verbatim from the pre-extraction `GameWindow.cs`
|
||
~2306-2334 per its own comment) — looks up `Setup` via
|
||
`dats.Get<Setup>(e.SourceGfxObjOrSetupId)`, then `MotionTable` via
|
||
`dats.Get<MotionTable>(setup.DefaultMotionTable)`, falling back through
|
||
three tiers (real/real → real-setup/empty-mtable → empty/empty) so the
|
||
constructor's null-guards are never tripped for any spawned entity, even
|
||
ones missing dat data.
|
||
|
||
## (c) acdream-invented vs dat-driven — summary classification
|
||
|
||
**Purely dat-driven (faithful mechanical port of retail's node/frame model):**
|
||
- Node list structure, link resolution forward-path, `GetStartFramePosition`/
|
||
`GetEndFramePosition`, `multiply_framerate` semantics (modulo the
|
||
documented Start/EndFrame-swap simplification), `update_internal`'s
|
||
frame-boundary walk + hook firing + root-motion accumulation,
|
||
`advance_to_next_animation` wrap, `execute_hooks` direction matching,
|
||
the retail slerp, `clear_physics`/`add_motion` replace-semantics for
|
||
Velocity/Omega, PlayAction's Action/Modifier dual-path lookup.
|
||
|
||
**acdream-invented (no retail citation, or explicitly-flagged deviation/workaround):**
|
||
1. **K-fix18 `skipTransitionLink`** — product decision to skip the retail
|
||
transition-link pose for Falling-on-jump-start; not retail behavior.
|
||
2. **Fix B locomotion cyclic→cyclic link-skip** — scoped fix verified via
|
||
live cdb trace (so it IS retail-faithful for that subset), but the
|
||
*mechanism* (removing the enqueued link node, forcing `_currNode` onto
|
||
`_firstCyclic`) is acdream's own structural patch, not a literal port
|
||
of a retail function.
|
||
3. **Stale-head `_currNode` force-relocation** (`preEnqueueTail`/`firstNew`
|
||
tracking) — acdream bug-fix for a structural mismatch versus retail's
|
||
apparent behavior; no retail citation for the mechanism itself.
|
||
2. **Stop-anim fallback** (direction-agnostic settle-link retry) — acdream
|
||
workaround for a null-linkData gap.
|
||
5. **`GetLink`'s reversed-direction branch** — while individually justified
|
||
against an *observed* bug (X-key twitch), it's presented as acdream's
|
||
own two-branch generalization of `get_link`, cross-checked against ACE
|
||
line ranges rather than a single retail function decompile.
|
||
6. **CurrentVelocity/CurrentOmega synthesis for locomotion/turn cycles**
|
||
(`WalkAnimSpeed=3.12f`, `RunAnimSpeed=4.0f`, `SidestepAnimSpeed=1.25f`
|
||
constants, turn `±π/2` synthesis) — folds a *separate* retail C++
|
||
mechanism (`CMotionInterp::get_state_velocity`, a physics-side
|
||
function) into the *sequencer's* velocity surface because the
|
||
Humanoid dat itself carries no baked velocity for these MotionData
|
||
entries. Functionally retail-faithful in intent but architecturally a
|
||
deviation (retail keeps this computation outside the Sequence object
|
||
entirely).
|
||
7. **BuildBlendedFrame's non-looping boundary-hold** (issue #61 fix) — an
|
||
empirically-motivated patch for the link→cycle boundary flash, without
|
||
a cited retail mechanism describing how the real client avoids this
|
||
flash. **This is exactly the class of bug the animation-sequencer
|
||
deep-dive skill was built to root-cause** — the current fix may be
|
||
masking rather than replicating retail's actual boundary behavior.
|
||
8. **`safety = 64` loop cap in `Advance`** — defensive engineering, no
|
||
retail citation.
|
||
9. **All `[SCFAST]/[SCFULL]/[SCNULLFALLBACK]/[CURRNODE]` diagnostics** —
|
||
acdream-only instrumentation, per-call-site env var reads (flagged by
|
||
CLAUDE.md itself as a tech-debt pattern that shouldn't be extended
|
||
further without promotion to a diagnostic-owner class per Code
|
||
Structure Rule 5).
|
||
10. **`AnimationDoneSentinel` as a single shared static instance** — works
|
||
for "some link just finished" notifications but can't carry per-fire
|
||
identity; not verified against how retail's `AnimDoneHook` actually
|
||
carries context.
|
||
|
||
**Unwired / apparently dead API surface found during this pass:**
|
||
- `ConsumeRootMotionDelta()` — no call site found in `src/` outside the
|
||
sequencer itself. Root motion (PosFrames) is accumulated every tick but
|
||
nothing drains it into entity placement.
|
||
- `Reset()` — no external call site found in the searched consumer set.
|
||
- `MultiplyCyclicFramerate` — only self-invoked by `SetCycle`'s fast path;
|
||
no direct external callers.
|
||
- `HasCurrentNode` — no external call site found beyond its own
|
||
declaration.
|
||
|
||
## Cross-references worth pulling into the deep-dive
|
||
|
||
- `docs/research/acclient_animation_pseudocode.md` — cited pseudocode doc
|
||
for FUN_005267E0/FUN_00525EB0/FUN_00526880/FUN_005268B0/FUN_005360d0/
|
||
FUN_005261D0 (sections 5-7).
|
||
- `docs/research/deepdives/r03-motion-animation.md` section 3 — cited by
|
||
`AnimationCommandRouter.cs` for the class-mask routing scheme.
|
||
- `references/ACE/Source/ACE.Server/Physics/Animation/Sequence.cs` and
|
||
`MotionTable.cs` — the ACE C# port cross-referenced throughout
|
||
(`Sequence.cs:127-130, 256-260, 262-270, 277-287, 351-443`;
|
||
`MotionTable.cs:100-101, 132-139, 152-153, 358-370, 372-379, 395-426`).
|
||
- `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs:394-428`
|
||
— `adjust_motion` source.
|
||
- `docs/research/2026-06-04-animation-sequencer-deep-dive.md` (per
|
||
MEMORY.md) — prior deep-dive already exists; ranked 8 divergences
|
||
including "missing pending_motions/MotionDone HIGH" — **this current
|
||
file does not implement any `pending_motions` concept** (no field named
|
||
that, no queueing of *future* motions ahead of the current queue beyond
|
||
the single link+cycle model) — worth checking whether that HIGH-ranked
|
||
gap from the prior research drop is still open against this current
|
||
implementation.
|