feat(physics): C4 route 4b-1 — remote placement infrastructure (dormant)
Builds the machinery route 4b-2 and 4b-3 will flip on, and changes no remote behaviour: it has no production caller, so RemotePlacementDrivePendingCount is provably 0 and IsConverged is unchanged. Five pieces: a per-entity remote placement owner (RuntimeRemotePlacementDriveController), a Position-time service-window guard with a Runtime interface plus BOTH host implementations, N3's headless RetryPending pump, parked-count observability in the ownership ledger, and the service-window optimisation that avoids parks we can cheaply predict. Landed alone because it is where the park-withdraws-the-entity failure was decided; that decision is fixed at the source in the preceding commit and must not share a review signal with a behaviour flip. Two parts of route 2's controller are deliberately NOT ported, both verified against retail rather than assumed. There is no ack: SendPositionEvent is called only inside HandleReceivedPosition's local-player FORCE_POSITION gate @0x0045400C-@0x00454091, and the remote arm @0x0045414D has no equivalent. There is no re-issue funnel: retail never re-attempts a position it could not apply — stale timestamps merely bump error_count @0x004542AC — and re-issuing packet N after N+1 has merged would apply a pose the newer packet already superseded, which is correct for a one-shot ForcePosition and wrong for a 5-10 Hz stream. The service-window guard is an OPTIMISATION, not the correctness mechanism. The original contract had it the other way round, justified by a claim that retail cannot represent "arrived but not placeable" — false, and corrected in the review findings: retail's GotoLostCell/reenter_visibility path represents it exactly. A pre-flight guard also cannot be complete, because Core defers on the entity's CURRENT cell, on the swept QueriedCellIds footprint spanning neighbouring landblocks, and on residency evaluated after AdjustToOutside — conditions only Core can see. Review found and this commit fixes: DetachRoute cleared two maps of LIVE Core operations without cancelling them (route 2's AbandonPending is the correct mirror, not the first-entry controller) and its test asserted that blindness as convergence; the headless predicate answered "can ever publish" rather than "is published", and after the first fix still matched only 1 of the 9 landblocks this host publishes; OwnsPlacement admitted remote top-level Creates until gated on the Teleport flag as well as the disposition; Advance re-submitted without re-checking the window; and four comments cited a report that did not exist. Contract item 6 is met by the structural proof, not the earlier test: HasOldPrefixPlacementDebt refuses collision-prefix mutation permission before ParkCollisionResidents is ever entered, so its overlap throw is unreachable. That same mechanism is the unbounded stall filed as #310, which 4b-1 does not bound — it only avoids widening it. #311 files the remaining per-tick allocation in RetryPendingProjections; the early-out for the empty-FIFO case landed via a new HasPendingReceipts accessor so hosts still never touch .Placements. directly. Gates: complete Release solution 10,973 passed / 4 skipped / 0 failed (baseline 10,938). Four review rounds; every fix discrimination-verified by revert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
634bc5513a
commit
2e8e09acd0
14 changed files with 3151 additions and 6 deletions
175
docs/research/2026-08-04-c4-route-4b-1-review-findings.md
Normal file
175
docs/research/2026-08-04-c4-route-4b-1-review-findings.md
Normal file
|
|
@ -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).
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4b-1: the graphical host's
|
||||
/// <see cref="IRuntimeRemotePlacementServiceWindow"/> — headless already had
|
||||
/// one (<c>HeadlessCollisionNeighborhood.IsWithinServiceWindow</c>,
|
||||
/// <c>HeadlessSessionWorldProjection.cs:27</c>/<c>:245-257</c>); the
|
||||
/// graphical host had none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Predicate choice — <see cref="GpuWorldState.IsNearTier"/>, not
|
||||
/// <see cref="GpuWorldState.IsNearTierOrPending"/>.</b> 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:
|
||||
/// <c>IsNearTierOrPending</c> is true for a landblock that has only been
|
||||
/// PARKED as a pending near-tier entity bucket
|
||||
/// (<c>GpuWorldState.CommitEntitiesToExistingLandblockSpatialCore</c>'s
|
||||
/// <c>parkIfMissing</c> branch, <c>:1919-1952</c>) — 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.
|
||||
/// <see cref="GpuWorldState.IsNearTier"/> alone is the correct predicate:
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Promotion direction (verified by reading, not assumed):</b> the only
|
||||
/// two writers of <c>_tierByLandblock[...] = LandblockStreamTier.Near</c> are
|
||||
/// <c>GpuWorldState.CommitLandblockSpatialCore</c> (:969) and
|
||||
/// <c>CommitEntitiesToExistingLandblockSpatialCore</c> (:1972), both called
|
||||
/// from <c>LandblockPresentationPipeline.Advance</c>'s
|
||||
/// <c>SpatialPresentationCommitted</c> stage — which runs strictly AFTER the
|
||||
/// preceding <c>PresentationCommitted</c> stage, the one that drives
|
||||
/// <c>LandblockPhysicsPublisher</c>'s staged collision/EnvCell advance to
|
||||
/// completion (<c>LandblockPresentationPipeline.cs</c> ~826-935). A
|
||||
/// landblock's tier cannot read Near before its collision has committed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Retirement direction (verified by reading, not assumed):</b>
|
||||
/// <c>GpuWorldState.DetachNearLayer</c> (:1755-1834) flips the tier to Far as
|
||||
/// the FIRST, synchronous step of a "detach-first" landblock retirement —
|
||||
/// <c>LandblockRetirementCoordinator.AdoptDetachedFull</c>'s own doc comment
|
||||
/// states spatial detachment has already committed by the time its retirement
|
||||
/// ticket is constructed, and that ticket's <c>LandblockRetirementStage.Physics</c>
|
||||
/// step (<c>_physics.AdvanceRemoval</c> → <c>RuntimeSetPositionState
|
||||
/// .WithdrawCollision</c> → the collision-side retirement
|
||||
/// <c>ParkCollisionResidents</c> can reach) runs strictly AFTER. A
|
||||
/// landblock's tier cannot still read Near once its collision has begun
|
||||
/// retiring.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Residual, not closed:</b> 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. <c>RuntimeSetPositionState</c>'s private
|
||||
/// <c>TryGetBlockingQuiescence</c> (:3820-3863) is Core's own check for
|
||||
/// exactly this case — an active <c>CollisionPrefixQuiescence</c> 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 <c>DeferredCell</c> residual
|
||||
/// <see cref="AcDream.Runtime.Session.RuntimeRemotePlacementDriveController"/>'s
|
||||
/// class doc describes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class GraphicalRemotePlacementServiceWindow
|
||||
: IRuntimeRemotePlacementServiceWindow
|
||||
{
|
||||
private readonly GpuWorldState _state;
|
||||
|
||||
internal GraphicalRemotePlacementServiceWindow(GpuWorldState state)
|
||||
{
|
||||
_state = state ?? throw new ArgumentNullException(nameof(state));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="landblockId"/> is a full ACE cell id (landblock high
|
||||
/// word + cell low word — <c>CreateObject.ServerPosition.LandblockId</c>
|
||||
/// carries the full id despite its name; see
|
||||
/// <c>RuntimeAcceptedPositionDriveControllerTests</c>'s own
|
||||
/// <c>SourceCell = SourceLandblock | 0x0001u</c> fixture shape).
|
||||
/// Canonicalized to <c>0xAAAAFFFF</c> the same way every
|
||||
/// <see cref="GpuWorldState"/> tier writer/reader does
|
||||
/// (<c>DetachNearLayer</c>, <c>CommitLandblockSpatialCore</c>) before the
|
||||
/// dictionary lookup — <see cref="GpuWorldState.IsNearTier"/> itself does
|
||||
/// not canonicalize its argument.
|
||||
/// </summary>
|
||||
public bool IsWithinServiceWindow(uint landblockId) =>
|
||||
_state.IsNearTier((landblockId & 0xFFFF0000u) | 0xFFFFu);
|
||||
}
|
||||
|
|
@ -20,6 +20,17 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting
|
|||
private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
|
||||
private readonly Action<RuntimeEntityRecord>? _localPlayerCompleted;
|
||||
private RuntimePlacementProjectionSubscription? _subscription;
|
||||
/// <summary>
|
||||
/// B5(c) review fix: the graphical host's per-frame retry callback goes
|
||||
/// through <c>RuntimePlacementProjectionRetrySlot</c>, which refuses a
|
||||
/// callback whose bound generation is no longer current
|
||||
/// (<c>RetryPending</c>'s own guard). Headless calls this route's
|
||||
/// <see cref="RetryPending"/> directly with no equivalent latch — this
|
||||
/// field plus the check inside <see cref="RetryPending"/> restore that
|
||||
/// same "the generation this route attached under must still be the
|
||||
/// live one" guard.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4b-1 (N3): republishes the canonical placement FIFO's
|
||||
/// current head. <see cref="Attach"/> only retries once, at subscribe
|
||||
/// time (<c>retryPendingOnSubscribe: true</c>) — the ONLY
|
||||
/// <c>RetryPending</c> 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
|
||||
/// (<c>GraphicalSessionEventRoute.Attach</c>'s retry-lease callback);
|
||||
/// <see cref="HeadlessSessionHost.Tick"/> calls this in the same order
|
||||
/// immediately after <c>HeadlessSessionWorldProjection.PumpFirstEntry</c>
|
||||
/// so a declined head left behind by
|
||||
/// <see cref="AcDream.Runtime.Physics.RuntimePlacementProjectionSubscription.OnPlacement"/>
|
||||
/// (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.
|
||||
///
|
||||
/// <para>
|
||||
/// B5(c) review fix: refuses when <see cref="_runtime"/>'s generation has
|
||||
/// moved past the one this route attached under — the graphical host's
|
||||
/// <c>RuntimePlacementProjectionRetrySlot</c> already refuses a
|
||||
/// stale-generation callback the same way; headless dereferenced
|
||||
/// <see cref="_subscription"/> directly with no equivalent guard before
|
||||
/// this fix.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// C2-2 review fix (delta round): the earlier version of this comment
|
||||
/// claimed <c>RuntimeSetPositionState.cs</c> 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
|
||||
/// <see cref="RuntimePlacementProjectionSubscription.HasPendingReceipts"/>
|
||||
/// (added for this fix) rather than the Runtime placement channel
|
||||
/// directly, so it never trips
|
||||
/// <c>RuntimePhysicsOwnershipTests.ProductionHostsUseSharedPlacementSubscriptionWithoutDirectChannel</c>
|
||||
/// — 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
|
||||
/// <c>RetryPendingProjections</c>'s own
|
||||
/// <c>_pendingProjection.Values.ToArray()</c> snapshot, which needs a
|
||||
/// change to <c>RuntimeSetPositionState.cs</c> this session did not make
|
||||
/// — filed as docs/ISSUES.md #311 rather than worked around.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal bool RetryPending()
|
||||
{
|
||||
if (_subscription is null
|
||||
|| _attachedGeneration != _runtime.Generation
|
||||
|| !_subscription.HasPendingReceipts)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return _subscription.RetryPending();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
|
|
|
|||
|
|
@ -121,6 +121,17 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
|
||||
private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease?
|
||||
_contentLease;
|
||||
/// <summary>
|
||||
/// B5(a) review fix: test-only seam (mirrors <c>policyOverride</c>'s own
|
||||
/// pattern) letting a focused test substitute a deterministic fake
|
||||
/// placement sink for the production <see cref="HeadlessRuntimePlacementProjectionSink"/>,
|
||||
/// so a test can drive <see cref="Tick"/> itself — the real
|
||||
/// <c>_eventRoute?.RetryPending()</c> call this fix covers — instead of
|
||||
/// hand-constructing a <see cref="HeadlessSessionEventRoute"/> outside
|
||||
/// this host. <c>null</c> (every production caller) keeps today's exact
|
||||
/// behavior.
|
||||
/// </summary>
|
||||
private readonly IRuntimePlacementProjectionSink? _placementSinkOverride;
|
||||
/// <summary>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;
|
||||
/// <summary>C4 route 4b-1 (N3): the exact route <see cref="CreateEventRoute"/>
|
||||
/// last constructed, so <see cref="Tick"/> can republish the canonical
|
||||
/// placement FIFO every tick — mirrors the graphical host's per-frame
|
||||
/// retry lease (<c>GraphicalSessionEventRoute.Attach</c>). Reassigned on
|
||||
/// every reconnect exactly like <see cref="_worldProjection"/>; the prior
|
||||
/// route's own disposal (via <c>LiveSessionHost</c>'s route replacement)
|
||||
/// is independent of this field.</summary>
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// C4 route 4b-1: also implements
|
||||
/// <see cref="AcDream.Runtime.Session.IRuntimeRemotePlacementServiceWindow"/>.
|
||||
/// B1 review fix: despite the identical <c>bool IsWithinServiceWindow(uint)</c>
|
||||
/// signature, the two interfaces ask DIFFERENT questions —
|
||||
/// <see cref="IHeadlessCollisionNeighborhood.IsWithinServiceWindow"/> is a
|
||||
/// pure geometry test ("can this landblock EVER collision-publish inside the
|
||||
/// requested 3x3 window" — <c>true</c> outright when no center has been
|
||||
/// requested yet), while
|
||||
/// <see cref="AcDream.Runtime.Session.IRuntimeRemotePlacementServiceWindow.IsWithinServiceWindow"/>
|
||||
/// 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
|
||||
/// <c>IsNearTierOrPending</c> for. <see cref="IsReady"/> asks the right
|
||||
/// question (it consults actual collision-publication residency), but it
|
||||
/// MUTATES — it calls <see cref="AdvanceWork"/> to progress publication work
|
||||
/// as a side effect, which a per-packet service-window pre-flight check must
|
||||
/// never do. <see cref="IsCollisionCurrentlyPublished"/> is
|
||||
/// <see cref="IsReady"/>'s residency shape with BOTH of its mutations
|
||||
/// removed — the <see cref="AdvanceWork"/> call AND the
|
||||
/// <c>_requestedFullCell</c> 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 <see cref="IsReady"/>'s
|
||||
/// own <c>_centerLandblock != center</c> restriction (correct for
|
||||
/// <see cref="IsReady"/>'s own use — "is the ONE landblock I most recently
|
||||
/// centered on ready" — but wrong here) to membership in
|
||||
/// <see cref="_resident"/>, the full set <see cref="BuildPublicationPlan"/>
|
||||
/// 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 <c>Advance</c> would then drop its retry once the service
|
||||
/// window (this predicate) forbade a destination that was genuinely
|
||||
/// servable. <see cref="_resident"/> is already scoped to the CURRENT
|
||||
/// window by construction: <see cref="CenterOn"/>'s re-center path fully
|
||||
/// retires the old center's <see cref="_resident"/> entries (via
|
||||
/// <see cref="AdvanceWork"/>'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 <see cref="_resident"/> and be misread as
|
||||
/// currently published. The explicit interface implementation below routes
|
||||
/// to it instead of the geometry test.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B1 review fix: a residency check — resident, terrain-published, and
|
||||
/// (for indoor cells) the <c>CellStruct</c> itself resolved — derived
|
||||
/// from <see cref="IsReady"/> with both of its mutations removed (the
|
||||
/// <see cref="AdvanceWork"/> call and the <c>_requestedFullCell</c> field
|
||||
/// write). This is what
|
||||
/// <see cref="AcDream.Runtime.Session.IRuntimeRemotePlacementServiceWindow.IsWithinServiceWindow"/>
|
||||
/// 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 <see cref="IsReady"/>, this
|
||||
/// checks membership in the FULL <see cref="_resident"/> set (the
|
||||
/// requested center plus its published 3x3 neighbors) rather than
|
||||
/// requiring an exact match against <see cref="_centerLandblock"/> — see
|
||||
/// the class remarks for why the narrower check was wrong for this
|
||||
/// interface's question.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B1 review fix: explicit implementation so this class can answer the
|
||||
/// two <c>IsWithinServiceWindow</c> questions differently despite the
|
||||
/// identical method signature — see the class remarks. Routes to
|
||||
/// <see cref="IsCollisionCurrentlyPublished"/>, never to the geometry
|
||||
/// test <see cref="IsWithinServiceWindow"/> implicitly implements for
|
||||
/// <see cref="IHeadlessCollisionNeighborhood"/>.
|
||||
/// </summary>
|
||||
bool AcDream.Runtime.Session.IRuntimeRemotePlacementServiceWindow
|
||||
.IsWithinServiceWindow(uint fullCellId) =>
|
||||
IsCollisionCurrentlyPublished(fullCellId);
|
||||
|
||||
private HeadlessCollisionGenerationTransaction? CreatePublication(
|
||||
uint landblockId,
|
||||
Vector3 origin,
|
||||
|
|
|
|||
|
|
@ -88,7 +88,20 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot(
|
|||
/// <see cref="RuntimeEntityObjectLifetime.RegisterAcceptedPositionDriveOwnership"/>.
|
||||
/// Gated by <see cref="IsConverged"/> — a leaked pending ack cannot hide.
|
||||
/// </summary>
|
||||
int AcceptedPositionDrivePendingCount = 0)
|
||||
int AcceptedPositionDrivePendingCount = 0,
|
||||
/// <summary>
|
||||
/// C4 route 4b-1: outstanding
|
||||
/// <c>AcDream.Runtime.Session.RuntimeRemotePlacementDriveController</c>
|
||||
/// preparation-retry entries (a not-yet-resolved
|
||||
/// <c>RetrySetupUnavailable</c>/<c>RetryWorldFrameUnavailable</c> for a
|
||||
/// remote), summed over every drive registered against this lifetime via
|
||||
/// <see cref="RuntimeEntityObjectLifetime.RegisterRemotePlacementDriveOwnership"/>.
|
||||
/// Gated by <see cref="IsConverged"/>, mirroring
|
||||
/// <see cref="AcceptedPositionDrivePendingCount"/> — steady-state
|
||||
/// remotes hold no operations, and this count proves it at every
|
||||
/// convergence checkpoint the same way.
|
||||
/// </summary>
|
||||
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<Func<int>> _firstEntryDriveOwnership = [];
|
||||
/// <summary>C4 route 2: see <see cref="RegisterAcceptedPositionDriveOwnership"/>.</summary>
|
||||
private readonly List<Func<int>> _acceptedPositionDriveOwnership = [];
|
||||
/// <summary>C4 route 4b-1: see <see cref="RegisterRemotePlacementDriveOwnership"/>.</summary>
|
||||
private readonly List<Func<int>> _remotePlacementDriveOwnership = [];
|
||||
/// <summary>
|
||||
/// C4 route 4a: captured by <see cref="BindEventContext"/> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4b-1: registers one host
|
||||
/// <c>RuntimeRemotePlacementDriveController</c>'s pending-count provider
|
||||
/// into this lifetime's ownership snapshot, mirroring
|
||||
/// <see cref="RegisterAcceptedPositionDriveOwnership"/> — 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).
|
||||
/// </summary>
|
||||
public void RegisterRemotePlacementDriveOwnership(Func<int> pendingCount)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pendingCount);
|
||||
EnsureNotDisposed();
|
||||
_remotePlacementDriveOwnership.Add(pendingCount);
|
||||
}
|
||||
|
||||
public void BindEventContext(
|
||||
Func<RuntimeGenerationToken> generation,
|
||||
Func<ulong> frameNumber)
|
||||
|
|
|
|||
|
|
@ -454,6 +454,21 @@ public sealed class RuntimePhysicsState : IDisposable
|
|||
_collisionGenerationCommittedObservers = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// C2-1 review fix (delta round): lets a ledger-provider callback
|
||||
/// registered against <see cref="RuntimeEntityObjectLifetime"/> (e.g.
|
||||
/// <c>RuntimeRemotePlacementDriveController.CountLiveAwaitingAcknowledgement</c>)
|
||||
/// check disposal state BEFORE calling into <see cref="SetPosition"/>,
|
||||
/// whose own <c>IsPlacementCurrent</c> throws <c>ObjectDisposedException</c>
|
||||
/// once disposed. <c>Dispose()</c> below disposes <see cref="SetPosition"/>
|
||||
/// strictly before setting this flag, so <c>IsDisposed == true</c> here
|
||||
/// guarantees <see cref="SetPosition"/> is already disposed too — a
|
||||
/// post-<c>Dispose()</c> <c>CaptureOwnership()</c> read is the designed
|
||||
/// contract (<c>GameWindowLifetime.DisposeGameRuntime</c>), so every
|
||||
/// ledger provider must survive it without throwing.
|
||||
/// </summary>
|
||||
internal bool IsDisposed => _disposed;
|
||||
|
||||
public event Action<RuntimePhysicsCellCommit>? CellCommitted;
|
||||
public event Action<RuntimeCollisionGenerationCommitted>?
|
||||
CollisionGenerationCommitted
|
||||
|
|
|
|||
|
|
@ -86,6 +86,20 @@ public sealed class RuntimePlacementProjectionSubscription
|
|||
public bool HasAppliedReceiptAwaitingAcknowledgement =>
|
||||
_appliedAwaitingAcknowledgement.IsValid;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="RetryPending"/> without reaching the
|
||||
/// Runtime placement channel directly — the architectural boundary
|
||||
/// <c>RuntimePhysicsOwnershipTests.ProductionHostsUseSharedPlacementSubscriptionWithoutDirectChannel</c>
|
||||
/// enforces (hosts consume placement state ONLY through this
|
||||
/// subscription). Only closes the EMPTY-FIFO case: when the count is
|
||||
/// nonzero, <c>RetryPending</c> still reaches
|
||||
/// <c>RuntimeSetPositionState.RetryPendingProjections</c>'s per-call
|
||||
/// array snapshot — see docs/ISSUES.md for that residual.
|
||||
/// </summary>
|
||||
public bool HasPendingReceipts => _channel.PendingCount != 0;
|
||||
|
||||
/// <summary>
|
||||
/// Republishes Runtime's complete still-pending FIFO. Later receipts are
|
||||
/// ignored until the exact oldest receipt projects and acknowledges.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4b-1: per-entity remote-placement service-window predicate. A
|
||||
/// host implements this over whatever collision-publication residency it
|
||||
/// actually tracks — <c>GpuWorldState.IsNearTier</c> for the graphical host,
|
||||
/// <c>HeadlessCollisionNeighborhood</c>'s explicit implementation (backed by
|
||||
/// its private <c>IsCollisionCurrentlyPublished</c> — NOT
|
||||
/// <see cref="AcDream.Headless.Hosting.IHeadlessCollisionNeighborhood.IsWithinServiceWindow"/>,
|
||||
/// 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
|
||||
/// <see cref="RuntimeRemotePlacementDriveController"/> can
|
||||
/// ask, BEFORE attempting any canonical SetPosition, whether the accepted
|
||||
/// destination is one this host can actually place a remote into right now.
|
||||
/// </summary>
|
||||
public interface IRuntimeRemotePlacementServiceWindow
|
||||
{
|
||||
/// <summary>
|
||||
/// True when <paramref name="landblockId"/>'s collision is currently
|
||||
/// published by this host, so a canonical SetPosition into it can be
|
||||
/// attempted without risking an un-wakeable <c>DeferredCell</c> park (see
|
||||
/// docs/research/2026-08-04-c4-route-4b-1-contract.md's "central
|
||||
/// decision"). <paramref name="landblockId"/> may be a full cell id — the
|
||||
/// implementation canonicalizes to the containing landblock.
|
||||
/// </summary>
|
||||
bool IsWithinServiceWindow(uint landblockId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Typed yields for
|
||||
/// <see cref="RuntimeRemotePlacementDriveController.TryExecuteAcceptedRemotePosition"/>.
|
||||
/// </summary>
|
||||
internal enum RuntimeRemotePlacementExecutionStatus : byte
|
||||
{
|
||||
/// <summary>Out of this route's scope: not a disposition this
|
||||
/// controller owns (<see cref="RuntimeRemotePlacementDriveController.OwnsPlacement"/>),
|
||||
/// no canonical body, or no incarnation key.</summary>
|
||||
NotApplicable,
|
||||
|
||||
/// <summary>
|
||||
/// The central decision (see the class doc on
|
||||
/// <see cref="RuntimeRemotePlacementDriveController"/>): 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.
|
||||
/// </summary>
|
||||
Refused,
|
||||
|
||||
/// <summary>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.</summary>
|
||||
Contention,
|
||||
|
||||
/// <summary>The canonical SetPosition committed synchronously.</summary>
|
||||
Committed,
|
||||
|
||||
/// <summary>Rejected/cancelled by Core (invalid prepared data, authority
|
||||
/// displaced mid-submit).</summary>
|
||||
Rejected,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4b-1: the Runtime-owned, per-entity accepted-Position execution
|
||||
/// seam for a remote whose classification is <c>SetPosition</c> (teleport /
|
||||
/// cell-less — 4b-3's eventual disposition) or <c>SetPositionSimple</c> (far
|
||||
/// snap, >=96 m — 4b-2's). Route 4a's
|
||||
/// <see cref="RuntimeRemoteSteadyStatePosition"/> already owns the two
|
||||
/// dispositions that perform no SetPosition at all
|
||||
/// (<c>NoPositionOperation</c>/<c>Interpolate</c>); 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:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>No ack.</b> Retail's remote arm has no
|
||||
/// <c>SendPositionEvent</c> — <c>HandleReceivedPosition</c> @0x00453FD0 calls
|
||||
/// it only on the local-player FORCE_POSITION branch. There is nothing here
|
||||
/// resembling route 2's <c>PositionEventOwed</c>/<c>SendPositionEvent</c>
|
||||
/// pair.</description></item>
|
||||
/// <item><description><b>No re-issue funnel.</b> 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.</description></item>
|
||||
/// <item><description><b>Per-entity, not per-session.</b> Route 2's
|
||||
/// <c>_pending</c> is a single slot because the local player is the only
|
||||
/// entity that route ever touches. Remotes are N entities, so
|
||||
/// <see cref="_pending"/> 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.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>The central decision — refuse, do not park.</b>
|
||||
/// <see cref="RuntimeSetPositionState"/>'s <c>DeferredCell</c> park
|
||||
/// withdraws the entity from the world (<c>ParkDeferred</c> sets
|
||||
/// <c>body.InWorld = false</c>, suspends the object clock, and publishes a
|
||||
/// <c>Withdraw</c>) — and <c>RuntimeEntityObjectLifetime.TryApplyPosition</c>
|
||||
/// calls <c>RuntimeSetPositionState.Forget</c> on EVERY subsequent accepted
|
||||
/// Position for that same entity, regardless of disposition. <c>Forget</c>'s
|
||||
/// <c>CancelCoreDeferred</c> removes the operation and rewrites the
|
||||
/// <c>Withdraw</c> into a <c>Discard</c> WITHOUT restoring <c>InWorld</c>,
|
||||
/// 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. <see cref="_serviceWindow"/>
|
||||
/// exists to prevent this controller from ever attempting a SetPosition
|
||||
/// whose destination cannot be placed right now: <see cref="TryExecuteAcceptedRemotePosition"/>
|
||||
/// checks it BEFORE calling
|
||||
/// <see cref="RuntimeSetPositionState.TryBeginExclusiveAuthoredPlacement"/>,
|
||||
/// and if Core still returns <c>DeferredCell</c> 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. <b>Why the guard can still miss (the co-extensivity
|
||||
/// finding and its residual):</b> a tier/residency-backed service window
|
||||
/// (the graphical host's <c>GpuWorldState.IsNearTier</c>, 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 —
|
||||
/// <see cref="RuntimeSetPositionState"/>'s private <c>TryGetBlockingQuiescence</c>
|
||||
/// 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// One instance per host session route, constructed once per host process
|
||||
/// and reused across reconnects exactly like
|
||||
/// <see cref="RuntimeFirstEntryDriveController"/> and
|
||||
/// <see cref="RuntimeAcceptedPositionDriveController"/> —
|
||||
/// <see cref="AttachRoute"/>/<see cref="DetachRoute"/> assert the same
|
||||
/// "session reset precedes a new route" ordering and clear any tracked
|
||||
/// entries left by a torn-down session.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Per-entity preparation-retry map (bounded to the two retryable
|
||||
/// <see cref="RuntimeSetPositionMoverPreparationStatus"/> reasons — Setup
|
||||
/// or world-frame data not resolved yet, NEITHER of which withdraws the
|
||||
/// entity). A <c>DeferredCell</c> outcome never enters this map — see the
|
||||
/// class doc's central decision.
|
||||
/// </summary>
|
||||
private readonly Dictionary<RuntimeEntityKey, Pending> _pending = [];
|
||||
/// <summary>
|
||||
/// B4 review fix: per-entity tokens whose <see cref="SubmitAndResolve"/>
|
||||
/// outcome was <c>CommittedHostAcknowledgementPending</c> and were STILL
|
||||
/// live (not synchronously consumed-and-acknowledged by the production
|
||||
/// placement-projection subscription inside that same call) the instant
|
||||
/// <see cref="SubmitAndResolve"/> returned <c>Committed</c>. Without this,
|
||||
/// the ledger went blind the moment <c>Committed</c> was returned, hiding
|
||||
/// exactly the declined-sink class the FIFO retry mechanism exists for.
|
||||
/// Pruned lazily (self-healing) on every read — see
|
||||
/// <see cref="CountLiveAwaitingAcknowledgement"/> — never gates any
|
||||
/// placement decision itself; Core's own <c>_operations</c> map remains
|
||||
/// the sole authority <c>TryBeginExclusiveAuthoredPlacement</c> consults.
|
||||
/// </summary>
|
||||
private readonly Dictionary<RuntimeEntityKey, RuntimeEntityPlacementToken>
|
||||
_awaitingAcknowledgement = [];
|
||||
private readonly List<RuntimeEntityKey> _driveScratch = [];
|
||||
/// <summary>
|
||||
/// C2-1 review fix (delta round): dedicated reusable scratch list for
|
||||
/// <see cref="CountLiveAwaitingAcknowledgement"/>'s self-heal removal
|
||||
/// pass — kept separate from <see cref="_driveScratch"/> (owned by
|
||||
/// <see cref="Advance"/>) so a ledger read reached while <c>Advance</c>
|
||||
/// is mid-iteration can never corrupt its scratch buffer.
|
||||
/// </summary>
|
||||
private readonly List<RuntimeEntityKey> _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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Preparation-stage retries only (see <see cref="_pending"/>'s own doc).
|
||||
/// Deliberately does NOT include <see cref="_awaitingAcknowledgement"/> —
|
||||
/// that dimension is reported to the lifetime's ownership ledger via the
|
||||
/// constructor's second <c>RegisterRemotePlacementDriveOwnership</c> call
|
||||
/// (B4 review fix) and has no separate test-visible counter of its own.
|
||||
/// </summary>
|
||||
internal int PendingCount => _pending.Count;
|
||||
|
||||
/// <summary>
|
||||
/// True when route 4b owns this classification for a remote — the two
|
||||
/// dispositions route 4a's <see cref="RuntimeRemoteSteadyStatePosition"/>
|
||||
/// does NOT already handle. Everything else (<c>Interpolate</c>,
|
||||
/// <c>NoPositionOperation</c>, <c>RejectedAuthority</c>,
|
||||
/// <c>RejectedData</c>, <c>AwaitFreshPosition</c>) is out of scope here.
|
||||
/// B6 review fix: <c>Disposition</c> alone is not exact — the classifier
|
||||
/// (<c>RuntimeAuthoritativePositionRouteClassifier.cs</c>) assigns
|
||||
/// <c>SetPosition</c>/<c>SetPositionSimple</c> at several independent
|
||||
/// call sites (:256, :332, :355, :403, :459) and derives
|
||||
/// <c>OperationKind</c> separately (:560-575) — the SAME disposition
|
||||
/// covers the LOCAL PLAYER's FORCE_POSITION/teleport branches
|
||||
/// (<c>RuntimeSetPositionOperationKind.LocalAuthoritative</c>).
|
||||
/// <paramref name="route"/> is a parameter separate from the entity
|
||||
/// record at every call site, so a mismatched pair is expressible;
|
||||
/// gating on <c>OperationKind</c> narrows to remotes AND the local
|
||||
/// player's own initial Create — <c>OperationKind</c>'s own switch
|
||||
/// (:559-570) maps <c>InitialLogin</c> to the LOCAL PLAYER's Create
|
||||
/// ONLY; a REMOTE top-level Create maps to <c>RemoteAuthoritative</c>
|
||||
/// exactly like a remote accepted Position does (correcting this
|
||||
/// comment's earlier, wrong claim that all initial Creates map to
|
||||
/// <c>InitialLogin</c>).
|
||||
/// <para>
|
||||
/// C2-4 review fix (delta round): so <c>OperationKind</c> alone still
|
||||
/// does not exclude a remote top-level Create — <c>ClassifyCreate</c>
|
||||
/// (:254-273) emits <c>SetPosition</c> + <c>RemoteAuthoritative</c> +
|
||||
/// <c>InitialCreateFlags</c> (<c>Placement|Slide</c>) for one, while
|
||||
/// <c>ClassifyAcceptedPosition</c>'s remote branches (:401-416,
|
||||
/// :454-472) always carry <c>AuthoritativeTeleportFlags</c>
|
||||
/// (<c>Teleport|Slide|SendPositionEvent</c>) for the SAME disposition/
|
||||
/// OperationKind pair. The <c>Teleport</c> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static bool OwnsPlacement(RuntimeAuthoritativePositionRoute route) =>
|
||||
route.OperationKind is RuntimeSetPositionOperationKind.RemoteAuthoritative
|
||||
&& route.Disposition is RuntimeAuthoritativePositionDisposition.SetPosition
|
||||
or RuntimeAuthoritativePositionDisposition.SetPositionSimple
|
||||
&& (route.SetPositionFlags & PhysicsSetPositionFlags.Teleport) != 0;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors <see cref="RuntimeFirstEntryDriveController.AttachRoute"/> and
|
||||
/// <see cref="RuntimeAcceptedPositionDriveController.AttachRoute"/>: this
|
||||
/// controller outlives its session routes, so the "session reset
|
||||
/// precedes a new route" ordering is asserted, not assumed.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Route-scoped teardown: abandons every tracked entry, but ONLY when
|
||||
/// <paramref name="route"/> is the attached owner.
|
||||
///
|
||||
/// <para>
|
||||
/// C2-1 review fix (delta round) — the prior version of this method (and
|
||||
/// this comment) was wrong: it cleared <see cref="_pending"/> and
|
||||
/// <see cref="_awaitingAcknowledgement"/> as if they were pure
|
||||
/// bookkeeping, citing <see cref="RuntimeFirstEntryDriveController.DetachRoute"/>
|
||||
/// 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 — <see cref="_pending"/> at
|
||||
/// <c>AwaitingPreparation</c> (already begun via
|
||||
/// <c>TryBeginExclusiveAuthoredPlacement</c>), <see cref="_awaitingAcknowledgement"/>
|
||||
/// at <c>AwaitingCommitAcknowledgement</c> with a published <c>Place</c>.
|
||||
/// Clearing the local dictionary without cancelling the Core operation
|
||||
/// left it live forever, pinning its landblock prefix
|
||||
/// (<c>HasOldPrefixPlacementDebt</c>) — 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 <c>RuntimeAcceptedPositionDriveController.DetachRoute</c>
|
||||
/// → <c>AbandonPending</c>, which this now matches: cancel every live
|
||||
/// operation (<see cref="CancelToken"/>, <c>restoreCancelledPark: true</c>
|
||||
/// — 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes an already-classified remote accepted Position against the
|
||||
/// canonical Runtime SetPosition owner. <paramref name="record"/>'s
|
||||
/// Snapshot and PositionAuthorityVersion must already reflect the merge
|
||||
/// <see cref="RuntimeEntityObjectLifetime.TryApplyPosition"/> performed —
|
||||
/// this method never re-merges the wire frame, and <paramref name="route"/>
|
||||
/// must already be the result of
|
||||
/// <see cref="RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition"/>
|
||||
/// for the SAME packet (the shared classification builder every remote
|
||||
/// caller uses — never re-derived here).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Host cadence pump: retries a preparation-only retry status
|
||||
/// (<c>RetrySetupUnavailable</c>/<c>RetryWorldFrameUnavailable</c>) by
|
||||
/// re-calling the SAME prepare+submit pair, exactly like
|
||||
/// <see cref="RuntimeFirstEntryDriveController"/>'s own continuation
|
||||
/// completion. Bounded, non-allocating iteration mirrors
|
||||
/// <see cref="RuntimeFirstEntryDriveController"/>'s <c>_driveScratch</c>
|
||||
/// template. Safe to call from any host cadence point; a no-op when
|
||||
/// nothing is pending.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Self-healing read: prunes every <see cref="_awaitingAcknowledgement"/>
|
||||
/// 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
|
||||
/// <c>RegisterRemotePlacementDriveOwnership</c> 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.
|
||||
///
|
||||
/// <para>
|
||||
/// C2-1 review fix (delta round), disposal safety: <c>IsPlacementCurrent</c>'s
|
||||
/// first statement is <c>EnsureNotDisposed</c>, which THROWS once
|
||||
/// <see cref="RuntimeSetPositionState"/> is disposed. A post-<c>Dispose()</c>
|
||||
/// <c>CaptureOwnership()</c> read is the designed contract
|
||||
/// (<c>GameWindowLifetime.DisposeGameRuntime</c>: <c>runtime.Dispose();
|
||||
/// runtime.CaptureOwnership();</c>), so this — the first ledger provider
|
||||
/// to reach into another disposable subsystem — must survive it. When
|
||||
/// <see cref="RuntimePhysicsState.IsDisposed"/> 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 <see cref="DetachRoute"/> before disposal, so this
|
||||
/// branch reports 0 in the healthy path and a genuine nonzero leak
|
||||
/// otherwise — never an exception either way.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// C2-1 review fix (delta round), allocation: reuses
|
||||
/// <see cref="_awaitingAcknowledgementScratch"/> instead of a per-call
|
||||
/// <c>List<RuntimeEntityKey></c>, mirroring <see cref="_driveScratch"/>'s
|
||||
/// own template.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
using AcDream.App.Streaming;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.Tests.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4b-1: focused tests for the graphical host's
|
||||
/// <see cref="GraphicalRemotePlacementServiceWindow"/> — the App-side
|
||||
/// implementation of <c>IRuntimeRemotePlacementServiceWindow</c> that did not
|
||||
/// exist before this route. Verifies the predicate matches
|
||||
/// <see cref="GpuWorldState.IsNearTier"/> exactly (not
|
||||
/// <see cref="GpuWorldState.IsNearTierOrPending"/>, 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.
|
||||
/// </summary>
|
||||
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<AcDream.Core.World.WorldEntity>()));
|
||||
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<AcDream.Core.World.WorldEntity>()));
|
||||
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<AcDream.Core.World.WorldEntity>()));
|
||||
var window = new GraphicalRemotePlacementServiceWindow(state);
|
||||
Assert.True(window.IsWithinServiceWindow(OutdoorCell));
|
||||
|
||||
_ = state.DetachNearLayer(LandblockHighWord);
|
||||
|
||||
Assert.False(window.IsWithinServiceWindow(OutdoorCell));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// B1 review fix: <see cref="HeadlessCollisionNeighborhood"/> implements TWO
|
||||
/// interfaces that share the identical <c>bool IsWithinServiceWindow(uint)</c>
|
||||
/// signature but ask different questions —
|
||||
/// <see cref="IHeadlessCollisionNeighborhood.IsWithinServiceWindow"/> is a
|
||||
/// pure geometry test ("can this landblock EVER collision-publish", true
|
||||
/// outright with no center requested), while
|
||||
/// <see cref="IRuntimeRemotePlacementServiceWindow.IsWithinServiceWindow"/>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C2-3 review fix (delta round): <c>BuildPublicationPlan</c> 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
|
||||
/// <c>_centerLandblock</c> (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 <see cref="IHeadlessCollisionNeighborhood.IsReady"/>'s own
|
||||
/// <c>_centerLandblock != center</c> restriction — correct for
|
||||
/// <c>IsReady</c>'s narrower question, wrong here — so only ONE of the
|
||||
/// nine published landblocks would ever read true.
|
||||
/// <para>
|
||||
/// Seeds <c>_resident</c> directly via reflection (no lightweight DAT
|
||||
/// fixture in this test project can drive real 3x3 publication through
|
||||
/// <c>CenterOn</c> — its dummy <see cref="IDatReaderWriter"/> proxy makes
|
||||
/// <c>LandblockLoader.Load</c> fail for every landblock, including a
|
||||
/// REQUIRED center) — mirrors the existing reflection precedent
|
||||
/// <c>HeadlessSessionHostTests.SeedRuntimePlacement</c> already uses for
|
||||
/// otherwise-unreachable internal state. <c>_centerLandblock</c> is
|
||||
/// deliberately left at its default (never set) — the whole point is
|
||||
/// that this predicate no longer depends on it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[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<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
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<uint>)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<IDatReaderWriter, TestResourceProxy>();
|
||||
PreparedResource =
|
||||
DispatchProxy.Create<ITestPreparedSource, TestResourceProxy>();
|
||||
}
|
||||
|
||||
internal IDatReaderWriter DatsResource { get; }
|
||||
internal ITestPreparedSource PreparedResource { get; }
|
||||
|
||||
public HeadlessOpenedProcessContent Open(
|
||||
HeadlessContentDescriptor descriptor,
|
||||
Action<string> 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<ClientObject> 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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4b-1 (N3): <c>HeadlessSessionEventRoute.Attach</c> constructs its
|
||||
/// <c>RuntimePlacementProjectionSubscription</c> with
|
||||
/// <c>retryPendingOnSubscribe: true</c>, and that was the ONLY
|
||||
/// <c>RetryPending</c> 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
|
||||
/// (<c>RuntimePlacementProjectionSubscription.OnPlacement</c>'s doc comment),
|
||||
/// but nothing headless did ever asked again. This is the focused proof that
|
||||
/// <see cref="HeadlessSessionEventRoute.RetryPending"/> — the method
|
||||
/// <c>HeadlessSessionHost.Tick</c> now calls every tick, immediately after
|
||||
/// <c>HeadlessSessionWorldProjection.PumpFirstEntry</c> — actually re-offers a
|
||||
/// declined head. Without a SECOND call, the declined receipt sits forever.
|
||||
///
|
||||
/// <para>
|
||||
/// Uses the SAME lightweight <c>LiveSessionHost</c> + no-op event/command
|
||||
/// route fixture as
|
||||
/// <c>RuntimeAcceptedPositionDriveControllerTests.StartRuntime</c> — it
|
||||
/// produces a genuine nonzero <c>GameRuntime.Generation</c> (required:
|
||||
/// <c>RuntimePlacementProjectionChannel.IsCurrent</c> 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.
|
||||
/// <c>HeadlessSessionHost.Start</c> would also work generation-wise, but its
|
||||
/// own internal route always uses the real
|
||||
/// <c>HeadlessRuntimePlacementProjectionSink</c>, which would consume-and-
|
||||
/// acknowledge this test's synthetic Place before this test's own route ever
|
||||
/// subscribed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B5(c) review fix: <see cref="HeadlessSessionEventRoute.RetryPending"/>
|
||||
/// must refuse once Runtime's generation has moved past the one this
|
||||
/// route attached under — mirroring the graphical host's
|
||||
/// <c>RuntimePlacementProjectionRetrySlot</c>, which already refuses a
|
||||
/// stale-generation callback the same way (<c>BindOwned</c>/
|
||||
/// <c>RetryPending</c>'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.
|
||||
/// </summary>
|
||||
[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<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
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<CreateObject.AnimPartChange>(),
|
||||
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
|
||||
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
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<byte> datagram)
|
||||
{
|
||||
}
|
||||
|
||||
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram)
|
||||
{
|
||||
}
|
||||
|
||||
public int Receive(
|
||||
Span<byte> destination, TimeSpan timeout, out IPEndPoint? from)
|
||||
{
|
||||
from = null;
|
||||
return -1;
|
||||
}
|
||||
|
||||
public ValueTask<NetReceiveResult> ReceiveAsync(
|
||||
Memory<byte> destination, CancellationToken cancellationToken) =>
|
||||
ValueTask.FromException<NetReceiveResult>(
|
||||
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<ClientObject> 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<FlatSetupCollision> ReadSetupCollision(
|
||||
uint sourceFileId, CancellationToken cancellationToken = default) =>
|
||||
PreparedCollisionReadResult<FlatSetupCollision>.Missing;
|
||||
|
||||
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset> ReadGfxObjCollision(
|
||||
uint sourceFileId, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public PreparedCollisionReadResult<FlatCellStructureCollisionAsset> ReadCellStructureCollision(
|
||||
uint sourceFileId, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public PreparedCollisionReadResult<FlatEnvCellTopology> ReadEnvCellTopology(
|
||||
uint sourceFileId, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public PreparedCollisionSourceStats CollisionStats => default;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -225,6 +225,125 @@ public sealed class HeadlessSessionHostTests
|
|||
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B5(a) review fix: <see cref="HeadlessSessionEventRouteRetryPendingTests"/>
|
||||
/// proved the underlying re-offer MECHANISM works, but hand-constructed
|
||||
/// <see cref="HeadlessSessionEventRoute"/> directly and called
|
||||
/// <c>route.RetryPending()</c> itself — it never touches
|
||||
/// <see cref="HeadlessSessionHost.Tick"/>'s own
|
||||
/// <c>_eventRoute?.RetryPending()</c> call. This test drives <c>Tick</c>
|
||||
/// itself (via the <c>placementSinkOverride</c> test seam added for this
|
||||
/// fix, mirroring the existing <c>policyOverride</c> parameter) so a
|
||||
/// regression that deletes or reorders that exact line would fail HERE,
|
||||
/// not just in the lower-level subscription test.
|
||||
/// </summary>
|
||||
[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()
|
||||
{
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue