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

@ -96,7 +96,8 @@ public sealed class HostInputCameraCompositionTests
ViewportAspect = new ViewportAspectState();
Framebuffer = new FramebufferResizeController(ViewportAspect);
Capture = new CaptureSource();
Movement = new DispatcherMovementInputSource(Capture);
MovementState = new RuntimeLocalPlayerMovementState();
Movement = new DispatcherMovementInputSource(MovementState, Capture);
CameraInput = new DispatcherCameraInputSource();
PlayerMode = new LocalPlayerModeState();
Chase = new ChaseCameraInputState();
@ -111,6 +112,7 @@ public sealed class HostInputCameraCompositionTests
public ViewportAspectState ViewportAspect { get; }
public FramebufferResizeController Framebuffer { get; }
public CaptureSource Capture { get; }
public RuntimeLocalPlayerMovementState MovementState { get; }
public DispatcherMovementInputSource Movement { get; }
public DispatcherCameraInputSource CameraInput { get; }
public LocalPlayerModeState PlayerMode { get; }
@ -141,7 +143,11 @@ public sealed class HostInputCameraCompositionTests
throw new InvalidOperationException($"fault at {point}");
});
public void Dispose() => Publication.Dispose();
public void Dispose()
{
Publication.Dispose();
MovementState.Dispose();
}
}
private sealed class Publication :

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

@ -76,7 +76,7 @@ public sealed class CameraPointerInputControllerTests
var mouseLook = new MouseLook(active: true);
var frame = new GameplayInputFrameController(
dispatcher: null,
new DispatcherMovementInputSource(),
new DispatcherMovementInputSource(new RuntimeLocalPlayerMovementState()),
mouseLook,
new Combat());
@ -113,7 +113,7 @@ public sealed class CameraPointerInputControllerTests
var mouseLook = new MouseLook(active: true);
var frame = new GameplayInputFrameController(
dispatcher: null,
new DispatcherMovementInputSource(),
new DispatcherMovementInputSource(new RuntimeLocalPlayerMovementState()),
mouseLook,
new Combat());
fixture.Owner.BindGameplayFrame(frame);

View file

@ -9,7 +9,7 @@ public sealed class DispatcherMovementInputSourceTests
[Fact]
public void UnboundSourceCapturesNeutralInput()
{
var source = new DispatcherMovementInputSource();
var source = CreateSource();
Assert.Equal(default, source.Capture());
}
@ -18,7 +18,7 @@ public sealed class DispatcherMovementInputSourceTests
public void CapturesDispatcherHeldStateAndRetailWalkModifier()
{
var (dispatcher, _, _) = CreateDispatcher();
var source = new DispatcherMovementInputSource();
var source = CreateSource();
source.Bind(dispatcher);
Assert.True(dispatcher.TrySetAutomationActionHeld(
@ -38,7 +38,7 @@ public sealed class DispatcherMovementInputSourceTests
public void RetainedKeyboardCaptureSilencesHeldKeysButDoesNotCancelAutorun()
{
var (dispatcher, _, mouse) = CreateDispatcher();
var source = new DispatcherMovementInputSource();
var source = CreateSource();
source.Bind(dispatcher);
dispatcher.TrySetAutomationActionHeld(InputAction.MovementForward, held: true);
Assert.True(source.HandlePressedAction(InputAction.MovementRunLock));
@ -59,7 +59,7 @@ public sealed class DispatcherMovementInputSourceTests
{
var capture = new FakeCapture();
var (dispatcher, _, _) = CreateDispatcher();
var source = new DispatcherMovementInputSource(capture);
var source = CreateSource(capture);
source.Bind(dispatcher);
source.HandlePressedAction(InputAction.MovementRunLock);
@ -78,7 +78,7 @@ public sealed class DispatcherMovementInputSourceTests
[InlineData(InputAction.MovementStrafeRight)]
public void RetailCancelActionsClearAutorun(InputAction action)
{
var source = new DispatcherMovementInputSource();
var source = CreateSource();
source.HandlePressedAction(InputAction.MovementRunLock);
Assert.False(source.HandlePressedAction(action));
@ -89,7 +89,7 @@ public sealed class DispatcherMovementInputSourceTests
[Fact]
public void ForwardDoesNotCancelAutorunAndResetDoes()
{
var source = new DispatcherMovementInputSource();
var source = CreateSource();
source.HandlePressedAction(InputAction.MovementRunLock);
Assert.False(source.HandlePressedAction(InputAction.MovementForward));
@ -102,7 +102,7 @@ public sealed class DispatcherMovementInputSourceTests
[Fact]
public void BindingIsIdempotentOnlyForTheSameDispatcher()
{
var source = new DispatcherMovementInputSource();
var source = CreateSource();
var (first, _, _) = CreateDispatcher();
var (second, _, _) = CreateDispatcher();
@ -115,7 +115,7 @@ public sealed class DispatcherMovementInputSourceTests
[Fact]
public void UnbindRequiresExactDispatcherAndRestoresNeutralCapture()
{
var source = new DispatcherMovementInputSource();
var source = CreateSource();
var (first, _, _) = CreateDispatcher();
var (other, _, _) = CreateDispatcher();
source.Bind(first);
@ -142,6 +142,10 @@ public sealed class DispatcherMovementInputSourceTests
return (dispatcher, keyboard, mouse);
}
private static DispatcherMovementInputSource CreateSource(
IInputCaptureSource? capture = null) =>
new(new RuntimeLocalPlayerMovementState(), capture);
private sealed class FakeKeyboard : IKeyboardSource
{
#pragma warning disable CS0067

View file

@ -30,7 +30,7 @@ public sealed class GameplayInputFrameControllerTests
var combat = new FakeCombat(calls);
var controller = new GameplayInputFrameController(
dispatcher,
new DispatcherMovementInputSource(),
new DispatcherMovementInputSource(new RuntimeLocalPlayerMovementState()),
mouseLook,
combat);
@ -45,7 +45,7 @@ public sealed class GameplayInputFrameControllerTests
var calls = new List<string>();
var controller = new GameplayInputFrameController(
dispatcher: null,
new DispatcherMovementInputSource(),
new DispatcherMovementInputSource(new RuntimeLocalPlayerMovementState()),
mouseLook: null,
new FakeCombat(calls));
@ -60,7 +60,7 @@ public sealed class GameplayInputFrameControllerTests
var calls = new List<string>();
var controller = new GameplayInputFrameController(
dispatcher: null,
new DispatcherMovementInputSource(),
new DispatcherMovementInputSource(new RuntimeLocalPlayerMovementState()),
mouseLook: null,
new FakeCombat(calls, consumes: true));
@ -76,7 +76,8 @@ public sealed class GameplayInputFrameControllerTests
public void ResetSessionReleasesMouseLookAndAutorun()
{
var calls = new List<string>();
var movement = new DispatcherMovementInputSource();
var movement = new DispatcherMovementInputSource(
new RuntimeLocalPlayerMovementState());
movement.HandlePressedAction(InputAction.MovementRunLock);
var mouseLook = new FakeMouseLook(calls);
var controller = new GameplayInputFrameController(
@ -98,7 +99,7 @@ public sealed class GameplayInputFrameControllerTests
var mouseLook = new FakeMouseLook(calls, active: true, consumes: true);
var controller = new GameplayInputFrameController(
dispatcher: null,
new DispatcherMovementInputSource(),
new DispatcherMovementInputSource(new RuntimeLocalPlayerMovementState()),
mouseLook,
new FakeCombat(calls));

View file

@ -1,114 +0,0 @@
using System.Buffers.Binary;
using System.Net;
using System.Numerics;
using AcDream.App.Input;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
namespace AcDream.App.Tests.Input;
public sealed class LocalPlayerImmediatePositionTests
{
[Fact]
public void ForcePositionAcknowledgement_SendsOneExactAutonomousPositionAndStampsIt()
{
using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000));
session.PublishAcceptedLocalPhysicsTimestamps(
instance: 11,
serverControlledMove: 12,
teleport: 13,
forcePosition: 14);
var sent = new List<byte[]>();
session.GameActionCapture = sent.Add;
PlayerMovementController player = GroundedPlayer();
Position preTurnCanonical = player.CellPosition;
Quaternion rotation = Quaternion.Normalize(
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.7f));
player.SetBodyOrientation(rotation);
Assert.NotEqual(rotation, preTurnCanonical.Frame.Orientation);
Assert.True(player.TryGetOutboundPosition(out Position outboundPosition));
var outbound = new LocalPlayerOutboundController((_, _, _, _, _, _) => { });
outbound.SendImmediatePosition(session, player);
byte[] body = Assert.Single(sent);
Assert.Equal(56, body.Length);
Assert.Equal(AutonomousPosition.GameActionOpcode, U32(body, 0));
Assert.Equal(1u, U32(body, 4));
Assert.Equal(AutonomousPosition.AutonomousPositionAction, U32(body, 8));
Assert.Equal(outboundPosition.ObjCellId, U32(body, 12));
Assert.Equal(outboundPosition.Frame.Origin.X, F32(body, 16));
Assert.Equal(outboundPosition.Frame.Origin.Y, F32(body, 20));
Assert.Equal(outboundPosition.Frame.Origin.Z, F32(body, 24));
Assert.Equal(rotation.W, F32(body, 28), precision: 6);
Assert.Equal(rotation.X, F32(body, 32), precision: 6);
Assert.Equal(rotation.Y, F32(body, 36), precision: 6);
Assert.Equal(rotation.Z, F32(body, 40), precision: 6);
Assert.Equal((ushort)11, U16(body, 44));
Assert.Equal((ushort)12, U16(body, 46));
Assert.Equal((ushort)13, U16(body, 48));
Assert.Equal((ushort)14, U16(body, 50));
Assert.Equal((byte)1, body[52]);
Assert.True(player.TryGetOutboundPosition(out Position currentOutbound));
Assert.False(player.ShouldSendPositionEvent(
currentOutbound,
player.ContactPlane,
player.SimTimeSeconds));
}
[Fact]
public void ImmediateAcknowledgement_RequiresSessionPlayerAndGroundContact()
{
using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000));
var sent = new List<byte[]>();
session.GameActionCapture = sent.Add;
var outbound = new LocalPlayerOutboundController((_, _, _, _, _, _) => { });
PlayerMovementController grounded = GroundedPlayer();
outbound.SendImmediatePosition(null, grounded);
outbound.SendImmediatePosition(session, null);
outbound.SendImmediatePosition(
session,
new PlayerMovementController(new PhysicsEngine()));
Assert.Empty(sent);
}
private static PlayerMovementController GroundedPlayer()
{
var engine = new PhysicsEngine();
var heights = new byte[81];
Array.Fill(heights, (byte)50);
var heightTable = new float[256];
for (int i = 0; i < heightTable.Length; i++)
heightTable[i] = i;
engine.AddLandblock(
0xA9B4FFFFu,
new TerrainSurface(heights, heightTable),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
var player = new PlayerMovementController(engine);
player.SetPosition(
new Vector3(96f, 97f, 50f),
0xA9B40001u,
new Vector3(96f, 97f, 50f));
Assert.True(player.CanSendPositionEvent);
return player;
}
private static uint U32(byte[] body, int offset) =>
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(offset, 4));
private static ushort U16(byte[] body, int offset) =>
BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(offset, 2));
private static float F32(byte[] body, int offset) =>
BitConverter.Int32BitsToSingle(
BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(offset, 4)));
}

View file

@ -1,50 +0,0 @@
using System.Buffers.Binary;
using AcDream.App.Input;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
using AcDream.Core.Physics;
namespace AcDream.App.Tests.Input;
public sealed class LocalPlayerOutboundCombatStyleTests
{
[Theory]
[InlineData(0x8000003Cu)] // HandCombat
[InlineData(0x8000003Eu)] // Magic
[InlineData(0x8000003Fu)] // BowCombat
[InlineData(0x80000041u)] // CrossbowCombat
public void CaptureAndBuild_PreservesCanonicalRawCombatStyle(uint combatStyle)
{
var controller = new PlayerMovementController(new PhysicsEngine());
controller.Motion.RawState.CurrentStyle = combatStyle;
MovementResult movement = controller.CaptureMovementResult(
mouseLookEvent: false);
RawMotionState outbound = LocalPlayerOutboundController.BuildRawMotionState(
movement);
Assert.Equal(combatStyle, movement.CurrentStyle);
Assert.Equal(combatStyle, outbound.CurrentStyle);
var writer = new PacketWriter(16);
RawMotionStatePacker.Pack(writer, outbound);
byte[] bytes = writer.ToArray();
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(bytes);
Assert.NotEqual(0u, flags & 0x2u);
Assert.Equal(
combatStyle,
BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(4)));
}
[Fact]
public void CaptureAndBuild_NonCombatRetainsRetailDefault()
{
var controller = new PlayerMovementController(new PhysicsEngine());
RawMotionState outbound = LocalPlayerOutboundController.BuildRawMotionState(
controller.CaptureMovementResult(mouseLookEvent: false));
Assert.Equal(RawMotionState.Default.CurrentStyle, outbound.CurrentStyle);
}
}

View file

@ -1,456 +0,0 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.App.Tests.Input;
public sealed class PlayerMouseLookMovementTests
{
[Fact]
public void KeyboardMotionEdges_StillRequestImmediateMovementEvents()
{
var controller = CreateController();
MovementResult pressed = controller.Update(
1f / 60f,
new MovementInput(Forward: true));
MovementResult held = controller.Update(
1f / 60f,
new MovementInput(Forward: true));
MovementResult released = controller.Update(
1f / 60f,
new MovementInput());
Assert.True(pressed.ShouldSendMovementEvent);
Assert.False(held.ShouldSendMovementEvent);
Assert.True(released.ShouldSendMovementEvent);
}
[Fact]
public void MouseAdjustment_UsesTurnMotionInsteadOfDirectYawMutation()
{
var controller = CreateController();
Assert.True(controller.BeginMouseLook(new MovementInput()));
controller.NoteMovementSent(controller.SimTimeSeconds, mouseLookEvent: false);
float yawBefore = controller.Yaw;
controller.SubmitMouseTurnAdjustment(-0.4f, new MovementInput());
// Rotation is authored/applied on an admitted retail object quantum,
// not directly by the mouse sample. Stay below the outbound mouse
// cadence while crossing the strict physics minimum.
MovementResult turning = controller.Update(
PhysicsBody.MinQuantum + 0.001f,
new MovementInput());
Assert.Equal(MotionCommand.TurnRight, turning.TurnCommand);
Assert.Equal(0.8f, turning.TurnSpeed);
Assert.True(controller.Yaw < yawBefore);
Assert.False(turning.ShouldSendMovementEvent);
Assert.True(turning.TurnUsesRunHold);
MovementResult held = controller.Update(1f / 60f, new MovementInput());
Assert.Equal(MotionCommand.TurnRight, held.TurnCommand);
controller.StopMouseDrift(new MovementInput());
MovementResult stopped = controller.Update(1f / 60f, new MovementInput());
Assert.Null(stopped.TurnCommand);
Assert.False(stopped.ShouldSendMovementEvent);
}
[Fact]
public void MouseLook_ReportsStartCadenceAndStopWithoutPerSampleFlooding()
{
var controller = CreateController();
Assert.True(controller.BeginMouseLook(new MovementInput()));
MovementResult begin = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.True(begin.ShouldSendMovementEvent);
Assert.False(begin.IsMouseLookMovementEvent);
controller.NoteMovementSent(controller.SimTimeSeconds, mouseLookEvent: false);
controller.SubmitMouseTurnAdjustment(0.5f, new MovementInput());
MovementResult sample = controller.Update(0.01f, new MovementInput());
Assert.Equal(MotionCommand.TurnLeft, sample.TurnCommand);
Assert.False(sample.ShouldSendMovementEvent);
controller.Update(
PlayerMovementController.MouseMovementEventInterval,
new MovementInput());
controller.SubmitMouseTurnAdjustment(0.5f, new MovementInput());
MovementResult cadence = controller.Update(
0.001f,
new MovementInput());
Assert.True(cadence.ShouldSendMovementEvent);
Assert.True(cadence.IsMouseLookMovementEvent);
controller.NoteMovementSent(
controller.SimTimeSeconds,
mouseLookEvent: true);
Assert.True(controller.EndMouseLook(new MovementInput()));
MovementResult ended = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.True(ended.ShouldSendMovementEvent);
Assert.False(ended.IsMouseLookMovementEvent);
Assert.Null(ended.TurnCommand);
}
[Fact]
public void MouseMovementEvent_IsRetriedUntilSuccessfulSendIsAcknowledged()
{
var controller = CreateController();
controller.BeginMouseLook(new MovementInput());
controller.Update(
PlayerMovementController.MouseMovementEventInterval + 0.001f,
new MovementInput());
controller.SubmitMouseTurnAdjustment(0.4f, new MovementInput());
MovementResult first = controller.Update(0.01f, new MovementInput(Run: true));
MovementResult retry = controller.Update(0.01f, new MovementInput(Run: true));
Assert.True(first.ShouldSendMovementEvent);
Assert.True(first.IsMouseLookMovementEvent);
Assert.True(retry.ShouldSendMovementEvent);
controller.NoteMovementSent(
controller.SimTimeSeconds,
mouseLookEvent: true);
MovementResult acknowledged = controller.Update(
0.01f,
new MovementInput(Run: true));
Assert.False(acknowledged.ShouldSendMovementEvent);
}
[Fact]
public void MouseLook_RapidPressReleaseCanBeCapturedSynchronously()
{
var controller = CreateController();
var input = new MovementInput(Run: true);
Assert.True(controller.BeginMouseLook(input));
MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.True(controller.EndMouseLook(input));
MovementResult release = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.True(press.ShouldSendMovementEvent);
Assert.False(press.IsMouseLookMovementEvent);
Assert.True(release.ShouldSendMovementEvent);
Assert.False(release.IsMouseLookMovementEvent);
}
[Fact]
public void MouseLook_CadenceUsesStrictHalfSecondBoundary()
{
var controller = CreateController();
controller.BeginMouseLook(new MovementInput());
controller.NoteMovementSent(nowSeconds: 0f, mouseLookEvent: true);
controller.SubmitMouseTurnAdjustment(0.4f, new MovementInput());
MovementResult boundary = controller.Update(
PlayerMovementController.MouseMovementEventInterval,
new MovementInput());
Assert.False(boundary.ShouldSendMovementEvent);
controller.SubmitMouseTurnAdjustment(0.4f, new MovementInput());
MovementResult justOver = controller.Update(
0.0001f,
new MovementInput());
Assert.True(justOver.ShouldSendMovementEvent);
Assert.True(justOver.IsMouseLookMovementEvent);
}
[Fact]
public void MouseTurn_UsesPerAxisRunHoldEvenInWalkMode()
{
var controller = CreateController();
controller.BeginMouseLook(new MovementInput());
controller.NoteMovementSent(nowSeconds: 0f, mouseLookEvent: true);
controller.SubmitMouseTurnAdjustment(-0.4f, new MovementInput());
MovementResult turning = controller.Update(
0.01f,
new MovementInput(Run: false));
Assert.Equal(MotionCommand.TurnRight, turning.TurnCommand);
Assert.False(turning.IsRunning);
Assert.True(turning.TurnUsesRunHold);
Assert.Equal(HoldKey.Run, controller.Motion.RawState.TurnHoldKey);
}
[Fact]
public void MouseLookRelease_SynchronouslyRestoresHeldKeyboardTurn()
{
var controller = CreateController();
controller.Update(
0.01f,
new MovementInput(TurnLeft: true, Run: true));
var held = new MovementInput(TurnLeft: true, Run: true);
controller.BeginMouseLook(held);
controller.NoteMovementSent(controller.SimTimeSeconds, mouseLookEvent: false);
controller.SubmitMouseTurnAdjustment(-0.4f, held);
controller.Update(0.01f, held);
controller.EndMouseLook(held);
MovementResult release = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.Equal(MotionCommand.TurnLeft, release.TurnCommand);
Assert.Equal(1f, release.TurnSpeed);
Assert.False(release.TurnUsesRunHold);
}
[Fact]
public void MouseLook_RemapMovesHeldKeyboardTurnToSidestepAndBack()
{
var controller = CreateController();
var held = new MovementInput(TurnLeft: true, Run: true);
controller.Update(0.01f, held);
Assert.True(controller.BeginMouseLook(held));
MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.Null(press.TurnCommand);
Assert.Equal(MotionCommand.SideStepLeft, press.SidestepCommand);
Assert.Equal(HoldKey.Run, controller.Motion.RawState.SidestepHoldKey);
Assert.True(controller.EndMouseLook(held));
MovementResult release = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.Null(release.SidestepCommand);
Assert.Equal(MotionCommand.TurnLeft, release.TurnCommand);
Assert.False(release.TurnUsesRunHold);
}
[Fact]
public void MouseLookEntry_PreservesHeldForwardAndRunAfterTakingControl()
{
var controller = CreateController();
var held = new MovementInput(Forward: true, Run: true);
Assert.True(controller.BeginMouseLook(held));
MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.Equal(MotionCommand.WalkForward, press.ForwardCommand);
Assert.True(press.IsRunning);
Assert.Equal(HoldKey.Run, controller.Motion.RawState.CurrentHoldKey);
}
[Fact]
public void MouseLookEntry_AlreadyAutonomousSameFrameForwardEdgeIsNotLost()
{
var controller = CreateController();
controller.Update(0.01f, new MovementInput(Forward: true));
controller.Update(0.01f, new MovementInput());
var justPressed = new MovementInput(Forward: true, Run: true);
controller.BeginMouseLook(justPressed);
MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.Equal(MotionCommand.WalkForward, press.ForwardCommand);
Assert.True(press.IsRunning);
Assert.Equal(HoldKey.Run, controller.Motion.RawState.CurrentHoldKey);
}
[Fact]
public void MouseLookEntry_AlreadyAutonomousSameFrameRunChangeIsNotLost()
{
var controller = CreateController();
controller.Update(0.01f, new MovementInput(Forward: true));
controller.Update(0.01f, new MovementInput());
controller.BeginMouseLook(new MovementInput(Run: true));
MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false);
RawMotionState wire = LocalPlayerOutboundController.BuildRawMotionState(press);
Assert.Equal(HoldKey.Run, controller.Motion.RawState.CurrentHoldKey);
Assert.True(press.IsRunning);
Assert.Equal(HoldKey.Run, wire.CurrentHoldKey);
Assert.Equal(HoldKey.Invalid, wire.ForwardHoldKey);
Assert.Equal(HoldKey.Invalid, wire.SidestepHoldKey);
Assert.Equal(HoldKey.Invalid, wire.TurnHoldKey);
}
[Fact]
public void MouseLookEntry_ServerControlledHeldTurnNeverQueuesWrongAxis()
{
var controller = CreateController();
controller.BeginMouseLook(new MovementInput(TurnLeft: true));
Assert.DoesNotContain(
controller.Motion.PendingMotions,
node => node.Motion == MotionCommand.TurnLeft);
Assert.Equal(MotionCommand.SideStepLeft,
controller.Motion.RawState.SidestepCommand);
}
[Fact]
public void MouseLookExit_ServerControlledHeldTurnNeverQueuesRemappedAxis()
{
var controller = CreateController();
var held = new MovementInput(TurnLeft: true);
controller.BeginMouseLook(held);
DrainPendingMotions(controller);
controller.SetLastMoveWasAutonomous(false);
controller.EndMouseLook(held);
Assert.DoesNotContain(
controller.Motion.PendingMotions,
node => node.Motion == MotionCommand.SideStepLeft);
Assert.Equal(MotionCommand.TurnLeft, controller.Motion.RawState.TurnCommand);
}
[Fact]
public void MouseLook_RemapPressedAfterEntryAppearsInOutboundResult()
{
var controller = CreateController();
controller.BeginMouseLook(new MovementInput());
MovementResult result = controller.Update(
0.01f,
new MovementInput(TurnLeft: true));
Assert.Equal(MotionCommand.SideStepLeft, result.SidestepCommand);
Assert.Null(result.TurnCommand);
Assert.True(result.SidestepUsesRunHold);
Assert.False(result.IsRunning);
Assert.True(result.ShouldSendMovementEvent);
}
[Fact]
public void MouseLook_WalkModeRemapCarriesPerAxisRunHold()
{
var controller = CreateController();
var held = new MovementInput(TurnRight: true, Run: false);
controller.BeginMouseLook(held);
MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false);
RawMotionState wire = LocalPlayerOutboundController.BuildRawMotionState(press);
Assert.Equal(MotionCommand.SideStepRight, press.SidestepCommand);
Assert.True(press.SidestepUsesRunHold);
Assert.False(press.IsRunning);
Assert.Equal(HoldKey.Run, controller.Motion.RawState.SidestepHoldKey);
Assert.Equal(HoldKey.None, wire.CurrentHoldKey);
Assert.Equal(HoldKey.Run, wire.SidestepHoldKey);
}
[Fact]
public void MouseLook_MouseHandlerRetakesServerControlWithoutDroppingHeldInput()
{
var controller = CreateController();
var held = new MovementInput(Forward: true, TurnLeft: true, Run: true);
controller.BeginMouseLook(held);
controller.SetLastMoveWasAutonomous(false);
controller.SubmitMouseTurnAdjustment(-0.4f, held);
MovementResult result = controller.Update(0.01f, held);
Assert.Equal(MotionCommand.WalkForward, result.ForwardCommand);
Assert.Equal(MotionCommand.SideStepLeft, result.SidestepCommand);
Assert.Equal(MotionCommand.TurnRight, result.TurnCommand);
Assert.True(result.IsRunning);
Assert.True(result.SidestepUsesRunHold);
Assert.True(result.TurnUsesRunHold);
}
[Fact]
public void MouseLookRelease_UsesCurrentInputSnapshotInsteadOfPreviousFrame()
{
var controller = CreateController();
var held = new MovementInput(Forward: true, TurnLeft: true, Run: true);
controller.Update(0.01f, held);
controller.BeginMouseLook(held);
Assert.True(controller.EndMouseLook(new MovementInput()));
MovementResult release = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.Null(release.ForwardCommand);
Assert.Null(release.SidestepCommand);
Assert.Null(release.TurnCommand);
Assert.False(release.IsRunning);
}
[Fact]
public void MouseLook_CannotBeginInPortalSpace()
{
var controller = CreateController();
controller.State = PlayerState.PortalSpace;
Assert.False(controller.BeginMouseLook(new MovementInput(Forward: true)));
MovementResult result = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.Null(result.ForwardCommand);
Assert.Null(result.SidestepCommand);
Assert.Null(result.TurnCommand);
}
[Fact]
public void AttackPreparation_StopsMouseTurnAndPublishesFinalHeading()
{
var controller = CreateController();
controller.BeginMouseLook(new MovementInput());
controller.Update(0.01f, new MovementInput());
controller.SubmitMouseTurnAdjustment(-0.5f, new MovementInput());
MovementResult turning = controller.Update(1f / 60f, new MovementInput());
Assert.Equal(MotionCommand.TurnRight, turning.TurnCommand);
Assert.True(controller.PrepareForAttackRequest());
float finalYaw = controller.Yaw;
MovementResult stopped = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.Equal(finalYaw, controller.Yaw);
Assert.Null(stopped.TurnCommand);
Assert.True(stopped.ShouldSendMovementEvent);
}
[Fact]
public void AttackPreparation_DoesNotInterruptServerControlledMovement()
{
var controller = CreateController();
controller.SetLastMoveWasAutonomous(false);
Assert.False(controller.PrepareForAttackRequest());
MovementResult result = controller.Update(
0.01f,
new MovementInput(Run: true));
Assert.False(result.ShouldSendMovementEvent);
}
[Fact]
public void AttackPreparation_DefaultServerControlledStateIsNoOp()
{
var controller = CreateController();
Assert.False(controller.PrepareForAttackRequest());
}
private static PlayerMovementController CreateController()
{
var engine = new PhysicsEngine();
var heights = new byte[81];
Array.Fill(heights, (byte)50);
var heightTable = new float[256];
for (int i = 0; i < heightTable.Length; i++)
heightTable[i] = i;
engine.AddLandblock(
0xA9B4FFFFu,
new TerrainSurface(heights, heightTable),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u);
return controller;
}
private static void DrainPendingMotions(PlayerMovementController controller)
{
int remaining = controller.Motion.PendingMotions.Count();
for (int i = 0; i < remaining; i++)
controller.Motion.MotionDone(0u, success: true);
}
}

View file

@ -1,215 +0,0 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.App.Tests.Physics;
public sealed class PlayerOutboundPositionTests
{
private static readonly Plane GroundPlane =
new(Vector3.UnitZ, 0f);
[Fact]
public void CrossLandblockWorldFrame_SendsCarriedCellLocalPosition()
{
var controller = new PlayerMovementController(new PhysicsEngine());
// The render origin remains A9B1, so a point 22.56 m into A9B2 is
// represented at world Y=214.56. The wire frame must remain the
// carried A9B2-local 22.56, never the render-space 214.56.
controller.SetPosition(
new Vector3(116.07f, 214.56f, 83.76f),
0xA9B20021u,
new Vector3(116.07f, 22.56f, 83.76f));
Assert.True(controller.TryGetOutboundPosition(out Position outbound));
Assert.Equal(0xA9B20021u, outbound.ObjCellId);
Assert.Equal(
new Vector3(116.07f, 22.56f, 83.76f),
outbound.Frame.Origin);
Assert.NotEqual(controller.Position, outbound.Frame.Origin);
}
[Fact]
public void UnplacedBody_DoesNotProduceWirePosition()
{
var controller = new PlayerMovementController(new PhysicsEngine());
Assert.False(controller.TryGetOutboundPosition(out _));
}
[Fact]
public void OutboundPosition_PreservesCompleteAuthoritativeQuaternion()
{
var controller = new PlayerMovementController(new PhysicsEngine());
controller.SetPosition(
new Vector3(116.07f, 214.56f, 83.76f),
0xA9B20021u,
new Vector3(116.07f, 22.56f, 83.76f));
Quaternion complete = Quaternion.Normalize(
Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.7f)
* Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.4f));
controller.SetBodyOrientation(complete);
Assert.True(controller.TryGetOutboundPosition(out Position outbound));
Assert.InRange(
MathF.Abs(Quaternion.Dot(
complete,
Quaternion.Normalize(outbound.Frame.Orientation))),
0.99999f,
1.00001f);
}
[Fact]
public void PositionEvent_FirstFrameIsDue()
{
var controller = new PlayerMovementController(new PhysicsEngine());
var current = PositionAt(Quaternion.Identity);
Assert.True(controller.ShouldSendPositionEvent(current, GroundPlane, 0f));
}
[Fact]
public void PositionEvent_StationaryHeadingChangeIsDueAfterRetailInterval()
{
var controller = new PlayerMovementController(new PhysicsEngine());
var sent = PositionAt(Quaternion.Identity);
var turned = PositionAt(Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
MathF.PI / 2f));
controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f);
Assert.False(controller.ShouldSendPositionEvent(
turned,
GroundPlane,
PlayerMovementController.HeartbeatInterval));
Assert.True(controller.ShouldSendPositionEvent(
turned,
GroundPlane,
PlayerMovementController.HeartbeatInterval + 0.01f));
}
[Fact]
public void PositionEvent_UnchangedCompleteFrameIsNotIdleHeartbeat()
{
var controller = new PlayerMovementController(new PhysicsEngine());
var sent = PositionAt(Quaternion.Identity);
controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f);
Assert.False(controller.ShouldSendPositionEvent(
sent,
GroundPlane,
PlayerMovementController.HeartbeatInterval + 0.01f));
}
[Fact]
public void MovementEvent_StampsOnlyTime_NotTheRememberedFrame()
{
var controller = new PlayerMovementController(new PhysicsEngine());
var sent = PositionAt(Quaternion.Identity);
var turned = PositionAt(Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
MathF.PI / 2f));
controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f);
controller.NoteMovementSent(nowSeconds: 0.9f);
Assert.False(controller.ShouldSendPositionEvent(
turned,
GroundPlane,
nowSeconds: 1.1f));
Assert.True(controller.ShouldSendPositionEvent(
turned,
GroundPlane,
nowSeconds: 1.91f));
}
[Fact]
public void PositionEvent_CellOrContactPlaneChangeIsDueInsideInterval()
{
var controller = new PlayerMovementController(new PhysicsEngine());
var sent = PositionAt(Quaternion.Identity);
controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f);
var otherCell = sent with { ObjCellId = 0xA9B20022u };
var otherPlane = new Plane(Vector3.Normalize(new Vector3(0.01f, 0f, 1f)), 0f);
Assert.True(controller.ShouldSendPositionEvent(otherCell, GroundPlane, 0.1f));
Assert.True(controller.ShouldSendPositionEvent(sent, otherPlane, 0.1f));
}
[Fact]
public void PositionEvent_QuaternionSignIsStoredFrameDataLikeRetail()
{
var controller = new PlayerMovementController(new PhysicsEngine());
var sent = PositionAt(Quaternion.Identity);
var oppositeStoredQuaternion = PositionAt(new Quaternion(0f, 0f, 0f, -1f));
controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f);
Assert.True(controller.ShouldSendPositionEvent(
oppositeStoredQuaternion,
GroundPlane,
PlayerMovementController.HeartbeatInterval + 0.01f));
}
[Fact]
public void PositionEvent_RetailEpsilonUsesDifferentOriginAndQuaternionBoundaries()
{
const float epsilon = 0.000199999995f;
var controller = new PlayerMovementController(new PhysicsEngine());
var sent = new Position(0xA9B20021u, Vector3.Zero, Quaternion.Identity);
controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f);
var exactOriginBoundary = sent with
{
Frame = sent.Frame with { Origin = new Vector3(epsilon, 0f, 0f) },
};
var exactQuaternionBoundary = sent with
{
Frame = sent.Frame with
{
Orientation = new Quaternion(epsilon, 0f, 0f, 1f),
},
};
float elapsed = PlayerMovementController.HeartbeatInterval + 0.01f;
Assert.False(controller.ShouldSendPositionEvent(
exactOriginBoundary,
GroundPlane,
elapsed));
Assert.True(controller.ShouldSendPositionEvent(
exactQuaternionBoundary,
GroundPlane,
elapsed));
}
[Fact]
public void PositionEvent_RetailPlaneDistanceBoundaryIsStrict()
{
const float epsilon = 0.000199999995f;
var controller = new PlayerMovementController(new PhysicsEngine());
var sent = PositionAt(Quaternion.Identity);
controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f);
var exactNormalBoundary = new Plane(
new Vector3(epsilon, 0f, 1f),
0f);
var exactDistanceBoundary = new Plane(Vector3.UnitZ, epsilon);
Assert.False(controller.ShouldSendPositionEvent(
sent,
exactNormalBoundary,
nowSeconds: 0.1f));
Assert.True(controller.ShouldSendPositionEvent(
sent,
exactDistanceBoundary,
nowSeconds: 0.1f));
}
private static Position PositionAt(Quaternion orientation)
=> new(
0xA9B20021u,
new Vector3(116.07f, 22.56f, 83.76f),
orientation);
}

