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>
583 lines
40 KiB
Markdown
583 lines
40 KiB
Markdown
# 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.
|