diff --git a/docs/research/2026-08-01-runtime-local-player-physics-publication.md b/docs/research/2026-08-01-runtime-local-player-physics-publication.md
new file mode 100644
index 00000000..d49be713
--- /dev/null
+++ b/docs/research/2026-08-01-runtime-local-player-physics-publication.md
@@ -0,0 +1,126 @@
+# Runtime local-player physics publication - 2026-08-01
+
+## Scope
+
+This is placement Slice 4B2 checkpoint 4. It adds the dormant,
+presentation-independent transaction which prepares and assigns ownership of
+one local-player `PhysicsBody` and `PlayerMovementController`. No App or
+Headless production route invokes this transaction, so graphical and no-window
+game behavior is unchanged and AP-1/AD-1 remain open.
+
+The checkpoint deliberately stops before canonical SetPosition activation.
+It does not consume the prepared placement operation, enter the body into the
+physics engine, publish FullCell/world/host/shadow/workset state, or project a
+presentation entity. Those effects belong to the next transaction and must all
+use the same Runtime-owned dormant body.
+
+## Ownership contract
+
+`RuntimeLocalPlayerPhysicsPublicationState` is the sole owner of unpublished
+local-player body/controller candidates. Each candidate is bound to a token
+containing:
+
+- The exact `RuntimeEntityKey` and authored SetPosition placement token.
+- A monotonic publication ID.
+- The nonzero canonical local-player server GUID and exact identity revision.
+- The record's physics-body and object-clock ownership epochs.
+- The movement state's controller ownership epoch.
+- The entity directory's session-lifetime authority.
+
+Preparation constructs a private controller, body, and object clock. It applies
+the exact authored cell frame, orientation, Setup sphere list, scale, step
+heights, and accepted final physics state without mutating the canonical entity,
+shared object clock, engine/worksets, shadow registry, FullCell, host state, or
+presentation. The candidate remains explicitly out of world and inactive. No
+method exposes its controller, body, clock, or another mutable reference while
+it is owned by the publication transaction.
+
+This checkpoint accepts only a pristine initial graph: no canonical body,
+movement controller, physics host, remote motion, projectile, acquisition or
+binding operation, or remote-placement contract may exist. It cannot replace or
+upgrade a live graph. The local-player identity must be live, nonzero, and name
+the same server GUID as the exact entity incarnation.
+
+Unpublished candidates and ownership-committed dormant controllers reject live
+movement operations: update, public SetPosition, blip, outbound-position
+capture, movement/position send tracking, and shared-engine position commit.
+Only the subsequent activation transaction may promote `RuntimeOwnedDormant`
+to `RuntimePublished`; this checkpoint never invokes that transition. Once a
+Runtime-owned dormant or published controller is replaced, reset, or disposed,
+its terminal retirement state rejects the same operations plus
+body/configuration mutation and manager acquisition. Publicly constructed
+legacy controllers keep their existing standalone behavior.
+
+## Failure-atomic commit
+
+Commit revalidates every authority after preparation:
+
+- The entity record is the current incarnation and is not accepted for delete.
+- The local-player identity still has the token's exact GUID and revision and
+ has not been disposed.
+- The exact authored SetPosition operation and sealed command remain current.
+- Session, body, object-clock, and controller ownership epochs still match.
+- The body/controller/host/remote/projectile graph remains completely pristine,
+ with no acquisition, binding, or remote-placement operation in progress.
+
+Only after validation completes does the callback-free update-thread tail:
+
+1. Rebind the candidate controller from its private clock to the record's exact
+ canonical object clock and mark it Runtime-owned but dormant.
+2. Store the candidate's exact body on the canonical record, advancing the
+ physics ownership epoch once.
+3. Store the same controller in `RuntimeLocalPlayerMovementState`, advancing the
+ controller ownership epoch once.
+
+The dormant controller rejects every live/configuration operation after these
+stores; ownership commit alone cannot tick physics, mutate the canonical clock,
+or publish an outbound frame. These stores allocate no new gameplay owner,
+invoke no host or presentation callback, and cannot replay an older
+incarnation. Replacing SetPosition,
+changing any accepted physics authority, binding remote/projectile state,
+replacing body/clock/controller ownership, delete plus GUID reuse, reset, or
+disposal causes the token to reject. An identity switch away and back also
+rejects because its revision changed. A rejected or superseded candidate is
+discarded and cannot perform a later live operation. Reset and disposal converge
+the publication ledger to zero candidates.
+
+Repeated stores of the same body/controller do not advance their epochs; real
+bind, replacement, and unbind edges do. This makes ABA-shaped reference changes
+observable even if a later value happens to equal an earlier reference.
+
+## Gates
+
+- Candidate privacy and live-operation rejection.
+- Pristine-only admission for body, controller, host, remote/projectile,
+ acquisition/binding, and remote-placement ownership.
+- Exact local-player identity, identity-switch, and disposed-identity rejection.
+- Exact same-body ownership in entity record and dormant movement controller.
+- Dormant rejection after ownership commit plus the isolated controller-level
+ `dormant -> activated -> live` lifecycle contract for the next checkpoint.
+- No mutation of SetPosition, FullCell, spatial roots, host projections,
+ shadows, worksets, world residence, or presentation during this checkpoint.
+- Replacement by position, vector, final physics state, object description,
+ CreateObject, remote/projectile/body/clock/controller ownership, and explicit
+ placement cancellation.
+- Delete plus same-GUID reincarnation.
+- Candidate replacement, reset, disposal, and ownership convergence.
+- Terminal stale-controller rejection after replacement, reset, and disposal.
+- Body/controller epochs advance only on actual ownership changes.
+
+Focused publication, controller, movement, and SetPosition tests pass 167/167.
+The complete Runtime project passes 627/627 under invariant culture. The App
+Runtime-ownership guard passes 4/4, the complete Release solution builds with
+zero errors, and the complete Release solution test gate passes 10,374 tests
+with 4 intentional skips. Under the machine's Swedish current culture, the
+three previously known formatting assertions still fail (`0,5` versus `0.5`
+and localized sky text); they are unrelated to this checkpoint.
+
+## Next checkpoint
+
+Add the canonical Runtime SetPosition activation transaction. It must evaluate
+and commit the already-owned dormant body, consume the exact prepared placement,
+and atomically establish physics-engine/workset/shadow/FullCell/world/host
+ownership before presentation receives an acknowledgement, then invoke the sole
+`ActivateRuntimePublication` transition. The activation must
+roll back or leave the operation retryable on every pre-commit failure and must
+not construct a second body or controller.
diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs
index bb4a353f..e9b028ae 100644
--- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs
+++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs
@@ -342,7 +342,7 @@ public sealed class RuntimeEntityDirectory
AcDream.Core.Physics.PhysicsBody? body)
{
EnsureKnown(record);
- record.PhysicsBody = body;
+ record.SetPhysicsBody(body);
}
public void SetPhysicsBodyAcquisitionInProgress(
diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs
index 369c7861..1d8def85 100644
--- a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs
+++ b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs
@@ -51,6 +51,7 @@ public sealed class RuntimeEntityRecord
public uint CanonicalLandblockId { get; internal set; }
public uint RawPhysicsState { get; internal set; }
public PhysicsStateFlags FinalPhysicsState { get; internal set; }
+ public ulong PhysicsOwnershipEpoch { get; private set; }
public ulong SpatialAuthorityVersion { get; private set; }
public ulong PlacementCommitVersion { get; private set; }
public ulong PhysicsStateMutationVersion { get; private set; }
@@ -68,7 +69,7 @@ public sealed class RuntimeEntityRecord
/// object reference.
///
public bool HasPartArray { get; internal set; }
- public PhysicsBody? PhysicsBody { get; internal set; }
+ public PhysicsBody? PhysicsBody { get; private set; }
public bool PhysicsBodyAcquisitionInProgress { get; internal set; }
public IRuntimeRemoteMotion? RemoteMotion { get; internal set; }
public bool RemoteMotionBindingInProgress { get; internal set; }
@@ -172,6 +173,14 @@ public sealed class RuntimeEntityRecord
FinalPhysicsState = state;
}
+ internal void SetPhysicsBody(PhysicsBody? body)
+ {
+ if (ReferenceEquals(PhysicsBody, body))
+ return;
+ PhysicsBody = body;
+ PhysicsOwnershipEpoch++;
+ }
+
///
/// Retail collision reporting clears Missile, AlignPath, and PathClipped
/// directly on the live CPhysicsObj. Keep the canonical record and its
diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs
index 4a15341a..08898a69 100644
--- a/src/AcDream.Runtime/GameRuntime.cs
+++ b/src/AcDream.Runtime/GameRuntime.cs
@@ -256,6 +256,13 @@ public sealed class GameRuntime
context.Character,
context.PlayerIdentity);
+ context.Movement.AttachPhysicsPublication(
+ new RuntimeLocalPlayerPhysicsPublicationState(
+ context.EntityObjects.Entities,
+ context.EntityObjects.Physics,
+ context.Movement,
+ context.PlayerIdentity));
+
context.EntityObjects.BindEventContext(
() => generationReset.ActiveRetiringGeneration
?? context.Session.Generation,
@@ -301,6 +308,8 @@ public sealed class GameRuntime
public RuntimeCommunicationState CommunicationOwner { get; }
public RuntimeActionState ActionOwner { get; }
public RuntimeLocalPlayerMovementState MovementOwner { get; }
+ internal RuntimeLocalPlayerPhysicsPublicationState
+ LocalPlayerPhysicsPublication => MovementOwner.PhysicsPublication;
public RuntimeWorldEnvironmentState EnvironmentOwner { get; }
public RuntimeWorldTransitState TransitOwner { get; }
public RuntimeGenerationReset GenerationReset { get; }
diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
index a3d8182b..3089a972 100644
--- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
+++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
@@ -121,6 +121,17 @@ public readonly record struct MovementResult(
///
public enum PlayerState { InWorld, PortalSpace }
+internal enum PlayerMovementControllerPublicationLifecycle
+{
+ StandalonePublished,
+ CandidatePreparing,
+ CandidateSealed,
+ RuntimeOwnedDormant,
+ RuntimePublished,
+ RuntimeRetired,
+ Discarded,
+}
+
///
/// Per-frame player movement controller. Reads input, drives the
/// ported PhysicsBody + MotionInterpreter, tracks motion state for
@@ -140,6 +151,17 @@ public sealed class PlayerMovementController
private readonly PhysicsBody _body;
private readonly MotionInterpreter _motion;
private readonly PlayerWeenie _weenie;
+ private readonly AcDream.Core.Physics.Motion.MovementManager
+ _movementManager;
+ private float _stepUpHeight = 0.4f;
+ private float _stepDownHeight = 0.4f;
+ private ObjectInfoState _ownPvpFlags = ObjectInfoState.None;
+ private System.Collections.Immutable.ImmutableArray
+ _sphereList;
+ private float _objectScale = 1f;
+ private PlayerState _state = PlayerState.InWorld;
+ private uint _localEntityId;
+ private AcDream.Core.Physics.Motion.PositionManager? _positionManager;
///
/// Maximum Z increase per movement step before the move is rejected.
@@ -151,7 +173,15 @@ public sealed class PlayerMovementController
/// Authoritative source is the player's Setup.StepUpHeight set
/// in GameWindow.cs at world-entry time.
///
- public float StepUpHeight { get; set; } = 0.4f;
+ public float StepUpHeight
+ {
+ get => _stepUpHeight;
+ set
+ {
+ EnsureConfigurationMutable();
+ _stepUpHeight = value;
+ }
+ }
///
/// L.2.3a (2026-04-29): how far below the foot the step-down probe
@@ -161,7 +191,15 @@ public sealed class PlayerMovementController
/// the ground 25 cm below produced a one-frame contact-plane gap — the
/// animation system briefly flickered to falling.
///
- public float StepDownHeight { get; set; } = 0.4f;
+ public float StepDownHeight
+ {
+ get => _stepDownHeight;
+ set
+ {
+ EnsureConfigurationMutable();
+ _stepDownHeight = value;
+ }
+ }
///
/// TS-23 (Campaign P Slice P3, 2026-07-30): the local player's own
@@ -176,7 +214,15 @@ public sealed class PlayerMovementController
/// hardcoded IsPlayer | EdgeSlide — the non-PK invariant this
/// port must not break.
///
- public ObjectInfoState OwnPvpFlags { get; set; } = ObjectInfoState.None;
+ public ObjectInfoState OwnPvpFlags
+ {
+ get => _ownPvpFlags;
+ set
+ {
+ EnsureConfigurationMutable();
+ _ownPvpFlags = value;
+ }
+ }
///
/// TS-46 (2026-07-30): the player's own Setup ≤2-sphere list (dat
@@ -191,14 +237,31 @@ public sealed class PlayerMovementController
/// (0,0,1.350) r=.48, a 5 mm improvement over the reconstruction's
/// (0,0,0.48) + (0,0,1.355).
///
- public System.Collections.Immutable.ImmutableArray SphereList { get; set; }
+ public System.Collections.Immutable.ImmutableArray
+ SphereList
+ {
+ get => _sphereList;
+ set
+ {
+ EnsureConfigurationMutable();
+ _sphereList = value;
+ }
+ }
///
/// Retail CPhysicsObj::m_scale. Grounded CSequence root
/// displacement is multiplied by this value before PositionManager
/// composition (UpdatePositionInternal @ 0x00512C30).
///
- public float ObjectScale { get; set; } = 1f;
+ public float ObjectScale
+ {
+ get => _objectScale;
+ set
+ {
+ EnsureConfigurationMutable();
+ _objectScale = value;
+ }
+ }
///
/// Current portal-space state. Set to PortalSpace when the server sends
@@ -207,7 +270,15 @@ public sealed class PlayerMovementController
/// While in PortalSpace, Update returns immediately with a zero-movement
/// result so no WASD input or physics is processed.
///
- public PlayerState State { get; set; } = PlayerState.InWorld;
+ public PlayerState State
+ {
+ get => _state;
+ set
+ {
+ EnsurePublishedForRuntimeOperation();
+ _state = value;
+ }
+ }
///
/// Horizontal projection of the authoritative body quaternion. Assigning
@@ -221,6 +292,7 @@ public sealed class PlayerMovementController
AcDream.Core.Physics.Motion.MoveToMath.GetHeading(_body.Orientation));
set
{
+ EnsurePublishedForRuntimeOperation();
float wrapped = value;
while (wrapped > MathF.PI) wrapped -= 2f * MathF.PI;
while (wrapped < -MathF.PI) wrapped += 2f * MathF.PI;
@@ -253,6 +325,7 @@ public sealed class PlayerMovementController
internal bool TryGetOutboundPosition(
out AcDream.Core.Physics.Position outboundPosition)
{
+ EnsurePublishedForRuntimeOperation();
AcDream.Core.Physics.Position canonical = _body.CellPosition;
outboundPosition = new AcDream.Core.Physics.Position(
canonical.ObjCellId,
@@ -273,7 +346,15 @@ public sealed class PlayerMovementController
/// sweep collides with its own ShadowEntry registered at
/// GameWindow.cs:2545 — see #42.
///
- public uint LocalEntityId { get; set; }
+ public uint LocalEntityId
+ {
+ get => _localEntityId;
+ set
+ {
+ EnsureConfigurationMutable();
+ _localEntityId = value;
+ }
+ }
///
/// Applies the canonical server PhysicsState to the local body. Retail's
@@ -282,6 +363,7 @@ public sealed class PlayerMovementController
///
public void ApplyPhysicsState(PhysicsStateFlags state)
{
+ EnsureConfigurationMutable();
_body.State = state;
_body.calc_acceleration();
}
@@ -431,7 +513,8 @@ public sealed class PlayerMovementController
//
// ACE: PhysicsObj.UpdateObject (Physics.cs).
// Named-retail: CPhysicsObj::update_object (acclient_2013_pseudo_c.txt:283950).
- private readonly RetailObjectQuantumClock _objectClock;
+ private RetailObjectQuantumClock _objectClock;
+ private PlayerMovementControllerPublicationLifecycle _publicationLifecycle;
private Vector3 _prevPhysicsPos;
private Vector3 _currPhysicsPos;
private Action?
@@ -463,7 +546,14 @@ public sealed class PlayerMovementController
/// DriveServerAutoWalk occupied and relays HitGround()
/// (0x00524300, minterp first then moveto).
///
- public AcDream.Core.Physics.Motion.MovementManager Movement { get; }
+ public AcDream.Core.Physics.Motion.MovementManager Movement
+ {
+ get
+ {
+ EnsureConfigurationMutable();
+ return _movementManager;
+ }
+ }
///
/// R4-V5: the local player's verbatim retail MoveToManager
@@ -486,6 +576,7 @@ public sealed class PlayerMovementController
get => Movement.MoveTo;
set
{
+ EnsureConfigurationMutable();
var mtm = value ?? throw new ArgumentNullException(nameof(value));
Movement.MoveToFactory = () => mtm;
Movement.MakeMoveToManager();
@@ -509,12 +600,28 @@ public sealed class PlayerMovementController
/// arms the leash without tearing it down first
/// (retail SmartBox::BlipPlayer survives motion/velocity/stick).
///
- public AcDream.Core.Physics.Motion.PositionManager? PositionManager { get; set; }
+ public AcDream.Core.Physics.Motion.PositionManager? PositionManager
+ {
+ get
+ {
+ EnsureConfigurationMutable();
+ return _positionManager;
+ }
+ set
+ {
+ EnsureConfigurationMutable();
+ _positionManager = value;
+ }
+ }
public PlayerMovementController(
PhysicsEngine physics,
RetailObjectQuantumClock? objectClock = null)
- : this(physics, objectClock, PlayerMovementConstructionOptions.Fallback)
+ : this(
+ physics,
+ objectClock,
+ PlayerMovementConstructionOptions.Fallback,
+ PlayerMovementControllerPublicationLifecycle.StandalonePublished)
{
}
@@ -522,9 +629,23 @@ public sealed class PlayerMovementController
PhysicsEngine physics,
RetailObjectQuantumClock? objectClock,
PlayerMovementConstructionOptions options)
+ : this(
+ physics,
+ objectClock,
+ options,
+ PlayerMovementControllerPublicationLifecycle.StandalonePublished)
+ {
+ }
+
+ private PlayerMovementController(
+ PhysicsEngine physics,
+ RetailObjectQuantumClock? objectClock,
+ PlayerMovementConstructionOptions options,
+ PlayerMovementControllerPublicationLifecycle publicationLifecycle)
{
_physics = physics;
_objectClock = objectClock ?? new RetailObjectQuantumClock();
+ _publicationLifecycle = publicationLifecycle;
_body = new PhysicsBody
{
@@ -551,8 +672,8 @@ public sealed class PlayerMovementController
// R5-V5: the MovementManager facade owns the interp from birth
// (retail CPhysicsObj::movement_manager); the moveto child binds
// later via MoveToFactory (EnterPlayerModeNow / the test rigs).
- Movement = new AcDream.Core.Physics.Motion.MovementManager(_motion);
- Movement.ActivatePhysicsObject = ActivateFromMovement;
+ _movementManager = new AcDream.Core.Physics.Motion.MovementManager(_motion);
+ _movementManager.ActivatePhysicsObject = ActivateFromMovement;
// R3-W4 (A3): the local player's movement is input-driven —
// movement_is_autonomous true so apply_current_movement's dual
// dispatch routes apply_raw_movement (IsThePlayer && autonomous).
@@ -560,6 +681,117 @@ public sealed class PlayerMovementController
_body.LastMoveWasAutonomous = true;
}
+ internal static PlayerMovementController CreatePublicationCandidate(
+ PhysicsEngine physics,
+ PlayerMovementConstructionOptions options) => new(
+ physics,
+ new RetailObjectQuantumClock(),
+ options,
+ PlayerMovementControllerPublicationLifecycle.CandidatePreparing);
+
+ internal PhysicsBody PhysicsBody
+ {
+ get
+ {
+ EnsureConfigurationMutable();
+ return _body;
+ }
+ }
+
+ internal bool OwnsPhysicsBody(PhysicsBody body) =>
+ ReferenceEquals(_body, body);
+
+ internal bool IsSealedPublicationCandidate => _publicationLifecycle
+ is PlayerMovementControllerPublicationLifecycle.CandidateSealed;
+
+ internal void SealPublicationCandidate()
+ {
+ if (_publicationLifecycle
+ is not PlayerMovementControllerPublicationLifecycle
+ .CandidatePreparing)
+ {
+ throw new InvalidOperationException(
+ "Only a preparing Runtime movement candidate can be sealed.");
+ }
+ _publicationLifecycle = PlayerMovementControllerPublicationLifecycle
+ .CandidateSealed;
+ }
+
+ internal void CommitRuntimeOwnership(RetailObjectQuantumClock objectClock)
+ {
+ ArgumentNullException.ThrowIfNull(objectClock);
+ if (_publicationLifecycle
+ is not PlayerMovementControllerPublicationLifecycle.CandidateSealed)
+ {
+ throw new InvalidOperationException(
+ "Only a sealed Runtime movement candidate can be published.");
+ }
+ _objectClock = objectClock;
+ _publicationLifecycle = PlayerMovementControllerPublicationLifecycle
+ .RuntimeOwnedDormant;
+ }
+
+ internal void ActivateRuntimePublication()
+ {
+ if (_publicationLifecycle
+ is not PlayerMovementControllerPublicationLifecycle
+ .RuntimeOwnedDormant)
+ {
+ throw new InvalidOperationException(
+ "Only a dormant Runtime-owned movement controller can be activated.");
+ }
+ _publicationLifecycle = PlayerMovementControllerPublicationLifecycle
+ .RuntimePublished;
+ }
+
+ internal void DiscardRuntimeCandidate()
+ {
+ if (_publicationLifecycle
+ is PlayerMovementControllerPublicationLifecycle.CandidatePreparing
+ or PlayerMovementControllerPublicationLifecycle.CandidateSealed)
+ {
+ _publicationLifecycle = PlayerMovementControllerPublicationLifecycle
+ .Discarded;
+ }
+ }
+
+ internal void RetireRuntimePublication()
+ {
+ if (_publicationLifecycle
+ is PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant
+ or PlayerMovementControllerPublicationLifecycle.RuntimePublished)
+ {
+ _publicationLifecycle = PlayerMovementControllerPublicationLifecycle
+ .RuntimeRetired;
+ }
+ }
+
+ private void EnsureConfigurationMutable()
+ {
+ if (_publicationLifecycle
+ is PlayerMovementControllerPublicationLifecycle.StandalonePublished
+ or PlayerMovementControllerPublicationLifecycle
+ .CandidatePreparing
+ or PlayerMovementControllerPublicationLifecycle.RuntimePublished)
+ {
+ return;
+ }
+ throw new InvalidOperationException(
+ "A sealed, retired, or discarded Runtime movement controller cannot be mutated.");
+ }
+
+ private void EnsurePublishedForRuntimeOperation()
+ {
+ if (_publicationLifecycle
+ is PlayerMovementControllerPublicationLifecycle.StandalonePublished
+ or PlayerMovementControllerPublicationLifecycle.RuntimePublished)
+ {
+ return;
+ }
+ throw new InvalidOperationException(
+ "An unpublished or retired Runtime movement controller cannot execute live movement operations.");
+ }
+
///
/// Host half of retail MovementManager::PerformMovement's
/// unconditional CPhysicsObj::set_active(1) head. Static objects
@@ -568,6 +800,7 @@ public sealed class PlayerMovementController
///
private void ActivateFromMovement()
{
+ EnsureConfigurationMutable();
if ((_body.State & PhysicsStateFlags.Static) != 0)
return;
@@ -582,6 +815,7 @@ public sealed class PlayerMovementController
///
internal void SuspendObjectUpdate(float elapsedSeconds)
{
+ EnsurePublishedForRuntimeOperation();
AdvancedObjectQuantumLastTick = false;
if (float.IsFinite(elapsedSeconds) && elapsedSeconds > 0f)
_simTimeSeconds += elapsedSeconds;
@@ -596,6 +830,7 @@ public sealed class PlayerMovementController
///
public bool BeginMouseLook(MovementInput input)
{
+ EnsurePublishedForRuntimeOperation();
if (_mouseLookActive || State != PlayerState.InWorld)
return false;
@@ -620,6 +855,7 @@ public sealed class PlayerMovementController
float signedAdjustment,
MovementInput input)
{
+ EnsurePublishedForRuntimeOperation();
if (!_mouseLookActive || !float.IsFinite(signedAdjustment))
return;
@@ -637,6 +873,7 @@ public sealed class PlayerMovementController
///
public void StopMouseDrift(MovementInput input)
{
+ EnsurePublishedForRuntimeOperation();
if (!_mouseLookActive)
return;
@@ -662,6 +899,7 @@ public sealed class PlayerMovementController
///
public bool EndMouseLook(MovementInput input)
{
+ EnsurePublishedForRuntimeOperation();
if (!_mouseLookActive && !_activeInputTurnFromMouse)
return false;
@@ -874,6 +1112,7 @@ public sealed class PlayerMovementController
///
public bool PrepareForAttackRequest()
{
+ EnsurePublishedForRuntimeOperation();
// CommandInterpreter::MaybeStopCompletely @ 0x006B3B90 is a no-op
// while an authoritative server movement owns the player.
if (_controlledByServer)
@@ -898,6 +1137,7 @@ public sealed class PlayerMovementController
///
public bool RequestPosture(uint motion)
{
+ EnsurePublishedForRuntimeOperation();
if (motion is not (
MotionCommand.Ready
or MotionCommand.Crouch
@@ -922,6 +1162,7 @@ public sealed class PlayerMovementController
public void SetCharacterSkills(int runSkill, int jumpSkill)
{
+ EnsureConfigurationMutable();
_weenie.SetSkills(runSkill, jumpSkill);
}
@@ -934,6 +1175,7 @@ public sealed class PlayerMovementController
///
public void SetCharacterBurden(float burden)
{
+ EnsureConfigurationMutable();
_weenie.SetBurden(burden);
}
@@ -946,6 +1188,7 @@ public sealed class PlayerMovementController
///
public void SetCharacterStamina(int currentStamina)
{
+ EnsureConfigurationMutable();
_weenie.SetStamina(currentStamina < 0 ? null : (uint)currentStamina);
}
@@ -960,6 +1203,7 @@ public sealed class PlayerMovementController
///
public void SetCharacterPkStatus(int playerKillerStatus, float? lastPkAttackTimestamp)
{
+ EnsureConfigurationMutable();
_weenie.SetPlayerKillerStatus(
playerKillerStatus < 0 ? null : playerKillerStatus,
lastPkAttackTimestamp);
@@ -972,7 +1216,14 @@ public sealed class PlayerMovementController
/// path remotes use. R3-W6 widens this into the full local-player
/// unification.
///
- internal MotionInterpreter Motion => _motion;
+ internal MotionInterpreter Motion
+ {
+ get
+ {
+ EnsureConfigurationMutable();
+ return _motion;
+ }
+ }
///
/// Retail CPhysicsObj::StopCompletely (0x00510180) enters through
@@ -981,11 +1232,14 @@ public sealed class PlayerMovementController
/// performs the required zero-duration animation completion sweep after
/// the interpreter has queued its matching pending-motion node.
///
- internal WeenieError StopCompletelyAtPhysicsObjectBoundary() =>
- Movement.PerformMovement(new MovementStruct
+ internal WeenieError StopCompletelyAtPhysicsObjectBoundary()
+ {
+ EnsureConfigurationMutable();
+ return _movementManager.PerformMovement(new MovementStruct
{
Type = MovementType.StopCompletely,
});
+ }
///
/// Retail CPhysicsObj::DoMotion (0x00510020) constructs a type-1
@@ -999,6 +1253,7 @@ public sealed class PlayerMovementController
uint motion,
AcDream.Core.Physics.Motion.MovementParameters parameters)
{
+ EnsureConfigurationMutable();
_body.LastMoveWasAutonomous = true;
return Movement.PerformMovement(new MovementStruct
{
@@ -1018,6 +1273,7 @@ public sealed class PlayerMovementController
uint motion,
AcDream.Core.Physics.Motion.MovementParameters parameters)
{
+ EnsureConfigurationMutable();
_body.LastMoveWasAutonomous = true;
return Movement.PerformMovement(new MovementStruct
{
@@ -1048,6 +1304,7 @@ public sealed class PlayerMovementController
///
public void SetBodyOrientation(Quaternion orientation)
{
+ EnsureConfigurationMutable();
_body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate(
_body.Position,
_body.Orientation,
@@ -1066,6 +1323,7 @@ public sealed class PlayerMovementController
/// dispatched motions with the idle raw state.
internal void SetLastMoveWasAutonomous(bool autonomous)
{
+ EnsureConfigurationMutable();
_body.LastMoveWasAutonomous = autonomous;
_controlledByServer = !autonomous;
}
@@ -1093,6 +1351,7 @@ public sealed class PlayerMovementController
///
public void AttachCycleVelocityAccessor(Func accessor)
{
+ EnsureConfigurationMutable();
if (accessor is null) throw new ArgumentNullException(nameof(accessor));
_motion.GetCycleVelocity = accessor;
}
@@ -1109,6 +1368,7 @@ public sealed class PlayerMovementController
Action advance,
Action? processHooks = null)
{
+ EnsureConfigurationMutable();
_advanceAnimationRootMotion = advance
?? throw new ArgumentNullException(nameof(advance));
_processAnimationHooks = processHooks;
@@ -1122,6 +1382,7 @@ public sealed class PlayerMovementController
///
public void NoteMovementSent(float nowSeconds, bool mouseLookEvent = false)
{
+ EnsurePublishedForRuntimeOperation();
_lastSentTime = nowSeconds;
if (mouseLookEvent)
{
@@ -1137,6 +1398,7 @@ public sealed class PlayerMovementController
///
public MovementResult CaptureMovementResult(bool mouseLookEvent)
{
+ EnsurePublishedForRuntimeOperation();
var raw = _motion.RawState;
uint? forward = raw.ForwardCommand == RawMotionState.Default.ForwardCommand
? null : raw.ForwardCommand;
@@ -1189,6 +1451,7 @@ public sealed class PlayerMovementController
System.Numerics.Plane contactPlane,
float nowSeconds)
{
+ EnsurePublishedForRuntimeOperation();
_lastSentPosition = position;
_lastSentContactPlane = contactPlane;
_lastSentTime = nowSeconds;
@@ -1207,6 +1470,7 @@ public sealed class PlayerMovementController
System.Numerics.Plane currentContactPlane,
float nowSeconds)
{
+ EnsurePublishedForRuntimeOperation();
if (!_lastSentInitialized)
return true;
@@ -1266,11 +1530,14 @@ public sealed class PlayerMovementController
/// directly via SnapToCell rather than delta-syncing through the setter.
///
public void SetPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
- => SetPositionCore(
+ {
+ EnsurePublishedForRuntimeOperation();
+ SetPositionCore(
pos,
cellId,
cellLocal,
publishSharedState: true);
+ }
///
/// Builds a new local controller's canonical body pose without publishing
@@ -1281,15 +1548,19 @@ public sealed class PlayerMovementController
internal void PreparePositionForCommit(
Vector3 pos,
uint cellId,
- Vector3 cellLocal) =>
+ Vector3 cellLocal)
+ {
+ EnsureConfigurationMutable();
SetPositionCore(
pos,
cellId,
cellLocal,
publishSharedState: false);
+ }
internal void CommitPreparedPosition()
{
+ EnsurePublishedForRuntimeOperation();
_physics.UpdatePlayerCurrCell(CellId);
PositionManager?.UnStick();
// #167 (Campaign P P5): mirrors the SetPositionCore teleport_hook
@@ -1404,6 +1675,7 @@ public sealed class PlayerMovementController
///
public void BlipPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
{
+ EnsurePublishedForRuntimeOperation();
_body.SnapToCell(cellId, pos, cellLocal);
_prevPhysicsPos = pos;
_currPhysicsPos = pos;
@@ -1435,6 +1707,7 @@ public sealed class PlayerMovementController
///
public MovementResult TickHidden(float dt, Action? handleTargeting = null)
{
+ EnsurePublishedForRuntimeOperation();
AdvancedObjectQuantumLastTick = false;
if (!float.IsFinite(dt) || dt <= 0f)
{
@@ -1554,6 +1827,7 @@ public sealed class PlayerMovementController
MovementInput input,
Action? handleTargeting = null)
{
+ EnsurePublishedForRuntimeOperation();
AdvancedObjectQuantumLastTick = false;
// Reject a malformed host-frame duration at the controller boundary.
// The retail object clock cannot sanitize state that input/jump/yaw
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs
index c173b93d..942e7249 100644
--- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs
+++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs
@@ -27,6 +27,7 @@ public sealed class RuntimeLocalPlayerMovementState
{
private PlayerMovementController? _controller;
private PlayerMovementController? _preparingMotionOwner;
+ private RuntimeLocalPlayerPhysicsPublicationState? _physicsPublication;
private bool _autoRunActive;
private bool _hasCommandInput;
private MovementInput _commandInput;
@@ -41,7 +42,9 @@ public sealed class RuntimeLocalPlayerMovementState
ObjectDisposedException.ThrowIf(_disposed, this);
if (ReferenceEquals(_controller, value))
return;
+ _controller?.RetireRuntimePublication();
_controller = value;
+ ControllerOwnershipEpoch++;
Interlocked.Increment(ref _revision);
}
}
@@ -50,8 +53,13 @@ public sealed class RuntimeLocalPlayerMovementState
public bool HasCommandInput => _hasCommandInput;
public MovementInput CommandInput => _commandInput;
public long Revision => Interlocked.Read(ref _revision);
+ public ulong ControllerOwnershipEpoch { get; private set; }
public IRuntimeMovementView View => this;
+ internal RuntimeLocalPlayerPhysicsPublicationState PhysicsPublication =>
+ _physicsPublication ?? throw new InvalidOperationException(
+ "The Runtime local-player physics publication owner is not bound.");
+
MotionInterpreter? IRuntimeLocalPlayerMotionSource.Motion =>
_preparingMotionOwner?.Motion ?? _controller?.Motion;
@@ -236,6 +244,7 @@ public sealed class RuntimeLocalPlayerMovementState
public void ResetSession()
{
ObjectDisposedException.ThrowIf(_disposed, this);
+ _physicsPublication?.ResetSession();
bool changed =
_autoRunActive
|| _hasCommandInput
@@ -244,7 +253,12 @@ public sealed class RuntimeLocalPlayerMovementState
_autoRunActive = false;
_hasCommandInput = false;
_commandInput = default;
- _controller = null;
+ if (_controller is not null)
+ {
+ _controller.RetireRuntimePublication();
+ _controller = null;
+ ControllerOwnershipEpoch++;
+ }
_preparingMotionOwner = null;
if (changed)
Interlocked.Increment(ref _revision);
@@ -257,7 +271,9 @@ public sealed class RuntimeLocalPlayerMovementState
_preparingMotionOwner is not null,
_autoRunActive,
_hasCommandInput,
- Revision);
+ Revision,
+ ControllerOwnershipEpoch,
+ _physicsPublication?.CaptureOwnership() ?? default);
public void Dispose()
{
@@ -266,12 +282,46 @@ public sealed class RuntimeLocalPlayerMovementState
_autoRunActive = false;
_hasCommandInput = false;
_commandInput = default;
- _controller = null;
+ _physicsPublication?.Dispose();
+ if (_controller is not null)
+ {
+ _controller.RetireRuntimePublication();
+ _controller = null;
+ ControllerOwnershipEpoch++;
+ }
_preparingMotionOwner = null;
Interlocked.Increment(ref _revision);
_disposed = true;
}
+ internal void AttachPhysicsPublication(
+ RuntimeLocalPlayerPhysicsPublicationState publication)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentNullException.ThrowIfNull(publication);
+ if (_physicsPublication is not null)
+ {
+ throw new InvalidOperationException(
+ "The Runtime local-player physics publication owner is already bound.");
+ }
+ _physicsPublication = publication;
+ }
+
+ internal bool CanCommitRuntimeOwnedController(
+ ulong expectedEpoch,
+ PlayerMovementController? expectedController) =>
+ !_disposed
+ && ControllerOwnershipEpoch == expectedEpoch
+ && ReferenceEquals(_controller, expectedController);
+
+ internal void CommitRuntimeOwnedController(PlayerMovementController controller)
+ {
+ _controller?.RetireRuntimePublication();
+ _controller = controller;
+ ControllerOwnershipEpoch++;
+ Interlocked.Increment(ref _revision);
+ }
+
private void EndMotionPreparation(PlayerMovementController controller)
{
// Terminal disposal clears the unpublished construction seam. A
@@ -310,12 +360,15 @@ public readonly record struct RuntimeLocalMovementOwnershipSnapshot(
bool HasPreparingMotionOwner,
bool AutoRunActive,
bool HasCommandInput,
- long Revision)
+ long Revision,
+ ulong ControllerOwnershipEpoch,
+ RuntimeLocalPlayerPhysicsPublicationOwnershipSnapshot PhysicsPublication)
{
public bool IsConverged =>
IsDisposed
&& !HasController
&& !HasPreparingMotionOwner
&& !AutoRunActive
- && !HasCommandInput;
+ && !HasCommandInput
+ && PhysicsPublication.IsConverged;
}
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs
new file mode 100644
index 00000000..823cfcb6
--- /dev/null
+++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs
@@ -0,0 +1,316 @@
+using System.Numerics;
+using AcDream.Core.Physics;
+using AcDream.Runtime.Entities;
+using AcDream.Runtime.Physics;
+
+namespace AcDream.Runtime.Gameplay;
+
+internal enum RuntimeLocalPlayerPhysicsPublicationStatus
+{
+ Prepared,
+ Committed,
+ RejectedAuthority,
+ RejectedToken,
+ Discarded,
+}
+
+internal readonly record struct RuntimeLocalPlayerPhysicsPublicationToken(
+ RuntimeEntityKey Entity,
+ RuntimeEntityPlacementToken Placement,
+ ulong PublicationId,
+ uint LocalPlayerServerGuid,
+ long LocalPlayerIdentityRevision,
+ ulong PhysicsOwnershipEpoch,
+ ulong ObjectClockEpoch,
+ ulong ControllerOwnershipEpoch,
+ ulong SessionGenerationAuthority)
+{
+ internal bool IsValid => PublicationId != 0UL
+ && LocalPlayerServerGuid != 0u
+ && Placement.IsValid
+ && Entity == Placement.Entity;
+}
+
+internal readonly record struct RuntimeLocalPlayerPhysicsCandidateSnapshot(
+ Vector3 Position,
+ Quaternion Orientation,
+ uint CellId,
+ Vector3 CellLocalPosition,
+ PhysicsStateFlags State,
+ TransientStateFlags TransientState,
+ bool InWorld);
+
+public readonly record struct
+ RuntimeLocalPlayerPhysicsPublicationOwnershipSnapshot(
+ bool IsBound,
+ bool IsDisposed,
+ int CandidateCount,
+ ulong LastPublicationId)
+{
+ internal bool IsConverged => !IsBound
+ || (IsDisposed && CandidateCount == 0);
+}
+
+///
+/// Dormant, presentation-independent owner of local-player body/controller
+/// candidates. Preparation owns a private body and clock. Commit is one
+/// callback-free update-thread transaction which assigns that exact body to
+/// the canonical entity and a dormant movement controller. It deliberately
+/// does not activate the controller or
+/// consume SetPosition or publish world residence, host, shadow, ordinary
+/// workset, FullCell, or presentation state; those belong to the subsequent
+/// activation transaction.
+///
+internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
+{
+ private sealed class Candidate
+ {
+ internal required RuntimeLocalPlayerPhysicsPublicationToken Token
+ { get; init; }
+ internal required RuntimeEntityRecord Record { get; init; }
+ internal required RuntimeSetPositionCommand PlacementCommand
+ { get; init; }
+ internal required PlayerMovementController Controller { get; init; }
+ internal required PhysicsBody Body { get; init; }
+ }
+
+ private readonly RuntimeEntityDirectory _entities;
+ private readonly RuntimePhysicsState _physics;
+ private readonly RuntimeLocalPlayerMovementState _movement;
+ private readonly RuntimeLocalPlayerIdentityState _identity;
+ private Candidate? _candidate;
+ private ulong _nextPublicationId;
+ private bool _disposed;
+
+ internal RuntimeLocalPlayerPhysicsPublicationState(
+ RuntimeEntityDirectory entities,
+ RuntimePhysicsState physics,
+ RuntimeLocalPlayerMovementState movement,
+ RuntimeLocalPlayerIdentityState identity)
+ {
+ _entities = entities ?? throw new ArgumentNullException(nameof(entities));
+ _physics = physics ?? throw new ArgumentNullException(nameof(physics));
+ _movement = movement ?? throw new ArgumentNullException(nameof(movement));
+ _identity = identity ?? throw new ArgumentNullException(nameof(identity));
+ }
+
+ internal RuntimeLocalPlayerPhysicsPublicationStatus Prepare(
+ RuntimeEntityRecord record,
+ in RuntimeEntityPlacementToken placement,
+ in RuntimeSetPositionCommand command,
+ PlayerMovementConstructionOptions options,
+ out RuntimeLocalPlayerPhysicsPublicationToken token)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentNullException.ThrowIfNull(record);
+ token = default;
+ if (!CanPrepare(record, placement, command))
+ return RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority;
+
+ var controller = PlayerMovementController.CreatePublicationCandidate(
+ _physics.Engine,
+ options);
+ controller.LocalEntityId = record.Key!.Value.LocalEntityId;
+ controller.StepUpHeight = command.Physics.StepUpHeight;
+ controller.StepDownHeight = command.Physics.StepDownHeight;
+ controller.SphereList = command.Physics.Spheres;
+ controller.ObjectScale = command.Physics.Scale;
+ controller.PreparePositionForCommit(
+ command.Physics.Position,
+ command.Physics.CellId,
+ command.Physics.CellLocalPosition);
+ controller.SetBodyOrientation(command.Physics.Orientation);
+ controller.ApplyPhysicsState(record.FinalPhysicsState);
+ PhysicsBody body = controller.PhysicsBody;
+ // This checkpoint publishes ownership only. The subsequent canonical
+ // SetPosition transaction is the sole authority which may enter the
+ // body into world simulation and activate its ordinary workset.
+ body.InWorld = false;
+ body.TransientState &= ~TransientStateFlags.Active;
+ controller.SealPublicationCandidate();
+
+ // Candidate construction is intentionally private, but every accepted
+ // authority is rechecked after it so future content/configuration work
+ // cannot accidentally create a callback-shaped stale publication.
+ if (!CanPrepare(record, placement, command))
+ {
+ controller.DiscardRuntimeCandidate();
+ return RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority;
+ }
+
+ DiscardCurrent();
+ token = new RuntimeLocalPlayerPhysicsPublicationToken(
+ record.Key.Value,
+ placement,
+ checked(++_nextPublicationId),
+ _identity.ServerGuid,
+ _identity.Revision,
+ record.PhysicsOwnershipEpoch,
+ record.ObjectClockEpoch,
+ _movement.ControllerOwnershipEpoch,
+ _entities.SessionLifetimeVersion);
+ _candidate = new Candidate
+ {
+ Token = token,
+ Record = record,
+ PlacementCommand = command,
+ Controller = controller,
+ Body = body,
+ };
+ return RuntimeLocalPlayerPhysicsPublicationStatus.Prepared;
+ }
+
+ internal RuntimeLocalPlayerPhysicsPublicationStatus Commit(
+ in RuntimeLocalPlayerPhysicsPublicationToken token)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (!token.IsValid
+ || _candidate is not { } candidate
+ || candidate.Token != token)
+ {
+ return RuntimeLocalPlayerPhysicsPublicationStatus.RejectedToken;
+ }
+ if (!IsCurrent(candidate))
+ {
+ DiscardCurrent();
+ return RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority;
+ }
+
+ // All validation is complete. The remaining stores are callback-free,
+ // non-allocating, and cannot fail on this single Runtime update thread.
+ // The controller remains RuntimeOwnedDormant; the subsequent world
+ // activation transaction is the only authority allowed to make it live.
+ candidate.Controller.CommitRuntimeOwnership(
+ candidate.Record.ObjectClock);
+ candidate.Record.SetPhysicsBody(candidate.Body);
+ _movement.CommitRuntimeOwnedController(candidate.Controller);
+ _candidate = null;
+ return RuntimeLocalPlayerPhysicsPublicationStatus.Committed;
+ }
+
+ internal RuntimeLocalPlayerPhysicsPublicationStatus Discard(
+ in RuntimeLocalPlayerPhysicsPublicationToken token)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (!token.IsValid
+ || _candidate is not { } candidate
+ || candidate.Token != token)
+ {
+ return RuntimeLocalPlayerPhysicsPublicationStatus.RejectedToken;
+ }
+ DiscardCurrent();
+ return RuntimeLocalPlayerPhysicsPublicationStatus.Discarded;
+ }
+
+ internal bool TryCaptureCandidateSnapshot(
+ in RuntimeLocalPlayerPhysicsPublicationToken token,
+ out RuntimeLocalPlayerPhysicsCandidateSnapshot snapshot)
+ {
+ if (!_disposed
+ && token.IsValid
+ && _candidate is { } candidate
+ && candidate.Token == token)
+ {
+ PhysicsBody body = candidate.Body;
+ snapshot = new RuntimeLocalPlayerPhysicsCandidateSnapshot(
+ body.Position,
+ body.Orientation,
+ body.CellPosition.ObjCellId,
+ body.CellPosition.Frame.Origin,
+ body.State,
+ body.TransientState,
+ body.InWorld);
+ return true;
+ }
+ snapshot = default;
+ return false;
+ }
+
+ internal RuntimeLocalPlayerPhysicsPublicationOwnershipSnapshot
+ CaptureOwnership() => new(
+ IsBound: true,
+ _disposed,
+ _candidate is null ? 0 : 1,
+ _nextPublicationId);
+
+ internal void ResetSession()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ DiscardCurrent();
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ DiscardCurrent();
+ _disposed = true;
+ }
+
+ private bool CanPrepare(
+ RuntimeEntityRecord record,
+ in RuntimeEntityPlacementToken placement,
+ in RuntimeSetPositionCommand command) =>
+ record.Key is { } key
+ && key == placement.Entity
+ && _entities.IsCurrent(record)
+ && !record.DeleteAcceptedForTeardown
+ && command.Kind is RuntimeSetPositionOperationKind.InitialLogin
+ or RuntimeSetPositionOperationKind.LocalAuthoritative
+ && command.Physics.MovingEntityId == key.LocalEntityId
+ && !_identity.IsDisposed
+ && _identity.ServerGuid != 0u
+ && _identity.ServerGuid == record.ServerGuid
+ && record.PhysicsBody is null
+ && _movement.Controller is null
+ && record.PhysicsHost is null
+ && record.RemoteMotion is null
+ && record.Projectile is null
+ && !record.PhysicsBodyAcquisitionInProgress
+ && !record.RemoteMotionBindingInProgress
+ && !record.ProjectileBindingInProgress
+ && !record.RequiresRemotePlacementRuntime
+ && _physics.SetPosition.IsExactPreparedPlacementCurrent(
+ record,
+ placement,
+ command);
+
+ private bool IsCurrent(Candidate candidate) =>
+ candidate.Controller.IsSealedPublicationCandidate
+ && candidate.Controller.OwnsPhysicsBody(candidate.Body)
+ && _entities.SessionLifetimeVersion
+ == candidate.Token.SessionGenerationAuthority
+ && _entities.IsCurrent(candidate.Record)
+ && candidate.Record.Key == candidate.Token.Entity
+ && !_identity.IsDisposed
+ && _identity.ServerGuid == candidate.Token.LocalPlayerServerGuid
+ && _identity.ServerGuid == candidate.Record.ServerGuid
+ && _identity.Revision == candidate.Token.LocalPlayerIdentityRevision
+ && candidate.Record.PhysicsOwnershipEpoch
+ == candidate.Token.PhysicsOwnershipEpoch
+ && candidate.Record.ObjectClockEpoch
+ == candidate.Token.ObjectClockEpoch
+ && _movement.CanCommitRuntimeOwnedController(
+ candidate.Token.ControllerOwnershipEpoch,
+ expectedController: null)
+ && candidate.Record.PhysicsBody is null
+ && candidate.Record.PhysicsHost is null
+ && candidate.Record.RemoteMotion is null
+ && candidate.Record.Projectile is null
+ && !candidate.Record.PhysicsBodyAcquisitionInProgress
+ && !candidate.Record.RemoteMotionBindingInProgress
+ && !candidate.Record.ProjectileBindingInProgress
+ && !candidate.Record.RequiresRemotePlacementRuntime
+ && !candidate.Record.DeleteAcceptedForTeardown
+ && _physics.SetPosition.IsExactPreparedPlacementCurrent(
+ candidate.Record,
+ candidate.Token.Placement,
+ candidate.PlacementCommand);
+
+ private void DiscardCurrent()
+ {
+ Candidate? candidate = _candidate;
+ _candidate = null;
+ candidate?.Controller.DiscardRuntimeCandidate();
+ }
+}
diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs
index 70c04794..a58bd2a1 100644
--- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs
+++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs
@@ -573,6 +573,30 @@ internal sealed class RuntimeSetPositionState : IDisposable
return RuntimeSetPositionMoverPreparationStatus.Prepared;
}
+ internal bool IsExactPreparedPlacementCurrent(
+ RuntimeEntityRecord record,
+ in RuntimeEntityPlacementToken token,
+ in RuntimeSetPositionCommand command)
+ {
+ EnsureNotDisposed();
+ ArgumentNullException.ThrowIfNull(record);
+ return token.IsValid
+ && token.Entity == record.Key
+ && _operations.TryGetValue(token.Entity, out Operation? operation)
+ && ReferenceEquals(operation.Record, record)
+ && operation.Token == token
+ && operation.Stage
+ is RuntimeEntityPlacementStage.AwaitingPreparation
+ && IsCurrent(operation)
+ && _moverPreparationAuthorities.TryGetValue(
+ token.Entity,
+ out MoverPreparationAuthority authority)
+ && authority.OperationId == token.OperationId
+ && authority.Prepared
+ && authority.PreparedCommand == command
+ && IsPreparationAuthorityCurrent(operation, authority);
+ }
+
internal RuntimeSetPositionOutcome SubmitPreparedPlacement(
in RuntimeEntityPlacementToken token,
in RuntimeSetPositionCommand command) =>
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
index 176e5233..d9ba7ffe 100644
--- a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
@@ -1100,6 +1100,74 @@ public class PlayerMovementControllerTests
public bool StopMotion(uint motion) => true;
}
+ [Fact]
+ public void PublicationLifecycleRequiresExplicitActivationAfterOwnershipCommit()
+ {
+ var candidate = PlayerMovementController.CreatePublicationCandidate(
+ new PhysicsEngine(),
+ PlayerMovementConstructionOptions.Fallback);
+ candidate.LocalEntityId = 0x70004001u;
+ candidate.StepUpHeight = 0.4f;
+ candidate.StepDownHeight = 0.4f;
+ candidate.ObjectScale = 1f;
+ candidate.PreparePositionForCommit(
+ new Vector3(1f, 2f, 3f),
+ 0xA9B40021u,
+ new Vector3(1f, 2f, 3f));
+ candidate.SetBodyOrientation(Quaternion.Identity);
+ candidate.ApplyPhysicsState(PhysicsStateFlags.Gravity);
+ PhysicsBody body = candidate.PhysicsBody;
+ body.InWorld = false;
+ body.TransientState &= ~TransientStateFlags.Active;
+ Assert.False(body.InWorld);
+
+ candidate.SealPublicationCandidate();
+
+ Assert.True(candidate.IsSealedPublicationCandidate);
+ Assert.Throws(() => candidate.Update(
+ 1f / 60f,
+ default));
+ Assert.Throws(() => candidate.TickHidden(
+ 1f / 60f));
+ Assert.Throws(() => candidate.SetPosition(
+ Vector3.One,
+ 0xA9B40021u,
+ Vector3.One));
+ Assert.Throws(() => candidate.BlipPosition(
+ Vector3.One,
+ 0xA9B40021u,
+ Vector3.One));
+ Assert.Throws(() =>
+ candidate.CaptureMovementResult(mouseLookEvent: false));
+ Assert.Throws(() =>
+ candidate.NoteMovementSent(1f));
+ Assert.Throws(candidate.CommitPreparedPosition);
+ Assert.Throws(() =>
+ candidate.ApplyPhysicsState(PhysicsStateFlags.Frozen));
+ Assert.Throws(() => candidate.LocalEntityId = 2u);
+ Assert.Throws(() => _ = candidate.Movement);
+
+ var canonicalClock = new RetailObjectQuantumClock();
+ candidate.CommitRuntimeOwnership(canonicalClock);
+ Assert.Throws(() => candidate.Update(
+ 1f / 60f,
+ default));
+ Assert.Throws(() =>
+ candidate.CaptureMovementResult(mouseLookEvent: false));
+ Assert.Throws(() => _ = candidate.PhysicsBody);
+
+ candidate.ActivateRuntimePublication();
+ Assert.Same(body, candidate.PhysicsBody);
+ _ = candidate.CaptureMovementResult(mouseLookEvent: false);
+ _ = candidate.Update(0f, default);
+ candidate.ApplyPhysicsState(PhysicsStateFlags.Gravity);
+
+ candidate.RetireRuntimePublication();
+ Assert.Throws(() => candidate.Update(
+ 1f / 60f,
+ default));
+ }
+
[Fact]
public void Update_RunningJumpLandsOnFlatGround_ResidualVelocitySurvivesAndDecays_NotFrozen()
{
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs
new file mode 100644
index 00000000..6ab1e808
--- /dev/null
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs
@@ -0,0 +1,713 @@
+using System.Collections.Immutable;
+using System.Numerics;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using AcDream.Runtime.Entities;
+using AcDream.Runtime.Gameplay;
+using AcDream.Runtime.Physics;
+
+namespace AcDream.Runtime.Tests.Gameplay;
+
+public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
+{
+ private const uint Cell = 0xA9B40021u;
+ private const uint SetupId = 0x02000001u;
+
+ [Fact]
+ public void PreparationOwnsPrivateBodyAndMutatesNoCanonicalOrSharedState()
+ {
+ using var fixture = new Fixture();
+ uint fullCell = fixture.Record.FullCellId;
+ ulong spatial = fixture.Record.SpatialAuthorityVersion;
+ ulong physicsEpoch = fixture.Record.PhysicsOwnershipEpoch;
+ ulong clockEpoch = fixture.Record.ObjectClockEpoch;
+ RuntimePhysicsOwnershipSnapshot physics = fixture.Lifetime.Physics
+ .CaptureOwnership();
+
+ RuntimeLocalPlayerPhysicsPublicationToken token = fixture.Prepare();
+
+ Assert.Null(fixture.Record.PhysicsBody);
+ Assert.Null(fixture.Movement.Controller);
+ Assert.Equal(fullCell, fixture.Record.FullCellId);
+ Assert.Equal(spatial, fixture.Record.SpatialAuthorityVersion);
+ Assert.Equal(physicsEpoch, fixture.Record.PhysicsOwnershipEpoch);
+ Assert.Equal(clockEpoch, fixture.Record.ObjectClockEpoch);
+ Assert.Null(fixture.Record.PhysicsHost);
+ Assert.Equal(physics.SpatialRootCount,
+ fixture.Lifetime.Physics.CaptureOwnership().SpatialRootCount);
+ Assert.Equal(physics.RetainedShadowRegistrationCount,
+ fixture.Lifetime.Physics.CaptureOwnership()
+ .RetainedShadowRegistrationCount);
+ Assert.Equal(1, fixture.Owner.CaptureOwnership().CandidateCount);
+ Assert.True(token.IsValid);
+ }
+
+ [Fact]
+ public void CommitOwnsExactDormantBodyAndControllerWithoutWorldEdges()
+ {
+ using var fixture = new Fixture();
+ RuntimeLocalPlayerPhysicsPublicationToken token = fixture.Prepare();
+ Assert.True(fixture.Owner.TryCaptureCandidateSnapshot(
+ token,
+ out RuntimeLocalPlayerPhysicsCandidateSnapshot prepared));
+ uint fullCell = fixture.Record.FullCellId;
+ ulong spatial = fixture.Record.SpatialAuthorityVersion;
+ ulong bodyEpoch = fixture.Record.PhysicsOwnershipEpoch;
+ ulong controllerEpoch = fixture.Movement.ControllerOwnershipEpoch;
+ double clockPending = fixture.Record.ObjectClock.PendingSeconds;
+ bool clockActive = fixture.Record.ObjectClock.IsActive;
+ object? currentCell = fixture.Lifetime.Physics.DataCache.CellGraph.CurrCell;
+
+ RuntimeLocalPlayerPhysicsPublicationStatus status = fixture.Owner
+ .Commit(token);
+
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, status);
+ PlayerMovementController candidate = Assert.IsType(
+ fixture.Movement.Controller);
+ PhysicsBody body = Assert.IsType(fixture.Record.PhysicsBody);
+ Assert.Same(body, fixture.Record.PhysicsBody);
+ Assert.Same(candidate, fixture.Movement.Controller);
+ Assert.True(candidate.OwnsPhysicsBody(body));
+ Assert.Equal(bodyEpoch + 1UL, fixture.Record.PhysicsOwnershipEpoch);
+ Assert.Equal(controllerEpoch + 1UL,
+ fixture.Movement.ControllerOwnershipEpoch);
+ Assert.Equal(fullCell, fixture.Record.FullCellId);
+ Assert.Equal(spatial, fixture.Record.SpatialAuthorityVersion);
+ Assert.Same(currentCell,
+ fixture.Lifetime.Physics.DataCache.CellGraph.CurrCell);
+ Assert.Equal(clockPending, fixture.Record.ObjectClock.PendingSeconds);
+ Assert.Equal(clockActive, fixture.Record.ObjectClock.IsActive);
+ Assert.Equal(prepared.Position, body.Position);
+ Assert.Equal(prepared.Orientation, body.Orientation);
+ Assert.Equal(prepared.CellId, body.CellPosition.ObjCellId);
+ Assert.Equal(prepared.CellLocalPosition,
+ body.CellPosition.Frame.Origin);
+ Assert.Equal(prepared.State, body.State);
+ Assert.Equal(prepared.TransientState, body.TransientState);
+ Assert.Equal(prepared.InWorld, body.InWorld);
+ Assert.False(body.InWorld);
+ Assert.False((body.TransientState & TransientStateFlags.Active) != 0);
+ Assert.Null(fixture.Record.PhysicsHost);
+ Assert.Equal(0, fixture.Lifetime.Physics.CaptureOwnership()
+ .SpatialRootCount);
+ Assert.Equal(0, fixture.Lifetime.Physics.CaptureOwnership()
+ .RetainedShadowRegistrationCount);
+ Assert.True(fixture.Lifetime.Physics.SetPosition
+ .IsExactPreparedPlacementCurrent(
+ fixture.Record,
+ fixture.Placement,
+ fixture.Command));
+ Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount);
+ AssertNotLive(candidate);
+ Assert.Equal(prepared.Position, body.Position);
+ Assert.Equal(prepared.Orientation, body.Orientation);
+ Assert.Equal(prepared.State, body.State);
+ Assert.Equal(prepared.TransientState, body.TransientState);
+ Assert.Equal(clockPending, fixture.Record.ObjectClock.PendingSeconds);
+ Assert.Equal(clockActive, fixture.Record.ObjectClock.IsActive);
+ Assert.Same(currentCell,
+ fixture.Lifetime.Physics.DataCache.CellGraph.CurrCell);
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.RejectedToken,
+ fixture.Owner.Commit(token));
+ }
+
+ [Theory]
+ [InlineData(PublicationInvalidation.SetPositionReplacement)]
+ [InlineData(PublicationInvalidation.Vector)]
+ [InlineData(PublicationInvalidation.FinalState)]
+ [InlineData(PublicationInvalidation.ObjectDescription)]
+ [InlineData(PublicationInvalidation.Create)]
+ [InlineData(PublicationInvalidation.Remote)]
+ [InlineData(PublicationInvalidation.Projectile)]
+ [InlineData(PublicationInvalidation.Body)]
+ [InlineData(PublicationInvalidation.Clock)]
+ [InlineData(PublicationInvalidation.Controller)]
+ [InlineData(PublicationInvalidation.CancelPlacement)]
+ public void EveryNestedAuthorityReplacementDiscardsCandidateWithoutRollback(
+ PublicationInvalidation invalidation)
+ {
+ using var fixture = new Fixture();
+ RuntimeLocalPlayerPhysicsPublicationToken token = fixture.Prepare();
+ PhysicsBody? replacementBody = null;
+ PlayerMovementController? replacementController = null;
+
+ switch (invalidation)
+ {
+ case PublicationInvalidation.SetPositionReplacement:
+ Assert.True(fixture.Lifetime.Physics.SetPosition
+ .BeginAuthoredPlacement(
+ fixture.Record,
+ fixture.Record.PositionAuthorityVersion,
+ RuntimeSetPositionOperationKind.LocalAuthoritative)
+ .IsValid);
+ break;
+ case PublicationInvalidation.Vector:
+ fixture.Lifetime.Entities.AdvanceVectorAuthority(fixture.Record);
+ break;
+ case PublicationInvalidation.FinalState:
+ fixture.Lifetime.Entities.SetFinalPhysicsState(
+ fixture.Record,
+ fixture.Record.FinalPhysicsState | PhysicsStateFlags.Frozen);
+ break;
+ case PublicationInvalidation.ObjectDescription:
+ fixture.Lifetime.Entities.AdvanceObjDescAuthority(fixture.Record);
+ break;
+ case PublicationInvalidation.Create:
+ fixture.Lifetime.Entities.AdvanceCreateAuthority(fixture.Record);
+ break;
+ case PublicationInvalidation.Remote:
+ fixture.Lifetime.Entities.SetRemoteMotion(
+ fixture.Record,
+ new RemoteMotion());
+ break;
+ case PublicationInvalidation.Projectile:
+ fixture.Lifetime.Entities.SetProjectile(
+ fixture.Record,
+ new RuntimeProjectile(
+ new PhysicsBody(),
+ new ProjectileCollisionSphere(Vector3.Zero, 0.1f)));
+ break;
+ case PublicationInvalidation.Body:
+ replacementBody = new PhysicsBody();
+ fixture.Lifetime.Entities.SetPhysicsBody(
+ fixture.Record,
+ replacementBody);
+ break;
+ case PublicationInvalidation.Clock:
+ fixture.Lifetime.Entities.SuspendObjectClock(fixture.Record);
+ break;
+ case PublicationInvalidation.Controller:
+ replacementController = new PlayerMovementController(
+ new PhysicsEngine());
+ fixture.Movement.Controller = replacementController;
+ break;
+ case PublicationInvalidation.CancelPlacement:
+ Assert.True(fixture.Lifetime.Physics.SetPosition.Cancel(
+ fixture.Record,
+ publishWithdrawal: false));
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(invalidation));
+ }
+
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority,
+ fixture.Owner.Commit(token));
+ Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount);
+ if (replacementBody is not null)
+ Assert.Same(replacementBody, fixture.Record.PhysicsBody);
+ if (replacementController is not null)
+ Assert.Same(replacementController, fixture.Movement.Controller);
+ }
+
+ [Fact]
+ public void DeleteAndGuidReuseCannotPublishRetiredCandidate()
+ {
+ using var fixture = new Fixture();
+ RuntimeLocalPlayerPhysicsPublicationToken token = fixture.Prepare();
+ uint guid = fixture.Record.ServerGuid;
+ Assert.True(fixture.Lifetime.TryAcceptDelete(
+ new DeleteObject.Parsed(guid, fixture.Record.Incarnation),
+ isLocalPlayer: false,
+ removeRetainedObject: false,
+ out RuntimeEntityDeleteAcceptance acceptance));
+ fixture.Lifetime.CompleteAcceptedDelete(acceptance);
+ Assert.Null(fixture.Lifetime.RetireCanonicalOnly(fixture.Record));
+ RuntimeEntityRecord replacement = fixture.Lifetime.RegisterEntity(
+ Spawn(guid, incarnation: 2)).Canonical!;
+
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority,
+ fixture.Owner.Commit(token));
+ Assert.Null(replacement.PhysicsBody);
+ Assert.Null(fixture.Movement.Controller);
+ Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount);
+ }
+
+ [Fact]
+ public void ResetAndDisposeDiscardCandidatesAndConvergeOwnership()
+ {
+ var fixture = new Fixture();
+ _ = fixture.Prepare();
+ fixture.Movement.ResetSession();
+ Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount);
+
+ fixture.RepreparePlacement();
+ _ = fixture.Prepare();
+ fixture.Movement.Dispose();
+ Assert.True(fixture.Movement.CaptureOwnership().IsConverged);
+ fixture.DisposeLifetimeOnly();
+ }
+
+ [Theory]
+ [InlineData(PristineViolation.Body)]
+ [InlineData(PristineViolation.Controller)]
+ [InlineData(PristineViolation.Host)]
+ [InlineData(PristineViolation.BodyAcquisition)]
+ [InlineData(PristineViolation.Remote)]
+ [InlineData(PristineViolation.RemoteBinding)]
+ [InlineData(PristineViolation.Projectile)]
+ [InlineData(PristineViolation.ProjectileBinding)]
+ [InlineData(PristineViolation.RemotePlacement)]
+ public void PreparationRejectsEveryNonPristineOwnershipGraph(
+ PristineViolation violation)
+ {
+ using var fixture = new Fixture();
+ switch (violation)
+ {
+ case PristineViolation.Body:
+ {
+ var activeBody = new PhysicsBody
+ {
+ TransientState = TransientStateFlags.Active,
+ };
+ fixture.Lifetime.Entities.SetPhysicsBody(
+ fixture.Record,
+ activeBody);
+ break;
+ }
+ case PristineViolation.Controller:
+ fixture.Movement.Controller = new PlayerMovementController(
+ new PhysicsEngine());
+ break;
+ case PristineViolation.Host:
+ fixture.Lifetime.Entities.SetPhysicsHost(
+ fixture.Record,
+ CreatePhysicsHost(fixture.Record.ServerGuid));
+ break;
+ case PristineViolation.BodyAcquisition:
+ fixture.Lifetime.Entities.SetPhysicsBodyAcquisitionInProgress(
+ fixture.Record,
+ true);
+ break;
+ case PristineViolation.Remote:
+ fixture.Lifetime.Entities.SetRemoteMotion(
+ fixture.Record,
+ new RemoteMotion());
+ break;
+ case PristineViolation.RemoteBinding:
+ fixture.Lifetime.Entities.SetRemoteMotionBindingInProgress(
+ fixture.Record,
+ true);
+ break;
+ case PristineViolation.Projectile:
+ fixture.Lifetime.Entities.SetProjectile(
+ fixture.Record,
+ new RuntimeProjectile(
+ new PhysicsBody(),
+ new ProjectileCollisionSphere(Vector3.Zero, 0.1f)));
+ break;
+ case PristineViolation.ProjectileBinding:
+ fixture.Lifetime.Entities.SetProjectileBindingInProgress(
+ fixture.Record,
+ true);
+ break;
+ case PristineViolation.RemotePlacement:
+ fixture.Lifetime.Entities.SetRequiresRemotePlacementRuntime(
+ fixture.Record,
+ true);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(violation));
+ }
+
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority,
+ fixture.Owner.Prepare(
+ fixture.Record,
+ fixture.Placement,
+ fixture.Command,
+ PlayerMovementConstructionOptions.Fallback,
+ out RuntimeLocalPlayerPhysicsPublicationToken token));
+ Assert.False(token.IsValid);
+ Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount);
+ }
+
+ [Fact]
+ public void PreparationRejectsRecordWhichIsNotTheExactLocalIdentity()
+ {
+ using var fixture = new Fixture();
+ fixture.Identity.ServerGuid = 0x70003002u;
+
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority,
+ fixture.Owner.Prepare(
+ fixture.Record,
+ fixture.Placement,
+ fixture.Command,
+ PlayerMovementConstructionOptions.Fallback,
+ out _));
+ }
+
+ [Fact]
+ public void PreparationRejectsDisposedLocalIdentity()
+ {
+ using var fixture = new Fixture();
+ fixture.Identity.Dispose();
+
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority,
+ fixture.Owner.Prepare(
+ fixture.Record,
+ fixture.Placement,
+ fixture.Command,
+ PlayerMovementConstructionOptions.Fallback,
+ out _));
+ }
+
+ [Fact]
+ public void IdentityRevisionSwitchRejectsPreparedCandidate()
+ {
+ using var fixture = new Fixture();
+ RuntimeLocalPlayerPhysicsPublicationToken token = fixture.Prepare();
+ uint guid = fixture.Identity.ServerGuid;
+ fixture.Identity.ServerGuid = guid + 1u;
+ fixture.Identity.ServerGuid = guid;
+
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority,
+ fixture.Owner.Commit(token));
+ Assert.Null(fixture.Record.PhysicsBody);
+ Assert.Null(fixture.Movement.Controller);
+ }
+
+ [Theory]
+ [InlineData(PublishedRetirement.Replacement)]
+ [InlineData(PublishedRetirement.Reset)]
+ [InlineData(PublishedRetirement.Dispose)]
+ public void RuntimeOwnedControllerRejectsStaleOperationsAfterRetirement(
+ PublishedRetirement retirement)
+ {
+ using var fixture = new Fixture();
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(fixture.Prepare()));
+ PlayerMovementController stale = fixture.Movement.Controller!;
+
+ switch (retirement)
+ {
+ case PublishedRetirement.Replacement:
+ fixture.Movement.Controller = new PlayerMovementController(
+ new PhysicsEngine());
+ break;
+ case PublishedRetirement.Reset:
+ fixture.Movement.ResetSession();
+ break;
+ case PublishedRetirement.Dispose:
+ fixture.Movement.Dispose();
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(retirement));
+ }
+
+ AssertNotLive(stale);
+ }
+
+ [Fact]
+ public void BodyAndControllerEpochsAdvanceOnlyOnOwnershipChanges()
+ {
+ using var fixture = new Fixture(preparePlacement: false);
+ var firstBody = new PhysicsBody();
+ var secondBody = new PhysicsBody();
+ ulong bodyEpoch = fixture.Record.PhysicsOwnershipEpoch;
+ fixture.Lifetime.Entities.SetPhysicsBody(fixture.Record, firstBody);
+ Assert.Equal(bodyEpoch + 1UL, fixture.Record.PhysicsOwnershipEpoch);
+ fixture.Lifetime.Entities.SetPhysicsBody(fixture.Record, firstBody);
+ Assert.Equal(bodyEpoch + 1UL, fixture.Record.PhysicsOwnershipEpoch);
+ fixture.Lifetime.Entities.SetPhysicsBody(fixture.Record, secondBody);
+ Assert.Equal(bodyEpoch + 2UL, fixture.Record.PhysicsOwnershipEpoch);
+ fixture.Lifetime.Entities.SetPhysicsBody(fixture.Record, null);
+ Assert.Equal(bodyEpoch + 3UL, fixture.Record.PhysicsOwnershipEpoch);
+
+ var first = new PlayerMovementController(new PhysicsEngine());
+ var second = new PlayerMovementController(new PhysicsEngine());
+ ulong controllerEpoch = fixture.Movement.ControllerOwnershipEpoch;
+ fixture.Movement.Controller = first;
+ Assert.Equal(controllerEpoch + 1UL,
+ fixture.Movement.ControllerOwnershipEpoch);
+ fixture.Movement.Controller = first;
+ Assert.Equal(controllerEpoch + 1UL,
+ fixture.Movement.ControllerOwnershipEpoch);
+ fixture.Movement.Controller = second;
+ Assert.Equal(controllerEpoch + 2UL,
+ fixture.Movement.ControllerOwnershipEpoch);
+ fixture.Movement.Controller = null;
+ Assert.Equal(controllerEpoch + 3UL,
+ fixture.Movement.ControllerOwnershipEpoch);
+ }
+
+ private sealed class Fixture : IDisposable
+ {
+ private bool _lifetimeDisposed;
+
+ internal Fixture(bool preparePlacement = true)
+ {
+ Lifetime = new RuntimeEntityObjectLifetime();
+ Movement = new RuntimeLocalPlayerMovementState();
+ Identity = new RuntimeLocalPlayerIdentityState();
+ Owner = new RuntimeLocalPlayerPhysicsPublicationState(
+ Lifetime.Entities,
+ Lifetime.Physics,
+ Movement,
+ Identity);
+ Movement.AttachPhysicsPublication(Owner);
+ Record = Lifetime.RegisterEntity(
+ Spawn(0x70003001u, incarnation: 1)).Canonical!;
+ Identity.ServerGuid = Record.ServerGuid;
+ if (preparePlacement)
+ RepreparePlacement();
+ }
+
+ internal RuntimeEntityObjectLifetime Lifetime { get; }
+ internal RuntimeLocalPlayerMovementState Movement { get; }
+ internal RuntimeLocalPlayerIdentityState Identity { get; }
+ internal RuntimeLocalPlayerPhysicsPublicationState Owner { get; }
+ internal RuntimeEntityRecord Record { get; }
+ internal RuntimeEntityPlacementToken Placement { get; private set; }
+ internal RuntimeSetPositionCommand Command { get; private set; }
+
+ internal void RepreparePlacement()
+ {
+ Placement = Lifetime.Physics.SetPosition.BeginAuthoredPlacement(
+ Record,
+ Record.PositionAuthorityVersion,
+ RuntimeSetPositionOperationKind.LocalAuthoritative);
+ Assert.True(Placement.IsValid);
+ var setup = new FlatSetupCollision(
+ ImmutableArray.Empty,
+ [new FlatCollisionSphere(Vector3.Zero, 0.48f)],
+ height: 0f,
+ radius: 0f,
+ stepUpHeight: 0.4f,
+ stepDownHeight: 0.4f);
+ Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared,
+ Lifetime.Physics.SetPosition.PrepareMover(
+ Placement,
+ new RuntimeSetPositionMoverPreparation(
+ RuntimeSetPositionMoverSetup.Resolved(SetupId, setup),
+ RuntimeSetPositionOperationKind.LocalAuthoritative,
+ GameTime: 10d,
+ PhysicsPlacementClass.Ordinary,
+ PhysicsSetPositionFlags.Placement
+ | PhysicsSetPositionFlags.Slide),
+ out RuntimeSetPositionCommand command));
+ Command = command;
+ }
+
+ internal RuntimeLocalPlayerPhysicsPublicationToken Prepare()
+ {
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Prepared,
+ Owner.Prepare(
+ Record,
+ Placement,
+ Command,
+ PlayerMovementConstructionOptions.Fallback,
+ out RuntimeLocalPlayerPhysicsPublicationToken token));
+ Assert.Equal(Record.Key, token.Entity);
+ Assert.Equal(Placement, token.Placement);
+ Assert.Equal(Identity.ServerGuid, token.LocalPlayerServerGuid);
+ Assert.Equal(Identity.Revision, token.LocalPlayerIdentityRevision);
+ return token;
+ }
+
+ internal void DisposeLifetimeOnly()
+ {
+ if (_lifetimeDisposed)
+ return;
+ Lifetime.Dispose();
+ _lifetimeDisposed = true;
+ }
+
+ public void Dispose()
+ {
+ Movement.Dispose();
+ Identity.Dispose();
+ DisposeLifetimeOnly();
+ }
+ }
+
+ private static WorldSession.EntitySpawn Spawn(uint guid, ushort incarnation)
+ {
+ var position = new CreateObject.ServerPosition(
+ Cell,
+ 1f,
+ 2f,
+ 3f,
+ 1f,
+ 0f,
+ 0f,
+ 0f);
+ var timestamps = new PhysicsTimestamps(
+ Position: 1,
+ Movement: 1,
+ State: 1,
+ Vector: 1,
+ Teleport: 0,
+ ServerControlledMove: 1,
+ ForcePosition: 0,
+ ObjDesc: 1,
+ Instance: incarnation);
+ var physics = new PhysicsSpawnData(
+ RawState: (uint)(PhysicsStateFlags.Gravity
+ | PhysicsStateFlags.ReportCollisions),
+ Position: position,
+ Movement: null,
+ AnimationFrame: null,
+ SetupTableId: SetupId,
+ MotionTableId: 0x09000001u,
+ SoundTableId: null,
+ PhysicsScriptTableId: null,
+ Parent: null,
+ Children: null,
+ Scale: 1f,
+ Friction: null,
+ Elasticity: null,
+ Translucency: null,
+ Velocity: null,
+ Acceleration: null,
+ AngularVelocity: null,
+ DefaultScriptType: null,
+ DefaultScriptIntensity: null,
+ Timestamps: timestamps);
+ return new WorldSession.EntitySpawn(
+ Guid: guid,
+ Position: position,
+ SetupTableId: SetupId,
+ AnimPartChanges: Array.Empty(),
+ TextureChanges: Array.Empty(),
+ SubPalettes: Array.Empty(),
+ BasePaletteId: null,
+ ObjScale: 1f,
+ Name: "local-publication-fixture",
+ ItemType: null,
+ MotionState: null,
+ MotionTableId: 0x09000001u,
+ PhysicsState: physics.RawState,
+ ObjectDescriptionFlags: 0x8u,
+ InstanceSequence: incarnation,
+ MovementSequence: 1,
+ ServerControlSequence: 1,
+ PositionSequence: 1,
+ Physics: physics);
+ }
+
+ public enum PublicationInvalidation
+ {
+ SetPositionReplacement,
+ Vector,
+ FinalState,
+ ObjectDescription,
+ Create,
+ Remote,
+ Projectile,
+ Body,
+ Clock,
+ Controller,
+ CancelPlacement,
+ }
+
+ public enum PristineViolation
+ {
+ Body,
+ Controller,
+ Host,
+ BodyAcquisition,
+ Remote,
+ RemoteBinding,
+ Projectile,
+ ProjectileBinding,
+ RemotePlacement,
+ }
+
+ public enum PublishedRetirement
+ {
+ Replacement,
+ Reset,
+ Dispose,
+ }
+
+ private static EntityPhysicsHost CreatePhysicsHost(uint id) => new(
+ id,
+ getPosition: static () => default,
+ getVelocity: static () => Vector3.Zero,
+ getRadius: static () => 0.48f,
+ inContact: static () => true,
+ minterpMaxSpeed: static () => null,
+ curTime: static () => 0d,
+ physicsTimerTime: static () => 0d,
+ getObjectA: static _ => null,
+ handleUpdateTarget: static _ => { },
+ interruptCurrentMovement: static () => { });
+
+ private static void AssertNotLive(PlayerMovementController controller)
+ {
+ Assert.Throws(() => controller.Update(
+ 1f / 60f,
+ default));
+ Assert.Throws(() => controller.TickHidden(
+ 1f / 60f));
+ Assert.Throws(() =>
+ controller.SuspendObjectUpdate(1f / 60f));
+ Assert.Throws(() => controller.SetPosition(
+ Vector3.One,
+ Cell,
+ Vector3.One));
+ Assert.Throws(() => controller.BlipPosition(
+ Vector3.One,
+ Cell,
+ Vector3.One));
+ Assert.Throws(() =>
+ controller.ApplyPhysicsState(PhysicsStateFlags.Frozen));
+ Assert.Throws(() => controller.Yaw = 1f);
+ Assert.Throws(() =>
+ controller.CaptureMovementResult(mouseLookEvent: false));
+ Assert.Throws(() =>
+ controller.CapturePresentationResult());
+ Assert.Throws(() =>
+ controller.TryGetOutboundPosition(out _));
+ Assert.Throws(() =>
+ controller.NoteMovementSent(1f));
+ Assert.Throws(() =>
+ controller.NotePositionSent(default, default, 1f));
+ Assert.Throws(() =>
+ controller.ShouldSendPositionEvent(default, default, 1f));
+ Assert.Throws(() =>
+ controller.BeginMouseLook(default));
+ Assert.Throws(() =>
+ controller.SubmitMouseTurnAdjustment(1f, default));
+ Assert.Throws(() =>
+ controller.StopMouseDrift(default));
+ Assert.Throws(() =>
+ controller.EndMouseLook(default));
+ Assert.Throws(() =>
+ controller.PrepareForAttackRequest());
+ Assert.Throws(() =>
+ controller.RequestPosture(MotionCommand.Ready));
+ Assert.Throws(() =>
+ controller.CommitPreparedPosition());
+ Assert.Throws(() =>
+ controller.PreparePositionForCommit(Vector3.One, Cell, Vector3.One));
+ Assert.Throws(() =>
+ controller.SetCharacterSkills(1, 1));
+ Assert.Throws(() =>
+ controller.SetBodyOrientation(Quaternion.Identity));
+ Assert.Throws(() =>
+ controller.SetLastMoveWasAutonomous(true));
+ Assert.Throws(() =>
+ controller.State = PlayerState.PortalSpace);
+ Assert.Throws(() =>
+ controller.StepUpHeight = 1f);
+ Assert.Throws(() =>
+ controller.AttachCycleVelocityAccessor(static () => Vector3.One));
+ Assert.Throws(() =>
+ controller.AttachAnimationRootMotionSource(static (_, _) => { }));
+ Assert.Throws(() => _ = controller.Movement);
+ Assert.Throws(() => _ = controller.MoveTo);
+ Assert.Throws(() => _ = controller.PositionManager);
+ Assert.Throws(() => _ = controller.Motion);
+ Assert.Throws(() => _ = controller.PhysicsBody);
+ }
+}