acdream/docs/research/acclient_animation_pseudocode.md
Erik 8402aee703 research: full animation pseudocode from decompiled acclient.exe
Complete pseudocode translation of the retail AC client's animation
system, extracted from chunk_00520000.c. Covers:

- Sequence::update_internal (1021 bytes, the core frame advance loop)
- Sequence::advance_to_next_animation (node transitions)
- Sequence::append_animation (queue management)
- MotionTableManager::PerformMovement (1878 bytes, full state machine)
- AddAnimationsToSequence (transition link → sequence nodes)
- GetStartFramePosition / GetEndFramePosition (reverse playback support)
- AdjustNodeSpeed (negative speed = swapped start/end frames)

Key findings:
- framePosition is a 64-bit DOUBLE, not float
- Negative speedScale swaps startFrame↔endFrame at the node level
- update_internal handles both forward and reverse in one loop
- Frame triggers fire at every integer boundary crossing
- The keyframe slerp lives in the renderer, not the sequencer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:43:44 +02:00

45 KiB
Raw Permalink Blame History

AC Client Animation System — Full Pseudocode Port

Source: docs/research/decompiled/chunk_00520000.c Date: 2026-04-13 Purpose: Direct translation guide for C# AnimationSequencer implementation.


0. Data Structure Map

Before reading the functions, here is the memory layout of the key objects decoded from offsets found throughout the decompiled code.

AnimNode (AnimData entry in a sequence — size 0x1C = 28 bytes)

These are the items stored in the doubly-linked sequence list.

AnimNode {
  +0x00  vtable*           // &PTR_LAB_007c92b8 = AnimData vtable
  +0x04  AnimNode* next    // forward link
  +0x08  AnimNode* prev    // backward link (or owning-list sentinel)
  +0x0C  uint frameCount   // number of frames in this animation
  +0x10  float speedScale  // speed multiplier (from MotionTable transition)
  +0x14  int startFrame    // inclusive start frame index (forward mode)
  +0x18  int endFrame      // inclusive end frame index (forward mode; reversed when negative speed)
}

When speedScale < 0 the node's startFrame/endFrame are swapped (see FUN_005267e0).

The vtable tag sentinel PTR_FUN_007c92cc marks a "temporary/borrowed" node constructed inline in FUN_00526900 for transit animation nodes.

Sequence (the Sequencer object)

Each PhysicsObject that has animations owns one Sequence. Size inferred from field offsets.

Sequence {
  +0x00  vtable*
  +0x04  AnimNode* listHead   // head of doubly-linked pending list (front)
  +0x08  AnimNode* listTail   // tail of pending list (back)
  +0x0C  AnimNode* current    // pointer to the current (playing) node (= head+4, adjusted)
  +0x10  float     linearVelocityX   // accumulated linear velocity (m/s, world X)
  +0x14  float     linearVelocityY
  +0x18  float     linearVelocityZ
  +0x1C  float     angularVelocityX  // accumulated angular velocity
  +0x20  float     angularVelocityY
  +0x24  float     angularVelocityZ
  +0x28  int       hasCallback       // non-zero = send event callbacks on frame triggers
  +0x30  double    framePosition     // fractional frame position within current animation
  +0x34  double    (padding)
  +0x38  AnimNode* activeNode        // the node actively being ticked (= current during tick)
  +0x3C  AFrame*   overrideFrame     // static override frame when no animation node active
  +0x40  Quaternion overrideRot      // (part of above)
}

MotionTableData (per-transition node — size 0x50 = 80 bytes)

Loaded from the dat MotionTable resource (FUN_00521770 deserializes it):

MotionTableData {
  +0x00  uint     stateId            // motion state this is valid from
  +0x04  uint     animId             // dat animation resource ID
  +0x08  int      frameCount         // number of frames
  +0x0C  int*     frameList          // pointer to frame-index array
  +0x10  float*   framePosArray      // per-frame position vectors (optional, bit 0 in flags)
  +0x14  float*   frameVelArray      // per-frame velocity vectors (optional, bit 1 in flags)
  +0x18  int      numBones           // bone/part count
  +0x1C  ...      transitions        // map: targetState -> AnimNode params
  +0x30  byte     flags              // bit 0 = has_positions, bit 1 = has_velocities
  ...
  +0x74  hashmap* transitionsMap     // from-state -> transition list
  +0x78  hashmap* defaultTransMap
  +0x80..0x88     hash header (size, mask, bucket ptr)
  +0x94  uint     defaultMotionId    // default starting motion (often Walk_Forward / Stand)
  +0x98  uint     currentMotionId    // current motion state
  +0x9C  uint     currentSubstate
  +0xA0  uint     fallbackMotionId
  +0xA4  TransitionLink* linked      // chain of other MotionTableData that must track this one
}

1. MotionTable Deserialization — FUN_00521770 (MotionTableManager::Load)

Purpose: Deserialize a MotionTable dat resource into the MotionTableData struct. Not called at runtime per-frame, but documents the full structure.

