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

40 KiB
Raw Blame History

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_animationCSequence::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.000199999995f0.0002f = ACE.Server.Physics.PhysicsGlobals.EPSILON (references/ACE/Source/ACE.Server/Physics/PhysicsGlobals.cs:9). Confirmed identical constant.

append_animationCSequence::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 += Velocityquantum; frame.Rotate(Omegaquantum);— exact match. All arithmetic in retail runs atlong 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):

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):

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.

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).

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):

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):

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:

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):

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):

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.