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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:45:56 +02:00

70 KiB
Raw Blame History

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.

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

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'S arg3 direction matches exactly.

The actual call sites in update_internal (see §21 below) pass:

  • arg3 = 1 when 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::ExecuteCPhysicsObj::Hook_AnimDoneCPartArray::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 sequence
  • arg2 — 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 a double* even though the underlying field is long double; the field is read/written through floor(lo,hi) pairs which address it as two 32-bit halves — the x87 extended representation)
  • arg5 = destination Frame* to accumulate physics into (or null if the caller only wants the frame counter advanced, no motion applied — see CSequence::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 through AnimSequenceNode's vtable at the framerate-comparison slot — cross-checked against AnimSequenceNode::get_starting_frame/get_ending_frame (§26/27 below), whose boolean logic (framerate < 0.0 branch, then return high_frame+1 or low_frame) is EXACTLY what every one of these call sites' surrounding code does immediately afterward. MD_Data_Fade is an unrelated MediaDesc-family class (acclient.h:34176) whose real GetDuration takes incompatible arguments — a decompiler address-collision mislabel, not a real call target. Read these sites as node->framerate (raw field access via a trivial inline getter), not a call to media-fade duration.
  • Similarly EffectInfoRegion::GetStat(node) (lines 301917, 301931, 301979) and Attribute2ndInfoRegion::GetStat(node) (lines 302064, 302079, 302129) are mislabeled — by call-site position and use (assigned into var_28_1 then immediately combined with AnimSequenceNode::get_pos_frame/get_part_frame and frame-index arithmetic) these are AnimSequenceNode::get_starting_frame(node) / AnimSequenceNode::get_ending_frame(node) respectively — same address-collision artifact as above (EffectInfoRegion/ Attribute2ndInfoRegion::GetStat are 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. delta alone overshoots in one step); in that branch apply_physics/position-combine happens ONCE using the raw 1.0/node_framerate quantum and the function returns WITHOUT calling advance_to_next_animation or firing execute_hooks at all for that tick — because arg5(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_hooks triple for EVERY whole frame index crossed this tick, not just the final one — multi-frame skips (large elapsed/delta values, 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 its AnimFrame.hooks chain filtered by direction (1 forward / -1 (0xffffffff) reverse) via execute_hooks, in strict ascending (forward) or descending (reverse) frame order.
  • Frame::combine (forward) vs Frame::subtract1 (reverse) — these are NOT symmetric operations; combine composes the animation's stored per-frame AFrame (quaternion + origin) INTO the destination frame (i.e. applies the pose), subtract1 un-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 from curr_anim.get_pos_frame(idx) (root/AFrame-level) and get_part_frame(idx) (per-part AnimFrame) tables baked into the CAnimation dat resource, NOT from interpolating frame_number's fractional part at render time inside this function (rendering interpolation happens elsewhere, in PartArray::SetFrame/UpdateParts, off get_curr_animframe's floored index).
  • CSequence::execute_hooks is called with the PART frame ( AnimSequenceNode::get_part_frame(idx), an AnimFrame const*), NOT the pos frame — hooks live on AnimFrame.hooks, keyed per animation part-frame index, exactly matching the AnimFrame struct's hooks field.
  • Boundary detection uses > / < (strict) against get_ending_frame() / get_starting_frame(), meaning frame_number is allowed to sit EXACTLY AT the boundary value without triggering an advance — the advance only fires once the position strictly exceeds it. Combined with get_ending_frame() returning high_frame + 1 - ε-ish (see §27, actually high_frame+1 verbatim, 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 to first_cyclic — the boundary marker between one-shot "link" animations (queued ahead of the loop, consumed once and freed via apricot) 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 with apply_physics calls using 1.0 / node.framerate as the physics quantum's magnitude and the ORIGINAL caller's elapsed/arg2 as the sign source (matching apply_physics's copysign semantics 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 default 0xffffffff == -1 when interpreted signed) → clamp to num_frames - 1 (i.e. "play to the end").
  • low_frame >= num_frames → clamp to num_frames - 1.
  • high_frame >= num_frames → clamp to num_frames - 1.
  • if after clamping low_frame > high_frame, force high_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 at low_frame, ends at high_frame + 1.
  • Reverse playback (framerate < 0): starts at high_frame + 1, ends at low_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

  1. CSequence::update(elapsed, frame) called once per physics tick from PartArray::Update.
  2. If anim_list is non-empty: update_internal runs, possibly LOOPING internally (the goto loop in §21) if elapsed overshoots 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.
  3. 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::combine forward, Frame::subtract1 reverse) — but ONLY if the node's CAnimation.pos_frames != null. b. apply_physics folds in the sequence's accumulated velocity/omega scaled by 1.0/framerate, signed by the caller's original elapsed/rate — but ONLY if fabs(framerate) >= F_EPSILON. c. execute_hooks(part_frame_at_index, direction) queues every matching CAnimHook (direction 0 = both, else must match caller's 1=forward / -1=reverse) onto the owning CPhysicsObj.anim_hooks array — NOT executed inline.
  4. When a node's frame range is exhausted (frame_number strictly past get_ending_frame()/get_starting_frame()), BEFORE calling advance_to_next_animation: if the OLD list head has already been fully consumed (head != first_cyclic), AnimDoneHook (the global singleton) is queued directly onto anim_hooks — this is the signal that eventually calls CPartArray::AnimationDone(1) / MovementManager::MotionDone once drained.
  5. advance_to_next_animation performs the un-apply/select-next/apply pose sequence (§23), wrapping forward exhaustion to first_cyclic (loop the cycle) or reverse exhaustion to the list tail.
  6. 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 NEW curr_anim in the same update_internal call — i.e. a single CSequence::update() call can transition through several queued animation nodes in one tick if elapsed is large enough (a slow frame / lag spike can legitimately fast-forward through multiple short one-shot transition nodes in one physics step).
  7. After update_internal returns (list still non-empty), apricot() frees every node strictly BEFORE the (possibly new) curr_anim, bounded so it never deletes into first_cyclic's cyclic tail.
  8. All queued anim_hooks (frame hooks + any AnimDoneHook) are actually EXECUTED later, once per physics tick, by CPhysicsObj::process_hooks — a separate call NOT inside CSequence at all, invoked by the owning CPhysicsObj's update path — and the anim_hooks array is fully drained (m_num = 0) after every execute pass.