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

@ -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

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
{

View file

@ -9,7 +9,8 @@ namespace AcDream.Core.Physics;
///
/// <para>
/// L.3a (2026-04-30): added optional collision-normal fields so the
/// caller (typically <see cref="AcDream.App.Input.PlayerMovementController"/>)
/// caller (typically
/// <see cref="AcDream.Runtime.Gameplay.PlayerMovementController"/>)
/// can apply retail's velocity-reflection bounce
/// (<c>v_new = v - (1 + elasticity) * dot(v, n) * n</c>) to the
/// PhysicsBody after the geometric resolve completes. ACE port mirror:

View file

@ -9,6 +9,9 @@
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Runtime.Tests" />
<InternalsVisibleTo Include="AcDream.App" />
<InternalsVisibleTo Include="AcDream.App.Tests" />
<InternalsVisibleTo Include="AcDream.Core.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />

View file

@ -73,7 +73,9 @@ public readonly record struct RuntimeMovementSnapshot(
Position Position,
System.Numerics.Vector3 Velocity,
bool IsAirborne,
double SimulationTimeSeconds);
double SimulationTimeSeconds,
long Revision = 0,
bool AutoRunActive = false);
public interface IRuntimeMovementView
{

View file

@ -3,7 +3,24 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
namespace AcDream.App.Input;
namespace AcDream.Runtime.Gameplay;
public 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();
}
/// <summary>
/// Serializes input-originated output from the object-phase result and the
@ -250,3 +267,33 @@ public sealed class LocalPlayerOutboundController
};
}
}
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

