diff --git a/docs/research/2026-07-02-r1-csequence/P0-pins.md b/docs/research/2026-07-02-r1-csequence/P0-pins.md new file mode 100644 index 00000000..bf3b4d27 --- /dev/null +++ b/docs/research/2026-07-02-r1-csequence/P0-pins.md @@ -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). diff --git a/docs/research/2026-07-02-r1-csequence/r1-acdream-sequencer.md b/docs/research/2026-07-02-r1-csequence/r1-acdream-sequencer.md new file mode 100644 index 00000000..13655d86 --- /dev/null +++ b/docs/research/2026-07-02-r1-csequence/r1-acdream-sequencer.md @@ -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(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 _queue`, `_currNode`, `_firstCyclic` pointers | Doubly-linked list mirrored with .NET `LinkedList`. 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 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 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`), + 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` keyed by + `(style<<16)|(adjustedMotion&0xFFFFFF)` — the looping tail source, + read in `SetCycle`/`HasCycle`. + - `Links : Dictionary` where + `MotionCommandData.MotionData : Dictionary` — the + transition-frame source, read in `GetLink` (both forward and + reversed-key branches) and in `PlayAction`'s Action-mask lookup. + - `Modifiers : Dictionary` — the overlay/action source + for Modifier-mask commands in `PlayAction`, tried with a styled key + first then a plain (unstyled) key. + - `StyleDefaults : Dictionary` — used only + inside `GetLink`'s reversed-direction fallback branch. +- **MotionData** (per Cycles/Links/Modifiers entry) — `Anims : + List`; `Velocity`/`Omega : Vector3`; `Flags : + MotionDataFlags` (`HasVelocity=0x01`, `HasOmega=0x02`) gates whether + `Velocity`/`Omega` are applied or zeroed in `EnqueueMotionData` and + `PlayAction`. +- **AnimData** — `AnimId : QualifiedDataId` (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` (drives `numFrames` bounds-clamping + in `LoadAnimNode` and the per-part lookup in `BuildBlendedFrame`); + `PosFrames : List` (root motion, gated by `Flags.HasFlag(PosFrames)` + AND `PosFrames.Count >= numFrames`); `Flags : AnimationFlags`. +- **AnimationFrame** — `Frames : List` (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` (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(e.SourceGfxObjOrSetupId)`, then `MotionTable` via +`dats.Get(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. diff --git a/docs/research/2026-07-02-r1-csequence/r1-ace-sequence.md b/docs/research/2026-07-02-r1-csequence/r1-ace-sequence.md new file mode 100644 index 00000000..31a60e92 --- /dev/null +++ b/docs/research/2026-07-02-r1-csequence/r1-ace-sequence.md @@ -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 AnimList` + `FirstCyclic`/`CurrAnim` as `LinkedListNode` | `DLList 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`, 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. diff --git a/docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md b/docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md new file mode 100644 index 00000000..b267dce0 --- /dev/null +++ b/docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md @@ -0,0 +1,1756 @@ +# CSequence family — verbatim retail decomp extraction + +Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (PDB-named, +Sept 2013 EoR build, x87 32-bit) + `docs/research/named-retail/acclient.h` +(verbatim retail struct layouts). Line numbers below are `grep -n` line +anchors into `acclient_2013_pseudo_c.txt` as of this extraction. + +All code quoted is the tool's literal x87-mangled pseudo-C output +(FCMP_UO / floor / `unimplemented {fld ...}` comments and all) — this is +what the decompiler actually emitted; the "Cleaned control flow" sections +underneath translate it into readable pseudocode without changing any +comparison direction, boundary constant, or branch order. + +--- + +## 0. Struct layouts (verbatim from acclient.h) + +### `CSequence` (acclient.h:30747, PDB record 3267) + +```cpp +struct __cppobj CSequence : PackObj +{ + DLList anim_list; // +0x00 head_, +0x04 tail_ (DLListBase) + AnimSequenceNode *first_cyclic; // +0x08 (per line 30750; see note below) + AC1Legacy::Vector3 velocity; // linear velocity accumulator + AC1Legacy::Vector3 omega; // angular velocity accumulator + CPhysicsObj *hook_obj; // owning physics object (fires hooks through this) + long double frame_number; // x87 80-bit extended — CURRENT FRACTIONAL FRAME + AnimSequenceNode *curr_anim; // node actively playing + AnimFrame *placement_frame; // static override frame when anim_list is empty + unsigned int placement_frame_id; + int bIsTrivial; +}; +``` + +`frame_number` is declared `long double` (x87 extended, 80-bit) — NOT a +`double`. This is why every comparison against it in the decomp goes +through the raw FPU compare/FCMP_UO sequence rather than a SSE `ucomisd`. + +### `CPartArray` (acclient.h:30762) — where CSequence lives + +```cpp +struct __cppobj CPartArray +{ + unsigned int pa_state; + CPhysicsObj *owner; + CSequence sequence; // EMBEDDED, not a pointer + MotionTableManager *motion_table_manager; + CSetup *setup; + unsigned int num_parts; + CPhysicsPart **parts; + AC1Legacy::Vector3 scale; + Palette **pals; + LIGHTLIST *lights; + AnimFrame *last_animframe; +}; +``` + +### `AnimSequenceNode` (acclient.h:31063, PDB record 3266) + +```cpp +struct __cppobj AnimSequenceNode : PackObj, DLListData +{ + CAnimation *anim; + float framerate; // signed — negative = playing this node in reverse + int low_frame; // inclusive start frame index (forward-orientation) + int high_frame; // inclusive end frame index (forward-orientation) +}; +``` + +`DLListData` (base) supplies `dllist_next` / `dllist_prev`. The node +object itself is allocated at `dllist_data_ptr - 4` (the "ADJ" / `- 4` +adjustment seen throughout the list-splice code is converting between a +`DLListData*` and the owning `AnimSequenceNode*`, i.e. `DLListData` is +the FIRST base and the node is 4 bytes past it — actually the compiler +emits `((char*)head_ - 4)` meaning the AnimSequenceNode base starts 4 +bytes BEFORE the DLListData sub-object, so `DLListData` is inherited +second / offset +4 inside the node). + +### `AnimFrame` (acclient.h:31072, PDB record 3264) + +```cpp +struct __cppobj AnimFrame +{ + AFrame *frame; + unsigned int num_frame_hooks; + CAnimHook *hooks; // singly-linked hook chain for THIS frame + unsigned int num_parts; +}; +``` + +### `CAnimHook` (acclient.h:30973, PDB record 3262) + +```cpp +struct __cppobj CAnimHook +{ + CAnimHookVtbl *vfptr; + CAnimHook *next_hook; + int direction_; // 0 = fire on either direction; else must match exactly +}; +``` + +`AnimDoneHook : CAnimHook` (acclient.h:57557) is an empty subclass — +pure dispatch marker, `Execute()` calls `CPhysicsObj::Hook_AnimDone`. + +### Constants (verbatim, multiple identical copies in the data segment) + +``` +F_EPSILON = 0.000199999995f (acclient.h data; ~0.0002f) +data_794610 (double) = 0x0000000000000000 (0.0) +data_7928c0 (double) = 0x3ff0000000000000 (1.0) +``` + +--- + +## 1. `CSequence::CSequence` — ctor (line 300891, addr `0x005249f0`) + +``` +00524a30... wait — ctor is 005249f0 +``` + +```c +00524a30 // actually printed at 300891: +005249f0 void __fastcall CSequence::CSequence(class CSequence* this) +{ + this->vtable = 0x7c84d8; + __builtin_memset(&this->anim_list, 0, 0x28); + __builtin_memset(&this->frame_number, 0, 0x18); +} +``` + +Cleaned: zero-fills `anim_list` (0x28 = 40 bytes, covers `anim_list` + +`first_cyclic` + `velocity` + `omega` + `hook_obj`), then zero-fills the +tail 0x18 = 24 bytes starting at `frame_number` (covers `frame_number` +[10 bytes as long double, padded] + `curr_anim` + `placement_frame` + +`placement_frame_id`). `frame_number` starts at exactly `0.0`. + +## 2. `CSequence::~CSequence` — dtor (line 300901, addr `0x00524a30`) + +```c +00524a30 void __fastcall CSequence::~CSequence(class CSequence* this) +{ + bool cond:0 = this->anim_list.head_ == 0; + this->vtable = 0x7c84d8; + + if (!(cond:0)) + { + do + { + class DLListData* head_ = this->anim_list.head_; + if (head_ != 0) + { + class DLListData* dllist_prev = head_->dllist_prev; + if (dllist_prev == 0) + { + class DLListData* dllist_next = head_->dllist_next; + this->anim_list.head_ = dllist_next; + if (dllist_next != 0) + dllist_next->dllist_prev = nullptr; + } + else + dllist_prev->dllist_next = head_->dllist_next; + + class DLListData* dllist_next_1 = head_->dllist_next; + if (dllist_next_1 == 0) + { + class DLListData* dllist_prev_1 = this->anim_list.tail_->dllist_prev; + this->anim_list.tail_ = dllist_prev_1; + if (dllist_prev_1 != 0) + dllist_prev_1->dllist_next = 0; + } + else + dllist_next_1->dllist_prev = head_->dllist_prev; + + head_->dllist_next = 0; + head_->dllist_prev = nullptr; + + if ((head_ != 0 && head_ != 4)) + *(uint32_t*)head_->dllist_next(1); // AnimSequenceNode::`scalar deleting destructor`(1) + } + } while (this->anim_list.head_ != 0); + } + + this->vtable = 0x79285c; // PackObj vtable (base slice) +} +``` + +Cleaned: pop-and-delete every node from `anim_list.head_` until empty +(unlink from doubly-linked list, then call the node's scalar deleting +destructor with `delete`-flag=1). + +## 3. `CSequence::clear` (line 301828, addr `0x005255b0`) + +```c +005255b0 void __fastcall CSequence::clear(class CSequence* this) +{ + CSequence::clear_animations(this); + CSequence::clear_physics(this); + this->placement_frame = nullptr; + this->placement_frame_id = 0; +} +``` + +## 4. `CSequence::clear_physics` (line 301194, addr `0x00524d50`) + +```c +00524d50 void __fastcall CSequence::clear_physics(class CSequence* this) +{ + this->velocity.x = 0; + this->velocity.y = 0f; + this->velocity.z = 0f; + this->omega.x = 0; + this->omega.y = 0f; + this->omega.z = 0f; +} +``` + +## 5. `CSequence::clear_animations` (line 301207, addr `0x00524dc0`) + +```c +00524dc0 void __fastcall CSequence::clear_animations(class CSequence* this) +{ + while (this->anim_list.head_ != 0) + { + class DLListData* head_ = this->anim_list.head_; + if (head_ != 0) + { + class DLListData* dllist_prev = head_->dllist_prev; + if (dllist_prev == 0) + { + class DLListData* dllist_next = head_->dllist_next; + this->anim_list.head_ = dllist_next; + if (dllist_next != 0) + dllist_next->dllist_prev = nullptr; + } + else + dllist_prev->dllist_next = head_->dllist_next; + + class DLListData* dllist_next_1 = head_->dllist_next; + if (dllist_next_1 == 0) + { + class DLListData* dllist_prev_1 = this->anim_list.tail_->dllist_prev; + this->anim_list.tail_ = dllist_prev_1; + if (dllist_prev_1 != 0) + dllist_prev_1->dllist_next = 0; + } + else + dllist_next_1->dllist_prev = head_->dllist_prev; + + head_->dllist_next = 0; + head_->dllist_prev = nullptr; + + if ((head_ != 0 && head_ != 4)) + *(uint32_t*)head_->dllist_next(1); // node dtor+delete + } + } + + this->first_cyclic = nullptr; + this->frame_number = 0f; + *(uint32_t*)((char*)this->frame_number)[4] = 0; // (high dword of the long double, zeroed too) + this->curr_anim = nullptr; +} +``` + +Cleaned: identical unlink-and-delete loop over the WHOLE list (same +pattern as the dtor), then resets `first_cyclic = null`, +`frame_number = 0.0`, `curr_anim = null`. This is the full "wipe the +sequence back to empty" operation, distinct from `remove_cyclic_anims` +below which only removes the cyclic tail. + +## 6. `CSequence::remove_cyclic_anims` (line 301258, addr `0x00524e40`) + +```c +00524e40 void __fastcall CSequence::remove_cyclic_anims(class CSequence* this) +{ + class CSequence* this_1 = this; + class AnimSequenceNode* first_cyclic_1; + + for (class AnimSequenceNode* first_cyclic = this->first_cyclic; first_cyclic != 0; first_cyclic = first_cyclic_1) + { + if (this->curr_anim == first_cyclic) + { + class AnimSequenceNode* eax_1 = AnimSequenceNode::GetPrev(first_cyclic); + this->curr_anim = eax_1; + + if (eax_1 == 0) + { + this->frame_number = 0f; + *(uint32_t*)((char*)this->frame_number)[4] = 0; + } + else + this->frame_number = ((double)AnimSequenceNode::get_ending_frame(eax_1)); + } + + first_cyclic_1 = AnimSequenceNode::GetNext(first_cyclic); + class DLListData** eax_2; + if (first_cyclic == 0) + eax_2 = nullptr; + else + eax_2 = &first_cyclic->dllist_next; + + class DLListData* dllist_prev = ADJ(eax_2)->dllist_prev; + if (dllist_prev == 0) + { + class DLListData* dllist_next = this->anim_list.head_->dllist_next; + this->anim_list.head_ = dllist_next; + if (dllist_next != 0) + dllist_next->dllist_prev = nullptr; + } + else + dllist_prev->dllist_next = ADJ(eax_2)->dllist_next; + + class DLListData* dllist_next_1 = ADJ(eax_2)->dllist_next; + if (dllist_next_1 == 0) + { + class DLListData* dllist_prev_1 = this->anim_list.tail_->dllist_prev; + this->anim_list.tail_ = dllist_prev_1; + if (dllist_prev_1 != 0) + dllist_prev_1->dllist_next = 0; + } + else + dllist_next_1->dllist_prev = ADJ(eax_2)->dllist_prev; + + ADJ(eax_2)->dllist_next = nullptr; + ADJ(eax_2)->dllist_prev = nullptr; + + if (first_cyclic != 0) + first_cyclic->vtable->__vecDelDtor(1); + } + + class DLListData* tail_ = this->anim_list.tail_; + if (tail_ != 0) + { + this->first_cyclic = ((char*)tail_ - 4); + return; + } + + this->first_cyclic = nullptr; +} +``` + +Cleaned: walks from `first_cyclic` to the END of the list, unlinking +and deleting EVERY node from `first_cyclic` onward (the "cyclic" tail — +i.e. the looping animation(s) queued after the one-shot transition +animations). If `curr_anim` was one of the removed nodes, `curr_anim` +snaps back to the PREVIOUS node (the last non-cyclic node) and +`frame_number` is set to that previous node's `get_ending_frame()` (or +`0.0` if there is no previous node at all). After the sweep, +`first_cyclic` is reset to point at the new tail of the list (`tail_ - +4`), or `null` if the list is now empty. + +## 7. `CSequence::remove_link_animations` (line 301060, addr `0x00524be0`) + +```c +00524be0 void __thiscall CSequence::remove_link_animations(class CSequence* this, uint32_t arg2) +{ + int32_t ebp = 0; + + if (arg2 > 0) + { + do + { + if (AnimSequenceNode::GetPrev(this->first_cyclic) == 0) + break; + + if (AnimSequenceNode::GetPrev(this->first_cyclic) == this->curr_anim) + { + class AnimSequenceNode* first_cyclic = this->first_cyclic; + this->curr_anim = first_cyclic; + if (first_cyclic != 0) + this->frame_number = ((double)AnimSequenceNode::get_starting_frame(first_cyclic)); + } + + class AnimSequenceNode* eax_2 = AnimSequenceNode::GetPrev(this->first_cyclic); + class DLListData** edx_1; + if (eax_2 == 0) + edx_1 = nullptr; + else + edx_1 = &eax_2->dllist_next; + + // unlink eax_2 (the node immediately before first_cyclic) from anim_list + class DLListData* dllist_prev = ADJ(edx_1)->dllist_prev; + if (dllist_prev == 0) + { + class DLListData* dllist_next = this->anim_list.head_->dllist_next; + this->anim_list.head_ = dllist_next; + if (dllist_next != 0) + dllist_next->dllist_prev = nullptr; + } + else + dllist_prev->dllist_next = ADJ(edx_1)->dllist_next; + + class DLListData* dllist_next_1 = ADJ(edx_1)->dllist_next; + if (dllist_next_1 == 0) + { + class DLListData* dllist_prev_1 = this->anim_list.tail_->dllist_prev; + this->anim_list.tail_ = dllist_prev_1; + if (dllist_prev_1 != 0) + dllist_prev_1->dllist_next = 0; + } + else + dllist_next_1->dllist_prev = ADJ(edx_1)->dllist_prev; + + ADJ(edx_1)->dllist_next = nullptr; + ADJ(edx_1)->dllist_prev = nullptr; + + if (eax_2 != 0) + eax_2->vtable->__vecDelDtor(1); + + ebp += 1; + } while (ebp < arg2); + } +} +``` + +Cleaned: removes `arg2` (count) "link" nodes — nodes chained +IMMEDIATELY BEFORE `first_cyclic` (i.e. the linked/one-shot animations +queued ahead of the cyclic tail) — one at a time, working backward from +`first_cyclic`'s predecessor. If the removed node was `curr_anim`, +`curr_anim` is force-advanced to `first_cyclic` and `frame_number` reset +to that node's `get_starting_frame()`. Stops early if there's no +predecessor left (`GetPrev(first_cyclic) == 0`). + +## 8. `CSequence::remove_all_link_animations` (line 301128, addr `0x00524ca0`) + +```c +00524ca0 void __fastcall CSequence::remove_all_link_animations(class CSequence* this) +{ + class AnimSequenceNode* first_cyclic = this->first_cyclic; + + if ((first_cyclic != 0 && AnimSequenceNode::GetPrev(first_cyclic) != 0)) + { + class AnimSequenceNode* i; + do + { + if (AnimSequenceNode::GetPrev(this->first_cyclic) == this->curr_anim) + { + class AnimSequenceNode* first_cyclic_1 = this->first_cyclic; + this->curr_anim = first_cyclic_1; + if (first_cyclic_1 != 0) + this->frame_number = ((double)AnimSequenceNode::get_starting_frame(first_cyclic_1)); + } + + class AnimSequenceNode* eax_3 = AnimSequenceNode::GetPrev(this->first_cyclic); + // ... identical unlink-and-delete of eax_3 as remove_link_animations ... + if (eax_3 != 0) + eax_3->vtable->__vecDelDtor(1); + + i = AnimSequenceNode::GetPrev(this->first_cyclic); + } while (i != 0); + } +} +``` + +Cleaned: identical to `remove_link_animations` but loops until +`GetPrev(first_cyclic) == 0` (i.e. removes ALL link nodes, not a fixed +count). + +## 9. `CSequence::has_anims` (line 301050, addr `0x00524bd0`) + +```c +00524bd0 int32_t __fastcall CSequence::has_anims(class CSequence const* this) +{ + int32_t result; + result = this->anim_list.head_ != 0; + return result; +} +``` + +## 10. `CSequence::set_velocity` (line 300798, addr `0x00524880`) + +```c +00524880 void __thiscall CSequence::set_velocity(class CSequence* this, class AC1Legacy::Vector3 const* arg2) +{ + this->velocity.x = arg2->x; + this->velocity.y = arg2->y; + this->velocity.z = arg2->z; +} +``` + +## 11. `CSequence::set_omega` (line 300808, addr `0x005248a0`) + +```c +005248a0 void __thiscall CSequence::set_omega(class CSequence* this, class AC1Legacy::Vector3 const* arg2) +{ + this->omega.x = arg2->x; + this->omega.y = arg2->y; + this->omega.z = arg2->z; +} +``` + +## 12. `CSequence::combine_physics` (line 300818, addr `0x005248c0`) + +```c +005248c0 void __thiscall CSequence::combine_physics(class CSequence* this, class AC1Legacy::Vector3 const* arg2, class AC1Legacy::Vector3 const* arg3) +{ + float* eax = arg2; + this->velocity.x = ((float)(((long double)this->velocity.x) + ((long double)*(uint32_t*)eax))); + this->velocity.y = ((float)(((long double)eax[1]) + ((long double)this->velocity.y))); + this->velocity.z = ((float)(((long double)eax[2]) + ((long double)this->velocity.z))); + this->omega.x = ((float)(((long double)this->omega.x) + ((long double)arg3->x))); + this->omega.y = ((float)(((long double)arg3->y) + ((long double)this->omega.y))); + this->omega.z = ((float)(((long double)arg3->z) + ((long double)this->omega.z))); +} +``` + +Cleaned: `velocity += arg2; omega += arg3;` (element-wise), all math +promoted through x87 `long double` and truncated back to `float` per +component (retail's usual FP-widen-then-narrow pattern; NOT a +bit-identical no-op — matters for exact conformance tests). + +## 13. `CSequence::subtract_physics` (line 300832, addr `0x00524900`) + +```c +00524900 void __thiscall CSequence::subtract_physics(class CSequence* this, class AC1Legacy::Vector3 const* arg2, class AC1Legacy::Vector3 const* arg3) +{ + float* eax = arg2; + this->velocity.x = ((float)(((long double)this->velocity.x) - ((long double)*(uint32_t*)eax))); + this->velocity.y = ((float)(((long double)this->velocity.y) - ((long double)eax[1]))); + this->velocity.z = ((float)(((long double)this->velocity.z) - ((long double)eax[2]))); + this->omega.x = ((float)(((long double)this->omega.x) - ((long double)arg3->x))); + this->omega.y = ((float)(((long double)this->omega.y) - ((long double)arg3->y))); + this->omega.z = ((float)(((long double)this->omega.z) - ((long double)arg3->z))); +} +``` + +`velocity -= arg2; omega -= arg3;` — mirror of `combine_physics`. + +## 14. `CSequence::multiply_cyclic_animation_fr` (line 300846, addr `0x00524940`) + +Note: the task prompt's "multiply_cyclic_animation_framerate" is this +function; the PDB-recovered name is truncated to `multiply_cyclic_animation_fr` +(28-char Windows debug-symbol truncation on some builds). + +```c +00524940 void __thiscall CSequence::multiply_cyclic_animation_fr(class CSequence* this, float arg2) +{ + for (class AnimSequenceNode* first_cyclic = this->first_cyclic; first_cyclic != 0; first_cyclic = AnimSequenceNode::GetNext(first_cyclic)) + AnimSequenceNode::multiply_framerate(first_cyclic, arg2); +} +``` + +Walks from `first_cyclic` to the tail (the cyclic/looping portion of +the sequence) and multiplies EVERY node's framerate by `arg2`. Used +elsewhere (line 298286/298295 call sites) to retarget a cyclic motion's +playback rate — e.g. scaling Walk_Forward's framerate to match the +character's actual movement speed: + +``` +298286 CSequence::multiply_cyclic_animation_fr(arg1, ((float)(((long double)arg4) / ((long double)arg3)))); +298295 CSequence::multiply_cyclic_animation_fr(arg1, 0f); +``` + +### 14a. `AnimSequenceNode::multiply_framerate` (line 302425, addr `0x00525be0`) + +```c +00525be0 void __thiscall AnimSequenceNode::multiply_framerate(class AnimSequenceNode* this, float arg2) +{ + long double x87_r7 = ((long double)arg2); + long double temp1 = ((long double)0f); + (x87_r7 - temp1); + int32_t eax; + eax = ((((x87_r7 < temp1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7, temp1))) ? 1 : 0) << 0xa) | ((((x87_r7 == temp1) ? 1 : 0) << 0xe) | 0)))); + bool p = /* test ah, 0x5 -> "arg2 < 0.0" (unordered-safe less-than) */; + + if (!(p)) + { + int32_t low_frame = this->low_frame; + this->low_frame = this->high_frame; + this->high_frame = low_frame; + } + + this->framerate = ((float)(((long double)arg2) * ((long double)this->framerate))); +} +``` + +Cleaned: +``` +if (arg2 < 0.0f) + swap(low_frame, high_frame); +framerate *= arg2; +``` +(NOTE the branch condition polarity: the raw x87 test is `test ah,0x5` +on a compare of `arg2` vs `0.0`; the `!(p)` wrapping the swap makes the +swap fire when `arg2 < 0.0` is TRUE — i.e. multiplying by a NEGATIVE +factor swaps low/high frame, consistent with `get_starting_frame`/ +`get_ending_frame` below which key off `framerate < 0` to decide +playback direction.) + +## 15. `CSequence::get_curr_animframe` (line 300855, addr `0x00524970`) + +(This is retail's "get current animation frame" accessor — the closest +named function to the task's "get_curr_animation"; there is no separate +`CSequence::get_curr_animation` symbol in the PDB.) + +```c +00524970 class AnimFrame const* __fastcall CSequence::get_curr_animframe(class CSequence const* this) +{ + class AnimSequenceNode* curr_anim = this->curr_anim; + + if (curr_anim == 0) + return this->placement_frame; + + int32_t eax = this->frame_number; + int32_t ecx = *(uint32_t*)((char*)this->frame_number)[4]; + int32_t var_8 = eax; + int32_t var_4 = ecx; + floor(eax, ecx); + return AnimSequenceNode::get_part_frame(curr_anim, _ftol2()); +} +``` + +Cleaned: +``` +if (curr_anim == null) + return placement_frame; +return curr_anim.get_part_frame((int)floor(frame_number)); +``` +If there is no active animation node, the sequence renders whatever +static `placement_frame` was set (see `set_placement_frame` below). +Otherwise it floors the fractional `frame_number` to an integer frame +index and asks the current node for that frame's part-transform data. + +## 16. `CSequence::set_placement_frame` (line 300872, addr `0x005249b0`) + +```c +005249b0 void __thiscall CSequence::set_placement_frame(class CSequence* this, class AnimFrame const* arg2, uint32_t arg3) +{ + this->placement_frame = arg2; + this->placement_frame_id = arg3; +} +``` + +Trivial setter for the two placement fields; called elsewhere (line +287517, 287550) with `nullptr, 0` to clear placement, or a live +`AnimFrame*` + id to set a static pose (e.g. for objects with no +animation, "placement_frame_id" ~ `0x65` seen in the older function +map's field notes). + +## 17. `CSequence::get_curr_frame_number` (line 300881, addr `0x005249d0`) + +```c +005249d0 uint32_t __fastcall CSequence::get_curr_frame_number(class CSequence const* this) +{ + floor(this->frame_number, *(uint32_t*)((char*)this->frame_number)[4]); + return _ftol2(); +} +``` + +`return (uint32_t)floor(frame_number);` — integer frame index for the +current fractional position (used by `CPartArray::get_curr_frame_number` +callers at line 300779/300781 in `CPartArray`). + +## 18. `CSequence::execute_hooks` — hook dispatch (line 300780, addr `0x00524830`) + +```c +00524830 void __thiscall CSequence::execute_hooks(class CSequence const* this, class AnimFrame const* arg2, int32_t arg3) +{ + if (this->hook_obj != 0) + { + for (class CAnimHook* i = arg2->hooks; i != 0; i = i->next_hook) + { + int32_t direction_ = i->direction_; + if ((direction_ == 0 || arg3 == direction_)) + CPhysicsObj::add_anim_hook(this->hook_obj, i); + } + } +} +``` + +**Hook-dispatch mechanics (verbatim):** `execute_hooks(frame, direction)` +walks the singly-linked `hooks` chain attached to a SPECIFIC `AnimFrame` +(a frame within the current animation's `part_frames` array — see +`AnimFrame` struct, field `hooks`). Each `CAnimHook` node carries a +`direction_` filter: +- `direction_ == 0` → fires regardless of playback direction (both + forward and reverse frame-crossings queue it). +- `direction_ != 0` → only fires when the CALLER'S `arg3` direction + matches exactly. + +The actual call sites in `update_internal` (see §21 below) pass: +- `arg3 = 1` when crossing a frame FORWARD (line 302189: + `CSequence::execute_hooks(this, AnimSequenceNode::get_part_frame(*arg3, ebx_2), 1)`) +- `arg3 = 0xffffffff` (i.e. `-1`) when crossing a frame BACKWARD (line + 302039: `CSequence::execute_hooks(this, AnimSequenceNode::get_part_frame(*arg3, ebx_1), 0xffffffff)`) + +So a hook registered with `direction_ = 1` fires only on forward frame +crossings, one with `direction_ = -1` (`0xffffffff`) fires only on +backward crossings, and `direction_ = 0` fires on both. Matched hooks +are NOT executed immediately — they are appended (`CPhysicsObj::add_anim_hook`, +line 282906, `0x00514c20`) to the owning `CPhysicsObj`'s `anim_hooks` +SmartArray, which is drained once per physics tick by +`CPhysicsObj::process_hooks` (line 279431, `0x00511550`): + +```c +00511550 void __fastcall CPhysicsObj::process_hooks(class CPhysicsObj* this) +{ + // ... first drains a SEPARATE linked list `this->hooks` (PhysicsObjHook*) + // — one-shot script/PES/transparency hooks, unrelated to CAnimHook ... + + uint32_t m_num = this->anim_hooks.m_num; + if (m_num > 0) + { + int32_t i = 0; + if (m_num > 0) + { + do + { + this->anim_hooks.m_data[i]->vtable->Execute(this); + i += 1; + } while (i < this->anim_hooks.m_num); + } + AC1Legacy::SmartArray::shrink(&this->anim_hooks); + this->anim_hooks.m_num = 0; + } +} +``` + +So the full pipeline per crossed frame is: `execute_hooks` queues +matching `CAnimHook*` pointers into `anim_hooks` (append-only, growable +SmartArray, doubles capacity from a base of 8 via `grow()`) → +`process_hooks` later executes EVERY queued hook via its vtable +`Execute(CPhysicsObj*)` and then resets `m_num = 0` (queue is drained +completely every call, `shrink()` just trims capacity bookkeeping). + +### 18a. `AnimDoneHook` — the animation-complete hook (line 302227/302223 call site + 303832 Execute) + +```c +00526c20 void __stdcall AnimDoneHook::Execute(class AnimDoneHook const* this @ ecx, class CPhysicsObj* arg2) +{ + CPhysicsObj::Hook_AnimDone(arg2); +} +``` + +```c +0050fda0 void __fastcall CPhysicsObj::Hook_AnimDone(class CPhysicsObj* this) +{ + class CPartArray* part_array = this->part_array; + if (part_array != 0) + CPartArray::AnimationDone(part_array, 1); +} +``` + +`AnimDoneHook` is a GLOBAL singleton instance (`class AnimDoneHook +anim_done_hook` at data address `0x0081d9fc`, vtable installed at +line 901343 `0x007681f0`) — NOT allocated per-node. It has no +per-instance `direction_`/frame association; it's queued directly by +`update_internal` (not via `execute_hooks`/per-frame `hooks` chain) when +a node transition consumes the LAST node in the list (see §21, the +"leading edge" check). This is retail's `MotionDone` signal path: +`AnimDoneHook::Execute` → `CPhysicsObj::Hook_AnimDone` → +`CPartArray::AnimationDone(part_array, 1)`. + +## 19. `CSequence::apply_physics` (line 300955, addr `0x00524ab0`) + +```c +00524ab0 void __thiscall CSequence::apply_physics(class CSequence const* this, class Frame* arg2, double arg3, double arg4) +{ + long double x87_r7 = ((long double)arg4); + long double temp1 = ((long double)0.0); + (x87_r7 - temp1); + long double x87_r7_2 = fabsl(((long double)arg3)); + + if ((*(uint8_t*)((char*)((((x87_r7 < temp1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7, temp1))) ? 1 : 0) << 0xa) | ((((x87_r7 == temp1) ? 1 : 0) << 0xe) | 0x3800)))))[1] & 1) != 0) + x87_r7_2 = -(x87_r7_2); + + arg2->m_fOrigin.x = ((float)((x87_r7_2 * ((long double)this->velocity.x)) + ((long double)arg2->m_fOrigin.x))); + arg2->m_fOrigin.y = ((float)(((long double)((float)(x87_r7_2 * ((long double)this->velocity.y)))) + ((long double)arg2->m_fOrigin.y))); + arg2->m_fOrigin.z = ((float)(((long double)((float)(x87_r7_2 * ((long double)this->velocity.z)))) + ((long double)arg2->m_fOrigin.z))); + long double x87_r5_5 = (x87_r7_2 * ((long double)this->omega.y)); + float var_10_1 = ((float)(x87_r7_2 * ((long double)this->omega.z))); + float var_18 = ((float)(x87_r7_2 * ((long double)this->omega.x))); + float var_14_1 = ((float)x87_r5_5); + Frame::rotate(arg2, &var_18); +} +``` + +Cleaned: +``` +CSequence::apply_physics(this, Frame* frame, double quantum, double sign_source): + signed_quantum = fabs(quantum) + if (sign_source < 0.0) // sign copied from arg4, magnitude from arg3 + signed_quantum = -signed_quantum + + frame.m_fOrigin += velocity * signed_quantum // per-component + frame.rotate( omega * signed_quantum ) // Vector3(omega.x,omega.y,omega.z) * signed_quantum +``` + +This is `copysign(fabs(quantum_magnitude), sign_source)` — i.e. the +function takes the MAGNITUDE from `arg3` and the SIGN from `arg4` +(this matches every call site passing `1.0/framerate` for the magnitude +and the raw signed `frameRate`/`arg2` (elapsed-time-with-direction) as +the sign source — see §21). The result scales BOTH the accumulated +linear `velocity` (added into the frame's origin) and the accumulated +angular `omega` (fed into `Frame::rotate`) by the same signed quantum. + +`Frame::rotate` signature (line 91477, `0x004525b0`): +`void Frame::rotate(Frame* this, AC1Legacy::Vector3 const* arg2)` — +takes the omega*quantum vector and applies it as an incremental +rotation to the frame's orientation. + +## 20. `CSequence::apricot` — trim consumed nodes (line 300978, addr `0x00524b40`) + +(Retail's actual internal name for this function IS `apricot` — verified +via the PDB symbol table, not a placeholder.) + +```c +00524b40 void __fastcall CSequence::apricot(class CSequence* this) +{ + class DLListData* head_ = this->anim_list.head_; + void* __offset(DLListData, -0x4) i; + + if (head_ == 0) + i = nullptr; + else + i = ((char*)head_ - 4); + + if (i != this->curr_anim) + { + int32_t ebx; + int32_t var_c_1 = ebx; + + while (i != this->first_cyclic) + { + class DLListData* eax; + if (i == 0) + eax = nullptr; + else + eax = ((char*)i + 4); + + // unlink `eax` (the head node) from anim_list + class DLListData* dllist_prev = eax->dllist_prev; + if (dllist_prev == 0) + { + class DLListData* dllist_next = this->anim_list.head_->dllist_next; + this->anim_list.head_ = dllist_next; + if (dllist_next != 0) + dllist_next->dllist_prev = nullptr; + } + else + dllist_prev->dllist_next = eax->dllist_next; + + class DLListData* dllist_next_1 = eax->dllist_next; + if (dllist_next_1 == 0) + { + class DLListData* dllist_prev_1 = this->anim_list.tail_->dllist_prev; + this->anim_list.tail_ = dllist_prev_1; + if (dllist_prev_1 != 0) + dllist_prev_1->dllist_next = 0; + } + else + dllist_next_1->dllist_prev = eax->dllist_prev; + + eax->dllist_next = 0; + eax->dllist_prev = nullptr; + + if (i != 0) + *(uint32_t*)ADJ(i)->dllist_next(1); // delete head node + + class DLListData* head__1 = this->anim_list.head_; + if (head__1 == 0) + i = nullptr; + else + i = ((char*)head__1 - 4); + + if (i == this->curr_anim) + break; + } + } +} +``` + +Cleaned: +``` +CSequence::apricot(): + head = anim_list.head (as AnimSequenceNode*) + if (head == curr_anim) + return // nothing consumed yet, no trim needed + + while (head != first_cyclic): + // unlink+delete `head` from anim_list + delete_and_unlink(head) + head = anim_list.head (new head, as AnimSequenceNode*) + if (head == curr_anim) + break +``` + +Called at the end of every `CSequence::update` (see §22), immediately +after `update_internal` advances `curr_anim`. Its job is to free every +node BEFORE `curr_anim` that has already fully played and been popped +— it walks from the OLD list head forward, deleting nodes one at a time, +until it reaches either `curr_anim` (stop — that node is still live) or +`first_cyclic` (stop — do not delete into the cyclic tail even if +`curr_anim` has somehow moved past it, a defensive bound). + +## 21. `CSequence::update_internal` — the core per-frame advance loop (line 301839, addr `0x005255d0`) + +**Signature:** `CSequence::update_internal(CSequence const* this, double arg2, +AnimSequenceNode** arg3, double* arg4, Frame* arg5)` +- `this` — the sequence +- `arg2` — SIGNED elapsed time this tick (positive = forward playback + request, negative = reverse) +- `arg3` = `&this->curr_anim` (in/out — current node pointer) +- `arg4` = `&this->frame_number` (in/out — fractional frame position, + passed as a `double*` even though the underlying field is `long + double`; the field is read/written through `floor(lo,hi)` pairs which + address it as two 32-bit halves — the x87 extended representation) +- `arg5` = destination `Frame*` to accumulate physics into (or `null` + if the caller only wants the frame counter advanced, no motion + applied — see `CSequence::update`'s no-arg5-branch fallback) + +The raw pseudo-C for this function is almost entirely FPU compare +soup (each conditional branch lowers to an `fcom`/`fcomp`/`fcompp` +against `data_794610` [0.0] or `F_EPSILON` [0.0002f], captured as a +software-emulated FPU status word test `((c0<<8)|(c2<<10)|(c3<<14))` +then `test ah, 0x41`/`0x5`). Two call-site name mismatches from +vtable-slot devirtualization noise are corrected below: + +- Every call site the decompiler labeled `MD_Data_Fade::GetDuration(node)` + (lines 301633, 301647, 301657, 301680, 301696, 301706, 301725, 301735, + 301745, 301759, 301769) is a virtual call through `AnimSequenceNode`'s + vtable at the `framerate`-comparison slot — cross-checked against + `AnimSequenceNode::get_starting_frame`/`get_ending_frame` (§26/27 + below), whose boolean logic (`framerate < 0.0` branch, then return + `high_frame+1` or `low_frame`) is EXACTLY what every one of these call + sites' surrounding code does immediately afterward. `MD_Data_Fade` is + an unrelated `MediaDesc`-family class (acclient.h:34176) whose real + `GetDuration` takes incompatible arguments — a decompiler + address-collision mislabel, not a real call target. Read these sites + as `node->framerate` (raw field access via a trivial inline getter), + not a call to media-fade duration. +- Similarly `EffectInfoRegion::GetStat(node)` (lines 301917, 301931, + 301979) and `Attribute2ndInfoRegion::GetStat(node)` (lines 302064, + 302079, 302129) are mislabeled — by call-site position and use + (assigned into `var_28_1` then immediately combined with + `AnimSequenceNode::get_pos_frame`/`get_part_frame` and frame-index + arithmetic) these are `AnimSequenceNode::get_starting_frame(node)` / + `AnimSequenceNode::get_ending_frame(node)` respectively — same + address-collision artifact as above (`EffectInfoRegion`/ + `Attribute2ndInfoRegion::GetStat` are real functions elsewhere in the + binary, lines 244494/245331, unrelated chat/stat classes). + +### Cleaned control flow (semantics-preserving translation) + +``` +CSequence::update_internal(seq, elapsed, &curr_anim, &frame_number, frame): + + loop: + node_framerate = curr_anim.framerate // AnimSequenceNode::framerate (signed) + delta = elapsed * node_framerate // signed frame-step for this tick + old_frame_idx = floor(frame_number) // integer frame BEFORE advancing + new_pos = frame_number + delta + frame_number = new_pos + remaining = 0.0 // leftover elapsed time after a boundary hit + hit_boundary = false // "var_30_1" — did we cross curr_anim's end? + + if (delta < 0.0): // ── REVERSE playback ── + // boundary = curr_anim.get_starting_frame() (mislabeled EffectInfoRegion::GetStat call) + boundary = curr_anim.get_starting_frame() + + if (frame_number < boundary): // crossed/undershot the start + if (frame != null): + if (curr_anim.anim.pos_frames != 0) + Frame::subtract1(frame, frame, curr_anim.get_pos_frame((int)frame_number)) + if (fabs(node_framerate) >= F_EPSILON) // 0.000199999995f + CSequence::apply_physics(this, frame, 1.0 / node_framerate, elapsed) + // (return early here — the "p_5"/"return" branch, line 301911: + // if the compare says frame_number was not < 0.0 relative to F_EPSILON + // test, execute_hooks/advance is skipped this call — degenerate + // micro-step case) + return + + // fire per-frame REVERSE hooks/velocity from old_frame_idx down to + // (but not below) floor(frame_number), highest frame first: + idx = old_frame_idx + do: + if (frame != null): + if (curr_anim.anim.pos_frames != 0) + Frame::subtract1(frame, frame, curr_anim.get_pos_frame(idx)) + if (fabs() >= F_EPSILON): + CSequence::apply_physics(this, frame, 1.0/node_framerate, elapsed) + CSequence::execute_hooks(this, curr_anim.get_part_frame(idx), 0xffffffff) // direction = -1 (backward) + idx -= 1 + while (idx > floor(frame_number)) + + // Node exhausted going backward → boundary crossed + // (var_30_1 = 1 set here; falls through to the "advance" tail) + hit_boundary = true + + else if (delta > 0.0): // ── FORWARD playback ── + // boundary = curr_anim.get_ending_frame() (mislabeled Attribute2ndInfoRegion::GetStat call) + boundary = curr_anim.get_ending_frame() + + if (frame_number > boundary): // crossed/overshot the end + // same early-apply-then-return shape as the reverse case, + // using Frame::combine instead of Frame::subtract1: + if (frame != null): + if (curr_anim.anim.pos_frames != 0) + Frame::combine(frame, frame, curr_anim.get_pos_frame((int)frame_number)) + if (fabs(node_framerate) >= F_EPSILON) + CSequence::apply_physics(this, frame, 1.0 / node_framerate, elapsed) + return + + // fire per-frame FORWARD hooks/velocity from old_frame_idx up to + // (but not past) floor(frame_number), lowest frame first: + idx = old_frame_idx + do: + if (frame != null): + if (curr_anim.anim.pos_frames != 0) + Frame::combine(frame, frame, curr_anim.get_pos_frame(idx)) + if (fabs() >= F_EPSILON): + CSequence::apply_physics(this, frame, 1.0/node_framerate, elapsed) + CSequence::execute_hooks(this, curr_anim.get_part_frame(idx), 1) // direction = +1 (forward) + idx += 1 + while (idx < floor(frame_number)) + + hit_boundary = true + + else: // delta == 0.0 (node_framerate == 0 or elapsed == 0) + return // nothing to do this tick + + // ── boundary crossed: advance to the next queued animation ── + if (hit_boundary == false): + return + + hook_obj = this->hook_obj + if (hook_obj != null): + // if the OLD list head has already been fully consumed and we are + // NOT still inside the "cyclic tail" region, queue AnimDoneHook — + // signals MotionDone to the owning weenie/entity: + list_head_node = (anim_list.head_ != null) ? (anim_list.head_ - 4) : null + if (list_head_node != this->first_cyclic): + CPhysicsObj::add_anim_hook(hook_obj, &anim_done_hook) + + CSequence::advance_to_next_animation(this, elapsed, &curr_anim, &frame_number, frame) + + elapsed = remaining // carry the leftover time (past the boundary) into the next loop pass + goto loop +``` + +**Verbatim structural notes preserved from the raw decomp:** + +- The **degenerate-boundary early-return** (reverse case around line + 301885-301911, forward mirror around line 302008-301912-ish) is a + REAL early exit distinct from the main per-frame loop — it only fires + when the SINGLE-STEP position already lands past the boundary on the + FIRST comparison (i.e. `delta` alone overshoots in one step); in that + branch `apply_physics`/position-combine happens ONCE using the raw + `1.0/node_framerate` quantum and the function returns WITHOUT calling + `advance_to_next_animation` or firing `execute_hooks` at all for that + tick — because `arg5`(frame)==null guards the whole inner block, so if + no destination Frame was supplied, this early-return path does + literally nothing but return. +- The **per-frame do/while loops** (lines 302006-302056 forward; the + reverse mirror is symmetric) always execute the position-combine + + `apply_physics` + `execute_hooks` triple for EVERY whole frame index + crossed this tick, not just the final one — multi-frame skips (large + `elapsed`/`delta` values, e.g. a lag spike) fire ALL intermediate + frame hooks in order, matching the task's requirement to "note which + hooks fire on which frame crossings": every crossed integer frame + fires its `AnimFrame.hooks` chain filtered by direction (`1` forward + / `-1` (`0xffffffff`) reverse) via `execute_hooks`, in strict + ascending (forward) or descending (reverse) frame order. +- `Frame::combine` (forward) vs `Frame::subtract1` (reverse) — these are + NOT symmetric operations; `combine` composes the animation's stored + per-frame `AFrame` (quaternion + origin) INTO the destination frame + (i.e. applies the pose), `subtract1` un-applies (backs out) that same + pose. This is retail's mechanism for incremental per-frame pose + application driven purely by boundary-crossing bookkeeping — the + actual skeletal pose comes from `curr_anim.get_pos_frame(idx)` + (root/`AFrame`-level) and `get_part_frame(idx)` (per-part + `AnimFrame`) tables baked into the `CAnimation` dat resource, NOT from + interpolating `frame_number`'s fractional part at render time inside + this function (rendering interpolation happens elsewhere, in + `PartArray::SetFrame`/`UpdateParts`, off `get_curr_animframe`'s + floored index). +- `CSequence::execute_hooks` is called with the **PART frame** ( + `AnimSequenceNode::get_part_frame(idx)`, an `AnimFrame const*`), NOT + the pos frame — hooks live on `AnimFrame.hooks`, keyed per animation + part-frame index, exactly matching the `AnimFrame` struct's `hooks` + field. +- Boundary detection uses `>` / `<` (strict) against + `get_ending_frame()` / `get_starting_frame()`, meaning `frame_number` + is allowed to sit EXACTLY AT the boundary value without triggering an + advance — the advance only fires once the position strictly exceeds + it. Combined with `get_ending_frame()` returning `high_frame + 1 - + ε`-ish (see §27, actually `high_frame+1` verbatim, no explicit + epsilon subtraction visible in the recovered code — the epsilon + appears in the F_EPSILON velocity-magnitude gates, not the frame + boundary values themselves) this is how retail avoids double-firing + the last frame's hooks. + +## 22. `CSequence::update` — public per-tick entry point (line 302402, addr `0x00525b80`) + +```c +00525b80 void __thiscall CSequence::update(class CSequence* this, double arg2, class Frame* arg3) +{ + if (this->anim_list.head_ != 0) + { + int32_t var_14_1 = *(uint32_t*)((char*)arg2)[4]; + CSequence::update_internal(this, arg2, &this->curr_anim, &this->frame_number, arg3); + CSequence::apricot(this); + return; + } + + if (arg3 != 0) + { + int32_t eax_3 = *(uint32_t*)((char*)arg2)[4]; + int32_t ecx_4 = arg2; + int32_t var_8_2 = eax_3; + int32_t var_10_2 = eax_3; + CSequence::apply_physics(this, arg3, ecx_4, ecx_4); + } +} +``` + +Cleaned: +``` +CSequence::update(this, double elapsed, Frame* frame): + if (anim_list.head_ != null): + update_internal(this, elapsed, &curr_anim, &frame_number, frame) + apricot(this) // trim already-consumed leading nodes + return + + // no queued animations at all — pure physics-only motion (e.g. free-fall, + // knockback velocity with no animation playing) + if (frame != null): + apply_physics(this, frame, elapsed, elapsed) // magnitude == sign source == elapsed +``` + +This is THE per-tick call site (`PartArray::Update` in the older +function-map cross-reference, `FUN_005188e0`/named +`CSequence::update` wrapper) — every physics tick that has an active +animation list goes through `update_internal` then immediately +`apricot`s the consumed leading nodes; a sequence with an EMPTY +animation list (e.g. between transitions, or an object with no motion +table) falls through to a bare `apply_physics` call so accumulated +`velocity`/`omega` (e.g. from `combine_physics` calls, jump/knockback) +still moves the frame even with nothing animating. + +## 23. `CSequence::advance_to_next_animation` (line 301622, addr `0x005252b0`) + +**Signature:** `advance_to_next_animation(CSequence const* this, double arg2, +AnimSequenceNode const** arg3, double* arg4, Frame* arg5)` — `arg2` is +the signed elapsed/rate value carried over from `update_internal` +(same sign convention: negative = reverse). + +```c +005252b0 void __thiscall CSequence::advance_to_next_animation(class CSequence const* this, double arg2, class AnimSequenceNode const** arg3, double* arg4, class Frame* arg5) +{ + class CSequence* this_1 = this; + long double x87_r7 = ((long double)arg2); + long double temp1 = ((long double)0.0); + (x87_r7 - temp1); + class AnimSequenceNode* ecx = *(uint32_t*)arg3; + + if (/* arg2 < 0.0 */) + { + // ── REVERSE: step to the PREVIOUS node ── + if (!(/* node.get_ending_frame's framerate<0 branch NOT taken, i.e. degenerate small-duration guard */) && arg5 != 0) + { + class AnimSequenceNode* ecx_16 = *(uint32_t*)arg3; + if (ecx_16->anim->pos_frames != 0) + Frame::subtract1(arg5, arg5, AnimSequenceNode::get_pos_frame(ecx_16, *(uint32_t*)arg4)); + + if (fabs(node.framerate) >= F_EPSILON) // 0.000199999995f + CSequence::apply_physics(this, arg5, ((double)(((long double)1.0) / node.framerate)), arg2); + } + + // move to predecessor, or wrap to the list tail if there is none + class AnimSequenceNode* eax_17; + if (AnimSequenceNode::GetPrev(*(uint32_t*)arg3) == 0) + { + class DLListData* tail_ = this->anim_list.tail_; + eax_17 = (tail_ == 0) ? nullptr : ((char*)tail_ - 4); + } + else + eax_17 = AnimSequenceNode::GetPrev(*(uint32_t*)arg3); + + *(uint32_t*)arg3 = eax_17; // curr_anim = eax_17 + *(uint64_t*)arg4 = ((double)AnimSequenceNode::get_ending_frame(eax_17)); // frame_number = new node's ending frame + + if (!(/* degenerate small-duration guard */) && arg5 != 0) + { + class AnimSequenceNode* ecx_26 = *(uint32_t*)arg3; + if (ecx_26->anim->pos_frames != 0) + Frame::combine(arg5, arg5, AnimSequenceNode::get_pos_frame(ecx_26, *(uint32_t*)arg4)); + + if (fabs(node.framerate) >= F_EPSILON) + CSequence::apply_physics(this, arg5, ((double)(((long double)1.0) / node.framerate)), arg2); + } + } + else + { + // ── FORWARD: step to the NEXT node, wrapping to first_cyclic at the tail ── + if (!(/* degenerate small-duration guard */) && arg5 != 0) + { + class AnimSequenceNode* ecx_1 = *(uint32_t*)arg3; + if (ecx_1->anim->pos_frames != 0) + Frame::subtract1(arg5, arg5, AnimSequenceNode::get_pos_frame(ecx_1, *(uint32_t*)arg4)); + + if (fabs(node.framerate) >= F_EPSILON) + CSequence::apply_physics(this, arg5, ((double)(((long double)1.0) / node.framerate)), arg2); + } + + if (AnimSequenceNode::GetNext(*(uint32_t*)arg3) == 0) + *(uint32_t*)arg3 = this->first_cyclic; // wrap to first_cyclic when list exhausted + else + *(uint32_t*)arg3 = AnimSequenceNode::GetNext(*(uint32_t*)arg3); + + *(uint64_t*)arg4 = ((double)AnimSequenceNode::get_starting_frame(*(uint32_t*)arg3)); // frame_number = new node's starting frame + + if (/* extra 0x41-mask condition — degenerate guard with an extra bit vs the mirrored branches above */ && arg5 != 0) + { + class AnimSequenceNode* ecx_12 = *(uint32_t*)arg3; + if (ecx_12->anim->pos_frames != 0) + Frame::combine(arg5, arg5, AnimSequenceNode::get_pos_frame(ecx_12, *(uint32_t*)arg4)); + + if (fabs(node.framerate) >= F_EPSILON) + CSequence::apply_physics(this, arg5, ((double)(((long double)1.0) / node.framerate)), arg2); + } + } +} +``` + +Cleaned: +``` +CSequence::advance_to_next_animation(this, elapsed, &curr_anim, &frame_number, frame): + + if (elapsed < 0.0): // REVERSE + // (a) un-apply the outgoing node's pose at its current frame_number, + // and roll its residual velocity/omega into `frame`, UNLESS this + // is a zero-duration/degenerate node + if (frame != null && duration(curr_anim) != 0.0): + if (curr_anim.anim.pos_frames != 0) + Frame::subtract1(frame, frame, curr_anim.get_pos_frame((int)frame_number)) + if (fabs(curr_anim.framerate) >= F_EPSILON) + CSequence::apply_physics(this, frame, 1.0 / curr_anim.framerate, elapsed) + + // (b) step to the PREVIOUS node in the list; if there is no + // predecessor, wrap around to the LIST TAIL + prev = GetPrev(curr_anim) + curr_anim = (prev != null) ? prev : (anim_list.tail_ != null ? tail_node : null) + frame_number = curr_anim.get_ending_frame() + + // (c) apply the INCOMING node's pose at its new frame_number + if (frame != null && duration(curr_anim) != 0.0): + if (curr_anim.anim.pos_frames != 0) + Frame::combine(frame, frame, curr_anim.get_pos_frame((int)frame_number)) + if (fabs(curr_anim.framerate) >= F_EPSILON) + CSequence::apply_physics(this, frame, 1.0 / curr_anim.framerate, elapsed) + + else: // FORWARD (elapsed >= 0.0) + // (a) un-apply the outgoing node's pose + if (frame != null && duration(curr_anim) != 0.0): + if (curr_anim.anim.pos_frames != 0) + Frame::subtract1(frame, frame, curr_anim.get_pos_frame((int)frame_number)) + if (fabs(curr_anim.framerate) >= F_EPSILON) + CSequence::apply_physics(this, frame, 1.0 / curr_anim.framerate, elapsed) + + // (b) step to the NEXT node; if there is none, WRAP TO first_cyclic + // (this is the "loop the cyclic tail forever" mechanism) + next = GetNext(curr_anim) + curr_anim = (next != null) ? next : first_cyclic + frame_number = curr_anim.get_starting_frame() + + // (c) apply the incoming node's pose + if (frame != null && duration(curr_anim) != 0.0): // + one extra mask bit vs (a)/reverse-(c) — same epsilon-style guard + if (curr_anim.anim.pos_frames != 0) + Frame::combine(frame, frame, curr_anim.get_pos_frame((int)frame_number)) + if (fabs(curr_anim.framerate) >= F_EPSILON) + CSequence::apply_physics(this, frame, 1.0 / curr_anim.framerate, elapsed) +``` + +**Key retail-faithful details:** +- **Forward wrap target is `first_cyclic`, NOT the list head.** When + the last node in the forward chain is exhausted, playback loops back + to `first_cyclic` — the boundary marker between one-shot "link" + animations (queued ahead of the loop, consumed once and freed via + `apricot`) and the actual repeating cycle. This is THE mechanism that + makes e.g. Walk_Forward loop forever while a one-shot Jump transition + plays once and falls through. +- **Reverse wrap target is the LIST TAIL**, not `first_cyclic` — reverse + playback (used for backing out of a motion, e.g. an interrupted + transition) wraps to the very end of the queued list, not to the + cyclic boundary. Asymmetric by design. +- Every node transition does FOUR pose operations in sequence: + un-apply-outgoing (`subtract1`/reverse-mirror), select-new-node, + apply-incoming (`combine`), interleaved with `apply_physics` calls + using `1.0 / node.framerate` as the physics quantum's magnitude and + the ORIGINAL caller's `elapsed`/`arg2` as the sign source (matching + `apply_physics`'s `copysign` semantics from §19). +- The three inline "degenerate guard" conditions (reverse-out, + reverse-in, forward-out, forward-in) are the SAME F_EPSILON-style FPU + compare pattern seen throughout — a duration/framerate near-zero + check that skips the pos-frame combine/subtract entirely when the + node's timing is degenerate (e.g. a 1-frame or 0-duration transition + node), a guard against divide-by-near-zero when computing + `1.0/node.framerate`. + +## 24. `CSequence::append_animation` (line 301777, addr `0x00525510`) + +```c +00525510 void __thiscall CSequence::append_animation(class CSequence* this, class AnimData const* arg2) +{ + void* eax = operator new(0x1c); + int32_t* esi; + + if (eax == 0) + esi = nullptr; + else + esi = AnimSequenceNode::AnimSequenceNode(eax, arg2); + + if (AnimSequenceNode::has_anim(esi) != 0) + { + void* eax_3; + if (esi == 0) + eax_3 = nullptr; + else + eax_3 = &esi[1]; + + DLListBase::InsertAfter(&this->anim_list, eax_3, this->anim_list.tail_); + class DLListData* tail_ = this->anim_list.tail_; + void* __offset(DLListData, -0x4) eax_4; + + if (tail_ == 0) + eax_4 = nullptr; + else + eax_4 = ((char*)tail_ - 4); + + this->first_cyclic = eax_4; + + if (this->curr_anim == 0) + { + void* head_ = this->anim_list.head_; + if (head_ != 0) + { + this->curr_anim = ((char*)head_ - 4); + this->frame_number = ((double)AnimSequenceNode::get_starting_frame(((char*)head_ - 4))); + return; + } + + this->curr_anim = nullptr; + this->frame_number = ((double)AnimSequenceNode::get_starting_frame(nullptr)); + } + } + else if (esi != 0) + **(uint32_t**)esi(1); // node had no anim data — self-destruct (scalar deleting dtor, delete=1) +} +``` + +Cleaned: +``` +CSequence::append_animation(this, AnimData const* data): + node = new AnimSequenceNode(data) // heap alloc 0x1c bytes; ctor copies framerate/low_frame/high_frame/anim_id from `data` + if (node.has_anim()): // node.anim != null (DBObj::Get succeeded) + anim_list.InsertAfter(node, anim_list.tail_) // append at the tail + first_cyclic = anim_list.tail_ // ALWAYS repoints first_cyclic to the JUST-APPENDED node + if (curr_anim == null): + if (anim_list.head_ != null): + curr_anim = anim_list.head_ + frame_number = curr_anim.get_starting_frame() + return + curr_anim = null + frame_number = AnimSequenceNode::get_starting_frame(null) // degenerate/default-framerate(30) starting-frame value + else: + delete node // failed to resolve the anim dat resource — discard +``` + +**Critical retail-faithful detail:** `first_cyclic` is updated to the +NEWLY APPENDED node on EVERY successful `append_animation` call, not +just the first. This means the "cyclic tail" boundary always tracks +the LAST node appended so far — i.e. calling `append_animation` +multiple times in sequence (as a transition chain builder does — e.g. +"exit-substate, transition, new-stance" in sequence) keeps sliding +`first_cyclic` forward to the newest node, so only the FINAL +`append_animation` call in a chain-build actually establishes the +node(s) that will be treated as the looping cycle once earlier +one-shot nodes are trimmed by `remove_cyclic_anims`/consumed by +`advance_to_next_animation`'s forward-wrap. + +## 25. `AnimSequenceNode::AnimSequenceNode` ctors (lines 302547 & 302744) + +### Default ctor (line 302547, addr `0x00525d30`) + +```c +00525d30 void __fastcall AnimSequenceNode::AnimSequenceNode(class AnimSequenceNode* this) +{ + this->dllist_next = nullptr; + this->dllist_prev = nullptr; + this->anim = nullptr; + this->vtable = 0x7c8504; + this->framerate = 30f; + this->low_frame = 0xffffffff; + this->high_frame = 0xffffffff; +} +``` + +### From `AnimData` (line 302744, addr `0x00525f90`) + +```c +00525f90 void __thiscall AnimSequenceNode::AnimSequenceNode(class AnimSequenceNode* this, class AnimData const* arg2) +{ + this->dllist_next = nullptr; + this->dllist_prev = nullptr; + this->anim = nullptr; + this->vtable = 0x7c8504; + this->framerate = arg2->framerate; + this->low_frame = arg2->low_frame; + this->high_frame = arg2->high_frame; + AnimSequenceNode::set_animation_id(this, arg2->anim_id.id); +} +``` + +### `AnimSequenceNode::set_animation_id` (line 302561, addr `0x00525d60`) + +```c +00525d60 void __thiscall AnimSequenceNode::set_animation_id(class AnimSequenceNode* this, class IDClass<_tagDataID,32,0> arg2) +{ + class CAnimation* anim_1 = this->anim; + if (anim_1 != 0) + anim_1->vtable->Release(); + + void var_8; + if (arg2 == 0) + this->anim = nullptr; + else + this->anim = DBObj::Get(QualifiedDataID::QualifiedDataID(&var_8, arg2, 8)); + + class CAnimation* anim = this->anim; + if (anim != 0) + { + if (this->high_frame < 0) + this->high_frame = (anim->num_frames - 1); + + uint32_t num_frames_1 = anim->num_frames; + if (this->low_frame >= num_frames_1) + this->low_frame = (num_frames_1 - 1); + + uint32_t num_frames = anim->num_frames; + if (this->high_frame >= num_frames) + this->high_frame = (num_frames - 1); + + int32_t low_frame = this->low_frame; + if (low_frame > this->high_frame) + this->high_frame = low_frame; + } +} +``` + +Cleaned: resolves the dat animation resource (type-8 qualified DBObj +lookup) by `anim_id`, then CLAMPS `low_frame`/`high_frame` into +`[0, anim.num_frames-1]`: +- `high_frame < 0` (the ctor default `0xffffffff` == `-1` when + interpreted signed) → clamp to `num_frames - 1` (i.e. "play to the + end"). +- `low_frame >= num_frames` → clamp to `num_frames - 1`. +- `high_frame >= num_frames` → clamp to `num_frames - 1`. +- if after clamping `low_frame > high_frame`, force `high_frame = + low_frame` (degenerate single-frame range, never inverted). + +`AnimData::AnimData` default ctor (line 302519, addr `0x00525ce0`) +confirms the DEFAULT values a fresh `AnimData` (before per-transition +override) carries: `anim_id = 0`, `low_frame = 0`, `high_frame = +0xffffffff` (i.e. -1, "use full anim"), `framerate = 30.0f`. + +## 26. `AnimSequenceNode::get_starting_frame` (line 302483, addr `0x00525c80`) + +```c +00525c80 int32_t __fastcall AnimSequenceNode::get_starting_frame(class AnimSequenceNode const* this) +{ + class AnimSequenceNode* this_1 = this; + long double x87_r7 = ((long double)this->framerate); + long double temp0 = ((long double)0f); + (x87_r7 - temp0); + int16_t result = ((((x87_r7 < temp0) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7, temp0))) ? 1 : 0) << 0xa) | ((((x87_r7 == temp0) ? 1 : 0) << 0xe) | 0)))); + + if ((*(uint8_t*)((char*)result)[1] & 1) != 0) + return (this->high_frame + 1); + + this->low_frame; + return result; +} +``` + +Cleaned: +``` +AnimSequenceNode::get_starting_frame(): + if (framerate < 0.0f) + return high_frame + 1 + return low_frame +``` +(the `test ah,1` bit pulled from the FPU status word after +`fcomp` is the raw "less-than" flag — `framerate < 0.0`.) + +## 27. `AnimSequenceNode::get_ending_frame` (line 302501, addr `0x00525cb0`) + +```c +00525cb0 int32_t __fastcall AnimSequenceNode::get_ending_frame(class AnimSequenceNode const* this) +{ + class AnimSequenceNode* this_1 = this; + long double x87_r7 = ((long double)this->framerate); + long double temp0 = ((long double)0f); + (x87_r7 - temp0); + int16_t result = ((((x87_r7 < temp0) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7, temp0))) ? 1 : 0) << 0xa) | ((((x87_r7 == temp0) ? 1 : 0) << 0xe) | 0)))); + + if ((*(uint8_t*)((char*)result)[1] & 1) == 0) + return (this->high_frame + 1); + + this->low_frame; + return result; +} +``` + +Cleaned: +``` +AnimSequenceNode::get_ending_frame(): + if (framerate < 0.0f) + return low_frame + return high_frame + 1 +``` + +**These two are exact mirrors of each other, keyed off the SAME sign +test** (`framerate < 0.0`), which is retail's playback-direction flag: +- Forward playback (`framerate >= 0`): starts at `low_frame`, ends at + `high_frame + 1`. +- Reverse playback (`framerate < 0`): starts at `high_frame + 1`, ends + at `low_frame`. + +So "starting frame" and "ending frame" are DIRECTION-AWARE — for a +reverse node, `get_starting_frame()` returns the numerically HIGHER +value (`high_frame + 1`) and `get_ending_frame()` returns the +numerically LOWER value (`low_frame`), because playback is counting +DOWN. This is exactly what feeds `frame_number` at every +`advance_to_next_animation` transition (§23) and is why +`update_internal`'s forward/reverse branches both correctly detect +"exhausted" via a single `frame_number > boundary` / `frame_number < +boundary` test regardless of which physical direction the node's +`framerate` sign implies. + +## 28. `AnimSequenceNode::get_pos_frame` (two overloads, lines 300734 & 302447) + +### `double`-index overload (line 300734, addr `0x005247b0`) + +```c +005247b0 class AFrame* __thiscall AnimSequenceNode::get_pos_frame(class AnimSequenceNode const* this, double arg2) +{ + floor(arg2, *(uint32_t*)((char*)arg2)[4]); + return AnimSequenceNode::get_pos_frame(this, _ftol2()); +} +``` +Truncates `arg2` (a `double` frame position) to `int` via `floor` then +tailcalls the `int`-index overload. + +### `int`-index overload (line 302447, addr `0x00525c10`) + +```c +00525c10 class AFrame* __thiscall AnimSequenceNode::get_pos_frame(class AnimSequenceNode const* this, int32_t arg2) +{ + class CAnimation* anim = this->anim; + if ((anim != 0 && (arg2 >= 0 && arg2 < anim->num_frames))) + return ((arg2 * 0x1c) + anim->pos_frames); + return 0; +} +``` + +Cleaned: +``` +AnimSequenceNode::get_pos_frame(int frame_index): + if (anim != null && 0 <= frame_index < anim.num_frames) + return &anim.pos_frames[frame_index] // AFrame, stride 0x1c (28 bytes) + return null +``` +Bounds-checked lookup into `CAnimation::pos_frames` (root/whole-object +position+orientation per frame — an `AFrame`, stride 28 bytes). Returns +`null` (not a fallback frame) out of range or when the node has no +resolved `anim`. + +## 29. `AnimSequenceNode::get_part_frame` (line 302460, addr `0x00525c40`) + +```c +00525c40 class AnimFrame const* __thiscall AnimSequenceNode::get_part_frame(class AnimSequenceNode const* this, int32_t arg2) +{ + class CAnimation* anim = this->anim; + if ((anim != 0 && (arg2 >= 0 && arg2 < anim->num_frames))) + return &anim->part_frames[arg2]; + return 0; +} +``` + +Cleaned: identical shape to `get_pos_frame` but indexes +`CAnimation::part_frames` (the PER-PART `AnimFrame` array — this is +what carries `.hooks` for the frame, consumed by +`CSequence::execute_hooks`). Same bounds check, same `null` on +out-of-range/no-anim. + +## 30. `AnimSequenceNode::has_anim` (line 302473, addr `0x00525c70`) + +```c +00525c70 int32_t __fastcall AnimSequenceNode::has_anim(class AnimSequenceNode const* this) +{ + int32_t result; + result = this->anim != 0; + return result; +} +``` + +## 31. `AnimSequenceNode::GetNext` / `GetPrev` (lines 302601, 302614) + +```c +00525de0 class AnimSequenceNode const* __fastcall AnimSequenceNode::GetNext(class AnimSequenceNode const* this) +{ + class DLListData* dllist_next = this->dllist_next; + if (dllist_next == 0) + return 0; + return ((char*)dllist_next - 4); +} + +00525df0 class AnimSequenceNode* __fastcall AnimSequenceNode::GetPrev(class AnimSequenceNode* this) +{ + class DLListData* dllist_prev = this->dllist_prev; + if (dllist_prev == 0) + return 0; + return ((char*)dllist_prev - 4); +} +``` + +Both convert the raw `DLListData*` link pointer to the owning +`AnimSequenceNode*` via the same `-4` byte adjustment seen throughout +the list-splice code (§0 struct-layout note). + +## 32. `CSequence::pack_size` / `Pack` / `UnPack` (lines 301334, 301458, 302235) — wire serialization + +Included for completeness (not part of the per-frame hot path, but +documents the `CSequence` wire format used by `PackObj::Pack`/`UnPack` +dispatch, relevant if acdream ever needs to interoperate with a +serialized retail sequence snapshot): + +```c +00524f20 uint32_t __thiscall CSequence::pack_size(class CSequence* this, uint32_t* arg2, uint32_t* arg3) +{ + *(uint32_t*)arg2 = 0; // flags accumulator (bit0 = has non-zero velocity, bit1 = has non-zero omega) + *(uint32_t*)arg3 = 0; // node count accumulator + + // count nodes in anim_list, summing each node's Pack() size (0x10 each) into `edi` + // edi starts at 4 (header dword), += 4 more if list non-empty (node-count field), + // += 0x10 if node count != 0 vs += 4 if empty (mismatched header sizing between + // "has nodes" vs "no nodes" cases) + + result = edi_1 + 4; // + placement_frame_id / frame_number header dword + + // velocity: if fabs(x) < F_EPSILON check EACH axis (short-circuit: first axis that + // fails the epsilon test forces the WHOLE vector to be packed as 0xc bytes + flag bit 0) + if (fabs(velocity.x) >= F_EPSILON) { result += 0xc; flags |= 1; } + else if (fabs(velocity.y) >= F_EPSILON) { result += 0xc; flags |= 1; } + else if (fabs(velocity.z) >= F_EPSILON) { result += 0xc; flags |= 1; } + + // omega: same pattern, flag bit 1 + if (fabs(omega.x) >= F_EPSILON) { result += 0xc; flags |= 2; } + else if (fabs(omega.y) >= F_EPSILON) { result += 0xc; flags |= 2; } + else if (fabs(omega.z) >= F_EPSILON) { result += 0xc; flags |= 2; } + + return result; +} +``` + +`CSequence::Pack` writes: flags-dword, [per-node `Pack()` blob × +count], then EITHER `placement_frame_id` (if no nodes) OR +`frame_number` (8 bytes) + `first_cyclic`-index (distance from head to +`first_cyclic` walking `GetNext`) + `curr_anim`-index (distance from +head to `curr_anim`), then conditionally `velocity` (if flags&1) and +`omega` (if flags&2) as 3 floats each (12 bytes, `0xc`) gated by +`arg3 >= 0xc` (buffer-size guard). + +`AnimSequenceNode::Pack`/`UnPack` (lines 302692/302721, `0x00525ee0`/ +`0x00525f40`) pack as `[DID (or INVALID_DID if anim==null), low_frame, +high_frame, framerate]` = 0x10 (16) bytes fixed. + +## 33. `CSequence::UnPack` (line 302235, addr `0x005259d0`) — tail (velocity/omega restore) + +```c +00525b3f if (((ecx_11 & 2) != 0 && arg3 >= 0xc)) + { + this->omega.x = *(uint32_t*)eax_14; + void* edx_5 = (*(uint32_t*)esi + 4); + *(uint32_t*)esi = edx_5; + this->omega.y = *(uint32_t*)edx_5; + void* ecx_14 = (*(uint32_t*)esi + 4); + *(uint32_t*)esi = ecx_14; + this->omega.z = *(uint32_t*)ecx_14; + *(uint32_t*)esi += 4; + } + + return 1; +} +``` + +Symmetric restore of `velocity` (flag bit 0) then `omega` (flag bit 1), +matching the `Pack`/`pack_size` bit layout above. `UnPack` begins (line +302239-302247) by calling `clear_animations()` + `clear_physics()` and +resetting `placement_frame`/`placement_frame_id` to null/0 before +reading the wire data — a full state wipe before deserializing. + +--- + +## Summary: hook-firing timeline per `CSequence::update` tick + +1. `CSequence::update(elapsed, frame)` called once per physics tick from + `PartArray::Update`. +2. If `anim_list` is non-empty: `update_internal` runs, possibly + LOOPING internally (the `goto loop` in §21) if `elapsed` overshoots + the current node's frame range — each loop iteration can itself fire + MULTIPLE per-frame hook batches (the inner do/while over crossed + integer frame indices) before even considering a node transition. +3. For EVERY whole integer frame index crossed within a single node + (forward or reverse), in strict frame order: + a. The node's stored pose delta at that frame index is + combined/subtracted into the destination `Frame*` (`Frame::combine` + forward, `Frame::subtract1` reverse) — but ONLY if the node's + `CAnimation.pos_frames != null`. + b. `apply_physics` folds in the sequence's accumulated + `velocity`/`omega` scaled by `1.0/framerate`, signed by the + caller's original elapsed/rate — but ONLY if + `fabs(framerate) >= F_EPSILON`. + c. `execute_hooks(part_frame_at_index, direction)` queues every + matching `CAnimHook` (direction 0 = both, else must match caller's + `1`=forward / `-1`=reverse) onto the owning `CPhysicsObj.anim_hooks` + array — NOT executed inline. +4. When a node's frame range is exhausted (`frame_number` strictly past + `get_ending_frame()`/`get_starting_frame()`), BEFORE calling + `advance_to_next_animation`: if the OLD list head has already been + fully consumed (`head != first_cyclic`), `AnimDoneHook` (the global + singleton) is queued directly onto `anim_hooks` — this is the signal + that eventually calls `CPartArray::AnimationDone(1)` / + `MovementManager::MotionDone` once drained. +5. `advance_to_next_animation` performs the un-apply/select-next/apply + pose sequence (§23), wrapping forward exhaustion to `first_cyclic` + (loop the cycle) or reverse exhaustion to the list tail. +6. Control returns to `update_internal`'s outer loop with the LEFTOVER + elapsed time (`remaining`, the fractional overshoot past the old + boundary converted back to a time delta via `/ node_framerate`), + which may immediately trigger ANOTHER frame-crossing pass against + the NEW `curr_anim` in the same `update_internal` call — i.e. a + single `CSequence::update()` call can transition through several + queued animation nodes in one tick if `elapsed` is large enough + (a slow frame / lag spike can legitimately fast-forward through + multiple short one-shot transition nodes in one physics step). +7. After `update_internal` returns (list still non-empty), `apricot()` + frees every node strictly BEFORE the (possibly new) `curr_anim`, + bounded so it never deletes into `first_cyclic`'s cyclic tail. +8. All queued `anim_hooks` (frame hooks + any `AnimDoneHook`) are + actually EXECUTED later, once per physics tick, by + `CPhysicsObj::process_hooks` — a separate call NOT inside + `CSequence` at all, invoked by the owning `CPhysicsObj`'s update + path — and the `anim_hooks` array is fully drained (`m_num = 0`) + after every execute pass. diff --git a/docs/research/2026-07-02-r1-csequence/r1-gap-map.md b/docs/research/2026-07-02-r1-csequence/r1-gap-map.md new file mode 100644 index 00000000..54cc5890 --- /dev/null +++ b/docs/research/2026-07-02-r1-csequence/r1-gap-map.md @@ -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). diff --git a/src/AcDream.Core/Physics/Motion/AnimSequenceNode.cs b/src/AcDream.Core/Physics/Motion/AnimSequenceNode.cs new file mode 100644 index 00000000..e7c80588 --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/AnimSequenceNode.cs @@ -0,0 +1,159 @@ +using System; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.Core.Physics.Motion; + +/// +/// R1-P1 — verbatim port of retail's AnimSequenceNode (Phase R plan +/// `docs/plans/2026-07-02-retail-motion-animation-rewrite.md`, stage R1; +/// oracle `docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md` +/// §25-28). +/// +/// One node of a CSequence's animation list: a resolved dat +/// plus a playable frame window +/// (..) and a signed +/// whose SIGN is retail's playback-direction flag. +/// +/// Retail semantics preserved exactly (gap-map items G1/G2/G16/G18): +/// +/// The boundary pair ( / +/// ) is direction-aware and returns BARE +/// integers — retail has NO epsilon here (0x00525c80/0x00525cb0). ACE's +/// PhysicsGlobals.EPSILON subtraction compensates for ACE's own +/// float FrameNumber and must not be copied (P0-pins.md). +/// SWAPS and +/// when the factor is negative (0x00525be0) — +/// coupled with the boundary pair's framerate < 0 test. +/// clamps in retail's exact order +/// (0x00525d60): high<0 → num−1; low≥num → num−1; high≥num → num−1; +/// low>high → high=low. +/// returns null out of range +/// (retail; ACE's identity-frame return is an ACE-ism). +/// +/// +/// Unlike the legacy AnimationSequencer.AnimNode, retail nodes carry +/// NO per-node IsLooping/Velocity/Omega — loop membership is list structure +/// (first_cyclic) and physics accumulators live on the sequence +/// (G16). +/// +public sealed class AnimSequenceNode +{ + /// Resolved dat animation, or null (id 0 / missing). + public Animation? Anim { get; private set; } + + /// + /// Frames per second; NEGATIVE means reverse playback (retail's + /// direction flag). Default 30f (0x00525d30). + /// + public float Framerate = 30f; + + /// Inclusive window low bound. Default −1 (0x00525d30). + public int LowFrame = -1; + + /// Inclusive window high bound; −1 = "to the end" sentinel + /// resolved by . Default −1. + public int HighFrame = -1; + + public bool HasAnim => Anim is not null; + + /// Default ctor — retail defaults (0x00525d30). + public AnimSequenceNode() + { + } + + /// + /// Ctor from a MotionData entry (0x00525f90): + /// copy framerate/low/high, then resolve + clamp via + /// . + /// + public AnimSequenceNode(AnimData animData, IAnimationLoader loader) + { + Framerate = animData.Framerate; + LowFrame = animData.LowFrame; + HighFrame = animData.HighFrame; + SetAnimationId((uint)animData.AnimId, loader); + } + + /// + /// Resolve the dat animation and clamp the frame window + /// (0x00525d60). The clamp block runs only when an animation resolved; + /// order is retail-exact. + /// + public void SetAnimationId(uint animId, IAnimationLoader loader) + { + Anim = animId == 0 ? null : loader.LoadAnimation(animId); + + if (Anim is null) + return; + + int numFrames = Anim.PartFrames.Count; + + if (HighFrame < 0) + HighFrame = numFrames - 1; + if (LowFrame >= numFrames) + LowFrame = numFrames - 1; + if (HighFrame >= numFrames) + HighFrame = numFrames - 1; + if (LowFrame > HighFrame) + HighFrame = LowFrame; + } + + /// + /// Direction-aware starting boundary (0x00525c80): reverse playback + /// starts at high_frame + 1, forward at low_frame. + /// BARE int — no epsilon (G1). + /// + public int GetStartingFrame() => Framerate < 0f ? HighFrame + 1 : LowFrame; + + /// + /// Direction-aware ending boundary (0x00525cb0): reverse ends at + /// low_frame, forward at high_frame + 1. BARE int. + /// + public int GetEndingFrame() => Framerate < 0f ? LowFrame : HighFrame + 1; + + /// + /// Scale playback rate (0x00525be0): a NEGATIVE factor swaps + /// low/high before multiplying — the swapped fields plus the + /// now-negative framerate are how retail encodes reversed windows. + /// + public void MultiplyFramerate(float factor) + { + if (factor < 0f) + { + (LowFrame, HighFrame) = (HighFrame, LowFrame); + } + Framerate *= factor; + } + + /// + /// Root-motion frame at a double position (0x005247b0): floor then the + /// int overload. + /// + public Frame? GetPosFrame(double frameNumber) + => GetPosFrame((int)Math.Floor(frameNumber)); + + /// + /// Root-motion frame by index (0x00525c10): null when no animation, + /// index out of range, or the animation carries no PosFrames. + /// + public Frame? GetPosFrame(int index) + { + if (Anim is null || index < 0 || index >= Anim.PartFrames.Count) + return null; + if (Anim.PosFrames is null || index >= Anim.PosFrames.Count) + return null; + return Anim.PosFrames[index]; + } + + /// + /// Skeletal part frame by index — same bounds discipline as + /// . + /// + public AnimationFrame? GetPartFrame(int index) + { + if (Anim is null || index < 0 || index >= Anim.PartFrames.Count) + return null; + return Anim.PartFrames[index]; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/AnimSequenceNodeTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/AnimSequenceNodeTests.cs new file mode 100644 index 00000000..2452d153 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/AnimSequenceNodeTests.cs @@ -0,0 +1,209 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R1-P1 — verbatim AnimSequenceNode (Phase R plan, gap-map items +/// G1/G2/G16/G18). Oracle: r1-csequence-decomp.md §25-28 (ctors 0x00525d30 / +/// 0x00525f90, set_animation_id 0x00525d60, get_starting_frame 0x00525c80, +/// get_ending_frame 0x00525cb0, multiply_framerate 0x00525be0, get_pos_frame +/// 0x005247b0 / 0x00525c10). +/// +/// KEY RETAIL SEMANTICS UNDER TEST: +/// - boundary pair is DIRECTION-AWARE and returns BARE INTS with NO epsilon +/// (ACE's epsilon subtraction is an ACE fabrication — P0-pins.md); +/// - multiply_framerate SWAPS low/high on a negative factor; +/// - set_animation_id clamps in a fixed order (high<0 → num−1; +/// low≥num → num−1; high≥num → num−1; low>high → high=low). +/// +public class AnimSequenceNodeTests +{ + private sealed class FakeLoader : IAnimationLoader + { + private readonly Animation? _anim; + public FakeLoader(Animation? anim) => _anim = anim; + public Animation? LoadAnimation(uint id) => _anim; + } + + private static Animation MakeAnim(int numFrames, bool posFrames = false) + { + var anim = new Animation(); + for (int f = 0; f < numFrames; f++) + { + var pf = new AnimationFrame(1u); + pf.Frames.Add(new Frame { Origin = new Vector3(f, 0, 0), Orientation = Quaternion.Identity }); + anim.PartFrames.Add(pf); + if (posFrames) + anim.PosFrames.Add(new Frame { Origin = new Vector3(0, f, 0), Orientation = Quaternion.Identity }); + } + return anim; + } + + [Fact] + public void DefaultCtor_RetailDefaults() + { + // 0x00525d30: framerate=30f, low=-1, high=-1, anim=null. + var n = new AnimSequenceNode(); + Assert.Equal(30f, n.Framerate); + Assert.Equal(-1, n.LowFrame); + Assert.Equal(-1, n.HighFrame); + Assert.False(n.HasAnim); + } + + [Fact] + public void SetAnimationId_Zero_LeavesAnimNullAndFramesUnclamped() + { + // 0x00525d60: arg2==0 → anim=null; the clamp block is gated on + // anim != null, so low/high stay at their raw values. + var n = new AnimSequenceNode(); + n.SetAnimationId(0, new FakeLoader(MakeAnim(10))); + Assert.False(n.HasAnim); + Assert.Equal(-1, n.LowFrame); + Assert.Equal(-1, n.HighFrame); + } + + [Theory] + // low, high (pre-clamp), numFrames, expectedLow, expectedHigh + [InlineData(0, -1, 10, 0, 9)] // high<0 → num-1 ("play to end") + [InlineData(12, -1, 10, 9, 9)] // high<0 first, then low>=num → num-1 + [InlineData(3, 15, 10, 3, 9)] // high>=num → num-1 + [InlineData(15, 15, 10, 9, 9)] // both clamp to num-1 + [InlineData(5, 2, 10, 5, 5)] // low>high after clamps → high=low + [InlineData(2, 7, 10, 2, 7)] // in-range untouched + public void SetAnimationId_ClampOrder(int low, int high, int numFrames, int expLow, int expHigh) + { + var n = new AnimSequenceNode { LowFrame = low, HighFrame = high }; + n.SetAnimationId(0x0300ABCDu, new FakeLoader(MakeAnim(numFrames))); + Assert.True(n.HasAnim); + Assert.Equal(expLow, n.LowFrame); + Assert.Equal(expHigh, n.HighFrame); + } + + [Theory] + // framerate, low, high, expectedStart, expectedEnd — BARE INTS, no epsilon + [InlineData(30f, 2, 7, 2, 8)] // forward: start=low, end=high+1 + [InlineData(-30f, 2, 7, 8, 2)] // reverse: start=high+1, end=low + [InlineData(0f, 2, 7, 2, 8)] // zero framerate is NOT < 0 → forward + [InlineData(30f, 0, 0, 0, 1)] // single-frame forward + [InlineData(-30f, 0, 0, 1, 0)] // single-frame reverse + public void BoundaryPair_DirectionAware_BareInts( + float framerate, int low, int high, int expStart, int expEnd) + { + var n = new AnimSequenceNode { Framerate = framerate, LowFrame = low, HighFrame = high }; + Assert.Equal(expStart, n.GetStartingFrame()); + Assert.Equal(expEnd, n.GetEndingFrame()); + } + + [Fact] + public void MultiplyFramerate_Positive_NoSwap() + { + var n = new AnimSequenceNode { Framerate = 30f, LowFrame = 2, HighFrame = 7 }; + n.MultiplyFramerate(2f); + Assert.Equal(60f, n.Framerate); + Assert.Equal(2, n.LowFrame); + Assert.Equal(7, n.HighFrame); + } + + [Fact] + public void MultiplyFramerate_Negative_SwapsLowHigh() + { + // 0x00525be0: factor < 0 → swap(low_frame, high_frame), then + // framerate *= factor. Coupled with the direction-aware boundary + // pair (framerate now negative reads the swapped fields). + var n = new AnimSequenceNode { Framerate = 30f, LowFrame = 2, HighFrame = 7 }; + n.MultiplyFramerate(-1f); + Assert.Equal(-30f, n.Framerate); + Assert.Equal(7, n.LowFrame); + Assert.Equal(2, n.HighFrame); + // Reverse playback boundaries against the SWAPPED fields: + // start = high_frame(2)+1 = 3?? — no: framerate<0 → start = high+1 + // where high_frame is now 2 → 3; end = low_frame = 7. + Assert.Equal(3, n.GetStartingFrame()); + Assert.Equal(7, n.GetEndingFrame()); + } + + [Fact] + public void MultiplyFramerate_DoubleNegative_RoundTrips() + { + var n = new AnimSequenceNode { Framerate = 30f, LowFrame = 2, HighFrame = 7 }; + n.MultiplyFramerate(-1f); + n.MultiplyFramerate(-1f); + Assert.Equal(30f, n.Framerate); + Assert.Equal(2, n.LowFrame); + Assert.Equal(7, n.HighFrame); + } + + [Fact] + public void GetPosFrame_NullAnim_ReturnsNull() + { + var n = new AnimSequenceNode(); + Assert.Null(n.GetPosFrame(0)); + } + + [Fact] + public void GetPosFrame_OutOfRange_ReturnsNull() + { + // 0x00525c10: retail returns NULL out of range (ACE returns identity + // — an ACE-ism, gap G18: port the null). + var n = new AnimSequenceNode(); + n.SetAnimationId(1, new FakeLoader(MakeAnim(3, posFrames: true))); + Assert.Null(n.GetPosFrame(-1)); + Assert.Null(n.GetPosFrame(3)); + Assert.NotNull(n.GetPosFrame(2)); + } + + [Fact] + public void GetPosFrame_DoubleOverload_Floors() + { + // 0x005247b0: floor(double) → int overload. + var n = new AnimSequenceNode(); + n.SetAnimationId(1, new FakeLoader(MakeAnim(3, posFrames: true))); + var f = n.GetPosFrame(1.99); + Assert.NotNull(f); + Assert.Equal(1f, f!.Origin.Y); // frame index 1, not 2 + } + + [Fact] + public void GetPosFrame_AnimWithoutPosFrames_ReturnsNull() + { + var n = new AnimSequenceNode(); + n.SetAnimationId(1, new FakeLoader(MakeAnim(3, posFrames: false))); + Assert.Null(n.GetPosFrame(0)); + } + + [Fact] + public void GetPartFrame_BoundsAndValue() + { + var n = new AnimSequenceNode(); + n.SetAnimationId(1, new FakeLoader(MakeAnim(3))); + Assert.Null(n.GetPartFrame(-1)); + Assert.Null(n.GetPartFrame(3)); + var pf = n.GetPartFrame(2); + Assert.NotNull(pf); + Assert.Equal(2f, pf!.Frames[0].Origin.X); + } + + [Fact] + public void CtorFromAnimData_CopiesThenClamps() + { + // 0x00525f90: copies framerate/low/high from AnimData then runs + // set_animation_id (which clamps against the resolved anim). + QualifiedDataId qid = 0x03001234u; + var ad = new AnimData + { + AnimId = qid, + LowFrame = 0, + HighFrame = -1, + Framerate = 15f, + }; + var n = new AnimSequenceNode(ad, new FakeLoader(MakeAnim(5))); + Assert.Equal(15f, n.Framerate); + Assert.Equal(0, n.LowFrame); + Assert.Equal(4, n.HighFrame); // -1 sentinel clamped to num-1 + Assert.True(n.HasAnim); + } +}