From 30115d96aa5a3cba2db43e1376e94a3c8a3d6a2a Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 3 Jul 2026 09:52:23 +0200 Subject: [PATCH] =?UTF-8?q?fix(R3-W6b):=20entry-cache=20the=20apply=20pass?= =?UTF-8?q?'s=20axis=20reads=20=E2=80=94=20the=20'press=20W=20and=20stop?= =?UTF-8?q?=20instantly'=20regression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User live report (the R3 visual pass): pressing W stopped the local player instantly (retail observers saw run + rubber-band back — our AP never moved); S/strafe appeared to work. The edge tracer + a harness-with-echo repro converged on the funnel: ApplyInterpretedMovement live-read InterpretedState.ForwardCommand AFTER the style dispatch. The style dispatch SUCCEEDS against the real MotionTableDispatchSink (GetObjectSequence Branch 1 style==target → success — verified in the raw @298636), which gates the ModifyInterpretedState write → InterpretedMotionState::ApplyMotion's style branch resets forward_command to Ready UNCONDITIONALLY (raw 0051ea6c — our port is verbatim). Retail SELF-HEALS: its compiled apply pass reads the axis fields into registers BEFORE the style call, so the fwd dispatch re-applies the pre-reset command and the field recovers within the pass — 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 inversion. Under the live-field read, every apply pass (the ~10Hz ACE UM echo via ApplyServerRunRate included) dispatched Ready and left the field permanently Ready; the controller's per-frame get_state_velocity then zeroed the body. The 183-case suite could not catch it: its RecordingSink's return value doesn't mirror the real sink's style-success, so the resetting state-write never ran under test. Fix: entry-cache fwd/sidestep/turn commands+speeds (and the my_run_rate read) before the style dispatch — restoring the S2a funnel's original semantics through the W5 merge. 3 new regression tests bind the REAL sink over a real sequencer (the masked condition): a full apply pass self-heals forward; 2 seconds of held-W with echo cadence covers meters not centimeters; Shift walk↔run toggling survives mid-transition echoes. Full suite: 3,734 passed. Temp diagnostics removed. Co-Authored-By: Claude Fable 5 --- src/AcDream.Core/Physics/MotionInterpreter.cs | 40 ++-- .../Input/W6EdgeDrivenMovementTests.cs | 179 ++++++++++++++++++ 2 files changed, 208 insertions(+), 11 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs 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); + } +}