@ -2,7 +2,7 @@ using System;
using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.App.Input;
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// Input state for a single frame of player movement.
@ -18,6 +18,29 @@ public readonly record struct MovementInput(
float MouseDeltaX = 0f,
bool Jump = false);
/// <summary>
/// Typed construction policy for the local movement owner. Server-authoritative
/// values come from <see cref="RuntimeMovementSkillState"/>; the fallback only
/// preserves the pre-description test/login baseline and never reads process
/// environment state.
/// </summary>
public readonly record struct PlayerMovementConstructionOptions(
int RunSkill,
int JumpSkill)
{
public const int FallbackRunSkill = 200;
public const int FallbackJumpSkill = 300;
public static PlayerMovementConstructionOptions Fallback =>
new(FallbackRunSkill, FallbackJumpSkill);
public static PlayerMovementConstructionOptions From(
RuntimeMovementSkillSnapshot skills) =>
new(
skills.RunSkill >= 0 ? skills.RunSkill : FallbackRunSkill,
skills.JumpSkill >= 0 ? skills.JumpSkill : FallbackJumpSkill);
}
/// <summary>
/// Read-only presentation snapshot of retail's pending jump build. The movement
/// controller remains the sole owner of charge timing; retained UI only projects
@ -456,6 +479,14 @@ public sealed class PlayerMovementController
public PlayerMovementController(
PhysicsEngine physics,
RetailObjectQuantumClock? objectClock = null)
: this(physics, objectClock, PlayerMovementConstructionOptions.Fallback)
{
}
public PlayerMovementController(
PhysicsEngine physics,
RetailObjectQuantumClock? objectClock,
PlayerMovementConstructionOptions options)
{
_physics = physics;
_objectClock = objectClock ?? new RetailObjectQuantumClock();
@ -467,20 +498,20 @@ public sealed class PlayerMovementController
// Default skills — tuned toward mid-retail feel. Real characters'
// skills come from PlayerDescription (0xF7B0/0x0013) — GameWindow
// pushes them via SetCharacterSkills once the controller exists
// (K-fix7; PD arrives at login before auto-entry). These env-var
// defaults only cover tests / pre-PD frames:
// ACDREAM_RUN_SKILL, ACDREAM_JUMP_SKILL
// Runtime supplies them through typed construction options and
// updates them via SetCharacterSkills when later authoritative
// values arrive.
// K-fix6 (2026-04-26): bumped default jump skill from 200 → 300.
// Retail formula: height = (skill/(skill+1300))*22.2 + 0.05 (extent=1):
// skill=200 → 3.01m max (felt too low — user complaint)
// skill=300 → 4.21m max (closer to a typical retail mid-tier
// character's "I can clear that fence" hop)
// Until #7 ships and PlayerDescription gives us the server's real
// skill, this default is the right "feels like retail" baseline.
int runSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_RUN_SKILL"), out var rs) ? rs : 200;
int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 300;
_weenie = new PlayerWeenie(runSkill: runSkill, jumpSkill: jumpSkill);
// Until PlayerDescription supplies both values, retain the typed
// construction baseline. RuntimeMovementSkillState updates this same
// PlayerWeenie after authoritative character data arrives.
_weenie = new PlayerWeenie(
runSkill: options.RunSkill,
jumpSkill: options.JumpSkill);
_motion = new MotionInterpreter(_body, _weenie);
// R5-V5: the MovementManager facade owns the interp from birth
// (retail CPhysicsObj::movement_manager); the moveto child binds

View file

@ -2,20 +2,22 @@ namespace AcDream.Runtime.Gameplay;
/// <summary>
/// One allocation-free ledger over the gameplay-state lifetime group through
/// J5.1. It contains no state of its own; graphical and no-window hosts capture
/// the same four canonical owners.
/// J5.4. It contains no state of its own; graphical and no-window hosts capture
/// the same canonical owners.
/// </summary>
public readonly record struct RuntimeGameplayOwnershipSnapshot(
RuntimeInventoryOwnershipSnapshot Inventory,
RuntimeCharacterOwnershipSnapshot Character,
RuntimeCommunicationOwnershipSnapshot Communication,
RuntimeActionOwnershipSnapshot Actions)
RuntimeActionOwnershipSnapshot Actions,
RuntimeLocalMovementOwnershipSnapshot Movement)
{
public bool IsConverged =>
Inventory.IsConverged
&& Character.IsConverged
&& Communication.IsConverged
&& Actions.IsConverged;
&& Actions.IsConverged
&& Movement.IsConverged;
}
public static class RuntimeGameplayOwnership
@ -24,16 +26,19 @@ public static class RuntimeGameplayOwnership
RuntimeInventoryState inventory,
RuntimeCharacterState character,
RuntimeCommunicationState communication,
RuntimeActionState actions)
RuntimeActionState actions,
RuntimeLocalPlayerMovementState movement)
{
ArgumentNullException.ThrowIfNull(inventory);
ArgumentNullException.ThrowIfNull(character);
ArgumentNullException.ThrowIfNull(communication);
ArgumentNullException.ThrowIfNull(actions);
ArgumentNullException.ThrowIfNull(movement);
return new RuntimeGameplayOwnershipSnapshot(
inventory.CaptureOwnership(),
character.CaptureOwnership(),
communication.CaptureOwnership(),
actions.CaptureOwnership());
actions.CaptureOwnership(),
movement.CaptureOwnership());
}
}

View file