MotionTableData::Load(byte** dataPtr, uint version):
  flags = read_byte(dataPtr)     // packed: bit2=hasCycleAnimData, bit3=hasLinks
  hasCycleAnimData = (flags >> 2) & 1
  hasLinks = (flags >> 3) & 1

  numAnimations = read_uint32(dataPtr)

  // Allocate frame-index array  
  frameList = malloc(numAnimations * 4 + 4)
  frameList[0] = numAnimations  // length prefix
  frameListBase = frameList + 1

  // Read animation IDs
  for i in 0..numAnimations:
    frameListBase[i] = read_uint32(dataPtr)

  if hasCycleAnimData:
    // Allocate per-frame position data
    framePosArray = malloc(numAnimations * 4)
    for i in 0..numAnimations:
      framePosArray[i] = read_uint32(dataPtr)

    if version > 0xb:
      // Allocate velocity vectors
      frameVelArray = malloc(numAnimations * 12)
      for i in 0..numAnimations:
        frameVelArray[i*3 + 0] = read_float(dataPtr)
        frameVelArray[i*3 + 1] = read_float(dataPtr)
        frameVelArray[i*3 + 2] = read_float(dataPtr)

  // Read the S → (motion link → motion link) transition table
  numTransLinks1 = read_uint32(dataPtr)
  if numTransLinks1 > 0:
    allocate transitionsMap (hash map)
    for i in 0..numTransLinks1:
      linkNode = alloc 0x50 bytes (TransitionLink)
      linkNode.key1 = read_uint32(dataPtr)    // from-state
      linkNode.key2 = read_uint32(dataPtr)    // to-state or composite
      ReadTransitionNode(dataPtr, version)    // fills in anim, speed, start/end frames
      insert linkNode into transitionsMap

  numTransLinks2 = read_uint32(dataPtr)
  if numTransLinks2 > 0:
    allocate defaultTransMap (hash map)
    for i in 0..numTransLinks2:
      // same structure as above

  // Read sub-state transition array
  numSubstates = read_uint32(dataPtr)
  if numSubstates > 0:
    substates = malloc(numSubstates * 0x68 + 4)
    for i in 0..numSubstates:
      if version > 0x2c:
        substate[i].id = read_uint32(dataPtr)
        ReadTransitionNode(dataPtr, version)
        ReadVelocity(dataPtr, version)  // 3 floats if version > 0xb

  // Read 5 default motion IDs and the motion state fields
  defaultMotionId  = read_uint32(dataPtr)    // +0x94
  currentMotionId  = read_uint32(dataPtr)    // +0x98
  currentSubstate  = read_uint32(dataPtr)    // +0x9C
  fallbackMotionId = read_uint32(dataPtr)    // +0xA0
  unknownMotionId  = read_uint32(dataPtr)    // +0xA4

  // 4-byte align
  align_to_4(dataPtr)
  return 1

ReadTransitionNode (FUN_0053B6C0 / FUN_0053B640 area)

Each transition node records:

  • animId — the animation resource to play during this transition
  • speedScale — playback speed multiplier (1.0 = normal)
  • startFrame, endFrame — inclusive frame range within the animation
  • frameCount — total frames in the animation

2. MotionTable Lookup — FUN_005231D0 (LookupTransitionNode)

Purpose: Hash-map lookup: given a composite key, return the TransitionLink*. The key encodes (fromState << 16) | (toState & 0xFFFFFF).

TransitionLink* LookupTransitionNode(HashMap* map, uint key):
  bucket = map.buckets[ (key ^ (key >> map.shift)) & map.mask ]
  node = bucket
  while node != null:
    if node.key == key: return node - 4   // offset -4 to get the TransitionLink base
    node = node.next
  return null

The -4 offset adjustment is consistent throughout: all pointers returned by hash-map lookups are bumped back by 4 to point to the containing struct's header, not the hash-node sub-member.


3. FUN_005232B0 — FindBestTransition

Purpose: Find the best transition node between two motion states, with fallback logic. Returns a TransitionLink* (or null if no path exists).

TransitionLink* FindBestTransition(
    uint fromState,
    uint currentSubstate,
    float currentSpeed,
    uint toState,
    float targetSpeed):

  // Path 1: direct transition with speed context
  if targetSpeed < EPSILON or currentSpeed < EPSILON:
    // Speed-agnostic: use (fromState, currentSubstate) composite key
    node = LookupInMap1(fromState & 0xFFFFFF | (currentSubstate << 16))
    if node != null:
      result = LookupTransitionNode(map2, node)  // resolve within target
      if result != null: return result

  else:
    // Speed-sensitive: direct (fromState, toState) lookup
    node = LookupInMap1(fromState & 0xFFFFFF | (currentSubstate << 16))
    if node != null:
      result = LookupTransitionNode(map2, toState)
      if result != null: return result

  // Path 2: fallback via defaultMotionId  
  if targetSpeed < EPSILON or currentSpeed < EPSILON:
    defaultNode = LookupInMap1(fromState)
    if defaultNode != null:
      viaPrimary = LookupInMap1(fromState & 0xFFFFFF | (currentSubstate << 16))
      if viaPrimary != null:
        result = LookupTransitionNode(map2, viaPrimary)
        if result != null: return result
  else:
    viaDirect = LookupInMap1(fromState)
    if viaDirect != null:
      result = LookupTransitionNode(map2, toState)
      if result != null: return result

  return null

4. FUN_00523000 — AddAnimationsToSequence

Purpose: Given a TransitionLink*, add its animation(s) to the sequence. The link can contain multiple chained animation nodes.

AddAnimationsToSequence(Sequence* seq, TransitionLink* link, float speedScale):
  if link == null: return

  // Apply position displacement from this transition's linear velocity
  SetLinearVelocity(seq, link.linearVel * speedScale)     // +0x18..+0x20
  SetAngularVelocity(seq, link.angularVel * speedScale)   // +0x24..+0x2C

  // Iterate the chain of animation nodes stored in the link
  count = link.nodeCount   // byte at link+0x10
  if count > 0:
    ptr = link.nodeArrayPtr   // pointer at link+0x14; each entry is 0x14 bytes
    for i in 0..count:
      // Build a temporary AnimNode from the inline array entry
      tempNode = BuildTempAnimNode(speedScale, ptr[i])   // FUN_00526900
      // Append it to the sequence's pending list
      AppendAnimation(seq, tempNode)                      // FUN_00526110
      FreeTempNode(tempNode)
      ptr += 0x14

FUN_00526900 — BuildTempAnimNode

Builds a stack-allocated AnimNode from an inline transition record:

