fix(anim): Phase L.1c sequencer cycle fallback for missing MoveTo motion
User-observed regression on commit37de771: 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 commit186a584(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 (commit37de771) 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:
parent
37de771778
commit
34d7f4def2
3 changed files with 114 additions and 1 deletions
|
|
@ -2578,7 +2578,53 @@ public sealed class GameWindow : IDisposable
|
||||||
// whatever the interpreted state says when the body
|
// whatever the interpreted state says when the body
|
||||||
// lands.
|
// lands.
|
||||||
if (!remoteIsAirborne)
|
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
|
// Retail runs the full MotionInterp state machine on every
|
||||||
// remote. Route each wire command (forward, sidestep, turn)
|
// remote. Route each wire command (forward, sidestep, turn)
|
||||||
|
|
|
||||||
|
|
@ -330,6 +330,33 @@ public sealed class AnimationSequencer
|
||||||
/// makes the jump look delayed (legs stand still for ~100 ms while
|
/// makes the jump look delayed (legs stand still for ~100 ms while
|
||||||
/// the link drains, then fold into Falling). Defaults to false to
|
/// the link drains, then fold into Falling). Defaults to false to
|
||||||
/// preserve normal smooth transitions for everything else.</param>
|
/// 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)
|
public void SetCycle(uint style, uint motion, float speedMod = 1f, bool skipTransitionLink = false)
|
||||||
{
|
{
|
||||||
// ── adjust_motion: remap left→right / backward→forward variants ───
|
// ── adjust_motion: remap left→right / backward→forward variants ───
|
||||||
|
|
|
||||||
|
|
@ -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]
|
[Fact]
|
||||||
public void SetCycle_LoadsAnimation_AdvanceReturnsBoundedTransforms()
|
public void SetCycle_LoadsAnimation_AdvanceReturnsBoundedTransforms()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue