refactor(runtime): own local movement and outbound cadence

Move the canonical local movement controller, body/motion managers, object clock, movement wire data, and MTS/jump/AP sender into AcDream.Runtime. Replace process skill defaults with typed Runtime character options, make graphical and direct commands borrow one autorun owner, retain the construction-time PartArray seam, and include movement in terminal ownership convergence.

Preserve the accepted pre-inbound movement/jump and post-inbound autonomous-position order while moving the exact packet/cadence fixtures into Runtime tests. Add graphical/direct parity, two-instance isolation, teardown, allocation, architecture, and divergence-path coverage.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-26 12:33:53 +02:00
parent 3456dff038
commit aa3f4a60f8
36 changed files with 878 additions and 276 deletions

View file

@ -883,8 +883,7 @@ internal sealed class SessionPlayerCompositionPhase
d.PlayerController,
worldReveal,
d.UpdateClock,
live.SelectionInteractions,
gameplayInput);
live.SelectionInteractions);
bindings.Adopt("current game runtime adapter", gameRuntime);
bindings.Adopt(
"retained-UI game runtime commands",

View file

@ -0,0 +1,7 @@
global using AcDream.Runtime.Gameplay;
global using LocalPlayerControllerSlot =
AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState;
global using ILocalPlayerControllerSource =
AcDream.Runtime.Gameplay.IRuntimeLocalPlayerControllerSource;
global using ILocalPlayerMotionSource =
AcDream.Runtime.Gameplay.IRuntimeLocalPlayerMotionSource;

View file

@ -8,18 +8,25 @@ internal interface IMovementInputSource
}
/// <summary>
/// Owns held movement sampling and the retail autorun latch. The input
/// dispatcher remains the only keyboard/mouse-button state source.
/// 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(IInputCaptureSource? capture = null) =>
public DispatcherMovementInputSource(
RuntimeLocalPlayerMovementState movement,
IInputCaptureSource? capture = null)
{
_movement = movement ?? throw new ArgumentNullException(nameof(movement));
_capture = capture;
}
public bool AutoRunActive { get; private set; }
public bool AutoRunActive => _movement.AutoRunActive;
public bool IsAvailable => _dispatcher is not null;
public void Bind(InputDispatcher dispatcher)
@ -70,10 +77,8 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
public bool HandlePressedAction(InputAction action)
{
if (action == InputAction.MovementRunLock)
{
AutoRunActive = !AutoRunActive;
return true;
}
return _movement.Execute(
AcDream.Runtime.RuntimeMovementCommand.ToggleRunLock);
if (AutoRunActive && action is (
InputAction.MovementBackup
@ -81,11 +86,11 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
or InputAction.MovementStrafeLeft
or InputAction.MovementStrafeRight))
{
AutoRunActive = false;
_movement.CancelAutoRun();
}
return false;
}
public void ResetSession() => AutoRunActive = false;
public void ResetSession() => _movement.ResetInputIntent();
}

View file

