acdream/docs/research/2026-07-02-r1-csequence/r1-ace-sequence.md
Erik 1371c2a14c feat(R1-P0/P1): CSequence research base + verbatim AnimSequenceNode
P0 — research + pins: full CSequence-family verbatim extraction (1756
lines, per-function raw pseudo-C + cleaned flow, decomp line anchors),
ACE cross-reference (9 ranked divergences; headline: retail frame_number
is x87 long double — ACE's float is the worst case, our double the best
available; ACE's frame-boundary epsilon is an ACE fabrication, NOT
retail), current-sequencer map, and the R1 gap map (20 gaps, 13 keeps,
P0-P6 port order). Pinned the one decomp ambiguity (leftover-time carry
after advance_to_next_animation — ACE reading adopted; cdb confirmation
protocol recorded, non-blocking).

P1 — AnimSequenceNode verbatim (gap G1/G2/G16/G18):
- direction-aware BARE-INT boundary pair (get_starting_frame 0x00525c80 /
  get_ending_frame 0x00525cb0): reverse starts at high+1, ends at low —
  NO epsilon;
- multiply_framerate (0x00525be0) swaps low/high on negative factor;
- set_animation_id (0x00525d60) retail clamp order (high<0 -> num-1;
  low>=num -> num-1; high>=num -> num-1; low>high -> high=low);
- ctors with retail defaults (30f/-1/-1; AnimData copy + clamp);
- get_pos_frame null out-of-range (retail; ACE returns identity),
  floor double overload; get_part_frame same discipline;
- NO per-node IsLooping/Velocity/Omega — loop membership is list
  structure, physics accumulators live on the sequence (G16).

22 conformance tests (clamp table, boundary mirror table, swap
round-trip, bounds/floor semantics).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:45:56 +02:00

583 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

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