diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs
index 61d95ee..4d77ef2 100644
--- a/src/AcDream.Core/Physics/AnimationSequencer.cs
+++ b/src/AcDream.Core/Physics/AnimationSequencer.cs
@@ -229,21 +229,26 @@ public sealed class AnimationSequencer
public float CurrentSpeedMod { get; private set; } = 1f;
///
- /// World-space per-second velocity from the currently active
- /// (Sequence.Velocity in retail). Zero when no
- /// motion data carries a velocity. Scaled by speedMod at enqueue
- /// time.
+ /// Sequence-wide velocity mirror of ACE's Sequence.Velocity field.
+ /// Updated each time a MotionData is appended or combined — reflects the
+ /// MOST RECENT MotionData's velocity × speedMod, matching
+ /// Sequence.SetVelocity semantics (ACE Sequence.cs L127-L130,
+ /// MotionTable.add_motion L358-L370).
+ ///
+ ///
+ /// Crucially this is **not** per-node: while a link animation plays, the
+ /// surfaced velocity is still the cycle's velocity (the cycle was added
+ /// last, so SetVelocity's latest call wins). Remote entity dead-reckoning
+ /// reads this to integrate position without gapping during stance
+ /// transitions.
+ ///
///
- public Vector3 CurrentVelocity =>
- _currNode?.Value.Velocity ?? Vector3.Zero;
+ public Vector3 CurrentVelocity { get; private set; }
///
- /// Radians-per-second omega (axis-angle integration rate) from the
- /// currently active . Scaled by speedMod
- /// at enqueue time.
+ /// Sequence-wide omega, matching 's semantics.
///
- public Vector3 CurrentOmega =>
- _currNode?.Value.Omega ?? Vector3.Zero;
+ public Vector3 CurrentOmega { get; private set; }
// Diagnostics
public int QueueCount => _queue.Count;
@@ -375,6 +380,11 @@ public sealed class AnimationSequencer
// been played yet (ACE behaviour: non-cyclic anims drain naturally).
ClearCyclicTail();
+ // Clear sequence-wide physics before the rebuild. Retail's
+ // GetObjectSequence calls sequence.clear_physics() before each
+ // add_motion chain (MotionTable.cs L100-L101, L152-L153).
+ ClearPhysics();
+
// Enqueue link frames (with adjusted speed for left→right remapping).
if (linkData is { Anims.Count: > 0 })
EnqueueMotionData(linkData, adjustedSpeed, isLooping: false);
@@ -445,15 +455,15 @@ public sealed class AnimationSequencer
for (var node = _firstCyclic; node != null; node = node.Next)
{
node.Value.MultiplyFramerate((double)factor);
- // Velocity/Omega carried on the node scale with the framerate, so
- // the physics velocity surfaced by CurrentVelocity matches the
- // animation playback. (ACE does the same: add_motion sets both
- // to the scaled value and multiply_cyclic_animation_framerate is
- // preceded by subtract_motion/combine_motion in change_cycle_speed
- // to keep them aligned — MotionTable.cs:372-379.)
- node.Value.Velocity *= factor;
- node.Value.Omega *= factor;
}
+
+ // Sequence-wide velocity/omega scale too. Retail's flow is
+ // subtract_motion(oldSpeed) + combine_motion(newSpeed) in
+ // MotionTable.change_cycle_speed (MotionTable.cs L372-L379), which
+ // algebraically equals scaling by newSpeed/oldSpeed — exactly
+ // what the factor represents here.
+ CurrentVelocity *= factor;
+ CurrentOmega *= factor;
}
///
@@ -738,6 +748,8 @@ public sealed class AnimationSequencer
CurrentStyle = 0;
CurrentMotion = 0;
CurrentSpeedMod = 1f;
+ CurrentVelocity = Vector3.Zero;
+ CurrentOmega = Vector3.Zero;
}
// ── Private helpers ──────────────────────────────────────────────────────
@@ -829,6 +841,17 @@ public sealed class AnimationSequencer
omega);
}
+ ///
+ /// Reset the sequence's Velocity + Omega (retail Sequence.clear_physics,
+ /// ACE Sequence.cs L256-L260). Called before a style-transition rebuild
+ /// in SetCycle so we don't inherit velocity from the previous cycle.
+ ///
+ private void ClearPhysics()
+ {
+ CurrentVelocity = Vector3.Zero;
+ CurrentOmega = Vector3.Zero;
+ }
+
///
/// Append all AnimData entries from to the
/// queue. Each AnimData becomes one AnimNode. Velocity / Omega from the
@@ -842,6 +865,23 @@ public sealed class AnimationSequencer
Vector3 omg = motionData.Flags.HasFlag(MotionDataFlags.HasOmega)
? motionData.Omega * speedMod : Vector3.Zero;
+ // Sequence-wide velocity/omega update, matching ACE's
+ // MotionTable.add_motion (MotionTable.cs L358-L370): SetVelocity
+ // REPLACES the previous sequence velocity. When SetCycle enqueues
+ // link then cycle, the final CurrentVelocity is the cycle's — which
+ // is what dead-reckoning needs to read from the first frame of the
+ // link transition (the cycle velocity is already "queued up" even
+ // while a zero-velocity link plays visually).
+ //
+ // Only replace if HasVelocity (else we'd zero out a running cycle
+ // when a transient HasVelocity=0 modifier enqueues). Matches
+ // retail's conditional behavior: MotionData without HasVelocity
+ // doesn't touch the sequence velocity.
+ if (motionData.Flags.HasFlag(MotionDataFlags.HasVelocity))
+ CurrentVelocity = vel;
+ if (motionData.Flags.HasFlag(MotionDataFlags.HasOmega))
+ CurrentOmega = omg;
+
for (int i = 0; i < motionData.Anims.Count; i++)
{
bool nodeCycling = isLooping && (i == motionData.Anims.Count - 1);
diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs
index 8292bbd..55fc874 100644
--- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs
@@ -1174,6 +1174,67 @@ public sealed class AnimationSequencerTests
Assert.Equal(1.5f, seq.CurrentSpeedMod, 3);
}
+ [Fact]
+ public void CurrentVelocity_PersistsThroughLinkTransition()
+ {
+ // Retail behavior (ACE MotionTable.add_motion + Sequence.SetVelocity):
+ // sequence.Velocity is REPLACED by the most-recent MotionData's
+ // velocity. When SetCycle enqueues [link][cycle], after the final
+ // add_motion the velocity is the cycle's velocity — ALREADY.
+ // So even while the link animation plays visually, dead-reckoning
+ // reads the cycle's run-speed and moves the entity smoothly.
+ // Crucial: otherwise remote entities would stutter at every stance
+ // transition while the link plays.
+ const uint Style = 0x003Du;
+ const uint IdleMotion = 0x0003u;
+ const uint WalkMotion = 0x0005u;
+ const uint CycleAnim = 0x03000601u;
+ const uint LinkAnim = 0x03000602u;
+
+ var cycleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
+ var linkAnim = Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity);
+
+ var setup = Fixtures.MakeSetup(1);
+ var mt = new MotionTable();
+ mt.DefaultStyle = (DRWMotionCommand)Style;
+ mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)WalkMotion;
+
+ int cycleKey = (int)((Style << 16) | (WalkMotion & 0xFFFFFFu));
+ var cycleMd = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 3.12f, 0) };
+ QualifiedDataId cycleQid = CycleAnim;
+ cycleMd.Anims.Add(new AnimData { AnimId = cycleQid, LowFrame = 0, HighFrame = -1, Framerate = 10f });
+ mt.Cycles[cycleKey] = cycleMd;
+
+ // Link from idle → walk. Link MotionData has no velocity (typical).
+ int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
+ var linkCmdData = new MotionCommandData();
+ var linkMd = new MotionData(); // no HasVelocity flag
+ QualifiedDataId linkQid = LinkAnim;
+ linkMd.Anims.Add(new AnimData { AnimId = linkQid, LowFrame = 0, HighFrame = -1, Framerate = 10f });
+ linkCmdData.MotionData[(int)WalkMotion] = linkMd;
+ mt.Links[linkOuter] = linkCmdData;
+
+ var loader = new FakeLoader();
+ loader.Register(CycleAnim, cycleAnim);
+ loader.Register(LinkAnim, linkAnim);
+
+ var seq = new AnimationSequencer(setup, mt, loader);
+ SetCurrentMotion(seq, Style, IdleMotion);
+ seq.SetCycle(Style, WalkMotion);
+
+ // We just enqueued [link(0)][cycle(3.12 forward)]. Current node is
+ // the link, but CurrentVelocity reflects the most recent
+ // SetVelocity call — the cycle's. So velocity is 3.12 even before
+ // the link plays out.
+ Assert.Equal(3.12f, seq.CurrentVelocity.Y, 2);
+
+ // Advance past the link frames (2 frames at 10fps = 0.2s).
+ seq.Advance(0.25f);
+
+ // Still 3.12 — cycle is now current.
+ Assert.Equal(3.12f, seq.CurrentVelocity.Y, 2);
+ }
+
// ── PlayAction: Action / Modifier / ChatEmote routing ───────────────────
[Fact]