AnimNode BuildTempAnimNode(float speedScale, TransitionRecord* rec):
  node.vtable     = AnimDataVtable
  node.speedScale = rec.speedScale * speedScale  // +0x10
  node.startFrame = rec.startFrame               // +0x14 (via +0x08 in rec)
  node.endFrame   = rec.endFrame                 // +0x18 (via +0x0C in rec)
  node.frameCount = rec.frameCount               // +0x0C (via +0x04 in rec? No, +0x10 raw)
  // speed sign: rec.speedScale at rec+0x10, start at rec+0x04, end at rec+0x08, frameCount at rec+0x0C
  return node

5. FUN_00526110 — AppendAnimation (Sequence::append_animation)

Purpose: Push a new animation node onto the tail of the sequence's pending list. This is the write path — callers always allocate a fresh AnimNode and pass it here.

Sequence::AppendAnimation(Sequence* seq, AnimNode* node):
  // Allocate the permanent 0x1C-byte node
  newNode = malloc(0x1C)
  if newNode == null: return

  // Copy contents from the source node (FUN_00526b90)
  // fills: vtable, speedScale, startFrame, endFrame, frameCount
  newNode = CopyAnimNode(node)   // FUN_00526b90

  hasActiveAnim = (seq.current != null)   // FUN_00526870 checks seq+0x0C

  if not hasActiveAnim:
    // No active animation — discard (or free via vtable destructor if non-null)
    if newNode != null:
      vtable_destructor(newNode, 1)   // (*newNode.vtable)(1)
    return

  // Sequence is active: append to the doubly-linked list
  // FUN_00410820 = deque push_back
  PushBack(seq.list, newNode)
  // Update seq.current to point to the new tail
  if seq.listHead == null:
    seq.current = null
  else:
    seq.current = seq.listHead - 4   // adjust for header

  // If we just set the first active node, seed the frame position
  if seq.activeNode == null:
    if seq.listHead != null:
      seq.activeNode = seq.listHead - 4
      seq.framePosition = GetStartFramePosition(seq.activeNode)  // FUN_00526880

FUN_00526880 — GetStartFramePosition

Returns the starting framePosition for a node (double).

double GetStartFramePosition(AnimNode* node):
  if node.speedScale >= 0.0:
    return (double)node.startFrame   // +0x14
  else:
    // Reverse playback: start from endFrame, go backwards
    return (double)(node.endFrame + 1) - EPSILON   // +0x18 + 1 - tiny

EPSILON here is _DAT_007c92b4 ≈ a very small float used to place the cursor just before the end boundary so it steps backwards into valid frames.

FUN_005268B0 — GetEndFramePosition

Returns where the cursor sits at the END of a node (used for boundary detection):

double GetEndFramePosition(AnimNode* node):
  if node.speedScale >= 0.0:
    return (double)(node.endFrame + 1) - EPSILON   // last frame boundary
  else:
    return (double)node.startFrame   // forward boundary for reverse

6. FUN_005261D0 — Sequence::update_internal (1021 bytes)

Purpose: The core per-frame advancement loop. Advances framePosition by frameRate * dt, handles boundary crossings (including multi-frame skips), fires frame-trigger events, calls advance_to_next_animation when a node completes, and loops if there's remaining time after advancing.

Sequence::update_internal(
    Sequence*  seq,        // this = param_1
    double     frameRate,  // param_2+param_3 (64-bit double)
    AnimNode** pCurrent,   // param_4 = &seq.activeNode (pointer to ptr)
    double*    pFramePos,  // param_5 = &seq.framePosition
    int        entity):    // param_6 = PhysicsObject* (0 = no events)

  while true:
    // Get current frame-rate magnitude (may be scaled by node)
    double rate    = GetNodeFrameRate()      // FUN_0069eda0 — reads scaled FPS
    double delta   = rate * frameRate        // frameRate is signed (negative = reverse)
    bool   wrapped = false

    // Compute next frame position (no-op floor call is just rounding the existing pos)
    int    oldFrameIndex = floor(*pFramePos)   // truncate to int for trigger checks
    double newPos        = delta + *pFramePos
    *pFramePos = newPos

    // ── FORWARD PLAYBACK (delta > 0) ──
    if delta > 0:
      int maxFrame = GetNodeMaxFrame()    // FUN_004f24e0 = node.endFrame + 1

      // Check if we crossed or hit the end boundary
      if floor(newPos) > maxFrame:
        // Compute remaining time (overflow past the end)
        double overflow  = *pFramePos - maxFrame
        overflow = min(overflow, 0.0)     // clamp (note: _DAT_00795610 = 0.0 sentinel)
        double remainingTime = overflow / rate   // how much time past boundary
        if rate <= EPSILON: remainingTime = 0.0

        *pFramePos = (double)maxFrame
        wrapped = true

      // Fire frame-trigger events for all whole frames we crossed
      if floor(*pFramePos) > oldFrameIndex:
        do:
          if entity != 0:
            // Check if current animation has frame triggers
            if (*pCurrent).frameData != null:
              triggerData = GetFrameTriggerData(oldFrameIndex)   // FUN_00526810
              FireApproachEvent(entity, triggerData)             // FUN_00525d80 (approach)
            // Fire velocity callback if moving fast enough
            if |rate| > EPSILON:
              ApplyVelocity(entity, 1.0/rate, frameRate)         // FUN_005256b0
          
          // Fire "step" on the frame node (sends sound/effect event)
          // uVar8=1 means "forward trigger"
          frameNode = GetFrameNode(oldFrameIndex)               // FUN_00526840
          FireFrameCallback(frameNode, 1)                       // FUN_00525430
          oldFrameIndex++
        while floor(*pFramePos) > oldFrameIndex

    // ── REVERSE PLAYBACK (delta < 0) ──
    else if delta < 0:
      int minFrame = GetNodeMinFrame()    // FUN_004f32b0 = node.startFrame

      // Check if we crossed the start boundary
      if floor(newPos) < minFrame:
        double underflow  = *pFramePos - minFrame
        // cap to 0 (same sentinel check)
        if underflow > 0.0: underflow = 0.0
        double remainingTime = underflow / rate
        if rate <= EPSILON: remainingTime = 0.0
        
        *pFramePos = (double)minFrame
        wrapped = true

      // Fire frame-trigger events for crossed frames (backwards)
      if floor(*pFramePos) < oldFrameIndex:
        do:
          if entity != 0:
            if (*pCurrent).frameData != null:
              triggerData = GetFrameTriggerData(oldFrameIndex)   // FUN_00526810
              FireLeaveEvent(entity, triggerData)                // FUN_00536260 (leave)
            if |rate| > EPSILON:
              ApplyVelocity(entity, 1.0/rate, frameRate)
          
          // uVar8=0xFFFFFFFF (-1) = "reverse trigger"
          frameNode = GetFrameNode(oldFrameIndex)
          FireFrameCallback(frameNode, -1)                      // FUN_00525430
          oldFrameIndex--
        while floor(*pFramePos) < oldFrameIndex

    // ── ZERO-RATE (rate ≈ 0) ──
    else:
      // Neither forward nor backward — just send velocity update if needed
      if entity != 0 and |frameRate| > EPSILON:
        ApplyVelocity(entity, frameRate, frameRate)
      return  // nothing to advance

    // ── BOUNDARY CROSSED? → advance to next animation ──
    if not wrapped: break   // no wrap — done for this tick

    // Check if the playing node is still the committed one
    // (hasCallback check: if the leading edge of the list != current, warn)
    if seq.hasCallback:
      leadingEdge = (seq.listHead != null) ? seq.listHead - 4 : null
      if leadingEdge != *pCurrent:
        log_warning("sequence mismatch")

    // Advance to the next animation node (pops current, loads next)
    AdvanceToNextAnimation(*pCurrent, *pFramePos, pCurrent, pFramePos, entity)
    // FUN_00525eb0

    // Set frameRate for the remainder pass (time left after boundary)
    frameRate = remainingTime  // loop again with leftover time
  // end while

