diff --git a/docs/research/2026-08-04-c4-route-4b-1-review-findings.md b/docs/research/2026-08-04-c4-route-4b-1-review-findings.md new file mode 100644 index 00000000..aec7c1c3 --- /dev/null +++ b/docs/research/2026-08-04-c4-route-4b-1-review-findings.md @@ -0,0 +1,175 @@ +# C4 route 4b-1 — dual review FAIL, and a design correction (2026-08-04) + +Both mandated reviews returned **FAIL**. Nothing is committed. This supersedes +the 4b-1 contract, **whose central premise was factually wrong.** + +## The premise error — mine + +The contract justified refuse-rather-than-park with: *"retail's world is fully +resident, so 'arrived but not placeable' is unrepresentable there."* + +**False.** Retail represents it explicitly, with a working park: + +- `CPhysicsObj::SetPositionInternal` @0x00515BD0 — when `AdjustPosition` yields + no cell (@0x00515C1D): `prepare_to_leave_visibility` @0x00515CDA, + `store_position` @0x00515CE2 (**the destination pose IS committed**), + `CObjectMaint::GotoLostCell` @0x00515CF2, clear transient 0x80 @0x00515CF7, + return `OK_SPE` @0x00515D07. +- `CObjectMaint::GotoLostCell` @0x00508210 appends the object to that cell's + lost list. +- `CObjectMaint::InitObjCell` @0x00508260 drains the list on cell load and calls + `reenter_visibility` per object @0x00508296. +- `MoveOrTeleport` discards the `SetPositionError` from both + `SetPosition` @0x00516420 and `SetPositionSimple` @0x005163D9 and returns 1 + regardless, so `ConstrainTo` @0x00454272 is armed **even when the placement + failed**. + +Retail's reaction to an unplaceable remote: advance the pose, hide the object, +register it lost, wake it on cell arrival, arm the leash anyway. Our refusal +leaves the remote **visible at a stale pose**. For a remote that teleports into +a non-resident landblock and then stops moving — ACE stops broadcasting for a +stationary entity — "the next packet is the retry" never arrives and the +divergence is permanent. + +## Refuse cannot be made complete — the second reason to abandon it + +The guard checks ONE landblock (`accepted.LandblockId`). Core defers on at least +four independent conditions: + +1. **`PlacementTouchesPrefix` matches the entity's CURRENT cell**, not only the + destination (`RuntimeSetPositionState.cs:3668-3673`, consumed `:2916`). A + remote standing in a quiescing landblock, moving to a perfectly published + destination, parks. That is the contract's own producing sequence with + ordinary streaming churn behind it. +2. **`ResultTouchesPrefix` matches every cell in `QueriedCellIds`** + (`:3675-3690`, consumed `:2981`) — the sweep footprint spans neighbouring + landblocks near a boundary, so a quiescing NEIGHBOUR parks a fine placement. +3. **Engine-level non-residency after `AdjustToOutside`** + (`PhysicsEngine.cs:1309-1318`, `:1464-1475`) — the adjusted cell can land in + an adjacent landblock, evaluated against THAT landblock. Not visible to any + pre-flight caller. +4. Headless, where the predicate is weaker still (see below). + +**A pre-flight guard cannot close conditions that only Core can see.** Refuse is +structurally incapable of being complete. + +## The actual root cause — and it is shipped, not new + +`ParkDeferred` (`RuntimeSetPositionState.cs:4088-4120`) withdraws the entity: +`body.InWorld = false`, `Active` cleared, `WithdrawCanonical` (→ +`RemoveSpatialProjection` + `SetFullCell(record, 0u, 0u)`), `SuspendObjectClock`. + +`CancelCoreDeferred` (`:5131-5198`) removes the operation, rewrites +`Withdraw`→`Discard`, and **restores none of it**. The only `InWorld = true` in +the file is the local-player dormant-activation commit (`:2591`). + +So cancelling a wakeable park is strictly worse than retaining one: the park is +at least wakeable; the cancel destroys the only object that could wake it. + +**This affects route 2's DeferredCell path too.** Route 2 compensates with its +re-issue funnel — which is correct for a one-shot ForcePosition and wrong for a +5-10 Hz remote stream. So the underlying defect has been masked, not fixed. + +## Corrected direction for the next round + +**Make the park work, at the source, modelled on retail's lost-cell.** A +cancellation of a wakeable park must restore what `ParkDeferred` withdrew — +`InWorld`, the object clock, and canonical residency — or the park must survive +the merge-time `Forget` so its collision-generation wake can still fire. + +The 4b-1 contract said a withdrawal-restore inside `RuntimeSetPositionState` +required STOP-and-report. **That stop has now happened and this is the answer**: +refuse is structurally incomplete, retail has a working park, and the restore +fixes route 2's latent path as well. Proceed with it deliberately. + +The service-window guard still has value as an OPTIMISATION — avoiding parks we +can cheaply predict — but it is no longer the correctness mechanism and must not +be presented as one. + +Whatever residual divergence remains after this needs a +`docs/architecture/retail-divergence-register.md` row measured against retail's +`GotoLostCell`/`reenter_visibility` behaviour, not against a "retail-shaped" +label. + +## The other blocking findings + +**B1 — the headless predicate is not a service window.** +`HeadlessSessionWorldProjection.cs:254-266` is a pure 3x3 Chebyshev GEOMETRY +test against `_requestedCenterLandblock`, and returns `true` outright when no +centre has been requested. Its own pre-existing doc says "can EVER +collision-publish". The new interface promises "currently published". Signature +match, predicate mismatch — the same over-permissiveness the graphical adapter +explicitly rejected `IsNearTierOrPending` for. `IsReady` (`:268-285`) is the +correct shape and sits fourteen lines below. Remove the "same question" claim. + +**B2 — the `ParkCollisionResidents` evidence is void, and the real hazard is a +different one.** The delivered test demonstrates the two states that were +already safe and calls `ParkCollisionResidents` DIRECTLY, bypassing +`TryAcquireCollisionPrefixMutationPermission`'s `HasOldPrefixPlacementDebt` +check (`:3641-3666`, consumed `:887`) — which is the thing that actually makes +the throw unreachable. The genuine hazard is not a throw but an **indefinite +streaming stall**: that predicate refuses permission on every poll while a +retained retry is held, so the landblock never retires. `RetrySetupUnavailable` +on an asset that never loads makes it permanent, and `DetachRoute` clears +`_pending` WITHOUT cancelling the operation, orphaning it until session reset +while it continues to pin the prefix. + +**B3 — `Advance()` re-submits with no service-window re-check** (`:298-332`, +`SubmitAndResolve` `:334-394`). A retained entry can sit across many frames +while its destination retires. + +**B4 — `Committed` leaves an untracked live operation.** It returns and retains +nothing while the operation sits at `AwaitingCommitAcknowledgement`, retired +only by `AcknowledgeProjection`. `RemotePlacementDrivePendingCount` cannot see +it, so the ledger is blind to exactly the class that produces B2's stall. + +**B5 — N3's actual fix is untested.** `HeadlessSessionHost.cs:328` has zero +coverage; the new test hand-builds the route and never touches `Tick`. Also: +per-tick `RetryPendingProjections()` does `_pendingProjection.Values.ToArray()`, +a new per-tick allocation K4's 30-session envelope was measured without; and +headless dereferences the route directly with no generation latch, where +graphical goes through `RuntimePlacementProjectionRetrySlot` which refuses a +stale-generation callback. + +**B6 — `OwnsPlacement` keys on Disposition alone** (`:189-191`). The classifier +also emits `SetPositionSimple` for the local player's FORCE_POSITION and +teleport branches, and `SetPosition` for every initial Create. `record` and +`route` are separate parameters, so a mismatched pair is expressible. One +`route.OperationKind is RemoteAuthoritative` guard makes ownership exact. + +**B7 — three comments cite a "route 4b-1 report" that does not exist** +(`GraphicalRemotePlacementServiceWindow.cs:57`, +`RuntimeRemotePlacementDriveController.cs:122`, and the test file `:28`). Two of +them point at precisely the evidence the contract demanded. + +**B8 — advisory for 4b-2/4b-3**: retail arms `ConstrainTo` even when the +placement failed, so the successors must arm on refusal/rejection too, not only +on commit. "Arm on Committed" is the natural misreading and is the same shape as +the already-recorded unarmed-leash bug. + +## Verified correct — do not churn + +- Both omissions are right: `SendPositionEvent` is local-player-FORCE-only + (@0x00454091 inside the @0x0045400C gate); the remote arm @0x0045414D has no + equivalent, and retail never re-attempts — stale timestamps just bump + `error_count` @0x004542AC. +- The disposition mapping is exact for Remote-kind routes: `SetPosition` ≡ + teleport-or-cell-less (@0x00516386, flags 0x1012), `SetPositionSimple` ≡ far + snap (@0x005163C1-E8). +- The graphical co-extensivity argument holds in both directions, independently + verified by both reviewers. `IsNearTier` over `IsNearTierOrPending` is right. +- Contract item 2 holds: no production caller, no behaviour change, ledger + member always 0 in production. +- The "Do NOT touch" list was respected — AP-135's writes, the teleport classes, + the legacy far halves, the single `ConstrainTo` site, route 1's executor. +- Per-entity mechanics are otherwise sound: incarnation-keyed identity, + self-heal, `_driveScratch` snapshotting, `_driving` re-entry guard. +- N3's ordering matches the graphical route. + +## Gate + +Complete Release suite, not a subset. Baseline **10,938 / 4 / 0**; the 4b-1 +state measured 10,955 / 4 / 0 while being defective. Two known flakes, do not +chase and do not conflate: **#302** (`PortalProjectionTests…`, GC-allocation +assertion, App.Tests) and **#308** (`NakEmissionTests.LossSoak_…`, wall-clock +deadline, Core.Net.Tests, full-suite load only). diff --git a/src/AcDream.App/Streaming/GraphicalRemotePlacementServiceWindow.cs b/src/AcDream.App/Streaming/GraphicalRemotePlacementServiceWindow.cs new file mode 100644 index 00000000..49a5f17f --- /dev/null +++ b/src/AcDream.App/Streaming/GraphicalRemotePlacementServiceWindow.cs @@ -0,0 +1,91 @@ +using AcDream.Runtime.Session; + +namespace AcDream.App.Streaming; + +/// +/// C4 route 4b-1: the graphical host's +/// — headless already had +/// one (HeadlessCollisionNeighborhood.IsWithinServiceWindow, +/// HeadlessSessionWorldProjection.cs:27/:245-257); the +/// graphical host had none. +/// +/// +/// +/// Predicate choice — , not +/// . The contract asks +/// whether near-tier residency is exactly co-extensive with collision +/// publication and, if not, to propose the correct predicate rather than +/// ship the assumption. It is not — for the "pending" half specifically: +/// IsNearTierOrPending is true for a landblock that has only been +/// PARKED as a pending near-tier entity bucket +/// (GpuWorldState.CommitEntitiesToExistingLandblockSpatialCore's +/// parkIfMissing branch, :1919-1952) — i.e. queued, not yet +/// collision-published. Using it here would let this guard say "go" for a +/// destination whose collision generation has not committed, reopening +/// exactly the DeferredCell park this route exists to prevent. +/// alone is the correct predicate: +/// +/// +/// Promotion direction (verified by reading, not assumed): the only +/// two writers of _tierByLandblock[...] = LandblockStreamTier.Near are +/// GpuWorldState.CommitLandblockSpatialCore (:969) and +/// CommitEntitiesToExistingLandblockSpatialCore (:1972), both called +/// from LandblockPresentationPipeline.Advance's +/// SpatialPresentationCommitted stage — which runs strictly AFTER the +/// preceding PresentationCommitted stage, the one that drives +/// LandblockPhysicsPublisher's staged collision/EnvCell advance to +/// completion (LandblockPresentationPipeline.cs ~826-935). A +/// landblock's tier cannot read Near before its collision has committed. +/// +/// +/// Retirement direction (verified by reading, not assumed): +/// GpuWorldState.DetachNearLayer (:1755-1834) flips the tier to Far as +/// the FIRST, synchronous step of a "detach-first" landblock retirement — +/// LandblockRetirementCoordinator.AdoptDetachedFull's own doc comment +/// states spatial detachment has already committed by the time its retirement +/// ticket is constructed, and that ticket's LandblockRetirementStage.Physics +/// step (_physics.AdvanceRemovalRuntimeSetPositionState +/// .WithdrawCollision → the collision-side retirement +/// ParkCollisionResidents can reach) runs strictly AFTER. A +/// landblock's tier cannot still read Near once its collision has begun +/// retiring. +/// +/// +/// Residual, not closed: a landblock-prefix collision MUTATION that is +/// not a full retirement (a live in-place quiescence/refresh while the tier +/// stays Near) is not ruled out by this reading and is not exercised by any +/// gate this route adds. RuntimeSetPositionState's private +/// TryGetBlockingQuiescence (:3820-3863) is Core's own check for +/// exactly this case — an active CollisionPrefixQuiescence entry for +/// the destination's landblock prefix, independent of tier/residency — and +/// it is what a placement can still hit even after this guard passes, +/// producing the narrow DeferredCell residual +/// 's +/// class doc describes. +/// +/// +internal sealed class GraphicalRemotePlacementServiceWindow + : IRuntimeRemotePlacementServiceWindow +{ + private readonly GpuWorldState _state; + + internal GraphicalRemotePlacementServiceWindow(GpuWorldState state) + { + _state = state ?? throw new ArgumentNullException(nameof(state)); + } + + /// + /// is a full ACE cell id (landblock high + /// word + cell low word — CreateObject.ServerPosition.LandblockId + /// carries the full id despite its name; see + /// RuntimeAcceptedPositionDriveControllerTests's own + /// SourceCell = SourceLandblock | 0x0001u fixture shape). + /// Canonicalized to 0xAAAAFFFF the same way every + /// tier writer/reader does + /// (DetachNearLayer, CommitLandblockSpatialCore) before the + /// dictionary lookup — itself does + /// not canonicalize its argument. + /// + public bool IsWithinServiceWindow(uint landblockId) => + _state.IsNearTier((landblockId & 0xFFFF0000u) | 0xFFFFu); +} diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs b/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs index cfb87ef7..24a5d1fa 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs @@ -20,6 +20,17 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive; private readonly Action? _localPlayerCompleted; private RuntimePlacementProjectionSubscription? _subscription; + /// + /// B5(c) review fix: the graphical host's per-frame retry callback goes + /// through RuntimePlacementProjectionRetrySlot, which refuses a + /// callback whose bound generation is no longer current + /// (RetryPending's own guard). Headless calls this route's + /// directly with no equivalent latch — this + /// field plus the check inside restore that + /// same "the generation this route attached under must still be the + /// live one" guard. + /// + private RuntimeGenerationToken _attachedGeneration; private bool _attachStarted; private bool _eventsDisposed; private bool _disposed; @@ -51,6 +62,12 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting // succeeds and throws, LiveSessionHost's retryable rollback still // invokes Dispose on the underlying route. _attachStarted = true; + // B5(c) review fix: capture the generation this route is attaching + // under — RetryPending's own check below refuses a call reached + // after Runtime has since moved to a newer generation, mirroring + // RuntimePlacementProjectionRetrySlot.BindOwned/RetryPending's + // guard on the graphical side. + _attachedGeneration = _runtime.Generation; // C3c-R1 review F6: assert (not assume) that the prior route // detached — session reset precedes a new route — before this route // takes ownership of the shared drive controller's tracked entries. @@ -64,6 +81,59 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting _placements); } + /// + /// C4 route 4b-1 (N3): republishes the canonical placement FIFO's + /// current head. only retries once, at subscribe + /// time (retryPendingOnSubscribe: true) — the ONLY + /// RetryPending call headless made before this fix. The graphical + /// host's per-frame retry lease also drives pending first-entry/accepted- + /// position sequences first and republishes the FIFO last + /// (GraphicalSessionEventRoute.Attach's retry-lease callback); + /// calls this in the same order + /// immediately after HeadlessSessionWorldProjection.PumpFirstEntry + /// so a declined head left behind by + /// + /// (a delta that is not yet the FIFO head is never revisited on its own) + /// gets a retry every headless tick, not only once per session. + /// + /// + /// B5(c) review fix: refuses when 's generation has + /// moved past the one this route attached under — the graphical host's + /// RuntimePlacementProjectionRetrySlot already refuses a + /// stale-generation callback the same way; headless dereferenced + /// directly with no equivalent guard before + /// this fix. + /// + /// + /// C2-2 review fix (delta round): the earlier version of this comment + /// claimed RuntimeSetPositionState.cs was "not permitted to + /// touch" as a blanket premise — false; that file simply was NOT this + /// session's file to edit (a concurrent, separately-owned change was + /// landing in it). The early-out below is now real: it checks + /// + /// (added for this fix) rather than the Runtime placement channel + /// directly, so it never trips + /// RuntimePhysicsOwnershipTests.ProductionHostsUseSharedPlacementSubscriptionWithoutDirectChannel + /// — hosts still consume placement state ONLY through the subscription. + /// This closes the EMPTY-FIFO case (the overwhelming common case in + /// steady state). The non-empty case still allocates inside + /// RetryPendingProjections's own + /// _pendingProjection.Values.ToArray() snapshot, which needs a + /// change to RuntimeSetPositionState.cs this session did not make + /// — filed as docs/ISSUES.md #311 rather than worked around. + /// + /// + internal bool RetryPending() + { + if (_subscription is null + || _attachedGeneration != _runtime.Generation + || !_subscription.HasPendingReceipts) + { + return false; + } + return _subscription.RetryPending(); + } + public void Dispose() { if (_disposed) diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 2fe59664..19aded57 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -121,6 +121,17 @@ internal sealed class HeadlessSessionHost : IDisposable private readonly RuntimeLocalPlayerFrameController _localPlayerFrame; private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease? _contentLease; + /// + /// B5(a) review fix: test-only seam (mirrors policyOverride's own + /// pattern) letting a focused test substitute a deterministic fake + /// placement sink for the production , + /// so a test can drive itself — the real + /// _eventRoute?.RetryPending() call this fix covers — instead of + /// hand-constructing a outside + /// this host. null (every production caller) keeps today's exact + /// behavior. + /// + private readonly IRuntimePlacementProjectionSink? _placementSinkOverride; /// C3c: one per-host first-entry drive controller (lazy — its /// residence-begin subscription binds once against the persistent /// Runtime lifetime) plus the active world projection it pumps @@ -132,6 +143,14 @@ internal sealed class HeadlessSessionHost : IDisposable private RuntimeAcceptedPositionDriveController? _acceptedPositionDrive; private AcDream.Core.Net.WorldSession? _currentSession; private HeadlessSessionWorldProjection? _worldProjection; + /// C4 route 4b-1 (N3): the exact route + /// last constructed, so can republish the canonical + /// placement FIFO every tick — mirrors the graphical host's per-frame + /// retry lease (GraphicalSessionEventRoute.Attach). Reassigned on + /// every reconnect exactly like ; the prior + /// route's own disposal (via LiveSessionHost's route replacement) + /// is independent of this field. + private HeadlessSessionEventRoute? _eventRoute; private int _disposeStage; private long _reconnectDeadline; private bool _reconnectPending; @@ -150,7 +169,8 @@ internal sealed class HeadlessSessionHost : IDisposable TimeSpan? reconnectQuiescence = null, HeadlessProcessContentOwner.HeadlessProcessContentLease? contentLease = null, - IHeadlessBotPolicy? policyOverride = null) + IHeadlessBotPolicy? policyOverride = null, + IRuntimePlacementProjectionSink? placementSinkOverride = null) { _descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor)); @@ -158,6 +178,7 @@ internal sealed class HeadlessSessionHost : IDisposable ?? throw new ArgumentNullException(nameof(credential)); _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + _placementSinkOverride = placementSinkOverride; _timeProvider = timeProvider ?? TimeProvider.System; _reconnectQuiescence = reconnectQuiescence ?? (sessionOperations is null @@ -311,6 +332,13 @@ internal sealed class HeadlessSessionHost : IDisposable // collision-generation progress and freshly accepted Creates both // surface here, mirroring the graphical per-frame retry phase. _worldProjection?.PumpFirstEntry(); + // C4 route 4b-1 (N3): republish the canonical placement FIFO LAST, + // same order as the graphical host's retry-lease callback (drives + // first, retry last) — a declined Place left at the FIFO head by + // RuntimePlacementProjectionSubscription is otherwise never + // revisited, because Attach's retryPendingOnSubscribe only fires + // once, at subscribe time. + _eventRoute?.RetryPending(); _localPlayerFrame.RunPostNetworkCommandPhase(); Runtime.ActionOwner.CombatAttack.Tick(); _policy.Tick(Runtime, Commands); @@ -666,13 +694,16 @@ internal sealed class HeadlessSessionHost : IDisposable Runtime.CommunicationOwner.TurbineChat, Runtime.CommunicationOwner.Friends, Runtime.CommunicationOwner.Squelch)); - return new HeadlessSessionEventRoute( + var eventRoute = new HeadlessSessionEventRoute( route, Runtime, - new HeadlessRuntimePlacementProjectionSink(Runtime), + _placementSinkOverride + ?? new HeadlessRuntimePlacementProjectionSink(Runtime), _firstEntryDrive, _ => session.SendGameAction(GameActionLoginComplete.Build()), _acceptedPositionDrive); + _eventRoute = eventRoute; + return eventRoute; } private static LiveSessionCharacterSelector MapCharacterSelector( diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs index d7cea790..e2991694 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs @@ -174,8 +174,52 @@ internal sealed class HeadlessCollisionGenerationTransaction /// DAT and pak inputs. Every session retains its own engine, data cache, /// cell graph, shadow registry, and publication ledger. /// +/// +/// C4 route 4b-1: also implements +/// . +/// B1 review fix: despite the identical bool IsWithinServiceWindow(uint) +/// signature, the two interfaces ask DIFFERENT questions — +/// is a +/// pure geometry test ("can this landblock EVER collision-publish inside the +/// requested 3x3 window" — true outright when no center has been +/// requested yet), while +/// +/// promises "is it collision-published RIGHT NOW". Aliasing the new +/// interface straight to the geometry test would readmit exactly the +/// over-permissiveness the graphical adapter's own doc explicitly rejected +/// IsNearTierOrPending for. asks the right +/// question (it consults actual collision-publication residency), but it +/// MUTATES — it calls to progress publication work +/// as a side effect, which a per-packet service-window pre-flight check must +/// never do. is +/// 's residency shape with BOTH of its mutations +/// removed — the call AND the +/// _requestedFullCell field write (an earlier version of this comment +/// said "the ONE mutating call"; that undercounted — a field write is a +/// mutation too, just a cheaper one than pumping publication work). C2-3 +/// review fix (delta round): it is ALSO widened from 's +/// own _centerLandblock != center restriction (correct for +/// 's own use — "is the ONE landblock I most recently +/// centered on ready" — but wrong here) to membership in +/// , the full set +/// actually publishes (the requested center plus its 3x3 neighbors). Without +/// the widening this predicate answered true for at most ONE of the nine +/// landblocks this host has actually published, refusing a remote one +/// landblock off-center — exactly the boundary population route 4b-1 exists +/// to serve — and Advance would then drop its retry once the service +/// window (this predicate) forbade a destination that was genuinely +/// servable. is already scoped to the CURRENT +/// window by construction: 's re-center path fully +/// retires the old center's entries (via +/// 's reset-then-retire sequence) before the new +/// center's landblocks are ever added, so a stale neighbor from a PRIOR +/// center cannot linger in and be misread as +/// currently published. The explicit interface implementation below routes +/// to it instead of the geometry test. +/// internal sealed class HeadlessCollisionNeighborhood - : IHeadlessCollisionNeighborhood + : IHeadlessCollisionNeighborhood, + AcDream.Runtime.Session.IRuntimeRemotePlacementServiceWindow { private readonly record struct PublicationSpec( uint LandblockId, @@ -276,6 +320,49 @@ internal sealed class HeadlessCollisionNeighborhood .GetCellStruct(fullCellId) is not null; } + /// + /// B1 review fix: a residency check — resident, terrain-published, and + /// (for indoor cells) the CellStruct itself resolved — derived + /// from with both of its mutations removed (the + /// call and the _requestedFullCell field + /// write). This is what + /// + /// needs: "is collision published for this landblock RIGHT NOW", read + /// only, safe to call from a per-packet pre-flight check without racing + /// or pumping this neighborhood's own cadence-driven publication work. + /// C2-3 review fix (delta round): unlike , this + /// checks membership in the FULL set (the + /// requested center plus its published 3x3 neighbors) rather than + /// requiring an exact match against — see + /// the class remarks for why the narrower check was wrong for this + /// interface's question. + /// + private bool IsCollisionCurrentlyPublished(uint fullCellId) + { + uint landblock = CanonicalLandblock(fullCellId); + if (!_resident.Contains(landblock) + || !_runtime.EntityObjects.Physics.Engine + .IsLandblockTerrainResident(landblock)) + { + return false; + } + return (fullCellId & 0xFFFFu) < 0x0100u + || _runtime.EntityObjects.Physics.DataCache + .GetCellStruct(fullCellId) is not null; + } + + /// + /// B1 review fix: explicit implementation so this class can answer the + /// two IsWithinServiceWindow questions differently despite the + /// identical method signature — see the class remarks. Routes to + /// , never to the geometry + /// test implicitly implements for + /// . + /// + bool AcDream.Runtime.Session.IRuntimeRemotePlacementServiceWindow + .IsWithinServiceWindow(uint fullCellId) => + IsCollisionCurrentlyPublished(fullCellId); + private HeadlessCollisionGenerationTransaction? CreatePublication( uint landblockId, Vector3 origin, diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index cc9d53cc..453d32e9 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -88,7 +88,20 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( /// . /// Gated by — a leaked pending ack cannot hide. /// - int AcceptedPositionDrivePendingCount = 0) + int AcceptedPositionDrivePendingCount = 0, + /// + /// C4 route 4b-1: outstanding + /// AcDream.Runtime.Session.RuntimeRemotePlacementDriveController + /// preparation-retry entries (a not-yet-resolved + /// RetrySetupUnavailable/RetryWorldFrameUnavailable for a + /// remote), summed over every drive registered against this lifetime via + /// . + /// Gated by , mirroring + /// — steady-state + /// remotes hold no operations, and this count proves it at every + /// convergence checkpoint the same way. + /// + int RemotePlacementDrivePendingCount = 0) { public bool IsConverged => IsDisposed @@ -114,6 +127,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( && RemoteFirstEntryActiveCount == 0 && FirstEntryDrivePendingCount == 0 && AcceptedPositionDrivePendingCount == 0 + && RemotePlacementDrivePendingCount == 0 && StreamSubscriberCount == 0 && PlacementStreamSubscriberCount == 0 && PendingDispatchCount == 0 @@ -163,6 +177,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable private readonly List> _firstEntryDriveOwnership = []; /// C4 route 2: see . private readonly List> _acceptedPositionDriveOwnership = []; + /// C4 route 4b-1: see . + private readonly List> _remotePlacementDriveOwnership = []; /// /// C4 route 4a: captured by alongside the /// other generation-consuming children so @@ -486,7 +502,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable LocalPlayerFirstEntry.CaptureOwnership().ActiveCount, RemoteFirstEntry.CaptureOwnership().ActiveCount, CaptureFirstEntryDrivePendingCount(), - CaptureAcceptedPositionDrivePendingCount()); + CaptureAcceptedPositionDrivePendingCount(), + CaptureRemotePlacementDrivePendingCount()); } private int CaptureFirstEntryDrivePendingCount() @@ -505,6 +522,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable return total; } + private int CaptureRemotePlacementDrivePendingCount() + { + int total = 0; + for (int i = 0; i < _remotePlacementDriveOwnership.Count; i++) + total = checked(total + _remotePlacementDriveOwnership[i]()); + return total; + } + /// /// C3c-R1 review F5: registers one host first-entry drive controller's /// pending-count provider into this lifetime's ownership snapshot, so @@ -535,6 +560,22 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable _acceptedPositionDriveOwnership.Add(pendingCount); } + /// + /// C4 route 4b-1: registers one host + /// RuntimeRemotePlacementDriveController's pending-count provider + /// into this lifetime's ownership snapshot, mirroring + /// — a leaked + /// remote preparation retry must not sit outside every ledger. The drive + /// controller registers itself at construction; multiple registrations + /// sum (one per host route sharing this lifetime). + /// + public void RegisterRemotePlacementDriveOwnership(Func pendingCount) + { + ArgumentNullException.ThrowIfNull(pendingCount); + EnsureNotDisposed(); + _remotePlacementDriveOwnership.Add(pendingCount); + } + public void BindEventContext( Func generation, Func frameNumber) diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index c6d95943..877674c5 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -454,6 +454,21 @@ public sealed class RuntimePhysicsState : IDisposable _collisionGenerationCommittedObservers = new(); private bool _disposed; + /// + /// C2-1 review fix (delta round): lets a ledger-provider callback + /// registered against (e.g. + /// RuntimeRemotePlacementDriveController.CountLiveAwaitingAcknowledgement) + /// check disposal state BEFORE calling into , + /// whose own IsPlacementCurrent throws ObjectDisposedException + /// once disposed. Dispose() below disposes + /// strictly before setting this flag, so IsDisposed == true here + /// guarantees is already disposed too — a + /// post-Dispose() CaptureOwnership() read is the designed + /// contract (GameWindowLifetime.DisposeGameRuntime), so every + /// ledger provider must survive it without throwing. + /// + internal bool IsDisposed => _disposed; + public event Action? CellCommitted; public event Action? CollisionGenerationCommitted diff --git a/src/AcDream.Runtime/Physics/RuntimePlacementProjectionSubscription.cs b/src/AcDream.Runtime/Physics/RuntimePlacementProjectionSubscription.cs index 622cea4e..0347f12d 100644 --- a/src/AcDream.Runtime/Physics/RuntimePlacementProjectionSubscription.cs +++ b/src/AcDream.Runtime/Physics/RuntimePlacementProjectionSubscription.cs @@ -86,6 +86,20 @@ public sealed class RuntimePlacementProjectionSubscription public bool HasAppliedReceiptAwaitingAcknowledgement => _appliedAwaitingAcknowledgement.IsValid; + /// + /// C2-2 review fix (delta round, B5(b)): true when Runtime's placement + /// FIFO has at least one outstanding receipt, so a host can early-out + /// before calling without reaching the + /// Runtime placement channel directly — the architectural boundary + /// RuntimePhysicsOwnershipTests.ProductionHostsUseSharedPlacementSubscriptionWithoutDirectChannel + /// enforces (hosts consume placement state ONLY through this + /// subscription). Only closes the EMPTY-FIFO case: when the count is + /// nonzero, RetryPending still reaches + /// RuntimeSetPositionState.RetryPendingProjections's per-call + /// array snapshot — see docs/ISSUES.md for that residual. + /// + public bool HasPendingReceipts => _channel.PendingCount != 0; + /// /// Republishes Runtime's complete still-pending FIFO. Later receipts are /// ignored until the exact oldest receipt projects and acknowledges. diff --git a/src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs b/src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs new file mode 100644 index 00000000..6c16f5f5 --- /dev/null +++ b/src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs @@ -0,0 +1,634 @@ +using AcDream.Content; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Session; + +/// +/// C4 route 4b-1: per-entity remote-placement service-window predicate. A +/// host implements this over whatever collision-publication residency it +/// actually tracks — GpuWorldState.IsNearTier for the graphical host, +/// HeadlessCollisionNeighborhood's explicit implementation (backed by +/// its private IsCollisionCurrentlyPublished — NOT +/// , +/// which is a pure geometry test over the requested 3x3 window and answers a +/// different question: "can this landblock EVER collision-publish", not "is +/// it collision-published right now" — B1 review fix) for headless — so +/// can +/// ask, BEFORE attempting any canonical SetPosition, whether the accepted +/// destination is one this host can actually place a remote into right now. +/// +public interface IRuntimeRemotePlacementServiceWindow +{ + /// + /// True when 's collision is currently + /// published by this host, so a canonical SetPosition into it can be + /// attempted without risking an un-wakeable DeferredCell park (see + /// docs/research/2026-08-04-c4-route-4b-1-contract.md's "central + /// decision"). may be a full cell id — the + /// implementation canonicalizes to the containing landblock. + /// + bool IsWithinServiceWindow(uint landblockId); +} + +/// +/// Typed yields for +/// . +/// +internal enum RuntimeRemotePlacementExecutionStatus : byte +{ + /// Out of this route's scope: not a disposition this + /// controller owns (), + /// no canonical body, or no incarnation key. + NotApplicable, + + /// + /// The central decision (see the class doc on + /// ): the accepted + /// destination is not one this host can currently collision-publish, or + /// the canonical SetPosition attempted anyway and Core still deferred it. + /// No operation is retained either way — the entity keeps its last + /// committed pose and waits for the next accepted Position, which for a + /// remote is a 5-10 Hz stream away. + /// + Refused, + + /// The entity already holds an active operation — a concurrent + /// placement authority (portal/teleport/another host route) or this + /// controller's own still-outstanding preparation retry. + Contention, + + /// The canonical SetPosition committed synchronously. + Committed, + + /// Rejected/cancelled by Core (invalid prepared data, authority + /// displaced mid-submit). + Rejected, +} + +/// +/// C4 route 4b-1: the Runtime-owned, per-entity accepted-Position execution +/// seam for a remote whose classification is SetPosition (teleport / +/// cell-less — 4b-3's eventual disposition) or SetPositionSimple (far +/// snap, >=96 m — 4b-2's). Route 4a's +/// already owns the two +/// dispositions that perform no SetPosition at all +/// (NoPositionOperation/Interpolate); this controller is the +/// architectural sibling for the remaining two, built from route 2's +/// controller shape with two deliberate omissions and one deliberate +/// generalization: +/// +/// +/// No ack. Retail's remote arm has no +/// SendPositionEventHandleReceivedPosition @0x00453FD0 calls +/// it only on the local-player FORCE_POSITION branch. There is nothing here +/// resembling route 2's PositionEventOwed/SendPositionEvent +/// pair. +/// No re-issue funnel. Route 2 re-issues a dead +/// operation because a ForcePosition is a one-shot correction ACE never +/// repeats. A remote Position is a REPEATED stream — re-issuing packet N +/// after N+1 has already merged would apply a pose the newer packet already +/// superseded. When this controller's own tracked operation dies for any +/// reason (superseded, torn down, forgotten by the entity's next accepted +/// Position), it is simply dropped: the next packet supplies the current +/// truth on its own. +/// Per-entity, not per-session. Route 2's +/// _pending is a single slot because the local player is the only +/// entity that route ever touches. Remotes are N entities, so +/// is a per-key map and every entry is independent — +/// route 2's single-owner invariant (Begin refuses a second live entry for +/// the SAME key) is unchanged, just re-derived per entity instead of +/// globally. +/// +/// +/// +/// The central decision — refuse, do not park. +/// 's DeferredCell park +/// withdraws the entity from the world (ParkDeferred sets +/// body.InWorld = false, suspends the object clock, and publishes a +/// Withdraw) — and RuntimeEntityObjectLifetime.TryApplyPosition +/// calls RuntimeSetPositionState.Forget on EVERY subsequent accepted +/// Position for that same entity, regardless of disposition. Forget's +/// CancelCoreDeferred removes the operation and rewrites the +/// Withdraw into a Discard WITHOUT restoring InWorld, +/// resuming the clock, or re-entering residency. Because ACE broadcasts a +/// remote's Position every 100-150 ms — almost always faster than the +/// collision-generation wake this park would need to resolve on its own — +/// any DeferredCell park opened here would be cancelled by the entity's own +/// next packet long before it could wake, leaving the entity invisible AND +/// intangible for the rest of the session. +/// exists to prevent this controller from ever attempting a SetPosition +/// whose destination cannot be placed right now: +/// checks it BEFORE calling +/// , +/// and if Core still returns DeferredCell despite the guard passing, +/// the operation is cancelled immediately rather than retained, so the +/// ledger still converges even though the guard's invariant did not hold for +/// that one packet. Why the guard can still miss (the co-extensivity +/// finding and its residual): a tier/residency-backed service window +/// (the graphical host's GpuWorldState.IsNearTier, headless's +/// collision-published check) is co-extensive with collision PUBLICATION in +/// both directions — verified by reading both tier-writer call sites +/// (promotion cannot read Near before collision commits) and the retirement +/// call site (the tier flips to Far as the FIRST, synchronous step of +/// retirement, strictly before collision-side withdrawal). It is NOT +/// co-extensive with a live in-place collision-prefix MUTATION that leaves +/// the tier/residency reading unchanged while the prefix quiesces — +/// 's private TryGetBlockingQuiescence +/// is Core's own check for exactly that case, and it is what a placement can +/// still hit even after this guard passes. That one narrow window is the +/// residual this guard cannot close from outside Core. +/// +/// +/// +/// One instance per host session route, constructed once per host process +/// and reused across reconnects exactly like +/// and +/// — +/// / assert the same +/// "session reset precedes a new route" ordering and clear any tracked +/// entries left by a torn-down session. +/// +/// +internal sealed class RuntimeRemotePlacementDriveController +{ + private sealed class Pending + { + internal required RuntimeEntityRecord Record { get; init; } + internal required RuntimeEntityPlacementToken Token { get; init; } + internal required RuntimeAuthoritativePositionRoute Route { get; init; } + } + + private readonly RuntimeEntityObjectLifetime _entityObjects; + private readonly IGameRuntimeClock _clock; + private readonly IPreparedCollisionSource _collisionSource; + private readonly IRuntimeRemotePlacementServiceWindow _serviceWindow; + + /// + /// Per-entity preparation-retry map (bounded to the two retryable + /// reasons — Setup + /// or world-frame data not resolved yet, NEITHER of which withdraws the + /// entity). A DeferredCell outcome never enters this map — see the + /// class doc's central decision. + /// + private readonly Dictionary _pending = []; + /// + /// B4 review fix: per-entity tokens whose + /// outcome was CommittedHostAcknowledgementPending and were STILL + /// live (not synchronously consumed-and-acknowledged by the production + /// placement-projection subscription inside that same call) the instant + /// returned Committed. Without this, + /// the ledger went blind the moment Committed was returned, hiding + /// exactly the declined-sink class the FIFO retry mechanism exists for. + /// Pruned lazily (self-healing) on every read — see + /// — never gates any + /// placement decision itself; Core's own _operations map remains + /// the sole authority TryBeginExclusiveAuthoredPlacement consults. + /// + private readonly Dictionary + _awaitingAcknowledgement = []; + private readonly List _driveScratch = []; + /// + /// C2-1 review fix (delta round): dedicated reusable scratch list for + /// 's self-heal removal + /// pass — kept separate from (owned by + /// ) so a ledger read reached while Advance + /// is mid-iteration can never corrupt its scratch buffer. + /// + private readonly List _awaitingAcknowledgementScratch = []; + private bool _driving; + private object? _routeOwner; + + internal RuntimeRemotePlacementDriveController( + RuntimeEntityObjectLifetime entityObjects, + IGameRuntimeClock clock, + IPreparedCollisionSource collisionSource, + IRuntimeRemotePlacementServiceWindow serviceWindow) + { + _entityObjects = entityObjects + ?? throw new ArgumentNullException(nameof(entityObjects)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _collisionSource = collisionSource + ?? throw new ArgumentNullException(nameof(collisionSource)); + _serviceWindow = serviceWindow + ?? throw new ArgumentNullException(nameof(serviceWindow)); + _entityObjects.RegisterRemotePlacementDriveOwnership( + () => _pending.Count); + // B4 review fix: a second, independent registration — multiple + // registrations sum (RegisterRemotePlacementDriveOwnership's own doc + // comment) — so the awaiting-acknowledgement dimension is visible in + // the SAME ledger without changing what _pending itself reports. + _entityObjects.RegisterRemotePlacementDriveOwnership( + CountLiveAwaitingAcknowledgement); + } + + /// + /// Preparation-stage retries only (see 's own doc). + /// Deliberately does NOT include — + /// that dimension is reported to the lifetime's ownership ledger via the + /// constructor's second RegisterRemotePlacementDriveOwnership call + /// (B4 review fix) and has no separate test-visible counter of its own. + /// + internal int PendingCount => _pending.Count; + + /// + /// True when route 4b owns this classification for a remote — the two + /// dispositions route 4a's + /// does NOT already handle. Everything else (Interpolate, + /// NoPositionOperation, RejectedAuthority, + /// RejectedData, AwaitFreshPosition) is out of scope here. + /// B6 review fix: Disposition alone is not exact — the classifier + /// (RuntimeAuthoritativePositionRouteClassifier.cs) assigns + /// SetPosition/SetPositionSimple at several independent + /// call sites (:256, :332, :355, :403, :459) and derives + /// OperationKind separately (:560-575) — the SAME disposition + /// covers the LOCAL PLAYER's FORCE_POSITION/teleport branches + /// (RuntimeSetPositionOperationKind.LocalAuthoritative). + /// is a parameter separate from the entity + /// record at every call site, so a mismatched pair is expressible; + /// gating on OperationKind narrows to remotes AND the local + /// player's own initial Create — OperationKind's own switch + /// (:559-570) maps InitialLogin to the LOCAL PLAYER's Create + /// ONLY; a REMOTE top-level Create maps to RemoteAuthoritative + /// exactly like a remote accepted Position does (correcting this + /// comment's earlier, wrong claim that all initial Creates map to + /// InitialLogin). + /// + /// C2-4 review fix (delta round): so OperationKind alone still + /// does not exclude a remote top-level Create — ClassifyCreate + /// (:254-273) emits SetPosition + RemoteAuthoritative + + /// InitialCreateFlags (Placement|Slide) for one, while + /// ClassifyAcceptedPosition's remote branches (:401-416, + /// :454-472) always carry AuthoritativeTeleportFlags + /// (Teleport|Slide|SendPositionEvent) for the SAME disposition/ + /// OperationKind pair. The Teleport bit is the exact + /// discriminator retail's own flag choice provides — Create asks for + /// placement collision, an accepted Position asks for a teleport + /// resolve — so requiring it here excludes Creates (route 4b-1 is a + /// POSITION-only route; the first-entry conductor owns every Create) + /// without excluding either remote Position shape. + /// + /// + internal static bool OwnsPlacement(RuntimeAuthoritativePositionRoute route) => + route.OperationKind is RuntimeSetPositionOperationKind.RemoteAuthoritative + && route.Disposition is RuntimeAuthoritativePositionDisposition.SetPosition + or RuntimeAuthoritativePositionDisposition.SetPositionSimple + && (route.SetPositionFlags & PhysicsSetPositionFlags.Teleport) != 0; + + /// + /// Mirrors and + /// : this + /// controller outlives its session routes, so the "session reset + /// precedes a new route" ordering is asserted, not assumed. + /// + internal void AttachRoute(object route) + { + ArgumentNullException.ThrowIfNull(route); + if (_routeOwner is not null && !ReferenceEquals(_routeOwner, route)) + { + throw new InvalidOperationException( + "A remote placement drive controller serves one session " + + "route at a time; the prior route must be disposed " + + "(session reset precedes a new route) before a " + + "replacement attaches."); + } + _routeOwner = route; + } + + /// + /// Route-scoped teardown: abandons every tracked entry, but ONLY when + /// is the attached owner. + /// + /// + /// C2-1 review fix (delta round) — the prior version of this method (and + /// this comment) was wrong: it cleared and + /// as if they were pure + /// bookkeeping, citing + /// as the mirror. That is the WRONG sibling — first-entry's tracked + /// entries have an INDEPENDENT owner (the residence lease) that survives + /// route teardown on its own, so clearing first-entry's local map merely + /// stops WATCHING an operation something else still owns. These two maps + /// have no such owner: EVERY entry holds a Core operation THIS + /// controller alone began — at + /// AwaitingPreparation (already begun via + /// TryBeginExclusiveAuthoredPlacement), + /// at AwaitingCommitAcknowledgement with a published Place. + /// Clearing the local dictionary without cancelling the Core operation + /// left it live forever, pinning its landblock prefix + /// (HasOldPrefixPlacementDebt) — docs/ISSUES.md #310's unbounded + /// streaming-stall hazard, now reachable from an ordinary reconnect/ + /// session-reset instead of only a stuck asset retry. The correct mirror + /// is route 2's RuntimeAcceptedPositionDriveController.DetachRoute + /// → AbandonPending, which this now matches: cancel every live + /// operation (, restoreCancelledPark: true + /// — these are cancellations of an abandoned placement INTENT, not + /// withdrawals, so any park rolls back rather than stranding the body) + /// before clearing the local maps. + /// + /// + internal void DetachRoute(object route) + { + ArgumentNullException.ThrowIfNull(route); + if (!ReferenceEquals(_routeOwner, route)) + return; + _routeOwner = null; + + RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition; + if (_pending.Count != 0) + { + Pending[] abandoned = [.. _pending.Values]; + _pending.Clear(); + foreach (Pending entry in abandoned) + { + setPosition.ForgetPlacementCompletion(entry.Token); + CancelToken(setPosition, entry.Token); + } + } + if (_awaitingAcknowledgement.Count != 0) + { + RuntimeEntityPlacementToken[] abandoned = + [.. _awaitingAcknowledgement.Values]; + _awaitingAcknowledgement.Clear(); + foreach (RuntimeEntityPlacementToken token in abandoned) + { + setPosition.ForgetPlacementCompletion(token); + CancelToken(setPosition, token); + } + } + } + + /// + /// Executes an already-classified remote accepted Position against the + /// canonical Runtime SetPosition owner. 's + /// Snapshot and PositionAuthorityVersion must already reflect the merge + /// performed — + /// this method never re-merges the wire frame, and + /// must already be the result of + /// + /// for the SAME packet (the shared classification builder every remote + /// caller uses — never re-derived here). + /// + internal RuntimeRemotePlacementExecutionStatus TryExecuteAcceptedRemotePosition( + RuntimeEntityRecord record, + in RuntimeAuthoritativePositionRoute route) + { + ArgumentNullException.ThrowIfNull(record); + if (!OwnsPlacement(route) + || record.PhysicsBody is null + || record.Key is not { } key) + { + return RuntimeRemotePlacementExecutionStatus.NotApplicable; + } + + RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition; + + // A retained preparation retry whose operation died some other way + // (superseded, torn down, generation change, or simply forgotten by + // an unrelated accepted Position for this same entity) must not + // block a fresh Begin for THIS packet — self-heal rather than report + // a Contention nothing is actually contending. + if (_pending.TryGetValue(key, out Pending? stale) + && !setPosition.IsPlacementCurrent(stale.Token)) + { + _pending.Remove(key); + } + + CreateObject.ServerPosition? destination = + record.Snapshot.Physics?.Position ?? record.Snapshot.Position; + if (destination is not { } accepted + || !_serviceWindow.IsWithinServiceWindow(accepted.LandblockId)) + { + // The central decision: refuse rather than open a park this + // host's own service window could never wake. + return RuntimeRemotePlacementExecutionStatus.Refused; + } + + RuntimeEntityPlacementToken token = + setPosition.TryBeginExclusiveAuthoredPlacement( + record, + record.PositionAuthorityVersion, + route.OperationKind); + if (!token.IsValid) + return RuntimeRemotePlacementExecutionStatus.Contention; + + return SubmitAndResolve(record, token, route); + } + + /// + /// Host cadence pump: retries a preparation-only retry status + /// (RetrySetupUnavailable/RetryWorldFrameUnavailable) by + /// re-calling the SAME prepare+submit pair, exactly like + /// 's own continuation + /// completion. Bounded, non-allocating iteration mirrors + /// 's _driveScratch + /// template. Safe to call from any host cadence point; a no-op when + /// nothing is pending. + /// + internal void Advance() + { + if (_driving || _pending.Count == 0) + return; + _driving = true; + try + { + _driveScratch.Clear(); + foreach (RuntimeEntityKey key in _pending.Keys) + _driveScratch.Add(key); + + RuntimeSetPositionState setPosition = + _entityObjects.Physics.SetPosition; + foreach (RuntimeEntityKey key in _driveScratch) + { + if (!_pending.TryGetValue(key, out Pending? pending)) + continue; + if (!setPosition.IsPlacementCurrent(pending.Token)) + { + // Forgotten by some other accepted Position for this + // same entity before this retry resolved. No re-issue + // funnel for remotes: the next packet supplies current + // truth on its own. + _pending.Remove(key); + continue; + } + _pending.Remove(key); + + // B3 review fix: a retry can sit retained across many host + // cadence pumps (bounded only by how long the asset stayed + // unavailable) while its destination's collision publication + // retires out from under it. Re-check the SAME service-window + // guard the entry point uses BEFORE resubmitting — dropping + // (and cancelling the already-begun token) rather than + // resubmitting matches the entry point's own Refused + // semantics: no operation survives, the entity keeps its last + // committed pose, and the next packet supplies current truth + // on its own. Without this re-check a destination that fell + // out of the window would just keep coming back Contention + // forever (the asset source has not changed), never + // converging even though the window already knows better. + CreateObject.ServerPosition? destination = + pending.Record.Snapshot.Physics?.Position + ?? pending.Record.Snapshot.Position; + if (destination is not { } accepted + || !_serviceWindow.IsWithinServiceWindow( + accepted.LandblockId)) + { + CancelToken(setPosition, pending.Token); + continue; + } + + _ = SubmitAndResolve(pending.Record, pending.Token, pending.Route); + } + } + finally + { + _driving = false; + } + } + + private RuntimeRemotePlacementExecutionStatus SubmitAndResolve( + RuntimeEntityRecord record, + in RuntimeEntityPlacementToken token, + in RuntimeAuthoritativePositionRoute route) + { + RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition; + RuntimeSetPositionMoverPreparationStatus status = + setPosition.TryPrepareAndSubmitAuthoredPlacement( + record, + token, + route.OperationKind, + route.SetPositionFlags, + _collisionSource, + _clock.SimulationTimeSeconds, + out RuntimeSetPositionOutcome outcome, + resolveWorldOffsetFromRuntimeFrame: true); + + if (status != RuntimeSetPositionMoverPreparationStatus.Prepared) + { + if (status.IsRetryable()) + { + _pending[token.Entity] = new Pending + { + Record = record, + Token = token, + Route = route, + }; + return RuntimeRemotePlacementExecutionStatus.Contention; + } + + CancelToken(setPosition, token); + return RuntimeRemotePlacementExecutionStatus.Rejected; + } + + switch (outcome.Status) + { + case RuntimeSetPositionStatus.CommittedHostAcknowledgementPending: + // No ack for remotes. The production placement-projection + // subscription (shared infrastructure, not owned here) has + // already applied-and-acknowledged the Place receipt + // synchronously inside the SetPosition call above, if it was + // going to — exactly like route 2's own commit branch, minus + // the ack call route 2 makes for the local player. B4 review + // fix: when the sink declined instead (host not ready — the + // exact scenario the FIFO retry mechanism exists for), the + // operation is STILL LIVE in Core's _operations map until + // some later AcknowledgeProjection retires it. Track it so + // the ledger can see that class of outstanding operation + // instead of going blind the instant this method returns. + if (setPosition.IsPlacementCurrent(token)) + _awaitingAcknowledgement[token.Entity] = token; + return RuntimeRemotePlacementExecutionStatus.Committed; + + case RuntimeSetPositionStatus.DeferredCell: + // Central decision: this branch means the service-window + // guard passed but Core still deferred the destination — the + // narrow residual the class doc's central-decision paragraph + // explains (a live in-place collision-prefix quiescence the + // tier/residency guard cannot see from outside Core, per + // RuntimeSetPositionState's private TryGetBlockingQuiescence). + // Cancel immediately rather than retain a watch: no + // re-issue, no park survives this controller. + CancelToken(setPosition, token); + return RuntimeRemotePlacementExecutionStatus.Refused; + + default: + // Rejected/Cancelled — authority moved out from under this + // operation, so the body never moved. + CancelToken(setPosition, token); + return RuntimeRemotePlacementExecutionStatus.Rejected; + } + } + + /// + /// Self-healing read: prunes every + /// entry whose token Core no longer considers current (the ack already + /// landed through whatever path — the synchronous in-call apply, a later + /// FIFO retry, or Runtime's own session-reset/generation-change teardown + /// clearing the operation outright) before returning the live count. + /// This is what lets the constructor's second + /// RegisterRemotePlacementDriveOwnership registration converge to + /// zero (B4 review fix) without this controller needing a separate + /// periodic pump for this one dictionary — every ownership-snapshot read + /// (including the exact convergence checks teardown/reset/generation + /// change assert) sees the truth as of that read. + /// + /// + /// C2-1 review fix (delta round), disposal safety: IsPlacementCurrent's + /// first statement is EnsureNotDisposed, which THROWS once + /// is disposed. A post-Dispose() + /// CaptureOwnership() read is the designed contract + /// (GameWindowLifetime.DisposeGameRuntime: runtime.Dispose(); + /// runtime.CaptureOwnership();), so this — the first ledger provider + /// to reach into another disposable subsystem — must survive it. When + /// is already true, Core + /// itself is gone; there is nothing left to ask, so this returns + /// whatever count is STILL in the local map rather than calling into the + /// disposed state. A healthy teardown already cancelled and cleared + /// every entry via before disposal, so this + /// branch reports 0 in the healthy path and a genuine nonzero leak + /// otherwise — never an exception either way. + /// + /// + /// C2-1 review fix (delta round), allocation: reuses + /// instead of a per-call + /// List<RuntimeEntityKey>, mirroring 's + /// own template. + /// + /// + private int CountLiveAwaitingAcknowledgement() + { + if (_awaitingAcknowledgement.Count == 0) + return 0; + if (_entityObjects.Physics.IsDisposed) + return _awaitingAcknowledgement.Count; + + RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition; + _awaitingAcknowledgementScratch.Clear(); + foreach ((RuntimeEntityKey key, RuntimeEntityPlacementToken token) + in _awaitingAcknowledgement) + { + if (!setPosition.IsPlacementCurrent(token)) + _awaitingAcknowledgementScratch.Add(key); + } + foreach (RuntimeEntityKey key in _awaitingAcknowledgementScratch) + _awaitingAcknowledgement.Remove(key); + return _awaitingAcknowledgement.Count; + } + + private static void CancelToken( + RuntimeSetPositionState setPosition, + in RuntimeEntityPlacementToken token) + { + // Cancellation, not withdrawal: this controller abandons a placement + // intent while the remote stays in the world, so a DeferredCell park + // must roll back rather than strand the entity invisible and + // intangible (RuntimeSetPositionState.Forget). + RuntimePlacementCancellationReceipt cancellation = + setPosition.ForgetExactPlacement( + token, + restoreCancelledPark: true); + if (cancellation.IsValid) + setPosition.PublishCancellation(cancellation); + } +} diff --git a/tests/AcDream.App.Tests/Streaming/GraphicalRemotePlacementServiceWindowTests.cs b/tests/AcDream.App.Tests/Streaming/GraphicalRemotePlacementServiceWindowTests.cs new file mode 100644 index 00000000..82c742c2 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/GraphicalRemotePlacementServiceWindowTests.cs @@ -0,0 +1,84 @@ +using AcDream.App.Streaming; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Streaming; + +/// +/// C4 route 4b-1: focused tests for the graphical host's +/// — the App-side +/// implementation of IRuntimeRemotePlacementServiceWindow that did not +/// exist before this route. Verifies the predicate matches +/// exactly (not +/// , which is also true for a +/// merely-queued landblock — see the class's own doc comment) and +/// canonicalizes a full ACE cell id to the landblock the tier map keys on. +/// +public sealed class GraphicalRemotePlacementServiceWindowTests +{ + // GpuWorldState's tier map is keyed by the CANONICAL 0xFFFF-ending form + // (LoadedLandblock.LandblockId/AddLandblock require callers to already + // pass it that way — see StreamingControllerReadinessTests' own + // 0x1236FFFFu convention). The window itself is exercised with a full + // ACE outdoor-cell id (landblock high word + a real cell low word), + // matching CreateObject.ServerPosition.LandblockId's actual shape, to + // prove IsWithinServiceWindow canonicalizes it the same way + // GpuWorldState's own writers do. + private const uint LandblockHighWord = 0x0A0B0000u; + private const uint CanonicalLandblock = LandblockHighWord | 0xFFFFu; + private const uint OutdoorCell = LandblockHighWord | 0x0001u; + + [Fact] + public void True_WhenTheLandblockIsNearTier() + { + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + CanonicalLandblock, + new LandBlock(), + Array.Empty())); + var window = new GraphicalRemotePlacementServiceWindow(state); + + Assert.True(window.IsWithinServiceWindow(OutdoorCell)); + } + + [Fact] + public void False_WhenTheLandblockWasNeverPublished() + { + var state = new GpuWorldState(); + var window = new GraphicalRemotePlacementServiceWindow(state); + + Assert.False(window.IsWithinServiceWindow(OutdoorCell)); + } + + [Fact] + public void False_WhenTheLandblockIsOnlyPendingNotYetCollisionPublished() + { + var state = new GpuWorldState(); + // A live projection arriving before its landblock loads parks as + // "pending near tier" — GpuWorldState.IsNearTierOrPending reads true + // for this, but collision has NOT been published yet. + Assert.False( + state.AddEntitiesToExistingLandblock( + LandblockHighWord, Array.Empty())); + Assert.True(state.IsNearTierOrPending(CanonicalLandblock)); + var window = new GraphicalRemotePlacementServiceWindow(state); + + Assert.False(window.IsWithinServiceWindow(OutdoorCell)); + } + + [Fact] + public void False_AfterTheLandblockRetiresViaDetachNearLayer() + { + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + CanonicalLandblock, + new LandBlock(), + Array.Empty())); + var window = new GraphicalRemotePlacementServiceWindow(state); + Assert.True(window.IsWithinServiceWindow(OutdoorCell)); + + _ = state.DetachNearLayer(LandblockHighWord); + + Assert.False(window.IsWithinServiceWindow(OutdoorCell)); + } +} diff --git a/tests/AcDream.Headless.Tests/HeadlessCollisionNeighborhoodServiceWindowTests.cs b/tests/AcDream.Headless.Tests/HeadlessCollisionNeighborhoodServiceWindowTests.cs new file mode 100644 index 00000000..bcc110d7 --- /dev/null +++ b/tests/AcDream.Headless.Tests/HeadlessCollisionNeighborhoodServiceWindowTests.cs @@ -0,0 +1,215 @@ +using System.Collections.Immutable; +using System.Reflection; +using AcDream.Content; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Physics; +using AcDream.Core.Spells; +using AcDream.Headless.Configuration; +using AcDream.Headless.Hosting; +using AcDream.Runtime; +using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Session; + +namespace AcDream.Headless.Tests; + +/// +/// B1 review fix: implements TWO +/// interfaces that share the identical bool IsWithinServiceWindow(uint) +/// signature but ask different questions — +/// is a +/// pure geometry test ("can this landblock EVER collision-publish", true +/// outright with no center requested), while +/// +/// must answer "is it collision-published RIGHT NOW". This is the focused +/// proof that the two answers genuinely diverge — before this fix a single +/// method satisfied both interfaces, so both answers were identical (and +/// wrong for the new interface's contract). +/// +public sealed class HeadlessCollisionNeighborhoodServiceWindowTests +{ + [Fact] + public void ServiceWindowIsResidencyNotGeometry_UnpublishedLandblockIsRefusedDespiteGeometricMembership() + { + var factory = new FixtureContentFactory(); + using var owner = new HeadlessProcessContentOwner( + ContentDescriptor(), + _ => { }, + factory); + using HeadlessProcessContentOwner.HeadlessProcessContentLease lease = + owner.AcquireLease("fixture"); + var operations = new FixtureGameplayOperations(); + using var runtime = new GameRuntime(new GameRuntimeDependencies( + operations, operations, operations, operations)); + var neighborhood = new HeadlessCollisionNeighborhood(runtime, lease); + + // CenterOn was never called, so the geometry interface's own + // documented contract applies: "no center requested yet" => true + // (this landblock could theoretically EVER be served). Nothing has + // published ANY collision for it, though — the residency-based + // interface must say false. + const uint cell = 0xA9B40001u; + Assert.True( + ((IHeadlessCollisionNeighborhood)neighborhood) + .IsWithinServiceWindow(cell)); + Assert.False( + ((IRuntimeRemotePlacementServiceWindow)neighborhood) + .IsWithinServiceWindow(cell)); + } + + /// + /// C2-3 review fix (delta round): BuildPublicationPlan publishes + /// the requested center's FULL 3x3 window, not just the exact center — + /// so a landblock this host HAS published but which is not the exact + /// _centerLandblock (a remote sitting one landblock off-center, + /// exactly the boundary population this route exists to serve) must + /// still read as currently published. Before this fix the predicate + /// inherited 's own + /// _centerLandblock != center restriction — correct for + /// IsReady's narrower question, wrong here — so only ONE of the + /// nine published landblocks would ever read true. + /// + /// Seeds _resident directly via reflection (no lightweight DAT + /// fixture in this test project can drive real 3x3 publication through + /// CenterOn — its dummy proxy makes + /// LandblockLoader.Load fail for every landblock, including a + /// REQUIRED center) — mirrors the existing reflection precedent + /// HeadlessSessionHostTests.SeedRuntimePlacement already uses for + /// otherwise-unreachable internal state. _centerLandblock is + /// deliberately left at its default (never set) — the whole point is + /// that this predicate no longer depends on it. + /// + /// + [Fact] + public void ServiceWindowCoversAPublishedNeighborLandblockNotOnlyTheExactCenter() + { + var factory = new FixtureContentFactory(); + using var owner = new HeadlessProcessContentOwner( + ContentDescriptor(), + _ => { }, + factory); + using HeadlessProcessContentOwner.HeadlessProcessContentLease lease = + owner.AcquireLease("fixture"); + var operations = new FixtureGameplayOperations(); + using var runtime = new GameRuntime(new GameRuntimeDependencies( + operations, operations, operations, operations)); + var neighborhood = new HeadlessCollisionNeighborhood(runtime, lease); + + const uint neighborLandblock = 0xA9B5FFFFu; + const uint neighborCell = 0xA9B50001u; + runtime.EntityObjects.Physics.Engine.AddLandblock( + neighborLandblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + SeedResident(neighborhood, neighborLandblock); + + Assert.True( + ((IRuntimeRemotePlacementServiceWindow)neighborhood) + .IsWithinServiceWindow(neighborCell)); + } + + private static void SeedResident( + HeadlessCollisionNeighborhood neighborhood, + uint landblockId) + { + FieldInfo field = typeof(HeadlessCollisionNeighborhood).GetField( + "_resident", + BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new MissingFieldException( + nameof(HeadlessCollisionNeighborhood), "_resident"); + var resident = (HashSet)field.GetValue(neighborhood)!; + resident.Add(landblockId); + } + + private static HeadlessContentDescriptor ContentDescriptor() => new() + { + DatDirectory = "fixture-dats", + PreparedAssetPath = "fixture.pak", + }; + + private sealed class FixtureContentFactory + : IHeadlessProcessContentFactory + { + internal FixtureContentFactory() + { + DatsResource = + DispatchProxy.Create(); + PreparedResource = + DispatchProxy.Create(); + } + + internal IDatReaderWriter DatsResource { get; } + internal ITestPreparedSource PreparedResource { get; } + + public HeadlessOpenedProcessContent Open( + HeadlessContentDescriptor descriptor, + Action diagnostic) => + new( + DatsResource, + PreparedResource, + MagicCatalog.Empty, + ImmutableArray.CreateRange(new float[256])); + } + + private sealed class FixtureGameplayOperations + : IRuntimeCombatAttackOperations, + IRuntimeCombatTargetOperations, + IRuntimeCombatModeOperations, + IRuntimeSpellCastOperations + { + public bool CanStartAttack() => false; + public void PrepareAttackRequest() + { + } + + public bool SendAttack(AttackHeight height, float power) => false; + public void SendCancelAttack() + { + } + + public bool IsDualWield => false; + public bool PlayerReadyForAttack => false; + public bool AutoRepeatAttack => false; + public bool AutoTarget => false; + public uint? SelectClosestTarget() => null; + public bool IsInWorld => false; + public IReadOnlyList GetOrderedEquipment() => []; + public void NotifyExplicitCombatModeRequest() + { + } + + public void SendChangeCombatMode(CombatMode mode) + { + } + + public uint LocalPlayerId => 0u; + public bool CanSend => false; + public bool HasRequiredComponents(uint spellId) => false; + + public bool IsTargetCompatible( + uint targetId, SpellMetadata spell, bool showMessage) => false; + + public void StopCompletely() + { + } + + public void SendUntargeted(uint spellId) + { + } + + public void SendTargeted(uint targetId, uint spellId) + { + } + + public void DisplayMessage(string message) + { + } + + public void IncrementBusy() + { + } + } +} diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionEventRouteRetryPendingTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionEventRouteRetryPendingTests.cs new file mode 100644 index 00000000..cd9d3f2c --- /dev/null +++ b/tests/AcDream.Headless.Tests/HeadlessSessionEventRouteRetryPendingTests.cs @@ -0,0 +1,497 @@ +using System.Net; +using System.Numerics; +using AcDream.Content; +using AcDream.Content.Pak; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Spells; +using AcDream.Headless.Hosting; +using AcDream.Runtime; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; + +namespace AcDream.Headless.Tests; + +/// +/// C4 route 4b-1 (N3): HeadlessSessionEventRoute.Attach constructs its +/// RuntimePlacementProjectionSubscription with +/// retryPendingOnSubscribe: true, and that was the ONLY +/// RetryPending call headless ever made — a Place a host sink declines +/// (landblock not loaded, stale transit authority) is left at the FIFO head +/// for its own later retry +/// (RuntimePlacementProjectionSubscription.OnPlacement's doc comment), +/// but nothing headless did ever asked again. This is the focused proof that +/// — the method +/// HeadlessSessionHost.Tick now calls every tick, immediately after +/// HeadlessSessionWorldProjection.PumpFirstEntry — actually re-offers a +/// declined head. Without a SECOND call, the declined receipt sits forever. +/// +/// +/// Uses the SAME lightweight LiveSessionHost + no-op event/command +/// route fixture as +/// RuntimeAcceptedPositionDriveControllerTests.StartRuntime — it +/// produces a genuine nonzero GameRuntime.Generation (required: +/// RuntimePlacementProjectionChannel.IsCurrent rejects generation 0 +/// outright) WITHOUT wiring any real placement-projection subscription, so +/// this test's own injected fake sink is the ONLY observer of the FIFO. +/// HeadlessSessionHost.Start would also work generation-wise, but its +/// own internal route always uses the real +/// HeadlessRuntimePlacementProjectionSink, which would consume-and- +/// acknowledge this test's synthetic Place before this test's own route ever +/// subscribed. +/// +/// +public sealed class HeadlessSessionEventRouteRetryPendingTests +{ + private const uint PlayerGuid = 0x50000001u; + private const uint Landblock = 0xC1000000u; + private const uint Cell = Landblock | 0x0001u; + private const float Height = 6f; + + [Fact] + public void RetryPending_ReoffersAPreviouslyDeclinedHeadUntilTheSinkAccepts() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + Assert.NotEqual(0UL, runtime.Generation.Value); + + CommitLandblockCollision(runtime, Landblock); + RuntimeEntityRecord record = CreateRemoteRecord(runtime, 0x70004001u); + AttachBody(runtime, record, Cell); + + RuntimeEntityPlacementToken token = runtime.EntityObjects.Physics + .SetPosition.TryBeginExclusiveAuthoredPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(token.IsValid); + RuntimeSetPositionMoverPreparationStatus status = runtime.EntityObjects + .Physics.SetPosition.TryPrepareAndSubmitAuthoredPlacement( + record, + token, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + PhysicsSetPositionFlags.Teleport | PhysicsSetPositionFlags.Slide, + new UnusedCollisionSource(), + gameTime: 10d, + out RuntimeSetPositionOutcome outcome, + resolveWorldOffsetFromRuntimeFrame: true); + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, status); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + + var sink = new DecliningThenAcceptingSink(); + var events = new NoOpEventRoute(); + var route = new HeadlessSessionEventRoute(events, runtime, sink); + + // Attach's own subscribe-time retry (retryPendingOnSubscribe: true) + // is the ONLY chance the receipt gets today — the sink is still + // declining, so it must remain unacknowledged. + route.Attach(); + Assert.Equal(1, sink.CallCount); + Assert.True( + runtime.EntityObjects.Physics.SetPosition.TryPeekProjection( + out _)); + + // The sink starts accepting (mirrors a landblock finishing streaming + // in) — but without a SECOND RetryPending call nothing re-offers the + // head. This is the exact gap N3 closes. + sink.Accept = true; + bool retried = route.RetryPending(); + + Assert.True(retried); + Assert.Equal(2, sink.CallCount); + Assert.False( + runtime.EntityObjects.Physics.SetPosition.TryPeekProjection( + out _)); + + route.Dispose(); + } + + /// + /// B5(c) review fix: + /// must refuse once Runtime's generation has moved past the one this + /// route attached under — mirroring the graphical host's + /// RuntimePlacementProjectionRetrySlot, which already refuses a + /// stale-generation callback the same way (BindOwned/ + /// RetryPending's own guard). Before this fix headless + /// dereferenced its subscription directly with no equivalent latch, so a + /// route left live across a generation change (a reconnect race window) + /// could still fire a callback against a retired generation. + /// + [Fact] + public void RetryPending_RefusesOnceRuntimeGenerationHasMovedPastAttach() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + + CommitLandblockCollision(runtime, Landblock); + RuntimeEntityRecord record = CreateRemoteRecord(runtime, 0x70004002u); + AttachBody(runtime, record, Cell); + + RuntimeEntityPlacementToken token = runtime.EntityObjects.Physics + .SetPosition.TryBeginExclusiveAuthoredPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(token.IsValid); + RuntimeSetPositionMoverPreparationStatus status = runtime.EntityObjects + .Physics.SetPosition.TryPrepareAndSubmitAuthoredPlacement( + record, + token, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + PhysicsSetPositionFlags.Teleport | PhysicsSetPositionFlags.Slide, + new UnusedCollisionSource(), + gameTime: 10d, + out RuntimeSetPositionOutcome outcome, + resolveWorldOffsetFromRuntimeFrame: true); + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, status); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + + var sink = new DecliningThenAcceptingSink(); + var events = new NoOpEventRoute(); + var route = new HeadlessSessionEventRoute(events, runtime, sink); + route.Attach(); + Assert.Equal(1, sink.CallCount); + + sink.Accept = true; + RuntimeGenerationToken attachedGeneration = runtime.Generation; + RuntimeTeardownAcknowledgement stopped = + started.Live.Stop(attachedGeneration); + Assert.True(stopped.IsComplete); + Assert.NotEqual(attachedGeneration, runtime.Generation); + + // The route is STILL live here (never Disposed) — exactly the shape + // a reconnect race could leave it in for one host-tick window before + // the owner swaps in the replacement route. + bool retried = route.RetryPending(); + + Assert.False(retried); + // The stale-generation refusal must short-circuit BEFORE ever + // touching the subscription — the sink's call count must not move. + Assert.Equal(1, sink.CallCount); + + route.Dispose(); + } + + // ── Fixture (mirrors RuntimeAcceptedPositionDriveControllerTests) ────── + + private sealed class StartedRuntime : IDisposable + { + internal required GameRuntime Runtime { get; init; } + internal required LiveSessionHost Live { get; init; } + + public void Dispose() + { + _ = Live.Stop(Runtime.Generation); + Runtime.Dispose(); + } + } + + private static StartedRuntime StartRuntime() + { + var operations = new FixtureGameplayOperations(); + var sessionOperations = new FixtureSessionOperations(); + var runtime = new GameRuntime(new GameRuntimeDependencies( + operations, operations, operations, operations, + SessionOperations: sessionOperations)); + var resetHost = new FixtureResetHost(); + var options = new LiveSessionConnectOptions( + true, "127.0.0.1", 9000, "account", "password"); + var live = new LiveSessionHost( + runtime.Session, + new LiveSessionHostBindings( + new LiveSessionRoutingFactories( + _ => new NoOpEventRoute(), + _ => new NoOpCommandRoute()), + generation => runtime.ResetGeneration(generation, resetHost), + new LiveSessionSelectionBindings( + id => runtime.PlayerIdentity.ServerGuid = id, + _ => { }, + runtime.CommunicationOwner.Chat.SetLocalPlayerGuid, + _ => { }, + _ => { }, + runtime.ActionOwner.Combat.Clear), + new LiveSessionEnteredWorldBindings( + _ => { }, () => { }, () => { }, _ => { }, () => { }), + (_, _, _) => { }, + () => { }), + options); + LiveSessionStartResult startResult = live.Start(options); + Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status); + Assert.NotEqual(0UL, runtime.Generation.Value); + return new StartedRuntime { Runtime = runtime, Live = live }; + } + + private static void CommitLandblockCollision( + GameRuntime runtime, uint landblockId) + { + var heights = new byte[81]; + Array.Fill(heights, (byte)Height); + var heightTable = new float[256]; + for (int index = 0; index < heightTable.Length; index++) + heightTable[index] = index; + runtime.EntityObjects.Physics.ObserveLocalWorldFrame( + landblockId | 0x0001u, teleportAdvanced: false); + runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + landblockId, 1UL); + runtime.EntityObjects.Physics.Engine.AddLandblock( + landblockId, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + landblockId, 1UL, ready: true); + } + + private static RuntimeEntityRecord CreateRemoteRecord( + GameRuntime runtime, uint guid) + { + RuntimeEntityRecord record = runtime.EntityObjects.RegisterEntity( + new WorldSession.EntitySpawn( + Guid: guid, + Position: new CreateObject.ServerPosition( + Cell, 10f, 10f, Height, 1f, 0f, 0f, 0f), + SetupTableId: null, + AnimPartChanges: Array.Empty(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "remote", + ItemType: null, + MotionState: null, + MotionTableId: 0x09000001u)) + .Canonical!; + runtime.EntityObjects.Entities.SetFinalPhysicsState( + record, PhysicsStateFlags.Gravity); + return record; + } + + private static void AttachBody( + GameRuntime runtime, RuntimeEntityRecord record, uint cellId) + { + runtime.EntityObjects.Entities.SetFullCell( + record, cellId, (cellId & 0xFFFF0000u) | 0xFFFFu); + var body = new PhysicsBody + { + Position = new Vector3(10f, 10f, Height), + Orientation = Quaternion.Identity, + LastUpdateTime = 1d, + State = PhysicsStateFlags.Gravity, + TransientState = TransientStateFlags.Active, + }; + body.SnapToCell(cellId, body.Position, body.Position); + runtime.EntityObjects.Entities.SetPhysicsBody(record, body); + record.ObjectClock.Activate(); + runtime.EntityObjects.Physics.AcknowledgeSpatialProjection( + record, spatial: true); + } + + private sealed class DecliningThenAcceptingSink + : IRuntimePlacementProjectionSink + { + internal int CallCount { get; private set; } + internal bool Accept { get; set; } + + public bool TryApply(in RuntimePlacementProjectionSnapshot projection) + { + CallCount++; + return Accept; + } + } + + private sealed class NoOpEventRoute : ILiveSessionEventRouting + { + public void Attach() + { + } + + public void Dispose() + { + } + } + + private sealed class NoOpCommandRoute : ILiveSessionCommandRouting + { + public void Activate() + { + } + + public void Dispose() + { + } + } + + private sealed class FixtureSessionOperations : ILiveSessionOperations + { + public IPEndPoint ResolveEndpoint(string host, int port) => + new(IPAddress.Loopback, port); + + public WorldSession CreateSession(IPEndPoint endpoint) => + new(endpoint, new FixtureTransport()); + + public void Connect(WorldSession session, string user, string password) + { + } + + public CharacterList.Parsed GetCharacters(WorldSession session) => + new( + 0u, + [new CharacterList.Character(PlayerGuid, "Direct", 0u)], + [], + 11, + "account", + true, + true); + + public void EnterWorld(WorldSession session, int activeCharacterIndex) + { + } + + public void Tick(WorldSession session) + { + } + + public void DisposeSession(WorldSession session) => session.Dispose(); + } + + private sealed class FixtureTransport : IWorldSessionTransport + { + public void Send(ReadOnlySpan datagram) + { + } + + public void Send(IPEndPoint remote, ReadOnlySpan datagram) + { + } + + public int Receive( + Span destination, TimeSpan timeout, out IPEndPoint? from) + { + from = null; + return -1; + } + + public ValueTask ReceiveAsync( + Memory destination, CancellationToken cancellationToken) => + ValueTask.FromException( + new OperationCanceledException(cancellationToken)); + + public void Dispose() + { + } + } + + private sealed class FixtureResetHost : IRuntimeGenerationResetHost + { + public void RetireEntityProjection(RuntimeEntityRecord entity) + { + } + + public void DrainEntityProjectionBoundary() + { + } + + public void CompleteEntityProjectionRetirement() + { + } + } + + private sealed class FixtureGameplayOperations + : IRuntimeCombatAttackOperations, + IRuntimeCombatTargetOperations, + IRuntimeCombatModeOperations, + IRuntimeSpellCastOperations + { + public bool CanStartAttack() => false; + public void PrepareAttackRequest() + { + } + + public bool SendAttack(AttackHeight height, float power) => false; + public void SendCancelAttack() + { + } + + public bool IsDualWield => false; + public bool PlayerReadyForAttack => false; + public bool AutoRepeatAttack => false; + public bool AutoTarget => false; + public uint? SelectClosestTarget() => null; + public bool IsInWorld => false; + public IReadOnlyList GetOrderedEquipment() => []; + public void NotifyExplicitCombatModeRequest() + { + } + + public void SendChangeCombatMode(CombatMode mode) + { + } + + public uint LocalPlayerId => 0u; + public bool CanSend => false; + public bool HasRequiredComponents(uint spellId) => false; + + public bool IsTargetCompatible( + uint targetId, SpellMetadata spell, bool showMessage) => false; + + public void StopCompletely() + { + } + + public void SendUntargeted(uint spellId) + { + } + + public void SendTargeted(uint targetId, uint spellId) + { + } + + public void DisplayMessage(string message) + { + } + + public void IncrementBusy() + { + } + } + + private sealed class UnusedCollisionSource : IPreparedCollisionSource + { + public PreparedAssetPresence ProbeCollision( + PakAssetType type, uint sourceFileId) => + PreparedAssetPresence.Available; + + public PreparedCollisionReadResult ReadSetupCollision( + uint sourceFileId, CancellationToken cancellationToken = default) => + PreparedCollisionReadResult.Missing; + + public PreparedCollisionReadResult ReadGfxObjCollision( + uint sourceFileId, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public PreparedCollisionReadResult ReadCellStructureCollision( + uint sourceFileId, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public PreparedCollisionReadResult ReadEnvCellTopology( + uint sourceFileId, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public PreparedCollisionSourceStats CollisionStats => default; + + public void Dispose() + { + } + } +} diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 08a02703..2dca2dce 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -225,6 +225,125 @@ public sealed class HeadlessSessionHostTests Assert.True(host.Runtime.CaptureOwnership().IsConverged); } + /// + /// B5(a) review fix: + /// proved the underlying re-offer MECHANISM works, but hand-constructed + /// directly and called + /// route.RetryPending() itself — it never touches + /// 's own + /// _eventRoute?.RetryPending() call. This test drives Tick + /// itself (via the placementSinkOverride test seam added for this + /// fix, mirroring the existing policyOverride parameter) so a + /// regression that deletes or reorders that exact line would fail HERE, + /// not just in the lower-level subscription test. + /// + [Fact] + public void TickRetriesAPreviouslyDeclinedPlacementThroughTheRealEventRoute() + { + const uint remote = 0x70004301u; + const uint landblock = 0xA9B40000u; + const uint cell = landblock | 0x0001u; + const float height = 6f; + + var operations = new FixtureSessionOperations(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + var sink = new DecliningThenAcceptingPlacementSink(); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations, + placementSinkOverride: sink); + GameRuntime runtime = host.Runtime; + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + + runtime.EntityObjects.Physics.ObserveLocalWorldFrame( + cell, teleportAdvanced: false); + runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + landblock, 1UL); + AddFlatLandblock(runtime.EntityObjects.Physics.Engine); + runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + landblock, 1UL, ready: true); + + RuntimeEntityRecord record = runtime.EntityObjects + .RegisterEntity(Spawn(remote, cell)) + .Canonical!; + runtime.EntityObjects.Entities.SetFinalPhysicsState( + record, PhysicsStateFlags.Gravity); + runtime.EntityObjects.Entities.SetFullCell( + record, cell, landblock); + var body = new PhysicsBody + { + Position = new Vector3(10f, 10f, height), + Orientation = Quaternion.Identity, + LastUpdateTime = 1d, + State = PhysicsStateFlags.Gravity, + TransientState = TransientStateFlags.Active, + }; + body.SnapToCell(cell, body.Position, body.Position); + runtime.EntityObjects.Entities.SetPhysicsBody(record, body); + record.ObjectClock.Activate(); + runtime.EntityObjects.Physics.AcknowledgeSpatialProjection( + record, spatial: true); + + RuntimeEntityPlacementToken token = runtime.EntityObjects.Physics + .SetPosition.TryBeginExclusiveAuthoredPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(token.IsValid); + RuntimeSetPositionMoverPreparationStatus status = runtime.EntityObjects + .Physics.SetPosition.TryPrepareAndSubmitAuthoredPlacement( + record, + token, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + PhysicsSetPositionFlags.Teleport | PhysicsSetPositionFlags.Slide, + new LoadedSetupCollisionSource(), + gameTime: runtime.Clock.SimulationTimeSeconds, + out RuntimeSetPositionOutcome outcome, + resolveWorldOffsetFromRuntimeFrame: true); + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, status); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + + // The production HeadlessSessionEventRoute's subscription attached + // during host.Start() already observed this Place synchronously — + // the fake sink is still declining, so it must remain unacknowledged. + Assert.Equal(1, sink.CallCount); + Assert.True( + runtime.EntityObjects.Physics.SetPosition.TryPeekProjection( + out _)); + + // The sink starts accepting (mirrors a landblock finishing streaming + // in) — driving ONE real host tick is what must re-offer the head, + // through Tick's own wiring, not a hand-built route. + sink.Accept = true; + host.Tick(0.015d); + + Assert.Equal(2, sink.CallCount); + Assert.False( + runtime.EntityObjects.Physics.SetPosition.TryPeekProjection( + out _)); + } + + private sealed class DecliningThenAcceptingPlacementSink + : IRuntimePlacementProjectionSink + { + internal int CallCount { get; private set; } + internal bool Accept { get; set; } + + public bool TryApply(in RuntimePlacementProjectionSnapshot projection) + { + CallCount++; + return Accept; + } + } + [Fact] public void WorldProjectionHydratesCanonicalMovementAndTeleportState() { diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeRemotePlacementDriveControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeRemotePlacementDriveControllerTests.cs new file mode 100644 index 00000000..3d44298a --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeRemotePlacementDriveControllerTests.cs @@ -0,0 +1,1072 @@ +using System.Numerics; +using AcDream.Content; +using AcDream.Content.Pak; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; + +namespace AcDream.Runtime.Tests.Session; + +/// +/// C4 route 4b-1: focused tests for the Runtime-owned, per-entity remote +/// placement infrastructure. This route wires NO production caller — every +/// test below constructs an already-classified +/// directly (the same shape +/// +/// would hand a real caller) so each scenario is exercised precisely, mirroring +/// RuntimeSetPositionStateTests' bare- +/// fixture rather than the full GameRuntime/LiveSessionHost +/// harness route 2's tests use — this controller has no local-player +/// controller, outbound session, or generation dependency to bootstrap. +/// +/// Each test was verified to actually discriminate its own fix by temporarily +/// reverting the corresponding guard/omission in +/// and confirming the +/// matching test failed, then restoring it. +/// +public sealed class RuntimeRemotePlacementDriveControllerTests +{ + private const uint SourceLandblock = 0xB1000000u; + private const uint SourceCell = SourceLandblock | 0x0001u; + private const uint DestinationLandblock = 0xB2000000u; + private const uint DestinationCell = DestinationLandblock | 0x0001u; + private const float SpawnHeight = 7f; + + [Fact] + public void NotApplicable_WhenDispositionIsNotOwnedByThisRoute() + { + foreach (RuntimeAuthoritativePositionDisposition disposition in + new[] + { + RuntimeAuthoritativePositionDisposition.Interpolate, + RuntimeAuthoritativePositionDisposition.NoPositionOperation, + RuntimeAuthoritativePositionDisposition.RejectedAuthority, + RuntimeAuthoritativePositionDisposition.RejectedData, + RuntimeAuthoritativePositionDisposition.AwaitFreshPosition, + }) + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003001u); + AttachBody(lifetime, record, SourceCell); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, disposition, DestinationCell); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.NotApplicable, + drive.TryExecuteAcceptedRemotePosition(record, route)); + Assert.Equal(0, drive.PendingCount); + AssertConverged(lifetime); + } + } + + [Fact] + public void NotApplicable_WhenTheEntityHasNoCanonicalBody() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003002u); + // Deliberately never attach a body. + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.NotApplicable, + drive.TryExecuteAcceptedRemotePosition(record, route)); + } + + /// + /// B6 review fix: OwnsPlacement keyed on Disposition alone + /// would claim this route too — the classifier emits + /// SetPositionSimple for the LOCAL PLAYER's FORCE_POSITION/ + /// teleport branches (RuntimeSetPositionOperationKind.LocalAuthoritative), + /// not only for remotes. The static predicate itself is exercised + /// directly (no entity/body needed) since it takes only the route. + /// + [Fact] + public void OwnsPlacement_FalseWhenOperationKindIsNotRemoteAuthoritative() + { + // RuntimeSetPositionOperationKind is internal, so a public [Theory] + // cannot take it as a parameter (CS0051) — iterate directly instead, + // mirroring NotApplicable_WhenDispositionIsNotOwnedByThisRoute's own + // foreach-over-internal-enum shape. + foreach (RuntimeSetPositionOperationKind operationKind in + new[] + { + RuntimeSetPositionOperationKind.InitialLogin, + RuntimeSetPositionOperationKind.LocalAuthoritative, + RuntimeSetPositionOperationKind.ProjectileAuthoritative, + }) + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x7000300Fu); + + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationCell, + operationKind: operationKind); + + Assert.False( + RuntimeRemotePlacementDriveController.OwnsPlacement(route)); + + // End to end: this controller must decline the SAME route as + // NotApplicable, not merely the static predicate in isolation. + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = + CreateDrive(lifetime, window); + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.NotApplicable, + drive.TryExecuteAcceptedRemotePosition(record, route)); + Assert.Equal(0, drive.PendingCount); + } + } + + /// + /// C2-4 review fix (delta round): the OperationKind is RemoteAuthoritative + /// guard alone is STILL not exact — RuntimeAuthoritativePositionRouteClassifier + /// .ClassifyCreate emits Disposition = SetPosition AND + /// OperationKind = RemoteAuthoritative for a REMOTE top-level + /// initial Create too (only the LOCAL PLAYER's Create maps to + /// InitialLogin), so both the disposition and operation-kind + /// guards pass for a Create-shaped route. Retail's own flag choice is + /// what actually distinguishes them: a Create carries + /// InitialCreateFlags (Placement|Slide, no + /// Teleport); every remote accepted-Position SetPosition/ + /// SetPositionSimple route carries AuthoritativeTeleportFlags + /// (Teleport|Slide|SendPositionEvent). Route 4b-1 is a + /// POSITION-only route — the first-entry conductor owns every Create — + /// so a Create-shaped route reaching this controller must be declined. + /// + [Fact] + public void OwnsPlacement_FalseForARemoteTopLevelCreateShapedRoute() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003014u); + + RuntimeAuthoritativePositionRoute createRoute = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell, + operationKind: RuntimeSetPositionOperationKind.RemoteAuthoritative, + setPositionFlags: PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Slide); + + Assert.False( + RuntimeRemotePlacementDriveController.OwnsPlacement(createRoute)); + + // End to end: this controller must decline the SAME route as + // NotApplicable, not merely the static predicate in isolation. + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = + CreateDrive(lifetime, window); + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.NotApplicable, + drive.TryExecuteAcceptedRemotePosition(record, createRoute)); + Assert.Equal(0, drive.PendingCount); + + // The genuine remote accepted-Position shape (SAME disposition, SAME + // operation kind, Teleport-flagged) must still be owned — the fix + // must not have over-corrected into refusing everything. + RuntimeAuthoritativePositionRoute positionRoute = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell); + Assert.True( + RuntimeRemotePlacementDriveController.OwnsPlacement(positionRoute)); + } + + /// + /// The central decision's core pin: a destination this host's service + /// window does not currently cover must be refused, not parked — no + /// operation may open at all, so the body's own InWorld/object + /// clock are never touched. + /// + [Fact] + public void Refused_WhenDestinationIsNotWithinServiceWindow_NoOperationOpensAndBodyStaysInWorld() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003003u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + // The service window allows nothing — mirrors a destination + // landblock this host has never collision-published. + var window = new FakeServiceWindow(); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell); + + RuntimeRemotePlacementExecutionStatus status = + drive.TryExecuteAcceptedRemotePosition(record, route); + + Assert.Equal(RuntimeRemotePlacementExecutionStatus.Refused, status); + Assert.True(body.InWorld); + Assert.True(record.ObjectClock.IsActive); + Assert.Equal( + 0, + lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + AssertConverged(lifetime); + } + + /// + /// The exact §"central decision" sequence the contract's acceptance + /// section pins: attempt a placement whose destination is refused, then + /// deliver the entity's NEXT accepted Position — which, whatever it + /// classifies to, ALWAYS runs RuntimeSetPositionState.Forget for + /// this entity first + /// (, called + /// unconditionally regardless of disposition). Because nothing was ever + /// parked in the first step, that unconditional Forget has nothing to + /// discard, and the entity is STILL in the world afterward — the + /// invisible-and-intangible failure this route exists to prevent. + /// + [Fact] + public void CentralDecision_RefusedPlacementThenNextAcceptedPositionForgetsNothing_EntityStaysInWorld() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003004u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + var window = new FakeServiceWindow(); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell); + + // Packet N: destination not placeable now. + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Refused, + drive.TryExecuteAcceptedRemotePosition(record, route)); + Assert.True(body.InWorld); + Assert.True(record.ObjectClock.IsActive); + + // Packet N+1 ~150 ms later: whatever it classifies to (here, + // Interpolate — the caller never reaches this controller at all for + // that disposition), production's merge unconditionally Forgets any + // in-flight operation for this entity FIRST. Simulated directly + // (mirrors RuntimeAcceptedPositionDriveControllerTests' + // Equal_ClearsPendingWithoutReissuing... fixture, which drives the + // same production Forget call without a full second network + // round-trip). + RuntimePlacementCancellationReceipt cancellation = + lifetime.Physics.SetPosition.Forget(record); + if (cancellation.IsValid) + lifetime.Physics.SetPosition.PublishCancellation(cancellation); + + Assert.True(body.InWorld); + Assert.True(record.ObjectClock.IsActive); + Assert.Equal(SourceCell, record.FullCellId); + AssertConverged(lifetime); + } + + [Fact] + public void Committed_WhenDestinationIsWithinServiceWindowAndCollisionGenerationCommitted() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003005u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell, + destination); + + RuntimeRemotePlacementExecutionStatus status = + drive.TryExecuteAcceptedRemotePosition(record, route); + + Assert.Equal(RuntimeRemotePlacementExecutionStatus.Committed, status); + // Runtime's world-frame resolution shifts an authored landblock-local + // position into Runtime's continuous world frame + // (resolveWorldOffsetFromRuntimeFrame: true) — DestinationLandblock + // sits +192m on X from SourceLandblock per CommitLandblockCollision. + Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position); + Assert.Equal(0, drive.PendingCount); + // No host subscription is wired in this bare fixture — drain the + // Place receipt exactly like production's placement-projection + // subscription would, so the operation itself (not this drive's own + // ledger) also converges to zero. + DrainPlacementFifo(lifetime); + Assert.Equal( + 0, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + AssertConverged(lifetime); + } + + /// + /// B4 review fix: SubmitAndResolve's Committed branch returns and + /// retains nothing in _pending, but when nothing synchronously + /// consumes-and-acknowledges the Place receipt (this bare fixture has no + /// host subscription wired — production's declined-sink retry scenario + /// is the identical shape), the operation stays live in Core's + /// _operations map until something later calls + /// AcknowledgeProjection. Before this fix + /// RemotePlacementDrivePendingCount was blind to that live + /// operation the instant Committed was returned. This proves it is + /// now visible, and that it still converges to zero once the receipt is + /// actually acknowledged. + /// + [Fact] + public void Committed_UnacknowledgedOperationStaysVisibleInTheLedgerUntilAcknowledged() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003010u); + AttachBody(lifetime, record, SourceCell); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell, + destination); + + RuntimeRemotePlacementExecutionStatus status = + drive.TryExecuteAcceptedRemotePosition(record, route); + + Assert.Equal(RuntimeRemotePlacementExecutionStatus.Committed, status); + // No host subscription is wired in this bare fixture, so the Place + // receipt is still genuinely unacknowledged — the ledger must SEE it. + Assert.Equal( + 1, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + Assert.Equal( + 1, + lifetime.CaptureOwnership().RemotePlacementDrivePendingCount); + + DrainPlacementFifo(lifetime); + + Assert.Equal( + 0, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + AssertConverged(lifetime); + } + + [Fact] + public void Contention_WhenTheEntityAlreadyOwnsAnActiveOperation() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003006u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + Vector3 positionBefore = body.Position; + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + // An external placement authority (portal/teleport/another route) + // already owns this entity's SetPosition operation. + RuntimeEntityPlacementToken displaced = lifetime.Physics.SetPosition + .TryBeginExclusiveAuthoredPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(displaced.IsValid); + + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Contention, + drive.TryExecuteAcceptedRemotePosition(record, route)); + Assert.Equal(positionBefore, body.Position); + Assert.Equal(0, drive.PendingCount); + + RuntimePlacementCancellationReceipt cancellation = lifetime.Physics + .SetPosition.ForgetExactPlacement(displaced); + if (cancellation.IsValid) + lifetime.Physics.SetPosition.PublishCancellation(cancellation); + } + + /// + /// Per-entity independence: two remotes interleaved must not share any + /// state — one entity's Contention must never block the other's Begin, + /// and each converges to zero independently. + /// + [Fact] + public void PerEntityIndependence_TwoRemotesInterleavedDoNotBlockEachOther() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord first = CreateRemoteRecord(lifetime, 0x70003007u); + RuntimeEntityRecord second = CreateRemoteRecord(lifetime, 0x70003008u); + PhysicsBody firstBody = AttachBody(lifetime, first, SourceCell); + PhysicsBody secondBody = AttachBody(lifetime, second, SourceCell); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + // First entity's own operation is still outstanding (an external + // authority holds it) when the second entity's Position arrives. + RuntimeEntityPlacementToken firstDisplaced = lifetime.Physics.SetPosition + .TryBeginExclusiveAuthoredPlacement( + first, + first.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(firstDisplaced.IsValid); + + RuntimeAuthoritativePositionRoute firstRoute = MakeRoute( + first, RuntimeAuthoritativePositionDisposition.SetPosition, DestinationCell); + RuntimeAuthoritativePositionRoute secondRoute = MakeRoute( + second, RuntimeAuthoritativePositionDisposition.SetPosition, DestinationCell, + new Vector3(20f, 22f, SpawnHeight)); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Contention, + drive.TryExecuteAcceptedRemotePosition(first, firstRoute)); + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Committed, + drive.TryExecuteAcceptedRemotePosition(second, secondRoute)); + + Assert.Equal( + new Vector3(20f, 22f, SpawnHeight) + new Vector3(192f, 0f, 0f), + secondBody.Position); + Assert.Equal(0, drive.PendingCount); + + RuntimePlacementCancellationReceipt cancellation = lifetime.Physics + .SetPosition.ForgetExactPlacement(firstDisplaced); + if (cancellation.IsValid) + lifetime.Physics.SetPosition.PublishCancellation(cancellation); + DrainPlacementFifo(lifetime); + _ = firstBody; + } + + /// + /// Per-entity independence, the shape route 2's single _pending + /// slot cannot express: TWO entities each hold their OWN outstanding + /// preparation retry at the same time. Route 2's + /// RetainPending throws if a second live entry would displace the + /// first; this per-key map must track both simultaneously and let each + /// resolve (or die) independently. + /// + [Fact] + public void PerEntityIndependence_TwoConcurrentPreparationRetriesDoNotCollide() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord first = CreateRemoteRecord( + lifetime, 0x7000300Du, setupTableId: 0x02000001u); + RuntimeEntityRecord second = CreateRemoteRecord( + lifetime, 0x7000300Eu, setupTableId: 0x02000001u); + AttachBody(lifetime, first, SourceCell); + AttachBody(lifetime, second, SourceCell); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + RuntimeAuthoritativePositionRoute firstRoute = MakeRoute( + first, RuntimeAuthoritativePositionDisposition.SetPosition, DestinationCell); + RuntimeAuthoritativePositionRoute secondRoute = MakeRoute( + second, RuntimeAuthoritativePositionDisposition.SetPosition, DestinationCell); + + // Both entities' Setup assets are unresolved (Missing) — both retry. + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Contention, + drive.TryExecuteAcceptedRemotePosition(first, firstRoute)); + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Contention, + drive.TryExecuteAcceptedRemotePosition(second, secondRoute)); + Assert.Equal(2, drive.PendingCount); + + // The FIRST entity's retry dies (an unrelated accepted Position for + // IT ALONE forgets it); the second must remain completely + // unaffected — a shared/overwriting single slot could not represent + // this. + RuntimePlacementCancellationReceipt cancellation = + lifetime.Physics.SetPosition.Forget(first); + if (cancellation.IsValid) + lifetime.Physics.SetPosition.PublishCancellation(cancellation); + + drive.Advance(); + + Assert.Equal(1, drive.PendingCount); + // The second entity's own operation is still tracked — the first's + // cancellation removed exactly one operation, not both. + Assert.Equal( + 1, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + + // Cleanup: retire the second entity's still-live retry too. + RuntimePlacementCancellationReceipt secondCancellation = + lifetime.Physics.SetPosition.Forget(second); + if (secondCancellation.IsValid) + { + lifetime.Physics.SetPosition + .PublishCancellation(secondCancellation); + } + drive.Advance(); + Assert.Equal(0, drive.PendingCount); + } + + /// + /// B3 review fix: a retained preparation retry must re-check the SAME + /// service-window guard the entry point uses before Advance() + /// resubmits it — a destination that retired its collision publication in + /// the frames since the retry was retained must be dropped, not + /// resubmitted. Discriminates from the OLD unconditional resubmit: + /// resubmitting against the SAME always-Missing collision source + /// returns the SAME retryable status, so without the re-check the entry + /// would simply be re-retained (PendingCount stays 1) even though + /// the window already forbids the destination — never converging. + /// + [Fact] + public void Advance_DropsRetainedRetryWhenTheServiceWindowNoLongerCoversItsDestination() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + RuntimeEntityRecord record = CreateRemoteRecord( + lifetime, 0x70003011u, setupTableId: 0x02000001u); + AttachBody(lifetime, record, SourceCell); + CommitLandblockCollision(lifetime, DestinationLandblock); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, RuntimeAuthoritativePositionDisposition.SetPosition, DestinationCell); + + // The Setup asset is unresolved (Missing) — the first attempt + // retries rather than commits, retaining the entry. + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Contention, + drive.TryExecuteAcceptedRemotePosition(record, route)); + Assert.Equal(1, drive.PendingCount); + Assert.Equal( + 1, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + + // The remote wandered back out of range between this retry being + // retained and the next host cadence pump. + window.Forbid(DestinationLandblock); + + drive.Advance(); + + Assert.Equal(0, drive.PendingCount); + Assert.Equal( + 0, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + AssertConverged(lifetime); + } + + /// + /// Currency: a preparation-retry entry left tracking a token some OTHER + /// caller already Forgot must not leak once this entity's NEXT packet + /// takes a path that itself never touches _pending (here, a + /// service-window refusal) — proving the self-heal is doing real work, + /// not merely being masked by the retry branch's own dictionary + /// overwrite (which would hide the leak if this test only ever re-hit + /// the SAME retryable status). + /// + [Fact] + public void StalePreparationRetry_SelfHealsRatherThanLeakingWhenTheNextPacketNeverTouchesPending() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + RuntimeEntityRecord record = CreateRemoteRecord( + lifetime, 0x70003009u, setupTableId: 0x02000001u); + AttachBody(lifetime, record, SourceCell); + CommitLandblockCollision(lifetime, DestinationLandblock); + // A Setup asset that never resolves (Missing) — the first attempt + // through this collision source retries rather than commits. + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, RuntimeAuthoritativePositionDisposition.SetPosition, DestinationCell); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Contention, + drive.TryExecuteAcceptedRemotePosition(record, route)); + Assert.Equal(1, drive.PendingCount); + + // An unrelated accepted Position for this SAME entity Forgets the + // retained retry before it ever resolves — the mundane 5-10 Hz case. + RuntimePlacementCancellationReceipt cancellation = + lifetime.Physics.SetPosition.Forget(record); + if (cancellation.IsValid) + lifetime.Physics.SetPosition.PublishCancellation(cancellation); + + // The destination is no longer serviceable by the NEXT packet (the + // remote wandered back out of range) — the Refused branch never + // assigns or clears _pending itself, so only the entry-point + // self-heal can retire the dead entry left behind above. + window.Forbid(DestinationLandblock); + RuntimeAuthoritativePositionRoute refusedRoute = MakeRoute( + record, RuntimeAuthoritativePositionDisposition.SetPosition, DestinationCell); + + RuntimeRemotePlacementExecutionStatus status = + drive.TryExecuteAcceptedRemotePosition(record, refusedRoute); + Assert.Equal(RuntimeRemotePlacementExecutionStatus.Refused, status); + Assert.Equal(0, drive.PendingCount); + AssertConverged(lifetime); + } + + /// + /// C2-1 review fix (delta round): the retained _pending entry + /// holds a LIVE Core operation (already begun via + /// TryBeginExclusiveAuthoredPlacement, sitting at + /// AwaitingPreparation) — clearing this controller's own map on + /// detach is not enough; the operation itself must be cancelled or it + /// pins its landblock prefix forever (docs/ISSUES.md #310). Asserting + /// SetPositionOperationCount == 0 in addition to the ledger going + /// to zero is what actually proves the Core operation died, not just + /// that this controller stopped watching it — sibling tests in this file + /// (Committed_..., ParkCollisionResidents_...) already + /// check this dimension; this one previously did not, and would have + /// passed even with the old clear-only DetachRoute. + /// + [Fact] + public void LedgerConverges_AfterDetachRouteClearsTrackedEntries() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + RuntimeEntityRecord record = CreateRemoteRecord( + lifetime, 0x7000300Au, setupTableId: 0x02000001u); + AttachBody(lifetime, record, SourceCell); + CommitLandblockCollision(lifetime, DestinationLandblock); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + var route = new object(); + drive.AttachRoute(route); + + RuntimeAuthoritativePositionRoute positionRoute = MakeRoute( + record, RuntimeAuthoritativePositionDisposition.SetPosition, DestinationCell); + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Contention, + drive.TryExecuteAcceptedRemotePosition(record, positionRoute)); + Assert.Equal(1, drive.PendingCount); + Assert.Equal( + 1, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + + drive.DetachRoute(route); + + Assert.Equal(0, drive.PendingCount); + Assert.Equal(0, lifetime.CaptureOwnership().RemotePlacementDrivePendingCount); + Assert.Equal( + 0, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + } + + /// + /// C2-1 review fix (delta round): the OTHER map — _awaitingAcknowledgement + /// — holds an equally live Core operation (a published, unacknowledged + /// Place sitting at AwaitingCommitAcknowledgement), and the + /// prior DetachRoute left it live the same way. Mirrors the + /// _pending case above for the SECOND map DetachRoute must cancel. + /// + [Fact] + public void LedgerConverges_AfterDetachRouteCancelsAnUnacknowledgedCommit() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003012u); + AttachBody(lifetime, record, SourceCell); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + var route = new object(); + drive.AttachRoute(route); + + RuntimeAuthoritativePositionRoute positionRoute = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell, + new Vector3(12f, 14f, SpawnHeight)); + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Committed, + drive.TryExecuteAcceptedRemotePosition(record, positionRoute)); + // No host subscription is wired in this bare fixture, so the Place + // receipt is still genuinely unacknowledged — the SAME shape the + // production declined-sink FIFO retry class leaves behind. + Assert.Equal( + 1, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + Assert.Equal( + 1, lifetime.CaptureOwnership().RemotePlacementDrivePendingCount); + + drive.DetachRoute(route); + + Assert.Equal( + 0, lifetime.CaptureOwnership().RemotePlacementDrivePendingCount); + Assert.Equal( + 0, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + } + + /// + /// C2-1 review fix (delta round), disposal safety: + /// CountLiveAwaitingAcknowledgement calls + /// RuntimeSetPositionState.IsPlacementCurrent, whose first + /// statement is EnsureNotDisposed — it THROWS once disposed. A + /// post-Dispose() CaptureOwnership() read is the designed + /// contract (GameWindowLifetime.DisposeGameRuntime: + /// runtime.Dispose(); runtime.CaptureOwnership();), so this + /// dimension must survive it too. Deliberately leaves the entry + /// UNDRAINED at dispose time — a genuine leak, exactly what this ledger + /// exists to report — to prove the disposed branch reports it rather + /// than hiding it behind a thrown exception. + /// + [Fact] + public void CountLiveAwaitingAcknowledgement_SurvivesReadAfterDisposeWithoutThrowing() + { + var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003013u); + AttachBody(lifetime, record, SourceCell); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + RuntimeAuthoritativePositionRoute positionRoute = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell, + new Vector3(12f, 14f, SpawnHeight)); + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Committed, + drive.TryExecuteAcceptedRemotePosition(record, positionRoute)); + + lifetime.Dispose(); + int pendingAfterDispose = -1; + Exception? thrown = Record.Exception(() => + pendingAfterDispose = + lifetime.CaptureOwnership().RemotePlacementDrivePendingCount); + + Assert.Null(thrown); + Assert.Equal(1, pendingAfterDispose); + } + + [Fact] + public void AttachRoute_ThrowsForADifferentRouteWhileTheFirstIsStillLive() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + var window = new FakeServiceWindow(); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + var firstRoute = new object(); + var secondRoute = new object(); + drive.AttachRoute(firstRoute); + + Assert.Throws( + () => drive.AttachRoute(secondRoute)); + + drive.DetachRoute(firstRoute); + drive.AttachRoute(secondRoute); + } + + /// + /// Gate item: ParkCollisionResidents throws on overlap for every + /// spatial root in a retiring prefix that holds an active operation + /// (RuntimeSetPositionState.cs:3387-3395). This is the concrete + /// proof it stays unreachable under this route's design — both after a + /// refusal (no operation ever opened) and after a normal commit + /// (operation retired to zero), the SAME prefix can be "retired" without + /// throwing. + /// + [Fact] + public void ParkCollisionResidents_StaysUnreachable_AfterRefusalAndAfterCommit() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord refusedEntity = CreateRemoteRecord(lifetime, 0x7000300Bu); + AttachBody(lifetime, refusedEntity, DestinationCell); + RuntimeEntityRecord committedEntity = CreateRemoteRecord(lifetime, 0x7000300Cu); + AttachBody(lifetime, committedEntity, SourceCell); + + var refusingWindow = new FakeServiceWindow(); + RuntimeRemotePlacementDriveController refusingDrive = + CreateDrive(lifetime, refusingWindow); + RuntimeAuthoritativePositionRoute refusedRoute = MakeRoute( + refusedEntity, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell); + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Refused, + refusingDrive.TryExecuteAcceptedRemotePosition(refusedEntity, refusedRoute)); + + var allowingWindow = new FakeServiceWindow(); + allowingWindow.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController committingDrive = + CreateDrive(lifetime, allowingWindow); + RuntimeAuthoritativePositionRoute committedRoute = MakeRoute( + committedEntity, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell, + new Vector3(30f, 30f, SpawnHeight)); + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Committed, + committingDrive.TryExecuteAcceptedRemotePosition( + committedEntity, committedRoute)); + // No host subscription is wired in this bare fixture — drain the + // Place receipt exactly like production's placement-projection + // subscription would, converging the operation to zero the same way + // a live host's synchronous consumption does. + DrainPlacementFifo(lifetime); + + // Both entities now sit resident in the destination prefix with NO + // active operation. Retiring that prefix must not throw. + Exception? thrown = Record.Exception(() => + lifetime.Physics.SetPosition.ParkCollisionResidents( + DestinationLandblock, includeOutdoorCells: true)); + Assert.Null(thrown); + } + + // ── Fixture ────────────────────────────────────────────────────────── + + /// + /// Stands in for the production placement-projection subscription this + /// bare fixture never wires — a committed placement's Place + /// receipt would otherwise sit unacknowledged forever, exactly like + /// RuntimeAcceptedPositionDriveControllerTests.DrainPlacementFifo. + /// + private static void DrainPlacementFifo(RuntimeEntityObjectLifetime lifetime) + { + while (lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot head)) + { + if (!lifetime.Physics.SetPosition.AcknowledgeProjection(head.Token)) + break; + } + } + + private static void AssertConverged(RuntimeEntityObjectLifetime lifetime) + { + Assert.Equal( + 0, + lifetime.CaptureOwnership().RemotePlacementDrivePendingCount); + } + + private static RuntimeRemotePlacementDriveController CreateDrive( + RuntimeEntityObjectLifetime lifetime, + IRuntimeRemotePlacementServiceWindow window) => + new( + lifetime, + new GameRuntimeClock(), + new UnusedCollisionSource(), + window); + + private static RuntimeAuthoritativePositionRoute MakeRoute( + RuntimeEntityRecord record, + RuntimeAuthoritativePositionDisposition disposition, + uint destinationCellId, + Vector3? destinationPosition = null, + RuntimeSetPositionOperationKind operationKind = + RuntimeSetPositionOperationKind.RemoteAuthoritative, + PhysicsSetPositionFlags? setPositionFlags = null) + { + // Publishes the destination into the record's accepted Snapshot — + // exactly what the upstream merge + // (RuntimeEntityObjectLifetime.TryApplyPosition) already did before a + // real caller would ever reach this controller — so the controller's + // own service-window read (record.Snapshot.Position.LandblockId) + // observes the SAME destination the route was classified against. + Vector3 position = destinationPosition ?? new Vector3(10f, 10f, SpawnHeight); + record.Snapshot = record.Snapshot with + { + Position = new CreateObject.ServerPosition( + destinationCellId, + position.X, + position.Y, + position.Z, + 1f, + 0f, + 0f, + 0f), + }; + + var authority = new RuntimeAuthoritativePositionAuthority( + new RuntimeGenerationToken(1), + record.Key!.Value, + record.PositionAuthorityVersion, + AcceptedPositionSequence: 2, + PreviousTeleportSequence: 0, + AcceptedTeleportSequence: 0, + PositionTimestampDisposition.Apply); + + bool performsSetPosition = disposition is + RuntimeAuthoritativePositionDisposition.SetPosition + or RuntimeAuthoritativePositionDisposition.SetPositionSimple; + + return new RuntimeAuthoritativePositionRoute( + authority, + disposition, + operationKind, + setPositionFlags ?? (performsSetPosition + ? PhysicsSetPositionFlags.Teleport + | PhysicsSetPositionFlags.Slide + : PhysicsSetPositionFlags.None), + PlacementFrame: 0u, + UnparentBeforeRouting: true, + ApplyPlacementFrameBeforeRouting: false, + LeaveWorld: false, + TeleportHookPhase: RuntimeTeleportHookPhase.None, + StopInterpolating: false, + ConstrainPhase: RuntimePositionConstrainPhase.AfterPositionOperation, + PreserveHeading: false, + ZeroVelocity: false, + SendPositionImmediately: false, + CollisionBatchEligible: true); + } + + private static RuntimeEntityRecord CreateRemoteRecord( + RuntimeEntityObjectLifetime lifetime, + uint guid, + uint? setupTableId = null) + { + RuntimeEntityRecord record = lifetime.RegisterEntity( + Spawn(guid, setupTableId)).Canonical!; + lifetime.Entities.SetFinalPhysicsState(record, PhysicsStateFlags.Gravity); + return record; + } + + private static PhysicsBody AttachBody( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord record, + uint cellId) + { + lifetime.Entities.SetFullCell( + record, cellId, (cellId & 0xFFFF0000u) | 0xFFFFu); + var body = new PhysicsBody + { + Position = new Vector3(10f, 10f, SpawnHeight), + Orientation = Quaternion.Identity, + LastUpdateTime = 1d, + State = PhysicsStateFlags.Gravity, + TransientState = TransientStateFlags.Active, + }; + body.SnapToCell(cellId, body.Position, body.Position); + lifetime.Entities.SetPhysicsBody(record, body); + record.ObjectClock.Activate(); + lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true); + return body; + } + + private static WorldSession.EntitySpawn Spawn(uint guid, uint? setupTableId) => + new( + guid, + new CreateObject.ServerPosition( + SourceCell, 10f, 10f, SpawnHeight, 1f, 0f, 0f, 0f), + setupTableId, + AnimPartChanges: Array.Empty(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "remote", + ItemType: null, + MotionState: null, + MotionTableId: 0x09000001u); + + private static PhysicsEngine FlatEngine() + { + var engine = new PhysicsEngine + { + DataCache = new PhysicsDataCache(), + }; + engine.AddLandblock( + SourceLandblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + return engine; + } + + /// + /// Mirrors RuntimeAcceptedPositionDriveControllerTests.CommitLandblockCollision + /// exactly, against a bare + /// instead of a full GameRuntime. Also observes the SOURCE + /// landblock's world frame once — Runtime's world-frame resolution is + /// arithmetic thereafter, so a destination landblock never needs its own + /// call (route 2's own fixture comment). + /// + private static void CommitLandblockCollision( + RuntimeEntityObjectLifetime lifetime, + uint landblockId) + { + var heights = new byte[81]; + Array.Fill(heights, (byte)SpawnHeight); + var heightTable = new float[256]; + for (int index = 0; index < heightTable.Length; index++) + heightTable[index] = index; + lifetime.Physics.ObserveLocalWorldFrame( + SourceCell, teleportAdvanced: false); + lifetime.Physics.SetPosition.BeginCollisionGeneration(landblockId, 1UL); + lifetime.Physics.Engine.AddLandblock( + landblockId, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + worldOffsetX: 192f, + worldOffsetY: 0f); + lifetime.Physics.SetPosition.CommitCollisionGeneration( + landblockId, 1UL, ready: true); + } + + private sealed class FakeServiceWindow : IRuntimeRemotePlacementServiceWindow + { + private readonly HashSet _within = []; + + internal void Allow(uint landblockId) => + _within.Add(Canonical(landblockId)); + + internal void Forbid(uint landblockId) => + _within.Remove(Canonical(landblockId)); + + public bool IsWithinServiceWindow(uint landblockId) => + _within.Contains(Canonical(landblockId)); + + private static uint Canonical(uint landblockId) => + (landblockId & 0xFFFF0000u) | 0xFFFFu; + } + + private sealed class UnusedCollisionSource : IPreparedCollisionSource + { + public PreparedAssetPresence ProbeCollision( + PakAssetType type, + uint sourceFileId) => + PreparedAssetPresence.Available; + + public PreparedCollisionReadResult ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + PreparedCollisionReadResult.Missing; + + public PreparedCollisionReadResult ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public PreparedCollisionReadResult ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public PreparedCollisionReadResult ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public PreparedCollisionSourceStats CollisionStats => default; + + public void Dispose() + { + } + } +}