View file

@ -782,7 +782,8 @@ public sealed class CurrentGameRuntimeAdapterTests
CombatMode = new RecordingCombatModeOperations();
_combatModeBinding =
CombatModeOperations.BindOwned(CombatMode);
MovementInput = new DispatcherMovementInputSource();
MovementState = new RuntimeLocalPlayerMovementState();
MovementInput = new DispatcherMovementInputSource(MovementState);
GameplayInput = new GameplayInputFrameController(
dispatcher: null,
MovementInput,
@ -832,11 +833,10 @@ public sealed class CurrentGameRuntimeAdapterTests
Character,
Communication,
Actions,
new LocalPlayerControllerSlot(),
MovementState,
WorldReveal,
Clock,
selectionController,
GameplayInput);
selectionController);
}
public RuntimeOptions Options { get; }
@ -853,6 +853,7 @@ public sealed class CurrentGameRuntimeAdapterTests
public RuntimeSpellCastOperationsSlot SpellCastOperations { get; }
public RuntimeActionState Actions { get; }
public SelectionState Selection => Actions.Selection;
public RuntimeLocalPlayerMovementState MovementState { get; }
public DispatcherMovementInputSource MovementInput { get; }
public GameplayInputFrameController GameplayInput { get; }
public RecordingCombatModeOperations CombatMode { get; }
@ -874,6 +875,7 @@ public sealed class CurrentGameRuntimeAdapterTests
Actions.Dispose();
InventoryState.Dispose();
Entities.Clear();
MovementState.Dispose();
}
}