Key sentinel values:

  • _DAT_00795610 = 0.0 (the "zero" double sentinel used for boundary clamping)
  • _DAT_007938c0 = 1.0
  • _DAT_007c9264 = small epsilon for float comparisons (~1e-6)

7. FUN_00525EB0 — Sequence::advance_to_next_animation

Purpose: When a node's frames are exhausted, pop it from the sequence and load the next one. Sets up the new framePosition from the new node's start/end boundary.

Sequence::AdvanceToNextAnimation(
    AnimNode**  pCurrent,    // param_4
    double*     pFramePos,   // param_5
    int         entity,      // param_6
    double      frameRate,   // param_2+param_3
    Sequence*   seq):        // param_1 (this)

  // Is frameRate > 0 (forward) or < 0 (reverse)?
  bool forward = CONCAT44(param_3, param_2) < 0.0  // note: negative = forward in decompiler output
  // (The comparison is with _DAT_00795610 = 0.0;  "< 0.0" means negative rate = actually the forward case
  //  because of how CONCAT44 handles the sign bit — verify against actual playback direction)

  if forward:
    // Going forward: load the NEXT node (advance from head toward tail)
    nextNode = GetNextNode()     // FUN_005269f0: if seq.listTail != null, return tail - 4
    if nextNode == null:
      *pCurrent = null
      // Guard: if there's no node but listHead exists, use that
    else:
      nextNode = GetNextNode()
    *pCurrent = nextNode
    *pFramePos = GetStartFramePosition(nextNode)    // FUN_00526880
    
    rate = GetNodeFrameRate()
    if rate >= 0:
      return  // no velocity event
    if entity != 0:
      // Notify entity of new animation (approach trigger for frame at new position)
      if (*pCurrent).frameData != null:
        ApplyVelocity(entity, frameRate)
      if |rate| > EPSILON:
        ScaleVelocity(entity, 1.0/rate, frameRate)

  else:
    // Going backwards: load the PREVIOUS node
    prevNode = GetPrevNode()     // FUN_005269e0: if seq.listHead != null, return head - 4
    if prevNode == null:
      *pCurrent = seq.listHead pointer  // +0x0C
    else:
      *pCurrent = prevNode
    *pFramePos = GetEndFramePosition(*pCurrent)     // FUN_005268b0
    
    rate = GetNodeFrameRate()
    if rate < 0:
      return
    if entity != 0:
      if (*pCurrent).frameData != null:
        ApplyVelocity(entity, frameRate)
      if |rate| > EPSILON:
        ScaleVelocity(entity, 1.0/rate, frameRate)

Note: After AdvanceToNextAnimation sets the new *pCurrent and *pFramePos, control returns to update_internal which loops with the remaining frameRate (the leftover time after the boundary crossing).


8. The Big PerformMovement — FUN_00523400 (MotionTableManager::PerformMovement)

Purpose: The top-level motion dispatch. Given a current state (*param_3[0], *param_3[1]) and a commanded motion (param_2), it looks up the appropriate transition animation chain and calls AddAnimationsToSequence to load it.

The function is 1878 bytes and handles four distinct motion-command categories distinguished by bits in param_2 (the motion command ID):

Bit 31 set (negative int):   STANCE CHANGE — transition to a new stance
Bit 30 set (0x40000000):     CYCLE MOTION  — looping motion like Walk/Run/TurnLeft
Bit 28 set (0x10000000):     LINKED MOTION — one-shot that links back to current cycle
Bit 29 set (0x20000000):     SUBSTATE ANIM — play a substate override animation

The this pointer (param_1) is the MotionTableManager which holds the hash maps. The state trio is param_3[0..2] = [currentState, currentSubstate, currentSpeed].

