feat(R1-P0/P1): CSequence research base + verbatim AnimSequenceNode

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-02 19:45:56 +02:00
parent cae56afc82
commit 1371c2a14c
7 changed files with 3363 additions and 0 deletions

View file

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