diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs index 1144cf08..87cb5628 100644 --- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs @@ -70,15 +70,46 @@ public sealed class LandblockPhysicsPublication : IDisposable internal int RefloodCursor { get; set; } internal bool RefloodCommitted { get; set; } internal bool SealCommitted { get; set; } + internal bool EngineMutationCommitted { get; set; } + /// + /// The last Runtime mutation poll was nonterminal. This includes exact + /// placement acknowledgements, collision-report/shadow dispatch debt, and + /// Runtime's deliberate first quiescence poll. + /// + internal bool RuntimeMutationPending { get; set; } internal bool BeginCommitted { get; set; } internal bool CompletionCommitted { get; set; } + internal bool CancellationRequested { get; private set; } public void Dispose() { - if (!CompletionCommitted) - Physics.CancelCollisionGeneration( - CollisionAdmission, - PreparedGeneration); + if (!TryCancel()) + { + throw new InvalidOperationException( + "Collision publication cancellation is waiting for exact placement acknowledgements and must remain retained."); + } + } + + internal bool TryCancel() + { + if (CompletionCommitted) + return true; + CancellationRequested = true; + for (int poll = 0; poll < 2; poll++) + { + if (Physics.CancelCollisionGeneration( + CollisionAdmission, + PreparedGeneration)) + { + return true; + } + if (Physics.CaptureOwnership() + .PendingCollisionPrefixProjectionCount != 0) + { + return false; + } + } + return false; } public uint LandblockId => Build.Landblock.LandblockId; @@ -246,9 +277,14 @@ public sealed class LandblockPhysicsPublisher publication.SetupObjectIds); return publication; } - catch + catch (Exception publicationError) { - _physics.CancelCollisionGeneration(collisionAdmission, prepared); + if (!_physics.CancelCollisionGeneration(collisionAdmission, prepared)) + { + throw new AggregateException( + "Collision preparation failed and its pre-engine cancellation did not converge.", + publicationError); + } throw; } } @@ -416,7 +452,7 @@ public sealed class LandblockPhysicsPublisher /// presentation pipeline supplies the static-presentation owner callback /// that preserves per-entity light-before-collision order. /// - public void CompletePublication( + public bool CompletePublication( LandblockPhysicsPublication publication, Action? beforeStaticCollision = null) { @@ -426,7 +462,29 @@ public sealed class LandblockPhysicsPublisher "Physics publication cannot complete before its prefix commits."); while (!AdvanceCompleteOne(publication, beforeStaticCollision)) { + if (publication.RuntimeMutationPending) + { + if (!CanContinueMutationSynchronously()) + return false; + // The activation transaction deliberately closes its exact + // quiescence boundary on one poll and transfers the engine on + // the next. This synchronous compatibility API may consume + // that finite internal suffix; the frame-budgeted pipeline + // always yields on the first nonterminal commit below. + publication.RuntimeMutationPending = false; + } } + return true; + } + + internal bool CanContinueMutationSynchronously() + { + RuntimePhysicsOwnershipSnapshot ownership = _physics.CaptureOwnership(); + return ownership.PendingCollisionPrefixProjectionCount == 0 + && ownership.PendingCollisionReportCount == 0 + && ownership.PendingCollisionSetPositionDispatchCount == 0 + && ownership.PendingShadowSetPositionDispatchCount == 0 + && !ownership.IsCollisionReportDispatching; } /// @@ -438,11 +496,15 @@ public sealed class LandblockPhysicsPublisher Action? beforeStaticCollision = null) { ValidateReceipt(publication); + if (publication.CancellationRequested) + throw new InvalidOperationException( + "A cancelled collision publication cannot resume."); if (!publication.BeginCommitted) throw new InvalidOperationException( "Physics publication cannot complete before its prefix commits."); if (publication.CompletionCommitted) return true; + publication.RuntimeMutationPending = false; long started = Stopwatch.GetTimestamp(); LoadedLandblock landblock = publication.Build.Landblock; @@ -567,12 +629,20 @@ public sealed class LandblockPhysicsPublisher _physics.CommitCollisionGeneration( publication.CollisionAdmission, publication.PreparedGeneration); - if (!commit.Committed) + publication.EngineMutationCommitted |= commit.EngineCommitted; + if (!commit.Completed) { - // Runtime coalesces post-seal arrivals in its owner journal. - // Resume that seal tail rather than restarting the - // completed generation-wide capture/reflood pass. - publication.SealCommitted = false; + publication.RuntimeMutationPending = true; + if (!commit.EngineCommitted) + { + // Runtime coalesces post-seal arrivals in its owner + // journal. Resume that seal tail rather than restarting + // the completed generation-wide capture/reflood pass. + publication.SealCommitted = false; + } + // Once EngineCommitted is true the replacement is canonical + // and cannot be resealed or rolled back. Later frames poll + // only the exact resident-restore acknowledgement suffix. _completePublishTicks += Stopwatch.GetTimestamp() - started; return false; } @@ -587,16 +657,54 @@ public sealed class LandblockPhysicsPublisher return publication.CompletionCommitted; } - public void DemoteToTerrain(uint landblockId) + public bool DemoteToTerrain(uint landblockId) { - _physics.DemoteCollisionToTerrain(landblockId); - _demotionCount++; + for (int poll = 0; poll < 2; poll++) + { + if (AdvanceDemotion(landblockId)) + return true; + if (_physics.CaptureOwnership() + .PendingCollisionPrefixProjectionCount != 0) + { + return false; + } + } + return false; } - public void RemoveLandblock(uint landblockId) + internal bool AdvanceDemotion(uint landblockId) { - _physics.WithdrawCollision(landblockId); + RuntimeCollisionMutationResult result = + _physics.DemoteCollisionToTerrain(landblockId); + if (!result.Completed) + return false; + _demotionCount++; + return true; + } + + public bool RemoveLandblock(uint landblockId) + { + for (int poll = 0; poll < 2; poll++) + { + if (AdvanceRemoval(landblockId)) + return true; + if (_physics.CaptureOwnership() + .PendingCollisionPrefixProjectionCount != 0) + { + return false; + } + } + return false; + } + + internal bool AdvanceRemoval(uint landblockId) + { + RuntimeCollisionMutationResult result = + _physics.WithdrawCollision(landblockId); + if (!result.Completed) + return false; _fullRemovalCount++; + return true; } private void PublishCell( @@ -1066,7 +1174,8 @@ public sealed class LandblockPhysicsPublisher "The physics publication receipt belongs to another publisher.", nameof(publication)); } - if (!publication.CompletionCommitted) + if (!publication.CompletionCommitted + && !publication.EngineMutationCommitted) { ObjectDisposedException.ThrowIf( publication.PreparedGeneration.IsDisposed, diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs index 5f7c9fff..75802436 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -217,14 +217,34 @@ public sealed class LandblockPresentationPipeline /// /// Cancels retained publication receipts during a generation reset. A - /// collision receipt owns only its private staging world until activation, - /// so cancellation cannot withdraw or partially replace the active world. + /// pre-engine receipt restores its exact prior generation. A receipt that + /// already transferred the engine generation remains retained until its + /// canonical post-engine placement acknowledgement suffix completes; the + /// committed transfer is never rolled back. /// - internal void CancelPendingPublications() + internal bool CancelPendingPublications() { - foreach (PublicationTransaction transaction in _publications.Values) - transaction.PhysicsPublication?.Dispose(); - _publications.Clear(); + if (_publications.Count == 0) + return true; + LandblockStreamResult[] pending = [.. _publications.Keys]; + bool completed = true; + for (int index = 0; index < pending.Length; index++) + { + LandblockStreamResult result = pending[index]; + if (!_publications.TryGetValue( + result, + out PublicationTransaction? transaction)) + { + continue; + } + bool cancelled = transaction.PhysicsPublication is not { } physics + || physics.TryCancel(); + if (cancelled) + _publications.Remove(result); + else + completed = false; + } + return completed && _publications.Count == 0; } public void ResumePublication(LandblockStreamResult result) @@ -247,10 +267,15 @@ public sealed class LandblockPresentationPipeline return Advance(result, transaction, meter, ensureProgress); } - public void AdvanceRetirements() => _retirements.Advance(); + public void AdvanceRetirements() + { + _retirements.Advance(); + } - public void AdvanceRetirements(StreamingWorkMeter meter) => + public void AdvanceRetirements(StreamingWorkMeter meter) + { _retirements.Advance(meter); + } internal void AdvancePriorityRetirement( uint landblockId, @@ -261,14 +286,26 @@ public sealed class LandblockPresentationPipeline { _retirements.BeginFull(landblockId); if (_retirements.UsesBudgetedSteps) + { _retirements.Advance(); + if (_retirements.IsPending(landblockId) + && (_physicsPublisher?.CanContinueMutationSynchronously() + ?? true)) + _retirements.Advance(); + } } public void BeginNearLayerRetirement(uint landblockId) { _retirements.BeginNearLayer(landblockId); if (_retirements.UsesBudgetedSteps) + { _retirements.Advance(); + if (_retirements.IsPending(landblockId) + && (_physicsPublisher?.CanContinueMutationSynchronously() + ?? true)) + _retirements.Advance(); + } } internal void EnqueueFullRetirement(uint landblockId) => @@ -754,6 +791,17 @@ public sealed class LandblockPresentationPipeline { return new LandblockPublicationAdvance(false, progressed); } + if (transaction.PhysicsPublication.RuntimeMutationPending) + { + if (meter is not null + || !_physicsPublisher + .CanContinueMutationSynchronously()) + { + return new LandblockPublicationAdvance( + false, + progressed); + } + } } while (!transaction.StaticPublication.CompletionCommitted) { diff --git a/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs b/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs index f9e0e937..368565c7 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs @@ -73,22 +73,20 @@ public sealed class LandblockPresentationRetirementOwner static entity => entity.ServerGuid == 0, _staticPresentation.RemovePluginProjection); + if (!ticket.RunOnce( + LandblockRetirementStage.Physics, + () => ticket.Kind == LandblockRetirementKind.Full + ? _physics.AdvanceRemoval(ticket.LandblockId) + : _physics.AdvanceDemotion(ticket.LandblockId))) + { + return; + } if (ticket.Kind == LandblockRetirementKind.Full) { ticket.RunOnce( LandblockRetirementStage.Terrain, () => _render.RemoveTerrain(ticket.LandblockId)); } - - ticket.RunOnce( - LandblockRetirementStage.Physics, - () => - { - if (ticket.Kind == LandblockRetirementKind.Full) - _physics.RemoveLandblock(ticket.LandblockId); - else - _physics.DemoteToTerrain(ticket.LandblockId); - }); ticket.RunOnce( LandblockRetirementStage.CellVisibility, () => _render.RemoveCellVisibility(ticket.LandblockId)); @@ -128,13 +126,9 @@ public sealed class LandblockPresentationRetirementOwner LandblockRetirementStage.Physics => ticket.RunOnceStep( LandblockRetirementStage.Physics, - () => - { - if (ticket.Kind == LandblockRetirementKind.Full) - _physics.RemoveLandblock(ticket.LandblockId); - else - _physics.DemoteToTerrain(ticket.LandblockId); - }), + () => ticket.Kind == LandblockRetirementKind.Full + ? _physics.AdvanceRemoval(ticket.LandblockId) + : _physics.AdvanceDemotion(ticket.LandblockId)), LandblockRetirementStage.CellVisibility => ticket.RunOnceStep( LandblockRetirementStage.CellVisibility, diff --git a/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs b/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs index 82046fa1..5519758d 100644 --- a/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs +++ b/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs @@ -12,8 +12,8 @@ public enum LandblockRetirementStage : ushort EntityLighting = 1 << 3, EntityTranslucency = 1 << 4, PluginProjection = 1 << 5, - Terrain = 1 << 6, - Physics = 1 << 7, + Physics = 1 << 6, + Terrain = 1 << 7, CellVisibility = 1 << 8, BuildingRegistry = 1 << 9, EnvironmentCells = 1 << 10, @@ -24,6 +24,7 @@ internal enum LandblockRetirementOperationResult : byte { NoWork, Progressed, + Pending, Failed, } @@ -77,6 +78,28 @@ public sealed class LandblockRetirementTicket } } + public bool RunOnce(LandblockRetirementStage stage, Func operation) + { + ValidateSingleStage(stage); + ArgumentNullException.ThrowIfNull(operation); + if ((CompletedStages & stage) != 0) + return true; + + try + { + if (!operation()) + return false; + CompletedStages |= stage; + _failures.Remove(stage); + return true; + } + catch (Exception error) + { + _failures[stage] = error; + return false; + } + } + internal LandblockRetirementOperationResult RunOnceStep( LandblockRetirementStage stage, Action operation) @@ -100,6 +123,31 @@ public sealed class LandblockRetirementTicket } } + internal LandblockRetirementOperationResult RunOnceStep( + LandblockRetirementStage stage, + Func operation) + { + ValidateSingleStage(stage); + ArgumentNullException.ThrowIfNull(operation); + if ((CompletedStages & stage) != 0) + return LandblockRetirementOperationResult.NoWork; + + try + { + if (!operation()) + return LandblockRetirementOperationResult.Pending; + + CompletedStages |= stage; + _failures.Remove(stage); + return LandblockRetirementOperationResult.Progressed; + } + catch (Exception error) + { + _failures[stage] = error; + return LandblockRetirementOperationResult.Failed; + } + } + public bool RunForEachEntity( LandblockRetirementStage stage, Func predicate, @@ -815,6 +863,8 @@ public sealed class LandblockRetirementCoordinator } meter.Complete(); + if (result == LandblockRetirementOperationResult.Pending) + return BudgetedAdvanceResult.Yielded; return BudgetedAdvanceResult.Progressed; } diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 6f4c7912..b10e88e5 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -1362,19 +1362,23 @@ public sealed class StreamingController { if (!transaction.PendingPublicationsCleared) { + bool cancelled = false; if (!TryRunStreamingWork( meter, new StreamingWorkCost(EntityOperations: 1), "recenter-cancel-publications", () => { - _presentation.CancelPendingPublications(); - transaction.PendingPublicationsCleared = true; + cancelled = + _presentation.CancelPendingPublications(); return true; })) { return false; } + transaction.PendingPublicationsCleared = cancelled; + if (!cancelled) + return false; } if (!TryRunStreamingWork( meter, @@ -1519,19 +1523,23 @@ public sealed class StreamingController { if (!transaction.PendingPublicationsCleared) { + bool cancelled = false; if (!TryRunStreamingWork( meter, new StreamingWorkCost(EntityOperations: 1), "reload-cancel-publications", () => { - _presentation.CancelPendingPublications(); - transaction.PendingPublicationsCleared = true; + cancelled = + _presentation.CancelPendingPublications(); return true; })) { return false; } + transaction.PendingPublicationsCleared = cancelled; + if (!cancelled) + return false; } if (!TryRunStreamingWork( meter, diff --git a/src/AcDream.Core/AcDream.Core.csproj b/src/AcDream.Core/AcDream.Core.csproj index 8300307e..966b25e1 100644 --- a/src/AcDream.Core/AcDream.Core.csproj +++ b/src/AcDream.Core/AcDream.Core.csproj @@ -26,6 +26,12 @@ <_Parameter1>AcDream.Runtime.Tests + + <_Parameter1>AcDream.App.Tests + + + <_Parameter1>AcDream.Headless.Tests + diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 7af81615..69194998 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -1106,7 +1106,7 @@ public sealed class PhysicsEngine /// Register a landblock with its terrain surface, indoor cells, portal /// planes, and world-space origin offset. /// - public void AddLandblock(uint landblockId, TerrainSurface terrain, + internal void AddLandblock(uint landblockId, TerrainSurface terrain, IReadOnlyList cells, IReadOnlyList portals, float worldOffsetX, float worldOffsetY) { @@ -1120,7 +1120,7 @@ public sealed class PhysicsEngine /// /// Remove a previously registered landblock, including its shadow objects. /// - public void RemoveLandblock(uint landblockId) + internal void RemoveLandblock(uint landblockId) { _landblocks.Remove(landblockId); RemoveLandblockSlot(landblockId); @@ -1152,7 +1152,7 @@ public sealed class PhysicsEngine /// owned by this engine. Runtime calls this only at terminal disposal; /// ordinary streaming still uses the typed per-landblock retirement path. /// - public void Clear() + internal void Clear() { if (_landblocks.Count != 0) { @@ -1173,7 +1173,7 @@ public sealed class PhysicsEngine /// while preserving its terrain surface and world offset for Far-tier use. /// The corresponding render-side demotion preserves the terrain slot too. /// - public void DemoteLandblockToTerrain(uint landblockId) + internal void DemoteLandblockToTerrain(uint landblockId) { uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; if (_landblocks.TryGetValue(canonical, out var landblock)) diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs index c903e04a..407d7a62 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs @@ -18,9 +18,37 @@ internal interface IHeadlessCollisionNeighborhood bool IsReady(uint fullCellId); } -internal static class HeadlessCollisionGenerationTransaction +internal readonly record struct HeadlessCollisionGenerationAdvance( + bool Completed, + bool Progressed, + bool WaitingForProjectionAcknowledgement, + bool YieldToCaller); + +internal sealed class HeadlessCollisionGenerationTransaction { - internal static RuntimeCollisionGenerationCommit Execute( + private readonly RuntimePhysicsState _physics; + private readonly RuntimeCollisionAdmission _admission; + private readonly PreparedLandblockCollisionGeneration _prepared; + private bool _ownerCaptureCommitted; + private int _refreshCursor; + private bool _sealCommitted; + + private HeadlessCollisionGenerationTransaction( + RuntimePhysicsState physics, + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + _physics = physics; + _admission = admission; + _prepared = prepared; + } + + internal uint LandblockId => _admission.LandblockId; + internal bool EngineMutationCommitted { get; private set; } + internal bool CompletionCommitted { get; private set; } + internal bool CancellationRequested { get; private set; } + + internal static HeadlessCollisionGenerationTransaction Begin( RuntimePhysicsState physics, uint landblockId, Action? afterAdmission, @@ -33,60 +61,103 @@ internal static class HeadlessCollisionGenerationTransaction RuntimeCollisionAdmission admission = physics.BeginCollisionAdmission(landblockId); PreparedLandblockCollisionGeneration? prepared = null; - bool committed = false; try { - // This hook exists so the exact post-admission/pre-prepare failure - // boundary remains covered. Production does not install one. afterAdmission?.Invoke(admission); prepared = physics.PrepareCollisionGeneration(admission); stage(admission, prepared); - - RuntimeCollisionOwnerCaptureStep ownerCapture; - do - { - ownerCapture = physics.AdvanceCollisionRetainedOwnerCapture( - admission, - prepared); - } - while (!ownerCapture.Completed); - foreach (uint ownerId in prepared.RetainedOwnerIds) - { - physics.RefreshCollisionRetainedOwner( - admission, - prepared, - ownerId); - } - RuntimeCollisionSealStep seal; - do - { - seal = physics.AdvanceCollisionGenerationSeal( - admission, - prepared); - } - while (!seal.Completed && !seal.Restarted); - if (!seal.Completed) - { - throw new InvalidOperationException( - "Headless collision owner set changed during synchronous sealing."); - } - - RuntimeCollisionGenerationCommit result = - physics.CommitCollisionGeneration(admission, prepared); - if (!result.Committed) - { - throw new InvalidOperationException( - "Headless collision generation changed during synchronous publication."); - } - committed = true; - return result; + return new HeadlessCollisionGenerationTransaction( + physics, + admission, + prepared); } - finally + catch (Exception publicationError) { - if (!committed) - physics.CancelCollisionGeneration(admission, prepared); + if (!physics.CancelCollisionGeneration(admission, prepared)) + { + throw new AggregateException( + "Headless collision preparation failed and its pre-engine cancellation did not converge.", + publicationError); + } + throw; } } + + internal HeadlessCollisionGenerationAdvance Advance() + { + if (CancellationRequested) + throw new InvalidOperationException( + "A cancelled headless collision generation cannot resume."); + if (CompletionCommitted) + return new(true, false, false, false); + + if (!EngineMutationCommitted) + { + if (!_ownerCaptureCommitted) + { + RuntimeCollisionOwnerCaptureStep capture = + _physics.AdvanceCollisionRetainedOwnerCapture( + _admission, + _prepared); + _ownerCaptureCommitted = capture.Completed; + return new(false, true, false, false); + } + + if (_refreshCursor < _prepared.RetainedOwnerIds.Count) + { + _physics.RefreshCollisionRetainedOwner( + _admission, + _prepared, + _prepared.RetainedOwnerIds[_refreshCursor]); + _refreshCursor++; + return new(false, true, false, false); + } + + if (!_sealCommitted) + { + RuntimeCollisionSealStep seal = + _physics.AdvanceCollisionGenerationSeal( + _admission, + _prepared); + _sealCommitted = seal.Completed; + if (seal.Restarted) + { + _ownerCaptureCommitted = false; + _refreshCursor = 0; + } + return new(false, true, false, false); + } + } + + RuntimeCollisionGenerationCommit commit = + _physics.CommitCollisionGeneration(_admission, _prepared); + EngineMutationCommitted |= commit.EngineCommitted; + CompletionCommitted = commit.Completed; + bool waiting = !commit.Completed + && _physics.CaptureOwnership() + .PendingCollisionPrefixProjectionCount != 0; + if (!commit.Completed && !commit.EngineCommitted) + _sealCommitted = false; + return new( + commit.Completed, + Progressed: true, + WaitingForProjectionAcknowledgement: waiting, + YieldToCaller: !commit.Completed); + } + + internal bool TryCancel() + { + if (CompletionCommitted) + return true; + CancellationRequested = true; + bool completed = _physics.CancelCollisionGeneration( + _admission, + _prepared); + if (completed && EngineMutationCommitted) + CompletionCommitted = true; + return completed; + } + } /// @@ -97,10 +168,23 @@ internal static class HeadlessCollisionGenerationTransaction internal sealed class HeadlessCollisionNeighborhood : IHeadlessCollisionNeighborhood { + private readonly record struct PublicationSpec( + uint LandblockId, + Vector3 Origin, + bool Required); + private readonly GameRuntime _runtime; private readonly HeadlessProcessContentOwner .HeadlessProcessContentLease _content; private readonly HashSet _resident = []; + private readonly Queue _retirementQueue = []; + private readonly Queue _publicationQueue = []; + private HeadlessCollisionGenerationTransaction? _pendingPublication; + private bool _pendingPublicationCancellation; + private bool _resetRequired; + private bool _publicationPlanBuilt; + private uint _requestedCenterLandblock; + private uint _requestedFullCell; private uint _centerLandblock; internal HeadlessCollisionNeighborhood( @@ -122,6 +206,21 @@ internal sealed class HeadlessCollisionNeighborhood nameof(fullCellId), "A collision neighborhood requires a real destination cell."); } + if (_requestedCenterLandblock != center) + { + _requestedCenterLandblock = center; + _requestedFullCell = fullCellId; + _resetRequired = true; + _publicationPlanBuilt = false; + _publicationQueue.Clear(); + _pendingPublicationCancellation = + _pendingPublication is not null; + } + else + { + _requestedFullCell = fullCellId; + } + if (_centerLandblock == center && _resident.Contains(center) && _runtime.EntityObjects.Physics.Engine @@ -131,55 +230,17 @@ internal sealed class HeadlessCollisionNeighborhood .UpdatePlayerCurrCell(fullCellId); return; } - - RetireAll(); - int centerX = (int)((center >> 24) & 0xFFu); - int centerY = (int)((center >> 16) & 0xFFu); - try - { - PublishOne( - center, - Vector3.Zero, - fullCellId, - required: true); - for (int dx = -1; dx <= 1; dx++) - { - for (int dy = -1; dy <= 1; dy++) - { - if (dx == 0 && dy == 0) - continue; - int landblockX = centerX + dx; - int landblockY = centerY + dy; - if ((uint)landblockX > byte.MaxValue - || (uint)landblockY > byte.MaxValue) - { - continue; - } - uint landblockId = - ((uint)landblockX << 24) - | ((uint)landblockY << 16) - | 0xFFFFu; - PublishOne( - landblockId, - new Vector3(dx * 192f, dy * 192f, 0f), - fullCellId, - required: false); - } - } - _centerLandblock = center; - _runtime.EntityObjects.Physics.Engine - .UpdatePlayerCurrCell(fullCellId); - } - catch - { - RetireAll(); - throw; - } + AdvanceWork(); } public bool IsReady(uint fullCellId) { uint center = CanonicalLandblock(fullCellId); + if (_requestedCenterLandblock == center) + { + _requestedFullCell = fullCellId; + AdvanceWork(); + } if (_centerLandblock != center || !_resident.Contains(center) || !_runtime.EntityObjects.Physics.Engine @@ -192,7 +253,7 @@ internal sealed class HeadlessCollisionNeighborhood .GetCellStruct(fullCellId) is not null; } - private void PublishOne( + private HeadlessCollisionGenerationTransaction? CreatePublication( uint landblockId, Vector3 origin, uint currentCellId, @@ -207,7 +268,7 @@ internal sealed class HeadlessCollisionNeighborhood throw new InvalidDataException( $"Required headless landblock 0x{landblockId:X8} is missing."); } - return; + return null; } IReadOnlyList staticEntities = @@ -244,7 +305,7 @@ internal sealed class HeadlessCollisionNeighborhood landblock); RuntimePhysicsState physics = _runtime.EntityObjects.Physics; - _ = HeadlessCollisionGenerationTransaction.Execute( + return HeadlessCollisionGenerationTransaction.Begin( physics, landblockId, afterAdmission: null, @@ -294,22 +355,113 @@ internal sealed class HeadlessCollisionNeighborhood collisions, origin); }); - _resident.Add(CanonicalLandblock(landblockId)); } - private void RetireAll() + private void AdvanceWork() { - if (_resident.Count == 0) + if (_pendingPublicationCancellation + && _pendingPublication is { } cancelling) { - _centerLandblock = 0u; - return; + bool wasCommitted = cancelling.EngineMutationCommitted; + if (!cancelling.TryCancel()) + return; + if (wasCommitted || cancelling.EngineMutationCommitted) + _resident.Add(cancelling.LandblockId); + _pendingPublication = null; + _pendingPublicationCancellation = false; } - uint[] retiring = [.. _resident]; - _resident.Clear(); - _centerLandblock = 0u; - foreach (uint landblock in retiring) - _ = _runtime.EntityObjects.Physics.WithdrawCollision(landblock); + if (_resetRequired) + { + _retirementQueue.Clear(); + foreach (uint landblock in _resident) + _retirementQueue.Enqueue(landblock); + _centerLandblock = 0u; + _resetRequired = false; + } + + while (_retirementQueue.TryPeek(out uint retiring)) + { + RuntimeCollisionMutationResult result = _runtime.EntityObjects + .Physics.WithdrawCollision(retiring); + if (!result.Completed) + return; + _retirementQueue.Dequeue(); + _resident.Remove(retiring); + } + + if (!_publicationPlanBuilt) + { + BuildPublicationPlan( + _requestedCenterLandblock, + _publicationQueue); + _publicationPlanBuilt = true; + } + + while (true) + { + if (_pendingPublication is null) + { + if (!_publicationQueue.TryDequeue(out PublicationSpec spec)) + { + _centerLandblock = _requestedCenterLandblock; + _runtime.EntityObjects.Physics.Engine + .UpdatePlayerCurrCell(_requestedFullCell); + return; + } + _pendingPublication = CreatePublication( + spec.LandblockId, + spec.Origin, + _requestedFullCell, + spec.Required); + if (_pendingPublication is null) + continue; + } + + HeadlessCollisionGenerationAdvance advance = + _pendingPublication.Advance(); + if (advance.Completed) + { + _resident.Add(_pendingPublication.LandblockId); + _pendingPublication = null; + continue; + } + if (advance.WaitingForProjectionAcknowledgement) + return; + if (advance.YieldToCaller) + return; + if (!advance.Progressed) + throw new InvalidOperationException( + "Headless collision publication made no progress."); + } + } + + private static void BuildPublicationPlan( + uint center, + Queue destination) + { + destination.Enqueue(new PublicationSpec( + center, + Vector3.Zero, + Required: true)); + int centerX = (int)((center >> 24) & 0xFFu); + int centerY = (int)((center >> 16) & 0xFFu); + for (int dx = -1; dx <= 1; dx++) + { + for (int dy = -1; dy <= 1; dy++) + { + if (dx == 0 && dy == 0) + continue; + int x = centerX + dx; + int y = centerY + dy; + if ((uint)x > byte.MaxValue || (uint)y > byte.MaxValue) + continue; + destination.Enqueue(new PublicationSpec( + ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu, + new Vector3(dx * 192f, dy * 192f, 0f), + Required: false)); + } + } } private static uint CanonicalLandblock(uint fullCellId) => diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index 951c189f..3814f099 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -1053,8 +1053,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable _sessionClearInProgress = true; RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray(); Physics.CollisionReports.LeaveWorldBatch(active); - Physics.SetPosition.ResetSession(); - Physics.CollisionReports.ResetSession(); + Physics.ResetSessionPhysics(); Entities.BeginSessionClear(); foreach (RuntimeEntityRecord canonical in active) { diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs index b57ee1dd..8a631030 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs @@ -388,6 +388,14 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable return RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority; } + // Seal the exact SetPosition owner before the irreversible no-fail + // suffix. Any internal invariant failure therefore leaves every + // canonical body/controller owner untouched. + _physics.SetPosition.PrepareDormantLocalActivationOwnership( + candidate.Record, + candidate.Body, + candidate.PreparedActivation.Token.Placement); + // 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 diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index 7b3c2e06..86ab4deb 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -37,6 +37,8 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot( bool IsCollisionReportDispatching, int CollisionPrefixQuiescenceCount, int PendingCollisionPrefixProjectionCount, + int CollisionPrefixMutationCount, + int CommittedCollisionPrefixMutationCount, int CollisionAdmissionCount, int CollisionGenerationCount, bool OwnsProductionDataCache, @@ -75,6 +77,8 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot( && !IsCollisionReportDispatching && CollisionPrefixQuiescenceCount == 0 && PendingCollisionPrefixProjectionCount == 0 + && CollisionPrefixMutationCount == 0 + && CommittedCollisionPrefixMutationCount == 0 && CollisionAdmissionCount == 0 && CollisionGenerationCount == 0 && OwnsProductionDataCache; @@ -100,11 +104,13 @@ public sealed class RuntimeCollisionAdmission internal RuntimeCollisionAdmission( RuntimePhysicsState owner, uint landblockId, - ulong generation) + ulong generation, + ulong previousGeneration) { Owner = owner; LandblockId = landblockId; Generation = generation; + PreviousGeneration = previousGeneration; } internal RuntimePhysicsState Owner { get; } @@ -112,6 +118,32 @@ public sealed class RuntimeCollisionAdmission internal bool Completed { get; set; } public uint LandblockId { get; } public ulong Generation { get; } + internal ulong PreviousGeneration { get; } +} + +internal enum RuntimeCollisionPrefixMutationKind : byte +{ + Activation, + Demotion, + Withdrawal, +} + +internal sealed class RuntimeCollisionPrefixMutation +{ + internal required RuntimeCollisionPrefixMutationKind Kind { get; init; } + internal required uint LandblockId { get; init; } + internal required ulong PreviousGeneration { get; init; } + internal required ulong TargetGeneration { get; init; } + internal ulong InvalidatedGeneration { get; init; } + internal required RuntimeCollisionPrefixQuiescenceToken Quiescence + { get; init; } + internal RuntimeCollisionAdmission? Admission { get; init; } + internal PreparedLandblockCollisionGeneration? Prepared { get; init; } + internal RuntimeCollisionPrefixMutationPermission Permission { get; set; } + internal bool EngineMutationCommitted { get; set; } + internal bool CancellationRequested { get; set; } + internal bool WasResident { get; set; } + internal bool Ready { get; set; } } public readonly record struct RuntimeCollisionAcknowledgement( @@ -120,11 +152,23 @@ public readonly record struct RuntimeCollisionAcknowledgement( bool WasResident, bool Ready); +public readonly record struct RuntimeCollisionMutationResult( + RuntimeCollisionAcknowledgement Acknowledgement, + bool Completed) +{ + public uint LandblockId => Acknowledgement.LandblockId; + public ulong Generation => Acknowledgement.Generation; + public bool WasResident => Acknowledgement.WasResident; + public bool Ready => Acknowledgement.Ready; +} + public readonly record struct RuntimeCollisionGenerationCommit( RuntimeCollisionAcknowledgement Acknowledgement, - uint[] DirtyRetainedOwnerIds) + uint[] DirtyRetainedOwnerIds, + bool EngineCommitted, + bool Completed) { - public bool Committed => Acknowledgement.Ready; + public bool Committed => Completed; } public readonly record struct RuntimeCollisionGenerationCommitted( @@ -1045,6 +1089,8 @@ public sealed class RuntimePhysicsState : IDisposable _collisionAdmissions = new(); private readonly Dictionary _preparedCollisionGenerations = new(); + private readonly Dictionary + _collisionPrefixMutations = new(); private readonly CollisionOwnerMutationJournal _collisionOwnerJournal = new(); private readonly Dictionary> _collisionOwnerSubscribers = new(); @@ -1175,6 +1221,9 @@ public sealed class RuntimePhysicsState : IDisposable collisionReports.IsDispatching, setPosition.CollisionPrefixQuiescenceCount, setPosition.PendingQuiescenceProjectionCount, + _collisionPrefixMutations.Count, + _collisionPrefixMutations.Values.Count( + mutation => mutation.EngineMutationCommitted), _collisionAdmissions.Count, _collisionGenerations.Count, ReferenceEquals(Engine.DataCache, DataCache), @@ -1907,6 +1956,29 @@ public sealed class RuntimePhysicsState : IDisposable _spatialRoots.Clear(); } + internal void ResetSessionPhysics() + { + EnsureNotDisposed(); + // GameRuntime serializes reset after the host update loop has stopped. + // Reset may therefore run on the lifecycle/disposal thread rather than + // the retired generation's update thread. It clears every mutation + // owner before releasing affinity so the next generation can bind its + // own update thread without admitting concurrent mutation. + foreach ((_, PreparedLandblockCollisionGeneration prepared) in + _preparedCollisionGenerations) + { + prepared.Dispose(); + } + _preparedCollisionGenerations.Clear(); + _collisionPrefixMutations.Clear(); + _collisionAdmissions.Clear(); + SetPosition.ResetSession(); + CollisionReports.ResetSession(); + TrimCollisionOwnerJournal(); + AdvanceCollisionWorldAuthority(); + Volatile.Write(ref _collisionMutationThreadId, 0); + } + internal RuntimeCollisionPrefixQuiescenceToken BeginCollisionPrefixQuiescence( uint landblockId, @@ -1957,25 +2029,29 @@ public sealed class RuntimePhysicsState : IDisposable successorReady); } - internal bool CompleteCollisionPrefixQuiescence( - in RuntimeCollisionPrefixMutationPermission permission) - { - EnsureNotDisposed(); - EnsureCollisionMutationThread(); - return SetPosition.CompleteCollisionPrefixQuiescence(permission); - } - public RuntimeCollisionAdmission BeginCollisionAdmission( uint landblockId) { EnsureNotDisposed(); EnsureCollisionMutationThread(); uint canonical = CanonicalLandblock(landblockId); + if (_collisionPrefixMutations.ContainsKey(canonical)) + { + throw new InvalidOperationException( + $"Collision prefix 0x{canonical:X8} is still completing its previous mutation transaction."); + } + ulong currentGeneration = _collisionGenerations.TryGetValue( + canonical, + out ulong current) + ? current + : 0UL; + ulong previousGeneration = currentGeneration; AdvanceCollisionWorldAuthority(); if (_collisionAdmissions.Remove( canonical, out RuntimeCollisionAdmission? superseded)) { + previousGeneration = superseded.PreviousGeneration; SetPosition.CancelCollisionGeneration( canonical, superseded.Generation); @@ -1986,16 +2062,13 @@ public sealed class RuntimePhysicsState : IDisposable prepared.Dispose(); } } - ulong generation = _collisionGenerations.TryGetValue( - canonical, - out ulong current) - ? checked(current + 1UL) - : 1UL; + ulong generation = checked(currentGeneration + 1UL); _collisionGenerations[canonical] = generation; var admission = new RuntimeCollisionAdmission( this, canonical, - generation); + generation, + previousGeneration); _collisionAdmissions[canonical] = admission; SetPosition.BeginCollisionGeneration(canonical, generation); return admission; @@ -2047,7 +2120,7 @@ public sealed class RuntimePhysicsState : IDisposable /// collision world is never withdrawn. A stale receipt may dispose its /// own staging storage but cannot invalidate a newer admission. /// - internal void CancelCollisionGeneration( + internal bool CancelCollisionGeneration( RuntimeCollisionAdmission admission, PreparedLandblockCollisionGeneration? prepared = null) { @@ -2067,6 +2140,38 @@ public sealed class RuntimePhysicsState : IDisposable nameof(prepared)); } + if (_collisionPrefixMutations.TryGetValue( + admission.LandblockId, + out RuntimeCollisionPrefixMutation? mutation)) + { + if (mutation.Kind is not RuntimeCollisionPrefixMutationKind.Activation + || !ReferenceEquals(mutation.Admission, admission) + || (prepared is not null + && !ReferenceEquals(mutation.Prepared, prepared))) + { + return false; + } + if (mutation.EngineMutationCommitted) + return AdvanceCommittedActivation(mutation).Committed; + + mutation.CancellationRequested = true; + bool previousReady = mutation.PreviousGeneration != 0UL + && Engine.IsLandblockTerrainResident( + mutation.LandblockId); + bool released = mutation.PreviousGeneration == 0UL + ? SetPosition.CancelCollisionPrefixQuiescenceToUnavailable( + mutation.Quiescence) + : CancelCollisionPrefixQuiescence( + mutation.Quiescence, + mutation.PreviousGeneration, + previousReady); + if (!released) + { + return false; + } + _collisionPrefixMutations.Remove(mutation.LandblockId); + } + prepared?.Dispose(); if (prepared is not null && _preparedCollisionGenerations.TryGetValue( @@ -2090,6 +2195,7 @@ public sealed class RuntimePhysicsState : IDisposable AdvanceCollisionWorldAuthority(); } TrimCollisionOwnerJournal(); + return true; } internal void StageCollisionAssets( @@ -2261,8 +2367,29 @@ public sealed class RuntimePhysicsState : IDisposable RuntimeCollisionAdmission admission, PreparedLandblockCollisionGeneration prepared) { - ValidateAdmission(admission); + EnsureNotDisposed(); EnsureCollisionMutationThread(); + ArgumentNullException.ThrowIfNull(admission); + ArgumentNullException.ThrowIfNull(prepared); + + if (_collisionPrefixMutations.TryGetValue( + admission.LandblockId, + out RuntimeCollisionPrefixMutation? pending)) + { + if (pending.Kind is not RuntimeCollisionPrefixMutationKind.Activation + || !ReferenceEquals(pending.Admission, admission) + || !ReferenceEquals(pending.Prepared, prepared)) + { + throw new InvalidOperationException( + "A different collision-prefix mutation owns this landblock."); + } + if (pending.EngineMutationCommitted) + return AdvanceCommittedActivation(pending); + if (pending.CancellationRequested) + return PendingActivation(pending); + } + + ValidateAdmission(admission); ValidatePreparedGeneration(admission, prepared); if (!admission.AssetsPrepared) { @@ -2292,8 +2419,62 @@ public sealed class RuntimePhysicsState : IDisposable admission.Generation, Engine.IsLandblockTerrainResident(admission.LandblockId), Ready: false), - Array.Empty()); + Array.Empty(), + EngineCommitted: false, + Completed: false); } + + RuntimeCollisionPrefixMutation mutation; + if (!_collisionPrefixMutations.TryGetValue( + admission.LandblockId, + out mutation!)) + { + RuntimeCollisionPrefixQuiescenceToken token = + BeginCollisionPrefixQuiescence( + admission.LandblockId, + admission.Generation, + includeOutdoorCells: true); + mutation = new RuntimeCollisionPrefixMutation + { + Kind = RuntimeCollisionPrefixMutationKind.Activation, + LandblockId = admission.LandblockId, + PreviousGeneration = admission.PreviousGeneration, + TargetGeneration = admission.Generation, + Quiescence = token, + Admission = admission, + Prepared = prepared, + WasResident = Engine.IsLandblockTerrainResident( + admission.LandblockId), + }; + _collisionPrefixMutations.Add(admission.LandblockId, mutation); + + // The first poll deliberately closes the prefix and marks its + // resident-parking pass complete, even when the prefix is empty. + // A later poll alone may consume mutation permission; affected + // residents also require their exact Withdraw acknowledgements. + } + + if (!TryAcquireCollisionPrefixMutationPermission( + mutation.Quiescence, + out RuntimeCollisionPrefixMutationPermission permission)) + { + return PendingActivation(mutation); + } + mutation.Permission = permission; + + // Parking a live owner writes to the canonical shadow journal. The + // prepared replacement must be resealed against that exact journal + // tail before permission can be consumed. + if (!IsCollisionPrefixMutationPermissionCurrent(permission) + || !prepared.IsOwnerMutationReconciliationCurrent + || !prepared.IsReadyForActivation + || HasOlderPreparedGeneration(prepared) + || prepared.HasPendingCommittedRebase + || prepared.HasPendingRetirement) + { + return PendingActivation(mutation); + } + PhysicsEngine.PreparedPhysicsEngineLandblock replacement = prepared.TakeSealedReplacement(); bool suppressOwnerJournal = _suppressCollisionOwnerJournal; @@ -2313,20 +2494,70 @@ public sealed class RuntimePhysicsState : IDisposable if (later.Sequence > prepared.Sequence) later.EnqueueCommittedRebase(replacement); } - admission.Completed = true; - _collisionAdmissions.Remove(admission.LandblockId); _preparedCollisionGenerations.Remove(admission.LandblockId); prepared.MarkCommitted(); - TrimCollisionOwnerJournal(); - var acknowledgement = new RuntimeCollisionAcknowledgement( - admission.LandblockId, - admission.Generation, - Engine.IsLandblockTerrainResident(admission.LandblockId), - Ready: Engine.IsLandblockTerrainResident(admission.LandblockId)); + mutation.EngineMutationCommitted = true; + mutation.Ready = Engine.IsLandblockTerrainResident( + admission.LandblockId); + mutation.WasResident = mutation.Ready; SetPosition.CommitCollisionGeneration( - acknowledgement.LandblockId, - acknowledgement.Generation, - acknowledgement.Ready); + mutation.LandblockId, + mutation.TargetGeneration, + mutation.Ready); + RuntimeCollisionGenerationCommit completed = + AdvanceCommittedActivation(mutation); + return completed; + } + + private RuntimeCollisionGenerationCommit PendingActivation( + RuntimeCollisionPrefixMutation mutation) => new( + new RuntimeCollisionAcknowledgement( + mutation.LandblockId, + mutation.TargetGeneration, + mutation.WasResident, + Ready: mutation.EngineMutationCommitted && mutation.Ready), + Array.Empty(), + EngineCommitted: mutation.EngineMutationCommitted, + Completed: false); + + private RuntimeCollisionGenerationCommit AdvanceCommittedActivation( + RuntimeCollisionPrefixMutation mutation) + { + if (!mutation.EngineMutationCommitted) + throw new InvalidOperationException( + "Collision activation cannot release before its engine transaction commits."); + + bool completed = SetPosition.ReleaseCollisionPrefixAfterMutation( + mutation.Quiescence, + mutation.TargetGeneration, + mutation.Ready); + var acknowledgement = new RuntimeCollisionAcknowledgement( + mutation.LandblockId, + mutation.TargetGeneration, + mutation.WasResident, + mutation.Ready); + if (!completed) + { + return new RuntimeCollisionGenerationCommit( + acknowledgement, + Array.Empty(), + EngineCommitted: true, + Completed: false); + } + + RuntimeCollisionAdmission admission = mutation.Admission + ?? throw new InvalidOperationException( + "Collision activation lost its admission owner."); + admission.Completed = true; + if (_collisionAdmissions.TryGetValue( + mutation.LandblockId, + out RuntimeCollisionAdmission? current) + && ReferenceEquals(current, admission)) + { + _collisionAdmissions.Remove(mutation.LandblockId); + } + _collisionPrefixMutations.Remove(mutation.LandblockId); + TrimCollisionOwnerJournal(); PublishCollisionGenerationCommitted( new RuntimeCollisionGenerationCommitted( acknowledgement.LandblockId, @@ -2334,67 +2565,179 @@ public sealed class RuntimePhysicsState : IDisposable acknowledgement.Ready)); return new RuntimeCollisionGenerationCommit( acknowledgement, - Array.Empty()); + Array.Empty(), + EngineCommitted: true, + Completed: true); } - public RuntimeCollisionAcknowledgement DemoteCollisionToTerrain( + private static RuntimeCollisionMutationResult PendingRetirement( + RuntimeCollisionPrefixMutation mutation) => new( + new RuntimeCollisionAcknowledgement( + mutation.LandblockId, + mutation.TargetGeneration, + mutation.WasResident, + Ready: mutation.EngineMutationCommitted && mutation.Ready), + Completed: false); + + private void CommitCollisionInvalidation( + RuntimeCollisionPrefixMutation mutation) + { + _collisionGenerations[mutation.LandblockId] = + mutation.TargetGeneration; + SetPosition.CancelCollisionGeneration( + mutation.LandblockId, + mutation.InvalidatedGeneration); + if (_collisionAdmissions.TryGetValue( + mutation.LandblockId, + out RuntimeCollisionAdmission? currentAdmission) + && ReferenceEquals(currentAdmission, mutation.Admission)) + { + _collisionAdmissions.Remove(mutation.LandblockId); + } + if (_preparedCollisionGenerations.TryGetValue( + mutation.LandblockId, + out PreparedLandblockCollisionGeneration? currentPrepared) + && ReferenceEquals(currentPrepared, mutation.Prepared)) + { + _preparedCollisionGenerations.Remove(mutation.LandblockId); + currentPrepared.Dispose(); + } + } + + public RuntimeCollisionMutationResult DemoteCollisionToTerrain( uint landblockId) + => AdvanceCollisionRetirementMutation( + landblockId, + RuntimeCollisionPrefixMutationKind.Demotion); + + public RuntimeCollisionMutationResult WithdrawCollision( + uint landblockId) + => AdvanceCollisionRetirementMutation( + landblockId, + RuntimeCollisionPrefixMutationKind.Withdrawal); + + private RuntimeCollisionMutationResult AdvanceCollisionRetirementMutation( + uint landblockId, + RuntimeCollisionPrefixMutationKind kind) { EnsureNotDisposed(); EnsureCollisionMutationThread(); uint canonical = CanonicalLandblock(landblockId); - bool resident = Engine.IsLandblockTerrainResident(canonical); - InvalidateCollisionAdmission(canonical); - bool suppressOwnerJournal = _suppressCollisionOwnerJournal; - _suppressCollisionOwnerJournal = true; - try - { - Engine.DemoteLandblockToTerrain(canonical); - } - finally - { - _suppressCollisionOwnerJournal = suppressOwnerJournal; - } - foreach ((_, PreparedLandblockCollisionGeneration prepared) in - _preparedCollisionGenerations) - { - prepared.RecordDemotion(canonical); - } - return new RuntimeCollisionAcknowledgement( - canonical, - _collisionGenerations[canonical], - resident, - Ready: Engine.IsLandblockTerrainResident(canonical)); - } + if (canonical == 0u) + throw new ArgumentOutOfRangeException(nameof(landblockId)); + if (kind is RuntimeCollisionPrefixMutationKind.Activation) + throw new ArgumentOutOfRangeException(nameof(kind)); - public RuntimeCollisionAcknowledgement WithdrawCollision( - uint landblockId) - { - EnsureNotDisposed(); - EnsureCollisionMutationThread(); - uint canonical = CanonicalLandblock(landblockId); - bool resident = Engine.IsLandblockTerrainResident(canonical); - InvalidateCollisionAdmission(canonical); - bool suppressOwnerJournal = _suppressCollisionOwnerJournal; - _suppressCollisionOwnerJournal = true; - try + if (!_collisionPrefixMutations.TryGetValue( + canonical, + out RuntimeCollisionPrefixMutation? mutation)) { - Engine.RemoveLandblock(canonical); + ulong currentGeneration = _collisionGenerations.TryGetValue( + canonical, + out ulong current) + ? current + : 0UL; + RuntimeCollisionAdmission? admission = + _collisionAdmissions.GetValueOrDefault(canonical); + ulong previousGeneration = admission?.PreviousGeneration + ?? currentGeneration; + ulong invalidatedGeneration = admission is not null + ? admission.Generation + : checked(currentGeneration + 1UL); + ulong targetGeneration = checked( + Math.Max(currentGeneration, invalidatedGeneration) + 1UL); + SetPosition.BeginCollisionGeneration( + canonical, + targetGeneration); + RuntimeCollisionPrefixQuiescenceToken token = + BeginCollisionPrefixQuiescence( + canonical, + targetGeneration, + includeOutdoorCells: + kind is RuntimeCollisionPrefixMutationKind.Withdrawal); + mutation = new RuntimeCollisionPrefixMutation + { + Kind = kind, + LandblockId = canonical, + PreviousGeneration = previousGeneration, + InvalidatedGeneration = invalidatedGeneration, + TargetGeneration = targetGeneration, + Quiescence = token, + Admission = admission, + Prepared = _preparedCollisionGenerations.GetValueOrDefault( + canonical), + WasResident = Engine.IsLandblockTerrainResident(canonical), + }; + _collisionPrefixMutations.Add(canonical, mutation); } - finally + if (mutation.Kind != kind) { - _suppressCollisionOwnerJournal = suppressOwnerJournal; + throw new InvalidOperationException( + "A different collision-prefix mutation owns this landblock."); } - foreach ((_, PreparedLandblockCollisionGeneration prepared) in - _preparedCollisionGenerations) + + if (!mutation.EngineMutationCommitted) { - prepared.RecordWithdrawal(canonical); + if (!TryAcquireCollisionPrefixMutationPermission( + mutation.Quiescence, + out RuntimeCollisionPrefixMutationPermission permission) + || !IsCollisionPrefixMutationPermissionCurrent(permission)) + { + return PendingRetirement(mutation); + } + mutation.Permission = permission; + CommitCollisionInvalidation(mutation); + + bool suppressOwnerJournal = _suppressCollisionOwnerJournal; + _suppressCollisionOwnerJournal = true; + try + { + if (kind is RuntimeCollisionPrefixMutationKind.Demotion) + Engine.DemoteLandblockToTerrain(canonical); + else + Engine.RemoveLandblock(canonical); + AdvanceCollisionWorldAuthority(); + } + finally + { + _suppressCollisionOwnerJournal = suppressOwnerJournal; + } + foreach ((_, PreparedLandblockCollisionGeneration prepared) in + _preparedCollisionGenerations) + { + if (kind is RuntimeCollisionPrefixMutationKind.Demotion) + prepared.RecordDemotion(canonical); + else + prepared.RecordWithdrawal(canonical); + } + mutation.EngineMutationCommitted = true; + mutation.Ready = kind is RuntimeCollisionPrefixMutationKind.Demotion + && Engine.IsLandblockTerrainResident(canonical); + if (mutation.Ready) + { + SetPosition.CommitCollisionGeneration( + canonical, + mutation.TargetGeneration, + ready: true); + } } - return new RuntimeCollisionAcknowledgement( - canonical, - _collisionGenerations[canonical], - resident, - Ready: false); + + bool completed = SetPosition.ReleaseCollisionPrefixAfterMutation( + mutation.Quiescence, + mutation.TargetGeneration, + mutation.Ready); + if (completed) + { + _collisionPrefixMutations.Remove(canonical); + TrimCollisionOwnerJournal(); + } + return new RuntimeCollisionMutationResult( + new RuntimeCollisionAcknowledgement( + canonical, + mutation.TargetGeneration, + mutation.WasResident, + mutation.Ready), + completed); } public void Dispose() @@ -2419,6 +2762,7 @@ public sealed class RuntimePhysicsState : IDisposable _spatialRemotes.Clear(); _spatialProjectiles.Clear(); _spatialRoots.Clear(); + _collisionPrefixMutations.Clear(); _collisionAdmissions.Clear(); _collisionGenerations.Clear(); CellCommitted = null; @@ -2570,35 +2914,6 @@ public sealed class RuntimePhysicsState : IDisposable } } - private void InvalidateCollisionAdmission(uint landblockId) - { - AdvanceCollisionWorldAuthority(); - ulong currentGeneration = _collisionGenerations.TryGetValue( - landblockId, - out ulong current) - ? current - : 0UL; - ulong invalidatedGeneration = _collisionAdmissions.TryGetValue( - landblockId, - out RuntimeCollisionAdmission? admission) - ? admission.Generation - : checked(currentGeneration + 1UL); - ulong generation = checked( - Math.Max(currentGeneration, invalidatedGeneration) + 1UL); - _collisionGenerations[landblockId] = generation; - SetPosition.CancelCollisionGeneration( - landblockId, - invalidatedGeneration); - _collisionAdmissions.Remove(landblockId); - if (_preparedCollisionGenerations.Remove( - landblockId, - out PreparedLandblockCollisionGeneration? prepared)) - { - prepared.Dispose(); - } - TrimCollisionOwnerJournal(); - } - internal bool TryPrepareSpatialRootAdmission(RuntimeEntityRecord record) { EnsureNotDisposed(); @@ -2960,7 +3275,13 @@ public sealed class RuntimePhysicsState : IDisposable { return; } - subscribers.Remove(prepared); + for (int index = 0; index < subscribers.Count; index++) + { + if (!ReferenceEquals(subscribers[index], prepared)) + continue; + subscribers.RemoveAt(index); + break; + } if (subscribers.Count == 0) _collisionOwnerSubscribers.Remove(ownerId); } diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index f651eb92..b817d45d 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -106,8 +106,7 @@ internal readonly record struct RuntimeCollisionPrefixMutationPermission( RuntimeCollisionPrefixQuiescenceToken Quiescence, ImmutableArray Withdrawals) { - internal bool IsValid => Quiescence.IsValid - && !Withdrawals.IsDefault; + internal bool IsValid => Quiescence.IsValid; } internal readonly record struct RuntimeCollisionEvaluationAuthority( @@ -373,8 +372,9 @@ internal sealed class RuntimeSetPositionState : IDisposable { get; } = []; internal bool ResidentsParked { get; set; } internal bool PermissionIssued { get; set; } - internal bool AbortReleaseInProgress { get; set; } - internal ulong AbortRestoreGeneration { get; set; } + internal bool ReleaseInProgress { get; set; } + internal ulong ReleaseGeneration { get; set; } + internal bool ReleaseGenerationReady { get; set; } } private sealed class ContactCommitGuard( @@ -504,10 +504,10 @@ internal sealed class RuntimeSetPositionState : IDisposable if (_collisionPrefixQuiescence.TryGetValue( prefix, out CollisionPrefixQuiescence? active) - && active.AbortReleaseInProgress) + && active.ReleaseInProgress) { throw new InvalidOperationException( - $"Collision quiescence 0x{prefix:X8}/{active.Token.OperationId} is restoring its retained generation."); + $"Collision quiescence 0x{prefix:X8}/{active.Token.OperationId} is releasing its retained residents."); } _collisionPrefixQuiescence.Remove( prefix, @@ -592,7 +592,9 @@ internal sealed class RuntimeSetPositionState : IDisposable current.PermissionIssued = true; permission = new RuntimeCollisionPrefixMutationPermission( current.Token, - current.RetainedWithdrawals.ToImmutableArray()); + current.RetainedWithdrawals.Count == 0 + ? default + : current.RetainedWithdrawals.ToImmutableArray()); return true; } @@ -635,39 +637,93 @@ internal sealed class RuntimeSetPositionState : IDisposable return removedBeforePark; } - if (successorGeneration == 0UL - || !successorReady - || current.PendingWithdrawals.Count != 0) + if (successorGeneration == 0UL) + return false; + + return AdvanceCollisionPrefixRelease( + token, + successorGeneration, + successorReady, + requireMutationPermission: false); + } + + internal bool CancelCollisionPrefixQuiescenceToUnavailable( + in RuntimeCollisionPrefixQuiescenceToken token) + { + EnsureNotDisposed(); + return AdvanceCollisionPrefixRelease( + token, + generation: 0UL, + ready: false, + requireMutationPermission: false); + } + + internal bool ReleaseCollisionPrefixAfterMutation( + in RuntimeCollisionPrefixQuiescenceToken token, + ulong activeGeneration, + bool ready) + { + EnsureNotDisposed(); + return AdvanceCollisionPrefixRelease( + token, + activeGeneration, + ready, + requireMutationPermission: true); + } + + private bool AdvanceCollisionPrefixRelease( + in RuntimeCollisionPrefixQuiescenceToken token, + ulong generation, + bool ready, + bool requireMutationPermission) + { + if ((generation == 0UL && ready) + || !TryGetCurrentQuiescence( + token, + out CollisionPrefixQuiescence? state)) { return false; } - if (!current.AbortReleaseInProgress) + CollisionPrefixQuiescence current = state!; + if (current.PendingWithdrawals.Count != 0) + return false; + if (requireMutationPermission + && !current.PermissionIssued + && !current.ReleaseInProgress) { - current.AbortReleaseInProgress = true; - current.AbortRestoreGeneration = successorGeneration; - current.PermissionIssued = false; - RebindQuiescedDeferredOperations( - token, - successorGeneration, - ready: true); + return false; } - else if (current.AbortRestoreGeneration != successorGeneration) + if (!current.ReleaseInProgress) + { + current.ReleaseInProgress = true; + current.ReleaseGeneration = generation; + current.ReleaseGenerationReady = ready; + current.PermissionIssued = false; + } + else if (current.ReleaseGeneration != generation + || current.ReleaseGenerationReady != ready) { return false; } // A re-entrant/network placement may have joined the still-closed - // prefix after abort release started. Transfer every exact newcomer - // on each poll before deciding the barrier can open. - RebindQuiescedDeferredOperations( - token, - successorGeneration, - ready: true); + // prefix after release started. Transfer every exact newcomer on each + // poll before deciding the barrier can open. + if (_operations.Count != 0) + { + RebindQuiescedDeferredOperations( + token, + ready ? generation : 0UL, + ready, + releaseUnavailable: !ready); + } if (current.PendingRestorePlacements.Count != 0 || HasQuiescedDeferredOperations(token.LandblockPrefix)) + { return false; + } bool removed = _collisionPrefixQuiescence.Remove( token.LandblockPrefix); if (removed) @@ -675,19 +731,6 @@ internal sealed class RuntimeSetPositionState : IDisposable return removed; } - internal bool CompleteCollisionPrefixQuiescence( - in RuntimeCollisionPrefixMutationPermission permission) - { - EnsureNotDisposed(); - if (!IsCollisionPrefixMutationPermissionCurrent(permission)) - return false; - bool removed = _collisionPrefixQuiescence.Remove( - permission.Quiescence.LandblockPrefix); - if (removed) - _physics.AdvanceCollisionQuiescenceAuthority(); - return removed; - } - internal void BindEventStream(RuntimeEntityObjectEventStream events) { EnsureNotDisposed(); @@ -770,6 +813,34 @@ internal sealed class RuntimeSetPositionState : IDisposable portal, captureMoverPreparationAuthority: true); + internal void PrepareDormantLocalActivationOwnership( + RuntimeEntityRecord record, + PhysicsBody body, + in RuntimeEntityPlacementToken token) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(body); + if (!token.IsValid + || record.Key != token.Entity + || !_operations.TryGetValue(token.Entity, out Operation? operation) + || operation.Token != token + || operation.Stage is not RuntimeEntityPlacementStage + .AwaitingPreparation + || !ReferenceEquals(operation.Record, record) + || record.PhysicsBody is not null + || !IsCurrent(operation) + || body.InWorld + || (body.TransientState & TransientStateFlags.Active) != 0) + { + throw new InvalidOperationException( + "Dormant local activation must bind to the exact current placement owner."); + } + + operation.Body = body; + operation.DormantLocalActivation = true; + } + private RuntimeEntityPlacementToken BeginAcceptedPlacementCore( RuntimeEntityRecord record, ulong expectedPositionAuthorityVersion, @@ -2531,6 +2602,8 @@ internal sealed class RuntimeSetPositionState : IDisposable uint prefix, bool includeOutdoorCells) { + if (_physics.SpatialRootCount == 0) + return false; var roots = new List(); _physics.CopySpatialRootsTo(roots); for (int index = 0; index < roots.Count; index++) @@ -2586,7 +2659,7 @@ internal sealed class RuntimeSetPositionState : IDisposable uint prefix = state.Token.LandblockPrefix; foreach (Operation operation in _operations.Values) { - if (operation.WakeableLostCell) + if (operation.WakeableLostCell || operation.DormantLocalActivation) continue; if (PlacementTouchesPrefix(operation.Command.Physics, prefix) || ResultTouchesPrefix(operation.Result, prefix) @@ -2703,7 +2776,8 @@ internal sealed class RuntimeSetPositionState : IDisposable || !_collisionPrefixQuiescence.TryGetValue( operation.CollisionPrefix, out CollisionPrefixQuiescence? state) - || !state.AbortReleaseInProgress) + || !state.ReleaseInProgress + || !state.ReleaseGenerationReady) { return; } @@ -2745,21 +2819,40 @@ internal sealed class RuntimeSetPositionState : IDisposable private void RebindQuiescedDeferredOperations( in RuntimeCollisionPrefixQuiescenceToken token, ulong successorGeneration, - bool ready) + bool ready, + bool releaseUnavailable = false) { foreach (Operation operation in _operations.Values.ToArray()) { + bool unavailableAfterReadyCommit = ready + && operation.CollisionQuiescenceHeld + && operation.CollisionGeneration == 0UL + && operation.CollisionPrefix == token.LandblockPrefix; if (!operation.WakeableLostCell - || operation.CollisionGeneration != token.CollisionGeneration - || operation.CollisionPrefix != token.LandblockPrefix) + || !operation.CollisionQuiescenceHeld + || operation.CollisionPrefix != token.LandblockPrefix + || (operation.CollisionGeneration + != token.CollisionGeneration + && !unavailableAfterReadyCommit)) { continue; } UnindexDeferred(operation); - operation.CollisionGeneration = successorGeneration; + ulong reboundGeneration = releaseUnavailable + || unavailableAfterReadyCommit + ? 0UL + : successorGeneration; + operation.CollisionGeneration = reboundGeneration; operation.CollisionGenerationReady = ready - && successorGeneration != 0UL; - if (successorGeneration != 0UL) + && reboundGeneration != 0UL; + if (releaseUnavailable || unavailableAfterReadyCommit) + { + operation.CollisionQuiescenceHeld = false; + operation.Stage = operation.RequiresPreparation + ? RuntimeEntityPlacementStage.AwaitingPreparation + : RuntimeEntityPlacementStage.AwaitingCell; + } + if (reboundGeneration != 0UL) IndexDeferred(operation); else IndexUnboundDeferred(operation); @@ -3073,26 +3166,27 @@ internal sealed class RuntimeSetPositionState : IDisposable operation.Command.Physics, out CollisionPrefixQuiescence? blocking)) { - if (blocking!.AbortReleaseInProgress + if (blocking!.ReleaseInProgress + && blocking.ReleaseGenerationReady && operation.CollisionQuiescenceHeld && operation.CollisionPrefix == blocking.Token.LandblockPrefix) { - // The old collision generation remains active. Keep the - // admission barrier closed to new commands while this exact - // parked operation restores and its Place receipt drains. + // The selected collision generation is active. Keep the + // admission barrier closed while this exact parked operation + // restores and its Place receipt drains. restoringQuiescence = blocking.Token; } else { - UnindexDeferred(operation); - operation.CollisionPrefix = blocking.Token.LandblockPrefix; - operation.CollisionGeneration = blocking.Token.CollisionGeneration; - operation.CollisionGenerationReady = false; - operation.CollisionQuiescenceHeld = true; - operation.Stage = RuntimeEntityPlacementStage.QuiescenceHeld; - IndexDeferred(operation); - return; + UnindexDeferred(operation); + operation.CollisionPrefix = blocking.Token.LandblockPrefix; + operation.CollisionGeneration = blocking.Token.CollisionGeneration; + operation.CollisionGenerationReady = false; + operation.CollisionQuiescenceHeld = true; + operation.Stage = RuntimeEntityPlacementStage.QuiescenceHeld; + IndexDeferred(operation); + return; } } diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs index a5f9106d..a3287ea4 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs @@ -2,9 +2,13 @@ using System.Collections.Immutable; using System.Numerics; using System.Reflection; using AcDream.App.Streaming; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.World; using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; @@ -19,6 +23,91 @@ public sealed class LandblockPhysicsPublisherTests private static readonly float[] HeightTable = Enumerable.Range(0, 256).Select(index => (float)index).ToArray(); + [Fact] + public void ReplacementYieldsAtHeldWithdrawAndPlaceWithoutPostEngineReseal() + { + using var lifetime = new RuntimeEntityObjectLifetime( + new PhysicsDataCache()); + RuntimePhysicsState physics = lifetime.Physics; + var publisher = new LandblockPhysicsPublisher(physics, HeightTable); + Publish(publisher, Build(FirstLandblock)); + + const uint guid = 0x70004101u; + const uint cell = 0xA9B40001u; + Vector3 position = new(10f, 10f, 0f); + RuntimeEntityRecord record = lifetime.RegisterEntity( + RuntimeSpawn(guid, cell, position)).Canonical!; + lifetime.Entities.SetFinalPhysicsState(record, PhysicsStateFlags.Gravity); + lifetime.Entities.SetFullCell(record, cell, FirstLandblock); + var body = new PhysicsBody + { + Position = position, + Orientation = Quaternion.Identity, + LastUpdateTime = 1d, + State = PhysicsStateFlags.Gravity, + TransientState = TransientStateFlags.Active, + }; + body.SnapToCell(cell, position, position); + lifetime.Entities.SetPhysicsBody(record, body); + record.ObjectClock.Activate(); + physics.AcknowledgeSpatialProjection(record, spatial: true); + RuntimePlacementProjectionToken seeded = SeedRuntimePlacement( + physics, + record, + cell, + position); + Assert.True(physics.SetPosition.AcknowledgeProjection(seeded)); + + LandblockPhysicsPublication receipt = Begin( + publisher, + Build(FirstLandblock)); + Assert.False(publisher.CompletePublication(receipt)); + Assert.False(receipt.EngineMutationCommitted); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.Equal(RuntimePlacementProjectionKind.Withdraw, withdrawal.Kind); + + Assert.True(physics.SetPosition.AcknowledgeProjection(withdrawal.Token)); + Assert.False(publisher.CompletePublication(receipt)); + Assert.True(receipt.EngineMutationCommitted); + Assert.True(receipt.SealCommitted); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot placement)); + Assert.Equal(RuntimePlacementProjectionKind.Place, placement.Kind); + + Assert.False(publisher.CompletePublication(receipt)); + Assert.True(receipt.SealCommitted); + Assert.True(receipt.EngineMutationCommitted); + + Assert.True(physics.SetPosition.AcknowledgeProjection(placement.Token)); + Assert.True(publisher.CompletePublication(receipt)); + Assert.True(receipt.CompletionCommitted); + + LandblockPhysicsPublication cancelled = Begin( + publisher, + Build(FirstLandblock)); + Assert.False(publisher.CompletePublication(cancelled)); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot cancelWithdrawal)); + Assert.True(physics.SetPosition.AcknowledgeProjection( + cancelWithdrawal.Token)); + Assert.False(publisher.CompletePublication(cancelled)); + Assert.True(cancelled.EngineMutationCommitted); + Assert.True(cancelled.SealCommitted); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot cancelPlacement)); + + Assert.False(cancelled.TryCancel()); + Assert.True(cancelled.CancellationRequested); + Assert.True(cancelled.SealCommitted); + Assert.True(physics.SetPosition.AcknowledgeProjection( + cancelPlacement.Token)); + Assert.True(cancelled.TryCancel()); + RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership(); + Assert.Equal(0, ownership.CollisionPrefixMutationCount); + Assert.Equal(0, ownership.CollisionAdmissionCount); + } + [Fact] public void Constructor_ClonesHeightTableAndRejectsIncompleteInput() { @@ -821,6 +910,151 @@ public sealed class LandblockPhysicsPublisherTests LandblockBuild build) => publisher.BeginPublication(RenderReceipt(build)); + private static RuntimePlacementProjectionToken SeedRuntimePlacement( + RuntimePhysicsState physics, + RuntimeEntityRecord record, + uint cell, + Vector3 position) + { + Type coreAssemblyMarker = typeof(PhysicsEngine); + Type requestType = coreAssemblyMarker.Assembly.GetType( + "AcDream.Core.Physics.PhysicsSetPositionRequest", + throwOnError: true)!; + Type flagsType = coreAssemblyMarker.Assembly.GetType( + "AcDream.Core.Physics.PhysicsSetPositionFlags", + throwOnError: true)!; + Type placementClassType = coreAssemblyMarker.Assembly.GetType( + "AcDream.Core.Physics.PhysicsPlacementClass", + throwOnError: true)!; + object request = Activator.CreateInstance( + requestType, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + args: + [ + position, + Quaternion.Identity, + cell, + position, + ImmutableArray.Empty, + 1f, + 0.4f, + 0.4f, + PhysicsStateFlags.None, + ObjectInfoState.None, + 0u, + Enum.ToObject(placementClassType, 0), + Enum.ToObject(flagsType, 0x011u), + Vector3.Zero, + 0f, + 0f, + 0u, + cell, + ], + culture: null)!; + + Type runtimeAssemblyMarker = typeof(RuntimePhysicsState); + Type commandType = runtimeAssemblyMarker.Assembly.GetType( + "AcDream.Runtime.Physics.RuntimeSetPositionCommand", + throwOnError: true)!; + Type kindType = runtimeAssemblyMarker.Assembly.GetType( + "AcDream.Runtime.Physics.RuntimeSetPositionOperationKind", + throwOnError: true)!; + object command = Activator.CreateInstance( + commandType, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + args: + [ + request, + Enum.ToObject(kindType, 2), + 10d, + 0UL, + 0f, + 0f, + default(RuntimePortalPlacementAuthority), + ], + culture: null)!; + MethodInfo apply = physics.SetPosition.GetType().GetMethod( + "Apply", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingMethodException("Runtime SetPosition.Apply"); + object outcome = apply.Invoke( + physics.SetPosition, + [record, record.PositionAuthorityVersion, command])!; + return (RuntimePlacementProjectionToken)(outcome.GetType().GetProperty( + "Projection", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?.GetValue(outcome) + ?? throw new MissingMemberException("Runtime placement projection")); + } + + private static WorldSession.EntitySpawn RuntimeSpawn( + uint guid, + uint cell, + Vector3 position) + { + var serverPosition = new CreateObject.ServerPosition( + cell, + position.X, + position.Y, + position.Z, + 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: 1); + var spawnPhysics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.Gravity, + Position: serverPosition, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + serverPosition, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "app-collision-publication-fixture", + null, + null, + 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.Gravity, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: spawnPhysics); + } + private static LandblockRenderPublication RenderReceipt(LandblockBuild build) { var publisher = new LandblockRenderPublisher( diff --git a/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs index 5904dddb..3c8c84fe 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs @@ -9,6 +9,106 @@ namespace AcDream.App.Tests.Streaming; public sealed class LandblockRetirementCoordinatorTests { + [Fact] + public void PendingPhysicsStageYieldsBeforeTerrainAndRemainsRetryable() + { + const uint landblockId = 0x2020FFFFu; + var ticket = new LandblockRetirementTicket( + new GpuLandblockRetirement( + landblockId, + LandblockRetirementKind.Full, + Array.Empty()), + LandblockRetirementStage.Physics + | LandblockRetirementStage.Terrain); + int physicsPolls = 0; + int terrainPolls = 0; + + Assert.Equal( + LandblockRetirementOperationResult.Pending, + ticket.RunOnceStep( + LandblockRetirementStage.Physics, + () => + { + physicsPolls++; + return false; + })); + Assert.Equal(LandblockRetirementStage.Physics, ticket.NextIncompleteStage); + Assert.Equal(1, physicsPolls); + Assert.Equal(0, terrainPolls); + + Assert.Equal( + LandblockRetirementOperationResult.Progressed, + ticket.RunOnceStep( + LandblockRetirementStage.Physics, + () => + { + physicsPolls++; + return true; + })); + Assert.Equal(LandblockRetirementStage.Terrain, ticket.NextIncompleteStage); + Assert.True(ticket.RunOnce( + LandblockRetirementStage.Terrain, + () => terrainPolls++)); + Assert.True(ticket.IsComplete); + Assert.Equal(2, physicsPolls); + Assert.Equal(1, terrainPolls); + } + + [Fact] + public void BudgetedPendingPhysicsPollsExactlyOncePerAdvance() + { + const uint landblockId = 0x2023FFFFu; + var state = StateWith(landblockId); + int physicsPolls = 0; + int terrainPolls = 0; + bool acknowledgePhysics = false; + LandblockRetirementOperationResult AdvancePresentation( + LandblockRetirementTicket ticket) => + ticket.NextIncompleteStage switch + { + LandblockRetirementStage.Physics => ticket.RunOnceStep( + LandblockRetirementStage.Physics, + () => + { + physicsPolls++; + return acknowledgePhysics; + }), + LandblockRetirementStage.Terrain => ticket.RunOnceStep( + LandblockRetirementStage.Terrain, + () => terrainPolls++), + { } stage => ticket.RunOnceStep(stage, () => { }), + }; + LandblockRetirementCoordinator coordinator = + LandblockRetirementCoordinator.CreateBudgeted( + state, + AdvancePresentation, + ticket => + { + while (!ticket.IsComplete) + _ = AdvancePresentation(ticket); + }); + coordinator.BeginFull(landblockId); + + var first = new StreamingWorkMeter(Budget(maxEntityOperations: 64)); + coordinator.Advance(first); + first.FinishFrame(); + + Assert.Equal(1, physicsPolls); + Assert.Equal(0, terrainPolls); + Assert.Equal(0, first.Snapshot.FailureCount); + Assert.Equal(1, coordinator.PendingCount); + + acknowledgePhysics = true; + var second = new StreamingWorkMeter(Budget(maxEntityOperations: 64)); + coordinator.Advance(second); + second.FinishFrame(); + + Assert.Equal(2, physicsPolls); + Assert.Equal(1, terrainPolls); + Assert.Equal(0, second.Snapshot.FailureCount); + Assert.Equal(0, coordinator.PendingCount); + } + [Fact] public void EntityStageFailure_DetachesImmediately_AndResumesAtFailedEntity() { diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 7bcb7114..131b4b6e 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -1,6 +1,8 @@ using System.Buffers.Binary; +using System.Collections.Immutable; using System.Net; using System.Numerics; +using System.Reflection; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; @@ -605,7 +607,7 @@ public sealed class HeadlessSessionHostTests using var lifetime = new RuntimeEntityObjectLifetime(); RuntimePhysicsState physics = lifetime.Physics; const uint landblockId = 0xA9B4FFFFu; - _ = HeadlessCollisionGenerationTransaction.Execute( + CompleteCollisionGeneration( physics, landblockId, afterAdmission: null, @@ -616,7 +618,7 @@ public sealed class HeadlessSessionHostTests CollisionAssets(landblockId, 10f))); Assert.Throws(() => - HeadlessCollisionGenerationTransaction.Execute( + CompleteCollisionGeneration( physics, landblockId, _ => throw new FixtureCollisionPublicationException(), @@ -629,13 +631,137 @@ public sealed class HeadlessSessionHostTests Assert.Equal(0, ownership.CollisionAdmissionCount); } + [Fact] + public void CollisionTransactionYieldsTheFirstNonterminalRuntimePoll() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint landblockId = 0xA9B4FFFFu; + HeadlessCollisionGenerationTransaction transaction = + HeadlessCollisionGenerationTransaction.Begin( + physics, + landblockId, + afterAdmission: null, + (admission, prepared) => + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(landblockId, 10f))); + + HeadlessCollisionGenerationAdvance advance; + do + { + advance = transaction.Advance(); + Assert.True(advance.Progressed); + } + while (!advance.YieldToCaller); + + Assert.False(advance.Completed); + Assert.False(advance.WaitingForProjectionAcknowledgement); + Assert.False(transaction.CompletionCommitted); + + do + { + advance = transaction.Advance(); + Assert.True(advance.Progressed); + } + while (!advance.Completed && !advance.YieldToCaller); + if (!advance.Completed) + advance = transaction.Advance(); + + Assert.True(advance.Completed); + Assert.True(transaction.EngineMutationCommitted); + Assert.True(transaction.CompletionCommitted); + Assert.Equal(0, physics.CaptureOwnership().CollisionAdmissionCount); + } + + [Fact] + public void CollisionTransactionRetainsPostEngineCancellationUntilPlaceAck() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint landblockId = 0xA9B4FFFFu; + CompleteCollisionGeneration( + physics, + landblockId, + afterAdmission: null, + (admission, prepared) => + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(landblockId, 10f))); + + const uint guid = 0x70004201u; + const uint cell = 0xA9B40001u; + Vector3 position = new(10f, 10f, 0f); + RuntimeEntityRecord record = lifetime.RegisterEntity( + Spawn(guid)).Canonical!; + lifetime.Entities.SetFinalPhysicsState(record, PhysicsStateFlags.Gravity); + lifetime.Entities.SetFullCell(record, cell, landblockId); + var body = new PhysicsBody + { + Position = position, + Orientation = Quaternion.Identity, + LastUpdateTime = 1d, + State = PhysicsStateFlags.Gravity, + TransientState = TransientStateFlags.Active, + }; + body.SnapToCell(cell, position, position); + lifetime.Entities.SetPhysicsBody(record, body); + record.ObjectClock.Activate(); + physics.AcknowledgeSpatialProjection(record, spatial: true); + RuntimePlacementProjectionToken seeded = SeedRuntimePlacement( + physics, + record, + cell, + position); + Assert.True(physics.SetPosition.AcknowledgeProjection(seeded)); + + HeadlessCollisionGenerationTransaction transaction = + HeadlessCollisionGenerationTransaction.Begin( + physics, + landblockId, + afterAdmission: null, + (admission, prepared) => + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(landblockId, 20f))); + HeadlessCollisionGenerationAdvance advance; + do + { + advance = transaction.Advance(); + } + while (!advance.WaitingForProjectionAcknowledgement); + Assert.False(transaction.EngineMutationCommitted); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.True(physics.SetPosition.AcknowledgeProjection(withdrawal.Token)); + + do + { + advance = transaction.Advance(); + } + while (!advance.WaitingForProjectionAcknowledgement); + Assert.True(transaction.EngineMutationCommitted); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot placement)); + + Assert.False(transaction.TryCancel()); + Assert.True(physics.SetPosition.AcknowledgeProjection(placement.Token)); + Assert.True(transaction.TryCancel()); + RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership(); + Assert.Equal(0, ownership.CollisionPrefixMutationCount); + Assert.Equal(0, ownership.CollisionAdmissionCount); + } + [Fact] public void CollisionTransactionCancelsStagingFaultWithoutWithdrawingActiveWorld() { using var lifetime = new RuntimeEntityObjectLifetime(); RuntimePhysicsState physics = lifetime.Physics; const uint landblockId = 0xA9B4FFFFu; - _ = HeadlessCollisionGenerationTransaction.Execute( + CompleteCollisionGeneration( physics, landblockId, afterAdmission: null, @@ -646,7 +772,7 @@ public sealed class HeadlessSessionHostTests CollisionAssets(landblockId, 10f))); Assert.Throws(() => - HeadlessCollisionGenerationTransaction.Execute( + CompleteCollisionGeneration( physics, landblockId, afterAdmission: null, @@ -714,6 +840,107 @@ public sealed class HeadlessSessionHostTests runtime.MovementOwner.Controller = controller; } + private static void CompleteCollisionGeneration( + RuntimePhysicsState physics, + uint landblockId, + Action? afterAdmission, + Action stage) + { + HeadlessCollisionGenerationTransaction transaction = + HeadlessCollisionGenerationTransaction.Begin( + physics, + landblockId, + afterAdmission, + stage); + while (true) + { + HeadlessCollisionGenerationAdvance advance = transaction.Advance(); + if (advance.Completed) + return; + Assert.True(advance.Progressed); + Assert.False(advance.WaitingForProjectionAcknowledgement); + } + } + + private static RuntimePlacementProjectionToken SeedRuntimePlacement( + RuntimePhysicsState physics, + RuntimeEntityRecord record, + uint cell, + Vector3 position) + { + Type coreMarker = typeof(PhysicsEngine); + Type requestType = coreMarker.Assembly.GetType( + "AcDream.Core.Physics.PhysicsSetPositionRequest", + throwOnError: true)!; + Type flagsType = coreMarker.Assembly.GetType( + "AcDream.Core.Physics.PhysicsSetPositionFlags", + throwOnError: true)!; + Type placementClassType = coreMarker.Assembly.GetType( + "AcDream.Core.Physics.PhysicsPlacementClass", + throwOnError: true)!; + object request = Activator.CreateInstance( + requestType, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + args: + [ + position, + Quaternion.Identity, + cell, + position, + ImmutableArray.Empty, + 1f, + 0.4f, + 0.4f, + PhysicsStateFlags.None, + ObjectInfoState.None, + 0u, + Enum.ToObject(placementClassType, 0), + Enum.ToObject(flagsType, 0x011u), + Vector3.Zero, + 0f, + 0f, + 0u, + cell, + ], + culture: null)!; + Type runtimeMarker = typeof(RuntimePhysicsState); + Type commandType = runtimeMarker.Assembly.GetType( + "AcDream.Runtime.Physics.RuntimeSetPositionCommand", + throwOnError: true)!; + Type kindType = runtimeMarker.Assembly.GetType( + "AcDream.Runtime.Physics.RuntimeSetPositionOperationKind", + throwOnError: true)!; + object command = Activator.CreateInstance( + commandType, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + args: + [ + request, + Enum.ToObject(kindType, 2), + 10d, + 0UL, + 0f, + 0f, + default(RuntimePortalPlacementAuthority), + ], + culture: null)!; + MethodInfo apply = physics.SetPosition.GetType().GetMethod( + "Apply", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingMethodException("Runtime SetPosition.Apply"); + object outcome = apply.Invoke( + physics.SetPosition, + [record, record.PositionAuthorityVersion, command])!; + return (RuntimePlacementProjectionToken)(outcome.GetType().GetProperty( + "Projection", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?.GetValue(outcome) + ?? throw new MissingMemberException("Runtime placement projection")); + } + private static RuntimeLandblockCollisionAssets CollisionAssets( uint landblockId, float terrainHeight) diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs index 1768124f..0c432e83 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs @@ -66,7 +66,33 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests .IsExactPreparedPlacementCurrent( fixture.Record, fixture.Placement, - fixture.Command)); + fixture.Command)); + } + + [Fact] + public void DormantOwnershipPreflightFailurePublishesNoCanonicalOwner() + { + using var fixture = new Fixture(); + RuntimeLocalPlayerPhysicsPublicationToken publication = + fixture.Prepare(); + Assert.True(fixture.Lifetime.Physics.SetPosition.Cancel( + fixture.Record, + publishWithdrawal: false)); + var unownedBody = new PhysicsBody(); + + Assert.Throws(() => fixture.Lifetime.Physics + .SetPosition.PrepareDormantLocalActivationOwnership( + fixture.Record, + unownedBody, + publication.Placement)); + + Assert.Null(fixture.Record.PhysicsBody); + Assert.Null(fixture.Movement.Controller); + Assert.False(unownedBody.InWorld); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record)); + Assert.Equal(1, fixture.Owner.CaptureOwnership().CandidateCount); + Assert.Equal(0, fixture.Owner.CaptureOwnership() + .PendingActivationCount); } [Fact] @@ -2467,32 +2493,48 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests RuntimeCollisionAdmission admission, PreparedLandblockCollisionGeneration prepared) { - while (true) + bool engineCommitted = false; + for (int poll = 0; poll < 10_000; poll++) { - while (!physics.AdvanceCollisionRetainedOwnerCapture( - admission, - prepared).Completed) + if (!engineCommitted) { + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + foreach (uint ownerId in prepared.RetainedOwnerIds) + { + physics.RefreshCollisionRetainedOwner( + admission, + prepared, + ownerId); + } + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + } + while (!seal.Completed && !seal.Restarted); + if (!seal.Completed) + continue; } - foreach (uint ownerId in prepared.RetainedOwnerIds) + RuntimeCollisionGenerationCommit result = + physics.CommitCollisionGeneration(admission, prepared); + if (result.Completed) + return result; + while (physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot projection)) { - physics.RefreshCollisionRetainedOwner( - admission, - prepared, - ownerId); + Assert.True(physics.SetPosition.AcknowledgeProjection( + projection.Token)); } - RuntimeCollisionSealStep seal; - do - { - seal = physics.AdvanceCollisionGenerationSeal( - admission, - prepared); - } - while (!seal.Completed && !seal.Restarted); - if (seal.Completed) - break; + engineCommitted = result.EngineCommitted; } - return physics.CommitCollisionGeneration(admission, prepared); + throw new InvalidOperationException( + "Collision generation did not complete its Runtime mutation transaction."); } private static RuntimeLandblockCollisionAssets CollisionAssets( diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionMutationTransactionTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionMutationTransactionTests.cs new file mode 100644 index 00000000..090a5f29 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionMutationTransactionTests.cs @@ -0,0 +1,479 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Tests.Physics; + +public sealed partial class RuntimeCollisionPrefixQuiescenceTests +{ + [Fact] + public void ActivationWaitsForExactWithdrawAndPlaceReceipts() + { + using var fixture = new Fixture(bindGeneration: true); + RuntimeEntityRecord record = fixture.Add( + 0x70003101u, + 1, + CellP, + new Vector3(11f, 40f, 0f)); + RuntimeSetPositionOutcome seeded = fixture.Place( + record, + CellP, + new Vector3(11.5f, 40f, 0f)); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(seeded.Projection)); + + RuntimePhysicsState physics = fixture.Lifetime.Physics; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(PrefixP); + using PreparedLandblockCollisionGeneration prepared = + PrepareSealedMutation(physics, admission, PrefixP); + int committedNotifications = 0; + physics.CollisionGenerationCommitted += _ => committedNotifications++; + + RuntimeCollisionGenerationCommit first = + physics.CommitCollisionGeneration(admission, prepared); + Assert.False(first.EngineCommitted); + Assert.False(first.Completed); + Assert.False(physics.IsSpatialRoot(record)); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawn)); + Assert.Equal(RuntimePlacementProjectionKind.Withdraw, withdrawn.Kind); + + Assert.True(physics.SetPosition.AcknowledgeProjection(withdrawn.Token)); + _ = SealMutation(physics, admission, prepared); + RuntimeCollisionGenerationCommit transferred = + physics.CommitCollisionGeneration(admission, prepared); + Assert.True(transferred.EngineCommitted); + Assert.False(transferred.Completed); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot restored)); + Assert.Equal(RuntimePlacementProjectionKind.Place, restored.Kind); + + Assert.True(physics.SetPosition.AcknowledgeProjection(restored.Token)); + RuntimeCollisionGenerationCommit completed = + physics.CommitCollisionGeneration(admission, prepared); + Assert.True(completed.EngineCommitted); + Assert.True(completed.Completed); + Assert.True(physics.IsSpatialRoot(record)); + Assert.Equal(1, committedNotifications); + Assert.Equal(0, physics.CaptureOwnership().CollisionPrefixMutationCount); + Assert.Equal(0, physics.CaptureOwnership().CollisionPrefixQuiescenceCount); + } + + [Fact] + public void DemotionParksOnlyIndoorResidentsAndLeavesOutdoorPresentationLive() + { + using var fixture = new Fixture(bindGeneration: true); + RuntimeEntityRecord outdoor = fixture.Add( + 0x70003102u, + 1, + CellP, + new Vector3(12f, 41f, 0f)); + RuntimeEntityRecord indoor = fixture.Add( + 0x70003103u, + 1, + PrefixP | 0x0100u, + new Vector3(13f, 41f, 0f)); + + RuntimeCollisionMutationResult first = + fixture.Lifetime.Physics.DemoteCollisionToTerrain(PrefixP); + Assert.False(first.Completed); + Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(outdoor)); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(indoor)); + Assert.True(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.Equal(indoor.Key, withdrawal.Token.Entity); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(withdrawal.Token)); + + RuntimeCollisionMutationResult completed = + fixture.Lifetime.Physics.DemoteCollisionToTerrain(PrefixP); + Assert.True(completed.Completed); + Assert.True(completed.Ready); + Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(outdoor)); + Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(indoor)); + Assert.False(fixture.Lifetime.Physics.SetPosition.TryPeekProjection(out _)); + } + + [Fact] + public void WithdrawalLeavesResidentUnboundUntilLaterGenerationWakesIt() + { + using var fixture = new Fixture(bindGeneration: true); + RuntimeEntityRecord record = fixture.Add( + 0x70003104u, + 1, + CellP, + new Vector3(14f, 42f, 0f)); + RuntimeSetPositionOutcome seeded = fixture.Place( + record, + CellP, + new Vector3(14.5f, 42f, 0f)); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(seeded.Projection)); + RuntimePhysicsState physics = fixture.Lifetime.Physics; + + RuntimeCollisionMutationResult withdrawal = + physics.WithdrawCollision(PrefixP); + Assert.False(withdrawal.Completed); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot removed)); + Assert.True(physics.SetPosition.AcknowledgeProjection(removed.Token)); + withdrawal = physics.WithdrawCollision(PrefixP); + Assert.True(withdrawal.Completed); + Assert.False(withdrawal.Ready); + Assert.False(physics.IsSpatialRoot(record)); + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(PrefixP); + using PreparedLandblockCollisionGeneration prepared = + PrepareSealedMutation(physics, admission, PrefixP); + RuntimeCollisionGenerationCommit pending = + physics.CommitCollisionGeneration(admission, prepared); + Assert.False(pending.Completed); + _ = SealMutation(physics, admission, prepared); + RuntimeCollisionGenerationCommit completed = + physics.CommitCollisionGeneration(admission, prepared); + Assert.True(completed.EngineCommitted); + Assert.False(completed.Completed); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot restored)); + Assert.Equal(RuntimePlacementProjectionKind.Place, restored.Kind); + Assert.True(physics.SetPosition.AcknowledgeProjection(restored.Token)); + completed = physics.CommitCollisionGeneration(admission, prepared); + Assert.True(completed.Completed); + Assert.True(physics.IsSpatialRoot(record)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SessionResetClearsPreAndPostEngineMutationDebt(bool postEngine) + { + using var fixture = new Fixture(bindGeneration: true); + RuntimeEntityRecord record = fixture.Add( + 0x70003105u, + 1, + CellP, + new Vector3(15f, 43f, 0f)); + RuntimeSetPositionOutcome seeded = fixture.Place( + record, + CellP, + new Vector3(15.5f, 43f, 0f)); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(seeded.Projection)); + RuntimePhysicsState physics = fixture.Lifetime.Physics; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(PrefixP); + using PreparedLandblockCollisionGeneration prepared = + PrepareSealedMutation(physics, admission, PrefixP); + + Assert.False(physics.CommitCollisionGeneration( + admission, + prepared).Completed); + if (postEngine) + { + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.True(physics.SetPosition.AcknowledgeProjection( + withdrawal.Token)); + _ = SealMutation(physics, admission, prepared); + RuntimeCollisionGenerationCommit transferred = + physics.CommitCollisionGeneration(admission, prepared); + Assert.True(transferred.EngineCommitted); + Assert.False(transferred.Completed); + } + + _ = fixture.Lifetime.BeginSessionClear(); + RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership(); + Assert.Equal(0, ownership.CollisionPrefixMutationCount); + Assert.Equal(0, ownership.CommittedCollisionPrefixMutationCount); + Assert.Equal(0, ownership.CollisionPrefixQuiescenceCount); + Assert.Equal(0, ownership.PendingCollisionPrefixProjectionCount); + Assert.Equal(0, ownership.CollisionAdmissionCount); + } + + [Fact] + public void CancelledActivationCannotCommitWhileRestoreAckIsPending() + { + using var fixture = new Fixture(bindGeneration: true); + RuntimePhysicsState physics = fixture.Lifetime.Physics; + RuntimeCollisionAdmission baselineAdmission = + physics.BeginCollisionAdmission(PrefixP); + using (PreparedLandblockCollisionGeneration baseline = + PrepareSealedMutation(physics, baselineAdmission, PrefixP)) + { + Assert.False(physics.CommitCollisionGeneration( + baselineAdmission, + baseline).Completed); + _ = SealMutation(physics, baselineAdmission, baseline); + Assert.True(physics.CommitCollisionGeneration( + baselineAdmission, + baseline).Completed); + } + + RuntimeEntityRecord record = fixture.Add( + 0x70003106u, + 1, + CellP, + new Vector3(16f, 44f, 0f)); + RuntimeSetPositionOutcome seeded = fixture.Place( + record, + CellP, + new Vector3(16.5f, 44f, 0f)); + Assert.True(physics.SetPosition.AcknowledgeProjection( + seeded.Projection)); + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(PrefixP); + using PreparedLandblockCollisionGeneration prepared = + PrepareSealedMutation(physics, admission, PrefixP); + + RuntimeCollisionGenerationCommit parked = + physics.CommitCollisionGeneration(admission, prepared); + Assert.False(parked.EngineCommitted); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.True(physics.SetPosition.AcknowledgeProjection(withdrawal.Token)); + + Assert.False(physics.CancelCollisionGeneration(admission, prepared)); + RuntimeCollisionGenerationCommit forbidden = + physics.CommitCollisionGeneration(admission, prepared); + Assert.False(forbidden.EngineCommitted); + Assert.False(forbidden.Completed); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot restored)); + Assert.Equal(RuntimePlacementProjectionKind.Place, restored.Kind); + Assert.True(physics.SetPosition.AcknowledgeProjection(restored.Token)); + Assert.True(physics.CancelCollisionGeneration(admission, prepared)); + + Assert.True(physics.IsSpatialRoot(record)); + Assert.True(physics.Engine.IsLandblockTerrainResident(PrefixP)); + Assert.Equal(0, physics.CaptureOwnership().CollisionPrefixMutationCount); + Assert.Equal(0, physics.CaptureOwnership().CollisionPrefixQuiescenceCount); + Assert.Equal(0, physics.CaptureOwnership().CollisionAdmissionCount); + } + + [Fact] + public void SupersededAdmissionCancellationRestoresExactBaselineGeneration() + { + using var fixture = new Fixture(bindGeneration: true); + RuntimePhysicsState physics = fixture.Lifetime.Physics; + RuntimeCollisionAdmission baseline = + physics.BeginCollisionAdmission(PrefixP); + using (PreparedLandblockCollisionGeneration preparedBaseline = + PrepareSealedMutation(physics, baseline, PrefixP)) + { + Assert.False(physics.CommitCollisionGeneration( + baseline, + preparedBaseline).Completed); + Assert.True(physics.CommitCollisionGeneration( + baseline, + preparedBaseline).Completed); + } + + RuntimeEntityRecord record = fixture.Add( + 0x70003107u, + 1, + CellP, + new Vector3(17f, 45f, 0f)); + RuntimeSetPositionOutcome seeded = fixture.Place( + record, + CellP, + new Vector3(17.5f, 45f, 0f)); + Assert.True(physics.SetPosition.AcknowledgeProjection( + seeded.Projection)); + + RuntimeCollisionAdmission admissionA = + physics.BeginCollisionAdmission(PrefixP); + using PreparedLandblockCollisionGeneration preparedA = + PrepareSealedMutation(physics, admissionA, PrefixP); + RuntimeCollisionAdmission admissionB = + physics.BeginCollisionAdmission(PrefixP); + Assert.Equal(baseline.Generation, admissionB.PreviousGeneration); + using PreparedLandblockCollisionGeneration preparedB = + PrepareSealedMutation(physics, admissionB, PrefixP); + + RuntimeCollisionGenerationCommit parked = + physics.CommitCollisionGeneration(admissionB, preparedB); + Assert.False(parked.EngineCommitted); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + + Assert.False(physics.CancelCollisionGeneration(admissionB, preparedB)); + Assert.True(physics.SetPosition.AcknowledgeProjection(withdrawal.Token)); + Assert.False(physics.CancelCollisionGeneration(admissionB, preparedB)); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot restored)); + Assert.Equal(RuntimePlacementProjectionKind.Place, restored.Kind); + Assert.True(physics.SetPosition.AcknowledgeProjection(restored.Token)); + Assert.True(physics.CancelCollisionGeneration(admissionB, preparedB)); + + Assert.True(physics.IsSpatialRoot(record)); + Assert.Equal(restored.Token.ExactCellId, record.FullCellId); + Assert.True(physics.Engine.IsLandblockTerrainResident(PrefixP)); + RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership(); + Assert.Equal(0, ownership.CollisionPrefixMutationCount); + Assert.Equal(0, ownership.CollisionPrefixQuiescenceCount); + Assert.Equal(0, ownership.CollisionAdmissionCount); + } + + [Fact] + public void CancellationAfterEngineCommitFinishesExactPlaceAckWithoutRollback() + { + using var fixture = new Fixture(bindGeneration: true); + RuntimePhysicsState physics = fixture.Lifetime.Physics; + RuntimeCollisionAdmission baseline = + physics.BeginCollisionAdmission(PrefixP); + using (PreparedLandblockCollisionGeneration preparedBaseline = + PrepareSealedMutation(physics, baseline, PrefixP)) + { + Assert.False(physics.CommitCollisionGeneration( + baseline, + preparedBaseline).Completed); + Assert.True(physics.CommitCollisionGeneration( + baseline, + preparedBaseline).Completed); + } + + RuntimeEntityRecord record = fixture.Add( + 0x70003108u, + 1, + CellP, + new Vector3(18f, 46f, 0f)); + RuntimeSetPositionOutcome seeded = fixture.Place( + record, + CellP, + new Vector3(18.5f, 46f, 0f)); + Assert.True(physics.SetPosition.AcknowledgeProjection( + seeded.Projection)); + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(PrefixP); + using PreparedLandblockCollisionGeneration prepared = + PrepareSealedMutation(physics, admission, PrefixP); + int committedNotifications = 0; + physics.CollisionGenerationCommitted += _ => committedNotifications++; + + Assert.False(physics.CommitCollisionGeneration( + admission, + prepared).Completed); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.True(physics.SetPosition.AcknowledgeProjection(withdrawal.Token)); + _ = SealMutation(physics, admission, prepared); + RuntimeCollisionGenerationCommit transferred = + physics.CommitCollisionGeneration(admission, prepared); + Assert.True(transferred.EngineCommitted); + Assert.False(transferred.Completed); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot restored)); + + Assert.False(physics.CancelCollisionGeneration(admission, prepared)); + Assert.True(physics.Engine.IsLandblockTerrainResident(PrefixP)); + Assert.Equal(0, committedNotifications); + Assert.True(physics.SetPosition.AcknowledgeProjection(restored.Token)); + Assert.True(physics.CancelCollisionGeneration(admission, prepared)); + + Assert.True(physics.IsSpatialRoot(record)); + Assert.True(physics.Engine.IsLandblockTerrainResident(PrefixP)); + Assert.Equal(1, committedNotifications); + RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership(); + Assert.Equal(0, ownership.CollisionPrefixMutationCount); + Assert.Equal(0, ownership.CollisionPrefixQuiescenceCount); + Assert.Equal(0, ownership.CollisionAdmissionCount); + } + + [Fact] + public void ColdFirstGenerationCancellationReleasesToUnavailableWithoutDebt() + { + // The injected-engine constructor is an internal fixture seam. It can + // begin with collision resident before Runtime has assigned generation + // one; production Runtime constructs an empty PhysicsEngine. + using var fixture = new Fixture(bindGeneration: true); + RuntimePhysicsState physics = fixture.Lifetime.Physics; + RuntimeEntityRecord record = fixture.Add( + 0x70003109u, + 1, + CellP, + new Vector3(19f, 47f, 0f)); + RuntimeSetPositionOutcome seeded = fixture.Place( + record, + CellP, + new Vector3(19.5f, 47f, 0f)); + Assert.True(physics.SetPosition.AcknowledgeProjection( + seeded.Projection)); + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(PrefixP); + Assert.Equal(0UL, admission.PreviousGeneration); + using PreparedLandblockCollisionGeneration prepared = + PrepareSealedMutation(physics, admission, PrefixP); + + RuntimeCollisionGenerationCommit parked = + physics.CommitCollisionGeneration(admission, prepared); + Assert.False(parked.EngineCommitted); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.True(physics.SetPosition.AcknowledgeProjection(withdrawal.Token)); + Assert.True(physics.CancelCollisionGeneration(admission, prepared)); + + Assert.True(physics.Engine.IsLandblockTerrainResident(PrefixP)); + Assert.False(physics.IsSpatialRoot(record)); + RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership(); + Assert.Equal(0, ownership.CollisionPrefixMutationCount); + Assert.Equal(0, ownership.CollisionPrefixQuiescenceCount); + Assert.Equal(0, ownership.PendingCollisionPrefixProjectionCount); + Assert.Equal(0, ownership.CollisionAdmissionCount); + RuntimeSetPositionOwnershipSnapshot placement = + physics.SetPosition.CaptureOwnership(); + Assert.Equal(1, placement.UnboundDeferredCellCount); + Assert.Equal(0, placement.DeferredBucketCount); + } + + private static PreparedLandblockCollisionGeneration PrepareSealedMutation( + RuntimePhysicsState physics, + RuntimeCollisionAdmission admission, + uint landblock) + { + PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + new RuntimeLandblockCollisionAssets( + landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + 0f, + 0f, + 0u)); + _ = SealMutation(physics, admission, prepared); + return prepared; + } + + private static uint[] SealMutation( + RuntimePhysicsState physics, + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + while (true) + { + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + foreach (uint ownerId in prepared.RetainedOwnerIds) + physics.RefreshCollisionRetainedOwner(admission, prepared, ownerId); + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + } + while (!seal.Completed && !seal.Restarted); + if (seal.Completed) + return [.. prepared.RetainedOwnerIds]; + } + } +} diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.cs index b49bc996..a244db9c 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.cs @@ -10,7 +10,7 @@ using DatReaderWriter.Types; namespace AcDream.Runtime.Tests.Physics; -public sealed class RuntimeCollisionPrefixQuiescenceTests +public sealed partial class RuntimeCollisionPrefixQuiescenceTests { private const uint PrefixP = 0xA9B40000u; private const uint CellP = PrefixP | 0x0001u; @@ -68,7 +68,10 @@ public sealed class RuntimeCollisionPrefixQuiescenceTests Assert.True(fixture.Lifetime.Physics .IsCollisionPrefixMutationPermissionCurrent(permission)); Assert.True(fixture.Lifetime.Physics - .CompleteCollisionPrefixQuiescence(permission)); + .CancelCollisionPrefixQuiescence( + token, + successorGeneration: 2UL, + successorReady: false)); Assert.False(fixture.Lifetime.Physics .IsCollisionPrefixMutationPermissionCurrent(permission)); } diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsOwnershipBoundaryTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsOwnershipBoundaryTests.cs new file mode 100644 index 00000000..3795a3ba --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsOwnershipBoundaryTests.cs @@ -0,0 +1,44 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using AcDream.Core.Physics; + +namespace AcDream.Runtime.Tests.Physics; + +public sealed class RuntimePhysicsOwnershipBoundaryTests +{ + [Fact] + public void PhysicsWorldRootMutatorsAreRuntimeOnlyProductionInternals() + { + string[] rootMutators = + [ + nameof(PhysicsEngine.AddLandblock), + nameof(PhysicsEngine.RemoveLandblock), + nameof(PhysicsEngine.DemoteLandblockToTerrain), + nameof(PhysicsEngine.Clear), + ]; + Type engine = typeof(PhysicsEngine); + MethodInfo[] publicMethods = engine.GetMethods( + BindingFlags.Instance | BindingFlags.Public); + MethodInfo[] internalMethods = engine.GetMethods( + BindingFlags.Instance | BindingFlags.NonPublic); + foreach (string name in rootMutators) + { + Assert.DoesNotContain( + publicMethods, + method => method.Name == name); + Assert.Contains( + internalMethods, + method => method.Name == name); + } + + string[] friends = engine.Assembly + .GetCustomAttributes() + .Select(attribute => attribute.AssemblyName.Split(',')[0]) + .ToArray(); + Assert.Contains("AcDream.Runtime", friends); + Assert.DoesNotContain("AcDream.App", friends); + Assert.DoesNotContain("acdream-headless", friends); + Assert.Contains("AcDream.App.Tests", friends); + Assert.Contains("AcDream.Headless.Tests", friends); + } +} diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs index 0972e99a..78349ead 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs @@ -336,7 +336,8 @@ public sealed class RuntimePhysicsStateTests CommitPrepared(first.Physics, newer, prepared)); RuntimeCollisionAcknowledgement withdrawn = - first.Physics.WithdrawCollision(0xA9B4FFFFu); + CompleteWithdrawal(first.Physics, 0xA9B4FFFFu) + .Acknowledgement; Assert.True(withdrawn.WasResident); Assert.True(withdrawn.Generation > completed.Generation); Assert.Equal(0, first.Physics.Engine.LandblockCount); @@ -465,7 +466,8 @@ public sealed class RuntimePhysicsStateTests uint owner = Assert.Single(SealPrepared(physics, admission, prepared)); physics.Engine.ShadowObjects.UpdatePhysicsState(owner, 0x14u); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); Assert.Equal(15f, physics.Engine.SampleTerrainZ(1f, 1f)); @@ -561,6 +563,11 @@ public sealed class RuntimePhysicsStateTests Assert.True(sealSteps > ownerCount); Assert.True(workUnits > ownerCount); + Assert.False(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + _ = SealPrepared(physics, admission, prepared); + _ = GC.GetAllocatedBytesForCurrentThread(); long before = GC.GetAllocatedBytesForCurrentThread(); RuntimeCollisionGenerationCommit commit = @@ -662,7 +669,8 @@ public sealed class RuntimePhysicsStateTests } while (!seal.Completed); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); Assert.Equal(15f, physics.Engine.SampleTerrainZ(1f, 1f)); @@ -742,7 +750,8 @@ public sealed class RuntimePhysicsStateTests break; } - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); ShadowEntry[] entries = physics.Engine.ShadowObjects @@ -841,7 +850,8 @@ public sealed class RuntimePhysicsStateTests } while (!seal.Completed); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); ShadowEntry[] entries = physics.Engine.ShadowObjects @@ -885,6 +895,10 @@ public sealed class RuntimePhysicsStateTests ShadowObjectRegistry shadowFacade = physics.Engine.ShadowObjects; int notifications = 0; physics.CollisionGenerationCommitted += _ => notifications++; + Assert.False(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + _ = SealPrepared(physics, admission, prepared); _ = GC.GetAllocatedBytesForCurrentThread(); long before = GC.GetAllocatedBytesForCurrentThread(); RuntimeCollisionGenerationCommit commit = @@ -1049,6 +1063,10 @@ public sealed class RuntimePhysicsStateTests PhysicsDataCache cacheFacade = physics.DataCache; CellGraph graphFacade = physics.DataCache.CellGraph; ShadowObjectRegistry shadowFacade = physics.Engine.ShadowObjects; + Assert.False(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + _ = SealPrepared(physics, admission, prepared); _ = GC.GetAllocatedBytesForCurrentThread(); long before = GC.GetAllocatedBytesForCurrentThread(); RuntimeCollisionGenerationCommit commit = @@ -1106,9 +1124,15 @@ public sealed class RuntimePhysicsStateTests _ = SealPrepared(physics, firstAdmission, first); _ = SealPrepared(physics, secondAdmission, second); + Assert.False(physics.CommitCollisionGeneration( + firstAdmission, + first).Committed); + _ = SealPrepared(physics, firstAdmission, first); + _ = GC.GetAllocatedBytesForCurrentThread(); long firstBefore = GC.GetAllocatedBytesForCurrentThread(); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, firstAdmission, first).Committed); long firstAllocated = @@ -1118,10 +1142,15 @@ public sealed class RuntimePhysicsStateTests Assert.True(physics.Engine.IsLandblockTerrainResident(firstLandblock)); Assert.False(physics.Engine.IsLandblockTerrainResident(secondLandblock)); + _ = SealPrepared(physics, secondAdmission, second); + Assert.False(physics.CommitCollisionGeneration( + secondAdmission, + second).Committed); _ = SealPrepared(physics, secondAdmission, second); _ = GC.GetAllocatedBytesForCurrentThread(); long secondBefore = GC.GetAllocatedBytesForCurrentThread(); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, secondAdmission, second).Committed); long secondAllocated = @@ -1172,7 +1201,8 @@ public sealed class RuntimePhysicsStateTests _ = SealPrepared(physics, firstAdmission, first); _ = SealPrepared(physics, secondAdmission, second); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, firstAdmission, first).Committed); physics.Engine.ShadowObjects.UpdatePosition( @@ -1185,7 +1215,8 @@ public sealed class RuntimePhysicsStateTests seedCellId: 0x01010001u); _ = SealPrepared(physics, secondAdmission, second); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, secondAdmission, second).Committed); Assert.Equal(12f, Assert.Single( @@ -1256,11 +1287,13 @@ public sealed class RuntimePhysicsStateTests _ = SealPrepared(physics, northAdmission, northPrepared); _ = SealPrepared(physics, southAdmission, southPrepared); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, northAdmission, northPrepared).Committed); _ = SealPrepared(physics, southAdmission, southPrepared); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, southAdmission, southPrepared).Committed); Assert.True(physics.Engine.ShadowObjects.HasOwnerRowsInLandblock( @@ -1394,7 +1427,8 @@ public sealed class RuntimePhysicsStateTests physics.Engine.UpdatePlayerCurrCell(secondCell); ObjCell oldRootCell = physics.DataCache.CellGraph.CurrCell!; - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); @@ -1454,7 +1488,8 @@ public sealed class RuntimePhysicsStateTests destination, seedCellId: 0x02020001u); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); Assert.False(physics.Engine.ShadowObjects.HasOwnerRowsInLandblock( @@ -1521,7 +1556,8 @@ public sealed class RuntimePhysicsStateTests seedCellId: 0x02020001u, isStatic: false); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); ShadowEntry entry = Assert.Single( @@ -1692,8 +1728,8 @@ public sealed class RuntimePhysicsStateTests preparedThird, CollisionAssets(replacement)); - Assert.True(physics.DemoteCollisionToTerrain(demoted).WasResident); - Assert.True(physics.WithdrawCollision(withdrawn).WasResident); + Assert.True(CompleteDemotion(physics, demoted).WasResident); + Assert.True(CompleteWithdrawal(physics, withdrawn).WasResident); Assert.True(CommitPrepared( physics, third, @@ -1745,7 +1781,8 @@ public sealed class RuntimePhysicsStateTests } _ = SealPrepared(physics, retiredAdmission, retiredPrepared); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, retiredAdmission, retiredPrepared).Committed); if (beginRebase) @@ -1762,9 +1799,9 @@ public sealed class RuntimePhysicsStateTests } if (withdraw) - Assert.True(physics.WithdrawCollision(retired).WasResident); + Assert.True(CompleteWithdrawal(physics, retired).WasResident); else - Assert.True(physics.DemoteCollisionToTerrain(retired).WasResident); + Assert.True(CompleteDemotion(physics, retired).WasResident); Assert.True(CommitPrepared( physics, @@ -1986,6 +2023,11 @@ public sealed class RuntimePhysicsStateTests while (!seal.Completed); Assert.Equal(ownerCount, worked); + Assert.False(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + _ = SealPrepared(physics, admission, prepared); + _ = GC.GetAllocatedBytesForCurrentThread(); long before = GC.GetAllocatedBytesForCurrentThread(); RuntimeCollisionGenerationCommit commit = @@ -2050,7 +2092,8 @@ public sealed class RuntimePhysicsStateTests survivingTarget, seedCellId: 0x02020001u, isStatic: false); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, survivingAdmission, surviving).Committed); Assert.Contains( @@ -2097,7 +2140,8 @@ public sealed class RuntimePhysicsStateTests 0f, target, seedCellId: 0x01010001u); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); Assert.True(physics.Engine.ShadowObjects.HasOwnerRowsInLandblock( @@ -2146,7 +2190,8 @@ public sealed class RuntimePhysicsStateTests outside, seedCellId: 0x02020001u); physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, 0x55u); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); ShadowEntry entry = Assert.Single( @@ -2217,7 +2262,7 @@ public sealed class RuntimePhysicsStateTests replacementAdmission, replacementPrepared); - Assert.True(physics.WithdrawCollision(retired).WasResident); + Assert.True(CompleteWithdrawal(physics, retired).WasResident); Assert.False(physics.CommitCollisionGeneration( replacementAdmission, replacementPrepared).Committed); @@ -2226,7 +2271,8 @@ public sealed class RuntimePhysicsStateTests physics, replacementAdmission, replacementPrepared); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, replacementAdmission, replacementPrepared).Committed); Assert.False(physics.Engine.IsLandblockTerrainResident(retired)); @@ -2254,7 +2300,8 @@ public sealed class RuntimePhysicsStateTests uint ordinal = index + 0x1000u; uint x = ordinal & 0xFFu; uint y = (ordinal >> 8) & 0xFFu; - _ = physics.WithdrawCollision( + _ = CompleteWithdrawal( + physics, (x << 24) | (y << 16) | 0xFFFFu); } Assert.False(physics.CommitCollisionGeneration( @@ -2272,7 +2319,8 @@ public sealed class RuntimePhysicsStateTests Assert.True(++steps < 100_000); } while (!seal.Completed); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); } @@ -2380,7 +2428,8 @@ public sealed class RuntimePhysicsStateTests Assert.InRange(seal.WorkUnits, 0, 1); } while (!seal.Completed); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); Assert.Empty(physics.Engine.ShadowObjects.AllEntriesForDebug()); @@ -2448,7 +2497,8 @@ public sealed class RuntimePhysicsStateTests seedCellId: 0x01010001u, isStatic: false); physics.Engine.ShadowObjects.Deregister(77u); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); Assert.Empty(physics.Engine.ShadowObjects.AllEntriesForDebug()); @@ -2484,7 +2534,8 @@ public sealed class RuntimePhysicsStateTests seedCellId: 0x01010001u, isStatic: false); } - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CompleteSealedCommit( + physics, admission, prepared).Committed); Assert.Equal( @@ -2903,7 +2954,55 @@ public sealed class RuntimePhysicsStateTests PreparedLandblockCollisionGeneration prepared) { _ = SealPrepared(physics, admission, prepared); - return physics.CommitCollisionGeneration(admission, prepared); + return CompleteSealedCommit(physics, admission, prepared); + } + + private static RuntimeCollisionGenerationCommit CompleteSealedCommit( + RuntimePhysicsState physics, + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + for (int poll = 0; poll < 10_000; poll++) + { + RuntimeCollisionGenerationCommit commit = + physics.CommitCollisionGeneration(admission, prepared); + if (commit.Committed) + return commit; + if (!commit.EngineCommitted) + _ = SealPrepared(physics, admission, prepared); + } + throw new InvalidOperationException( + "Collision generation did not complete its Runtime mutation transaction."); + } + + private static RuntimeCollisionMutationResult CompleteDemotion( + RuntimePhysicsState physics, + uint landblockId) + { + for (int poll = 0; poll < 10_000; poll++) + { + RuntimeCollisionMutationResult result = + physics.DemoteCollisionToTerrain(landblockId); + if (result.Completed) + return result; + } + throw new InvalidOperationException( + "Collision demotion did not complete its Runtime mutation transaction."); + } + + private static RuntimeCollisionMutationResult CompleteWithdrawal( + RuntimePhysicsState physics, + uint landblockId) + { + for (int poll = 0; poll < 10_000; poll++) + { + RuntimeCollisionMutationResult result = + physics.WithdrawCollision(landblockId); + if (result.Completed) + return result; + } + throw new InvalidOperationException( + "Collision withdrawal did not complete its Runtime mutation transaction."); } private static uint[] SealPrepared( diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs index 92368bdd..aa7896e1 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs @@ -1750,8 +1750,6 @@ public sealed class RuntimeSetPositionStateTests RuntimePlacementProjectionSnapshot placed = observer.Deltas[^1].Placement; Assert.Equal(RuntimePlacementProjectionKind.Place, placed.Kind); Assert.Equal(DestinationIndoorCell, placed.Token.ExactCellId); - Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( - placed.Token)); RuntimePhysicsOwnershipSnapshot final = lifetime.Physics.CaptureOwnership(); Assert.Equal(0, final.DeferredSetPositionBucketCount); Assert.Equal(0, final.UnboundDeferredSetPositionCellCount); @@ -1872,10 +1870,6 @@ public sealed class RuntimeSetPositionStateTests Assert.All(observer.Deltas, delta => Assert.Equal( RuntimePlacementProjectionKind.Place, delta.Placement.Kind)); - Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( - observer.Deltas[0].Placement.Token)); - Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( - observer.Deltas[1].Placement.Token)); } [Theory] @@ -2425,32 +2419,48 @@ public sealed class RuntimeSetPositionStateTests RuntimeCollisionAdmission admission, PreparedLandblockCollisionGeneration prepared) { - while (true) + bool engineCommitted = false; + for (int poll = 0; poll < 10_000; poll++) { - while (!physics.AdvanceCollisionRetainedOwnerCapture( - admission, - prepared).Completed) + if (!engineCommitted) { + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + foreach (uint ownerId in prepared.RetainedOwnerIds) + { + physics.RefreshCollisionRetainedOwner( + admission, + prepared, + ownerId); + } + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + } + while (!seal.Completed && !seal.Restarted); + if (!seal.Completed) + continue; } - foreach (uint ownerId in prepared.RetainedOwnerIds) + RuntimeCollisionGenerationCommit result = + physics.CommitCollisionGeneration(admission, prepared); + if (result.Completed) + return result; + while (physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot projection)) { - physics.RefreshCollisionRetainedOwner( - admission, - prepared, - ownerId); + Assert.True(physics.SetPosition.AcknowledgeProjection( + projection.Token)); } - RuntimeCollisionSealStep seal; - do - { - seal = physics.AdvanceCollisionGenerationSeal( - admission, - prepared); - } - while (!seal.Completed && !seal.Restarted); - if (seal.Completed) - break; + engineCommitted = result.EngineCommitted; } - return physics.CommitCollisionGeneration(admission, prepared); + throw new InvalidOperationException( + "Collision generation did not complete its Runtime mutation transaction."); } private static void AddSyntheticCell(