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

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