PerformMovement(
    MotionTableManager* mgr,  // param_1 (this)
    float commandedMotion,    // param_2
    float* stateTriple,       // param_3 = [currentState, currentSubstate, currentSpeed]
    Sequence* seq,            // param_4
    float targetSpeed,        // param_5
    uint* outFrameCount,      // param_6
    float unknown):           // param_7

  uint  currentState    = stateTriple[0]    // +0x00
  uint  currentSubstate = stateTriple[1]    // +0x04 (= local_8)
  float currentSpeed    = stateTriple[2]    // +0x08
  *outFrameCount = 0

  // Guard: must have a valid current state and substate
  if currentState == 0.0: return 0
  if currentSubstate == 0.0: return 0

  // Get what animation the current state plays (lookup returns a transition node)
  currentAnimId = LookupCurrentAnim(currentState)    // FUN_00523210

  // ─────────────────────────────────────────────────────────
  // CASE A: ALREADY PLAYING THIS — early-out for idempotent cycle
  // ─────────────────────────────────────────────────────────
  if commandedMotion == currentAnimId
     and unknown == 0.0
     and (currentSubstate & 0x20000000) != 0:
    return 1  // already in cycle, no-op

  // ─────────────────────────────────────────────────────────
  // CASE B: STANCE CHANGE (bit 31 set — negative command ID)
  // ─────────────────────────────────────────────────────────
  if (int)commandedMotion < 0:
    if currentState == commandedMotion: return 1  // already there

    // Find the transition FROM current TO the linked state
    currentAnimId = LookupCurrentAnim(currentState)
    if currentSubstate != currentAnimId:
      // Need an intermediate animation to exit substate
      exitLink = FindBestTransition(currentState, currentSubstate, currentSpeed,
                                    currentAnimId, targetSpeed)

    // Look up the stance change link (commandedMotion is a linked-state key)
    stanceLink = LookupInPrimaryMap(commandedMotion)
    if stanceLink != null:
      // If the stance has special flag (bit 0 of +0x30), fire linked-motion clear
      if stanceLink.flags & 1:
        ClearLinkedMotions(seq)   // FUN_00526c70

      // Build the transition path:
      // 1. Exit current substate (if any)
      // 2. Find path to commanded motion
      // 3. Find path FROM commanded TO its own default
      directLink = FindBestTransition(currentState, currentAnimId, currentSpeed,
                                      commandedMotion, targetSpeed)
      if directLink == null and commandedMotion != currentState:
        // Try via defaultMotionId as intermediate
        directLink = FindBestTransition(currentState, currentAnimId, 1.0,
                                        mgr.defaultMotionId, 1.0)
        defaultLink = FindBestTransition(mgr.defaultMotionId,
                                         LookupCurrentAnim(mgr.defaultMotionId),
                                         1.0, commandedMotion, 1.0)

      // Clear accumulated velocity, clear any pending linked-motion list
      ClearVelocity(seq)          // FUN_00525950: zero out velocity fields
      ClearPendingList(seq)       // FUN_00525a40: drain the pending deque

      // Add exit-substate, transition, and new-stance animations in order
      AddAnimationsToSequence(seq, exitLink, targetSpeed)    // FUN_00523000
      AddAnimationsToSequence(seq, directLink, targetSpeed)
      AddAnimationsToSequence(seq, defaultLink, targetSpeed)
      AddAnimationsToSequence(seq, stanceLink, targetSpeed)

      // Update the state triple
      stateTriple[1] = commandedMotion_asSubstate
      stateTriple[0] = commandedMotion_asState
      stateTriple[2] = targetSpeed

      // Fire any pending linked motions that were queued while transitioning
      ProcessPendingLinkedMotions(seq, stateTriple)  // FUN_00522e30

      // Compute total frame count
      *outFrameCount = stanceLink.frameCount
                     + (exitLink != null ? exitLink.frameCount : 0)
                     + (directLink != null ? directLink.frameCount : 0)
                     + (defaultLink != null ? defaultLink.frameCount : 0)
                     - 1   // subtract 1 for the final shared frame
      return 1

  // ─────────────────────────────────────────────────────────
  // CASE C: CYCLE MOTION (bit 30 set = 0x40000000)
  // Examples: Walk_Forward, Run_Forward, TurnLeft, TurnRight
  // ─────────────────────────────────────────────────────────
  if commandedMotion & 0x40000000:
    // Find the cycle animation for this motion in the current state context
    cycleNode = LookupTransitionNode(currentState << 16 | commandedMotion & 0xFFFFFF)
    if cycleNode == null:
      // Try the manager's root default state
      cycleNode = LookupTransitionNode(mgr.defaultMotionId << 16 | commandedMotion & 0xFFFFFF)

    if cycleNode != null and ValidateStateConstraints(commandedMotion, cycleNode, stateTriple):
      // ── Already playing this cycle at same speed? ──
      if commandedMotion == currentSubstate
         and SpeedsMatch(targetSpeed, currentSpeed)
         and SequenceHasActiveNode(seq):  // FUN_005257d0
        // Velocity blend: adjust speed smoothly
        BlendVelocity(seq, cycleNode, currentSpeed, targetSpeed)  // FUN_00522de0
        SetAngularVelocity(seq, cycleNode, targetSpeed)            // FUN_00523150
        SetLinearVelocity(seq, cycleNode, targetSpeed)             // FUN_005230d0
        stateTriple[2] = targetSpeed  // update speed only
        return 1

      // If transition requires clearing linked motions
      if cycleNode.flags & 1:
        ClearLinkedMotions(seq)    // FUN_00526c70

      // Find path from current to target cycle
      transitionLink = FindBestTransition(currentState, currentSubstate, currentSpeed,
                                           commandedMotion, targetSpeed)
      if transitionLink == null or not SpeedsMatch(targetSpeed, currentSpeed):
        // Try intermediate via default
        currentAnimId = LookupCurrentAnim(currentState)
        transitionLink = FindBestTransition(currentState, currentSubstate, currentSpeed,
                                             currentAnimId, 1.0)
        secondaryLink = FindBestTransition(currentState, currentAnimId, 1.0,
                                           commandedMotion, targetSpeed)

      // Clear velocity and pending anims
      ClearVelocity(seq)
      ClearPendingList(seq)

      // Handle negative speed (reverse playback): negate the scale on the transition
      if secondaryLink == null:
        // Going same direction or opposite?
        if currentSpeed >= 0 and targetSpeed >= 0:
          appliedSpeed = targetSpeed
        else:
          appliedSpeed = -targetSpeed   // reverse
        AddAnimationsToSequence(seq, transitionLink, appliedSpeed)
      else:
        AddAnimationsToSequence(seq, transitionLink, currentSpeed)
        AddAnimationsToSequence(seq, secondaryLink, targetSpeed)

      AddAnimationsToSequence(seq, cycleNode, targetSpeed)  // the actual cycle

      // Handle substate flag: if previous substate was also a cycle (bit 29 set),
      // and it was a different cycle, queue a "leave" event
      if (currentSubstate & 0x20000000) != 0
         and currentSubstate != commandedMotion:
        currentAnimId = LookupCurrentAnim(currentState)
        if currentAnimId != commandedMotion:
          QueueLeaveEvent(seq, currentSubstate, currentSpeed)  // FUN_00526bf0

      // Update state
      stateTriple[2] = targetSpeed
      stateTriple[1] = commandedMotion   // this becomes the current substate

      // Fire pending linked motions
      ProcessPendingLinkedMotions(seq, stateTriple)

      // Frame count
      *outFrameCount = cycleNode.frameCount - 1
                     + (transitionLink != null ? transitionLink.frameCount : 0)
                     + (secondaryLink != null ? secondaryLink.frameCount : 0)
      return 1

  // ─────────────────────────────────────────────────────────
  // CASE D: LINKED MOTION (bit 28 set = 0x10000000)
  // One-shot that fires and then returns to current cycle.
  // Example: Attack animations, jump, emotes.
  // ─────────────────────────────────────────────────────────
  if commandedMotion & 0x10000000:
    // Look up the linked motion node (keyed by current cycle + linked motion id)
    key = (currentState << 16) | (currentSubstate & 0xFFFFFF)
    linkedNode = LookupTransitionNode(key)
    if linkedNode != null:
      // Find the transition to the linked motion
      transitionLink = FindBestTransition(currentState, currentSubstate, currentSpeed,
                                           commandedMotion, targetSpeed)
      if transitionLink != null:
        // Queue a "cancel" event for the linked motion so it knows to restore state
        QueueCancelLinkedMotion(seq, commandedMotion, targetSpeed)  // FUN_00526ca0

        ClearVelocity(seq)
        ClearPendingList(seq)

        AddAnimationsToSequence(seq, transitionLink, targetSpeed)
        AddAnimationsToSequence(seq, linkedNode, currentSpeed)  // the looping base

        // Fire pending linked motions
        ProcessPendingLinkedMotions(seq, stateTriple)

        *outFrameCount = transitionLink.frameCount
        return 1

      // Fallback: try via default
      currentAnimId = LookupCurrentAnim(currentState)
      exitLink = FindBestTransition(currentState, currentSubstate, currentSpeed,
                                     currentAnimId, 1.0)
      if exitLink != null:
        entryLink = FindBestTransition(currentState, currentAnimId, 1.0,
                                        commandedMotion, targetSpeed)
        if entryLink != null:
          loopLink = LookupTransitionNode(key)  // refresh
          if loopLink != null:
            returnLink = FindBestTransition(currentState, currentAnimId, 1.0,
                                             currentSubstate, currentSpeed)
            
            QueueCancelLinkedMotion(seq, commandedMotion, targetSpeed)
            ClearVelocity(seq)
            ClearPendingList(seq)
            
            AddAnimationsToSequence(seq, exitLink, 1.0)     // exit substate
            AddAnimationsToSequence(seq, entryLink, targetSpeed)  // enter linked
            AddAnimationsToSequence(seq, returnLink, 1.0)   // return path
            AddAnimationsToSequence(seq, loopLink, currentSpeed)  // restore cycle

            ProcessPendingLinkedMotions(seq, stateTriple)

            *outFrameCount = exitLink.frameCount + entryLink.frameCount
                           + (returnLink != null ? returnLink.frameCount : 0)
            return 1

  // ─────────────────────────────────────────────────────────
  // CASE E: SUBSTATE ANIM (bit 29 set = 0x20000000)
  // Override animation layered on top of current stance.
  // ─────────────────────────────────────────────────────────
  if commandedMotion & 0x20000000:
    // Find the base cycle for this substate override
    currentBase = LookupTransitionNode(currentSubstate & 0xFFFFFF | currentState << 16)
    if currentBase != null and (currentBase.flags & 1 == 0):
      // Find the substate animation node
      substateNode = LookupTransitionNode(commandedMotion & 0xFFFFFF | currentState << 16)
      if substateNode == null:
        substateNode = LookupTransitionNode(commandedMotion & 0xFFFFFF)  // global fallback
      if substateNode == null: return 0

      // Fire any active linked motion → clear it
      linkedMotionActive = LookupLinkedMotion(commandedMotion, targetSpeed)  // FUN_00526f40
      if linkedMotionActive == 0:
        // Not yet active — trigger a stance-reset to install it, then re-check
        PerformMovement(mgr, commandedMotion, 1.0, stateTriple, seq, outFrameCount)
        linkedMotionActive = LookupLinkedMotion(commandedMotion, targetSpeed)
        if linkedMotionActive == 0: return 0

      // Apply substate velocity override
      SetLinearVelocity(seq, substateNode, targetSpeed)   // FUN_005230d0
      return 1

  return 0

