From 22651c823d8266be5f4e43fb37afffeb06aaa7d7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 10:01:30 +0200 Subject: [PATCH] feat(runtime): publish dormant local physics ownership --- ...untime-local-player-physics-publication.md | 126 ++++ .../Entities/RuntimeEntityDirectory.cs | 2 +- .../Entities/RuntimeEntityRecord.cs | 11 +- src/AcDream.Runtime/GameRuntime.cs | 9 + .../Gameplay/PlayerMovementController.cs | 310 +++++++- .../RuntimeLocalPlayerMovementState.cs | 63 +- ...ntimeLocalPlayerPhysicsPublicationState.cs | 316 ++++++++ .../Physics/RuntimeSetPositionState.cs | 24 + .../Gameplay/PlayerMovementControllerTests.cs | 68 ++ ...LocalPlayerPhysicsPublicationStateTests.cs | 713 ++++++++++++++++++ 10 files changed, 1617 insertions(+), 25 deletions(-) create mode 100644 docs/research/2026-08-01-runtime-local-player-physics-publication.md create mode 100644 src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs 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); + } +}