@ -1,252 +0,0 @@
using System.Numerics;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
namespace AcDream.App.Input;
/// <summary>
/// Serializes input-originated output from the object-phase result and the
/// periodic position report from post-inbound controller state.
/// </summary>
public sealed class LocalPlayerOutboundController
{
private readonly IMovementTruthDiagnosticSink _diagnostic;
internal LocalPlayerOutboundController(IMovementTruthDiagnosticSink diagnostic)
{
_diagnostic = diagnostic
?? throw new ArgumentNullException(nameof(diagnostic));
}
public LocalPlayerOutboundController(
Action<string, uint, MovementResult, Vector3, uint, byte> diagnostic)
{
_diagnostic = new DelegateMovementTruthDiagnosticSink(diagnostic);
}
/// <summary>
/// Sends input-originated movement and jump packets before inbound
/// dispatch. Retail emits these from input/object processing rather than
/// from <c>CommandInterpreter::UseTime</c>.
/// </summary>
public void SendPreNetworkActions(
WorldSession? session,
PlayerMovementController controller,
MovementResult movement,
bool hidden)
{
if (session is null || hidden)
return;
if (!controller.TryGetOutboundPosition(out Position outboundPosition))
{
return;
}
uint wireCellId = outboundPosition.ObjCellId;
Vector3 wirePosition = outboundPosition.Frame.Origin;
Quaternion wireRotation = outboundPosition.Frame.Orientation;
if (movement.ShouldSendMovementEvent)
TrySendMovement(session, controller, movement);
if (movement.JumpExtent.HasValue && movement.JumpVelocity.HasValue)
{
uint sequence = session.NextGameActionSequence();
byte[] body = JumpAction.Build(
gameActionSequence: sequence,
extent: movement.JumpExtent.Value,
velocity: movement.JumpVelocity.Value,
cellId: wireCellId,
position: wirePosition,
rotation: wireRotation,
instanceSequence: session.InstanceSequence,
serverControlSequence: session.ServerControlSequence,
teleportSequence: session.TeleportSequence,
forcePositionSequence: session.ForcePositionSequence);
session.SendGameAction(body);
}
}
/// <summary>
/// Ports the position-send slot in <c>CommandInterpreter::UseTime</c>
/// (<c>0x006B3BF0</c>), which SmartBox calls after draining inbound
/// events. The predicate and serialized frame therefore observe any
/// accepted ForcePosition or teleport state from this update.
/// </summary>
public void SendPostNetworkPosition(
WorldSession? session,
PlayerMovementController controller,
bool hidden)
{
if (session is null || hidden)
return;
if (!controller.TryGetOutboundPosition(out Position position))
{
return;
}
uint wireCellId = position.ObjCellId;
Vector3 wirePosition = position.Frame.Origin;
Quaternion wireRotation = position.Frame.Orientation;
if (!controller.ShouldSendPositionEvent(
position,
controller.ContactPlane,
controller.SimTimeSeconds)
|| !controller.CanSendPositionEvent)
{
return;
}
MovementResult movement = controller.CapturePresentationResult();
byte contactByte = movement.IsOnGround ? (byte)1 : (byte)0;
uint sequence = session.NextGameActionSequence();
byte[] body = AutonomousPosition.Build(
gameActionSequence: sequence,
cellId: wireCellId,
position: wirePosition,
rotation: wireRotation,
instanceSequence: session.InstanceSequence,
serverControlSequence: session.ServerControlSequence,
teleportSequence: session.TeleportSequence,
forcePositionSequence: session.ForcePositionSequence,
lastContact: contactByte);
_diagnostic.OnOutbound(
"AP",
sequence,
movement,
wirePosition,
wireCellId,
contactByte);
session.SendGameAction(body);
controller.NotePositionSent(
position,
controller.ContactPlane,
controller.SimTimeSeconds);
}
/// <summary>
/// Retail ForcePosition acknowledgement sent immediately after the local
/// body is blipped. Unlike the periodic post-network sender, this bypasses
/// the elapsed/difference predicate but still requires a valid grounded
/// canonical frame.
/// </summary>
internal void SendImmediatePosition(
WorldSession? session,
PlayerMovementController? controller)
{
if (session is null
|| controller is null
|| !controller.CanSendPositionEvent)
{
return;
}
if (!controller.TryGetOutboundPosition(out Position outboundPosition))
return;
uint cellId = outboundPosition.ObjCellId;
Vector3 position = outboundPosition.Frame.Origin;
Quaternion rotation = outboundPosition.Frame.Orientation;
uint sequence = session.NextGameActionSequence();
byte[] body = AutonomousPosition.Build(
gameActionSequence: sequence,
cellId: cellId,
position: position,
rotation: rotation,
instanceSequence: session.InstanceSequence,
serverControlSequence: session.ServerControlSequence,
teleportSequence: session.TeleportSequence,
forcePositionSequence: session.ForcePositionSequence,
lastContact: 1);
session.SendGameAction(body);
controller.NotePositionSent(
outboundPosition,
controller.ContactPlane,
controller.SimTimeSeconds);
}
public bool TrySendMovement(
WorldSession? session,
PlayerMovementController? controller,
MovementResult movement)
{
if (session is null || controller is null)
return false;
if (!controller.TryGetOutboundPosition(out Position outboundPosition))
{
return false;
}
uint wireCellId = outboundPosition.ObjCellId;
Vector3 wirePosition = outboundPosition.Frame.Origin;
Quaternion wireRotation = outboundPosition.Frame.Orientation;
byte contactByte = movement.IsOnGround ? (byte)1 : (byte)0;
RawMotionState rawMotionState = BuildRawMotionState(movement);
uint sequence = session.NextGameActionSequence();
byte[] body = MoveToState.Build(
gameActionSequence: sequence,
rawMotionState: rawMotionState,
cellId: wireCellId,
position: wirePosition,
rotation: wireRotation,
instanceSequence: session.InstanceSequence,
serverControlSequence: session.ServerControlSequence,
teleportSequence: session.TeleportSequence,
forcePositionSequence: session.ForcePositionSequence,
contact: contactByte != 0,
standingLongjump: false);
_diagnostic.OnOutbound(
"MTS",
sequence,
movement,
wirePosition,
wireCellId,
contactByte);
session.SendGameAction(body);
controller.NoteMovementSent(
controller.SimTimeSeconds,
movement.IsMouseLookMovementEvent);
return true;
}
public static RawMotionState BuildRawMotionState(MovementResult movement)
{
HoldKey axisHoldKey = movement.IsRunning ? HoldKey.Run : HoldKey.None;
return new RawMotionState
{
CurrentHoldKey = axisHoldKey,
// CommandInterpreter::SendMovementEvent @ 0x006B4680 passes
// CPhysicsObj::InqRawMotionState directly to MoveToStatePack.
// RawMotionState::UnPack @ 0x0051EFC0 treats an absent style as
// NonCombat, so this is state—not optional observer decoration.
CurrentStyle = movement.CurrentStyle,
ForwardCommand = movement.ForwardCommand
?? RawMotionState.Default.ForwardCommand,
ForwardHoldKey = movement.ForwardCommand.HasValue
? axisHoldKey : HoldKey.Invalid,
ForwardSpeed = movement.ForwardSpeed
?? RawMotionState.Default.ForwardSpeed,
SidestepCommand = movement.SidestepCommand
?? RawMotionState.Default.SidestepCommand,
SidestepHoldKey = movement.SidestepCommand.HasValue
? movement.SidestepUsesRunHold
? HoldKey.Run
: axisHoldKey
: HoldKey.Invalid,
SidestepSpeed = movement.SidestepSpeed
?? RawMotionState.Default.SidestepSpeed,
TurnCommand = movement.TurnCommand
?? RawMotionState.Default.TurnCommand,
TurnHoldKey = movement.TurnCommand.HasValue
? movement.TurnUsesRunHold
? HoldKey.Run
: axisHoldKey
: HoldKey.Invalid,
TurnSpeed = movement.TurnSpeed
?? RawMotionState.Default.TurnSpeed,
};
}
}

