fix(anim): implement adjust_motion — TurnLeft/SideStepLeft play backward

ROOT CAUSE FIX for missing left-side animations.

The AC client's MotionTable has NO cycles for TurnLeft (0x000E),
SideStepLeft (0x0010), or WalkBackward (0x0006). The real client
calls adjust_motion() which remaps these to their right-side
equivalents with NEGATIVE speed before looking up the cycle. Then
multiply_framerate() swaps LowFrame↔HighFrame so the animation
plays backward.

Source: ACE MotionInterp.cs:394-428, decompiled FUN_005267E0.

Changes:
- AnimationSequencer.SetCycle: adds adjust_motion block that remaps
  left→right with speed *= -1 (TurnLeft, SideStepLeft) or
  speed *= -0.65 (WalkBackward = BackwardsFactor)
- LoadAnimNode: when framerate < 0, swaps Low↔High (matching the
  decompiled multiply_framerate)
- GameWindow.UpdatePlayerAnimation: passes original animCommand to
  SetCycle (sequencer handles remapping internally), keeps legacy
  fallback for non-sequencer entities

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-13 12:17:26 +02:00
parent 0e66078e57
commit 67b51a3e6f
3 changed files with 339 additions and 35 deletions

View file

@ -0,0 +1,279 @@
# acclient.exe Animation System Function Map
Mapped from decompiled C chunks against ACE's C# animation port.
Cross-referenced with `references/ACE/Source/ACE.Server/Physics/`.
---
## CPartArray (PhysicsObj+0x10 ptr)
PartArray is heap-allocated and accessed via a pointer stored at `PhysicsObj+0x10`.
The Sequence is embedded inside PartArray at `PartArray+0x08`.
### Struct Layout
| Offset | Field | Type | Notes |
|--------|-------|------|-------|
| +0x00 | State | uint | Flags (0x10000 = HasPhysicsBSP) |
| +0x04 | Owner | ptr | PhysicsObj* |
| +0x08 | Sequence | embedded | 68 bytes of Sequence struct |
| +0x50 | MotionTableManager | ptr | null if no motion table |
| +0x54 | Setup | ptr | CSetup dat object |
| +0x58 | NumParts | uint | Part count |
| +0x5c | Parts | ptr | Array of PhysicsPart* |
| +0x60 | Scale | float[3] | XYZ scale (default 1,1,1) |
| +0x70 | ShadowParts | ptr | Shadow part array |
### Functions (chunk_00510000.c)
| Address | FUN name | ACE method | Description |
|---------|----------|-----------|-------------|
| 0x510140 | FUN_00510140 | PartArray::UpdateParts | Calls SetFrame when anim flag set |
| 0x512DE0 | FUN_00512de0 | AFrame::Combine | Quaternion compose + position transform |
| 0x513730 | FUN_00513730 | PhysicsObj::UpdatePositionInternal | Advances physics: calls PartArray::Update then SetFrame |
| 0x514B90 | FUN_00514b90 | PhysicsObj::UpdateObjectInternal | Calls PartArray::Update (SetFrame) |
| 0x518790 | FUN_00518790 | PartArray::CopyShadowParts | Copies frame data to shadow parts array (stride 0x48/part) |
| 0x5188E0 | FUN_005188e0 | PartArray::Update | Thin wrapper: calls Sequence::Update (FUN_00526780) |
| 0x519B00 | FUN_00519b00 | PartArray::SetPartFrame | Per-part transform: matrix3×3 + quaternion multiply → part position |
| 0x519C20 | FUN_00519c20 | PartArray::UpdateParts | Loops over NumParts (stride 0x1c per anim frame part), calls SetPartFrame |
| 0x519E40 | FUN_00519e40 | PartArray::SetFrame | Top-level frame apply: calls UpdateParts + CopyShadowParts |
**Key pattern in FUN_00519b00** (SetPartFrame):
Computes `pos = matrix3x3 * scale + parentPos`, then calls `FUN_00535dc0` (quaternion normalize) to compose rotation. The matrix at `animFrame[partIdx]` has 16 floats:
`[0..3]`=quaternion, `[4..0xc]`=rotation matrix (computed from quat), `[0xd..0xf]`=XYZ position.
---
## CSequence (embedded at PartArray+0x08)
### Struct Layout
| Offset | Field | Type | Notes |
|--------|-------|------|-------|
| +0x00 | vtable | ptr | Points to &PTR_FUN_007c92a0 |
| +0x04 | AnimList.Head | ptr | First node in linked list |
| +0x08 | AnimList.Tail | ptr | Last node sentinel |
| +0x0c | FirstCyclic | ptr | Node before first cyclic anim |
| +0x10 | Velocity.X | float | |
| +0x14 | Velocity.Y | float | |
| +0x18 | Velocity.Z | float | |
| +0x1c | Omega.X | float | Angular velocity |
| +0x20 | Omega.Y | float | |
| +0x24 | Omega.Z | float | |
| +0x28 | HookObj | ptr | PhysicsObj for animation hooks |
| +0x30 | FrameNumber | double | Current frame (fractional) |
| +0x38 | CurrAnim | ptr | Current AnimSequenceNode* |
| +0x3c | PlacementFrame | ptr | AnimationFrame* (used when no anims) |
| +0x40 | PlacementFrameID | int | Placement ID (e.g. 0x65) |
Total size: ~0x44 = 68 bytes.
### Functions (chunk_00520000.c)
| Address | FUN name | ACE method | Description |
|---------|----------|-----------|-------------|
| 0x5254C0 | FUN_005254c0 | Sequence::CombinePhysics | Adds velocity+omega to sequence totals |
| 0x525500 | FUN_00525500 | Sequence::subtract_physics | Subtracts velocity+omega |
| 0x525540 | FUN_00525540 | Sequence::remove_all_link_animations | Removes all pre-cyclic nodes from list |
| 0x525570 | FUN_00525570 | Sequence::GetCurrAnimFrame | If CurrAnim≠null: floor(FrameNumber)→get_part_frame; else PlacementFrame |
| 0x5255B0 | FUN_005255b0 | Sequence::SetPlacementFrame | Sets PlacementFrame ptr (+0x3c) and ID (+0x40) |
| 0x5255D0 | FUN_005255d0 | Sequence::get_curr_frame_number | floor(FrameNumber) |
| 0x5255F0 | FUN_005255f0 | Sequence::Init | Zero-initializes all fields, sets vtable |
| 0x525630 | FUN_00525630 | Sequence::clear_animations | Walks linked list, releases all nodes |
| 0x5256B0 | FUN_005256b0 | Sequence::apply_physics | frame.Origin += Velocity\*quantum; frame.Rotate(Omega\*quantum) |
| 0x525740 | FUN_00525740 | Sequence::apricot | Removes consumed nodes up to CurrAnim |
| 0x525EB0 | FUN_00525eb0 | Sequence::advance_to_next_animation | Moves CurrAnim to next/prev node or wraps to FirstCyclic |
| 0x526110 | FUN_00526110 | Sequence::append_animation | Allocates AnimSequenceNode (0x1c bytes), links at tail, sets FirstCyclic and CurrAnim if first |
| 0x5261B0 | FUN_005261b0 | Sequence::Clear | Calls clear_animations + clear_physics, zeroes PlacementFrame |
| 0x5261D0 | FUN_005261d0 | Sequence::update_internal | **Core update loop**: `FrameNumber += Framerate * elapsed`; handles forward/backward; fires hooks; calls advance_to_next_animation when done |
| 0x526780 | FUN_00526780 | Sequence::Update | Wrapper: if AnimList not empty → update_internal + apricot; else apply_physics only |
**Key pattern in FUN_005261d0** (update_internal):
```c
// Forward direction (frametime > 0):
while (floor(frameNum) > lastFrame) {
apply_physics + execute_hooks(lastFrame);
lastFrame++;
}
if (floor(frameNum) > get_high_frame()) {
frameTimeElapsed = (frameNum - get_high_frame() - 1.0) / framerate;
frameNum = get_high_frame();
advance_to_next_animation(timeElapsed, ...);
recurse with remaining time
}
```
---
## CAnimSequenceNode (linked list node, 0x1c bytes)
### Struct Layout
| Offset | Field | Type | Notes |
|--------|-------|------|-------|
| +0x00 | prev | ptr | Previous linked list node |
| +0x04 | next | ptr | Next linked list node |
| +0x08 | (sentinel) | ptr | |
| +0x0c | Anim | ptr | Animation* dat object |
| +0x10 | Framerate | float | Frames per second (default 30.0) |
| +0x14 | LowFrame | int | Start frame |
| +0x18 | HighFrame | int | End frame (-1 = use all) |
Total size: 0x1c = 28 bytes (heap allocated via `FUN_005df0f5(0x1c)`).
### Functions (chunk_00520000.c)
| Address | FUN name | ACE method | Description |
|---------|----------|-----------|-------------|
| 0x526810 | FUN_00526810 | AnimSequenceNode::get_pos_frame | Returns PosFrames[frameIdx] at ptr+frame\*0x1c |
| 0x526840 | FUN_00526840 | AnimSequenceNode::get_part_frame | Returns PartFrames[frameIdx] at ptr+frame\*0x10 |
| 0x526870 | FUN_00526870 | AnimSequenceNode::has_anim | Returns Anim != null |
| 0x526880 | FUN_00526880 | AnimSequenceNode::get_starting_frame | LowFrame if Framerate≥0, else HighFrame+1-ε |
| 0x5268B0 | FUN_005268b0 | AnimSequenceNode::get_ending_frame | HighFrame+1-ε if Framerate≥0, else LowFrame |
| 0x5267E0 | FUN_005267e0 | AnimSequenceNode::multiply_framerate | Swaps Low/High if multiplier<0; Framerate \*= multiplier |
**Animation dat object layout** (referenced from AnimSequenceNode+0x0c):
- `Anim+0x38`: PosFrames array ptr (AFrame per frame, stride 0x1c)
- `Anim+0x3c`: PartFrames array ptr (AnimationFrame per frame, stride 0x10 per part)
- `Anim+0x48`: NumFrames (int)
---
## MotionTable / MotionTableManager (chunk_00520000.c)
### Cycle Key Construction
The cycle key is built as: `(motionCategory << 16) | (command & 0xFFFFFF)`
Confirmed at lines 2469, 2474, 2491, 2551, 2597, 2660, 2699 in chunk_00520000.c:
```c
key = param_4 & 0xffffff | param_1 << 0x10;
// or equivalently:
key = (int)*param_3 << 0x10 | (uint)param_2 & 0xffffff;
```
### Hash Map Structure (used for all MotionTable lookups)
The hash map has:
- `+0x04`: hash mask (capacity - 1)
- `+0x08`: hash shift
- `+0x0c`: bucket array ptr
- Each bucket is a linked list of nodes; each node has key at `+0x08`, value at `+0x0c`, next at `+0x04`
Hash formula: `bucketIdx = (key >> hashShift ^ key) & hashMask`
### Functions
| Address | FUN name | ACE method | Description |
|---------|----------|-----------|-------------|
| 0x520A00 | FUN_00520a00 | MotionTableManager::Init | Initializes all MTM fields (large constructor) |
| 0x520BB0 | FUN_00520bb0 | MotionTableManager::Create | Factory: allocates 0xd8 bytes, calls Init |
| 0x521770 | FUN_00521770 | MotionTable::unpack | Deserializes MotionTable from dat stream |
| 0x521F10 | FUN_00521f10 | MotionTable::find_motion | Hash map lookup by motionID |
| 0x521F60 | FUN_00521f60 | MotionTable::destructor | Frees all MotionTable allocations |
| 0x522E30 | FUN_00522e30 | MotionTableManager::SetCurrentMotion | Sets current state node |
| 0x523000 | FUN_00523000 | MotionTable::add_animations_to_sequence | Applies velocity/omega + appends AnimData to Sequence |
| 0x5230D0 | FUN_005230d0 | MotionTable::set_physics | Sets velocity+omega on Sequence only |
| 0x523150 | FUN_00523150 | MotionTable::subtract_physics | Subtracts velocity+omega from Sequence |
| 0x5231D0 | FUN_005231d0 | MotionTable::FindObjectAndMotionData | Hash lookup returning node ptr-4 |
| 0x523210 | FUN_00523210 | MotionTable::FindObjectAndCycle | Hash lookup returning cycle data at node+0xc |
| 0x523260 | FUN_00523260 | MotionTable::CheckAlreadySet | Checks if motion already current at same speed |
| 0x5232B0 | FUN_005232b0 | MotionTable::GetObjectAnimData | Builds cycle key (`src<<16 \| dst&0xFFFFFF`), calls FindObjectAndMotionData |
| 0x523400 | FUN_00523400 | MotionTableManager::PerformMovement | **Core motion dispatch**: transitions between states, populates Sequence |
**FUN_00523400 (PerformMovement) key pattern**:
```c
// Transition key for cycle lookup:
key = (int)*param_3 << 0x10 | (uint)local_8 & 0xffffff;
anim_data = FUN_005231d0(key); // FindObjectAndMotionData
// Then:
FUN_00523000(sequence, link_anim, speed); // add link animations
FUN_00523000(sequence, from_anim, speed); // add departure animation
FUN_00523000(sequence, to_anim, speed); // add transition animation
FUN_00523000(sequence, cycle_anim, speed); // add cyclic animation
```
---
## AFrame (chunk_00530000.c)
AFrame is a combined rotation+position: quaternion (4 floats) + 3×3 matrix (derived) + XYZ (3 floats).
### Struct Layout (0x1c = 28 bytes total)
| Offset | Field | Type |
|--------|-------|------|
| +0x00..+0x0c | Quaternion W,X,Y,Z | float[4] |
| +0x10..+0x30 | Matrix 3×3 (derived) | float[9] |
| +0x34..+0x3c | Origin XYZ | float[3] |
Note: In Ghidra decompiled code the struct appears as offsets in uint32s:
`[0..3]` = quaternion, `[4..0xc]` = matrix, `[0xd..0xf]` = origin.
### Functions
| Address | FUN name | ACE method | Description |
|---------|----------|-----------|-------------|
| 0x535B30 | FUN_00535b30 | AFrame::update_matrix | Quaternion → 3×3 rotation matrix (stores in [4..0xc]) |
| 0x535C10 | FUN_00535c10 | AFrame::is_valid | Checks NaN in origin [0xd..0xf] and quaternion [0..3] |
| 0x535DC0 | FUN_00535dc0 | AFrame::normalize_quaternion | Normalizes quat: each *= 1/sqrt(dot); validates with is_valid |
| 0x535E70 | FUN_00535e70 | AFrame::pack_quaternion | Serializes quaternion [0..3] + origin [0xd..0xf] to byte stream |
---
## PhysicsObj Animation-Relevant Offsets (chunk_00510000.c)
| Offset | Field | Notes |
|--------|-------|-------|
| +0x10 | PartArray ptr | null = no animation; checked before every anim call |
| +0x50 | Position.Frame | AFrame (embedded); updated by FUN_00512de0 (AFrame::Combine) |
| +0xA8 | State | bit 0x4000 = no-animate flag; bit 0x1000 = invisible |
| +0xD8 | LastUpdateTime | double; used to compute dt for Sequence::Update |
| +0x114 | RunRate | float; velocity scale applied to omega after anim update |
---
## Call Chain: Per-Frame Animation Update
```
PhysicsEngine::update (FUN_00452A10)
→ PhysicsObj::update_object (FUN_00515020)
→ PhysicsObj::UpdatePositionInternal (FUN_00513730)
→ PartArray::Update (FUN_005188E0) [if PhysicsObj+0x10 != null]
→ Sequence::Update (FUN_00526780)
→ Sequence::update_internal (FUN_005261D0)
→ AnimSequenceNode::get_part_frame (FUN_00526840)
→ Sequence::execute_hooks (via FUN_00525430)
→ Sequence::advance_to_next_animation (FUN_00525EB0)
→ AnimSequenceNode::get_starting_frame (FUN_00526880)
→ Sequence::apply_physics (FUN_005256B0)
→ Sequence::apricot (FUN_00525740)
→ AFrame::Combine (FUN_00512DE0) [combines offsetFrame into PhysicsObj.Position]
→ PartArray::SetFrame (FUN_00519E40) [if PhysicsObj+0x10 != null]
→ PartArray::UpdateParts (FUN_00519C20)
→ Sequence::GetCurrAnimFrame (FUN_00525570)
→ PartArray::SetPartFrame (FUN_00519B00) [per-part matrix*scale+quaternion]
→ PartArray::CopyShadowParts (FUN_00518790) [if shadow parts exist]
```
---
## Key Constants and Identifiers
| Constant | Value | Meaning |
|----------|-------|---------|
| AnimSequenceNode size | 0x1c (28) | heap alloc size |
| AnimationFrame stride (PartFrames) | 0x10 per part | 4 floats: quaternion per part |
| AFrame size | 0x1c (28) | quaternion+matrix+pos |
| AnimData stride in MotionTable nodes | 0x1c | 7 uint32s per entry |
| PosFrames stride | 0x1c per frame | AFrame per position keyframe |
| PhysicsObj.PartArray offset | +0x10 | pointer field |
| PhysicsObj.Position.Frame offset | +0x50 | AFrame embedded |
| PartArray.NumParts offset | +0x58 (relative to PartArray) | uint |
| PartArray.Scale offset | +0x60 (relative to PartArray) | float[3] |
| Cycle key formula | `(category << 16) \| (cmd & 0xFFFFFF)` | MotionTable lookup |
| Default framerate | 30.0 (0x41F00000) | AnimSequenceNode default |
| PhysicsGlobals.EPSILON | from _DAT_007c92b4 | ~1e-5 |
| No-animate state flag | 0x6500000d | Walk/Run forward (left) |
| No-animate state flag | 0x6500000f | Walk/Run backward |

