fix(anim): Phase L.1c sequencer cycle fallback for missing MoveTo motion

User-observed regression on commit 37de771: monsters in combat with
another client appear as "just a torso on the ground" until they
move. User correctly identified this as a regression I introduced.

Cause traced to the SEQUENCER side, not the InterpretedState side.
AnimationSequencer.SetCycle (AnimationSequencer.cs:392-396)
unconditionally calls ClearCyclicTail() BEFORE looking up the
requested cycle in the MotionTable. If the cycle is missing
(_mtable.Cycles.TryGetValue returns false), the body is left without
ANY cyclic tail at all — and every part snaps to its setup-default
offset on the next Advance(). Most creatures' setup-defaults put
all limbs at the torso origin, so the visual collapses to "just a
torso on the ground" until a different (working) cycle arrives.

This is specifically a regression from commit 186a584 (Phase L.1c
port). Pre-fix, MoveTo packets fell through to fullMotion=Ready
(every MotionTable contains a Ready cycle). Post-fix, MoveTo packets
seed fullMotion=RunForward via PlanMoveToStart. Some combat-stance
creatures (e.g. monsters in HandCombat 0x003C) have no
(combat, RunForward) cycle in their MotionTable — they're meant to
walk in combat, with retail's apply_run_to_command upgrading
WalkForward → RunForward at the velocity layer rather than the
animation-cycle layer.

Fix: add `AnimationSequencer.HasCycle(style, motion)` query and gate
the SetCycle call site in GameWindow.OnLiveMotionUpdated behind it.
Fall back chain: requested motion → WalkForward → Ready →
no-op-don't-clear. The InterpretedState.ForwardCommand bulk-copy
(commit 37de771) is unchanged — body still gets RunForward velocity
even when the visible animation falls back to WalkForward or Ready.

Tests: 1420 → 1422.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-29 10:33:48 +02:00
parent 37de771778
commit 34d7f4def2
3 changed files with 114 additions and 1 deletions

View file

@ -2578,7 +2578,53 @@ public sealed class GameWindow : IDisposable
// whatever the interpreted state says when the body
// lands.
if (!remoteIsAirborne)
ae.Sequencer.SetCycle(fullStyle, animCycle, animSpeed);
{
// Fallback chain for missing cycles in the MotionTable.
// SetCycle unconditionally calls ClearCyclicTail() before
// looking up the cycle; if the cycle is absent, the body
// ends up with no cyclic tail at all and every part snaps
// to its setup-default offset — visible as "torso on the
// ground" because most creatures' setup-default puts all
// limbs at the torso origin.
//
// This is specifically a regression from commit 186a584
// (Phase L.1c port): pre-fix, MoveTo packets fell through
// to fullMotion=Ready (which always exists in every
// MotionTable). Post-fix, MoveTo packets seed
// fullMotion=RunForward, but some creatures (especially
// when stance=HandCombat) lack a (combat, RunForward)
// cycle. Fall through RunForward → WalkForward → Ready
// until we find one the table actually contains.
//
// Note: this fallback is for the SEQUENCER (visible
// animation) only. InterpretedState.ForwardCommand still
// gets the wire's (or seeded) ForwardCommand verbatim
// so apply_current_movement produces correct velocity.
uint cycleToPlay = animCycle;
if (!ae.Sequencer.HasCycle(fullStyle, cycleToPlay))
{
// RunForward (0x44000007) → WalkForward (0x45000005)
if ((cycleToPlay & 0xFFu) == 0x07
&& ae.Sequencer.HasCycle(fullStyle, 0x45000005u))
{
cycleToPlay = 0x45000005u;
}
// WalkForward → Ready (0x41000003)
else if (ae.Sequencer.HasCycle(fullStyle, 0x41000003u))
{
cycleToPlay = 0x41000003u;
}
// Ready missing too — leave the existing cycle alone
// by not calling SetCycle at all (avoids the
// ClearCyclicTail wipe).
else
{
cycleToPlay = 0;
}
}
if (cycleToPlay != 0)
ae.Sequencer.SetCycle(fullStyle, cycleToPlay, animSpeed);
}
// Retail runs the full MotionInterp state machine on every
// remote. Route each wire command (forward, sidestep, turn)

View file

@ -330,6 +330,33 @@ public sealed class AnimationSequencer
/// makes the jump look delayed (legs stand still for ~100 ms while
/// the link drains, then fold into Falling). Defaults to false to
/// preserve normal smooth transitions for everything else.</param>
/// <summary>
/// Check whether the underlying MotionTable contains a cycle for the
/// given (style, motion) pair. Useful for callers that want to fall
/// back to a known-good motion (e.g. <c>WalkForward</c> →
/// <c>Ready</c>) instead of triggering <see cref="SetCycle"/>'s
/// unconditional <c>ClearCyclicTail</c> path on a missing cycle —
/// which leaves the body without any animation tail and snaps every
/// part to the setup-default offset (visible as "torso on the
/// ground" since most creatures' setup-default has limbs at the
/// torso origin).
/// </summary>
public bool HasCycle(uint style, uint motion)
{
// adjust_motion remapping (mirrors the head of SetCycle):
// TurnLeft, SideStepLeft, WalkBackward map to their right/forward
// mirror cycles.
uint adjustedMotion = motion;
switch (motion & 0xFFFFu)
{
case 0x000E: adjustedMotion = (motion & 0xFFFF0000u) | 0x000Du; break;
case 0x0010: adjustedMotion = (motion & 0xFFFF0000u) | 0x000Fu; break;
case 0x0006: adjustedMotion = (motion & 0xFFFF0000u) | 0x0005u; break;
}
int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu));
return _mtable.Cycles.ContainsKey(cycleKey);
}
public void SetCycle(uint style, uint motion, float speedMod = 1f, bool skipTransitionLink = false)
{
// ── adjust_motion: remap left→right / backward→forward variants ───

View file

@ -223,6 +223,46 @@ public sealed class AnimationSequencerTests
}
}
[Fact]
public void HasCycle_PresentInTable_ReturnsTrue()
{
// Phase L.1c followup (2026-04-28): regression guard for
// "torso on the ground" — caller (GameWindow MoveTo path) needs
// to query the table before SetCycle to avoid the
// ClearCyclicTail wipe on a missing cycle.
const uint Style = 0x003Cu; // HandCombat
const uint Motion = 0x0003u; // Ready
const uint AnimId = 0x03000001u;
var setup = Fixtures.MakeSetup(2);
var mt = Fixtures.MakeMtable(Style, Motion, AnimId);
var loader = new FakeLoader();
loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(2, Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity));
var seq = new AnimationSequencer(setup, mt, loader);
// Caller passes the SAME shape SetCycle expects: full style with
// class byte (0x80000000) and full motion (0x40000000 / 0x10000000).
Assert.True(seq.HasCycle(0x8000003Cu, 0x41000003u));
}
[Fact]
public void HasCycle_MissingFromTable_ReturnsFalse()
{
const uint Style = 0x003Cu;
const uint ReadyMotion = 0x0003u;
const uint AnimId = 0x03000001u;
var setup = Fixtures.MakeSetup(2);
var mt = Fixtures.MakeMtable(Style, ReadyMotion, AnimId);
var loader = new FakeLoader();
loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(2, Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity));
var seq = new AnimationSequencer(setup, mt, loader);
// RunForward (0x44000007) is NOT in the table — caller should
// see false and fall back to a known motion (WalkForward / Ready).
Assert.False(seq.HasCycle(0x8000003Cu, 0x44000007u));
}
[Fact]
public void SetCycle_LoadsAnimation_AdvanceReturnsBoundedTransforms()
{