View file

@ -16,89 +16,6 @@ internal sealed class LocalPlayerIdentityState : ILocalPlayerIdentitySource
public uint ServerGuid { get; set; }
}
internal interface ILocalPlayerControllerSource
{
PlayerMovementController? Controller { get; }
}
internal interface ILocalPlayerMotionSource
{
MotionInterpreter? Motion { get; }
}
/// <summary>
/// The one mutable local movement-controller slot. Player-mode lifecycle owns
/// assignment; update and presentation owners receive the read-only seam.
/// </summary>
internal sealed class LocalPlayerControllerSlot
: ILocalPlayerControllerSource,
ILocalPlayerMotionSource
{
private PlayerMovementController? _preparingMotionOwner;
public PlayerMovementController? Controller { get; set; }
/// <summary>
/// The motion owner visible to the local PartArray completion relay.
/// During player-mode construction this is the fully animation-bound
/// candidate controller; all other consumers continue to see only the
/// committed <see cref="Controller"/>.
/// </summary>
MotionInterpreter? ILocalPlayerMotionSource.Motion =>
_preparingMotionOwner?.Motion ?? Controller?.Motion;
/// <summary>
/// Opens the narrow construction-time ownership seam required by retail's
/// CPhysicsObj/PartArray lifetime. Initial placement synchronously queues
/// and completes StopCompletely, so its MotionDone relay must reach the
/// candidate interpreter before the complete controller is published.
/// </summary>
public IDisposable BeginMotionPreparation(
PlayerMovementController controller,
Action? drainPriorAnimationQueue = null)
{
ArgumentNullException.ThrowIfNull(controller);
if (_preparingMotionOwner is not null)
{
throw new InvalidOperationException(
"A local player motion owner is already being prepared.");
}
// The live PartArray exists before the local player controller. Drain
// any animation completions produced under that prior ownership while
// they still resolve to the prior (or null) interpreter. Publishing
// the fresh interpreter first would let those old callbacks pop its
// new pending_motions queue and leave an unmatched Ready sentinel.
drainPriorAnimationQueue?.Invoke();
_preparingMotionOwner = controller;
return new MotionPreparation(this, controller);
}
private void EndMotionPreparation(PlayerMovementController controller)
{
if (!ReferenceEquals(_preparingMotionOwner, controller))
{
throw new InvalidOperationException(
"The local player motion preparation owner changed unexpectedly.");
}
_preparingMotionOwner = null;
}
private sealed class MotionPreparation(
LocalPlayerControllerSlot owner,
PlayerMovementController controller) : IDisposable
{
private LocalPlayerControllerSlot? _owner = owner;
public void Dispose()
{
LocalPlayerControllerSlot? current = Interlocked.Exchange(ref _owner, null);
current?.EndMotionPreparation(controller);
}
}
}
internal interface ILocalPlayerPhysicsHostSource
{
EntityPhysicsHost? Host { get; }

View file

@ -3,23 +3,6 @@ using AcDream.Core.Net;
namespace AcDream.App.Input;
internal interface IMovementTruthDiagnosticSink
{
void OnOutbound(
string kind,
uint sequence,
MovementResult result,
Vector3 wirePosition,
uint wireCellId,
byte contactByte);
void OnServerEcho(
WorldSession.EntityPositionUpdate update,
Vector3 serverWorldPosition);
void ResetSession();
}
internal sealed class MovementTruthDiagnosticController
: IMovementTruthDiagnosticSink
{
@ -129,33 +112,3 @@ internal sealed class MovementTruthDiagnosticController
? FormattableString.Invariant($"0x{command.Value:X8}")
: "-";
}
internal sealed class DelegateMovementTruthDiagnosticSink
: IMovementTruthDiagnosticSink
{
private readonly Action<string, uint, MovementResult, Vector3, uint, byte>
_outbound;
public DelegateMovementTruthDiagnosticSink(
Action<string, uint, MovementResult, Vector3, uint, byte> outbound) =>
_outbound = outbound ?? throw new ArgumentNullException(nameof(outbound));
public void OnOutbound(
string kind,
uint sequence,
MovementResult result,
Vector3 wirePosition,
uint wireCellId,
byte contactByte) =>
_outbound(kind, sequence, result, wirePosition, wireCellId, contactByte);
public void OnServerEcho(
WorldSession.EntityPositionUpdate update,
Vector3 serverWorldPosition)
{
}
public void ResetSession()
{
}
}

View file

@ -256,7 +256,8 @@ internal sealed class PlayerModeController :
{
var controller = new PlayerMovementController(
_physics,
playerRecord.ObjectClock);
playerRecord.ObjectClock,
PlayerMovementConstructionOptions.From(_skills.Snapshot));
controller.ApplyPhysicsState(playerRecord.FinalPhysicsState);
// Retail MovementManager::MakeMoveToManager @ 0x00524000 creates one

File diff suppressed because it is too large Load diff

View file

@ -162,7 +162,7 @@ public sealed class GameWindow :
private LiveEntityAnimationPresenter _animationPresenter = null!;
private readonly AcDream.App.Input.MovementTruthDiagnosticController
_movementTruthDiagnostics;
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
private readonly LocalPlayerOutboundController _localPlayerOutbound;
private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock = new();
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
// Step 7 projectile presentation. The controller owns no identity map;
@ -431,8 +431,8 @@ public sealed class GameWindow :
private AcDream.App.Rendering.SceneLightingUboBinding? _sceneLightingUbo;
private AcDream.App.Rendering.Sky.SkyRenderer? _skyRenderer;
// Phase B.2: player movement mode.
private readonly AcDream.App.Input.LocalPlayerControllerSlot _playerControllerSlot = new();
private AcDream.App.Input.PlayerMovementController? _playerController
private readonly RuntimeLocalPlayerMovementState _playerControllerSlot = new();
private PlayerMovementController? _playerController
=> _playerControllerSlot.Controller;
private readonly AcDream.App.Input.ChaseCameraInputState _chaseCameraInput = new();
private AcDream.App.Rendering.ChaseCamera? _chaseCamera
@ -586,6 +586,7 @@ public sealed class GameWindow :
new AcDream.App.Input.DevToolsInputCaptureSource(options.DevTools),
_retainedInputCapture);
_movementInput = new AcDream.App.Input.DispatcherMovementInputSource(
_playerControllerSlot,
_inputCapture);
_framebufferResize = new FramebufferResizeController(_viewportAspect);
_datDir = options.DatDir;
@ -620,7 +621,7 @@ public sealed class GameWindow :
options.DumpMoveTruth,
_playerControllerSlot,
_localPlayerIdentity);
_localPlayerOutbound = new AcDream.App.Input.LocalPlayerOutboundController(
_localPlayerOutbound = new LocalPlayerOutboundController(
_movementTruthDiagnostics);
}
@ -1587,6 +1588,7 @@ public sealed class GameWindow :
_runtimeCharacter,
_runtimeCommunication,
_runtimeActions,
_playerControllerSlot,
_renderSceneShadow,
_livePresentationBindings,
_entityEffectAdvance,