View file

@ -2054,47 +2054,35 @@ public sealed class GameWindow : IDisposable
stanceOverride: NonCombatStance, stanceOverride: NonCombatStance,
commandOverride: cmdOverride); commandOverride: cmdOverride);
// AC reuses right-side animations for left-side motions (played in // The sequencer handles left→right remapping internally via
// reverse). If the left-side command has no cycle, fall back to the // adjust_motion (TurnLeft→TurnRight with negative speed, etc.).
// right-side equivalent so the player isn't stuck in idle. // Pass the ORIGINAL animCommand — SetCycle does the remapping.
uint resolvedCommand = animCommand; // track which command actually resolved 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) if (cycle is null || cycle.Framerate == 0f || cycle.HighFrame <= cycle.LowFrame)
{ {
ushort fallback = cmdOverride switch ushort fallback = cmdOverride switch
{ {
0x000E => 0x000D, // TurnLeft → TurnRight 0x000E => 0x000D,
0x0010 => 0x000F, // SideStepLeft → SideStepRight 0x0010 => 0x000F,
0x0006 => 0x0005, // WalkBackward → WalkForward 0x0006 => 0x0005,
_ => (ushort)0, _ => (ushort)0,
}; };
if (fallback != 0) if (fallback != 0)
{
cycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle( cycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
ae.Setup, _dats, ae.Setup, _dats,
motionTableIdOverride: _playerMotionTableId, motionTableIdOverride: _playerMotionTableId,
stanceOverride: NonCombatStance, stanceOverride: NonCombatStance,
commandOverride: fallback); commandOverride: fallback);
// Update resolvedCommand so the sequencer looks up the right cycle
resolvedCommand = (animCommand & 0xFF000000u) | fallback;
}
} }
if (cycle is null || cycle.Framerate == 0f || cycle.HighFrame <= cycle.LowFrame) if (cycle is null || cycle.Framerate == 0f || cycle.HighFrame <= cycle.LowFrame) return;
{
Console.WriteLine($"[ANIM-DIAG] FAILED: animCmd=0x{animCommand:X8} resolved=0x{resolvedCommand:X8} cmdOverride=0x{cmdOverride:X4} cycle={cycle is not null} fr={cycle?.Framerate} lo={cycle?.LowFrame} hi={cycle?.HighFrame}");
return;
}
Console.WriteLine($"[ANIM-DIAG] OK: animCmd=0x{animCommand:X8} resolved=0x{resolvedCommand:X8} hasSeq={ae.Sequencer is not null} fr={cycle.Framerate} lo={cycle.LowFrame} hi={cycle.HighFrame}");
// If the entity has a sequencer, use SetCycle for transition-link-aware
// motion switching. Pass the RESOLVED command (after left→right fallback)
// so the sequencer's internal cycle lookup finds the same animation.
if (ae.Sequencer is not null)
{
uint fullStyle = 0x80000000u | (uint)NonCombatStance;
ae.Sequencer.SetCycle(fullStyle, resolvedCommand);
}
ae.Animation = cycle.Animation; ae.Animation = cycle.Animation;
ae.LowFrame = Math.Max(0, cycle.LowFrame); ae.LowFrame = Math.Max(0, cycle.LowFrame);