View file

@ -0,0 +1,123 @@
using System.Text.RegularExpressions;
namespace AcDream.App.Tests.Runtime;
public sealed class RuntimeMovementOwnershipTests
{
[Fact]
public void ProductionConstructsOneCanonicalRuntimeMovementOwner()
{
string root = FindRepositoryRoot();
string appRoot = Path.Combine(root, "src", "AcDream.App");
string runtimeRoot = Path.Combine(root, "src", "AcDream.Runtime");
string app = string.Join(
"\n",
Directory.EnumerateFiles(appRoot, "*.cs", SearchOption.AllDirectories)
.Select(File.ReadAllText));
string runtime = string.Join(
"\n",
Directory.EnumerateFiles(runtimeRoot, "*.cs", SearchOption.AllDirectories)
.Select(File.ReadAllText));
Assert.False(File.Exists(Path.Combine(
appRoot,
"Input",
"PlayerMovementController.cs")));
Assert.False(File.Exists(Path.Combine(
appRoot,
"Input",
"LocalPlayerOutboundController.cs")));
Assert.DoesNotContain(
"class PlayerMovementController",
app,
StringComparison.Ordinal);
Assert.DoesNotContain(
"class LocalPlayerOutboundController",
app,
StringComparison.Ordinal);
Assert.Empty(Regex.Matches(app, @"\bbool\s+_autoRunActive\b"));
Assert.Single(Regex.Matches(
app,
@"RuntimeLocalPlayerMovementState\s+\w+\s*=\s*new\s*\(")
.Cast<Match>());
Assert.DoesNotContain(
"ACDREAM_RUN_SKILL",
runtime,
StringComparison.Ordinal);
Assert.DoesNotContain(
"ACDREAM_JUMP_SKILL",
runtime,
StringComparison.Ordinal);
}
[Fact]
public void GraphicalInputRuntimeViewsAndShutdownBorrowTheExactOwner()
{
string root = FindRepositoryRoot();
string gameWindow = ReadAppSource(root, "Rendering", "GameWindow.cs");
string input = ReadAppSource(
root,
"Input",
"DispatcherMovementInputSource.cs");
string view = ReadAppSource(
root,
"Runtime",
"CurrentGameRuntimeViewAdapter.cs");
string commands = ReadAppSource(
root,
"Runtime",
"CurrentGameRuntimeCommandAdapter.cs");
string lifetime = ReadAppSource(
root,
"Rendering",
"GameWindowLifetime.cs");
Assert.Contains(
"RuntimeLocalPlayerMovementState _playerControllerSlot = new();",
gameWindow,
StringComparison.Ordinal);
Assert.Contains(
"new AcDream.App.Input.DispatcherMovementInputSource(",
gameWindow,
StringComparison.Ordinal);
Assert.Contains(
"_playerControllerSlot,",
gameWindow,
StringComparison.Ordinal);
Assert.Contains(
"RuntimeLocalPlayerMovementState movement,",
input,
StringComparison.Ordinal);
Assert.Contains("_movement.AutoRunActive", input, StringComparison.Ordinal);
Assert.Contains(
"movement ?? throw new ArgumentNullException(nameof(movement))).View",
view,
StringComparison.Ordinal);
Assert.Contains("_movement.Execute(command)", commands, StringComparison.Ordinal);
Assert.Contains(
"RuntimeLocalPlayerMovementState Movement",
lifetime,
StringComparison.Ordinal);
Assert.Contains(
"\"runtime local movement state\"",
lifetime,
StringComparison.Ordinal);
}
private static string ReadAppSource(string root, params string[] relative) =>
File.ReadAllText(Path.Combine(
[root, "src", "AcDream.App", .. relative]));
private static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "AcDream.slnx")))
return current.FullName;
current = current.Parent;
}
throw new DirectoryNotFoundException("AcDream.slnx was not found.");
}
}

View file

@ -625,11 +625,11 @@ public sealed class UpdateFrameOrchestratorTests
== typeof(AcDream.App.Interaction.SelectionInteractionController));
FieldInfo outboundDiagnostics = Assert.Single(
typeof(AcDream.App.Input.LocalPlayerOutboundController).GetFields(
typeof(LocalPlayerOutboundController).GetFields(
BindingFlags.Instance | BindingFlags.NonPublic),
field => field.Name == "_diagnostic");
Assert.Equal(
typeof(AcDream.App.Input.IMovementTruthDiagnosticSink),
typeof(IMovementTruthDiagnosticSink),
outboundDiagnostics.FieldType);
Assert.DoesNotContain(
typeof(AcDream.App.Input.MovementTruthDiagnosticController).GetFields(