diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index 4bae508..4dc4aef 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -479,6 +479,29 @@ public sealed class AnimationSequencer ? null : GetLink(style, CurrentMotion, CurrentSpeedMod, adjustedMotion, adjustedSpeed); + // Stop-anim fallback: dat-authored leg-settle / turn-stop links are + // keyed under the FORWARD/RIGHT variant only. Stopping from + // WalkBackward / SideStepLeft / TurnLeft hits a null linkData and + // would visibly snap to Ready. The settle anim is direction-agnostic + // (legs come to standing the same way regardless of which way you + // were walking), so retry GetLink with the substate's low-byte + // remapped to its forward/right peer. + if (linkData is null && !skipTransitionLink && CurrentMotion != 0) + { + uint substateLow = CurrentMotion & 0xFFu; + uint adjustedSubstate = substateLow switch + { + 0x06u => (CurrentMotion & 0xFFFFFF00u) | 0x05u, // WalkBackward → WalkForward + 0x10u => (CurrentMotion & 0xFFFFFF00u) | 0x0Fu, // SideStepLeft → SideStepRight + 0x0Eu => (CurrentMotion & 0xFFFFFF00u) | 0x0Du, // TurnLeft → TurnRight + _ => CurrentMotion, + }; + if (adjustedSubstate != CurrentMotion) + { + linkData = GetLink(style, adjustedSubstate, CurrentSpeedMod, adjustedMotion, adjustedSpeed); + } + } + // Resolve target cycle using the ADJUSTED motion (TurnRight not TurnLeft). int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu)); _mtable.Cycles.TryGetValue(cycleKey, out var cycleData); @@ -1419,18 +1442,24 @@ public sealed class AnimationSequencer frameIdx = Math.Clamp(frameIdx, rangeLo, rangeHi); // Next frame for interpolation: step in the playback direction. + // Wrap to opposite end ONLY for looping cyclic nodes. For one-shot + // nodes (link transitions, action overlays), hold the boundary + // frame instead — otherwise the fractional tail of the anim + // blends frame[end] with frame[0], producing a brief flash through + // the anim's starting pose at the link→cycle boundary (issue #61: + // door swing-open flap; run-stop twitch). int nextIdx; if (curr.Framerate >= 0.0) { nextIdx = frameIdx + 1; if (nextIdx > rangeHi || nextIdx >= numPartFrames) - nextIdx = rangeLo; // wrap forward + nextIdx = curr.IsLooping ? rangeLo : frameIdx; } else { nextIdx = frameIdx - 1; if (nextIdx < rangeLo) - nextIdx = rangeHi; // wrap backward + nextIdx = curr.IsLooping ? rangeHi : frameIdx; } // Fractional blend weight (always in [0, 1]). diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index b5f584a..e911d21 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs @@ -371,6 +371,124 @@ public sealed class AnimationSequencerTests $"Expected link-anim Y({transforms[0].Origin.Y}) > cycle X({transforms[0].Origin.X})"); } + [Fact] + public void Advance_LinkTailDoesNotBlendIntoLinkFrame0() + { + // Issue #61 regression: the fractional tail of a non-looping LINK + // (the ~30 ms between the last integer frame and the wrap boundary) + // must hold the link's END pose instead of blending into the link's + // FRAME 0 pose. Old behaviour: BuildBlendedFrame wrapped nextIdx to + // rangeLo unconditionally, producing a one-frame flash through the + // link's starting pose at the link→cycle boundary. Symptoms: door + // swing-open flap (frame 0 = closed); player run-stop twitch + // (frame 0 = mid-stride). + const uint Style = 0x003Du; + const uint IdleMotion = 0x0003u; + const uint WalkMotion = 0x0005u; + const uint CycleAnim = 0x03000080u; + const uint LinkAnim = 0x03000081u; + + // Link anim: 3 frames, distinct Y so we can tell which frame is being + // sampled. Frame 0 Y=10 (link's starting pose — e.g. closed door), + // frame 2 Y=0 (link's end pose — e.g. open door). + var linkAnim = new Animation(); + for (int f = 0; f < 3; f++) + { + var pf = new AnimationFrame(1); + float y = 10f - 5f * f; // 10, 5, 0 + pf.Frames.Add(new Frame { Origin = new Vector3(0, y, 0), Orientation = Quaternion.Identity }); + linkAnim.PartFrames.Add(pf); + } + + // Cycle anim: single frame at Y=0 (the "open" / "idle" rest pose). + var cycleAnim = Fixtures.MakeAnim(1, 1, new Vector3(0, 0, 0), Quaternion.Identity); + + var setup = Fixtures.MakeSetup(1); + var mt = Fixtures.MakeMtable( + style: Style, + motion: WalkMotion, + cycleAnimId: CycleAnim, + fromMotion: IdleMotion, + toMotion: WalkMotion, + linkAnimId: LinkAnim, + framerate: 30f); + + 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); + + // Advance to _framePosition ≈ 2.5 — past the last integer frame (2) + // but before maxBoundary - epsilon (≈ 3). At 30 fps, 2.5/30 = 0.0833s. + seq.Advance(0.0833f); + double pos = GetFramePosition(seq); + Assert.InRange(pos, 2.4, 2.7); + + var transforms = seq.Advance(0.0001f); // tiny extra dt to trigger blend read + + // Pre-fix: nextIdx would wrap to rangeLo (0), so transforms[0].Origin.Y + // would land near 0.5 × 0 + 0.5 × 10 = 5 (mid-blend with link frame 0). + // Post-fix: nextIdx = frameIdx (2), so transforms[0].Origin.Y = 0 (held). + Assert.True(transforms[0].Origin.Y < 1f, + $"Link tail should hold last-frame pose Y=0; got Y={transforms[0].Origin.Y} " + + "(would be ~5 if nextIdx still wrapped to link frame 0)"); + } + + [Fact] + public void SetCycle_StopFromWalkBackward_FallsBackToWalkForwardStopLink() + { + // Stop-anim asymmetry: the Humanoid motion table only authors a + // "stop walking" link under WalkForward (low byte 0x05). Stopping + // from WalkBackward (0x06) without a fallback returns null linkData + // and the cycle snaps to Ready with no settle blend. Fix: when the + // primary GetLink lookup fails, retry with WalkBackward's low byte + // remapped to WalkForward. + const uint Style = 0x003Du; + const uint WalkForwardCmd = 0x0005u; + const uint WalkBackCmd = 0x0006u; + const uint ReadyCmd = 0x0003u; + const uint CycleAnim = 0x03000090u; // Ready cycle (Y=0) + const uint LinkAnim = 0x03000091u; // stop-link (Y=7) + + var cycleAnim = Fixtures.MakeAnim(1, 1, new Vector3(0, 0, 0), Quaternion.Identity); + var linkAnim = Fixtures.MakeAnim(4, 1, new Vector3(0, 7, 0), Quaternion.Identity); + + var setup = Fixtures.MakeSetup(1); + // Table: Ready cycle + WalkForward→Ready link. NO WalkBackward→Ready link. + var mt = Fixtures.MakeMtable( + style: Style, + motion: ReadyCmd, + cycleAnimId: CycleAnim, + fromMotion: WalkForwardCmd, + toMotion: ReadyCmd, + linkAnimId: LinkAnim, + framerate: 30f); + + var loader = new FakeLoader(); + loader.Register(CycleAnim, cycleAnim); + loader.Register(LinkAnim, linkAnim); + + var seq = new AnimationSequencer(setup, mt, loader); + // Simulate "we were walking backward" — substate = WalkBackward, + // substateSpeed = +1 (the original speedMod stored by SetCycle). + SetCurrentMotion(seq, Style, WalkBackCmd); + + seq.SetCycle(Style, ReadyCmd); + + // Advance a tiny dt — should land on link frame 0 (Y=7), not the + // cycle (Y=0). Without the fallback, linkData is null, only the + // Ready cycle is enqueued, and we read Y=0 immediately. + var transforms = seq.Advance(0.001f); + Assert.Single(transforms); + Assert.True(transforms[0].Origin.Y > 5f, + $"Stop-from-backward should fall back to WalkForward→Ready link " + + $"(expect Y≈7 from link); got Y={transforms[0].Origin.Y} " + + "(Y=0 means linkData was null and we snapped to Ready cycle)."); + } + [Fact] public void SetCycle_NoLinkInTable_DirectCycleSwitch() {