9. FUN_00523B60 — ProcessLinkedMotionStop (StopMotion helper)

Called when a motion-stop command arrives and the current substate is a linked or cycle motion that needs to be unwound:

ProcessLinkedMotionStop(
    uint         motionId,   // param_1
    Sequence*    seq,        // param_2 (via param_3 array)
    int*         stateArr,   // param_3
    Sequence*    seqAlt,     // param_4
    uint*        outCount):  // param_5

  *outCount = 0

  // Case A: it's a cycle (bit 30) that IS the current substate — just restart default
  if motionId & 0x40000000 and motionId == stateArr[1]:
    currentAnimId = LookupCurrentAnim(stateArr[0])
    PerformMovement(currentAnimId, stateArr, seqAlt, 1.0, outCount, 1)
    return 1

  // Case B: it's a linked/substate motion (bit 29) — remove it from pending list
  if motionId & 0x20000000:
    prev = null
    node = stateArr[3]  // linked motion pending list
    while node != null:
      if node.motionId == motionId:
        // Found it — apply its exit animation and remove from list
        transNode = LookupTransitionNode(stateArr[0] << 16 | motionId & 0xFFFFFF)
        if transNode == null:
          transNode = LookupTransitionNode(motionId & 0xFFFFFF)
        if transNode == null: return 0
        AddAngularVelocity(seqAlt, transNode, node.speed)  // FUN_00523150
        RemoveFromLinkedList(node, prev)                    // FUN_00526c40
        return 1
      prev = node
      node = node.next

  return 0

