diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 542359a..100ea54 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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) diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index 9afe076..ffce8e1 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -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. + /// + /// 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. WalkForward → + /// Ready) instead of triggering 's + /// unconditional ClearCyclicTail 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). + /// + 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 ─── diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index 471af2c..b5f584a 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs @@ -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() {