diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs
index 9687fea..56e03e4 100644
--- a/src/AcDream.Core/Physics/AnimationSequencer.cs
+++ b/src/AcDream.Core/Physics/AnimationSequencer.cs
@@ -525,7 +525,59 @@ public sealed class AnimationSequencer
// newly-added node; if preEnqueueTail was null (queue was empty
// before enqueue), the first new node is _queue.First.
var firstNew = preEnqueueTail is null ? _queue.First : preEnqueueTail.Next;
- if (firstNew is not null)
+
+ // #39 Fix B (2026-05-06): for direct cyclic-locomotion →
+ // cyclic-locomotion transitions (Walk↔Run on Shift toggle,
+ // W↔S direct flip, A↔D, Forward↔Strafe), land _currNode on
+ // the new CYCLE (_firstCyclic), NOT on the link (firstNew),
+ // and remove the just-enqueued link from the queue.
+ //
+ // Why: the transition link's drain time (~100–300 ms at
+ // Framerate 30 × link runSpeed) gets restarted before it can
+ // end if the user toggles Shift faster than that. _currNode
+ // sits on a fresh link every UM and Advance never reaches
+ // the cycle. User observes "blips forward in walking
+ // animation" — what they're seeing is the link's
+ // interpolation pose, never the new cycle.
+ //
+ // Conditional on BOTH old AND new being locomotion cycles to
+ // avoid regressing the cases where the link IS the right
+ // animation:
+ // - Idle (Ready) → any cycle: link is the wind-up pose
+ // - Falling → Ready: landing animation
+ // - Ready → Sitting/Crouching: pose-change links
+ // - Combat substates (attack/parry/ready transitions)
+ // Commit c06b6c5 (reverted in a2ae2ae) demonstrated that
+ // unconditionally skipping the link breaks all of these.
+ //
+ // Retail reference: cdb live trace 2026-05-03 of a Walk→Run
+ // direct transition logged
+ // add_to_queue(45000005, looping=1) walk
+ // add_to_queue(44000007, looping=1) run
+ // with truncate_animation_list never firing — i.e. retail
+ // appends the new cycle directly without a separate link
+ // enqueue or visible link pose for cyclic→cyclic. Our
+ // structural mismatch was always enqueueing link+cycle and
+ // forcing _currNode onto the link; this fix matches retail's
+ // observed semantics for the locomotion subset.
+ bool prevIsLocomotion = IsLocomotionCycleLowByte(CurrentMotion & 0xFFu);
+ bool newIsLocomotion = IsLocomotionCycleLowByte(motion & 0xFFu);
+ if (prevIsLocomotion && newIsLocomotion && _firstCyclic is not null)
+ {
+ // Drop the just-enqueued link node (firstNew) from the
+ // queue if it's distinct from the cycle — nothing should
+ // ever play it, and leaving stale non-cyclic nodes ahead
+ // of _currNode contributes to the unbounded queue growth
+ // observed in [SCFULL] (qCount climbing past 49 over
+ // ~30 transitions).
+ if (firstNew is not null && firstNew != _firstCyclic)
+ {
+ _queue.Remove(firstNew);
+ }
+ _currNode = _firstCyclic;
+ _framePosition = _firstCyclic.Value.GetStartFramePosition();
+ }
+ else if (firstNew is not null)
{
_currNode = firstNew;
_framePosition = _currNode.Value.GetStartFramePosition();
@@ -1384,6 +1436,20 @@ public sealed class AnimationSequencer
return result;
}
+ ///
+ /// True if the given motion-low-byte names a locomotion cycle —
+ /// WalkForward (0x05), WalkBackward (0x06), RunForward (0x07),
+ /// SideStepRight (0x0F), or SideStepLeft (0x10).
+ /// Used by to recognise cyclic→cyclic
+ /// direct transitions and bypass the transition link in that case
+ /// (retail's observed add_to_queue semantics).
+ ///
+ private static bool IsLocomotionCycleLowByte(uint lowByte)
+ {
+ return lowByte == 0x05u || lowByte == 0x06u || lowByte == 0x07u
+ || lowByte == 0x0Fu || lowByte == 0x10u;
+ }
+
///
/// Quaternion slerp matching the retail client's FUN_005360d0
/// (chunk_00530000.c:4799-4846):