diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 7e475959..2004ebbd 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -65,7 +65,7 @@ reconciling #281's 43 test failures. gate may or may not exclude Create during that window, and #280 says other work does continue arriving through it. Two owners of one fact; the campaign answer is that Runtime owns the frame and App projects it. Route-3 adjacent. -- **#284 — OPEN — a placement that cannot resolve parks forever with no +- **#284 — DONE (2026-08-03) — a placement that cannot resolve parks forever with no diagnostic.** A first-entry placement whose world frame is absent returns `RetrySetupUnavailable` (`RuntimeSetPositionState.PrepareMover:1535-1543`) and is re-Advanced every pump indefinitely. Nothing counts it, names its @@ -77,6 +77,23 @@ reconciling #281's 43 test failures. exception pattern established in `01f4791e`. Doing this FIRST makes #282, #283, and every C4 route cheaper to diagnose and lets the connected gates fail on nonzero parked entries. + **Landed 2026-08-03 (S1).** `RetryWorldFrameUnavailable` splits the + world-frame park from the Setup park, so a missing frame stops reporting + itself as an asset problem; call sites now test `IsRetryable()` instead of + one reason, so a future reason cannot be silently demoted to a rejection. + The operation retains its `RuntimeSetPositionParkReason`, and + `RuntimeSetPositionOwnershipSnapshot` reports + `ParkedAwaitingSetupCollisionCount` / `ParkedAwaitingWorldFrameCount` / + `ParkedPlacementCount`. `ObserveLocalPlayerCreate` records the accepted + local-player Create even when it carries no landblock, and + `ThrowIfWorldFrameUnreachable` makes that contradiction terminal instead of + an infinite silent retry. + **Deliberately NOT folded into `IsConverged`:** #277 documents a far Create + legitimately parking for the whole session, so a parked entry at teardown is + not automatically a defect. The counts are exposed for gate assertions at + stable checkpoints; wiring them into the connected gates' `report.json` is + carried with #277's service-window conversion, where "legitimately parked" + becomes precisely definable. ## C3c placement cutover — 2026-08-02 diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index cd9e5a59..d2e49129 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -556,17 +556,17 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Func? retirePriorProjection) { EnsureNotDisposed(); - if (beginInitialResidence - && isLocalPlayer - && (incoming.Physics?.Position ?? incoming.Position) - is { LandblockId: not 0u } initialPlayerPosition) + if (beginInitialResidence && isLocalPlayer) { // The accepted local Create establishes the shared world frame // before any remote first-entry conductor converts its authored - // landblock-local coordinates. - Physics.ObserveLocalWorldFrame( - initialPlayerPosition.LandblockId, - teleportAdvanced: false); + // landblock-local coordinates. #284: observe the Create itself + // even when it carries no usable landblock - that is precisely + // the case in which no frame is ever published, and every remote + // placement would otherwise park forever without a diagnostic. + Physics.ObserveLocalPlayerCreate( + (incoming.Physics?.Position ?? incoming.Position) + ?.LandblockId ?? 0u); } if (_sessionClearInProgress) { diff --git a/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs b/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs index 90ad5c61..1a49c992 100644 --- a/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs +++ b/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs @@ -348,8 +348,10 @@ internal sealed class RuntimeRemoteFirstEntryState gameTime, out RuntimeSetPositionCommand command, resolveWorldOffsetFromRuntimeFrame: true); - if (moverStatus - == RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable) + // #284: every retryable reason resumes on a later pump. Comparing + // against one reason would silently reclassify a new one as a + // hard rejection. + if (moverStatus.IsRetryable()) { return RuntimeRemoteFirstEntryStatus.AwaitingCollisionSource; } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs index 2328da2d..bd70b59b 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs @@ -364,8 +364,10 @@ internal sealed class RuntimeLocalPlayerFirstEntryState collisionSource, gameTime, out RuntimeSetPositionCommand command); - if (moverStatus - == RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable) + // #284: every retryable reason resumes on a later pump. Comparing + // against one reason would silently reclassify a new one as a + // hard rejection. + if (moverStatus.IsRetryable()) { return RuntimeLocalPlayerFirstEntryStatus .AwaitingCollisionSource; diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index 77554eee..70fd147e 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -449,6 +449,7 @@ public sealed class RuntimePhysicsState : IDisposable private long _nextCollisionPreparationSequence; private ulong _collisionWorldAuthority = 1UL; private uint _worldFrameCenterLandblockId; + private bool _localPlayerCreateObserved; private readonly List> _collisionGenerationCommittedObservers = new(); private bool _disposed; @@ -545,6 +546,40 @@ public sealed class RuntimePhysicsState : IDisposable } } + /// + /// #284: records that this session's local-player Create was accepted, + /// independently of whether it carried a usable landblock. The frame is + /// published exactly once per session from that Create, so after it has + /// been observed a still-absent frame can never be supplied by a later + /// pump - see . + /// + internal void ObserveLocalPlayerCreate(uint fullCellId) + { + EnsureNotDisposed(); + _localPlayerCreateObserved = true; + ObserveLocalWorldFrame(fullCellId, teleportAdvanced: false); + } + + /// + /// #284: converts an unresolvable world-frame wait into a loud failure. + /// Parking is legitimate only while the local-player Create is still + /// outstanding; once it has been accepted without publishing a frame, + /// every remote placement would retry forever in silence and the entities + /// would simply never appear. Terminal by design - this is a violated + /// invariant, not resumable work. + /// + internal void ThrowIfWorldFrameUnreachable(uint fullCellId) + { + if (!_localPlayerCreateObserved || _worldFrameCenterLandblockId != 0u) + return; + + throw new InvalidOperationException( + "Runtime's world frame is unreachable: the local-player Create " + + "was accepted without publishing a frame, so the placement for " + + $"landblock 0x{fullCellId & 0xFFFF0000u:X8} can never resolve. " + + "A local-player CreateObject must carry a non-zero landblock."); + } + /// /// Converts a retail landblock-local network frame into the Runtime's /// current world frame without consulting presentation or waiting for @@ -1363,6 +1398,7 @@ public sealed class RuntimePhysicsState : IDisposable SetPosition.ResetSession(); CollisionReports.ResetSession(); _worldFrameCenterLandblockId = 0u; + _localPlayerCreateObserved = false; AdvanceCollisionWorldAuthority(); Volatile.Write(ref _collisionMutationThreadId, 0); } @@ -2056,6 +2092,7 @@ public sealed class RuntimePhysicsState : IDisposable _collisionAdmissions.Clear(); _collisionGenerations.Clear(); _worldFrameCenterLandblockId = 0u; + _localPlayerCreateObserved = false; CellCommitted = null; _collisionGenerationCommittedObservers.Clear(); _disposed = true; diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs index 9c46f161..fc6aef6e 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs @@ -9,11 +9,72 @@ namespace AcDream.Runtime.Physics; internal enum RuntimeSetPositionMoverPreparationStatus { Prepared, + + /// + /// The authored Setup collision payload has not been read yet. Resolves + /// when the prepared-asset read completes. + /// RetrySetupUnavailable, + + /// + /// #284: Runtime's world frame is not published yet, so a landblock-local + /// Create origin cannot be converted + /// (). Previously + /// this reported itself as , which sent + /// anyone diagnosing a parked placement to the asset pipeline instead of + /// the missing local-player Create that actually publishes the frame. + /// Retryable exactly like Setup - only the reason differs. + /// + RetryWorldFrameUnavailable, + RejectedAuthority, InvalidData, } +internal static class RuntimeSetPositionMoverPreparationStatusExtensions +{ + /// + /// True for the statuses whose work can still succeed on a later pump. + /// Call sites must use this rather than comparing against one reason, so + /// a newly added retry reason cannot be silently treated as a rejection. + /// + internal static bool IsRetryable( + this RuntimeSetPositionMoverPreparationStatus status) => + status is RuntimeSetPositionMoverPreparationStatus + .RetrySetupUnavailable + or RuntimeSetPositionMoverPreparationStatus + .RetryWorldFrameUnavailable; + + /// + /// The parked-placement reason a retryable status contributes to the + /// ownership ledger, or + /// when the status is not a park. + /// + internal static RuntimeSetPositionParkReason ParkReason( + this RuntimeSetPositionMoverPreparationStatus status) => + status switch + { + RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable => + RuntimeSetPositionParkReason.AwaitingSetupCollision, + RuntimeSetPositionMoverPreparationStatus + .RetryWorldFrameUnavailable => + RuntimeSetPositionParkReason.AwaitingWorldFrame, + _ => RuntimeSetPositionParkReason.None, + }; +} + +/// +/// #284: why a first-entry placement is currently parked. Retained on the +/// operation so CaptureOwnership can report parked work by cause +/// instead of leaving it invisible until a downstream symptom appears. +/// +internal enum RuntimeSetPositionParkReason +{ + None, + AwaitingSetupCollision, + AwaitingWorldFrame, +} + /// /// Distinguishes a Setup payload which has not arrived yet from a completed /// lookup whose result is absent. Retail supplies its dummy placement sphere diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index 0f6dcee0..41d47d86 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -273,8 +273,18 @@ internal readonly record struct RuntimeSetPositionOwnershipSnapshot( int AcknowledgedPlacementCompletionCount, int CollisionPrefixQuiescenceCount, int PendingQuiescenceProjectionCount, - int PooledOperationCount) + int PooledOperationCount, + int ParkedAwaitingSetupCollisionCount = 0, + int ParkedAwaitingWorldFrameCount = 0) { + /// + /// #284: placements that could not be prepared on their last attempt and + /// are waiting for a later pump, by cause. Non-zero at a stable + /// checkpoint means work is stuck; the reason names where to look. + /// + internal int ParkedPlacementCount => + ParkedAwaitingSetupCollisionCount + ParkedAwaitingWorldFrameCount; + internal bool IndexesConsistent => LostDeadlineCount == LostDeadlineNodeCount && LostDeadlineCount == LostDeadlineIndexCount @@ -399,6 +409,13 @@ internal sealed class RuntimeSetPositionState : IDisposable internal bool InheritedLostDeadline { get; set; } internal bool EnteringWorldFromCelllessResidence { get; set; } internal bool DormantLocalActivation { get; set; } + + /// + /// #284: why this operation's mover preparation is currently parked, + /// or when it is not. + /// Observability only - it never gates work. + /// + internal RuntimeSetPositionParkReason ParkReason { get; set; } internal RuntimeSetPositionCommand? PreparedCommandAwaitingWithdrawalAck { get; @@ -464,6 +481,7 @@ internal sealed class RuntimeSetPositionState : IDisposable InheritedLostDeadline = false; EnteringWorldFromCelllessResidence = false; DormantLocalActivation = false; + ParkReason = RuntimeSetPositionParkReason.None; PreparedCommandAwaitingWithdrawalAck = null; InPool = false; } @@ -726,6 +744,8 @@ internal sealed class RuntimeSetPositionState : IDisposable { int deferred = 0; int awaitingPreparation = 0; + int parkedAwaitingSetup = 0; + int parkedAwaitingWorldFrame = 0; foreach (Operation operation in _operations.Values) { if (operation.Stage @@ -735,6 +755,15 @@ internal sealed class RuntimeSetPositionState : IDisposable } if (operation.WakeableLostCell) deferred++; + switch (operation.ParkReason) + { + case RuntimeSetPositionParkReason.AwaitingSetupCollision: + parkedAwaitingSetup++; + break; + case RuntimeSetPositionParkReason.AwaitingWorldFrame: + parkedAwaitingWorldFrame++; + break; + } } int pendingQuiescenceProjections = 0; foreach (CollisionPrefixQuiescence quiescence @@ -764,7 +793,9 @@ internal sealed class RuntimeSetPositionState : IDisposable _acknowledgedPlacementCompletions.Count, _collisionPrefixQuiescence.Count, pendingQuiescenceProjections, - _operationPool.Count); + _operationPool.Count, + parkedAwaitingSetup, + parkedAwaitingWorldFrame); } internal int PendingProjectionCount => _pendingProjection.Count; @@ -1518,8 +1549,10 @@ internal sealed class RuntimeSetPositionState : IDisposable if (!preparation.Setup.IsResolved) { - return RuntimeSetPositionMoverPreparationStatus - .RetrySetupUnavailable; + return Park( + operation, + RuntimeSetPositionMoverPreparationStatus + .RetrySetupUnavailable); } RuntimeSetPositionMoverPreparation effectivePreparation = preparation; @@ -1537,8 +1570,19 @@ internal sealed class RuntimeSetPositionState : IDisposable out float worldOffsetX, out float worldOffsetY)) { - return RuntimeSetPositionMoverPreparationStatus - .RetrySetupUnavailable; + // #284: the frame is published once, by the accepted + // local-player Create, and is never withdrawn inside a + // session. If that Create has already been observed, waiting + // is not a wait - no later pump can ever supply the frame, so + // parking here would retry forever in silence. Surface the + // contradiction instead, exactly as 01f4791e made a violated + // receipt-ledger invariant terminal rather than resumable. + _physics.ThrowIfWorldFrameUnreachable( + authority.AcceptedPosition.LandblockId); + return Park( + operation, + RuntimeSetPositionMoverPreparationStatus + .RetryWorldFrameUnavailable); } effectivePreparation = preparation with @@ -1569,9 +1613,24 @@ internal sealed class RuntimeSetPositionState : IDisposable PreparedCommand = command, }; + operation.ParkReason = RuntimeSetPositionParkReason.None; return RuntimeSetPositionMoverPreparationStatus.Prepared; } + /// + /// #284: records why this operation could not be prepared, so parked work + /// is visible in by cause rather than only + /// as a downstream symptom. Retains the reason on the operation until the + /// preparation succeeds or the operation is retired. + /// + private static RuntimeSetPositionMoverPreparationStatus Park( + Operation operation, + RuntimeSetPositionMoverPreparationStatus status) + { + operation.ParkReason = status.ParkReason(); + return status; + } + /// /// C0-3: chains the exact-Setup mover pipeline end-to-end for an /// authored placement (initial-Create or any other authored-mover diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs index d1e4f973..1cd31f01 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs @@ -2404,6 +2404,7 @@ public sealed class RuntimeSetPositionStateTests "InheritedLostDeadline", "EnteringWorldFromCelllessResidence", "DormantLocalActivation", + "ParkReason", "PreparedCommandAwaitingWithdrawalAck", "InPool", ]; diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeWorldFrameTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeWorldFrameTests.cs index 2f0f35f3..2253a7c3 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeWorldFrameTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeWorldFrameTests.cs @@ -3,6 +3,7 @@ using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; namespace AcDream.Runtime.Tests.Physics; @@ -148,6 +149,80 @@ public sealed class RuntimeWorldFrameTests Assert.Equal(-192f, sourceX); } + /// + /// #284: waiting for the world frame is legitimate only while the + /// local-player Create is still outstanding. The frame is published once + /// per session from that Create, so once it has been accepted WITHOUT + /// publishing one - a local-player CreateObject carrying no landblock - + /// no later pump can ever supply it, and every remote placement would + /// retry forever in silence. That is a violated invariant, not a wait. + /// + [Fact] + public void ALocalPlayerCreateWithNoLandblock_MakesTheFrameUnreachable() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + BindGeneration(lifetime); + + // Before the local player is seen at all, waiting is legitimate. + lifetime.Physics.ThrowIfWorldFrameUnreachable(CenterCell); + + lifetime.Physics.ObserveLocalPlayerCreate(0u); + + InvalidOperationException error = + Assert.Throws(() => + lifetime.Physics.ThrowIfWorldFrameUnreachable(CenterCell)); + Assert.Contains("world frame is unreachable", error.Message); + } + + [Fact] + public void AnAcceptedLocalPlayerCreate_LeavesTheFrameReachable() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + BindGeneration(lifetime); + + lifetime.Physics.ObserveLocalPlayerCreate(CenterCell); + + // The frame exists, so nothing is unreachable and nothing throws. + lifetime.Physics.ThrowIfWorldFrameUnreachable(CenterCell); + Assert.True(lifetime.Physics.TryGetWorldFrameOffset( + CenterCell, + out _, + out _)); + } + + /// + /// #284: a missing world frame used to report itself as + /// RetrySetupUnavailable, sending anyone diagnosing a parked placement to + /// the asset pipeline rather than the absent local-player Create. Both + /// remain retryable - only the reported cause differs. + /// + [Fact] + public void ParkReasons_AreDistinctAndBothRetryable() + { + Assert.True(RuntimeSetPositionMoverPreparationStatus + .RetrySetupUnavailable.IsRetryable()); + Assert.True(RuntimeSetPositionMoverPreparationStatus + .RetryWorldFrameUnavailable.IsRetryable()); + Assert.False(RuntimeSetPositionMoverPreparationStatus + .RejectedAuthority.IsRetryable()); + Assert.False(RuntimeSetPositionMoverPreparationStatus + .InvalidData.IsRetryable()); + Assert.False(RuntimeSetPositionMoverPreparationStatus + .Prepared.IsRetryable()); + + Assert.Equal( + RuntimeSetPositionParkReason.AwaitingSetupCollision, + RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable + .ParkReason()); + Assert.Equal( + RuntimeSetPositionParkReason.AwaitingWorldFrame, + RuntimeSetPositionMoverPreparationStatus.RetryWorldFrameUnavailable + .ParkReason()); + Assert.Equal( + RuntimeSetPositionParkReason.None, + RuntimeSetPositionMoverPreparationStatus.Prepared.ParkReason()); + } + [Fact] public void AZeroCellIdNeitherPublishesNorResolves() {