@ -0,0 +1,200 @@
using AcDream.Core.Physics;
namespace AcDream.Runtime.Gameplay;
public interface IRuntimeLocalPlayerControllerSource
{
PlayerMovementController? Controller { get; }
}
public interface IRuntimeLocalPlayerMotionSource
{
MotionInterpreter? Motion { get; }
}
/// <summary>
/// Canonical local movement lifetime and intent owner. Graphical input,
/// presentation, diagnostics, and future no-window hosts borrow this exact
/// state; they never mirror the controller or the autorun latch.
/// </summary>
public sealed class RuntimeLocalPlayerMovementState
: IRuntimeLocalPlayerControllerSource,
IRuntimeLocalPlayerMotionSource,
IRuntimeMovementView,
IDisposable
{
private PlayerMovementController? _controller;
private PlayerMovementController? _preparingMotionOwner;
private bool _autoRunActive;
private bool _disposed;
private long _revision;
public PlayerMovementController? Controller
{
get => _controller;
set
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (ReferenceEquals(_controller, value))
return;
_controller = value;
Interlocked.Increment(ref _revision);
}
}
public bool AutoRunActive => _autoRunActive;
public long Revision => Interlocked.Read(ref _revision);
public IRuntimeMovementView View => this;
MotionInterpreter? IRuntimeLocalPlayerMotionSource.Motion =>
_preparingMotionOwner?.Motion ?? _controller?.Motion;
public RuntimeMovementSnapshot Snapshot
{
get
{
PlayerMovementController? controller = _controller;
return controller is null
? new RuntimeMovementSnapshot(
false,
0u,
default,
default,
false,
0d,
Revision,
_autoRunActive)
: new RuntimeMovementSnapshot(
true,
controller.LocalEntityId,
controller.CellPosition,
controller.BodyVelocity,
controller.IsAirborne,
controller.SimTimeSeconds,
Revision,
_autoRunActive);
}
}
/// <summary>
/// Opens the retail construction seam where a PartArray completion can
/// reach the candidate MotionInterpreter before the controller is
/// published to ordinary borrowers.
/// </summary>
public IDisposable BeginMotionPreparation(
PlayerMovementController controller,
Action? drainPriorAnimationQueue = null)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(controller);
if (_preparingMotionOwner is not null)
{
throw new InvalidOperationException(
"A local player motion owner is already being prepared.");
}
drainPriorAnimationQueue?.Invoke();
_preparingMotionOwner = controller;
return new MotionPreparation(this, controller);
}
public bool Execute(RuntimeMovementCommand command)
{
ObjectDisposedException.ThrowIf(_disposed, this);
switch (command)
{
case RuntimeMovementCommand.ToggleRunLock:
_autoRunActive = !_autoRunActive;
Interlocked.Increment(ref _revision);
return true;
case RuntimeMovementCommand.Stop:
CancelAutoRun();
return true;
default:
return false;
}
}
public bool CancelAutoRun()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_autoRunActive)
return false;
_autoRunActive = false;
Interlocked.Increment(ref _revision);
return true;
}
public void ResetInputIntent()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_autoRunActive)
return;
_autoRunActive = false;
Interlocked.Increment(ref _revision);
}
public RuntimeLocalMovementOwnershipSnapshot CaptureOwnership() =>
new(
_disposed,
_controller is not null,
_preparingMotionOwner is not null,
_autoRunActive,
Revision);
public void Dispose()
{
if (_disposed)
return;
_autoRunActive = false;
_controller = null;
_preparingMotionOwner = null;
Interlocked.Increment(ref _revision);
_disposed = true;
}
private void EndMotionPreparation(PlayerMovementController controller)
{
// Terminal disposal clears the unpublished construction seam. A
// caller may still unwind the already-issued lease afterward; that
// unwind is idempotent rather than resurrecting or faulting the
// retired runtime owner.
if (_disposed && _preparingMotionOwner is null)
return;
if (!ReferenceEquals(_preparingMotionOwner, controller))
{
throw new InvalidOperationException(
"The local player motion preparation owner changed unexpectedly.");
}
_preparingMotionOwner = null;
}
private sealed class MotionPreparation(
RuntimeLocalPlayerMovementState owner,
PlayerMovementController controller) : IDisposable
{
private RuntimeLocalPlayerMovementState? _owner = owner;
public void Dispose()
{
RuntimeLocalPlayerMovementState? current =
Interlocked.Exchange(ref _owner, null);
current?.EndMotionPreparation(controller);
}
}
}
public readonly record struct RuntimeLocalMovementOwnershipSnapshot(
bool IsDisposed,
bool HasController,
bool HasPreparingMotionOwner,
bool AutoRunActive,
long Revision)
{
public bool IsConverged =>
IsDisposed
&& !HasController
&& !HasPreparingMotionOwner
&& !AutoRunActive;
}