View file

@ -88,6 +88,7 @@ internal sealed record LiveShutdownRoots(
RuntimeCharacterState Character,
RuntimeCommunicationState Communication,
RuntimeActionState Actions,
RuntimeLocalPlayerMovementState Movement,
RenderSceneShadowRuntime? RenderSceneShadow,
LivePresentationRuntimeBindings? PresentationBindings,
DeferredEntityEffectAdvanceSource EffectAdvance,
@ -392,6 +393,9 @@ internal static class GameWindowShutdownManifest
]),
new ResourceShutdownStage("runtime entity/object stream",
[
Hard(
"runtime local movement state",
live.Movement.Dispose),
Hard(
"runtime action state",
live.Actions.Dispose),

View file

@ -38,11 +38,10 @@ internal sealed class CurrentGameRuntimeAdapter
RuntimeCharacterState character,
RuntimeCommunicationState communication,
RuntimeActionState actions,
ILocalPlayerControllerSource playerController,
RuntimeLocalPlayerMovementState movement,
WorldRevealCoordinator worldReveal,
IGameRuntimeClock clock,
SelectionInteractionController selection,
GameplayInputFrameController gameplayInput)
SelectionInteractionController selection)
{
_view = new CurrentGameRuntimeViewAdapter(
session,
@ -53,7 +52,7 @@ internal sealed class CurrentGameRuntimeAdapter
character,
communication,
actions,
playerController,
movement,
worldReveal,
clock);
entityObjects.BindEventContext(
@ -71,8 +70,8 @@ internal sealed class CurrentGameRuntimeAdapter
inventory,
character,
actions,
movement,
selection,
gameplayInput,
_events);
}

