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

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

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

```pseudocode
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.

```pseudocode
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:

```pseudocode
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.

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

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

```pseudocode
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.

```pseudocode
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.

```pseudocode
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]`.

```pseudocode
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:

```pseudocode
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:
```pseudocode
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
```pseudocode
bool HasActiveAnimation(Sequence* seq):
  return seq.current != null  // seq+0x0C
```

### `FUN_005257D0` — HasPendingAnimation
```pseudocode
bool HasPendingAnimation(Sequence* seq):
  return seq.listHead != null  // seq+0x04
```

### `FUN_00526810` — GetFrameTriggerNode (for "start of frame" events)
```pseudocode
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)
```pseudocode
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
```pseudocode
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
```pseudocode
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`:

```pseudocode
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:

```pseudocode
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
```csharp
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)
```csharp
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.
