From 67b51a3e6f350cd20d834af515cf4c48a6a275a9 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 13 Apr 2026 12:17:26 +0200 Subject: [PATCH] =?UTF-8?q?fix(anim):=20implement=20adjust=5Fmotion=20?= =?UTF-8?q?=E2=80=94=20TurnLeft/SideStepLeft=20play=20backward?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/research/acclient_animation_map.md | 279 ++++++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 42 +-- .../Physics/AnimationSequencer.cs | 53 +++- 3 files changed, 339 insertions(+), 35 deletions(-) create mode 100644 docs/research/acclient_animation_map.md diff --git a/docs/research/acclient_animation_map.md b/docs/research/acclient_animation_map.md new file mode 100644 index 0000000..8a56ae5 --- /dev/null +++ b/docs/research/acclient_animation_map.md @@ -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 | diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 4a4e966..ff25e5a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2054,47 +2054,35 @@ public sealed class GameWindow : IDisposable stanceOverride: NonCombatStance, commandOverride: cmdOverride); - // AC reuses right-side animations for left-side motions (played in - // reverse). If the left-side command has no cycle, fall back to the - // right-side equivalent so the player isn't stuck in idle. - uint resolvedCommand = animCommand; // track which command actually resolved + // 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, // TurnLeft → TurnRight - 0x0010 => 0x000F, // SideStepLeft → SideStepRight - 0x0006 => 0x0005, // WalkBackward → WalkForward + 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); - // 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) - { - 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); - } + if (cycle is null || cycle.Framerate == 0f || cycle.HighFrame <= cycle.LowFrame) return; ae.Animation = cycle.Animation; ae.LowFrame = Math.Max(0, cycle.LowFrame); diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index b8866d5..4c2226f 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -173,32 +173,55 @@ public sealed class AnimationSequencer /// Speed multiplier applied to framerates (1.0 = normal). 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. if (CurrentStyle == style && CurrentMotion == motion && _firstCyclic != null && _queue.Count > 0) return; - // Resolve transition link (currentSubstate → newMotion). + // Resolve transition link (currentSubstate → adjustedMotion). MotionData? linkData = CurrentMotion != 0 - ? GetLink(style, CurrentMotion, motion) + ? GetLink(style, CurrentMotion, adjustedMotion) : null; - // Resolve target cycle. - int cycleKey = (int)(((style & 0xFFFFu) << 16) | (motion & 0xFFFFFFu)); + // Resolve target cycle using the ADJUSTED motion (TurnRight, not TurnLeft). + int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu)); _mtable.Cycles.TryGetValue(cycleKey, out var cycleData); // Clear the old cyclic tail; keep any non-cyclic head that hasn't // been played yet (ACE behaviour: non-cyclic anims drain naturally). ClearCyclicTail(); - // Enqueue link frames. + // Enqueue link frames (with adjusted speed for left→right remapping). if (linkData is { Anims.Count: > 0 }) - EnqueueMotionData(linkData, speedMod, isLooping: false); + EnqueueMotionData(linkData, adjustedSpeed, isLooping: false); // Enqueue new cycle. if (cycleData is { Anims.Count: > 0 }) { - EnqueueMotionData(cycleData, speedMod, isLooping: true); + EnqueueMotionData(cycleData, adjustedSpeed, isLooping: true); } else if (_queue.Count == 0) { @@ -366,9 +389,23 @@ public sealed class AnimationSequencer if (low >= numFrames) low = numFrames - 1; if (high >= numFrames) high = numFrames - 1; if (low < 0) low = 0; - if (low > high) high = low; 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); }