View file

@ -34,8 +34,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter
private readonly RuntimeInventoryState _inventory;
private readonly RuntimeCharacterState _character;
private readonly RuntimeActionState _actions;
private readonly RuntimeLocalPlayerMovementState _movement;
private readonly SelectionInteractionController _selection;
private readonly GameplayInputFrameController _gameplayInput;
private readonly ICurrentGameRuntimeEventSink _events;
public CurrentGameRuntimeCommandAdapter(
@ -46,8 +46,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeInventoryState inventory,
RuntimeCharacterState character,
RuntimeActionState actions,
RuntimeLocalPlayerMovementState movement,
SelectionInteractionController selection,
GameplayInputFrameController gameplayInput,
ICurrentGameRuntimeEventSink events)
{
_session = session ?? throw new ArgumentNullException(nameof(session));
@ -58,9 +58,9 @@ internal sealed class CurrentGameRuntimeCommandAdapter
_character = character ?? throw new ArgumentNullException(nameof(character));
_actions = actions
?? throw new ArgumentNullException(nameof(actions));
_movement = movement
?? throw new ArgumentNullException(nameof(movement));
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_gameplayInput = gameplayInput
?? throw new ArgumentNullException(nameof(gameplayInput));
_events = events ?? throw new ArgumentNullException(nameof(events));
}
@ -246,22 +246,9 @@ internal sealed class CurrentGameRuntimeCommandAdapter
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
InputAction action = command switch
{
RuntimeMovementCommand.ToggleRunLock => InputAction.MovementRunLock,
RuntimeMovementCommand.Stop => InputAction.MovementStop,
_ => InputAction.None,
};
RuntimeCommandStatus status;
if (action == InputAction.None)
{
status = RuntimeCommandStatus.Unsupported;
}
else
{
_gameplayInput.HandlePressedMovementAction(action);
status = RuntimeCommandStatus.Accepted;
}
RuntimeCommandStatus status = _movement.Execute(command)
? RuntimeCommandStatus.Accepted
: RuntimeCommandStatus.Unsupported;
_events.EmitCommand(RuntimeCommandDomain.Movement, (int)command, status);
return Result(status);

