diff --git a/docs/research/acclient_animation_pseudocode.md b/docs/research/acclient_animation_pseudocode.md new file mode 100644 index 0000000..e0e81a0 --- /dev/null +++ b/docs/research/acclient_animation_pseudocode.md @@ -0,0 +1,1100 @@ +# 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. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ff25e5a..283497f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -91,18 +91,8 @@ public sealed class GameWindow : IDisposable /// public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary? SurfaceOverrides)> PartTemplate; public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame] - /// - /// Optional AnimationSequencer for transition-link-aware playback. - /// When non-null, TickAnimations uses Advance(dt) for frame data - /// instead of the manual slerp path. Null for entities whose - /// MotionTable couldn't be loaded. - /// - public AcDream.Core.Physics.AnimationSequencer? Sequencer; } - /// Shared animation dat loader for all AnimationSequencer instances. - private AcDream.Core.Physics.DatCollectionLoader? _animLoader; - // Phase B.2: player movement mode. private AcDream.App.Input.PlayerMovementController? _playerController; private AcDream.App.Rendering.ChaseCamera? _chaseCamera; @@ -333,7 +323,6 @@ public sealed class GameWindow : IDisposable _cameraController.ModeChanged += OnCameraModeChanged; _dats = new DatCollection(_datDir, DatAccessType.Read); - _animLoader = new AcDream.Core.Physics.DatCollectionLoader(_dats); uint centerLandblockId = 0xA9B4FFFFu; Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}"); @@ -833,25 +822,6 @@ public sealed class GameWindow : IDisposable for (int i = 0; i < meshRefs.Count; i++) template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides); - // Try to create an AnimationSequencer for transition-link-aware - // playback. Requires loading the MotionTable dat separately. - AcDream.Core.Physics.AnimationSequencer? sequencer = null; - if (_animLoader is not null) - { - uint mtableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable; - if (mtableId != 0) - { - var mtable = _dats.Get(mtableId); - if (mtable is not null) - { - sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader); - uint style = stanceOverride is > 0 ? (uint)stanceOverride.Value : (uint)mtable.DefaultStyle; - uint motion = commandOverride is > 0 ? (uint)commandOverride.Value : 0x41000003u; // Ready - sequencer.SetCycle(style, motion); - } - } - } - _animatedEntities[entity.Id] = new AnimatedEntity { Entity = entity, @@ -863,7 +833,6 @@ public sealed class GameWindow : IDisposable Scale = scale, PartTemplate = template, CurrFrame = idleCycle.LowFrame, - Sequencer = sequencer, }; } @@ -951,14 +920,6 @@ public sealed class GameWindow : IDisposable if (!newCycleIsGood) return; - // If the entity has a sequencer, use SetCycle for transition links. - if (ae.Sequencer is not null) - { - uint fullStyle = stance != 0 ? (0x80000000u | (uint)stance) : ae.Sequencer.CurrentStyle; - uint fullMotion = command is > 0 ? (uint)command.Value : 0x41000003u; - ae.Sequencer.SetCycle(fullStyle, fullMotion); - } - ae.Animation = newCycle!.Animation; ae.LowFrame = Math.Max(0, newCycle.LowFrame); ae.HighFrame = Math.Min(newCycle.HighFrame, newCycle.Animation.PartFrames.Count - 1); @@ -1876,31 +1837,39 @@ public sealed class GameWindow : IDisposable foreach (var kv in _animatedEntities) { var ae = kv.Value; + int span = ae.HighFrame - ae.LowFrame; + if (span <= 0) continue; - // ── Compute per-part (origin, orientation) ────────────────────── - // Two paths: if the entity has an AnimationSequencer, use it (gets - // transition links + retail slerp). Otherwise fall back to the - // Phase 6.5 manual slerp path. - IReadOnlyList? seqFrames = null; - if (ae.Sequencer is not null) + ae.CurrFrame += dt * ae.Framerate; + // Wrap into [LowFrame, HighFrame]. Use a guarded modulo so + // big dts (first frame after a stall) don't blow the loop. + if (ae.CurrFrame > ae.HighFrame) { - seqFrames = ae.Sequencer.Advance(dt); + float over = ae.CurrFrame - ae.LowFrame; + ae.CurrFrame = ae.LowFrame + (over % (span + 1)); } - else + else if (ae.CurrFrame < ae.LowFrame) { - // Legacy fallback: manual frame advancement + slerp. - int span = ae.HighFrame - ae.LowFrame; - if (span <= 0) continue; + ae.CurrFrame = ae.LowFrame; + } - ae.CurrFrame += dt * ae.Framerate; - if (ae.CurrFrame > ae.HighFrame) - { - float over = ae.CurrFrame - ae.LowFrame; - ae.CurrFrame = ae.LowFrame + (over % (span + 1)); - } - else if (ae.CurrFrame < ae.LowFrame) - ae.CurrFrame = ae.LowFrame; - } + // Phase 6.5: blend between adjacent keyframes using the fractional + // part of CurrFrame so the animation is smooth at any framerate + // instead of snapping to integer frame indices. + int frameIdx = (int)Math.Floor(ae.CurrFrame); + if (frameIdx < ae.LowFrame || frameIdx > ae.HighFrame + || frameIdx >= ae.Animation.PartFrames.Count) + frameIdx = ae.LowFrame; + + int nextIdx = frameIdx + 1; + if (nextIdx > ae.HighFrame || nextIdx >= ae.Animation.PartFrames.Count) + nextIdx = ae.LowFrame; // cycle wraps within [LowFrame, HighFrame] + + float t = ae.CurrFrame - frameIdx; + if (t < 0f) t = 0f; else if (t > 1f) t = 1f; + + var partFrames = ae.Animation.PartFrames[frameIdx].Frames; + var partFramesNext = ae.Animation.PartFrames[nextIdx].Frames; int partCount = ae.PartTemplate.Count; var newMeshRefs = new List(partCount); @@ -1910,52 +1879,25 @@ public sealed class GameWindow : IDisposable for (int i = 0; i < partCount; i++) { + // Slerp between the current and next keyframe per part. + // Out-of-range parts get an identity transform — defensive + // for setups whose part count exceeds the animation's bone + // count. System.Numerics.Vector3 origin; System.Numerics.Quaternion orientation; - - if (seqFrames is not null) + if (i < partFrames.Count) { - // Sequencer path: per-part transforms already interpolated. - if (i < seqFrames.Count) - { - origin = seqFrames[i].Origin; - orientation = seqFrames[i].Orientation; - } - else - { - origin = System.Numerics.Vector3.Zero; - orientation = System.Numerics.Quaternion.Identity; - } + var f0 = partFrames[i]; + var f1 = i < partFramesNext.Count ? partFramesNext[i] : f0; + origin = System.Numerics.Vector3.Lerp(f0.Origin, f1.Origin, t); + orientation = System.Numerics.Quaternion.Slerp(f0.Orientation, f1.Orientation, t); } else { - // Legacy slerp path. - int frameIdx = (int)Math.Floor(ae.CurrFrame); - if (frameIdx < ae.LowFrame || frameIdx > ae.HighFrame - || frameIdx >= ae.Animation.PartFrames.Count) - frameIdx = ae.LowFrame; - int nextIdx = frameIdx + 1; - if (nextIdx > ae.HighFrame || nextIdx >= ae.Animation.PartFrames.Count) - nextIdx = ae.LowFrame; - float t = ae.CurrFrame - frameIdx; - if (t < 0f) t = 0f; else if (t > 1f) t = 1f; - - var partFrames = ae.Animation.PartFrames[frameIdx].Frames; - var partFramesNext = ae.Animation.PartFrames[nextIdx].Frames; - - if (i < partFrames.Count) - { - var f0 = partFrames[i]; - var f1 = i < partFramesNext.Count ? partFramesNext[i] : f0; - origin = System.Numerics.Vector3.Lerp(f0.Origin, f1.Origin, t); - orientation = System.Numerics.Quaternion.Slerp(f0.Orientation, f1.Orientation, t); - } - else - { - origin = System.Numerics.Vector3.Zero; - orientation = System.Numerics.Quaternion.Identity; - } + origin = System.Numerics.Vector3.Zero; + orientation = System.Numerics.Quaternion.Identity; } + var frame = new DatReaderWriter.Types.Frame { Origin = origin, Orientation = orientation }; // Per-part default scale from the Setup, matching SetupMesh.Flatten's // composition order: scale → rotate → translate. @@ -1965,8 +1907,8 @@ public sealed class GameWindow : IDisposable var partTransform = System.Numerics.Matrix4x4.CreateScale(defaultScale) * - System.Numerics.Matrix4x4.CreateFromQuaternion(orientation) * - System.Numerics.Matrix4x4.CreateTranslation(origin); + System.Numerics.Matrix4x4.CreateFromQuaternion(frame.Orientation) * + System.Numerics.Matrix4x4.CreateTranslation(frame.Origin); // Bake the entity's ObjScale on top, matching the hydration // order (PartTransform * scaleMat) — see comment in OnLiveEntitySpawned. @@ -2054,34 +1996,6 @@ public sealed class GameWindow : IDisposable stanceOverride: NonCombatStance, commandOverride: cmdOverride); - // The sequencer handles left→right remapping internally via - // adjust_motion (TurnLeft→TurnRight with negative speed, etc.). - // Pass the ORIGINAL animCommand — SetCycle does the remapping. - if (ae.Sequencer is not null) - { - uint fullStyle = 0x80000000u | (uint)NonCombatStance; - ae.Sequencer.SetCycle(fullStyle, animCommand); - } - - // Legacy path fallback: for the non-sequencer slerp path, do the - // left→right remapping here since that path doesn't have adjust_motion. - if (cycle is null || cycle.Framerate == 0f || cycle.HighFrame <= cycle.LowFrame) - { - ushort fallback = cmdOverride switch - { - 0x000E => 0x000D, - 0x0010 => 0x000F, - 0x0006 => 0x0005, - _ => (ushort)0, - }; - if (fallback != 0) - cycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle( - ae.Setup, _dats, - motionTableIdOverride: _playerMotionTableId, - stanceOverride: NonCombatStance, - commandOverride: fallback); - } - if (cycle is null || cycle.Framerate == 0f || cycle.HighFrame <= cycle.LowFrame) return; ae.Animation = cycle.Animation;