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>
70 KiB
CSequence family — verbatim retail decomp extraction
Source: docs/research/named-retail/acclient_2013_pseudo_c.txt (PDB-named,
Sept 2013 EoR build, x87 32-bit) + docs/research/named-retail/acclient.h
(verbatim retail struct layouts). Line numbers below are grep -n line
anchors into acclient_2013_pseudo_c.txt as of this extraction.
All code quoted is the tool's literal x87-mangled pseudo-C output
(FCMP_UO / floor / unimplemented {fld ...} comments and all) — this is
what the decompiler actually emitted; the "Cleaned control flow" sections
underneath translate it into readable pseudocode without changing any
comparison direction, boundary constant, or branch order.
0. Struct layouts (verbatim from acclient.h)
CSequence (acclient.h:30747, PDB record 3267)
struct __cppobj CSequence : PackObj
{
DLList<AnimSequenceNode> anim_list; // +0x00 head_, +0x04 tail_ (DLListBase)
AnimSequenceNode *first_cyclic; // +0x08 (per line 30750; see note below)
AC1Legacy::Vector3 velocity; // linear velocity accumulator
AC1Legacy::Vector3 omega; // angular velocity accumulator
CPhysicsObj *hook_obj; // owning physics object (fires hooks through this)
long double frame_number; // x87 80-bit extended — CURRENT FRACTIONAL FRAME
AnimSequenceNode *curr_anim; // node actively playing
AnimFrame *placement_frame; // static override frame when anim_list is empty
unsigned int placement_frame_id;
int bIsTrivial;
};
frame_number is declared long double (x87 extended, 80-bit) — NOT a
double. This is why every comparison against it in the decomp goes
through the raw FPU compare/FCMP_UO sequence rather than a SSE ucomisd.
CPartArray (acclient.h:30762) — where CSequence lives
struct __cppobj CPartArray
{
unsigned int pa_state;
CPhysicsObj *owner;
CSequence sequence; // EMBEDDED, not a pointer
MotionTableManager *motion_table_manager;
CSetup *setup;
unsigned int num_parts;
CPhysicsPart **parts;
AC1Legacy::Vector3 scale;
Palette **pals;
LIGHTLIST *lights;
AnimFrame *last_animframe;
};
AnimSequenceNode (acclient.h:31063, PDB record 3266)
struct __cppobj AnimSequenceNode : PackObj, DLListData
{
CAnimation *anim;
float framerate; // signed — negative = playing this node in reverse
int low_frame; // inclusive start frame index (forward-orientation)
int high_frame; // inclusive end frame index (forward-orientation)
};
DLListData (base) supplies dllist_next / dllist_prev. The node
object itself is allocated at dllist_data_ptr - 4 (the "ADJ" / - 4
adjustment seen throughout the list-splice code is converting between a
DLListData* and the owning AnimSequenceNode*, i.e. DLListData is
the FIRST base and the node is 4 bytes past it — actually the compiler
emits ((char*)head_ - 4) meaning the AnimSequenceNode base starts 4
bytes BEFORE the DLListData sub-object, so DLListData is inherited
second / offset +4 inside the node).
AnimFrame (acclient.h:31072, PDB record 3264)
struct __cppobj AnimFrame
{
AFrame *frame;
unsigned int num_frame_hooks;
CAnimHook *hooks; // singly-linked hook chain for THIS frame
unsigned int num_parts;
};
CAnimHook (acclient.h:30973, PDB record 3262)
struct __cppobj CAnimHook
{
CAnimHookVtbl *vfptr;
CAnimHook *next_hook;
int direction_; // 0 = fire on either direction; else must match exactly
};
AnimDoneHook : CAnimHook (acclient.h:57557) is an empty subclass —
pure dispatch marker, Execute() calls CPhysicsObj::Hook_AnimDone.
Constants (verbatim, multiple identical copies in the data segment)
F_EPSILON = 0.000199999995f (acclient.h data; ~0.0002f)
data_794610 (double) = 0x0000000000000000 (0.0)
data_7928c0 (double) = 0x3ff0000000000000 (1.0)
1. CSequence::CSequence — ctor (line 300891, addr 0x005249f0)
00524a30... wait — ctor is 005249f0
00524a30 // actually printed at 300891:
005249f0 void __fastcall CSequence::CSequence(class CSequence* this)
{
this->vtable = 0x7c84d8;
__builtin_memset(&this->anim_list, 0, 0x28);
__builtin_memset(&this->frame_number, 0, 0x18);
}
Cleaned: zero-fills anim_list (0x28 = 40 bytes, covers anim_list +
first_cyclic + velocity + omega + hook_obj), then zero-fills the
tail 0x18 = 24 bytes starting at frame_number (covers frame_number
[10 bytes as long double, padded] + curr_anim + placement_frame +
placement_frame_id). frame_number starts at exactly 0.0.
2. CSequence::~CSequence — dtor (line 300901, addr 0x00524a30)
00524a30 void __fastcall CSequence::~CSequence(class CSequence* this)
{
bool cond:0 = this->anim_list.head_ == 0;
this->vtable = 0x7c84d8;
if (!(cond:0))
{
do
{
class DLListData* head_ = this->anim_list.head_;
if (head_ != 0)
{
class DLListData* dllist_prev = head_->dllist_prev;
if (dllist_prev == 0)
{
class DLListData* dllist_next = head_->dllist_next;
this->anim_list.head_ = dllist_next;
if (dllist_next != 0)
dllist_next->dllist_prev = nullptr;
}
else
dllist_prev->dllist_next = head_->dllist_next;
class DLListData* dllist_next_1 = head_->dllist_next;
if (dllist_next_1 == 0)
{
class DLListData* dllist_prev_1 = this->anim_list.tail_->dllist_prev;
this->anim_list.tail_ = dllist_prev_1;
if (dllist_prev_1 != 0)
dllist_prev_1->dllist_next = 0;
}
else
dllist_next_1->dllist_prev = head_->dllist_prev;
head_->dllist_next = 0;
head_->dllist_prev = nullptr;
if ((head_ != 0 && head_ != 4))
*(uint32_t*)head_->dllist_next(1); // AnimSequenceNode::`scalar deleting destructor`(1)
}
} while (this->anim_list.head_ != 0);
}
this->vtable = 0x79285c; // PackObj vtable (base slice)
}
Cleaned: pop-and-delete every node from anim_list.head_ until empty
(unlink from doubly-linked list, then call the node's scalar deleting
destructor with delete-flag=1).
3. CSequence::clear (line 301828, addr 0x005255b0)
005255b0 void __fastcall CSequence::clear(class CSequence* this)
{
CSequence::clear_animations(this);
CSequence::clear_physics(this);
this->placement_frame = nullptr;
this->placement_frame_id = 0;
}
4. CSequence::clear_physics (line 301194, addr 0x00524d50)
00524d50 void __fastcall CSequence::clear_physics(class CSequence* this)
{
this->velocity.x = 0;
this->velocity.y = 0f;
this->velocity.z = 0f;
this->omega.x = 0;
this->omega.y = 0f;
this->omega.z = 0f;
}
5. CSequence::clear_animations (line 301207, addr 0x00524dc0)
00524dc0 void __fastcall CSequence::clear_animations(class CSequence* this)
{
while (this->anim_list.head_ != 0)
{
class DLListData* head_ = this->anim_list.head_;
if (head_ != 0)
{
class DLListData* dllist_prev = head_->dllist_prev;
if (dllist_prev == 0)
{
class DLListData* dllist_next = head_->dllist_next;
this->anim_list.head_ = dllist_next;
if (dllist_next != 0)
dllist_next->dllist_prev = nullptr;
}
else
dllist_prev->dllist_next = head_->dllist_next;
class DLListData* dllist_next_1 = head_->dllist_next;
if (dllist_next_1 == 0)
{
class DLListData* dllist_prev_1 = this->anim_list.tail_->dllist_prev;
this->anim_list.tail_ = dllist_prev_1;
if (dllist_prev_1 != 0)
dllist_prev_1->dllist_next = 0;
}
else
dllist_next_1->dllist_prev = head_->dllist_prev;
head_->dllist_next = 0;
head_->dllist_prev = nullptr;
if ((head_ != 0 && head_ != 4))
*(uint32_t*)head_->dllist_next(1); // node dtor+delete
}
}
this->first_cyclic = nullptr;
this->frame_number = 0f;
*(uint32_t*)((char*)this->frame_number)[4] = 0; // (high dword of the long double, zeroed too)
this->curr_anim = nullptr;
}
Cleaned: identical unlink-and-delete loop over the WHOLE list (same
pattern as the dtor), then resets first_cyclic = null,
frame_number = 0.0, curr_anim = null. This is the full "wipe the
sequence back to empty" operation, distinct from remove_cyclic_anims
below which only removes the cyclic tail.
6. CSequence::remove_cyclic_anims (line 301258, addr 0x00524e40)
00524e40 void __fastcall CSequence::remove_cyclic_anims(class CSequence* this)
{
class CSequence* this_1 = this;
class AnimSequenceNode* first_cyclic_1;
for (class AnimSequenceNode* first_cyclic = this->first_cyclic; first_cyclic != 0; first_cyclic = first_cyclic_1)
{
if (this->curr_anim == first_cyclic)
{
class AnimSequenceNode* eax_1 = AnimSequenceNode::GetPrev(first_cyclic);
this->curr_anim = eax_1;
if (eax_1 == 0)
{
this->frame_number = 0f;
*(uint32_t*)((char*)this->frame_number)[4] = 0;
}
else
this->frame_number = ((double)AnimSequenceNode::get_ending_frame(eax_1));
}
first_cyclic_1 = AnimSequenceNode::GetNext(first_cyclic);
class DLListData** eax_2;
if (first_cyclic == 0)
eax_2 = nullptr;
else
eax_2 = &first_cyclic->dllist_next;
class DLListData* dllist_prev = ADJ(eax_2)->dllist_prev;
if (dllist_prev == 0)
{
class DLListData* dllist_next = this->anim_list.head_->dllist_next;
this->anim_list.head_ = dllist_next;
if (dllist_next != 0)
dllist_next->dllist_prev = nullptr;
}
else
dllist_prev->dllist_next = ADJ(eax_2)->dllist_next;
class DLListData* dllist_next_1 = ADJ(eax_2)->dllist_next;
if (dllist_next_1 == 0)
{
class DLListData* dllist_prev_1 = this->anim_list.tail_->dllist_prev;
this->anim_list.tail_ = dllist_prev_1;
if (dllist_prev_1 != 0)
dllist_prev_1->dllist_next = 0;
}
else
dllist_next_1->dllist_prev = ADJ(eax_2)->dllist_prev;
ADJ(eax_2)->dllist_next = nullptr;
ADJ(eax_2)->dllist_prev = nullptr;
if (first_cyclic != 0)
first_cyclic->vtable->__vecDelDtor(1);
}
class DLListData* tail_ = this->anim_list.tail_;
if (tail_ != 0)
{
this->first_cyclic = ((char*)tail_ - 4);
return;
}
this->first_cyclic = nullptr;
}
Cleaned: walks from first_cyclic to the END of the list, unlinking
and deleting EVERY node from first_cyclic onward (the "cyclic" tail —
i.e. the looping animation(s) queued after the one-shot transition
animations). If curr_anim was one of the removed nodes, curr_anim
snaps back to the PREVIOUS node (the last non-cyclic node) and
frame_number is set to that previous node's get_ending_frame() (or
0.0 if there is no previous node at all). After the sweep,
first_cyclic is reset to point at the new tail of the list (tail_ - 4), or null if the list is now empty.
7. CSequence::remove_link_animations (line 301060, addr 0x00524be0)
00524be0 void __thiscall CSequence::remove_link_animations(class CSequence* this, uint32_t arg2)
{
int32_t ebp = 0;
if (arg2 > 0)
{
do
{
if (AnimSequenceNode::GetPrev(this->first_cyclic) == 0)
break;
if (AnimSequenceNode::GetPrev(this->first_cyclic) == this->curr_anim)
{
class AnimSequenceNode* first_cyclic = this->first_cyclic;
this->curr_anim = first_cyclic;
if (first_cyclic != 0)
this->frame_number = ((double)AnimSequenceNode::get_starting_frame(first_cyclic));
}
class AnimSequenceNode* eax_2 = AnimSequenceNode::GetPrev(this->first_cyclic);
class DLListData** edx_1;
if (eax_2 == 0)
edx_1 = nullptr;
else
edx_1 = &eax_2->dllist_next;
// unlink eax_2 (the node immediately before first_cyclic) from anim_list
class DLListData* dllist_prev = ADJ(edx_1)->dllist_prev;
if (dllist_prev == 0)
{
class DLListData* dllist_next = this->anim_list.head_->dllist_next;
this->anim_list.head_ = dllist_next;
if (dllist_next != 0)
dllist_next->dllist_prev = nullptr;
}
else
dllist_prev->dllist_next = ADJ(edx_1)->dllist_next;
class DLListData* dllist_next_1 = ADJ(edx_1)->dllist_next;
if (dllist_next_1 == 0)
{
class DLListData* dllist_prev_1 = this->anim_list.tail_->dllist_prev;
this->anim_list.tail_ = dllist_prev_1;
if (dllist_prev_1 != 0)
dllist_prev_1->dllist_next = 0;
}
else
dllist_next_1->dllist_prev = ADJ(edx_1)->dllist_prev;
ADJ(edx_1)->dllist_next = nullptr;
ADJ(edx_1)->dllist_prev = nullptr;
if (eax_2 != 0)
eax_2->vtable->__vecDelDtor(1);
ebp += 1;
} while (ebp < arg2);
}
}
Cleaned: removes arg2 (count) "link" nodes — nodes chained
IMMEDIATELY BEFORE first_cyclic (i.e. the linked/one-shot animations
queued ahead of the cyclic tail) — one at a time, working backward from
first_cyclic's predecessor. If the removed node was curr_anim,
curr_anim is force-advanced to first_cyclic and frame_number reset
to that node's get_starting_frame(). Stops early if there's no
predecessor left (GetPrev(first_cyclic) == 0).
8. CSequence::remove_all_link_animations (line 301128, addr 0x00524ca0)
00524ca0 void __fastcall CSequence::remove_all_link_animations(class CSequence* this)
{
class AnimSequenceNode* first_cyclic = this->first_cyclic;
if ((first_cyclic != 0 && AnimSequenceNode::GetPrev(first_cyclic) != 0))
{
class AnimSequenceNode* i;
do
{
if (AnimSequenceNode::GetPrev(this->first_cyclic) == this->curr_anim)
{
class AnimSequenceNode* first_cyclic_1 = this->first_cyclic;
this->curr_anim = first_cyclic_1;
if (first_cyclic_1 != 0)
this->frame_number = ((double)AnimSequenceNode::get_starting_frame(first_cyclic_1));
}
class AnimSequenceNode* eax_3 = AnimSequenceNode::GetPrev(this->first_cyclic);
// ... identical unlink-and-delete of eax_3 as remove_link_animations ...
if (eax_3 != 0)
eax_3->vtable->__vecDelDtor(1);
i = AnimSequenceNode::GetPrev(this->first_cyclic);
} while (i != 0);
}
}
Cleaned: identical to remove_link_animations but loops until
GetPrev(first_cyclic) == 0 (i.e. removes ALL link nodes, not a fixed
count).
9. CSequence::has_anims (line 301050, addr 0x00524bd0)
00524bd0 int32_t __fastcall CSequence::has_anims(class CSequence const* this)
{
int32_t result;
result = this->anim_list.head_ != 0;
return result;
}
10. CSequence::set_velocity (line 300798, addr 0x00524880)
00524880 void __thiscall CSequence::set_velocity(class CSequence* this, class AC1Legacy::Vector3 const* arg2)
{
this->velocity.x = arg2->x;
this->velocity.y = arg2->y;
this->velocity.z = arg2->z;
}
11. CSequence::set_omega (line 300808, addr 0x005248a0)
005248a0 void __thiscall CSequence::set_omega(class CSequence* this, class AC1Legacy::Vector3 const* arg2)
{
this->omega.x = arg2->x;
this->omega.y = arg2->y;
this->omega.z = arg2->z;
}
12. CSequence::combine_physics (line 300818, addr 0x005248c0)
005248c0 void __thiscall CSequence::combine_physics(class CSequence* this, class AC1Legacy::Vector3 const* arg2, class AC1Legacy::Vector3 const* arg3)
{
float* eax = arg2;
this->velocity.x = ((float)(((long double)this->velocity.x) + ((long double)*(uint32_t*)eax)));
this->velocity.y = ((float)(((long double)eax[1]) + ((long double)this->velocity.y)));
this->velocity.z = ((float)(((long double)eax[2]) + ((long double)this->velocity.z)));
this->omega.x = ((float)(((long double)this->omega.x) + ((long double)arg3->x)));
this->omega.y = ((float)(((long double)arg3->y) + ((long double)this->omega.y)));
this->omega.z = ((float)(((long double)arg3->z) + ((long double)this->omega.z)));
}
Cleaned: velocity += arg2; omega += arg3; (element-wise), all math
promoted through x87 long double and truncated back to float per
component (retail's usual FP-widen-then-narrow pattern; NOT a
bit-identical no-op — matters for exact conformance tests).
13. CSequence::subtract_physics (line 300832, addr 0x00524900)
00524900 void __thiscall CSequence::subtract_physics(class CSequence* this, class AC1Legacy::Vector3 const* arg2, class AC1Legacy::Vector3 const* arg3)
{
float* eax = arg2;
this->velocity.x = ((float)(((long double)this->velocity.x) - ((long double)*(uint32_t*)eax)));
this->velocity.y = ((float)(((long double)this->velocity.y) - ((long double)eax[1])));
this->velocity.z = ((float)(((long double)this->velocity.z) - ((long double)eax[2])));
this->omega.x = ((float)(((long double)this->omega.x) - ((long double)arg3->x)));
this->omega.y = ((float)(((long double)this->omega.y) - ((long double)arg3->y)));
this->omega.z = ((float)(((long double)this->omega.z) - ((long double)arg3->z)));
}
velocity -= arg2; omega -= arg3; — mirror of combine_physics.
14. CSequence::multiply_cyclic_animation_fr (line 300846, addr 0x00524940)
Note: the task prompt's "multiply_cyclic_animation_framerate" is this
function; the PDB-recovered name is truncated to multiply_cyclic_animation_fr
(28-char Windows debug-symbol truncation on some builds).
00524940 void __thiscall CSequence::multiply_cyclic_animation_fr(class CSequence* this, float arg2)
{
for (class AnimSequenceNode* first_cyclic = this->first_cyclic; first_cyclic != 0; first_cyclic = AnimSequenceNode::GetNext(first_cyclic))
AnimSequenceNode::multiply_framerate(first_cyclic, arg2);
}
Walks from first_cyclic to the tail (the cyclic/looping portion of
the sequence) and multiplies EVERY node's framerate by arg2. Used
elsewhere (line 298286/298295 call sites) to retarget a cyclic motion's
playback rate — e.g. scaling Walk_Forward's framerate to match the
character's actual movement speed:
298286 CSequence::multiply_cyclic_animation_fr(arg1, ((float)(((long double)arg4) / ((long double)arg3))));
298295 CSequence::multiply_cyclic_animation_fr(arg1, 0f);
14a. AnimSequenceNode::multiply_framerate (line 302425, addr 0x00525be0)
00525be0 void __thiscall AnimSequenceNode::multiply_framerate(class AnimSequenceNode* this, float arg2)
{
long double x87_r7 = ((long double)arg2);
long double temp1 = ((long double)0f);
(x87_r7 - temp1);
int32_t eax;
eax = ((((x87_r7 < temp1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7, temp1))) ? 1 : 0) << 0xa) | ((((x87_r7 == temp1) ? 1 : 0) << 0xe) | 0))));
bool p = /* test ah, 0x5 -> "arg2 < 0.0" (unordered-safe less-than) */;
if (!(p))
{
int32_t low_frame = this->low_frame;
this->low_frame = this->high_frame;
this->high_frame = low_frame;
}
this->framerate = ((float)(((long double)arg2) * ((long double)this->framerate)));
}
Cleaned:
if (arg2 < 0.0f)
swap(low_frame, high_frame);
framerate *= arg2;
(NOTE the branch condition polarity: the raw x87 test is test ah,0x5
on a compare of arg2 vs 0.0; the !(p) wrapping the swap makes the
swap fire when arg2 < 0.0 is TRUE — i.e. multiplying by a NEGATIVE
factor swaps low/high frame, consistent with get_starting_frame/
get_ending_frame below which key off framerate < 0 to decide
playback direction.)
15. CSequence::get_curr_animframe (line 300855, addr 0x00524970)
(This is retail's "get current animation frame" accessor — the closest
named function to the task's "get_curr_animation"; there is no separate
CSequence::get_curr_animation symbol in the PDB.)
00524970 class AnimFrame const* __fastcall CSequence::get_curr_animframe(class CSequence const* this)
{
class AnimSequenceNode* curr_anim = this->curr_anim;
if (curr_anim == 0)
return this->placement_frame;
int32_t eax = this->frame_number;
int32_t ecx = *(uint32_t*)((char*)this->frame_number)[4];
int32_t var_8 = eax;
int32_t var_4 = ecx;
floor(eax, ecx);
return AnimSequenceNode::get_part_frame(curr_anim, _ftol2());
}
Cleaned:
if (curr_anim == null)
return placement_frame;
return curr_anim.get_part_frame((int)floor(frame_number));
If there is no active animation node, the sequence renders whatever
static placement_frame was set (see set_placement_frame below).
Otherwise it floors the fractional frame_number to an integer frame
index and asks the current node for that frame's part-transform data.
16. CSequence::set_placement_frame (line 300872, addr 0x005249b0)
005249b0 void __thiscall CSequence::set_placement_frame(class CSequence* this, class AnimFrame const* arg2, uint32_t arg3)
{
this->placement_frame = arg2;
this->placement_frame_id = arg3;
}
Trivial setter for the two placement fields; called elsewhere (line
287517, 287550) with nullptr, 0 to clear placement, or a live
AnimFrame* + id to set a static pose (e.g. for objects with no
animation, "placement_frame_id" ~ 0x65 seen in the older function
map's field notes).
17. CSequence::get_curr_frame_number (line 300881, addr 0x005249d0)
005249d0 uint32_t __fastcall CSequence::get_curr_frame_number(class CSequence const* this)
{
floor(this->frame_number, *(uint32_t*)((char*)this->frame_number)[4]);
return _ftol2();
}
return (uint32_t)floor(frame_number); — integer frame index for the
current fractional position (used by CPartArray::get_curr_frame_number
callers at line 300779/300781 in CPartArray).
18. CSequence::execute_hooks — hook dispatch (line 300780, addr 0x00524830)
00524830 void __thiscall CSequence::execute_hooks(class CSequence const* this, class AnimFrame const* arg2, int32_t arg3)
{
if (this->hook_obj != 0)
{
for (class CAnimHook* i = arg2->hooks; i != 0; i = i->next_hook)
{
int32_t direction_ = i->direction_;
if ((direction_ == 0 || arg3 == direction_))
CPhysicsObj::add_anim_hook(this->hook_obj, i);
}
}
}
Hook-dispatch mechanics (verbatim): execute_hooks(frame, direction)
walks the singly-linked hooks chain attached to a SPECIFIC AnimFrame
(a frame within the current animation's part_frames array — see
AnimFrame struct, field hooks). Each CAnimHook node carries a
direction_ filter:
direction_ == 0→ fires regardless of playback direction (both forward and reverse frame-crossings queue it).direction_ != 0→ only fires when the CALLER'Sarg3direction matches exactly.
The actual call sites in update_internal (see §21 below) pass:
arg3 = 1when crossing a frame FORWARD (line 302189:CSequence::execute_hooks(this, AnimSequenceNode::get_part_frame(*arg3, ebx_2), 1))arg3 = 0xffffffff(i.e.-1) when crossing a frame BACKWARD (line 302039:CSequence::execute_hooks(this, AnimSequenceNode::get_part_frame(*arg3, ebx_1), 0xffffffff))
So a hook registered with direction_ = 1 fires only on forward frame
crossings, one with direction_ = -1 (0xffffffff) fires only on
backward crossings, and direction_ = 0 fires on both. Matched hooks
are NOT executed immediately — they are appended (CPhysicsObj::add_anim_hook,
line 282906, 0x00514c20) to the owning CPhysicsObj's anim_hooks
SmartArray, which is drained once per physics tick by
CPhysicsObj::process_hooks (line 279431, 0x00511550):
00511550 void __fastcall CPhysicsObj::process_hooks(class CPhysicsObj* this)
{
// ... first drains a SEPARATE linked list `this->hooks` (PhysicsObjHook*)
// — one-shot script/PES/transparency hooks, unrelated to CAnimHook ...
uint32_t m_num = this->anim_hooks.m_num;
if (m_num > 0)
{
int32_t i = 0;
if (m_num > 0)
{
do
{
this->anim_hooks.m_data[i]->vtable->Execute(this);
i += 1;
} while (i < this->anim_hooks.m_num);
}
AC1Legacy::SmartArray<CAnimHook *>::shrink(&this->anim_hooks);
this->anim_hooks.m_num = 0;
}
}
So the full pipeline per crossed frame is: execute_hooks queues
matching CAnimHook* pointers into anim_hooks (append-only, growable
SmartArray, doubles capacity from a base of 8 via grow()) →
process_hooks later executes EVERY queued hook via its vtable
Execute(CPhysicsObj*) and then resets m_num = 0 (queue is drained
completely every call, shrink() just trims capacity bookkeeping).
18a. AnimDoneHook — the animation-complete hook (line 302227/302223 call site + 303832 Execute)
00526c20 void __stdcall AnimDoneHook::Execute(class AnimDoneHook const* this @ ecx, class CPhysicsObj* arg2)
{
CPhysicsObj::Hook_AnimDone(arg2);
}
0050fda0 void __fastcall CPhysicsObj::Hook_AnimDone(class CPhysicsObj* this)
{
class CPartArray* part_array = this->part_array;
if (part_array != 0)
CPartArray::AnimationDone(part_array, 1);
}
AnimDoneHook is a GLOBAL singleton instance (class AnimDoneHook anim_done_hook at data address 0x0081d9fc, vtable installed at
line 901343 0x007681f0) — NOT allocated per-node. It has no
per-instance direction_/frame association; it's queued directly by
update_internal (not via execute_hooks/per-frame hooks chain) when
a node transition consumes the LAST node in the list (see §21, the
"leading edge" check). This is retail's MotionDone signal path:
AnimDoneHook::Execute → CPhysicsObj::Hook_AnimDone →
CPartArray::AnimationDone(part_array, 1).
19. CSequence::apply_physics (line 300955, addr 0x00524ab0)
00524ab0 void __thiscall CSequence::apply_physics(class CSequence const* this, class Frame* arg2, double arg3, double arg4)
{
long double x87_r7 = ((long double)arg4);
long double temp1 = ((long double)0.0);
(x87_r7 - temp1);
long double x87_r7_2 = fabsl(((long double)arg3));
if ((*(uint8_t*)((char*)((((x87_r7 < temp1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7, temp1))) ? 1 : 0) << 0xa) | ((((x87_r7 == temp1) ? 1 : 0) << 0xe) | 0x3800)))))[1] & 1) != 0)
x87_r7_2 = -(x87_r7_2);
arg2->m_fOrigin.x = ((float)((x87_r7_2 * ((long double)this->velocity.x)) + ((long double)arg2->m_fOrigin.x)));
arg2->m_fOrigin.y = ((float)(((long double)((float)(x87_r7_2 * ((long double)this->velocity.y)))) + ((long double)arg2->m_fOrigin.y)));
arg2->m_fOrigin.z = ((float)(((long double)((float)(x87_r7_2 * ((long double)this->velocity.z)))) + ((long double)arg2->m_fOrigin.z)));
long double x87_r5_5 = (x87_r7_2 * ((long double)this->omega.y));
float var_10_1 = ((float)(x87_r7_2 * ((long double)this->omega.z)));
float var_18 = ((float)(x87_r7_2 * ((long double)this->omega.x)));
float var_14_1 = ((float)x87_r5_5);
Frame::rotate(arg2, &var_18);
}
Cleaned:
CSequence::apply_physics(this, Frame* frame, double quantum, double sign_source):
signed_quantum = fabs(quantum)
if (sign_source < 0.0) // sign copied from arg4, magnitude from arg3
signed_quantum = -signed_quantum
frame.m_fOrigin += velocity * signed_quantum // per-component
frame.rotate( omega * signed_quantum ) // Vector3(omega.x,omega.y,omega.z) * signed_quantum
This is copysign(fabs(quantum_magnitude), sign_source) — i.e. the
function takes the MAGNITUDE from arg3 and the SIGN from arg4
(this matches every call site passing 1.0/framerate for the magnitude
and the raw signed frameRate/arg2 (elapsed-time-with-direction) as
the sign source — see §21). The result scales BOTH the accumulated
linear velocity (added into the frame's origin) and the accumulated
angular omega (fed into Frame::rotate) by the same signed quantum.
Frame::rotate signature (line 91477, 0x004525b0):
void Frame::rotate(Frame* this, AC1Legacy::Vector3 const* arg2) —
takes the omega*quantum vector and applies it as an incremental
rotation to the frame's orientation.
20. CSequence::apricot — trim consumed nodes (line 300978, addr 0x00524b40)
(Retail's actual internal name for this function IS apricot — verified
via the PDB symbol table, not a placeholder.)
00524b40 void __fastcall CSequence::apricot(class CSequence* this)
{
class DLListData* head_ = this->anim_list.head_;
void* __offset(DLListData, -0x4) i;
if (head_ == 0)
i = nullptr;
else
i = ((char*)head_ - 4);
if (i != this->curr_anim)
{
int32_t ebx;
int32_t var_c_1 = ebx;
while (i != this->first_cyclic)
{
class DLListData* eax;
if (i == 0)
eax = nullptr;
else
eax = ((char*)i + 4);
// unlink `eax` (the head node) from anim_list
class DLListData* dllist_prev = eax->dllist_prev;
if (dllist_prev == 0)
{
class DLListData* dllist_next = this->anim_list.head_->dllist_next;
this->anim_list.head_ = dllist_next;
if (dllist_next != 0)
dllist_next->dllist_prev = nullptr;
}
else
dllist_prev->dllist_next = eax->dllist_next;
class DLListData* dllist_next_1 = eax->dllist_next;
if (dllist_next_1 == 0)
{
class DLListData* dllist_prev_1 = this->anim_list.tail_->dllist_prev;
this->anim_list.tail_ = dllist_prev_1;
if (dllist_prev_1 != 0)
dllist_prev_1->dllist_next = 0;
}
else
dllist_next_1->dllist_prev = eax->dllist_prev;
eax->dllist_next = 0;
eax->dllist_prev = nullptr;
if (i != 0)
*(uint32_t*)ADJ(i)->dllist_next(1); // delete head node
class DLListData* head__1 = this->anim_list.head_;
if (head__1 == 0)
i = nullptr;
else
i = ((char*)head__1 - 4);
if (i == this->curr_anim)
break;
}
}
}
Cleaned:
CSequence::apricot():
head = anim_list.head (as AnimSequenceNode*)
if (head == curr_anim)
return // nothing consumed yet, no trim needed
while (head != first_cyclic):
// unlink+delete `head` from anim_list
delete_and_unlink(head)
head = anim_list.head (new head, as AnimSequenceNode*)
if (head == curr_anim)
break
Called at the end of every CSequence::update (see §22), immediately
after update_internal advances curr_anim. Its job is to free every
node BEFORE curr_anim that has already fully played and been popped
— it walks from the OLD list head forward, deleting nodes one at a time,
until it reaches either curr_anim (stop — that node is still live) or
first_cyclic (stop — do not delete into the cyclic tail even if
curr_anim has somehow moved past it, a defensive bound).
21. CSequence::update_internal — the core per-frame advance loop (line 301839, addr 0x005255d0)
Signature: CSequence::update_internal(CSequence const* this, double arg2, AnimSequenceNode** arg3, double* arg4, Frame* arg5)
this— the sequencearg2— SIGNED elapsed time this tick (positive = forward playback request, negative = reverse)arg3=&this->curr_anim(in/out — current node pointer)arg4=&this->frame_number(in/out — fractional frame position, passed as adouble*even though the underlying field islong double; the field is read/written throughfloor(lo,hi)pairs which address it as two 32-bit halves — the x87 extended representation)arg5= destinationFrame*to accumulate physics into (ornullif the caller only wants the frame counter advanced, no motion applied — seeCSequence::update's no-arg5-branch fallback)
The raw pseudo-C for this function is almost entirely FPU compare
soup (each conditional branch lowers to an fcom/fcomp/fcompp
against data_794610 [0.0] or F_EPSILON [0.0002f], captured as a
software-emulated FPU status word test ((c0<<8)|(c2<<10)|(c3<<14))
then test ah, 0x41/0x5). Two call-site name mismatches from
vtable-slot devirtualization noise are corrected below:
- Every call site the decompiler labeled
MD_Data_Fade::GetDuration(node)(lines 301633, 301647, 301657, 301680, 301696, 301706, 301725, 301735, 301745, 301759, 301769) is a virtual call throughAnimSequenceNode's vtable at theframerate-comparison slot — cross-checked againstAnimSequenceNode::get_starting_frame/get_ending_frame(§26/27 below), whose boolean logic (framerate < 0.0branch, then returnhigh_frame+1orlow_frame) is EXACTLY what every one of these call sites' surrounding code does immediately afterward.MD_Data_Fadeis an unrelatedMediaDesc-family class (acclient.h:34176) whose realGetDurationtakes incompatible arguments — a decompiler address-collision mislabel, not a real call target. Read these sites asnode->framerate(raw field access via a trivial inline getter), not a call to media-fade duration. - Similarly
EffectInfoRegion::GetStat(node)(lines 301917, 301931, 301979) andAttribute2ndInfoRegion::GetStat(node)(lines 302064, 302079, 302129) are mislabeled — by call-site position and use (assigned intovar_28_1then immediately combined withAnimSequenceNode::get_pos_frame/get_part_frameand frame-index arithmetic) these areAnimSequenceNode::get_starting_frame(node)/AnimSequenceNode::get_ending_frame(node)respectively — same address-collision artifact as above (EffectInfoRegion/Attribute2ndInfoRegion::GetStatare real functions elsewhere in the binary, lines 244494/245331, unrelated chat/stat classes).
Cleaned control flow (semantics-preserving translation)
CSequence::update_internal(seq, elapsed, &curr_anim, &frame_number, frame):
loop:
node_framerate = curr_anim.framerate // AnimSequenceNode::framerate (signed)
delta = elapsed * node_framerate // signed frame-step for this tick
old_frame_idx = floor(frame_number) // integer frame BEFORE advancing
new_pos = frame_number + delta
frame_number = new_pos
remaining = 0.0 // leftover elapsed time after a boundary hit
hit_boundary = false // "var_30_1" — did we cross curr_anim's end?
if (delta < 0.0): // ── REVERSE playback ──
// boundary = curr_anim.get_starting_frame() (mislabeled EffectInfoRegion::GetStat call)
boundary = curr_anim.get_starting_frame()
if (frame_number < boundary): // crossed/undershot the start
if (frame != null):
if (curr_anim.anim.pos_frames != 0)
Frame::subtract1(frame, frame, curr_anim.get_pos_frame((int)frame_number))
if (fabs(node_framerate) >= F_EPSILON) // 0.000199999995f
CSequence::apply_physics(this, frame, 1.0 / node_framerate, elapsed)
// (return early here — the "p_5"/"return" branch, line 301911:
// if the compare says frame_number was not < 0.0 relative to F_EPSILON
// test, execute_hooks/advance is skipped this call — degenerate
// micro-step case)
return
// fire per-frame REVERSE hooks/velocity from old_frame_idx down to
// (but not below) floor(frame_number), highest frame first:
idx = old_frame_idx
do:
if (frame != null):
if (curr_anim.anim.pos_frames != 0)
Frame::subtract1(frame, frame, curr_anim.get_pos_frame(idx))
if (fabs(<per-frame quantum>) >= F_EPSILON):
CSequence::apply_physics(this, frame, 1.0/node_framerate, elapsed)
CSequence::execute_hooks(this, curr_anim.get_part_frame(idx), 0xffffffff) // direction = -1 (backward)
idx -= 1
while (idx > floor(frame_number))
// Node exhausted going backward → boundary crossed
// (var_30_1 = 1 set here; falls through to the "advance" tail)
hit_boundary = true
else if (delta > 0.0): // ── FORWARD playback ──
// boundary = curr_anim.get_ending_frame() (mislabeled Attribute2ndInfoRegion::GetStat call)
boundary = curr_anim.get_ending_frame()
if (frame_number > boundary): // crossed/overshot the end
// same early-apply-then-return shape as the reverse case,
// using Frame::combine instead of Frame::subtract1:
if (frame != null):
if (curr_anim.anim.pos_frames != 0)
Frame::combine(frame, frame, curr_anim.get_pos_frame((int)frame_number))
if (fabs(node_framerate) >= F_EPSILON)
CSequence::apply_physics(this, frame, 1.0 / node_framerate, elapsed)
return
// fire per-frame FORWARD hooks/velocity from old_frame_idx up to
// (but not past) floor(frame_number), lowest frame first:
idx = old_frame_idx
do:
if (frame != null):
if (curr_anim.anim.pos_frames != 0)
Frame::combine(frame, frame, curr_anim.get_pos_frame(idx))
if (fabs(<per-frame quantum>) >= F_EPSILON):
CSequence::apply_physics(this, frame, 1.0/node_framerate, elapsed)
CSequence::execute_hooks(this, curr_anim.get_part_frame(idx), 1) // direction = +1 (forward)
idx += 1
while (idx < floor(frame_number))
hit_boundary = true
else: // delta == 0.0 (node_framerate == 0 or elapsed == 0)
return // nothing to do this tick
// ── boundary crossed: advance to the next queued animation ──
if (hit_boundary == false):
return
hook_obj = this->hook_obj
if (hook_obj != null):
// if the OLD list head has already been fully consumed and we are
// NOT still inside the "cyclic tail" region, queue AnimDoneHook —
// signals MotionDone to the owning weenie/entity:
list_head_node = (anim_list.head_ != null) ? (anim_list.head_ - 4) : null
if (list_head_node != this->first_cyclic):
CPhysicsObj::add_anim_hook(hook_obj, &anim_done_hook)
CSequence::advance_to_next_animation(this, elapsed, &curr_anim, &frame_number, frame)
elapsed = remaining // carry the leftover time (past the boundary) into the next loop pass
goto loop
Verbatim structural notes preserved from the raw decomp:
- The degenerate-boundary early-return (reverse case around line
301885-301911, forward mirror around line 302008-301912-ish) is a
REAL early exit distinct from the main per-frame loop — it only fires
when the SINGLE-STEP position already lands past the boundary on the
FIRST comparison (i.e.
deltaalone overshoots in one step); in that branchapply_physics/position-combine happens ONCE using the raw1.0/node_frameratequantum and the function returns WITHOUT callingadvance_to_next_animationor firingexecute_hooksat all for that tick — becausearg5(frame)==null guards the whole inner block, so if no destination Frame was supplied, this early-return path does literally nothing but return. - The per-frame do/while loops (lines 302006-302056 forward; the
reverse mirror is symmetric) always execute the position-combine +
apply_physics+execute_hookstriple for EVERY whole frame index crossed this tick, not just the final one — multi-frame skips (largeelapsed/deltavalues, e.g. a lag spike) fire ALL intermediate frame hooks in order, matching the task's requirement to "note which hooks fire on which frame crossings": every crossed integer frame fires itsAnimFrame.hookschain filtered by direction (1forward /-1(0xffffffff) reverse) viaexecute_hooks, in strict ascending (forward) or descending (reverse) frame order. Frame::combine(forward) vsFrame::subtract1(reverse) — these are NOT symmetric operations;combinecomposes the animation's stored per-frameAFrame(quaternion + origin) INTO the destination frame (i.e. applies the pose),subtract1un-applies (backs out) that same pose. This is retail's mechanism for incremental per-frame pose application driven purely by boundary-crossing bookkeeping — the actual skeletal pose comes fromcurr_anim.get_pos_frame(idx)(root/AFrame-level) andget_part_frame(idx)(per-partAnimFrame) tables baked into theCAnimationdat resource, NOT from interpolatingframe_number's fractional part at render time inside this function (rendering interpolation happens elsewhere, inPartArray::SetFrame/UpdateParts, offget_curr_animframe's floored index).CSequence::execute_hooksis called with the PART frame (AnimSequenceNode::get_part_frame(idx), anAnimFrame const*), NOT the pos frame — hooks live onAnimFrame.hooks, keyed per animation part-frame index, exactly matching theAnimFramestruct'shooksfield.- Boundary detection uses
>/<(strict) againstget_ending_frame()/get_starting_frame(), meaningframe_numberis allowed to sit EXACTLY AT the boundary value without triggering an advance — the advance only fires once the position strictly exceeds it. Combined withget_ending_frame()returninghigh_frame + 1 - ε-ish (see §27, actuallyhigh_frame+1verbatim, no explicit epsilon subtraction visible in the recovered code — the epsilon appears in the F_EPSILON velocity-magnitude gates, not the frame boundary values themselves) this is how retail avoids double-firing the last frame's hooks.
22. CSequence::update — public per-tick entry point (line 302402, addr 0x00525b80)
00525b80 void __thiscall CSequence::update(class CSequence* this, double arg2, class Frame* arg3)
{
if (this->anim_list.head_ != 0)
{
int32_t var_14_1 = *(uint32_t*)((char*)arg2)[4];
CSequence::update_internal(this, arg2, &this->curr_anim, &this->frame_number, arg3);
CSequence::apricot(this);
return;
}
if (arg3 != 0)
{
int32_t eax_3 = *(uint32_t*)((char*)arg2)[4];
int32_t ecx_4 = arg2;
int32_t var_8_2 = eax_3;
int32_t var_10_2 = eax_3;
CSequence::apply_physics(this, arg3, ecx_4, ecx_4);
}
}
Cleaned:
CSequence::update(this, double elapsed, Frame* frame):
if (anim_list.head_ != null):
update_internal(this, elapsed, &curr_anim, &frame_number, frame)
apricot(this) // trim already-consumed leading nodes
return
// no queued animations at all — pure physics-only motion (e.g. free-fall,
// knockback velocity with no animation playing)
if (frame != null):
apply_physics(this, frame, elapsed, elapsed) // magnitude == sign source == elapsed
This is THE per-tick call site (PartArray::Update in the older
function-map cross-reference, FUN_005188e0/named
CSequence::update wrapper) — every physics tick that has an active
animation list goes through update_internal then immediately
apricots the consumed leading nodes; a sequence with an EMPTY
animation list (e.g. between transitions, or an object with no motion
table) falls through to a bare apply_physics call so accumulated
velocity/omega (e.g. from combine_physics calls, jump/knockback)
still moves the frame even with nothing animating.
23. CSequence::advance_to_next_animation (line 301622, addr 0x005252b0)
Signature: advance_to_next_animation(CSequence const* this, double arg2, AnimSequenceNode const** arg3, double* arg4, Frame* arg5) — arg2 is
the signed elapsed/rate value carried over from update_internal
(same sign convention: negative = reverse).
005252b0 void __thiscall CSequence::advance_to_next_animation(class CSequence const* this, double arg2, class AnimSequenceNode const** arg3, double* arg4, class Frame* arg5)
{
class CSequence* this_1 = this;
long double x87_r7 = ((long double)arg2);
long double temp1 = ((long double)0.0);
(x87_r7 - temp1);
class AnimSequenceNode* ecx = *(uint32_t*)arg3;
if (/* arg2 < 0.0 */)
{
// ── REVERSE: step to the PREVIOUS node ──
if (!(/* node.get_ending_frame's framerate<0 branch NOT taken, i.e. degenerate small-duration guard */) && arg5 != 0)
{
class AnimSequenceNode* ecx_16 = *(uint32_t*)arg3;
if (ecx_16->anim->pos_frames != 0)
Frame::subtract1(arg5, arg5, AnimSequenceNode::get_pos_frame(ecx_16, *(uint32_t*)arg4));
if (fabs(node.framerate) >= F_EPSILON) // 0.000199999995f
CSequence::apply_physics(this, arg5, ((double)(((long double)1.0) / node.framerate)), arg2);
}
// move to predecessor, or wrap to the list tail if there is none
class AnimSequenceNode* eax_17;
if (AnimSequenceNode::GetPrev(*(uint32_t*)arg3) == 0)
{
class DLListData* tail_ = this->anim_list.tail_;
eax_17 = (tail_ == 0) ? nullptr : ((char*)tail_ - 4);
}
else
eax_17 = AnimSequenceNode::GetPrev(*(uint32_t*)arg3);
*(uint32_t*)arg3 = eax_17; // curr_anim = eax_17
*(uint64_t*)arg4 = ((double)AnimSequenceNode::get_ending_frame(eax_17)); // frame_number = new node's ending frame
if (!(/* degenerate small-duration guard */) && arg5 != 0)
{
class AnimSequenceNode* ecx_26 = *(uint32_t*)arg3;
if (ecx_26->anim->pos_frames != 0)
Frame::combine(arg5, arg5, AnimSequenceNode::get_pos_frame(ecx_26, *(uint32_t*)arg4));
if (fabs(node.framerate) >= F_EPSILON)
CSequence::apply_physics(this, arg5, ((double)(((long double)1.0) / node.framerate)), arg2);
}
}
else
{
// ── FORWARD: step to the NEXT node, wrapping to first_cyclic at the tail ──
if (!(/* degenerate small-duration guard */) && arg5 != 0)
{
class AnimSequenceNode* ecx_1 = *(uint32_t*)arg3;
if (ecx_1->anim->pos_frames != 0)
Frame::subtract1(arg5, arg5, AnimSequenceNode::get_pos_frame(ecx_1, *(uint32_t*)arg4));
if (fabs(node.framerate) >= F_EPSILON)
CSequence::apply_physics(this, arg5, ((double)(((long double)1.0) / node.framerate)), arg2);
}
if (AnimSequenceNode::GetNext(*(uint32_t*)arg3) == 0)
*(uint32_t*)arg3 = this->first_cyclic; // wrap to first_cyclic when list exhausted
else
*(uint32_t*)arg3 = AnimSequenceNode::GetNext(*(uint32_t*)arg3);
*(uint64_t*)arg4 = ((double)AnimSequenceNode::get_starting_frame(*(uint32_t*)arg3)); // frame_number = new node's starting frame
if (/* extra 0x41-mask condition — degenerate guard with an extra bit vs the mirrored branches above */ && arg5 != 0)
{
class AnimSequenceNode* ecx_12 = *(uint32_t*)arg3;
if (ecx_12->anim->pos_frames != 0)
Frame::combine(arg5, arg5, AnimSequenceNode::get_pos_frame(ecx_12, *(uint32_t*)arg4));
if (fabs(node.framerate) >= F_EPSILON)
CSequence::apply_physics(this, arg5, ((double)(((long double)1.0) / node.framerate)), arg2);
}
}
}
Cleaned:
CSequence::advance_to_next_animation(this, elapsed, &curr_anim, &frame_number, frame):
if (elapsed < 0.0): // REVERSE
// (a) un-apply the outgoing node's pose at its current frame_number,
// and roll its residual velocity/omega into `frame`, UNLESS this
// is a zero-duration/degenerate node
if (frame != null && duration(curr_anim) != 0.0):
if (curr_anim.anim.pos_frames != 0)
Frame::subtract1(frame, frame, curr_anim.get_pos_frame((int)frame_number))
if (fabs(curr_anim.framerate) >= F_EPSILON)
CSequence::apply_physics(this, frame, 1.0 / curr_anim.framerate, elapsed)
// (b) step to the PREVIOUS node in the list; if there is no
// predecessor, wrap around to the LIST TAIL
prev = GetPrev(curr_anim)
curr_anim = (prev != null) ? prev : (anim_list.tail_ != null ? tail_node : null)
frame_number = curr_anim.get_ending_frame()
// (c) apply the INCOMING node's pose at its new frame_number
if (frame != null && duration(curr_anim) != 0.0):
if (curr_anim.anim.pos_frames != 0)
Frame::combine(frame, frame, curr_anim.get_pos_frame((int)frame_number))
if (fabs(curr_anim.framerate) >= F_EPSILON)
CSequence::apply_physics(this, frame, 1.0 / curr_anim.framerate, elapsed)
else: // FORWARD (elapsed >= 0.0)
// (a) un-apply the outgoing node's pose
if (frame != null && duration(curr_anim) != 0.0):
if (curr_anim.anim.pos_frames != 0)
Frame::subtract1(frame, frame, curr_anim.get_pos_frame((int)frame_number))
if (fabs(curr_anim.framerate) >= F_EPSILON)
CSequence::apply_physics(this, frame, 1.0 / curr_anim.framerate, elapsed)
// (b) step to the NEXT node; if there is none, WRAP TO first_cyclic
// (this is the "loop the cyclic tail forever" mechanism)
next = GetNext(curr_anim)
curr_anim = (next != null) ? next : first_cyclic
frame_number = curr_anim.get_starting_frame()
// (c) apply the incoming node's pose
if (frame != null && duration(curr_anim) != 0.0): // + one extra mask bit vs (a)/reverse-(c) — same epsilon-style guard
if (curr_anim.anim.pos_frames != 0)
Frame::combine(frame, frame, curr_anim.get_pos_frame((int)frame_number))
if (fabs(curr_anim.framerate) >= F_EPSILON)
CSequence::apply_physics(this, frame, 1.0 / curr_anim.framerate, elapsed)
Key retail-faithful details:
- Forward wrap target is
first_cyclic, NOT the list head. When the last node in the forward chain is exhausted, playback loops back tofirst_cyclic— the boundary marker between one-shot "link" animations (queued ahead of the loop, consumed once and freed viaapricot) and the actual repeating cycle. This is THE mechanism that makes e.g. Walk_Forward loop forever while a one-shot Jump transition plays once and falls through. - Reverse wrap target is the LIST TAIL, not
first_cyclic— reverse playback (used for backing out of a motion, e.g. an interrupted transition) wraps to the very end of the queued list, not to the cyclic boundary. Asymmetric by design. - Every node transition does FOUR pose operations in sequence:
un-apply-outgoing (
subtract1/reverse-mirror), select-new-node, apply-incoming (combine), interleaved withapply_physicscalls using1.0 / node.framerateas the physics quantum's magnitude and the ORIGINAL caller'selapsed/arg2as the sign source (matchingapply_physics'scopysignsemantics from §19). - The three inline "degenerate guard" conditions (reverse-out,
reverse-in, forward-out, forward-in) are the SAME F_EPSILON-style FPU
compare pattern seen throughout — a duration/framerate near-zero
check that skips the pos-frame combine/subtract entirely when the
node's timing is degenerate (e.g. a 1-frame or 0-duration transition
node), a guard against divide-by-near-zero when computing
1.0/node.framerate.
24. CSequence::append_animation (line 301777, addr 0x00525510)
00525510 void __thiscall CSequence::append_animation(class CSequence* this, class AnimData const* arg2)
{
void* eax = operator new(0x1c);
int32_t* esi;
if (eax == 0)
esi = nullptr;
else
esi = AnimSequenceNode::AnimSequenceNode(eax, arg2);
if (AnimSequenceNode::has_anim(esi) != 0)
{
void* eax_3;
if (esi == 0)
eax_3 = nullptr;
else
eax_3 = &esi[1];
DLListBase::InsertAfter(&this->anim_list, eax_3, this->anim_list.tail_);
class DLListData* tail_ = this->anim_list.tail_;
void* __offset(DLListData, -0x4) eax_4;
if (tail_ == 0)
eax_4 = nullptr;
else
eax_4 = ((char*)tail_ - 4);
this->first_cyclic = eax_4;
if (this->curr_anim == 0)
{
void* head_ = this->anim_list.head_;
if (head_ != 0)
{
this->curr_anim = ((char*)head_ - 4);
this->frame_number = ((double)AnimSequenceNode::get_starting_frame(((char*)head_ - 4)));
return;
}
this->curr_anim = nullptr;
this->frame_number = ((double)AnimSequenceNode::get_starting_frame(nullptr));
}
}
else if (esi != 0)
**(uint32_t**)esi(1); // node had no anim data — self-destruct (scalar deleting dtor, delete=1)
}
Cleaned:
CSequence::append_animation(this, AnimData const* data):
node = new AnimSequenceNode(data) // heap alloc 0x1c bytes; ctor copies framerate/low_frame/high_frame/anim_id from `data`
if (node.has_anim()): // node.anim != null (DBObj::Get succeeded)
anim_list.InsertAfter(node, anim_list.tail_) // append at the tail
first_cyclic = anim_list.tail_ // ALWAYS repoints first_cyclic to the JUST-APPENDED node
if (curr_anim == null):
if (anim_list.head_ != null):
curr_anim = anim_list.head_
frame_number = curr_anim.get_starting_frame()
return
curr_anim = null
frame_number = AnimSequenceNode::get_starting_frame(null) // degenerate/default-framerate(30) starting-frame value
else:
delete node // failed to resolve the anim dat resource — discard
Critical retail-faithful detail: first_cyclic is updated to the
NEWLY APPENDED node on EVERY successful append_animation call, not
just the first. This means the "cyclic tail" boundary always tracks
the LAST node appended so far — i.e. calling append_animation
multiple times in sequence (as a transition chain builder does — e.g.
"exit-substate, transition, new-stance" in sequence) keeps sliding
first_cyclic forward to the newest node, so only the FINAL
append_animation call in a chain-build actually establishes the
node(s) that will be treated as the looping cycle once earlier
one-shot nodes are trimmed by remove_cyclic_anims/consumed by
advance_to_next_animation's forward-wrap.
25. AnimSequenceNode::AnimSequenceNode ctors (lines 302547 & 302744)
Default ctor (line 302547, addr 0x00525d30)
00525d30 void __fastcall AnimSequenceNode::AnimSequenceNode(class AnimSequenceNode* this)
{
this->dllist_next = nullptr;
this->dllist_prev = nullptr;
this->anim = nullptr;
this->vtable = 0x7c8504;
this->framerate = 30f;
this->low_frame = 0xffffffff;
this->high_frame = 0xffffffff;
}
From AnimData (line 302744, addr 0x00525f90)
00525f90 void __thiscall AnimSequenceNode::AnimSequenceNode(class AnimSequenceNode* this, class AnimData const* arg2)
{
this->dllist_next = nullptr;
this->dllist_prev = nullptr;
this->anim = nullptr;
this->vtable = 0x7c8504;
this->framerate = arg2->framerate;
this->low_frame = arg2->low_frame;
this->high_frame = arg2->high_frame;
AnimSequenceNode::set_animation_id(this, arg2->anim_id.id);
}
AnimSequenceNode::set_animation_id (line 302561, addr 0x00525d60)
00525d60 void __thiscall AnimSequenceNode::set_animation_id(class AnimSequenceNode* this, class IDClass<_tagDataID,32,0> arg2)
{
class CAnimation* anim_1 = this->anim;
if (anim_1 != 0)
anim_1->vtable->Release();
void var_8;
if (arg2 == 0)
this->anim = nullptr;
else
this->anim = DBObj::Get(QualifiedDataID::QualifiedDataID(&var_8, arg2, 8));
class CAnimation* anim = this->anim;
if (anim != 0)
{
if (this->high_frame < 0)
this->high_frame = (anim->num_frames - 1);
uint32_t num_frames_1 = anim->num_frames;
if (this->low_frame >= num_frames_1)
this->low_frame = (num_frames_1 - 1);
uint32_t num_frames = anim->num_frames;
if (this->high_frame >= num_frames)
this->high_frame = (num_frames - 1);
int32_t low_frame = this->low_frame;
if (low_frame > this->high_frame)
this->high_frame = low_frame;
}
}
Cleaned: resolves the dat animation resource (type-8 qualified DBObj
lookup) by anim_id, then CLAMPS low_frame/high_frame into
[0, anim.num_frames-1]:
high_frame < 0(the ctor default0xffffffff==-1when interpreted signed) → clamp tonum_frames - 1(i.e. "play to the end").low_frame >= num_frames→ clamp tonum_frames - 1.high_frame >= num_frames→ clamp tonum_frames - 1.- if after clamping
low_frame > high_frame, forcehigh_frame = low_frame(degenerate single-frame range, never inverted).
AnimData::AnimData default ctor (line 302519, addr 0x00525ce0)
confirms the DEFAULT values a fresh AnimData (before per-transition
override) carries: anim_id = 0, low_frame = 0, high_frame = 0xffffffff (i.e. -1, "use full anim"), framerate = 30.0f.
26. AnimSequenceNode::get_starting_frame (line 302483, addr 0x00525c80)
00525c80 int32_t __fastcall AnimSequenceNode::get_starting_frame(class AnimSequenceNode const* this)
{
class AnimSequenceNode* this_1 = this;
long double x87_r7 = ((long double)this->framerate);
long double temp0 = ((long double)0f);
(x87_r7 - temp0);
int16_t result = ((((x87_r7 < temp0) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7, temp0))) ? 1 : 0) << 0xa) | ((((x87_r7 == temp0) ? 1 : 0) << 0xe) | 0))));
if ((*(uint8_t*)((char*)result)[1] & 1) != 0)
return (this->high_frame + 1);
this->low_frame;
return result;
}
Cleaned:
AnimSequenceNode::get_starting_frame():
if (framerate < 0.0f)
return high_frame + 1
return low_frame
(the test ah,1 bit pulled from the FPU status word after
fcomp is the raw "less-than" flag — framerate < 0.0.)
27. AnimSequenceNode::get_ending_frame (line 302501, addr 0x00525cb0)
00525cb0 int32_t __fastcall AnimSequenceNode::get_ending_frame(class AnimSequenceNode const* this)
{
class AnimSequenceNode* this_1 = this;
long double x87_r7 = ((long double)this->framerate);
long double temp0 = ((long double)0f);
(x87_r7 - temp0);
int16_t result = ((((x87_r7 < temp0) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7, temp0))) ? 1 : 0) << 0xa) | ((((x87_r7 == temp0) ? 1 : 0) << 0xe) | 0))));
if ((*(uint8_t*)((char*)result)[1] & 1) == 0)
return (this->high_frame + 1);
this->low_frame;
return result;
}
Cleaned:
AnimSequenceNode::get_ending_frame():
if (framerate < 0.0f)
return low_frame
return high_frame + 1
These two are exact mirrors of each other, keyed off the SAME sign
test (framerate < 0.0), which is retail's playback-direction flag:
- Forward playback (
framerate >= 0): starts atlow_frame, ends athigh_frame + 1. - Reverse playback (
framerate < 0): starts athigh_frame + 1, ends atlow_frame.
So "starting frame" and "ending frame" are DIRECTION-AWARE — for a
reverse node, get_starting_frame() returns the numerically HIGHER
value (high_frame + 1) and get_ending_frame() returns the
numerically LOWER value (low_frame), because playback is counting
DOWN. This is exactly what feeds frame_number at every
advance_to_next_animation transition (§23) and is why
update_internal's forward/reverse branches both correctly detect
"exhausted" via a single frame_number > boundary / frame_number < boundary test regardless of which physical direction the node's
framerate sign implies.
28. AnimSequenceNode::get_pos_frame (two overloads, lines 300734 & 302447)
double-index overload (line 300734, addr 0x005247b0)
005247b0 class AFrame* __thiscall AnimSequenceNode::get_pos_frame(class AnimSequenceNode const* this, double arg2)
{
floor(arg2, *(uint32_t*)((char*)arg2)[4]);
return AnimSequenceNode::get_pos_frame(this, _ftol2());
}
Truncates arg2 (a double frame position) to int via floor then
tailcalls the int-index overload.
int-index overload (line 302447, addr 0x00525c10)
00525c10 class AFrame* __thiscall AnimSequenceNode::get_pos_frame(class AnimSequenceNode const* this, int32_t arg2)
{
class CAnimation* anim = this->anim;
if ((anim != 0 && (arg2 >= 0 && arg2 < anim->num_frames)))
return ((arg2 * 0x1c) + anim->pos_frames);
return 0;
}
Cleaned:
AnimSequenceNode::get_pos_frame(int frame_index):
if (anim != null && 0 <= frame_index < anim.num_frames)
return &anim.pos_frames[frame_index] // AFrame, stride 0x1c (28 bytes)
return null
Bounds-checked lookup into CAnimation::pos_frames (root/whole-object
position+orientation per frame — an AFrame, stride 28 bytes). Returns
null (not a fallback frame) out of range or when the node has no
resolved anim.
29. AnimSequenceNode::get_part_frame (line 302460, addr 0x00525c40)
00525c40 class AnimFrame const* __thiscall AnimSequenceNode::get_part_frame(class AnimSequenceNode const* this, int32_t arg2)
{
class CAnimation* anim = this->anim;
if ((anim != 0 && (arg2 >= 0 && arg2 < anim->num_frames)))
return &anim->part_frames[arg2];
return 0;
}
Cleaned: identical shape to get_pos_frame but indexes
CAnimation::part_frames (the PER-PART AnimFrame array — this is
what carries .hooks for the frame, consumed by
CSequence::execute_hooks). Same bounds check, same null on
out-of-range/no-anim.
30. AnimSequenceNode::has_anim (line 302473, addr 0x00525c70)
00525c70 int32_t __fastcall AnimSequenceNode::has_anim(class AnimSequenceNode const* this)
{
int32_t result;
result = this->anim != 0;
return result;
}
31. AnimSequenceNode::GetNext / GetPrev (lines 302601, 302614)
00525de0 class AnimSequenceNode const* __fastcall AnimSequenceNode::GetNext(class AnimSequenceNode const* this)
{
class DLListData* dllist_next = this->dllist_next;
if (dllist_next == 0)
return 0;
return ((char*)dllist_next - 4);
}
00525df0 class AnimSequenceNode* __fastcall AnimSequenceNode::GetPrev(class AnimSequenceNode* this)
{
class DLListData* dllist_prev = this->dllist_prev;
if (dllist_prev == 0)
return 0;
return ((char*)dllist_prev - 4);
}
Both convert the raw DLListData* link pointer to the owning
AnimSequenceNode* via the same -4 byte adjustment seen throughout
the list-splice code (§0 struct-layout note).
32. CSequence::pack_size / Pack / UnPack (lines 301334, 301458, 302235) — wire serialization
Included for completeness (not part of the per-frame hot path, but
documents the CSequence wire format used by PackObj::Pack/UnPack
dispatch, relevant if acdream ever needs to interoperate with a
serialized retail sequence snapshot):
00524f20 uint32_t __thiscall CSequence::pack_size(class CSequence* this, uint32_t* arg2, uint32_t* arg3)
{
*(uint32_t*)arg2 = 0; // flags accumulator (bit0 = has non-zero velocity, bit1 = has non-zero omega)
*(uint32_t*)arg3 = 0; // node count accumulator
// count nodes in anim_list, summing each node's Pack() size (0x10 each) into `edi`
// edi starts at 4 (header dword), += 4 more if list non-empty (node-count field),
// += 0x10 if node count != 0 vs += 4 if empty (mismatched header sizing between
// "has nodes" vs "no nodes" cases)
result = edi_1 + 4; // + placement_frame_id / frame_number header dword
// velocity: if fabs(x) < F_EPSILON check EACH axis (short-circuit: first axis that
// fails the epsilon test forces the WHOLE vector to be packed as 0xc bytes + flag bit 0)
if (fabs(velocity.x) >= F_EPSILON) { result += 0xc; flags |= 1; }
else if (fabs(velocity.y) >= F_EPSILON) { result += 0xc; flags |= 1; }
else if (fabs(velocity.z) >= F_EPSILON) { result += 0xc; flags |= 1; }
// omega: same pattern, flag bit 1
if (fabs(omega.x) >= F_EPSILON) { result += 0xc; flags |= 2; }
else if (fabs(omega.y) >= F_EPSILON) { result += 0xc; flags |= 2; }
else if (fabs(omega.z) >= F_EPSILON) { result += 0xc; flags |= 2; }
return result;
}
CSequence::Pack writes: flags-dword, [per-node Pack() blob ×
count], then EITHER placement_frame_id (if no nodes) OR
frame_number (8 bytes) + first_cyclic-index (distance from head to
first_cyclic walking GetNext) + curr_anim-index (distance from
head to curr_anim), then conditionally velocity (if flags&1) and
omega (if flags&2) as 3 floats each (12 bytes, 0xc) gated by
arg3 >= 0xc (buffer-size guard).
AnimSequenceNode::Pack/UnPack (lines 302692/302721, 0x00525ee0/
0x00525f40) pack as [DID (or INVALID_DID if anim==null), low_frame, high_frame, framerate] = 0x10 (16) bytes fixed.
33. CSequence::UnPack (line 302235, addr 0x005259d0) — tail (velocity/omega restore)
00525b3f if (((ecx_11 & 2) != 0 && arg3 >= 0xc))
{
this->omega.x = *(uint32_t*)eax_14;
void* edx_5 = (*(uint32_t*)esi + 4);
*(uint32_t*)esi = edx_5;
this->omega.y = *(uint32_t*)edx_5;
void* ecx_14 = (*(uint32_t*)esi + 4);
*(uint32_t*)esi = ecx_14;
this->omega.z = *(uint32_t*)ecx_14;
*(uint32_t*)esi += 4;
}
return 1;
}
Symmetric restore of velocity (flag bit 0) then omega (flag bit 1),
matching the Pack/pack_size bit layout above. UnPack begins (line
302239-302247) by calling clear_animations() + clear_physics() and
resetting placement_frame/placement_frame_id to null/0 before
reading the wire data — a full state wipe before deserializing.
Summary: hook-firing timeline per CSequence::update tick
CSequence::update(elapsed, frame)called once per physics tick fromPartArray::Update.- If
anim_listis non-empty:update_internalruns, possibly LOOPING internally (thegoto loopin §21) ifelapsedovershoots the current node's frame range — each loop iteration can itself fire MULTIPLE per-frame hook batches (the inner do/while over crossed integer frame indices) before even considering a node transition. - For EVERY whole integer frame index crossed within a single node
(forward or reverse), in strict frame order:
a. The node's stored pose delta at that frame index is
combined/subtracted into the destination
Frame*(Frame::combineforward,Frame::subtract1reverse) — but ONLY if the node'sCAnimation.pos_frames != null. b.apply_physicsfolds in the sequence's accumulatedvelocity/omegascaled by1.0/framerate, signed by the caller's original elapsed/rate — but ONLY iffabs(framerate) >= F_EPSILON. c.execute_hooks(part_frame_at_index, direction)queues every matchingCAnimHook(direction 0 = both, else must match caller's1=forward /-1=reverse) onto the owningCPhysicsObj.anim_hooksarray — NOT executed inline. - When a node's frame range is exhausted (
frame_numberstrictly pastget_ending_frame()/get_starting_frame()), BEFORE callingadvance_to_next_animation: if the OLD list head has already been fully consumed (head != first_cyclic),AnimDoneHook(the global singleton) is queued directly ontoanim_hooks— this is the signal that eventually callsCPartArray::AnimationDone(1)/MovementManager::MotionDoneonce drained. advance_to_next_animationperforms the un-apply/select-next/apply pose sequence (§23), wrapping forward exhaustion tofirst_cyclic(loop the cycle) or reverse exhaustion to the list tail.- Control returns to
update_internal's outer loop with the LEFTOVER elapsed time (remaining, the fractional overshoot past the old boundary converted back to a time delta via/ node_framerate), which may immediately trigger ANOTHER frame-crossing pass against the NEWcurr_animin the sameupdate_internalcall — i.e. a singleCSequence::update()call can transition through several queued animation nodes in one tick ifelapsedis large enough (a slow frame / lag spike can legitimately fast-forward through multiple short one-shot transition nodes in one physics step). - After
update_internalreturns (list still non-empty),apricot()frees every node strictly BEFORE the (possibly new)curr_anim, bounded so it never deletes intofirst_cyclic's cyclic tail. - All queued
anim_hooks(frame hooks + anyAnimDoneHook) are actually EXECUTED later, once per physics tick, byCPhysicsObj::process_hooks— a separate call NOT insideCSequenceat all, invoked by the owningCPhysicsObj's update path — and theanim_hooksarray is fully drained (m_num = 0) after every execute pass.