View file

@ -173,32 +173,55 @@ public sealed class AnimationSequencer
/// <param name="speedMod">Speed multiplier applied to framerates (1.0 = normal).</param> /// <param name="speedMod">Speed multiplier applied to framerates (1.0 = normal).</param>
public void SetCycle(uint style, uint motion, float speedMod = 1f) public void SetCycle(uint style, uint motion, float speedMod = 1f)
{ {
// ── adjust_motion: remap left→right variants with negative speed ───
// The AC client's MotionTable has NO cycles for TurnLeft, SideStepLeft,
// or WalkBackward. These are played as their right-side / forward
// equivalents with negative framerate (animation runs backward).
// ACE: MotionInterp.cs:394-428
uint adjustedMotion = motion;
float adjustedSpeed = speedMod;
switch (motion & 0xFFFFu)
{
case 0x000E: // TurnLeft → TurnRight
adjustedMotion = (motion & 0xFFFF0000u) | 0x000Du;
adjustedSpeed *= -1f;
break;
case 0x0010: // SideStepLeft → SideStepRight
adjustedMotion = (motion & 0xFFFF0000u) | 0x000Fu;
adjustedSpeed *= -1f;
break;
case 0x0006: // WalkBackward → WalkForward
adjustedMotion = (motion & 0xFFFF0000u) | 0x0005u;
adjustedSpeed *= -0.65f; // BackwardsFactor from ACE
break;
}
// Fast-path: already playing this exact motion at the same speed. // Fast-path: already playing this exact motion at the same speed.
if (CurrentStyle == style && CurrentMotion == motion if (CurrentStyle == style && CurrentMotion == motion
&& _firstCyclic != null && _queue.Count > 0) && _firstCyclic != null && _queue.Count > 0)
return; return;
// Resolve transition link (currentSubstate → newMotion). // Resolve transition link (currentSubstate → adjustedMotion).
MotionData? linkData = CurrentMotion != 0 MotionData? linkData = CurrentMotion != 0
? GetLink(style, CurrentMotion, motion) ? GetLink(style, CurrentMotion, adjustedMotion)
: null; : null;
// Resolve target cycle. // Resolve target cycle using the ADJUSTED motion (TurnRight, not TurnLeft).
int cycleKey = (int)(((style & 0xFFFFu) << 16) | (motion & 0xFFFFFFu)); int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu));
_mtable.Cycles.TryGetValue(cycleKey, out var cycleData); _mtable.Cycles.TryGetValue(cycleKey, out var cycleData);
// Clear the old cyclic tail; keep any non-cyclic head that hasn't // Clear the old cyclic tail; keep any non-cyclic head that hasn't
// been played yet (ACE behaviour: non-cyclic anims drain naturally). // been played yet (ACE behaviour: non-cyclic anims drain naturally).
ClearCyclicTail(); ClearCyclicTail();
// Enqueue link frames. // Enqueue link frames (with adjusted speed for left→right remapping).
if (linkData is { Anims.Count: > 0 }) if (linkData is { Anims.Count: > 0 })
EnqueueMotionData(linkData, speedMod, isLooping: false); EnqueueMotionData(linkData, adjustedSpeed, isLooping: false);
// Enqueue new cycle. // Enqueue new cycle.
if (cycleData is { Anims.Count: > 0 }) if (cycleData is { Anims.Count: > 0 })
{ {
EnqueueMotionData(cycleData, speedMod, isLooping: true); EnqueueMotionData(cycleData, adjustedSpeed, isLooping: true);
} }
else if (_queue.Count == 0) else if (_queue.Count == 0)
{ {
@ -366,9 +389,23 @@ public sealed class AnimationSequencer
if (low >= numFrames) low = numFrames - 1; if (low >= numFrames) low = numFrames - 1;
if (high >= numFrames) high = numFrames - 1; if (high >= numFrames) high = numFrames - 1;
if (low < 0) low = 0; if (low < 0) low = 0;
if (low > high) high = low;
float fr = ad.Framerate * speedMod; float fr = ad.Framerate * speedMod;
// multiply_framerate: when speed is negative (TurnLeft, SideStepLeft),
// swap Low↔High so the animation plays backward. This is exactly what
// the decompiled FUN_005267E0 does. ACE: AnimData.GetFramerate(speed).
// After swap, LowFrame > HighFrame — the Advance loop handles this
// by checking negative frametime against LowFrame (the higher value).
if (fr < 0f)
{
(low, high) = (high, low);
}
else
{
if (low > high) high = low; // only clamp for positive-speed case
}
return new AnimNode(anim, fr, low, high, isLooping); return new AnimNode(anim, fr, low, high, isLooping);
} }