diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs
index bcf82438..a31cc37b 100644
--- a/src/AcDream.Core/Physics/MotionInterpreter.cs
+++ b/src/AcDream.Core/Physics/MotionInterpreter.cs
@@ -2729,12 +2729,33 @@ public sealed class MotionInterpreter : IMotionDoneSink
_ = cancelMoveTo;
_ = allowJump;
- if (InterpretedState.ForwardCommand == MotionCommand.RunForward)
- MyRunRate = InterpretedState.ForwardSpeed;
+ // ENTRY-CACHE all axis values BEFORE the style dispatch (W6 stop-bug
+ // fix, 2026-07-03). The style dispatch's success path runs
+ // InterpretedMotionState::ApplyMotion(style), whose style branch
+ // resets forward_command to Ready UNCONDITIONALLY (raw 0051ea6c —
+ // verbatim). Retail SELF-HEALS because its compiled apply pass reads
+ // the axis fields into registers before the style call, so the fwd
+ // dispatch re-applies the pre-reset command (proven by our own
+ // 183-case live-retail observer trace: the fwd dispatch carries the
+ // wire's RunForward after the style dispatch on the same UM — the
+ // BN pseudo-C's apparent post-style field reads at 0x528687 are
+ // decompiler rendering of hoisted registers, the same artifact
+ // class as the A1 polarity). A live-field read here dispatched
+ // READY after every style apply, leaving the field permanently
+ // Ready — the "press W and stop instantly" W6 regression.
+ uint entryFwdCmd = InterpretedState.ForwardCommand;
+ float entryFwdSpeed = InterpretedState.ForwardSpeed;
+ uint entrySideCmd = InterpretedState.SideStepCommand;
+ float entrySideSpeed = InterpretedState.SideStepSpeed;
+ uint entryTurnCmd = InterpretedState.TurnCommand;
+ float entryTurnSpeed = InterpretedState.TurnSpeed;
+
+ if (entryFwdCmd == MotionCommand.RunForward)
+ MyRunRate = entryFwdSpeed;
DispatchInterpretedMotion(currentStyle, 1.0f, sink);
- if (!contact_allows_move(InterpretedState.ForwardCommand))
+ if (!contact_allows_move(entryFwdCmd))
{
DispatchInterpretedMotion(MotionCommand.Falling, 1.0f, sink);
}
@@ -2745,19 +2766,16 @@ public sealed class MotionInterpreter : IMotionDoneSink
}
else
{
- DispatchInterpretedMotion(
- InterpretedState.ForwardCommand, InterpretedState.ForwardSpeed, sink);
- if (InterpretedState.SideStepCommand == 0)
+ DispatchInterpretedMotion(entryFwdCmd, entryFwdSpeed, sink);
+ if (entrySideCmd == 0)
DispatchStopInterpretedMotion(MotionCommand.SideStepRight, sink);
else
- DispatchInterpretedMotion(
- InterpretedState.SideStepCommand, InterpretedState.SideStepSpeed, sink);
+ DispatchInterpretedMotion(entrySideCmd, entrySideSpeed, sink);
}
- if (InterpretedState.TurnCommand != 0)
+ if (entryTurnCmd != 0)
{
- DispatchInterpretedMotion(
- InterpretedState.TurnCommand, InterpretedState.TurnSpeed, sink);
+ DispatchInterpretedMotion(entryTurnCmd, entryTurnSpeed, sink);
return; // retail early return — no idle-stop this call
}
diff --git a/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs b/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs
new file mode 100644
index 00000000..3b7aa5f6
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs
@@ -0,0 +1,179 @@
+using System;
+using System.Numerics;
+using AcDream.App.Input;
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Types;
+
+using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
+
+namespace AcDream.Core.Tests.Input;
+
+///
+/// R3-W6 regression suite for the edge-driven local player — specifically
+/// the "press W and stop instantly" bug (2026-07-03): the funnel's apply
+/// pass live-read InterpretedState.ForwardCommand AFTER the style
+/// dispatch, whose ApplyMotion(style) state-write resets forward to
+/// Ready unconditionally (raw 0051ea6c, verbatim). Retail self-heals via
+/// register-cached entry reads (proven by the 183-case live observer trace:
+/// the fwd dispatch carries the pre-reset command); the fix entry-caches
+/// the axes in ApplyInterpretedMovement.
+///
+/// The 183-case suite could NOT catch this: its RecordingSink's ApplyMotion
+/// return doesn't mirror the REAL ,
+/// which returns TRUE for the style dispatch (manager Branch 1
+/// style==target → success) — the true return is what gates the resetting
+/// state-write on. These tests bind the REAL sink over a real sequencer.
+///
+public class W6EdgeDrivenMovementTests
+{
+ private const uint NC = 0x8000003Du;
+ private const uint Ready = 0x41000003u;
+ private const uint Walk = 0x45000005u;
+ private const uint Run = 0x44000007u;
+
+ private sealed class Loader : IAnimationLoader
+ {
+ private readonly System.Collections.Generic.Dictionary _anims = new();
+ public void Register(uint id, Animation anim) => _anims[id] = anim;
+ public Animation? LoadAnimation(uint id) => _anims.TryGetValue(id, out var a) ? a : null;
+ }
+
+ private static Animation MakeAnim(int frames)
+ {
+ var anim = new Animation();
+ for (int f = 0; f < frames; f++)
+ {
+ var pf = new AnimationFrame(1);
+ pf.Frames.Add(new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity });
+ anim.PartFrames.Add(pf);
+ }
+ return anim;
+ }
+
+ private static MotionData MakeMd(uint animId)
+ {
+ var md = new MotionData();
+ QualifiedDataId qid = animId;
+ md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = -1, Framerate = 30f });
+ return md;
+ }
+
+ private static AnimationSequencer MakeSequencer()
+ {
+ var setup = new Setup();
+ setup.Parts.Add(0x01000000u);
+ setup.DefaultScale.Add(Vector3.One);
+
+ var loader = new Loader();
+ loader.Register(0x300u, MakeAnim(4));
+ loader.Register(0x301u, MakeAnim(6));
+ loader.Register(0x302u, MakeAnim(6));
+
+ var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NC };
+ mt.StyleDefaults[(DRWMotionCommand)NC] = (DRWMotionCommand)Ready;
+ mt.Cycles[(int)((NC << 16) | (Ready & 0xFFFFFFu))] = MakeMd(0x300u);
+ mt.Cycles[(int)((NC << 16) | (Walk & 0xFFFFFFu))] = MakeMd(0x301u);
+ mt.Cycles[(int)((NC << 16) | (Run & 0xFFFFFFu))] = MakeMd(0x302u);
+ return new AnimationSequencer(setup, mt, loader);
+ }
+
+ private static PhysicsEngine MakeFlatEngine()
+ {
+ var engine = new PhysicsEngine();
+ var heights = new byte[81];
+ Array.Fill(heights, (byte)50);
+ var heightTable = new float[256];
+ for (int i = 0; i < 256; i++) heightTable[i] = i * 1f;
+ var terrain = new TerrainSurface(heights, heightTable);
+ engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(),
+ Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f);
+ return engine;
+ }
+
+ private static PlayerMovementController MakeControllerWithRealSink(out AnimationSequencer seq)
+ {
+ var controller = new PlayerMovementController(MakeFlatEngine());
+ controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.Yaw = 0f;
+ seq = MakeSequencer();
+ // The full W6 GameWindow bind set (EnterPlayerModeNow equivalent).
+ controller.Motion.DefaultSink = new MotionTableDispatchSink(seq);
+ var s = seq;
+ controller.Motion.RemoveLinkAnimations = s.RemoveAllLinkAnimations;
+ controller.Motion.InitializeMotionTables = () => s.Manager.InitializeState();
+ controller.Motion.CheckForCompletedMotions = s.Manager.CheckForCompletedMotions;
+ return controller;
+ }
+
+ [Fact]
+ public void ApplyPass_WithRealSink_ForwardSelfHeals()
+ {
+ // The distilled bug: a full apply pass (style dispatch included)
+ // against the REAL sink must leave the interpreted forward exactly
+ // where it started — the style apply's Ready reset is re-applied
+ // over by the entry-cached fwd dispatch (retail self-heal).
+ var controller = MakeControllerWithRealSink(out _);
+ var input = new MovementInput { Forward = true, Run = true };
+ controller.Update(1f / 60f, input); // W press edge -> RunForward
+
+ Assert.Equal(Run, controller.Motion.InterpretedState.ForwardCommand);
+
+ // The live killer: the UM-echo pass (~10Hz in the real client).
+ controller.ApplyServerRunRate(4.5f);
+
+ Assert.Equal(Run, controller.Motion.InterpretedState.ForwardCommand);
+ Assert.True(controller.Motion.get_state_velocity().Length() > 1f,
+ "state velocity must survive the apply pass");
+ }
+
+ [Fact]
+ public void HoldW_WithRealSink_AndEchoes_BodyKeepsMoving()
+ {
+ var controller = MakeControllerWithRealSink(out _);
+ var input = new MovementInput { Forward = true, Run = true };
+
+ float startX = 96f;
+ Vector3 pos = new(startX, 96f, 50f);
+ for (int f = 0; f < 120; f++)
+ {
+ if (f % 6 == 3)
+ controller.ApplyServerRunRate(4.5f); // ACE echo cadence
+ pos = controller.Update(1f / 60f, input).Position;
+ }
+
+ // 2 seconds of held-W running (post-fix ~9.5 m/s) must cover
+ // meters, not centimeters. Pre-fix this stalled at ~one tick of
+ // travel (the echo pass reset forward to Ready).
+ Assert.True(pos.X - startX > 5f,
+ $"expected sustained forward motion, got {pos.X - startX:F2} m");
+ }
+
+ [Fact]
+ public void ShiftToggle_MidHold_WalkRunTransitionSurvivesEcho()
+ {
+ var controller = MakeControllerWithRealSink(out _);
+
+ // Hold W at run for 30 frames.
+ for (int f = 0; f < 30; f++)
+ controller.Update(1f / 60f, new MovementInput { Forward = true, Run = true });
+ Assert.Equal(Run, controller.Motion.InterpretedState.ForwardCommand);
+
+ // Shift pressed (walk) — the set_hold_run edge demotes to walk.
+ for (int f = 0; f < 30; f++)
+ {
+ if (f == 10) controller.ApplyServerRunRate(1.0f); // echo mid-walk
+ controller.Update(1f / 60f, new MovementInput { Forward = true, Run = false });
+ }
+ Assert.Equal(Walk, controller.Motion.InterpretedState.ForwardCommand);
+
+ // Shift released — promote back to run; survives another echo.
+ for (int f = 0; f < 30; f++)
+ {
+ if (f == 10) controller.ApplyServerRunRate(4.5f);
+ controller.Update(1f / 60f, new MovementInput { Forward = true, Run = true });
+ }
+ Assert.Equal(Run, controller.Motion.InterpretedState.ForwardCommand);
+ }
+}