acdream/docs/research/2026-07-02-r1-csequence/r1-acdream-sequencer.md
Erik 1371c2a14c feat(R1-P0/P1): CSequence research base + verbatim AnimSequenceNode
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>
2026-07-02 19:45:56 +02:00

377 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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.