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