View file

@ -29,7 +29,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
private readonly IRuntimeSocialView _socialView;
private readonly IRuntimeChatView _chatView;
private readonly IRuntimeActionView _actionView;
private readonly MovementView _movementView;
private readonly IRuntimeMovementView _movementView;
private readonly PortalView _portalView;
private bool _active = true;
@ -42,7 +42,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
RuntimeCharacterState character,
RuntimeCommunicationState communication,
RuntimeActionState actions,
ILocalPlayerControllerSource playerController,
RuntimeLocalPlayerMovementState movement,
WorldRevealCoordinator worldReveal,
IGameRuntimeClock clock)
{
@ -65,10 +65,8 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
_socialView = communication.SocialView;
_actionView = (
actions ?? throw new ArgumentNullException(nameof(actions))).View;
_movementView = new MovementView(
playerController
?? throw new ArgumentNullException(nameof(playerController)),
_clock);
_movementView = (
movement ?? throw new ArgumentNullException(nameof(movement))).View;
_portalView = new PortalView(
worldReveal ?? throw new ArgumentNullException(nameof(worldReveal)));
}
@ -132,35 +130,6 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
internal void Deactivate() => _active = false;
private sealed class MovementView(
ILocalPlayerControllerSource owner,
IGameRuntimeClock clock)
: IRuntimeMovementView
{
public RuntimeMovementSnapshot Snapshot
{
get
{
PlayerMovementController? controller = owner.Controller;
return controller is null
? new RuntimeMovementSnapshot(
false,
0u,
default,
default,
false,
clock.SimulationTimeSeconds)
: new RuntimeMovementSnapshot(
true,
controller.LocalEntityId,
controller.CellPosition,
controller.BodyVelocity,
controller.IsAirborne,
clock.SimulationTimeSeconds);
}
}
}
private sealed class PortalView(WorldRevealCoordinator owner)
: IRuntimePortalView
{