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>
40 KiB
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 30747CSequence, line 31063AnimSequenceNode).
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)thenmemset(&frame_number, 0, 0x18)— i.e. zeroesframe_number,curr_anim,placement_frame,placement_frame_id,bIsTrivialin one sweep. Velocity/omega/first_cyclic/hook_obj are zeroed by the first memset (part ofanim_list+first_cyclic+velocity+omega+hook_objspan, 0x28 bytes). - ACE
Init(): setsVelocity = Zero,Omega = Zero,FrameNumber = 0.0f,AnimList = new(). Does not resetCurrAnim,FirstCyclic,HookObj,PlacementFrame,PlacementFrameID,IsTrivial— those are left at C# default (null/0) only becauseInit()is only ever called from the ctor in practice. Faithful for the ctor path; would silently diverge ifInit()were ever called again on a live Sequence (retail's memset always re-zeros everything; ACE'sInit()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): ifframe != null && currAnim.Framerate < 0(i.e. finishing a reverse-playing anim), subtractget_pos_frame(frameNum), apply_physics with1/framerateif|framerate| > EPSILON. Then advanceanimNodeto.Nextor wrap toFirstCyclic.frameNum = currAnim.get_starting_frame(). Ifframe != null && framerate > 0(starting a forward-playing anim), combine pos_frame, apply_physics. ACE'sadvance_to_next_animation(Sequence.cs:145) matches line-for-line, including theMath.Abs(currAnim.Framerate) > PhysicsGlobals.EPSILONguards onapply_physics. - Backward branch (
timeElapsed < 0): mirror image — subtract whenframerate >= 0(finishing forward-playing), step to.Previousor wrap toList.Last,frameNum = get_ending_frame(), combine whenframerate < 0(starting reverse-playing). ACE matches. - EPSILON constant used for
|framerate|compares: retail literal0.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 += Velocityquantum; frame.Rotate(Omegaquantum);— exact match. All arithmetic in retail runs atlong double; ACE atfloat`. 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.
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):
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)
FrameNumber/frame_number:float(ACE) vslong double/80-bit-extended (retail). Pervasive — affects every frame-boundary comparison inupdate_internal,advance_to_next_animation,apply_physics. Highest-impact, hardest to fix in C# (no native 80-bit float type;doubleis the closest available and still not bit-exact to retail).AnimSequenceNode.get_starting_frame()/get_ending_frame()subtractPhysicsGlobals.EPSILONfromHighFrame + 1in 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.EPSILONterm) if porting fresh.AnimSequenceNode()parameterless ctor:LowFrame=0(ACE) vslow_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.AnimData()parameterless ctor: all-zero defaults (ACE) vslow_frame=0, high_frame=-1, framerate=30f(retail). Same dormant-but-real-mismatch profile as #3.AnimSequenceNode.get_pos_framereturns identityAFrameon failure (ACE) vsnullpointer (retail). Functionally converges to a no-op in practice given how call sites guard invocation; textual divergence only.- ACE's
Clear()additionally nullsPlacementFrame/PlacementFrameID, which retail's own 2-instructionclear()body does NOT do — that reset actually belongs to retail's separateUnPackfunction. Scope conflation, not a behavioral bug, but worth knowing which retail function ACE'sClear()is really modeling. 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.- Retail's
execute_hookshas a latent null-deref ifget_part_framereturns null for an out-of-range frame index (no null check beforearg2->hooks); ACE'sanimFrame == nullguard avoids this crash class — a safe defensive divergence, not something to "fix" toward retail. AnimSequenceNode::set_animation_idclamp-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 literal0.000199999995f, ACEPhysicsGlobals.EPSILON) — used identically inadvance_to_next_animation,update_internal,apply_physicsguards, andchange_cycle_speed.AFrame/Framestruct size = 28 bytes (0x1c) = Vector3(12) + Quaternion(16), confirmed viaAnimSequenceNode::get_pos_frame'sarg2 * 0x1cindex stride.AnimSequenceNodestruct layout:CAnimation* anim; float framerate; int low_frame; int high_frame;— exact field-for-field match with ACE's C# class (types included).CSequencestruct layout (acclient.h:30747): confirmsframe_numberis genuinelylong double, not a decompiler artifact — this is the verbatim retail header, authoritative.