refactor(anim): sequence-wide velocity/omega matching retail Sequence

Before: CurrentVelocity was a pass-through of the current AnimNode's
Velocity. So during a stance transition, while the link animation
played (with no velocity of its own), CurrentVelocity returned (0,0,0)
and remote dead-reckoning briefly stopped advancing the entity. Visible
as a hitch at every idle → walk or walk → run transition.

Retail's model (ACE Sequence.cs L16-L17, L127-L130): Velocity and Omega
are Sequence-wide fields updated by MotionTable.add_motion's
Sequence.SetVelocity call (MotionTable.cs L358-L370). Every time a new
MotionData is appended, the sequence velocity is REPLACED by that data's
velocity × speedMod. In SetCycle's rebuild path the order is:
  1. clear_physics      → zero
  2. add_motion(link)   → velocity = link's (typically 0)
  3. add_motion(cycle)  → velocity = cycle's (the real walk/run velocity)

After step 3, Sequence.Velocity is the CYCLE's velocity even though
CurrAnim is the link node. So dead-reckoning reads the cycle's velocity
from frame zero of the transition — no stutter.

This commit:

- Converts AnimationSequencer.CurrentVelocity / CurrentOmega from
  per-node computed properties to sequence-wide private-set properties.
- Adds ClearPhysics() helper (mirrors Sequence.clear_physics).
- EnqueueMotionData now updates the sequence velocity/omega (matching
  add_motion's SetVelocity semantics). Only replaces when the
  MotionData's HasVelocity/HasOmega flags are set — zero-HasVelocity
  modifiers don't zero the running cycle, matching retail.
- SetCycle's rebuild path calls ClearPhysics before the new add_motion
  chain (matches MotionTable.cs L100-L101, L152-L153).
- MultiplyCyclicFramerate scales the sequence-wide velocity/omega
  instead of per-node fields — algebraically equivalent to retail's
  subtract_motion(old) + combine_motion(new) pair in change_cycle_speed.

New test: CurrentVelocity_PersistsThroughLinkTransition — verifies that
after SetCycle enqueues [link][cycle], CurrentVelocity is the cycle's
velocity even during the link frames. Catches the old bug directly.

All 659 tests pass (was 658).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-19 10:41:21 +02:00
parent 6e589d3b89
commit 24974cfbb9
2 changed files with 120 additions and 19 deletions

View file

@ -229,21 +229,26 @@ public sealed class AnimationSequencer
public float CurrentSpeedMod { get; private set; } = 1f;
/// <summary>
/// World-space per-second velocity from the currently active
/// <see cref="MotionData"/> (Sequence.Velocity in retail). Zero when no
/// motion data carries a velocity. Scaled by <c>speedMod</c> at enqueue
/// time.
/// Sequence-wide velocity mirror of ACE's <c>Sequence.Velocity</c> field.
/// Updated each time a MotionData is appended or combined — reflects the
/// MOST RECENT MotionData's velocity × speedMod, matching
/// <c>Sequence.SetVelocity</c> semantics (ACE Sequence.cs L127-L130,
/// <c>MotionTable.add_motion</c> L358-L370).
///
/// <para>
/// 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.
/// </para>
/// </summary>
public Vector3 CurrentVelocity =>
_currNode?.Value.Velocity ?? Vector3.Zero;
public Vector3 CurrentVelocity { get; private set; }
/// <summary>
/// Radians-per-second omega (axis-angle integration rate) from the
/// currently active <see cref="MotionData"/>. Scaled by <c>speedMod</c>
/// at enqueue time.
/// Sequence-wide omega, matching <see cref="CurrentVelocity"/>'s semantics.
/// </summary>
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;
}
/// <summary>
@ -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);
}
/// <summary>
/// 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.
/// </summary>
private void ClearPhysics()
{
CurrentVelocity = Vector3.Zero;
CurrentOmega = Vector3.Zero;
}
/// <summary>
/// Append all AnimData entries from <paramref name="motionData"/> 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);

View file

@ -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<Animation> 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<Animation> 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]