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>
This commit is contained in:
Erik 2026-07-02 19:45:56 +02:00
parent cae56afc82
commit 1371c2a14c
7 changed files with 3363 additions and 0 deletions

View file

@ -0,0 +1,55 @@
# R1-P0 — pseudocode pins and ambiguity resolutions
The verbatim extraction lives in `r1-csequence-decomp.md` (1,756 lines, per-
function raw pseudo-C + cleaned translation, line anchors into
`docs/research/named-retail/acclient_2013_pseudo_c.txt`). This note records
the corrections and pins the R1 port codes against, per the gap map
(`r1-gap-map.md`).
## Corrections to the extraction (verified against raw decomp + ACE)
1. **§21 `update_internal` cleaned flow is garbled in two places.** The
authoritative skeleton is ACE `Sequence.cs:351-443` (verified verbatim,
matches the raw decomp branch layout at 0x005255d0-0x005259ca):
overshoot check → clamp `frame_number` to `get_high_frame()` (fwd) /
`get_low_frame()` (rev) + compute `frameTimeElapsed` leftover +
`animDone = true` → per-frame crossing loop → `if (!animDone) return`
AnimDone gate (`anim_list.head != first_cyclic`) →
`advance_to_next_animation` → carry leftover → loop.
2. **Hook direction constants**: forward crossings pass +1
(0x0052578c, counter `++` after), reverse pass 1 (0x0052590c, counter
`--` after). Retail encoding == ACE/DatReaderWriter `AnimationHookDir`
(Backward=1 / Both=0 / Forward=1) — no remap.
## PINNED ambiguity — leftover-time carry after node advance
Raw decomp at 0x0052598a-0x0052598d APPEARS to zero the elapsed argument
after `advance_to_next_animation` before looping; ACE carries the leftover
(`timeElapsed = frameTimeElapsed`, Sequence.cs:436-442).
**PIN: the ACE reading (carry the leftover).** Rationale: (a) Binary Ninja
routinely loses x87 stack-slot reassignments (the same artifact class as the
garbled setcc returns in `is_newer`); (b) zeroing would make a lag spike
unable to fast-forward through multiple queued nodes in one tick, visibly
freezing links under hitches — behavior nobody has ever reported on retail;
(c) the loop structure (`while(true)` with the leftover recomputed per
node) only makes sense with a carry.
**Confirmation pending (not blocking):** cdb breakpoints on
`acclient!CSequence::update` + `acclient!CSequence::advance_to_next_animation`
with hit counters (pattern `tools/cdb/l2g-observer.cdb`) under an induced
stall — an advance/update ratio > 1 in a single update proves the carry.
Fold into the next live retail session; if it DISPROVES the carry, the fix
is one line in `update_internal` + rerun of the P4 conformance suite.
## P0 addenda for later stages
- The same cdb session should also capture `append_animation` /
`remove_cyclic_anims` argument logs (P2/P5 goldens) — one session serves
all of R1.
- `frame_number` is x87 `long double` (acclient.h:30747); C# `double` is the
closest available → divergence-register row lands with the P2 commit
(G15).
- ACE's `PhysicsGlobals.EPSILON` subtraction in
`get_starting/get_ending_frame` is an ACE fabrication (compensating for
ACE's `float FrameNumber`) — retail returns bare ints. Do NOT copy (G1).

View file

@ -0,0 +1,377 @@
# 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.

View file

@ -0,0 +1,583 @@
# ACE CSequence port map — cross-reference vs. 2013 retail decomp
Scope: `references/ACE/Source/ACE.Server/Physics/Animation/{Sequence.cs, AnimSequenceNode.cs,
AnimData.cs, AFrame.cs}` + `MotionTable.cs`'s `add_motion/combine_motion/subtract_motion/change_cycle_speed`.
Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (`CSequence::*`,
`AnimSequenceNode::*`, free functions `add_motion`/`combine_motion`/`subtract_motion`/`change_cycle_speed`)
+ verbatim struct defs in `docs/research/named-retail/acclient.h` (line 30747 `CSequence`, line 31063
`AnimSequenceNode`).
## HEADLINE DIVERGENCE (all downstream methods inherit this)
**`Sequence.FrameNumber` is `float` in ACE; retail's `CSequence::frame_number` is `long double`
(80-bit x87 extended precision), not even `double`.**
`acclient.h:30754`: `long double frame_number;`
Every retail arithmetic op on frame_number/timeElapsed in `update_internal`,
`advance_to_next_animation`, `apply_physics` explicitly upcasts to `(long double)` before the
op and downcasts back only where storing into a `float` field (velocity/omega components,
`AnimSequenceNode::framerate`). The comparisons that decide "did we cross a frame boundary" run
at x87-extended precision in retail, at `float` precision in ACE.
Public entry point confirms the wire type: `CSequence::update(class CSequence* this, double arg2, class Frame* arg3)`
@ `0x00525b80` — the *external* `quantum` parameter is `double`, matching `MotionInterp` callers
(ACE's `Sequence.Update(float quantum, ...)` is `float`). Internally retail immediately treats it
as `long double` in the callee.
**Practical effect:** frame-boundary crossing math (`Math.Floor(frameNum) > lastFrame`,
`get_high_frame() < Math.Floor(frameNum)`) can disagree between ACE (float) and retail (80-bit)
at the ULP level near exact frame boundaries — this is a plausible root cause for
subtle frame-swap / off-by-one animation bugs when the accumulated quantum sum lands very close
to an integer frame number. acdream's own port should use `double` at minimum (C# doesn't expose
80-bit extended); pure `float` (ACE's choice) is the most divergent option available.
## `Sequence.cs` (ACE) ↔ `CSequence` (retail) — per-method map
### Fields
| ACE field | Retail field (`acclient.h:30747`) | Type match? |
|---|---|---|
| `int ID` | (not in CSequence; ACE-only bookkeeping, no retail equivalent found in struct) | N/A |
| `LinkedList<AnimSequenceNode> AnimList` + `FirstCyclic`/`CurrAnim` as `LinkedListNode<T>` | `DLList<AnimSequenceNode> anim_list` (intrusive doubly-linked list) + `AnimSequenceNode *first_cyclic` + `AnimSequenceNode *curr_anim` | Structural match; ACE trades retail's intrusive DLList for `System.Collections.Generic.LinkedList<T>`, semantically equivalent but allocates node wrappers separately (see `apricot()` note below) |
| `Vector3 Velocity` | `AC1Legacy::Vector3 velocity` | float×3, match |
| `Vector3 Omega` | `AC1Legacy::Vector3 omega` | float×3, match |
| `PhysicsObj HookObj` | `CPhysicsObj *hook_obj` | match |
| `float FrameNumber` | `long double frame_number` | **DIVERGENT — see headline** |
| `AnimationFrame PlacementFrame` | `AnimFrame *placement_frame` | match |
| `int PlacementFrameID` | `unsigned int placement_frame_id` | match (signedness cosmetic) |
| `bool IsTrivial` | `int bIsTrivial` | match (never read/written elsewhere in ACE's Sequence.cs — dead field there too) |
### Constructor / `Init()`
- Retail `CSequence::CSequence` (`0x005249f0`): `memset(&anim_list, 0, 0x28)` then
`memset(&frame_number, 0, 0x18)` — i.e. zeroes `frame_number`, `curr_anim`, `placement_frame`,
`placement_frame_id`, `bIsTrivial` in one sweep. Velocity/omega/first_cyclic/hook_obj are
zeroed by the first memset (part of `anim_list`+`first_cyclic`+`velocity`+`omega`+`hook_obj`
span, 0x28 bytes).
- ACE `Init()`: sets `Velocity = Zero`, `Omega = Zero`, `FrameNumber = 0.0f`, `AnimList = new()`.
Does **not** reset `CurrAnim`, `FirstCyclic`, `HookObj`, `PlacementFrame`,
`PlacementFrameID`, `IsTrivial` — those are left at C# default (null/0) only because `Init()`
is only ever called from the ctor in practice. Faithful for the ctor path; would silently diverge
if `Init()` were ever called again on a live Sequence (retail's memset always re-zeros
everything; ACE's `Init()` does not).
### `Update(quantum, ref offsetFrame)``CSequence::update` (`0x00525b80`)
Exact 1:1 structural match:
```
retail: if (anim_list.head_ != 0) { update_internal(...); apricot(); return; }
else if (arg3 != null) apply_physics(arg3, arg2, arg2);
ACE: if (AnimList.First != null) { update_internal(...); apricot(); }
else if (offsetFrame != null) apply_physics(offsetFrame, quantum, quantum);
```
Param type: retail `arg2` is `double` at this boundary (external quantum). ACE's `quantum` is
`float`. See headline.
### `advance_to_next_animation``CSequence::advance_to_next_animation` (`0x005252b0`)
Retail signature: `(this, double arg2 /*timeElapsed*/, AnimSequenceNode** arg3 /*animNode*/,
double* arg4 /*frameNum*/, Frame* arg5 /*frame*/)`.
Structurally identical two-branch dispatch on `timeElapsed >= 0.0`:
- **Forward branch** (`timeElapsed >= 0`): if `frame != null && currAnim.Framerate < 0` (i.e.
finishing a *reverse-playing* anim), subtract `get_pos_frame(frameNum)`, apply_physics with
`1/framerate` if `|framerate| > EPSILON`. Then advance `animNode` to `.Next` or wrap to
`FirstCyclic`. `frameNum = currAnim.get_starting_frame()`. If `frame != null && framerate > 0`
(starting a *forward-playing* anim), combine pos_frame, apply_physics.
ACE's `advance_to_next_animation` (`Sequence.cs:145`) matches line-for-line, including the
`Math.Abs(currAnim.Framerate) > PhysicsGlobals.EPSILON` guards on `apply_physics`.
- **Backward branch** (`timeElapsed < 0`): mirror image — subtract when `framerate >= 0`
(finishing forward-playing), step to `.Previous` or wrap to `List.Last`, `frameNum =
get_ending_frame()`, combine when `framerate < 0` (starting reverse-playing).
ACE matches.
- EPSILON constant used for `|framerate|` compares: retail literal `0.000199999995f`
`0.0002f` = `ACE.Server.Physics.PhysicsGlobals.EPSILON` (`references/ACE/Source/ACE.Server/Physics/PhysicsGlobals.cs:9`). Confirmed identical constant.
### `append_animation``CSequence::append_animation` (`0x00525510`)
Retail: allocate `AnimSequenceNode(arg2)`; if `has_anim()` fails, delete + return (no-op). Else
insert at tail of `anim_list`, set `first_cyclic = tail` (**every** append moves `first_cyclic`
to the newest node — i.e. "cyclic" region is always exactly the last-appended anim until removed).
If `curr_anim == null`: set `curr_anim = head`, `frame_number = get_starting_frame(head)` (or,
in an unreachable dead branch, `get_starting_frame(nullptr)` if head is somehow still null after
just inserting — BinNinja artifact, not real control flow).
ACE (`Sequence.cs:203`) matches: `if (!node.has_anim()) return;` — but ACE does NOT delete the
orphan node explicit (no-op is fine in GC'd C#, matches retail's leak-avoidance intent). `AnimList.AddLast`, `FirstCyclic = AnimList.Last`, `if (CurrAnim == null) { CurrAnim = AnimList.First; FrameNumber = CurrAnim.Value.get_starting_frame(); }`. Faithful.
### `apply_physics(frame, quantum, sign)``CSequence::apply_physics` (`0x00524ab0`)
Retail: `quantum = sign>=0 ? |quantum| : -|quantum|` (sign-copy pattern — note retail's param
order is `(Frame*, double quantum-as-arg3, double sign-as-arg4)`, i.e. **arg3 is the magnitude
source, arg4 is only used for its sign**). Then `Origin += Velocity * quantum` per-axis (retail
does each axis as a separate cast-heavy expression — no semantic difference), `Frame::rotate(Omega
* quantum)`.
ACE (`Sequence.cs:221`): `if (sign>=0.0) quantum=Abs(quantum); else quantum=-Abs(quantum); frame.Origin += Velocity*quantum; frame.Rotate(Omega*quantum);` — exact match. All arithmetic in
retail runs at `long double`; ACE at `float`. Same headline-precision divergence as FrameNumber.
### `apricot()``CSequence::apricot` (`0x00524b40`)
Purpose: after `update_internal` may have advanced `curr_anim` forward past older entries,
prune any anim nodes from `anim_list.head_` up to (but not including) `curr_anim`, UNLESS we hit
`first_cyclic` first (in which case stop — don't prune into the cyclic region).
Retail: `i = head; if (i != curr_anim) { while (i != first_cyclic) { if (i == first_cyclic)
break; delete-unlink(i); i = head; if (i == curr_anim) break; } }` — i.e. loop re-reads `head`
after every unlink (since unlinking changes what head is), and has a **redundant double check**
of `i == first_cyclic` (once as the while-condition, once again as the first statement inside
the loop before the delete — likely because retail's `while` condition is evaluated at the *top*,
and the body immediately re-checks in case the initial `head` already equals `first_cyclic`, which
would only be reachable if `i != curr_anim` was somehow also true — defensive but effectively the
same predicate twice).
ACE (`Sequence.cs:232`):
```csharp
var node = AnimList.First;
while (!node.Equals(CurrAnim)) {
if (node.Equals(FirstCyclic)) break;
AnimList.Remove(node);
node = AnimList.First;
}
```
Semantically equivalent to retail (loop-while-not-curr-anim, break-if-first-cyclic, remove head,
re-read head). Faithful port; the double-check redundancy in retail's disassembly collapses to
ACE's single `if` because C#'s `while(cond)` + `if(cond) break` at the top of the body is exactly
retail's structure once the duplicate compiler artifact is discounted.
### `clear_animations()``CSequence::clear_animations` (`0x00524dc0`)
Retail: pop every node off `anim_list` (unlink+delete each), then `first_cyclic = nullptr`,
`frame_number = 0`, `curr_anim = nullptr`.
ACE: `AnimList.Clear(); FirstCyclic = null; FrameNumber = 0; CurrAnim = null;` — exact match
(GC handles the per-node delete implicitly).
### `clear_physics()``CSequence::clear_physics` (`0x00524d50`)
Retail: zero `velocity` and `omega` component-wise. ACE: `Velocity = Vector3.Zero; Omega =
Vector3.Zero;`. Match.
### `Clear()``CSequence::clear` (`0x005255b0`)
Retail: `clear_animations(); clear_physics();` — does **NOT** touch `placement_frame` /
`placement_frame_id` in the retail disasm shown (only two calls visible at `0x005255b3`/
`0x005255ba`). ACE's `Clear()` (`Sequence.cs:71`) additionally sets `PlacementFrame = null;
PlacementFrameID = 0;` — **this is an ACE-only addition beyond what the two-instruction retail
`clear()` body does.** Worth flagging as a possible ACE embellishment/bug if a future port
strictly mirrors retail's `clear()`; however note ACE's own `~CSequence`/dtor is not modeled at
all (C# has no destructor equivalent needed), so this may be ACE compensating for a different owner-lifecycle assumption. Flag for acdream: **do not blindly copy ACE's `Clear()` — verify whether placement-frame reset belongs here or only in `UnPack`** (retail's `UnPack` explicitly nulls `placement_frame`/`placement_frame_id` — see below).
### `remove_cyclic_anims()``CSequence::remove_cyclic_anims` (`0x00524e40`)
Retail: iterate from `first_cyclic` forward (`AnimSequenceNode::GetNext`); for each node, if
`curr_anim == node`: set `curr_anim = GetPrev(node)`; if that prev is null, `frame_number = 0`,
else `frame_number = get_ending_frame(prev)`. Then unlink+delete `node` regardless. After the
loop, `first_cyclic = anim_list.tail_` (or null if list now empty — implied by the trailing code
not fully captured above but consistent with ACE).
ACE (`Sequence.cs:303`):
```csharp
var node = FirstCyclic;
while (node != null) {
if (CurrAnim.Equals(node)) {
CurrAnim = node.Previous;
if (CurrAnim != null) FrameNumber = CurrAnim.Value.get_ending_frame();
else FrameNumber = 0.0f;
}
var next = node.Next;
AnimList.Remove(node.Value);
node = next;
}
FirstCyclic = AnimList.Last;
```
Faithful — matches retail's per-node dispose-then-advance and the final `first_cyclic = tail`
reset.
### `remove_link_animations(amount)``CSequence::remove_link_animations` (`0x00524be0`)
Retail: loop `amount` times; each iteration, if `GetPrev(first_cyclic) == null` return early
(no more link anims to remove); if `curr_anim == GetPrev(first_cyclic)`, snap `curr_anim =
first_cyclic` and `frame_number = get_starting_frame(first_cyclic)`; then unlink+delete
`GetPrev(first_cyclic)`.
ACE (`Sequence.cs:324`) matches exactly, including the early-return-not-break semantics
(`if (FirstCyclic.Previous == null) return;` inside the `for` loop — matches retail's `break`-out-of-do-while-then-return-since-nothing-else-follows pattern).
### `remove_all_link_animations()``CSequence::remove_all_link_animations` (`0x00524ca0`)
Retail: `while (first_cyclic != null && GetPrev(first_cyclic) != null) { same snap-then-delete
pattern as remove_link_animations, unbounded }`.
ACE (`Sequence.cs:289`): `while (FirstCyclic != null && FirstCyclic.Previous != null) { if
(CurrAnim.Equals(FirstCyclic.Previous)) { CurrAnim = FirstCyclic; if (CurrAnim != null)
FrameNumber = CurrAnim.Value.get_starting_frame(); } AnimList.Remove(FirstCyclic.Previous); }`
Match.
### `execute_hooks(animFrame, dir)``CSequence::execute_hooks` (`0x00524830`)
Retail: `if (hook_obj != 0) for (hook in animFrame->hooks) if (hook.direction_ == 0 /*Both*/ ||
dir == hook.direction_) hook_obj->add_anim_hook(hook)`.
ACE (`Sequence.cs:262`): `if (animFrame == null || HookObj == null) return; foreach (hook in
animFrame.Hooks) if (hook.Direction == Both || hook.Direction == dir) HookObj.add_anim_hook(hook);`
Match (ACE adds an explicit `animFrame == null` guard retail doesn't need because retail always
passes a valid `arg2` from `AnimSequenceNode::get_part_frame` which can itself return null —
retail's caller `update_internal` still calls `execute_hooks` unconditionally with a
possibly-null frame pointer, then retail's `execute_hooks` dereferences `arg2->hooks` **without
a null check** at `0x00524844`. **This is a latent null-deref risk in retail itself** if
`get_part_frame` returns null for an out-of-range frame index — ACE's added `animFrame == null`
guard is a defensive divergence, not a bug, and is the *correct* choice for a managed port. Note
this in case a "PARTSDIAG null-guard" investigation (per project memory) surfaces this exact
retail-side latent null-deref as the root cause of a real bug.)
### `get_curr_frame_number()``CSequence::get_curr_frame_number` (`0x005249d0`)
Retail: `floor(frame_number); return (int)floor_result` (`_ftol2` = truncating float-to-long
after `floor`). ACE: `(int)Math.Floor(FrameNumber)`. Match, modulo the float-vs-long-double
headline divergence.
### `get_curr_animframe()``CSequence::get_curr_animframe` (`0x00524970`)
Retail: `if (curr_anim == null) return placement_frame; return curr_anim->get_part_frame((int)floor(frame_number));`
ACE `GetCurrAnimFrame()` (`Sequence.cs:89`): `if (CurrAnim == null) return PlacementFrame; return
CurrAnim.Value.get_part_frame(get_curr_frame_number());` where `get_curr_frame_number()` is the
same floor+truncate. Match.
### `set_placement_frame` / `set_velocity` / `set_omega` / `combine_physics` / `subtract_physics`
All four are trivial field setters/accumulators in both retail and ACE — verified byte-for-byte
equivalent logic (`Sequence.cs:111-130`, `:83-87`, `:345-349`). No divergence.
### `multiply_cyclic_animation_framerate(rate)``CSequence::multiply_cyclic_animation_fr` (`0x00524940`)
Retail: `for (n = first_cyclic; n != null; n = GetNext(n)) AnimSequenceNode::multiply_framerate(n, rate);`
ACE (`Sequence.cs:277`): identical loop over `FirstCyclic`. Match.
### `update_internal(timeElapsed, ref animNode, ref frameNum, ref frame)``CSequence::update_internal` (`0x005255d0`)
This is the core per-tick state machine; retail's decompiled x87 soup (heavy `long double`
comparison-flag reconstruction, unresolved `fld`/`fcomp` mnemonics the decompiler couldn't
symbolize) obscures direct reading, but the **control-flow skeleton is fully recoverable** and
matches ACE 1:1:
```
lastFrame = floor(frameNum)
frametime = framerate * timeElapsed
frameNum += frametime
frameTimeElapsed = 0
animDone = false
if (frametime > 0):
if (get_high_frame() < floor(frameNum)):
frameOffset = frameNum - get_high_frame() - 1; clamp to >=0
if |framerate| > EPS: frameTimeElapsed = frameOffset / framerate
frameNum = get_high_frame()
animDone = true
while floor(frameNum) > lastFrame:
if frame != null:
combine pos_frame(lastFrame) into frame (if pos_frames != null)
if |framerate| > EPS: apply_physics(frame, 1/framerate, timeElapsed)
execute_hooks(get_part_frame(lastFrame), Forward)
lastFrame++
elif (frametime < 0):
[mirror: get_low_frame(), subtract instead of combine, Backward hooks, lastFrame--]
else:
if frame != null && |timeElapsed| > EPS: apply_physics(frame, timeElapsed, timeElapsed)
if (!animDone): return
if (hook_obj != null && head != first_cyclic): hook_obj->add_anim_hook(AnimDoneHook)
advance_to_next_animation(timeElapsed, ref animNode, ref frameNum, ref frame)
timeElapsed = frameTimeElapsed
[LOOP back to top] <-- retail implements this as an actual `while(true)` loop with the
call to advance_to_next_animation + timeElapsed reassignment,
THEN re-enters the top of the function body (0x005255e8 label).
```
ACE (`Sequence.cs:351`) implements the exact same skeleton but as **explicit tail recursion**
(`update_internal(timeElapsed, ref animNode, ref frameNum, ref frame);` as the last statement)
rather than retail's `while(true)` loop. Semantically equivalent (C#'s JIT does NOT guarantee
tail-call optimization for this shape, so very long same-tick multi-anim-boundary crossings
could theoretically risk stack depth in ACE where retail would not — low practical risk since
`frameTimeElapsed` shrinks each iteration and hits `frametime == 0` quickly, but note this as
a structural (not behavioral) implementation difference).
Retail's `execute_hooks` direction constant: forward pass at `0x0052590c` passes `0xffffffff`
(-1, all-bits) not `1`; backward pass at `0x0052578c` passes `1`. **This looks inverted from a
naive reading** (forward should intuitively be "Forward" not `-1`), but cross-check against
`AnimationHookDir` enum values: ACE's port passes `AnimationHookDir.Forward` for the `lastFrame++`
(ascending, `frametime>0`) branch and `AnimationHookDir.Backward` for the `lastFrame--`
(descending, `frametime<0`) branch — i.e. ACE's C# reads correctly against the *semantic* forward/
backward regardless of retail's raw enum encoding. Need to verify `AnimationHookDir` enum's
actual underlying values in `ACE.Entity.Enum` to confirm `Forward == -1`/`0xffffffff` vs `1`,
but this is very likely just how the enum is defined (Both=0, Forward=-1, Backward=1, or similar
non-sequential encoding) rather than an ACE bug — **flag as needing confirmation, not a
confirmed divergence.**
EPSILON compares throughout use the same `0.000199999995f` literal as elsewhere. The `frametime
== 0` branch's guard is `|timeElapsed| > EPSILON` before calling `apply_physics(frame,
timeElapsed, timeElapsed)` — ACE matches exactly (`Sequence.cs:424`).
### `UnPack` (retail-only relevance)
`CSequence::UnPack` (`0x005259d0`) explicitly does `clear_animations(); clear_physics();
placement_frame = null; placement_frame_id = 0;` before deserializing — this is where retail
actually nulls the placement frame, which is the retail-verified justification for ACE's
`Clear()` including that reset (even though the disassembled 2-line `clear()` body itself does
not). ACE has no `Sequence.UnPack` in this file (no wire (de)serialization path ported) — this
is out of scope for acdream's runtime port (server-authoritative motion state, not client dat
deserialization) but is why ACE's `Clear()` and `clear()`/`clear_animations()`+`clear_physics()`
appear to disagree — they're actually modeling two different retail call sites (`clear()` proper
vs. `UnPack`'s manual clear+reset sequence). **Not a bug in ACE; a naming/scope conflation worth
noting for anyone tracing ACE's `Clear()` back to a single retail function.**
## `AnimSequenceNode.cs` (ACE) ↔ `AnimSequenceNode` (retail struct @ `acclient.h:31063`)
### Fields
`CAnimation *anim` / `float framerate` / `int low_frame` / `int high_frame` — all match ACE's
`Animation Anim` / `float Framerate` / `int LowFrame` / `int HighFrame` exactly, including types
(both `int`, not `uint`).
### Default ctor `AnimSequenceNode()` ↔ retail `AnimSequenceNode::AnimSequenceNode()` (`0x00525d30`)
Retail: `framerate = 30f; low_frame = 0xffffffff (-1); high_frame = 0xffffffff (-1);` (both
low+high default to **-1**, not `low=0`).
ACE (`AnimSequenceNode.cs:15`): `Framerate = 30.0f; LowFrame = 0; HighFrame = -1;`
**CONFIRMED DIVERGENCE: ACE's parameterless ctor sets `LowFrame = 0` where retail sets
`low_frame = -1`.** This constructor is never actually invoked by ACE's own runtime path (the
only call site is `new AnimSequenceNode(animData)`, the parameterized overload, which explicitly
sets `LowFrame = animData.LowFrame`), so this divergence is currently dormant/unreachable in
ACE's code — but it is a real textual mismatch against retail's default-construct semantics and
would matter if any future code path constructs a bare `AnimSequenceNode()` and relies on default
`LowFrame`.
### `get_starting_frame()``AnimSequenceNode::get_starting_frame` (`0x00525c80`)
Retail: `if (framerate < 0) return high_frame + 1; else return low_frame;` — **returns a plain
`int32_t`**, i.e. `high_frame + 1` with NO epsilon subtraction.
ACE (`AnimSequenceNode.cs:72`):
```csharp
public float get_starting_frame() {
if (Framerate >= 0.0f) return LowFrame;
else return HighFrame + 1 - PhysicsGlobals.EPSILON;
}
```
**CONFIRMED DIVERGENCE (significant):** ACE subtracts `PhysicsGlobals.EPSILON` (0.0002) from
`HighFrame + 1` when framerate is negative — retail does **not**; retail returns the exact
integer `high_frame + 1`. Also note the boundary condition flip: retail's branch condition is
`framerate < 0` (strict) with the `>= 0` case falling to `low_frame`; ACE's condition is
`Framerate >= 0.0f` returning `LowFrame` (equivalent boundary, `framerate==0` behaves the same
in both — returns `low_frame`/`LowFrame`). The boundary logic itself is faithful; **only the
epsilon subtraction is a fabricated addition not present in retail.** This likely exists in ACE
to avoid `Math.Floor` landing exactly on `HighFrame+1` and reading one frame past the end when
used as a float frame-cursor, but retail doesn't need it because retail's `get_starting_frame`
return value is immediately truncated back to an `int` in most call sites — however note retail's
`CSequence::advance_to_next_animation` and `remove_cyclic_anims` etc. store this int return value
directly into `frame_number` (a `long double`), so no fractional epsilon ever appears in retail's
frame_number for this codepath. **acdream should NOT copy this epsilon subtraction if porting
`get_starting_frame` faithfully** — investigate whether ACE added it to work around a downstream
float-precision issue introduced by ACE's OWN `float FrameNumber` choice (i.e. a workaround for
ACE's own divergence, compounding on top of it) rather than something retail does.
### `get_ending_frame()``AnimSequenceNode::get_ending_frame` (`0x00525cb0`)
Retail: `if (framerate >= 0) return high_frame + 1; else return low_frame;` — again plain
integer, no epsilon.
ACE (`AnimSequenceNode.cs:31`):
```csharp
public float get_ending_frame() {
if (Framerate >= 0.0f) return HighFrame + 1 - PhysicsGlobals.EPSILON;
else return LowFrame;
}
```
**Same confirmed divergence as `get_starting_frame`** — ACE subtracts EPSILON from `HighFrame+1`
where retail returns the bare int. Branch condition (`>= 0` → high+1 path) matches retail exactly
this time (mirrors correctly — `get_starting_frame` and `get_ending_frame` are exact opposites of
each other by design, both in retail and ACE); only the epsilon fabrication persists.
### `get_high_frame()` / `get_low_frame()`
Trivial accessors in both — direct field reads. No retail decompiled body found by name (likely
inlined/not separately emitted, or address not matched by this grep pass), but ACE's are pure
passthroughs (`return HighFrame;` / `return LowFrame;`) which cannot diverge from the struct field
values already confirmed to match. No risk.
### `get_part_frame(frameIdx)``AnimSequenceNode::get_part_frame` (`0x00525c40`)
Retail: `if (anim != null && arg2 >= 0 && arg2 < anim->num_frames) return &anim->part_frames[arg2];
else return null;`
ACE (`AnimSequenceNode.cs:49`): `if (Anim == null) return null; if (frameIdx < 0 || frameIdx >=
Anim.NumFrames) return null; return Anim.PartFrames[frameIdx];` Logically equivalent (De Morgan's
of the same guard). Match. **Note the retail-side latent null-deref risk flagged above in
`execute_hooks`**: retail's `get_part_frame` DOES null-check bounds here, so a null `AnimFrame*`
can legitimately flow into `execute_hooks(this, get_part_frame(...), dir)` when `frameIdx` is
out of `[0, num_frames)` — retail's `execute_hooks` then dereferences it unconditionally. ACE
avoids this crash class entirely via its own `animFrame == null` guard in `execute_hooks`.
### `get_pos_frame(int frameIdx)``AnimSequenceNode::get_pos_frame(int32_t)` (`0x00525c10`)
Retail: same null/bounds guard as `get_part_frame` but against `PosFrames`/`pos_frames`, returns
`&anim->pos_frames[arg2 * 0x1c]` (0x1c = sizeof(AFrame) = 28 bytes: Vector3 origin (12) +
Quaternion (16) = 28 — confirms `AFrame` layout) on success, else... retail returns `0` (null
pointer) on failure, whereas **ACE returns `new AFrame()`** (identity frame) instead of null:
```csharp
public AFrame get_pos_frame(int frameIdx) {
if (Anim == null) return new AFrame();
if (frameIdx < 0 || frameIdx >= Anim.PosFrames.Count) return new AFrame();
return Anim.PosFrames[frameIdx];
}
```
**CONFIRMED DIVERGENCE:** retail can return a null `AFrame*` from `get_pos_frame`; ACE always
returns a non-null identity `AFrame`. This is almost certainly intentional/necessary in ACE
because C#'s callers (`update_internal`, `advance_to_next_animation`) call
`AFrame.Combine(frame, currAnim.get_pos_frame(...))` / `frame.Subtract(...)` unconditionally when
`currAnim.Anim.PosFrames.Count > 0` is already true (guarding the call site) — so in practice the
only way retail's null path is hit is if `pos_frames` is non-null overall but the specific index
is out of the current `[0, num_frames)` bounds, an edge retail's callers appear to avoid by
construction. ACE's identity-frame fallback is a defensive substitute for retail's null (which
would otherwise NPE `AFrame.Combine`/`Subtract` in C#) — functionally converges to a no-op combine
in the one path where it could differ, matching retail's *intended* behavior (no-op) via a
different mechanism (identity frame vs. skipped call). Low risk, but textually a real divergence
worth listing.
There's also a `float`-overload convenience wrapper `get_pos_frame(float frameIdx)` in ACE
(`:67`, `=> get_pos_frame((int)Math.Floor(frameIdx))`) with no direct 1:1 retail counterpart found
by this pass — likely inlined at each call site in retail rather than a separate overload; no
behavioral risk since it's a pure convenience delegator.
### `has_anim(int appraisalProfile = 0)``AnimSequenceNode::has_anim` (`0x00525c70`)
Retail: `return anim != 0;` (no parameter). ACE: `return Anim != null;` with a vestigial unused
`appraisalProfile` parameter (default 0, never read in the body) — **ACE-only dead parameter**,
harmless (matches retail's actual logic; the extra param appears to be scaffolding for a
different unrelated retail overload elsewhere, not a behavioral difference here).
### `multiply_framerate(multiplier)``AnimSequenceNode::multiply_framerate` (`0x00525be0`)
Retail: `if (multiplier < 0) swap(low_frame, high_frame); framerate *= multiplier;`
ACE (`:85`): `if (multiplier < 0.0f) { swap LowFrame/HighFrame } Framerate *= multiplier;` Exact
match, including the swap-BEFORE-multiply ordering (doesn't matter for correctness here since the
swap doesn't depend on the post-multiply framerate value, but confirms ACE preserved retail's
instruction order faithfully).
### `set_animation_id(animID)``AnimSequenceNode::set_animation_id` (`0x00525d60`, body continues past what this pass read in full — only header + first 3 lines captured)
ACE (`:96`): looks up `Anim = new Animation(DBObj.GetAnimation(animID))`; if `Anim == null`
return; clamps `HighFrame` to `-1 -> NumFrames-1` if still default, clamps `LowFrame`/`HighFrame`
individually if `>= NumFrames`, and clamps `LowFrame > HighFrame` by raising `HighFrame =
LowFrame`. This clamping logic was not fully re-derived from the retail disasm in this pass
(truncated read) — **recommend a follow-up grep of `AnimSequenceNode::set_animation_id` body
past `0x00525d60` before treating ACE's clamp order as verified**; flagged as unverified rather
than confirmed-matching.
### Parameterized ctor `AnimSequenceNode(AnimData animData)` ↔ retail `AnimSequenceNode::AnimSequenceNode(AnimData const*)` (`0x00525f90`, referenced at `0x00525f80` calling `set_animation_id`)
ACE (`:22`): `Framerate = animData.Framerate; LowFrame = animData.LowFrame; HighFrame =
animData.HighFrame; set_animation_id(animData.AnimID);` — order (set framerate/low/high fields
FIRST, then resolve+clamp via `set_animation_id`) matches the retail call sequence implied by
`0x00525f80` calling `set_animation_id` from within the ctor body (consistent with fields being
pre-populated by the ctor's other init statements before the call, standard C++ member-init-list
ordering). Considered faithful pending the same `set_animation_id` body caveat above.
## `AnimData.cs` (ACE) ↔ retail `AnimData`/`operator*`
Retail default ctor `AnimData::AnimData` (`0x00525ce0`): `anim_id.id = 0; low_frame = 0; high_frame
= 0xffffffff (-1); framerate = 30f;`
ACE `AnimData` (this file, `references/ACE/.../Animation/AnimData.cs`) is just a plain data holder
with a parameterized ctor `AnimData(DatLoader.Entity.AnimData animData, float speed = 1.0f)` that
does `AnimID = animData.AnimId; LowFrame = animData.LowFrame; HighFrame = animData.HighFrame;
Framerate = animData.Framerate * speed;` — this matches retail's `operator*(float speed, AnimData
const* src)` (`0x00525d00`, invoked from `add_motion` at `0x0052255b`):
```
retail: dst.id = src.id; dst.low_frame = src.low_frame; dst.high_frame = src.high_frame;
dst.framerate = src.framerate * speed;
```
Field-for-field, operation-for-operation match, including that `Framerate` is the ONLY field
scaled by `speed` (low/high frame bounds pass through unscaled). No parameterless-ctor
default-value divergence to flag since ACE's `AnimData()` here is a no-op empty ctor (all fields
default to C# zero values: `0, 0, 0, 0f`) — **diverges from retail's `low_frame=0,
high_frame=0xffffffff, framerate=30f` defaults**, but this parameterless ctor does not appear to
be invoked anywhere in the `add_motion` call chain (only the parameterized ctor is used at the
`MotionTable.add_motion` call site), so — like `AnimSequenceNode()`'s bare ctor — this is a
dormant/unreachable-in-practice divergence, not an active bug.
## `AFrame.cs` — spot notes (not the primary ask, but touched by Sequence/AnimSequenceNode)
`AFrame` is ACE's C# port of retail's `Frame`/`AFrame` (28-byte struct = Vector3 origin (12B) +
Quaternion orientation (16B), confirmed via the `0x1c` (28) stride multiplier in
`AnimSequenceNode::get_pos_frame` at `0x00525c2c`). `Combine`/`Subtract`/`Rotate`/`apply_physics`
call sites all operate on this type consistently between ACE and retail's `Frame::combine`,
`Frame::subtract1`, `Frame::rotate` (referenced by name at `0x0052541b`, `0x005254c2`,
`0x00524b2d` etc. — not independently re-derived body-for-body in this pass; flagged out of scope
per the task's method list, but the call-site shapes into/out of `Sequence`/`AnimSequenceNode`
were confirmed consistent).
## `MotionTable.cs` (ACE, class name `MotionTable`) ↔ retail `CMotionTable` — the 4 requested methods
Retail's class is named `CMotionTable` (not `MotionTable`) — ACE renamed it during the port. The
4 target methods are **retail FREE FUNCTIONS** (not `CMotionTable::` member functions) that take
a `CSequence*` as their first parameter — ACE ported them as instance methods on `MotionTable`
taking a `Sequence` parameter, a structural (not behavioral) reshaping.
### `add_motion(Sequence sequence, MotionData motionData, float speed)` ↔ free fn `add_motion` (`0x005224b0`)
Retail: `if (motionData == null) return; set_velocity(motionData.velocity * speed); set_omega(
motionData.omega * speed); for each anim in motionData.anims: append_animation(AnimData(speed *
anim))` — i.e. append_animation is called with a **freshly speed-scaled `AnimData` value**
(via `operator*`), never the raw `motionData.Anims[i]`.
ACE (`MotionTable.cs:358`):
```csharp
if (motionData == null) return;
sequence.SetVelocity(motionData.Velocity * speed);
sequence.SetOmega(motionData.Omega * speed);
for (i in motionData.Anims.Count) {
var animData = new AnimData(motionData.Anims[i], speed);
sequence.append_animation(animData);
}
```
Exact match, including the crucial detail that velocity/omega REPLACE (via `set_velocity`/
`SetVelocity`, not accumulate) the sequence's existing physics vector, unlike `combine_motion`/
`subtract_motion` below.
### `combine_motion(Sequence sequence, MotionData motionData, float speed)` ↔ free fn `combine_motion` (`0x00522580`)
Retail: `if (motionData == null) return; combine_physics(velocity*speed, omega*speed);` (ADDS
into existing sequence velocity/omega via `CSequence::combine_physics`).
ACE (`MotionTable.cs:381`): `if (motionData == null) return; sequence.CombinePhysics(motionData.Velocity * speed, motionData.Omega * speed);` Match.
### `subtract_motion(Sequence sequence, MotionData motionData, float speed)` ↔ free fn `subtract_motion` (`0x00522600`)
Retail: `if (motionData == null) return; subtract_physics(velocity*speed, omega*speed);`
ACE (`MotionTable.cs:388`): `if (motionData == null) return; sequence.subtract_physics(motionData.Velocity * speed, motionData.Omega * speed);` Match.
### `change_cycle_speed(Sequence sequence, MotionData motionData, float substateMod, float speedMod)` ↔ free fn `change_cycle_speed` (`0x00522290`)
Retail: `if (|substateMod| > EPSILON) multiply_cyclic_animation_fr(speedMod / substateMod); else
if (|speedMod| < EPSILON) multiply_cyclic_animation_fr(0);` **note the retail param order is
`(CSequence*, MotionData* [UNUSED in this function's body], float substateMod, float speedMod)`
`motionData` is passed but never dereferenced inside `change_cycle_speed` itself** (it's there
for signature consistency with the other 3 sibling functions / call-site uniformity, not because
the function needs it).
ACE (`MotionTable.cs:372`):
```csharp
if (Math.Abs(substateMod) > PhysicsGlobals.EPSILON)
sequence.multiply_cyclic_animation_framerate(speedMod / substateMod);
else if (Math.Abs(speedMod) < PhysicsGlobals.EPSILON)
sequence.multiply_cyclic_animation_framerate(0);
```
Exact match, including that ACE's `motionData` parameter is likewise unused in the method body
(faithfully preserved as dead/unused, mirroring retail's own unused parameter — not an ACE bug,
an intentional signature-parity choice already present in retail).
**Boundary-condition note:** if `substateMod` is exactly `EPSILON` or between `EPSILON` and some
tiny nonzero value such that neither branch's condition is strictly satisfied (i.e.
`|substateMod| <= EPSILON` AND `|speedMod| >= EPSILON`), **neither branch fires and the cyclic
framerate is left unchanged** in both retail and ACE — this is retail's actual (if slightly odd)
behavior, faithfully reproduced, not a port bug.
## Call-site context confirmed (not itself divergence-graded, informational)
`MotionTable.GetObjectSequence` (ACE) corresponds to retail's `CMotionTable::GetObjectSequence`
(referenced at `0x00522347` from `CMotionTable::re_modify`, and the `sequence.clear_physics();
sequence.remove_cyclic_anims();` pairing before each `add_motion` burst matches retail's
`CSequence::clear_physics(arg4); CSequence::remove_cyclic_anims(arg4);` pattern visible at
`0x005229cf`/`0x005229d8`, `0x00522be3`/`0x00522bec`, `0x00522d6d`/`0x00522d74`,
`0x00522e5d`/`0x00522e64` — four separate call sites in retail's `GetObjectSequence`, matching
ACE's four corresponding branches (`Style` / `SubState` / `Action` / the default-state reset in
`SetDefaultState` which additionally calls `clear_animations()` instead of `remove_cyclic_anims()`
— confirmed intentional, `SetDefaultState` is a full reset not an in-place cycle swap).
`re_modify` in retail (`0x005222e0`, `CMotionTable::re_modify`) reapplies queued modifiers by
popping `MotionState.modifier_head` and re-calling `GetObjectSequence` — this exists in ACE too
(referenced in `MotionTable.cs` but not in the requested method list; noted only for completeness
of the call graph around the 4 target functions).
## Summary of confirmed divergences (ranked by likely runtime impact)
1. **`FrameNumber`/`frame_number`: `float` (ACE) vs `long double`/80-bit-extended (retail).**
Pervasive — affects every frame-boundary comparison in `update_internal`,
`advance_to_next_animation`, `apply_physics`. Highest-impact, hardest to fix in C# (no native
80-bit float type; `double` is the closest available and still not bit-exact to retail).
2. **`AnimSequenceNode.get_starting_frame()` / `get_ending_frame()` subtract
`PhysicsGlobals.EPSILON` from `HighFrame + 1` in ACE; retail returns the bare integer with NO
epsilon.** Directly affects where a reverse-playing animation's start/end frame lands —
potential off-by-epsilon frame read at cycle boundaries. Medium-high impact, easy to fix (just
drop the `- PhysicsGlobals.EPSILON` term) if porting fresh.
3. **`AnimSequenceNode()` parameterless ctor: `LowFrame=0` (ACE) vs `low_frame=-1` (retail).**
Dormant in ACE's current call graph (only the parameterized ctor is actually invoked), but a
real textual mismatch. Low impact unless something starts calling the bare ctor.
4. **`AnimData()` parameterless ctor: all-zero defaults (ACE) vs `low_frame=0, high_frame=-1,
framerate=30f` (retail).** Same dormant-but-real-mismatch profile as #3.
5. **`AnimSequenceNode.get_pos_frame` returns identity `AFrame` on failure (ACE) vs `null`
pointer (retail).** Functionally converges to a no-op in practice given how call sites guard
invocation; textual divergence only.
6. **ACE's `Clear()` additionally nulls `PlacementFrame`/`PlacementFrameID`, which retail's own
2-instruction `clear()` body does NOT do** — that reset actually belongs to retail's separate
`UnPack` function. Scope conflation, not a behavioral bug, but worth knowing which retail
function ACE's `Clear()` is really modeling.
7. **`update_internal`: retail loops (`while(true)`), ACE recurses (tail call).** Structural only;
equivalent output, theoretical (very unlikely in practice) stack-depth difference in
pathological same-tick multi-boundary-crossing cases.
8. Retail's `execute_hooks` has a latent null-deref if `get_part_frame` returns null for an
out-of-range frame index (no null check before `arg2->hooks`); ACE's `animFrame == null` guard
avoids this crash class — a safe defensive divergence, not something to "fix" toward retail.
9. `AnimSequenceNode::set_animation_id` clamp-order in ACE was NOT independently re-verified
against the full retail body in this pass (only the call header + first lines were read) —
flag for a follow-up targeted grep before treating ACE's clamping as ground truth.
## Constants confirmed identical between ACE and retail
- `EPSILON = 0.0002f` (retail literal `0.000199999995f`, ACE `PhysicsGlobals.EPSILON`) — used
identically in `advance_to_next_animation`, `update_internal`, `apply_physics` guards, and
`change_cycle_speed`.
- `AFrame`/`Frame` struct size = 28 bytes (0x1c) = Vector3(12) + Quaternion(16), confirmed via
`AnimSequenceNode::get_pos_frame`'s `arg2 * 0x1c` index stride.
- `AnimSequenceNode` struct layout: `CAnimation* anim; float framerate; int low_frame; int
high_frame;` — exact field-for-field match with ACE's C# class (types included).
- `CSequence` struct layout (`acclient.h:30747`): confirms `frame_number` is genuinely `long
double`, not a decompiler artifact — this is the verbatim retail header, authoritative.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,224 @@
# R1 gap map — retail CSequence vs acdream AnimationSequencer
Inputs: `r1-csequence-decomp.md` (verbatim retail extraction, line anchors into
`docs/research/named-retail/acclient_2013_pseudo_c.txt`), `r1-ace-sequence.md`
(ACE port cross-reference), `r1-acdream-sequencer.md` (current-code map).
Plan of record: `docs/plans/2026-07-02-retail-motion-animation-rewrite.md` (R1 stage).
Current code: `src/AcDream.Core/Physics/AnimationSequencer.cs` (1584 lines) +
`AnimationCommandRouter.cs` / `AnimationHookRouter.cs` / `IAnimationHookSink.cs`.
**Conflicts between the two research passes RESOLVED in this synthesis (re-read raw decomp):**
- **Hook direction constants** — r1-ace-sequence.md's claim ("forward pass at 0x0052590c passes
0xffffffff") was a branch misattribution. Raw decomp verified this pass:
`0x0052578c` `execute_hooks(..., ebx_2, 1)` with `ebx_2 += 1` after = FORWARD crossing → **+1**;
`0x0052590c` `execute_hooks(..., ebx_1, 0xffffffff)` with `ebx_1 -= 1` after = REVERSE → **-1**.
Retail encoding == ACE `AnimationHookDir` (`references/ACE/Source/ACE.Entity/Enum/AnimationHookDir.cs`:
Backward=-1, Both=0, Forward=1) == the DatReaderWriter enum acdream already uses. No enum-remap needed.
- **update_internal fine structure** — r1-csequence-decomp.md §21's cleaned flow is garbled in two
places (an "early return" where retail clamps+continues; `hit_boundary=true` placed
unconditionally after the per-frame loop). The authoritative skeleton is ACE
`Sequence.cs:351-443` (verified verbatim this pass, quoted below in P4), which matches the raw
decomp's branch layout at `0x005255d0`-`0x005259ca`: overshoot check → clamp `frameNum` to
`get_high_frame()` (fwd) / `get_low_frame()` (rev) + compute `frameTimeElapsed` leftover +
`animDone=true` → per-frame crossing loop → `if (!animDone) return` → AnimDone gate
(`head != first_cyclic`) → `advance_to_next_animation` → carry leftover → loop.
- **ONE remaining unresolved decomp ambiguity** — raw decomp at `0x0052598a-0x0052598d` appears to
zero `arg2` (elapsed) after `advance_to_next_animation` before looping, while ACE carries
`timeElapsed = frameTimeElapsed` (the leftover). If retail truly zeroed it, a lag spike could
never fast-forward through multiple queued nodes in one tick (observable). ACE's reading is far
more plausible (BN likely lost the var reassignment through the x87 stack slot). **Pin in the R1
pseudocode commit (P0)** — cdb breakpoint on `CSequence::advance_to_next_animation` counting
invocations per `CSequence::update` call under induced multi-node overshoot.
Severity key: **BLOCKER** = must be right or R1's conformance harness is meaningless;
**HIGH** = visible animation wrongness / blocks R2+; **MED** = edge-case visible or blocks a later
stage; **LOW** = dormant/textual.
---
## 1. ITEMIZED GAP LIST
| # | Retail behavior acdream lacks/diverges on | Decomp anchor | Current-code anchor | Severity |
|---|---|---|---|---|
| G1 | **Direction-aware boundary pair with bare-int values.** Retail `get_starting_frame` = `framerate<0 ? high+1 : low`, `get_ending_frame` = `framerate<0 ? low : high+1` — plain ints, **NO epsilon**. acdream (following ACE) returns `(EndFrame+1) - FrameEpsilon` with `FrameEpsilon = 1e-5` (ACE uses 0.0002); the epsilon is an ACE fabrication compensating for ACE's own `float FrameNumber`. | `0x00525c80`/`0x00525cb0`, pseudo-C lines 302483/302501 (r1-csequence-decomp §26/27); ACE divergence #2 (r1-ace-sequence) | `AnimationSequencer.cs:154,164` (`GetStartFramePosition`/`GetEndFramePosition`), `:171` (`FrameEpsilon=1e-5`) | **BLOCKER** — every boundary decision keys off these values |
| G2 | **`multiply_framerate` swaps `low_frame``high_frame` on negative factor.** acdream keeps `StartFrame ≤ EndFrame` invariant and encodes direction only in Framerate sign, compensating in `Advance` — explicitly documented divergence. Verbatim port requires the swap + the direction-aware G1 pair; the two mechanisms are coupled. | `0x00525be0`, line 302425 (§14a) | `AnimationSequencer.cs` `AnimNode.MultiplyFramerate` (r1-acdream map row "multiply_framerate") | **HIGH** — reverse playback (WalkBackward links, reverse stops) correctness |
| G3 | **update_internal boundary model: clamp-to-`high_frame`/`low_frame` + leftover-time carry, boundary test `floor(frameNum) > high_frame` (i.e. ≥ high+1).** acdream tests `newPos >= maxBoundary - FrameEpsilon` and clamps `_framePosition = maxBoundary - FrameEpsilon` — epsilon-shifted boundary, different clamp target, no strict retail equivalence at exact-integer landings. This is the #61 "link→cycle boundary flash" bug class. | `0x005255d0` body (lines 301839-302235); ACE `Sequence.cs:366-377` (fwd), `:394-406` (rev) | `AnimationSequencer.cs:894,900` (`maxBoundary - FrameEpsilon`) | **BLOCKER** |
| G4 | **`safety=64` loop cap** — retail has none; termination comes from `frameTimeElapsed` shrinking + the `frametime == 0` branch returning. Mandate says delete bandaids on cutover. | ACE `Sequence.cs:351-443` (no cap); decomp `0x005255d0` (while-true loop) | `AnimationSequencer.cs:872-874` | MED (delete in R1 core) |
| G5 | **AnimDone gate is a LIST-STRUCTURE test, not a node flag.** Retail queues the global `anim_done_hook` singleton when `animDone && hook_obj != null && (anim_list.head_ 4) != first_cyclic` — i.e. "the old head has been consumed and we're not already in the cyclic tail". acdream pushes `AnimationDoneSentinel` gated on `!_currNode.Value.IsLooping` — a per-node flag that acdream itself invented (retail nodes have no IsLooping). Different gate ⇒ different MotionDone timing. R2's `CheckForCompletedMotions → AnimationDone → MotionDone` chain consumes this signal; it must be retail-exact in R1. | `0x00525943-0x00525968` (verified raw this pass); AnimDoneHook singleton at data `0x0081d9fc`, Execute `0x00526c20``Hook_AnimDone 0x0050fda0``CPartArray::AnimationDone(1)` (§18a) | `AnimationSequencer.cs:1129` (`AnimationDoneSentinel`), Advance's `!IsLooping` gate (r1-acdream map row "AnimationDone hook") | **HIGH** |
| G6 | **Two-stage hook dispatch.** Retail `execute_hooks` QUEUES matching `CAnimHook*` into `CPhysicsObj.anim_hooks` (SmartArray); a separate `CPhysicsObj::process_hooks` drains + `Execute()`s once per physics tick then resets `m_num=0`. acdream's `_pendingHooks` + `ConsumePendingHooks()` is structurally similar (queue then drain) but the drain point is GameWindow's render-tick loop immediately after `Advance` — not positioned per retail's `UpdateObjectInternal` order (`process_hooks` runs LAST, after MovementManager.UseTime etc.). R1 must expose the queue through a host seam so R6 can place the drain correctly. | `execute_hooks 0x00524830` (line 300780), `add_anim_hook 0x00514c20` (line 282906), `process_hooks 0x00511550` (line 279431) (§18) | `AnimationSequencer.cs:1371-1388` (`ExecuteHooks``_pendingHooks`), `GameWindow.cs:9882` (drain) | MED for R1 (seam), HIGH by R6 |
| G7 | **`apply_physics` + Frame-target root motion is unwired.** Retail: `update(quantum, Frame*)` accumulates `velocity*signed_quantum` into `frame.m_fOrigin` and `omega*signed_quantum` via `Frame::rotate`, with `signed_quantum = copysign(fabs(arg3), arg4)`; per crossed frame the quantum is `1.0/framerate` signed by elapsed. acdream has NO `apply_physics`: velocity/omega are surfaced as `CurrentVelocity`/`CurrentOmega` properties consumed by external movement code, and pos-frame root motion accumulates into `_rootMotionPos/_rootMotionRot` that **nothing drains** (`ConsumeRootMotionDelta()` has zero callers). | `0x00524ab0` (line 300955, §19); `Frame::rotate 0x004525b0` | `AnimationSequencer.cs:975-979` region (`ConsumeRootMotionDelta`, dead); `CurrentVelocity/CurrentOmega` consumers `GameWindow.cs:9331-9334`, `:12917` | **HIGH** — this is retail's entire physics-from-animation mechanism; R6's `CPartArray.Update → adjust_offset → Frame.combine` tick order needs it |
| G8 | **Empty-list physics-only fallback.** Retail `update`: if `anim_list` empty and `frame != null``apply_physics(frame, elapsed, elapsed)` (accumulated velocity still moves the object — free-fall/knockback with no anim). acdream `Advance` with no `_currNode` does nothing. | `0x00525b80` (line 302402, §22) | `AnimationSequencer.cs:874` (`while (... && _currNode != null ...)` — falls out) | MED |
| G9 | **`advance_to_next_animation`'s four-pose-op transition + reverse node stepping + asymmetric wrap.** Retail per node transition: (a) subtract outgoing node's pos_frame at current frame_number + apply_physics(1/framerate, sign=elapsed); (b) step node — forward: `GetNext` else wrap to **first_cyclic**; reverse: `GetPrev` else wrap to **LIST TAIL**; (c) `frame_number = get_starting_frame()` (fwd) / `get_ending_frame()` (rev); (d) combine incoming node's pos_frame + apply_physics. acdream `AdvanceToNextAnimation` only steps `.Next`/wraps to `_firstCyclic`/holds-on-last (invented hold), resets `_framePosition`, and does **none** of the pose subtract/combine ops and has **no reverse branch at all**. | `0x005252b0` (line 301622, §23) | `AnimationSequencer.cs:1344-1364` | **HIGH** (fwd pose ops + reverse branch); the hold-on-last-node is an acdream invention to delete |
| G10 | **`append_animation` slides `first_cyclic` to the just-appended node on EVERY call.** Retail's "cyclic tail" is always exactly the LAST appended anim (so a multi-anim cycle MotionData loops only its final AnimData node once earlier ones are consumed). acdream sets `_firstCyclic` to the FIRST node of the cycle MotionData. Also retail: `if (curr_anim == null) { curr_anim = head; frame_number = get_starting_frame(head); }` — acdream's equivalents are scattered through `SetCycle`'s rebuild. | `0x00525510` (line 301777, §24) | `AnimationSequencer.cs:634-645` region + `EnqueueMotionData` (r1-acdream map rows "Node list", "add_motion") | **HIGH** — divergent loop membership for multi-anim cycles; also the retail invariant that makes remove_cyclic/apricot correct |
| G11 | **The remove-family with curr_anim snap semantics is missing.** Retail: `remove_cyclic_anims` (0x00524e40) deletes `first_cyclic`→tail, snapping `curr_anim` back to prev + `frame_number = get_ending_frame(prev)` (or 0), then `first_cyclic = tail`; `remove_link_animations(n)` (0x00524be0) / `remove_all_link_animations` (0x00524ca0) delete predecessors of `first_cyclic`, snapping `curr_anim` FORWARD to `first_cyclic` + `get_starting_frame`; `clear_animations` (0x00524dc0) full wipe; `apricot` (0x00524b40) trims consumed leading nodes after every update, bounded by `curr_anim`/`first_cyclic`. acdream instead has `ClearCyclicTail` + wholesale queue clears + the invented "stale-head `_currNode` force-relocation" + "Fix B link-skip" — all approximations of what the retail remove-family + apricot do naturally. | lines 301258/301060/301128/301207/300978 (§5-8, §20) | `AnimationSequencer.cs:1311` (`ClearCyclicTail`), `:511`, stale-head relocation + Fix B blocks in `SetCycle` (r1-acdream map rows "Stale-head handling", "Fix B") | **HIGH** — these retire two invented mechanisms |
| G12 | **`combine_physics`/`subtract_physics` accumulators absent.** Retail `velocity += / -=` element-wise (x87-widened). acdream only has replace (`EnqueueMotionData`) + `ClearPhysics`. Needed by R2's fast path (`change_cycle_speed` + `subtract_motion(old)` + `combine_motion(new)`) and by jump/knockback physics later. | `0x005248c0`/`0x00524900` (lines 300818/300832, §12/13) | no equivalent in `AnimationSequencer.cs` | MED (trivial; part of the verbatim class) |
| G13 | **`multiply_cyclic_animation_fr` must touch ONLY node framerates.** Retail walks `first_cyclic`→tail calling `multiply_framerate`. acdream's `MultiplyCyclicFramerate` additionally scales `CurrentVelocity/CurrentOmega` — algebraically equivalent to retail's `change_cycle_speed`+`subtract/combine_motion` composite (an R2 mechanism folded in). Core port must separate them or R2's verbatim fast path double-applies. | `0x00524940` (line 300846, §14) | `AnimationSequencer.cs` `MultiplyCyclicFramerate` (r1-acdream map row) | MED (correct today by accident; wrong the moment R2 lands) |
| G14 | **Placement frames absent.** Retail `set_placement_frame`/`placement_frame_id` + `get_curr_animframe` returning `placement_frame` when `curr_anim == null` (static pose for object with no active anims). acdream has no placement concept (identity frames only). Explicit R1 scope item in the plan. | `0x005249b0`/`0x00524970` (lines 300872/300855, §15/16) | no equivalent (grep "placement" hits only a doc comment at `AnimationSequencer.cs:979`) | MED |
| G15 | **`frame_number` precision.** Retail: x87 `long double` (80-bit; verbatim `acclient.h:30747`). acdream: `double` (`AnimationSequencer.cs:303`) — the best C# can do; ACE's `float` is worse. Residual double-vs-extended ULP divergence at exact frame boundaries is an unavoidable adaptation → **needs a divergence-register row in the R1 core commit**. | `acclient.h:30747`; headline of r1-ace-sequence | `AnimationSequencer.cs:302-303` | LOW runtime / **process-MANDATORY** register row |
| G16 | **Node ctor defaults + `set_animation_id` clamp order.** Retail defaults: `framerate=30f, low_frame=-1, high_frame=-1`; `AnimData` defaults `low=0, high=-1, framerate=30f`; clamp order: `high<0→num-1`, `low>=num→num-1`, `high>=num→num-1`, `low>high→high=low`. acdream `LoadAnimNode` handles the `-1` sentinel + `low>high` but not the full order; per-node it also stores `IsLooping/HasPosFrames/Velocity/Omega` fields retail nodes don't have (retail velocity/omega live on the SEQUENCE only). NOTE: r1-ace-sequence flagged set_animation_id as unverified, but r1-csequence-decomp §25 captured the FULL body — resolved, no follow-up grep needed. | `0x00525d30`/`0x00525f90`/`0x00525d60` (lines 302547/302744/302561, §25); `AnimData` ctor `0x00525ce0` | `AnimationSequencer.cs` `AnimNode` + `LoadAnimNode` (r1-acdream map §0, row "Placement / root frames") | MED |
| G17 | **`add_motion` velocity semantics: unconditional REPLACE.** Retail free fn `add_motion` (0x005224b0) calls `set_velocity(motionData.velocity*speed)`/`set_omega(...)` **unconditionally** (a MotionData without the dat HasVelocity bit carries zero → replace-with-zero). acdream gates on `Flags.HasFlag(HasVelocity)` and otherwise LEAVES the previous value (`AnimationSequencer.cs:1288-1294` — comment claims "matches retail's conditional behavior", which the decomp contradicts). Retail avoids the zero-a-running-cycle problem via call-graph (modifiers go through `combine_motion`, not `add_motion`) — an R2 distinction acdream compensates for with this flag gate. | `0x005224b0` (r1-ace-sequence `add_motion` section); `combine_motion 0x00522580` | `AnimationSequencer.cs:1288-1294` | MED — port unconditional replace in the R1 core; keep the gate in the adapter until R2 routes modifiers through combine_motion, then delete (register row if the adapter gate outlives R1) |
| G18 | **`get_pos_frame` returns null out-of-range** (retail), not identity — and retail's `execute_hooks` has a latent null-deref on `arg2->hooks` (no null check). Port: null-return + the ACE-style null guard in execute_hooks as a documented safe divergence (crash-parity with retail is not a goal). acdream's ExecuteHooks already bounds-checks (`:1373`) — keep the guard, cite it. | `0x00525c10`/`0x00524830` (§28/§18); ACE divergences #5/#8 | `AnimationSequencer.cs:1373` | LOW |
| G19 | **`update` entry contract.** Retail `update(double quantum, Frame*)`: non-empty → `update_internal` then **`apricot`**; empty → physics-only. acdream `Advance(float dt)` returns blended `PartTransform[]` and never trims consumed nodes structurally (rebuilds hide it). Core port needs the retail entry + apricot; the blended-frame render output stays an adapter/render-side concern (retail renders off `get_curr_animframe`'s FLOORED index; interpolation lives in CPartArray-land, out of R1 core scope). | `0x00525b80` + apricot `0x00524b40` (§20/22) | `AnimationSequencer.cs:872+` (`Advance`), `BuildBlendedFrame` | **HIGH** (apricot + entry contract); blend seam MED |
| G20 | **`clear()` scope.** Retail `clear` = `clear_animations()+clear_physics()` ONLY (2 calls, 0x005255b0); the placement_frame reset belongs to `UnPack` (0x005259d0). Do not copy ACE's `Clear()` which folds the placement reset in. | line 301828 (§3); ACE divergence #6 | acdream `Reset()` (no external callers) | LOW |
Invented behaviors NOT in the gap list because they are R2/R3 scope and survive R1 **in the
adapter, unchanged**: K-fix18 `skipTransitionLink` (retire in R3 jump family), Fix B
locomotion link-skip (retail mechanism = `remove_redundant_links 0x0051bf20`, R2), stop-anim
fallback + GetLink reversed branch (R2 `get_link`/`GetObjectSequence`), velocity-synthesis
constants Walk=3.12/Run=4.0/Side=1.25 (R3 `get_state_velocity`), `HasCycle` probe (R2),
retail slerp + BuildBlendedFrame (render-side, not CSequence). Each keeps/gets its
divergence-register row when touched.
---
## 2. KEEP LIST — already matching retail
| Behavior | Retail anchor | acdream anchor |
|---|---|---|
| `execute_hooks` direction filter `dir==0(Both) \|\| dir==caller` | `0x00524830` line 300780; constants verified: fwd=+1 @0x0052578c, rev=-1 @0x0052590c | `AnimationSequencer.cs:1371-1388`; DatReaderWriter `AnimationHookDir` Backward=-1/Both=0/Forward=1 == retail encoding |
| Queue-then-drain hook model (hooks NOT executed inline during frame advance) | `add_anim_hook 0x00514c20` + `process_hooks 0x00511550` | `_pendingHooks` + `ConsumePendingHooks()` (drain placement moves in R6, mechanism correct) |
| Per-frame crossing walk fires pose+hooks for EVERY integer frame crossed, strict ascending (fwd) / descending (rev) order | `0x005255d0` do/while loops (lines 302006-302056 + reverse mirror) | `AnimationSequencer.cs:910-941` (fwd `lastFrame++` w/ Forward, rev `lastFrame--` w/ Backward) |
| Forward node wrap to `first_cyclic` (loop-the-cycle mechanism) | `0x005252b0` @0x005253xx: `GetNext==null → first_cyclic` | `AnimationSequencer.cs:1350-1358` |
| Leftover-time carry into the next node after a boundary (multi-node fast-forward in one tick) | ACE `Sequence.cs:436-442` (`timeElapsed = frameTimeElapsed` + recurse); decomp loop-back @0x005255e8 (see open ambiguity above) | `Advance`'s `timeRemaining`/overflow continue (r1-acdream map row "update_internal") |
| Root-motion composition directions: combine (apply pose) forward, subtract (un-apply) reverse | `Frame::combine`/`Frame::subtract1` call sites in `0x005255d0`/`0x005252b0` | `ApplyPosFrame(node, idx, reverse:)` fwd/conjugate-reverse (r1-acdream map row "Root motion") — values correct, TARGET wrong (G7: accumulator never drained) |
| `frame_number` floored to int for pose lookup (`get_curr_animframe`/`get_curr_frame_number` shape) | `0x00524970`/`0x005249d0` (§15/17) | `AnimationSequencer.cs:884` (`(int)Math.Floor(_framePosition)`) |
| `clear_physics` zeroing before rebuild | `0x00524d50` + `GetObjectSequence`'s `clear_physics; remove_cyclic_anims` pairing @0x005229cf etc. | `ClearPhysics()` called from `SetCycle` (r1-acdream map row "clear_physics") |
| `AnimData` speed scaling: only framerate × speed, low/high pass through | `operator* 0x00525d00` (invoked from `add_motion` @0x0052255b) | `LoadAnimNode` (`AnimData.Framerate * speedMod`) |
| `HighFrame == -1` sentinel → last frame; `low > high → high = low` degenerate guard | `set_animation_id 0x00525d60` clamps | `LoadAnimNode` (r1-acdream map row "Placement / root frames") — partial (see G16 for full order) |
| Fast-path re-speed without restart on same motion (concept) | ACE `MotionTable.cs:132-139`; retail `change_cycle_speed 0x00522290` | `SetCycle` early-return → `MultiplyCyclicFramerate` (G13 caveat) |
| `frame_number` at `double` ≥ ACE's float | `acclient.h:30747` (`long double`) | `AnimationSequencer.cs:303` — already the best-available C# type |
| Retail slerp incl. validation-fallback quirk (render blend, not CSequence) | `FUN_005360d0` (chunk_00530000.c:4799-4846) | `SlerpRetailClient` — keep untouched |
---
## 3. PORT ORDER — R1 commit sequence (tests-first, each one commit)
New code target: `src/AcDream.Core/Physics/Motion/` (plan rule 4). Naming: retail names
(`CSequence`, `AnimSequenceNode`) or thin C# equivalents — decided in P1, consistent after.
Every commit: build+test green; register rows added/retired in-commit.
**P0 — pseudocode + ambiguity pinning (docs only).**
Write `docs/research/2026-07-xx-csequence-pseudocode.md` from r1-csequence-decomp.md,
CORRECTING §21 to the ACE-verified skeleton (this doc's header), and pin the ONE open ambiguity:
leftover-time carry vs `arg2=0` at `0x0052598a`.
Fixture source: **cdb trace** — breakpoint `acclient!CSequence::advance_to_next_animation` +
`acclient!CSequence::update` with hit counters (pattern: `tools/cdb/l2g-observer.cdb`); ratio >1
advance-per-update under a stall/lag proves the carry. Also capture
`append_animation`/`remove_cyclic_anims` arg logs here (they feed P2/P5 goldens — one cdb session
serves all of R1).
Dependencies: none. This is the workflow's mandatory step-3 artifact.
**P1 — `AnimSequenceNode` verbatim.** (closes G1, G2, G16, G18-node-half)
Fields `anim/framerate(float)/low_frame/high_frame` only (NO IsLooping/Velocity/Omega per-node);
ctors with retail defaults (30f/-1/-1); `set_animation_id` full clamp order (§25);
`get_starting_frame`/`get_ending_frame` bare-int direction-aware pair (NO epsilon);
`multiply_framerate` with low/high swap on negative; `get_pos_frame` (null OOB, both overloads) /
`get_part_frame` / `has_anim`. Uses existing `IAnimationLoader` for the DBObj::Get seam.
Tests first: synthetic (all clamp branches; negative-multiply swap; direction-aware boundary
mirror table) + **dat fixtures** (real Humanoid MotionTable AnimData via DatCollection: resolve,
clamp, verify against `Animation.PartFrames.Count`).
Dependencies: P0.
**P2 — `CSequence` container + list surgery.** (closes G10, G11, G12, G14, G15, G20; G5's structural precondition)
`anim_list` (LinkedList), `first_cyclic`, `curr_anim`, `frame_number:double`, `velocity/omega`,
`placement_frame/placement_frame_id`, `hook_obj`-seam. Methods: `append_animation`
(first_cyclic-slides-to-tail-every-call + curr_anim seed), `clear/clear_animations/clear_physics`,
`remove_cyclic_anims` (snap-back + `get_ending_frame(prev)`), `remove_link_animations(n)`,
`remove_all_link_animations`, `has_anims`, `apricot`, `set_velocity/set_omega/combine_physics/
subtract_physics`, `set_placement_frame/get_curr_animframe/get_curr_frame_number`,
`multiply_cyclic_animation_fr` (framerates ONLY — G13).
Register rows in-commit: double-vs-long-double (G15); managed LinkedList vs intrusive DLList.
Tests first: list-surgery state tables (curr_anim/first_cyclic/frame_number after every op, incl.
curr_anim-inside-removed-range snaps; apricot bounded by curr_anim AND first_cyclic).
Fixture source: **synthetic** + **cdb goldens from P0** (`append_animation`/`remove_cyclic_anims`
arg sequences from a live Walk→Run→Stop cycle — replay the call sequence, assert list shape).
Dependencies: P1.
**P3 — `apply_physics` + Frame math.** (closes G7's math half)
`apply_physics(Frame, quantum, sign)` with copysign semantics; verbatim `Frame.combine`/
`Frame.subtract1`/`Frame.rotate` equivalents (port beside the existing ApplyPosFrame math, which
becomes call-compatible).
Tests first: numeric goldens — hand-computed copysign cases (±quantum × ±sign), combine∘subtract1
= identity round-trips, rotate vs quaternion reference; cross-check values against ACE
`Sequence.cs:221` + `AFrame.cs`.
Fixture source: **synthetic** (pure math; no dat needed).
Dependencies: P2 (fields), parallel-safe with P1 internals.
**P4 — `update_internal` + `update` + `advance_to_next_animation`.** (closes G3, G4, G5, G6-queue, G8, G9, G19)
Verbatim per the ACE-verified skeleton: floor(lastFrame) → advance frame_number → overshoot clamp
to `get_high_frame()`/`get_low_frame()` + `frameTimeElapsed` leftover + animDone → per-frame
crossing loop (combine/subtract pos_frame if `pos_frames != null`; `apply_physics(1/framerate,
elapsed)` if `|framerate| ≥ 0.000199999995f`; `execute_hooks(part_frame, +1/-1)`) → `if
(!animDone) return` → AnimDone gate `head != first_cyclic` → queue global AnimDoneHook →
`advance_to_next_animation` (four pose ops; fwd wrap first_cyclic, rev wrap TAIL) → carry leftover
(per P0's pin) → loop (iterative, not ACE's recursion). `update`: non-empty → internal+`apricot`;
empty → `apply_physics(frame, elapsed, elapsed)`. `execute_hooks` queues into an
`IAnimHookQueue` host seam (stands in for `CPhysicsObj.anim_hooks`; GameWindow drain point
unchanged until R6). NO safety cap.
Tests first — the R1 conformance harness core:
(a) **dat fixture**: Humanoid walk cycle advanced at fixed 1/30s quanta N ticks — golden
`frame_number` series + hook-fire (frame,direction) sequence;
(b) **synthetic**: multi-node fast-forward in one tick (lag spike) — hook ORDER across nodes +
AnimDone timing; reverse playback; exact-integer boundary landings (the G3 class); zero-framerate
node; empty-list physics fallback;
(c) **cdb goldens from P0**: advance-per-update counts + frame_number progression trace.
Dependencies: P1+P2+P3.
**P5 — adapter cutover: `AnimationSequencer` rehosted on the core.** (closes G13-split, G17-core; DELETES stale-head relocation, ClearCyclicTail surgery, per-node Velocity/Omega, safety cap, boundary-epsilon)
`SetCycle` rebuild becomes: `remove_cyclic_anims()` [+ `remove_all_link_animations` where the old
code cleared] → per-AnimData `append_animation(speed-scaled AnimData)` (= retail free-fn
`add_motion 0x005224b0`, unconditional `set_velocity/set_omega` in core) → fast path =
`change_cycle_speed`-equivalent (`multiply_cyclic_animation_fr` framerates-only) + adapter-level
velocity rescale (the R2 subtract/combine composite, kept at adapter, register row). Invented
behaviors that SURVIVE at adapter level, byte-identical: K-fix18, Fix B, stop-anim fallback,
GetLink reversed branch, velocity synthesis (each verified to still have/get its register row).
`Advance` becomes `update(dt, frame)` + `BuildBlendedFrame` reading core `curr_anim`/
`frame_number`. `PlayAction` inserts via core list ops.
Tests first: FULL existing suite green (behavior-parity is the acceptance bar) + adapter parity
tests (same SetCycle call sequences → same selected cycle/link + same hook stream as pre-cutover
recordings taken BEFORE this commit). #61's boundary-hold re-tested against verbatim boundary
math — if the flash is gone with the hold removed, delete the hold (register row retired); if
not, keep + re-file #61 with the new evidence.
Fixture source: recorded pre-cutover adapter traces (**synthetic harness**) + user visual smoke.
Dependencies: P4.
**P6 — root-motion/placement wiring + API narrowing + register sweep.** (closes G7-wiring, G14-consumers, dead-API cleanup)
`update(quantum, Frame)` output exposed to the GameWindow tick as the entity root-motion delta
(replaces dead `ConsumeRootMotionDelta` — delete it); placement-frame path wired for
anim-less objects (`get_curr_animframe` fallback); delete `Reset()`/`HasCurrentNode` or map to
`clear()`/`curr_anim != null`; `MultiplyCyclicFramerate` public surface delegates to core.
Consumers keep reading `CurrentVelocity/CurrentOmega` (adapter mirrors core `velocity/omega` +
synthesis until R3). Register reconciliation + roadmap stage-table update + memory digest note.
Fixture source: existing launch-protocol smoke (`ACDREAM_REMOTE_VEL_DIAG` off/on parity) + suite.
Dependencies: P5.
---
## 4. API MIGRATION — consumer survival through the cutover
R1 is **adapter-preserving**: every public `AnimationSequencer` member keeps its signature through
P5; the core replaces the internals. Narrowing happens only in P6 and touches only dead surface.
| Consumer (from r1-acdream-sequencer map) | Call sites | R1 impact |
|---|---|---|
| `GameWindow` spawn/fallback cycles | `:3723/3728/3732/3824` (`HasCycle`), `:3751/:3825` (`SetCycle`) | none — adapter unchanged |
| `GameWindow` jump/land/stop | `:4830` (K-fix18), `:5155/:5309/:9817` | none — K-fix18 param preserved at adapter until R3 |
| `GameWindow` NPC legacy path | `:4936` (`ApplyServerControlledVelocityCycle`) | none — path dies in R2/R6, not R1 |
| `GameWindow` local-player cycle | `:10223` | none |
| `GameWindow` anim tick | `:9876` (`Advance`), `:9882` (`ConsumePendingHooks`) | signatures unchanged; `Advance` internally becomes `update()`+blend. Hook STREAM content must be parity-tested (P5) since AnimDone timing changes gate (G5) — RemoteMotionSink/GameWindow don't consume AnimDone yet (R2 does), so risk is bounded to the hook fan-out sinks |
| `RemoteMotionSink.Commit` | `RemoteMotionSink.cs:215` (`SetCycle`), + `HasCycle`, `ApplyMotion→PlayAction` | none — sink dissolves in R2, not R1 |
| `AnimationCommandRouter` | `RouteFullCommand → SetCycle/PlayAction` | none |
| `CurrentVelocity/CurrentOmega` readers | `GameWindow.cs:9331-9334` (remote body translation), `:12917` (`AttachCycleVelocityAccessor``MotionInterpreter.GetCycleVelocity`), `MotionInterpreter` docs | semantics preserved: adapter keeps replace-gate + locomotion synthesis EXACTLY as today (G17 core/adapter split); values must be bit-identical for locomotion low-bytes — covered by P5 parity tests |
| `CurrentStyle/CurrentMotion/CurrentSpeedMod` readers | `GameWindow.cs:3723/4827/4915/4919`, `RemoteMotionSink` ctor+Commit | adapter-owned bookkeeping, untouched |
| Diagnostics `CurrentNodeDiag`/`FirstCyclicAnimRefHash`/`QueueCount` | `GameWindow.cs:9863-9871` `[CURRNODE]` block | re-expressed over core list (AnimRefHash from core node's `anim`); tuple shape kept |
| `ConsumeRootMotionDelta` | **zero callers** | deleted in P6; replaced by `update(quantum, Frame)` output |
| `Reset` / `HasCurrentNode` / external `MultiplyCyclicFramerate` | zero external callers | P6: map to `clear()` / `curr_anim != null` / core delegate, or delete |
| `AnimationHookRouter` / `IAnimationHookSink` sinks | `GameWindow.cs:9890` fan-out | unchanged in R1; hook payload type stays DatReaderWriter's `AnimationHook`. (Side note for a separate issue, NOT R1: router's silent catch-all has no logger seam — `feedback_logger_injection_for_silent_catches.md`) |
| `RenderBootstrap.SequencerFactory` | `:138/:147-174` (3-tier Setup/MotionTable fallback) | ctor signature unchanged; empty-MotionTable tier must still yield a working do-nothing sequencer (add a P5 test: empty table → `has_anims()==false` → physics-only update path, no throw) |
**Cutover invariants (P5 acceptance):** (1) full existing test suite green untouched; (2) recorded
SetCycle→hook/pose traces byte-parity vs pre-cutover for the standard protocol (walk/run/toggle/
turn/stop/jump, player+NPC); (3) every deleted invented mechanism's register row retired in the
deleting commit; every surviving adapter-level invention has a row; (4) one user visual pass at
R1 end (plan: eyes are final sanity only).