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

1100 lines
45 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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