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:
parent
cae56afc82
commit
1371c2a14c
7 changed files with 3363 additions and 0 deletions
55
docs/research/2026-07-02-r1-csequence/P0-pins.md
Normal file
55
docs/research/2026-07-02-r1-csequence/P0-pins.md
Normal 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).
|
||||
377
docs/research/2026-07-02-r1-csequence/r1-acdream-sequencer.md
Normal file
377
docs/research/2026-07-02-r1-csequence/r1-acdream-sequencer.md
Normal 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.
|
||||
583
docs/research/2026-07-02-r1-csequence/r1-ace-sequence.md
Normal file
583
docs/research/2026-07-02-r1-csequence/r1-ace-sequence.md
Normal 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.
|
||||
1756
docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md
Normal file
1756
docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md
Normal file
File diff suppressed because it is too large
Load diff
224
docs/research/2026-07-02-r1-csequence/r1-gap-map.md
Normal file
224
docs/research/2026-07-02-r1-csequence/r1-gap-map.md
Normal 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).
|
||||
Loading…
Add table
Add a link
Reference in a new issue