10. Supporting Functions Reference

FUN_005267E0 — AdjustNodeSpeed (ApplySpeedScale to node)

Called by FUN_00525540 when iterating sequence nodes to apply a new speed:

AdjustNodeSpeed(AnimNode* node, float newSpeed):
  if newSpeed < 0.0:
    // Negative speed: swap start/end frames (reversal)
    swap(node.startFrame, node.endFrame)   // +0x14 ↔ +0x18
  node.speedScale = newSpeed * node.speedScale  // multiply into existing scale

FUN_00525950 — ClearVelocity

Zeros all six velocity components (+0x10..+0x24).

FUN_005259C0 — ClearPendingList (also resets framePosition)

Drains the entire pending deque (list at +0x04/+0x08) via vtable destructor calls, then resets seq.current, seq.framePosition, and seq.activeNode to zero.

FUN_00525A40 — TrimPendingToCommitted

Removes all pending nodes AFTER the currently-committed node pointer (activeNode). Used before loading a new motion set to avoid stale pending animations.

FUN_00526870 — HasActiveAnimation

bool HasActiveAnimation(Sequence* seq):
  return seq.current != null  // seq+0x0C

FUN_005257D0 — HasPendingAnimation

bool HasPendingAnimation(Sequence* seq):
  return seq.listHead != null  // seq+0x04

FUN_00526810 — GetFrameTriggerNode (for "start of frame" events)

AnimFrameData* GetFrameTriggerNode(Sequence* seq, int frameIdx):
  animData = seq.current.animData  // via seq.current+0x0C
  if animData != null and 0 <= frameIdx < animData.numFrames:
    return frameIdx * 0x1C + animData.frameTriggerBase  // each trigger = 28 bytes
  return null

FUN_00526840 — GetFrameEventNode (for step/sound callbacks)

AnimFrameEvent* GetFrameEventNode(Sequence* seq, int frameIdx):
  animData = seq.current.animData
  if animData != null and 0 <= frameIdx < animData.numFrames:
    return frameIdx * 0x10 + animData.frameEventBase  // each event = 16 bytes
  return null

FUN_00525570 — GetCurrentFrameIndex

int GetCurrentFrameIndex(Sequence* seq):
  if seq.activeNode != null:
    frameIdx = floor(seq.framePosition)   // truncate double to int
    return frameIdx
  else:
    return seq.overrideFrame  // static override (seq+0x3C)

FUN_00522DB0 — SpeedsMatch

bool SpeedsMatch(float speed1, float speed2):
  // Returns true if both are in the same "bucket" (close enough or both ≥ threshold)
  // Exact comparison is against DAT_00796344 (= 0.0 threshold)
  return |speed1 - speed2| < EPSILON

FUN_005256B0 — ApplyFrameVelocity (entity physics push from animation)

Multiplies the animation node's velocity vector (stored at node+0x10..+0x24) by the scaled frame rate and adds it to the entity's position accumulator. Sign of the accumulation is controlled by the sign of param_4:

ApplyFrameVelocity(AnimNode* node, PhysicsObject* entity,
                    double scaledRate, double frameRateSign):
  magnitude = |scaledRate|  // ABS
  if frameRateSign < 0.0: magnitude = -magnitude

  entity.posAccum.x += magnitude * node.linearVel.x   // +0x10
  entity.posAccum.y += magnitude * node.linearVel.y   // +0x14
  entity.posAccum.z += magnitude * node.linearVel.z   // +0x18
  // Then fire rotation accumulation via FUN_004525f0 with angular vel
  entity.rotAccum += magnitude * node.angularVel       // +0x1C..+0x24

11. Motion Command Bit Flags (decoded from PerformMovement)

Bit Mask Meaning Example
31 0x80000000 Stance / stance-change command Crouch, Stand, Combat
30 0x40000000 Cycle motion (loops) Walk, Run, TurnLeft
29 0x20000000 Substate override animation Breathing, Gesture
28 0x10000000 Linked one-shot motion Attack, Jump, Emote

Zero in bits 31..28 = direct animation ID reference (rare, used in stubs).


