acdream/src/AcDream.App/Input/DispatcherMovementInputSource.cs
Erik c0afcacbb2 fix(physics): movement-parity fixes - adjusted catch-up cap, autorun retail semantics, AP-30 retired
Ports CMotionInterp::get_adjusted_max_speed (0x00527D00, byte-decoded:
bare rate unless RunForward; forward_speed x 4.0 when running;
current_speed_factor proven a ctor-constant 1.0 at 0x00528C34) and swaps
all five interpolation catch-up call sites to it - retail's
fUseAdjustedSpeed_ static (.data 0x0081F418 = 1) makes this the live
branch, so standing/walking remotes now catch up at ~2x runRate instead
of 4x too fast (the #41/#165 presentation family). Autorun now hard-
forces Run for its duration and cancels on every fresh forward press
(CommandInterpreter::HandleNewForwardMovement 0x006b3d60 is literally
SetAutoRun(0,1)); the old test pin codified the divergence. AP-30
retired: retail Frame::is_equal genuinely uses the 0.0002 epsilon - the
row recorded a non-divergence. Three catch-up test pins re-baselined to
retail semantics with citations. Full Release suite 9,983/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:04:17 +02:00

110 lines
4.3 KiB
C#

using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Input;
internal interface IMovementInputSource
: IRuntimeMovementInputSource
{
}
/// <summary>
/// Samples held graphical input and maps press edges onto the Runtime-owned
/// retail autorun latch. The dispatcher remains the only physical
/// keyboard/mouse-button state source.
/// </summary>
internal sealed class DispatcherMovementInputSource : IMovementInputSource
{
private readonly RuntimeLocalPlayerMovementState _movement;
private readonly IInputCaptureSource? _capture;
private InputDispatcher? _dispatcher;
public DispatcherMovementInputSource(
RuntimeLocalPlayerMovementState movement,
IInputCaptureSource? capture = null)
{
_movement = movement ?? throw new ArgumentNullException(nameof(movement));
_capture = capture;
}
public bool AutoRunActive => _movement.AutoRunActive;
public bool IsAvailable => _dispatcher is not null;
public void Bind(InputDispatcher dispatcher)
{
ArgumentNullException.ThrowIfNull(dispatcher);
if (_dispatcher is not null && !ReferenceEquals(_dispatcher, dispatcher))
throw new InvalidOperationException(
"The movement input source is already bound to another dispatcher.");
_dispatcher = dispatcher;
}
public void Unbind(InputDispatcher dispatcher)
{
ArgumentNullException.ThrowIfNull(dispatcher);
if (ReferenceEquals(_dispatcher, dispatcher))
_dispatcher = null;
}
public MovementInput Capture()
{
// Devtools owns the whole gameplay keyboard while active, including
// a latched autorun. Retained chat owns physical key state only;
// retail's autorun latch continues until an explicit cancel action.
if (_capture?.DevToolsWantCaptureKeyboard == true)
return default;
if (_movement.HasCommandInput)
return _movement.CommandInput;
if (_dispatcher is not { } dispatcher)
return default;
bool walking = dispatcher.IsActionHeld(InputAction.MovementWalkMode);
bool forward = dispatcher.IsActionHeld(InputAction.MovementForward);
return new MovementInput(
Forward: forward || AutoRunActive,
Backward: dispatcher.IsActionHeld(InputAction.MovementBackup),
StrafeLeft: dispatcher.IsActionHeld(InputAction.MovementStrafeLeft),
StrafeRight: dispatcher.IsActionHeld(InputAction.MovementStrafeRight),
TurnLeft: dispatcher.IsActionHeld(InputAction.MovementTurnLeft),
TurnRight: dispatcher.IsActionHeld(InputAction.MovementTurnRight),
// Movement parity audit (2026-07-30): retail's autorun hard-forces
// Run for its whole duration (ACCmdInterp autorun dispatch always
// sends RunForward-class state); the live walk/run toggle only
// applies to ordinary held-key movement. Recomputing `!walking`
// unconditionally let a walk-mode toggle demote an active autorun
// to walking — impossible in retail.
Run: !walking || AutoRunActive,
Jump: dispatcher.IsActionHeld(InputAction.MovementJump));
}
/// <summary>
/// Applies the press-only autorun policy at the same point in the semantic
/// action pipeline as the former GameWindow body.
/// </summary>
/// <returns>True when the action is fully consumed.</returns>
public bool HandlePressedAction(InputAction action)
{
if (action == InputAction.MovementRunLock)
return _movement.Execute(
AcDream.Runtime.RuntimeMovementCommand.ToggleRunLock);
if (AutoRunActive && action is (
// Movement parity audit (2026-07-30): retail's
// ACCmdInterp::HandleNewForwardMovement drops autorun on EVERY
// fresh forward press edge, not only on backward/stop/strafe —
// a new W press while autorunning hands control back to the key.
InputAction.MovementForward
or InputAction.MovementBackup
or InputAction.MovementStop
or InputAction.MovementStrafeLeft
or InputAction.MovementStrafeRight))
{
_movement.CancelAutoRun();
}
return false;
}
public void ResetSession() => _movement.ResetInputIntent();
}