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

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

@ -0,0 +1,299 @@
using System.Numerics;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
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
/// 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,
};
}
}
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()
{
}
}

File diff suppressed because it is too large Load diff

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;
}