12. End-to-End Per-Frame Flow

This is the sequence of calls for one animation tick:

TickAnimations(entity, dt):
  seq = entity.Sequence
  
  // 1. Update the sequence — outer wrapper
  Sequence::Update(seq, frameRate, dt):
    // FUN_00526780
    if seq.listHead == null: 
      // No pending animations — send zero-velocity event only
      if entity != null:
        ApplyFrameVelocity(entity, 0, frameRate, frameRate)
      return
    
    // Call the actual advancement loop
    update_internal(seq, frameRate, &seq.activeNode, &seq.framePosition, entity)
    // FUN_005261d0
    
    // Trim any committed-but-not-yet-started nodes from the front
    TrimCommittedHead(seq)    // FUN_00525740

  // 2. Compute the current keyframe interpolation
  frameIdx = GetCurrentFrameIndex(seq)  // FUN_00525570: floor(framePosition)
  alpha    = frac(seq.framePosition)    // fractional part for interpolation
  animId   = seq.activeNode.animId

  // 3. Fetch the two bracketing keyframes from the dat
  frameA = GetKeyframe(animId, frameIdx)        // from Animation dat resource
  frameB = GetKeyframe(animId, frameIdx + 1)    // next keyframe
  
  // 4. Slerp each bone/part quaternion
  for part in entity.Parts:
    qA = frameA.partRotations[part.index]
    qB = frameB.partRotations[part.index]
    part.rotation = Slerp(qA, qB, alpha)
    // Also lerp translations if the animation has position data
    if animation.hasPositions:
      part.origin = Lerp(frameA.partOrigins[part.index], frameB.partOrigins[part.index], alpha)

The slerp step (step 4) is NOT in chunk_00520000.c — it lives in the rendering/GfxObj layer (likely references/ACViewer/Render/ or the physics engine). The FUN_00525d80 function shown above IS the per-frame matrix multiply that combines a parent transform with a child transform — that is the "apply keyframe to a part" step, producing the world-space position:

ApplyTransformToChild(Matrix4x3* parent, Vector7* childFrame, Matrix4x3* out):
  // parent = 4×3 rotation-translation matrix (columns at parent+0x34..+0x3C)
  // childFrame = quaternion (4 floats) + translation (3 floats)
  out.tx = parent[0,0]*child.tx + parent[0,1]*child.ty + parent[0,2]*child.tz + parent.tx
  out.ty = parent[1,0]*child.tx + ...
  out.tz = parent[2,0]*child.tx + ...
  out.rotation = QuatMultiply(parent.rotation, child.quaternion)  // FUN_00535dc0

The FUN_00535dc0 call computes the quaternion product explicitly:

outW = pW*cW - pX*cX - pY*cY - pZ*cZ
outX = pW*cX + pX*cW + pY*cZ - pZ*cY  (and cyclic permutations)

This is the standard Hamilton product, confirming AC uses standard quaternion conventions (scalar-last in memory: x,y,z,w at offsets 0,4,8,12 of the frame).


13. C# Port Notes

AnimNode class

class AnimNode {
    public AnimNode Next;          // linked list forward
    public AnimNode Prev;
    public int      FrameCount;    // dat frames
    public float    SpeedScale;    // signed; negative = reverse
    public int      StartFrame;    // inclusive
    public int      EndFrame;      // inclusive
    public AnimData AnimData;      // reference to loaded dat animation
}

Sequence.Update signature (matches FUN_00526780 wrapper)

void Update(double frameRate, PhysicsObject entity) {
    if (listHead == null) {
        if (entity != null) entity.AddVelocity(0.0, frameRate);
        return;
    }
    UpdateInternal(frameRate, ref activeNode, ref framePosition, entity);
    TrimCommittedHead();
}

Critical correctness notes

  1. framePosition is a double — not a float. The original uses 64-bit double throughout for sub-frame precision.
  2. Boundary: floor then compare — frame crossings are detected by floor(newPos) != floor(oldPos), iterating one integer step at a time.
  3. Negative speedScale swaps start/end — when AdjustNodeSpeed is given a negative speed, it literally swaps the +0x14/+0x18 words so that update_internal's forward-traversal code naturally runs the animation backwards.
  4. Frame trigger vs. frame event: two separate arrays per animation dat. Triggers (0x1C stride) fire FireApproachEvent/FireLeaveEvent; Events (0x10 stride) fire sound/particle callbacks. Both are fired once per whole-frame crossing.
  5. current vs activeNode: seq.current (+0x0C) is the "committed" pointer (furthest node the sequencer has acknowledged), while activeNode (+0x38) is the node being ticked RIGHT NOW. They differ during transitions.
  6. Pending list drain vs trim: ClearPendingList (FUN_005259c0) nukes everything and resets framePos. TrimCommittedHead (FUN_00525740) only removes nodes that precede the current committed pointer — never the active node or anything after it. Use TrimCommittedHead at the END of each tick, not ClearPendingList.
  7. AddAnimationsToSequence is idempotent for null links — all callers pass potentially-null TransitionLink* pointers; the null guard is the first line of the function.

14. Missing Piece: Keyframe Slerp

The actual quaternion-slerp keyframe interpolation is NOT in chunk_00520000.c. Based on the call to FUN_00535dc0 (quaternion multiply) and the structure of FUN_00525d80 (transform-chain application), the slerp likely lives in the dat-animation loader that converts from packed keyframe storage to quaternion pairs.

To find it: search for calls to FUN_00535dc0 from functions that also call FUN_00525570 (GetCurrentFrameIndex) and read from animation frame arrays. The interpolation formula from ACViewer's GetFrame() (which we already have ported) is confirmed correct for the rendering layer.

Our existing AnimationSequencer.cs already does the slerp via Unity's Quaternion.Slerp — the gap is only in wiring framePosition correctly to the (floor, frac) split shown above.