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>
1100 lines
45 KiB
Markdown
1100 lines
45 KiB
Markdown
# 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.
|