fix(R3-W6b): entry-cache the apply pass's axis reads — the 'press W and stop instantly' regression
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 <noreply@anthropic.com>
This commit is contained in:
parent
778a2b5385
commit
30115d96aa
2 changed files with 208 additions and 11 deletions
|
|
@ -2729,12 +2729,33 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
||||||
_ = cancelMoveTo;
|
_ = cancelMoveTo;
|
||||||
_ = allowJump;
|
_ = allowJump;
|
||||||
|
|
||||||
if (InterpretedState.ForwardCommand == MotionCommand.RunForward)
|
// ENTRY-CACHE all axis values BEFORE the style dispatch (W6 stop-bug
|
||||||
MyRunRate = InterpretedState.ForwardSpeed;
|
// 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);
|
DispatchInterpretedMotion(currentStyle, 1.0f, sink);
|
||||||
|
|
||||||
if (!contact_allows_move(InterpretedState.ForwardCommand))
|
if (!contact_allows_move(entryFwdCmd))
|
||||||
{
|
{
|
||||||
DispatchInterpretedMotion(MotionCommand.Falling, 1.0f, sink);
|
DispatchInterpretedMotion(MotionCommand.Falling, 1.0f, sink);
|
||||||
}
|
}
|
||||||
|
|
@ -2745,19 +2766,16 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
DispatchInterpretedMotion(
|
DispatchInterpretedMotion(entryFwdCmd, entryFwdSpeed, sink);
|
||||||
InterpretedState.ForwardCommand, InterpretedState.ForwardSpeed, sink);
|
if (entrySideCmd == 0)
|
||||||
if (InterpretedState.SideStepCommand == 0)
|
|
||||||
DispatchStopInterpretedMotion(MotionCommand.SideStepRight, sink);
|
DispatchStopInterpretedMotion(MotionCommand.SideStepRight, sink);
|
||||||
else
|
else
|
||||||
DispatchInterpretedMotion(
|
DispatchInterpretedMotion(entrySideCmd, entrySideSpeed, sink);
|
||||||
InterpretedState.SideStepCommand, InterpretedState.SideStepSpeed, sink);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (InterpretedState.TurnCommand != 0)
|
if (entryTurnCmd != 0)
|
||||||
{
|
{
|
||||||
DispatchInterpretedMotion(
|
DispatchInterpretedMotion(entryTurnCmd, entryTurnSpeed, sink);
|
||||||
InterpretedState.TurnCommand, InterpretedState.TurnSpeed, sink);
|
|
||||||
return; // retail early return — no idle-stop this call
|
return; // retail early return — no idle-stop this call
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
179
tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs
Normal file
179
tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>InterpretedState.ForwardCommand</c> AFTER the style
|
||||||
|
/// dispatch, whose <c>ApplyMotion(style)</c> 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 <c>ApplyInterpretedMovement</c>.
|
||||||
|
///
|
||||||
|
/// The 183-case suite could NOT catch this: its RecordingSink's ApplyMotion
|
||||||
|
/// return doesn't mirror the REAL <see cref="MotionTableDispatchSink"/>,
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<uint, Animation> _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<Animation> 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<CellSurface>(),
|
||||||
|
Array.Empty<PortalPlane>(), 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue