fix(physics): C4 route 3 — portal placement authority (local player)
Removes a duplicate placement authority for local-player portal arrival. Portalling worked before this change and works after it — this is not a bug fix, EXCEPT that it found and fixed one dead-code production bug. THE PRODUCTION BUG: TryExecuteCanonicalPortalPlacement re-read the accepted destination at Place time, but TryBeginPortalReveal already consumes that slot at Aim time — so the arm was 100% dead code and every real portal Place refused with host-token-unavailable. Found only because we refused to accept 7 skipped tests instead of chasing the count to zero. RETAIL IS THE GENERIC PATH FOR THE THIRD ROUTE RUNNING: SmartBox::TeleportPlayer @0x00453910 = SetPositionSimple(dest, 1) with flags 0x1012, followed by PlayerPositionUpdated. BOTH INVERSIONS, WITH THEIR ANCHORS: unlike route 2, the leash IS armed here (ConstrainTo @0x0045418A) and velocity is zeroed (set_velocity @0x004541B4); unlike route 4b-3, the local teleport_hook runs AFTER placement (@0x004538AE). THE THREE-ROUND DEFECT CHAIN, HONESTLY: - Round 1 released the player at the pre-teleport position while the anim stream marched on — the contract wrongly assumed Place re-fires (process rule 1's third occurrence this campaign). - Round 2's fix inferred commit from a global PendingCount, which three non-committing paths also clear — making the SAME bug complete cleanly and silently. Strictly worse than round 1: round 1 at least tripped portal-complete-before-materialized. - Round 3 latches the commit where it actually happens (ReconcileAndAcknowledgePortal), keyed on reveal generation and teleport sequence, via TryConsumePortalCommit. Two of the three required regression tests landed and are sabotage-verified on both hosts (ParkedPlace_ForgottenByOrdinaryMergeDoesNotLatchAsCommitted / HeadlessPortalPrepareDestinationForgottenByOrdinaryMergeDoesNotLatchAsCommitted). The third (force-arm-takes-the-slot) was judged unnecessary on review: with the inference gone, PendingCount is only a "don't ask yet" guard at both gates, so a force operation occupying or vacating the slot no longer changes an input the commit decision reads — the case collapses into what the landed test already discriminates. THE B2/P3 RESOLUTION: both round-2 reviews were right about different branches of the same synchronous call. RuntimePlacementProjectionSubscription .OnPlacement acknowledges the FIFO head only when TryApply returns true; a Place whose portal authority went stale (transit ended/superseded while parked) used to return false, wedging every later entity's placement receipt behind it forever. Both sinks (RuntimePlacementPresentationSink, HeadlessRuntimePlacementProjectionSink) now acknowledge-and-ignore a stale-authority Place instead of refusing it. The regression test (RuntimePlacementPresentationSinkTests .PortalPlace_StaleTransitHostOrSequenceIsAcknowledgedAndIgnored) had been asserting the old, wrong `false` behaviour; it now asserts and sabotage-verifies the fix. Also lands: AP-144 (register discipline — the portal movement-event send reuses the stricter UsePositionFromServer gate where retail's SendMovementEvent is the looser autonomy_level != 0 test, diverging only at level 1, currently unreachable), AP-145 + issue #318 (the local-player collision-shadow presentation write bypasses its own publisher's ShadowObjects write via a direct cache .Set(), self-healing only once dedup diverges — filed, not fixed, pending a composition test), AD-42 deleted (its last citation retired by the canonical portal arm), AD-2 updated (the wait-cue's trigger predicate now covers a second cause), and two documentation corrections: the enter_world misattribution (both call sites are in SmartBox::HandleCreateObject, only one in the player branch — portal arrival is TeleportPlayer, not enter_world) and the stale "local player never reaches this path" comment on the generic-remote-render-pose write. Suite: 11,090 passed / 4 skipped / 0 failed. No new skips, nothing weakened. STILL OWED: the connected two-client gate, with ACDREAM_PROBE_LOCAL_TELEPORT=1, scored only if [local-tp] lines actually appear in the capture — and explicitly NOT scored as covering issue #318 (no composition test yet asserts PhysicsEngine.ShadowObjects directly). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
cd3129e9d6
commit
e0f96a55bf
24 changed files with 5261 additions and 243 deletions
|
|
@ -24,6 +24,65 @@ What does NOT go here:
|
|||
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
|
||||
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
|
||||
|
||||
## #318 — C4 route 3 §8 items 8/9/10 residual: no end-to-end composition test, no local-player shadow assertion, no T8 ordering
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW (does not block round-3 acceptance per both reviewers; carried
|
||||
into C5)
|
||||
**Filed:** 2026-08-05, C4 route 3 round-3 review (retail B5/A5, architecture
|
||||
B5), carried per both reviewers' explicit conditions
|
||||
**Component:** Runtime / portal placement / local-player presentation
|
||||
|
||||
**Description:** The retail review's round-1 §3.4 premise — that
|
||||
`TryApplyRuntimePlacementPlace` does not write pose/rotation/`ParentCellId` or
|
||||
rebucket — was WRONG; round 3 verified it DOES. That closed the original
|
||||
blocking concern, but three narrower gaps remain and both reviewers agreed
|
||||
they must be tracked rather than silently dropped:
|
||||
|
||||
1. No end-to-end composition test exercises the full portal-arrival →
|
||||
canonical commit → presentation-suffix → `PhysicsEngine.ShadowObjects`
|
||||
chain for the LOCAL player specifically (existing tests cover pieces —
|
||||
the canonical commit, the presentation sink's `TryApply`, the drive
|
||||
controller — but not the full composed path with a real
|
||||
`RuntimePlacementPresentationSink` wired to a real `PhysicsEngine`).
|
||||
2. No test asserts the local-player collision SHADOW lands at the
|
||||
destination. The discriminating assertion for that future test:
|
||||
`PhysicsEngine.ShadowObjects` must hold a row at the destination cell/
|
||||
position, not just `LocalPlayerShadowState`'s internal dedup cache — see
|
||||
the register row (AP-131 amendment, filed alongside this issue) for the
|
||||
asymmetry this exposes: `LocalPlayerShadowState.Set` updates the dedup
|
||||
cache without publishing to `ShadowObjects`, self-healing only on the
|
||||
local player's first subsequent movement tick.
|
||||
3. No test proves T8's ordering — that the canonical commit's writes
|
||||
(pose/rotation/`ParentCellId`/rebucket) precede the presentation suffix's
|
||||
OWN redundant writes to the same fields, rather than racing or reversing.
|
||||
|
||||
**Root cause / status:** Not a defect — a coverage gap. The underlying
|
||||
mechanism (`RuntimePlacementPresentationSink.TryApply` →
|
||||
`LiveEntityRuntime.TryApplyRuntimePlacementProjection` →
|
||||
`TryPublishPlace` → `LocalPlayerShadowState.Set`) is correct by code reading
|
||||
and by the individual unit tests that DO exist; what's missing is the
|
||||
COMPOSED, end-to-end proof plus the specific shadow-registry assertion.
|
||||
|
||||
**Files:** `src/AcDream.App/World/RuntimePlacementPresentationSink.cs`
|
||||
(`TryPublishPlace`, `LocalPlayerShadowState.Set` call); `src/AcDream.App/Physics/LocalPlayerShadowState.cs`;
|
||||
`src/AcDream.Core/Physics/PhysicsEngine.cs` (`ShadowObjects`);
|
||||
`src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs`
|
||||
(`ReconcileAndAcknowledgePortal`, the T8 probe log).
|
||||
|
||||
**Research:** `docs/research/2026-08-04-c4-route-3-contract.md` §3.4;
|
||||
`docs/research/2026-08-04-c4-route-3-retail-review-round2.md` §D (B5/A5);
|
||||
`docs/research/2026-08-04-c4-route-3-architecture-review-round2.md` B5.
|
||||
|
||||
**Acceptance:** A composition test drives a real portal arrival through the
|
||||
canonical drive controller and the real `RuntimePlacementPresentationSink`
|
||||
against a real `PhysicsEngine`, then asserts `PhysicsEngine.ShadowObjects`
|
||||
holds the local player at the destination position/cell (not merely
|
||||
`LocalPlayerShadowState`'s cache) and that the write ordering matches T8 (a
|
||||
probe or log-order assertion). Do not score the existing connected/manual
|
||||
gate as covering this — it exercises the live path but does not assert the
|
||||
shadow registry specifically.
|
||||
|
||||
## #317 — `TryCommitAuthoritativeVelocity`'s call site has no established retail basis
|
||||
|
||||
**Status:** OPEN
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -91,6 +91,34 @@ same commit) → docs/handoff commit. No workarounds; no fused slices.
|
|||
and local-player owners.
|
||||
- `RuntimePortalPlacementAuthority` has zero producing call sites; the
|
||||
adapter from `RuntimeWorldTransitState` does not exist.
|
||||
**Corrected 2026-08-04 (C4 route 3 closure,
|
||||
`docs/research/2026-08-04-c4-route-3-contract.md`), itself corrected
|
||||
2026-08-05 (A10 architecture review — the first correction asserted a
|
||||
false fact of its own), and rewritten 2026-08-05 (N5 retail-review
|
||||
round-3 fix — the prior wording of this correction contradicted
|
||||
itself).** The original bullet conflated two separate claims into one
|
||||
sentence, and only one of them was true. What pre-dated route 3 and WAS
|
||||
accurate: the `RuntimePortalPlacementAuthority` type existed (referenced
|
||||
by route 2's `Pending.Portal` field, always `Present: false`), its
|
||||
`IsValid` check existed, and the sinks' portal-authority gates plus
|
||||
`BeginAcceptedPlacementCore`'s gate already read it. What was NOT
|
||||
accurate, and is what "zero producing call sites; the adapter does not
|
||||
exist" actually described: the PRODUCER half — nothing built a
|
||||
`Present: true` authority and called the consumer arm
|
||||
(`RuntimeAcceptedPositionDriveController.TryExecuteAcceptedPortalArrival`/
|
||||
`SubmitAndResolvePortal`/`ClassifyPortalArrival`) — that consumer arm
|
||||
ALSO did not exist before route 3. Route 3 added the producer and the
|
||||
consumer together, in the same slice: the producer is
|
||||
`LocalPlayerTeleportController.TryExecuteCanonicalPortalPlacement` (now
|
||||
`TryAdvancePortalCommit`/`TryExecuteCanonicalPortalPlacementCore`, per the
|
||||
2026-08-05 A1 review fix), which builds the authority from
|
||||
`WorldRevealCoordinator`/`RuntimeWorldTransitState` facts and calls
|
||||
`TryExecuteAcceptedPortalArrival`; the identical Runtime entry point is
|
||||
shared by the headless host. So: the type/`IsValid`/consumer-gate facts
|
||||
pre-dated route 3 and were true before it; the arm (both the producer
|
||||
that builds a live authority and the consumer that reads one) did not
|
||||
exist before route 3 and is what the original bullet's "zero producing
|
||||
call sites" language was pointing at.
|
||||
- The exact-Setup mover chain (`PrepareMover` /
|
||||
`RuntimeSetPositionMoverPreparer.TryBuild` /
|
||||
`IPreparedCollisionSource.ReadSetupCollision`) exists piecewise, unwired.
|
||||
|
|
|
|||
|
|
@ -206,25 +206,56 @@ which confirms that worker completion alone is not draw readiness.
|
|||
|
||||
### 2.1. Destination placement enters the spatial cell before simulation resumes
|
||||
|
||||
> **2026-08-04 correction (C4 route 3, D-T9), itself corrected 2026-08-05
|
||||
> (R6 retail review):** the listing below attributes portal arrival to
|
||||
> `player.enter_world(destination)`. That is wrong — a caller sweep of the
|
||||
> named retail decomp
|
||||
> (`docs/research/named-retail/acclient_2013_pseudo_c.txt:93770-93828`) shows
|
||||
> both `CPhysicsObj::enter_world` call sites (pseudo-C `:93797` @0x004550EC
|
||||
> and `:93824` @0x00455095) living inside **`SmartBox::HandleCreateObject`
|
||||
> @0x00454C80** — `CObjectMaint::CreateObject` @0x00454FD8 is merely a
|
||||
> *callee* it invokes partway through, not the enclosing function the first
|
||||
> correction pass named. The two call sites are also **not both in the
|
||||
> player branch**: @0x004550EC sits in the `if (arg3 != this->player_id)`
|
||||
> NON-player branch (`PhysicsDesc::get_position` → `enter_world` for a
|
||||
> newly-created REMOTE object); only @0x00455095 sits in the player branch,
|
||||
> after `SmartBox::init_player` + `CellManager::ChangePosition`. Both sites
|
||||
> are the LOGIN/CreateObject path that creates a physics object for the
|
||||
> first time — neither is portal arrival. Portal arrival is
|
||||
> `SmartBox::TeleportPlayer` (`0x00453910`) → `CPhysicsObj::SetPositionSimple`
|
||||
> (`0x00453924`/`0x005162B0`) — confirmed by C4 route 3's own §1 citations
|
||||
> and grep at `acclient_2013_pseudo_c.txt:92514-92521`. The conclusion below
|
||||
> (commit the cell before releasing simulation) is unaffected —
|
||||
> `SetPositionSimple` reaches the identical `change_cell`/`update_object`
|
||||
> machinery this section describes — only the entry-point name and
|
||||
> pseudocode's `enter_world` call are wrong; read `SetPositionSimple(destination)`
|
||||
> wherever this section says `enter_world(destination)`.
|
||||
>
|
||||
> This routing is `SmartBox::TeleportPlayer` → `SetPositionSimple`
|
||||
> everywhere; nothing in the passages below distinguishes retail's specific
|
||||
> Recall/Lifestone/GM-teleport CAUSES, since they all funnel through the same
|
||||
> accepted-destination Position at this layer.
|
||||
|
||||
Named retail references:
|
||||
|
||||
- `CPhysicsObj::change_cell` at `0x00513390`
|
||||
- `CPhysicsObj::update_object` at `0x00515D10`
|
||||
- `CPhysicsObj::enter_world` at `0x00516170`
|
||||
- `SmartBox::TeleportPlayer` at `0x00453910`
|
||||
- `CPhysicsObj::SetPositionSimple` at `0x005162B0`
|
||||
- `CPhysicsObj::prepare_to_enter_world` at `0x00511FA0`
|
||||
- `CPhysicsObj::set_hidden` at `0x00514C60`
|
||||
|
||||
Retail does not separate an accepted destination Position from the object's
|
||||
live cell pointer. `enter_world` runs `SetPosition`, which installs the object
|
||||
in its destination `CObjCell`, before the PartArray and MovementManager
|
||||
enter-world boundaries complete. `update_object` then rejects only a parented
|
||||
object, a null `cell`, or a Frozen object; Hidden is not a reason to skip the
|
||||
live cell pointer. `SetPositionSimple` installs the object in its destination
|
||||
`CObjCell`, before the PartArray and MovementManager enter-world boundaries
|
||||
complete. `update_object` then rejects only a parented object, a null `cell`,
|
||||
or a Frozen object; Hidden is not a reason to skip the
|
||||
ScriptManager/ParticleManager tail.
|
||||
|
||||
```text
|
||||
accepted portal destination becomes ready:
|
||||
player.enter_world(destination)
|
||||
SetPosition(destination)
|
||||
SmartBox.TeleportPlayer(destination)
|
||||
SetPositionSimple(destination)
|
||||
change_cell(destination CObjCell)
|
||||
PartArray.HandleEnterWorld()
|
||||
MovementManager.HandleEnterWorld()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,411 @@
|
|||
# C4 route 3 — architecture / adversarial review, ROUND 2 (delta) — 2026-08-05
|
||||
|
||||
**Verdict: FAIL.**
|
||||
|
||||
Reviewed: the uncommitted working tree at HEAD **`cd3129e9`**, +2,570/-232 across
|
||||
16 files. Round-1 report:
|
||||
[`2026-08-04-c4-route-3-architecture-review.md`](2026-08-04-c4-route-3-architecture-review.md).
|
||||
`dotnet build AcDream.slnx -c Debug` exits 0.
|
||||
|
||||
**Round-1 findings closed: A4, A6, A9, A10(a).** A1, A2, A3 were addressed with
|
||||
real design work that is directionally right — the A1 fix in particular
|
||||
(inverting the readiness feed instead of touching the sequencer) is the correct
|
||||
architectural answer to a hard constraint, and I want that stated plainly.
|
||||
|
||||
**The FAIL is one defect, present symmetrically on both hosts, introduced by
|
||||
the A1/A3 fixes themselves:** both new "am I committed yet?" gates infer
|
||||
*commit* from *the drive controller's global pending slot being empty*. That
|
||||
slot empties on at least three paths that do **not** commit — including the one
|
||||
the drive's own doc comment names as the *expected* outcome of a park. When it
|
||||
does, the graphical controller latches `_placementCommitted = true` and the
|
||||
headless projection reports `IsCollisionReady: true`, and both hosts then march
|
||||
the full completion sequence against an unmoved body. That is round-1's A1/A3
|
||||
restored, and on the graphical side it is now *worse*, because
|
||||
`AcknowledgePortalMaterialized` succeeds where it previously failed its
|
||||
invariant.
|
||||
|
||||
Numbering continues as **B*n*** to avoid collision with round 1.
|
||||
|
||||
---
|
||||
|
||||
## MAJOR — the FAIL
|
||||
|
||||
### B1 — `PendingCount == 0` is not "committed"; both hosts infer commit from a signal that is also set by three non-committing paths
|
||||
|
||||
**Severity: MAJOR (FAIL basis). Both hosts. Uncovered by any test.**
|
||||
|
||||
Graphical, `src/AcDream.App/Streaming/LocalPlayerTeleportController.cs:646-658`:
|
||||
|
||||
```csharp
|
||||
if (_awaitingDeferredWake)
|
||||
{
|
||||
if (_acceptedPositionDrive.PendingCount != 0)
|
||||
return false;
|
||||
_awaitingDeferredWake = false;
|
||||
_placementCommitted = true; // <-- infers commit from "not pending"
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
Headless, `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs:816-821`:
|
||||
|
||||
```csharp
|
||||
if (_awaitingPortalWake)
|
||||
{
|
||||
committed = _acceptedPositionDrive.PendingCount == 0;
|
||||
if (committed)
|
||||
_awaitingPortalWake = false;
|
||||
}
|
||||
```
|
||||
|
||||
`PendingCount` is
|
||||
`RuntimeAcceptedPositionDriveController.cs:330` — `_pending is null ? 0 : 1`.
|
||||
It is (a) **global**, not portal-scoped, and (b) cleared by every terminal path,
|
||||
committing or not. `Advance()` clears `_pending` at five sites; three of them
|
||||
run **without** a portal commit:
|
||||
|
||||
| site | condition | committed? |
|
||||
|---|---|---|
|
||||
| `:975` | *"The watch died — most likely a subsequent accepted Position's merge-time `Forget`"* | **no** |
|
||||
| `:920` | A2's new abandon-at-wake (`!IsPortalAuthorityCurrent`) | body moved by `RetryDeferred`, **suffix skipped** |
|
||||
| `:998` | A2's new abandon at the prepare-retry branch (`CancelToken`) | **no — nothing ever placed** |
|
||||
|
||||
**The `:975` path is the modal case, not a corner case.** The method's own
|
||||
doc comment (`:880-892`) says it verbatim:
|
||||
|
||||
> `RuntimeEntityObjectLifetime.TryApplyPosition` calls `Forget` on EVERY
|
||||
> accepted Position for this entity … ACE broadcasts at 5-10 Hz, so a
|
||||
> `DeferredCell` park surviving past one broadcast interval is cancelled
|
||||
> before its collision generation can ever commit it — **the exact
|
||||
> far-destination case the park exists to serve**.
|
||||
|
||||
So: park → within ~100-200 ms an ordinary broadcast `Forget`s it → `Advance`
|
||||
clears `_pending` → `PendingCount == 0` → both gates declare success.
|
||||
|
||||
**Concrete failure scenario (graphical).** Portal to a landblock whose
|
||||
collision generation has not committed. `TryExecuteAcceptedPortalArrival`
|
||||
returns `DeferredCell`; `_awaitingDeferredWake = true`. One ACE broadcast
|
||||
later the park is Forgotten and `_pending` clears. Next `Tick`:
|
||||
`_placementCommitted = true` → `placementReady = true` → the sequencer leaves
|
||||
`Tunnel` and fires `Place` → the `if (!_placementCommitted) return;` guard at
|
||||
`:557` **passes** → `_placement.Place(_pendingRotation)` writes the render
|
||||
entity from the *unmoved* `controller.Position` and rebuckets to the *source*
|
||||
cell → `ObserveMaterialized(_pendingRevealGeneration, sequence, _pendingCell)`
|
||||
**succeeds** (the reveal is still active and current) → `PlayExitSound` reveals
|
||||
the world viewport → `FireLoginComplete` sends LoginComplete, and
|
||||
`_transit.Complete(generation)` now **passes** its
|
||||
`portal-complete-before-materialized` check because materialization was falsely
|
||||
acknowledged. The player is released into the world standing at the
|
||||
pre-teleport position, the transit reports a clean completion, and nothing logs
|
||||
an invariant failure. Round 1's A1 at least tripped
|
||||
`FailInvariant("portal-complete-before-materialized")`; this does not.
|
||||
|
||||
**Concrete failure scenario (headless).** Identical shape:
|
||||
`PrepareDestination` returns `IsCollisionReady: true`, so
|
||||
`RuntimeLiveEntitySessionController.TryAdvancePortalCompletion` runs
|
||||
`AcknowledgeDestinationReadiness` → `AcknowledgePortalMaterialized` →
|
||||
`Complete` → `TerminalProjected` → `LoginComplete` → `EndTeleport`, and sets
|
||||
`controller.State = PlayerState.InWorld`, all with the body unmoved. That is
|
||||
round-1 A3 verbatim.
|
||||
|
||||
**Secondary hazard from the same root:** because the slot is global, a portal
|
||||
park killed by a merge-time `Forget` can be immediately replaced by the force
|
||||
arm's own `RetainPending` from that same merge
|
||||
(`TryExecuteAcceptedLocalPosition` → `RetainPending`). The portal gate then
|
||||
polls a **ForcePosition** operation, waits for it, and latches "portal
|
||||
committed" when the force operation settles.
|
||||
|
||||
**Why no test caught it.** Both new park tests
|
||||
(`PortalDeferredCell_ParksThenCommitsExactlyOnceOnTheCollisionGenerationWake`,
|
||||
`HeadlessPortalPrepareDestinationParksThenCommitsOnCollisionGenerationWake`)
|
||||
commit the destination's collision generation so the park resolves by
|
||||
committing. `PortalDeferredCell_WakeAbandonsInsteadOfReconcilingWhenAuthorityWentStale`
|
||||
asserts the **drive's** behaviour (`Assert.Empty(gameActions)`) and stops
|
||||
there — it never asks what the *host gate* concludes from the resulting
|
||||
`PendingCount == 0`. The seam between "the drive retired the park without
|
||||
committing" and "the host decides the portal is placed" is exactly where the
|
||||
defect lives and is exactly what no test crosses.
|
||||
|
||||
**Fix direction.** Stop inferring. The drive already knows the answer with
|
||||
certainty — `ReconcileAndAcknowledgePortal` runs on commit and only on commit.
|
||||
Publish that fact:
|
||||
|
||||
- add a portal-commit observable to `RuntimeAcceptedPositionDriveController`
|
||||
— e.g. `bool TryConsumePortalCommit(long revealGeneration, ushort teleportSequence)`
|
||||
latched in `ReconcileAndAcknowledgePortal` and cleared on consumption, or an
|
||||
`Action<RuntimePortalPlacementAuthority>` commit callback supplied at
|
||||
construction alongside `isPortalAuthorityCurrent`;
|
||||
- have both host gates consume **that**, keyed on the reveal
|
||||
generation/sequence they are waiting for, so a force pending, a Forgotten
|
||||
park, and an abandoned park are all correctly "still not committed";
|
||||
- give the park a terminal "abandoned" outcome the host can see, so it can
|
||||
either re-attempt cleanly (`_awaitingDeferredWake = false` and try Begin
|
||||
again next tick) or converge through the existing transit cancellation
|
||||
rather than silently succeeding.
|
||||
|
||||
**Required tests (both must fail against the current code):**
|
||||
1. Graphical: park, then kill the park with a merge-time `Forget` (an ordinary
|
||||
accepted Position — the fixture's `OfferDestination` already performs the
|
||||
merge) instead of committing the collision generation; drive 100 ticks;
|
||||
assert `Placement.Called == false`, `Movement.Controller.Position` unchanged,
|
||||
`Reveal.PortalMaterializationCount == 0`, `Session.LoginCompleteCount == 0`,
|
||||
`Controller.IsActive == true`.
|
||||
2. Headless: same, asserting `PrepareDestination` keeps returning
|
||||
`IsCollisionReady: false` and `controller.State` stays `PortalSpace`.
|
||||
3. Graphical: park, then let the force arm take the pending slot; assert the
|
||||
portal gate does not latch when the *force* operation settles.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM
|
||||
|
||||
### B2 — A2's re-validation does not close the FIFO wedge it was written for; it only narrows reachability
|
||||
|
||||
The implementer's own note is accurate and I confirm it: `RetryDeferred` →
|
||||
`CommitCanonical` publishes the `Place` receipt asynchronously, and
|
||||
`IsPortalAuthorityCurrent` runs only afterwards, inside `Advance`, gating the
|
||||
*suffix*. But the wedge was never in the suffix — it is in the receipt:
|
||||
|
||||
- `Advance:920`'s abandon branch is reached only **after**
|
||||
`TryPeekAcknowledgedPlacement` succeeded, i.e. the sink **already accepted**
|
||||
the receipt. On that path there was never a wedge to prevent.
|
||||
- The wedge path is the one where the sink **refuses**:
|
||||
`RuntimePlacementPresentationSink.TryApply` →
|
||||
`RuntimeWorldTransitState.IsCurrentPlacementAuthority` false → return false →
|
||||
`RuntimePlacementProjectionSubscription.OnPlacement` leaves it at the FIFO
|
||||
head → every later placement receipt for every entity is blocked and the
|
||||
drive's pending never converges. `IsPortalAuthorityCurrent` never runs on
|
||||
that path, because `TryPeekAcknowledgedPlacement` never yields.
|
||||
|
||||
What the A1 fix *does* buy is reachability: with `placementReady` false during
|
||||
a park, the sequencer cannot reach `FireLoginComplete`, so the transit no
|
||||
longer ends underneath an outstanding park in the ordinary flow. The remaining
|
||||
entries are mid-transit supersession (a second F751 while parked →
|
||||
`OnTeleportStarted` → `ResetTransit` → `EndTeleport`), `ResetSession`, and
|
||||
`ResetGenerationPresentation` — i.e. exactly the §9 "honest gap" cases.
|
||||
|
||||
That is a genuine narrowing and I credit it. It is not closure, and the
|
||||
consequence remains unbounded (whole-FIFO stall + non-convergent shutdown), so
|
||||
contract stop condition 4 ("P3 finds a portal receipt no mechanism can consume
|
||||
or retire — the FIFO-wedge shape changes the design, not the test") still
|
||||
applies.
|
||||
|
||||
**Fix direction.** The retire path has to exist at the receipt, not the suffix.
|
||||
Either (a) let the sinks treat a `Place` whose entity/versions are current but
|
||||
whose portal authority is dead as acknowledge-and-ignore (the same shape
|
||||
`Discard`/`ExecutorCompleted`/`WithdrawalRestored` already use, and for the
|
||||
same stated reason — refusing wedges the ordered stream), or (b) have
|
||||
`CommitCanonical` drop a portal suffix it can already see is not current rather
|
||||
than publishing a receipt nothing can consume. (a) is smaller and matches the
|
||||
existing precedent in both sinks' own doc comments.
|
||||
|
||||
### B3 — Headless `_awaitingPortalWake` is not reset across teleports within one session
|
||||
|
||||
`HeadlessSessionWorldProjection.cs:762`. The graphical twin
|
||||
(`_awaitingDeferredWake`) is cleared in `ResetTransit:932`, which
|
||||
`OnTeleportStarted` calls — clean. The headless field has no equivalent: it is
|
||||
cleared only when the poll declares success, and `HeadlessSessionWorldProjection`
|
||||
is constructed per *session*, not per teleport.
|
||||
|
||||
**Scenario.** Teleport 1 parks → `_awaitingPortalWake = true`. Before the next
|
||||
`PumpPortalCompletion`, a new F751 arrives; `TryCompletePortal` overwrites
|
||||
`_pendingPortalCompletion` with reveal 2. The next pump calls
|
||||
`PrepareDestination(reveal 2)`, which takes the **poll** branch left over from
|
||||
reveal 1 — so reveal 2's placement is never even attempted, and if
|
||||
`PendingCount` happens to be 0 it is immediately declared committed. This is
|
||||
B1's inference bug plus a stale latch, on a path that does not require a
|
||||
Forgotten park.
|
||||
|
||||
**Fix direction.** Key the latch to the reveal generation (or clear it in
|
||||
`BeginTeleport`, which already runs per teleport on this host).
|
||||
|
||||
### B4 — Both hosts' retry loops are unbounded and undiagnosable
|
||||
|
||||
A refusal loop (`Contention`, stale reveal, or a park that never converges)
|
||||
now retries forever with no timeout and no terminal path.
|
||||
|
||||
- **Graphical**: the player holds in the tunnel with the retail wait cue after
|
||||
5 s. This is a *modelled* end state — AD-2 already documents "predicate never
|
||||
satisfies → portal transit remains in the authored tunnel and presents the
|
||||
centered wait cue" — so an infinite stall is strictly better than round 1's
|
||||
silent release, and I do not consider it blocking. Two gaps, though: AD-2
|
||||
attributes that state to streaming/DAT failure only, and now a *placement*
|
||||
refusal produces the identical user-visible state; and `_holdSeconds` /
|
||||
`ObserveWait` are now driven by `!placementReady` rather than `!dataReady`,
|
||||
which is a real semantic change to the wait cue's meaning. The
|
||||
`[tp-probe] REFUSED cause=…` line does distinguish them in the log — good —
|
||||
but AD-2 should say so.
|
||||
- **Headless**: worse, because there is no cue and no bound. A bot whose
|
||||
destination collision never becomes resident sits in `PlayerState.PortalSpace`
|
||||
indefinitely, connected and healthy-looking; `PumpPortalCompletion` is
|
||||
entirely silent. Under K4's 30-session envelope this is an invisible stuck
|
||||
session. At minimum emit one probe line on the first N retries; ideally bound
|
||||
the wait and fail loudly.
|
||||
|
||||
### B5 — §8 items 8/9/10 (the committed-receipt presentation suite) — **does NOT block**, with conditions
|
||||
|
||||
Answering the coordinator's direct question.
|
||||
|
||||
**Does not block route 3.** Reasons, in order of weight:
|
||||
|
||||
1. The render-entity half now has a *proven mechanism*, not a claim.
|
||||
`LiveEntityRuntime.TryApplyRuntimePlacementPlace:1394-1402` performs
|
||||
`entity.SetPosition(projection.WorldPosition)`, `entity.Rotation = …`,
|
||||
`entity.ParentCellId = token.ExactCellId` and `RebucketLiveEntity`,
|
||||
synchronously inside `CommitCanonical`, before `TryPublishPlace` snapshots.
|
||||
That path is shared with route 2's force arm and C3c's first entry and has
|
||||
existing coverage. Round 2 correctly rewrote the class doc to say so
|
||||
(round-1 A10(a) closed).
|
||||
2. The restored `Movement.Controller.Position`/`CellId` assertions prove the
|
||||
canonical body resolved the offered destination — which is the half route 3
|
||||
actually changed ownership of.
|
||||
3. The shadow half is a **pre-existing route-2 defect**, not a route-3
|
||||
regression: `LocalPlayerShadowState.Set` (written by the sink) updates only
|
||||
the dedup cache, never `PhysicsEngine.ShadowObjects`, while
|
||||
`LocalPlayerShadowSynchronizer.SyncPose` dedups against that same cache. It
|
||||
self-heals on the player's first >1 cm move. Route 3 widens the window (via
|
||||
`LocalPlayerProjectionController.Project:102`'s PortalSpace early return) but
|
||||
does not change its kind.
|
||||
|
||||
**Conditions — all three, or it does block:**
|
||||
|
||||
- It is recorded as an **open issue with a number** (#312's layer / route 2's
|
||||
B2 gap, now two campaigns old) and carried explicitly into C5's parity-test
|
||||
scope. Not a comment; a tracked item.
|
||||
- The connected gate is **not** scored as covering it. The probe fields
|
||||
(`leash`, `autorun`, `hookTail`) say nothing about the shadow; there is no
|
||||
visual for it. If the user's session passes, the shadow claim remains
|
||||
test-verified-nowhere and must be reported that way (the §9 "honest gap"
|
||||
discipline).
|
||||
- The register gets one line under AD-2 or a sibling row naming the
|
||||
cache-without-publish asymmetry, so the next reader does not assume
|
||||
`LocalPlayerShadowState.Current` means "published".
|
||||
|
||||
**Concretely, what the test needs** (in
|
||||
`tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs`, whose
|
||||
fixture already owns `BeginPortal` and a `LocalShadow`):
|
||||
|
||||
1. **Committed local-player portal Place through the real sink.** Pre-seed the
|
||||
render `WorldEntity` at a *different* (wire) pose — this is also §8 item 9's
|
||||
T8-ordering half. Drive a `Place` receipt for the local player carrying a
|
||||
VALID portal authority. Assert, after: `entity.Position`/`Rotation`/
|
||||
`ParentCellId` equal the receipt's `WorldPosition`/`Orientation`/
|
||||
`ExactCellId` (the wire pose did not survive); the spatial bucket moved to
|
||||
the destination landblock; `LocalShadow.Current` equals the resolved pose;
|
||||
**and `PhysicsEngine.ShadowObjects` actually holds a row for the player at
|
||||
the destination cell** — that last assertion is the one that discriminates
|
||||
cache-only from published, and is the whole point.
|
||||
2. **Discrimination half.** The same receipt with a stale/superseded portal
|
||||
authority must be refused **and then retired** — not left at the FIFO head.
|
||||
This is also B2's regression test.
|
||||
3. **Refused-Place presentation (§8 item 10).** Already partly covered by
|
||||
`RefusedPlace_HoldsTheStreamAndConvergesOnlyAfterContentionClears`; extend it
|
||||
to assert the pre-teleport pose is still the *presented* pose (entity +
|
||||
world snapshot store), not only that the body is unmoved.
|
||||
4. **Sabotages that must fail:** remove the sink's `entity.SetPosition` → (1)
|
||||
fails; leave the shadow write as cache-only → (1)'s `ShadowObjects`
|
||||
assertion fails; make the stale-authority receipt return `false` forever →
|
||||
(2) fails.
|
||||
|
||||
---
|
||||
|
||||
## MINOR
|
||||
|
||||
### B6 — `isPortalAuthorityCurrent` should be a required constructor parameter
|
||||
|
||||
The coordinator's specific concern, checked: **both production sites wire it** —
|
||||
`SessionPlayerComposition.cs:597-603` and `HeadlessSessionHost.cs:665-671`,
|
||||
both to `RuntimeWorldTransitState.CanPlacePortalDestination`. Four test sites
|
||||
do not, which is fine.
|
||||
|
||||
The residual is that a null default silently restores the round-1 defect, and
|
||||
nothing catches a future production site that forgets it — there is no
|
||||
architecture guard for this the way
|
||||
`RuntimePhysicsOwnershipTests.ProductionHostsUseSharedPlacementSubscriptionWithoutDirectChannel`
|
||||
guards the placement channel. With only 2 production + 4 test constructions,
|
||||
making the parameter required (tests pass `static _ => true`) converts a silent
|
||||
regression into a compile error for ~6 lines of churn. Same argument will apply
|
||||
to B1's commit observable.
|
||||
|
||||
### B7 — `_placementCommitted` is checked once and never re-validated
|
||||
|
||||
`LocalPlayerTeleportController.cs:557`. Round 1's Place handler ran
|
||||
`CanPlacePortalDestination` immediately before mutating; round 2 moved that
|
||||
check into `TryAdvancePortalCommit`'s **non-deferred** branch only
|
||||
(`:668-676`). Once `_placementCommitted` latches, the only guard before
|
||||
`_placement.Place()` / `ObserveMaterialized` is `IsCurrentLifetime`. If the
|
||||
reveal is cancelled between the commit and the Place event,
|
||||
`ObserveMaterialized` refuses (`IsCurrentPortalDestination`) but the
|
||||
presentation suffix has already run and the stream continues to
|
||||
`FireLoginComplete` with an unmaterialized reveal. Much less severe than B1 —
|
||||
the body genuinely is at the destination — but it is the same family. Cheap
|
||||
fix: keep the `CanPlacePortalDestination` re-check in the Place handler
|
||||
alongside `_placementCommitted`.
|
||||
|
||||
### B8 — A8 (round 1) confirmed still open and confirmed non-blocking
|
||||
|
||||
Five portal tests still call `ConvergePortalHost` as the last statement of the
|
||||
test body rather than in a `finally`, so an assertion failure is still masked
|
||||
by a `Dispose()` throw during unwinding. Correctly flagged rather than silently
|
||||
dropped. Test hygiene only — no production effect. It does mean that when B1's
|
||||
new tests are written, a genuine failure may again present as a teardown throw;
|
||||
fixing the `try/finally` first would save that debugging round.
|
||||
|
||||
### B9 — round-1 A11/A12 unchanged
|
||||
|
||||
`PhysicsDiagnostics.LocalTeleportHostKind` process-global (accepted);
|
||||
`AddSyntheticIndoorCell` geometry-free (accepted — and less load-bearing now
|
||||
that A6's position assertions are restored).
|
||||
|
||||
---
|
||||
|
||||
## Closed since round 1
|
||||
|
||||
| round-1 finding | status | evidence |
|
||||
|---|---|---|
|
||||
| **A4** — route facts unread, inversions hardcoded | **CLOSED** | `RuntimeAuthoritativePositionRoute.RunsTeleportHook:164` / `.ConstrainAfterRouting:170`; `ReconcileAndAcknowledgePortal` reads both; `CommitCanonicalTeleportFrame(bool zeroVelocity, bool rearmConstraintLeash)` branches on them. `ConstrainPhase.None` sabotage now fails `PortalCommitted_MovesBodyArmsLeashOnceCancelsAutorunAndSendsExactlyOneMovementEvent`'s stale-anchor assertion, as the contract's §8 item 11 intended. |
|
||||
| **A6** — superseded-teleport discriminator removed | **CLOSED** | `NewerStart_ReplacesOldDestinationWithoutReusingIt` asserts `Movement.Controller.Position.X/Y == 2` and the cell; `SameLandblockDestination_…` asserts `(20,30,4)` + `0x20210123`; the Z-vs-X/Y reasoning in the comment is sound. |
|
||||
| **A9** — two sequence sources | **CLOSED** | The authority now uses `destination.TeleportSequence`, with a `Debug.Assert` pinning it equal to the caller's copy. |
|
||||
| **A10(a)** — class doc claimed the suffix was the render entity's only mover | **CLOSED** | Doc rewritten to state the writes are redundant repeats of the canonical receipt's mutation, with the ordering cited. |
|
||||
| **A1** — refused Place did not stop the anim stream | **partially** — the mechanism is right (readiness inversion, sequencer untouched), and the `Contention` path is now correctly held and tested (`RefusedPlace_HoldsTheStreamAndConvergesOnlyAfterContentionClears`). **B1 reopens it for the `DeferredCell` path.** |
|
||||
| **A2** — no wake re-validation | **partially** — D-T2.4's re-validation now exists and is wired in both hosts and tested; **B2** shows it does not close the FIFO wedge. |
|
||||
| **A3** — headless discarded the status | **partially** — status now honoured, throws on `Rejected`/`NotApplicable` and on a missing drive, retryable pump added. **B1/B3 reopen it.** |
|
||||
| **A5** — presentation suite | **still open** — see B5 for the definite blocks/does-not-block answer. |
|
||||
| **A7** — dual-host parity | **CLOSED** | `HeadlessPortalPrepareDestinationParksThenCommitsOnCollisionGenerationWake` drives a real wired drive end to end and asserts body position, cell, and `PlayerState`. |
|
||||
| **A8** | open, non-blocking (B8). |
|
||||
| **A11 / A12** | unchanged (B9). |
|
||||
|
||||
## Also verified this round
|
||||
|
||||
- `_pendingDestination` lifetime — round-1 judgment stands, not re-litigated.
|
||||
- The sequencer's own invariants survive the late `worldReady`: `Tunnel` is
|
||||
explicitly a hold state (*"Hold here until worldReady"*), `TickTunnel` still
|
||||
runs on the hold path so `CurrentAnimationFrame` keeps advancing into
|
||||
`TunnelContinue`'s exit window, and `maxForce` at 5 s covers a stale frame.
|
||||
`worldReady` has exactly one consumer (`case TeleportAnimState.Tunnel`), so
|
||||
no other transition changed meaning.
|
||||
- No double-Begin headless: `TryCompletePortal`'s own
|
||||
`TryGetAcceptedTeleportDestination`/`TryBeginPortalReveal` prefix cannot
|
||||
succeed twice for one reveal (the destination slot is consumed), and
|
||||
`_awaitingPortalWake` suppresses a second concurrent `Begin` while parked.
|
||||
(The stale-latch problem is B3, a different failure.)
|
||||
- `_placementCommitted` / `_awaitingDeferredWake` are both cleared in
|
||||
`ResetTransit:931-932`, so the graphical latches do not survive a new F751,
|
||||
a session reset, or a generation reset.
|
||||
- Build green.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| # | Severity | Finding |
|
||||
|---|---|---|
|
||||
| B1 | MAJOR | `PendingCount == 0` inferred as "committed" on both hosts; three non-committing paths clear it, including the drive's own documented modal outcome. Reintroduces A1 (graphical, now with a *successful* false materialization) and A3 (headless). Untested. |
|
||||
| B2 | MEDIUM | A2's re-validation gates the suffix, not the receipt; the FIFO wedge is narrowed (supersession/reset only) but not closed. |
|
||||
| B3 | MEDIUM | Headless `_awaitingPortalWake` is not reset per teleport; a stale latch can skip reveal N+1's placement attempt entirely. |
|
||||
| B4 | MEDIUM | Unbounded, undiagnosable retry loops on both hosts; headless has no cue, no bound, and no log. |
|
||||
| B5 | — | §8 8/9/10 presentation suite: **does not block**, subject to three stated conditions; required test spelled out. |
|
||||
| B6 | MINOR | Make `isPortalAuthorityCurrent` (and B1's commit observable) required constructor parameters. |
|
||||
| B7 | MINOR | `_placementCommitted` never re-validated before the presentation suffix. |
|
||||
| B8 | MINOR | A8 still open (5 tests), confirmed non-blocking. |
|
||||
| B9 | MINOR | A11/A12 unchanged. |
|
||||
550
docs/research/2026-08-04-c4-route-3-architecture-review.md
Normal file
550
docs/research/2026-08-04-c4-route-3-architecture-review.md
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
# C4 route 3 — architecture / adversarial review (2026-08-04)
|
||||
|
||||
**Verdict: FAIL.**
|
||||
|
||||
Reviewed: the uncommitted working-tree diff (`git diff HEAD` + untracked) on
|
||||
`claude/acdream-physics-divergence-5aa784` at HEAD **`cd3129e9`** (route 7's
|
||||
commit, "child cell propagation moves from a render tick into Runtime"). 16
|
||||
files, +1,573/-216.
|
||||
|
||||
Reference documents read in full: the route-3 contract
|
||||
(`2026-08-04-c4-route-3-contract.md`), the route-3 scoping, the route-2
|
||||
contract, and the route-5/route-7 review defect classes.
|
||||
|
||||
Independent verification performed against source, not against the
|
||||
implementer's summary: `RuntimeWorldTransitState`, `RuntimeSetPositionState`'s
|
||||
commit tail, `RuntimeEntityObjectEventStream`/
|
||||
`RuntimePlacementProjectionSubscription` (publication synchronicity),
|
||||
`RuntimePlacementPresentationSink` + `LiveEntityRuntime
|
||||
.TryApplyRuntimePlacementPlace`, `LocalPlayerShadowSynchronizer`,
|
||||
`LocalPlayerProjectionController`, `TeleportAnimSequencer`, and both hosts'
|
||||
`Advance()` pump sites. `dotnet build AcDream.slnx -c Debug` exits 0.
|
||||
|
||||
**The `_pendingDestination` fix — the item flagged as highest risk — is
|
||||
correct, and is not the reason for the FAIL.** See §"Judgment on the
|
||||
`_pendingDestination` lifetime" at the end.
|
||||
|
||||
The FAIL rests on A1 and A2: the slice adds five new ways for the Place edge
|
||||
to refuse, and the refusal path it hands them to does not stop the teleport
|
||||
animation stream. One of those five (`DeferredCell`) additionally commits the
|
||||
placement out of band after the transit has ended, which both splits the body
|
||||
from presentation and leaves a placement receipt nothing can consume — proof
|
||||
obligations **P3** (graphical half) and **P4** are undischarged, and D-T2.4's
|
||||
"the wake path must re-validate the portal authority before committing" is not
|
||||
implemented at all.
|
||||
|
||||
---
|
||||
|
||||
## MAJOR
|
||||
|
||||
### A1 — A refused canonical Place does not stop the teleport animation; the player is released into the world without ever having been placed
|
||||
|
||||
**Severity: MAJOR (FAIL basis).**
|
||||
|
||||
`src/AcDream.Core/World/TeleportAnimSequencer.cs:134-142`:
|
||||
|
||||
```csharp
|
||||
case TeleportAnimState.Tunnel:
|
||||
if (worldReady)
|
||||
{
|
||||
evts.Add(TeleportAnimEvent.Place);
|
||||
Advance(TeleportAnimState.TunnelContinue, enterTunnel: false);
|
||||
_continueElapsed = 0f;
|
||||
}
|
||||
break;
|
||||
```
|
||||
|
||||
`TeleportAnimEvent.Place` is emitted **exactly once**, and the sequencer
|
||||
advances to `TunnelContinue` in the same statement block, unconditionally and
|
||||
with no knowledge of whether the consumer's handler succeeded. There is no
|
||||
path back to `Tunnel`.
|
||||
|
||||
`src/AcDream.App/Streaming/LocalPlayerTeleportController.cs:505-526`:
|
||||
|
||||
```csharp
|
||||
case TeleportAnimEvent.Place:
|
||||
if (!_worldReveal.CanPlacePortalDestination(...)) return;
|
||||
if (!TryExecuteCanonicalPortalPlacement(sequence))
|
||||
return; // <- new in this slice
|
||||
...
|
||||
_placement.Place(_pendingRotation);
|
||||
...
|
||||
_worldReveal.ObserveMaterialized(...);
|
||||
```
|
||||
|
||||
The `return` exits `Tick`, but the sequencer has already left `Tunnel`. Every
|
||||
subsequent `Tick` therefore runs the rest of the stream with **no placement
|
||||
and no materialization**:
|
||||
|
||||
| next event | what runs |
|
||||
|---|---|
|
||||
| `TunnelContinue` → `TunnelFadeOut` | — |
|
||||
| `PlayExitSound` (`:532-542`) | `_worldReveal.RevealWorldViewport()` + `_presentation.ExitTunnel()` |
|
||||
| `FireLoginComplete` (`:543-554`) | `_mode.EnterWorld()`, `_session.SendLoginComplete()`, `_worldReveal.Complete()`, `ResetTransit(clearSession: false)` |
|
||||
|
||||
`WorldRevealCoordinator.RevealWorldViewport` only needs a live host projection
|
||||
(`WorldRevealCoordinator.cs:258-266`) — present. `Complete()` reaches
|
||||
`RuntimeWorldTransitState.Complete` (`:701-706`), which hits
|
||||
`FailInvariant("portal-complete-before-materialized")` and returns `false`;
|
||||
`FailInvariant` (`:934-946`) only increments a counter and logs — it does not
|
||||
throw and does not stop the caller. `ResetTransit` then runs `EndTeleport()` +
|
||||
`_worldReveal.Cancel()`.
|
||||
|
||||
**Concrete failure scenario.** A `Contention` outcome (route 2's force arm, a
|
||||
route-7 parent drive, or an earlier park still owns the entity's placement
|
||||
token at the Place edge) makes `TryExecuteAcceptedPortalArrival` return
|
||||
`Contention` at `RuntimeAcceptedPositionDriveController.cs:492-501`. The
|
||||
player watches the portal tunnel finish normally, the world viewport is
|
||||
revealed, `LoginComplete` is sent to ACE — and the player is standing at the
|
||||
**pre-teleport position** in the **pre-teleport cell**. ACE has them at the
|
||||
destination. Every subsequent server broadcast fights the client. Nothing in
|
||||
the client logs above `[world-reveal] event=invariant-failure` (a `SafeLog`
|
||||
line), and the D-T8 probe never emits because
|
||||
`ReconcileAndAcknowledgePortal` never ran.
|
||||
|
||||
**Why this is the slice's problem and not inherited.** The pre-existing
|
||||
`CanPlacePortalDestination` early return (`:506-512`) has the same shape, but
|
||||
it fires only when the transit no longer owns this reveal — a case where
|
||||
marching on is at worst redundant, because a newer transit owns the world.
|
||||
This slice adds **five new refusal causes that all fire while the transit is
|
||||
perfectly healthy**: `host-token-unavailable`, `NotApplicable`, `Rejected`,
|
||||
`Contention`, and `DeferredCell` (`LocalPlayerTeleportController.cs:596-625`,
|
||||
which treats everything except `Committed` as a refusal at `:619`).
|
||||
|
||||
**Contract obligations violated.** §4 item 4 ("on every refusal … the transit
|
||||
remains coherent … never a half-state … no path leaves the player permanently
|
||||
in portal space with a dead operation"); §4 item 5 ("a refused placement must
|
||||
NOT … `RevealWorldViewport`, must NOT advance the anim-event stream's terminal
|
||||
events"); D-T5's Begin-refusal row ("the anim stream stays where it is, so the
|
||||
NEXT Tick re-attempts the Place edge … if the Place anim event is one-shot,
|
||||
the re-attempt must be driven by the same Tick predicate that produced it, and
|
||||
THAT mechanism must be stated in the commit"); and proof obligation **P4**
|
||||
verbatim. P4 was to be discharged by reading `TeleportAnimSequencer`. It is
|
||||
one-shot. No re-attempt driver exists.
|
||||
|
||||
**Fix direction.** Two shapes are available without touching the sequencer
|
||||
(stop condition 2 forbids sequencer timing changes):
|
||||
|
||||
1. Make the Place edge idempotent-and-latched at the controller: keep a
|
||||
`_placementCommitted` flag; on a refusal, do NOT let the stream reach
|
||||
`PlayExitSound`/`FireLoginComplete` — gate those two cases on the latch and
|
||||
drive a bounded re-attempt from the same `ready` predicate that produced
|
||||
the Place event (the contract's stated fallback). A refusal that never
|
||||
converges must then take the existing transit cancellation
|
||||
(`ResetTransit(clearSession: false)` — which already cancels the reveal and
|
||||
restores presentation) rather than a silent world release.
|
||||
2. Or treat a refusal as an immediate transit cancellation and let the
|
||||
existing supersession path own recovery. Louder, smaller, and it satisfies
|
||||
D-T5's "never a silent wedge" — but it needs the user's eyes because it is
|
||||
user-visible (the portal fails and the player stays put) rather than a
|
||||
silent desync.
|
||||
|
||||
Either way this needs a test: *refused arm → the anim stream does not reach
|
||||
`RevealWorldViewport`/`FireLoginComplete` with an unplaced body.*
|
||||
|
||||
---
|
||||
|
||||
### A2 — A `DeferredCell` portal park splits the body from presentation and leaves a placement receipt nothing can consume (P3 undischarged on the graphical host; D-T2.4's re-validation missing)
|
||||
|
||||
**Severity: MAJOR.**
|
||||
|
||||
`RuntimeAcceptedPositionDriveController.SubmitAndResolvePortal:614-645` parks a
|
||||
`DeferredCell` outcome into `_pending` carrying the portal authority.
|
||||
`LocalPlayerTeleportController.TryExecuteCanonicalPortalPlacement:619` returns
|
||||
`false` for it, so A1's march runs: the stream reaches `FireLoginComplete`,
|
||||
`ResetTransit` calls `_transit.EndTeleport()` and `_worldReveal.Cancel()`.
|
||||
|
||||
The park is still live. `Advance()` is pumped by the graphical host at
|
||||
`src/AcDream.App/Net/GraphicalSessionEventRoute.cs:117` and
|
||||
`src/AcDream.App/World/LiveEntityHydrationController.cs:416`. When the
|
||||
destination landblock's collision generation eventually commits:
|
||||
|
||||
- `Advance:763-771` runs `ReconcileAndAcknowledgePortal` — the body moves, the
|
||||
leash re-arms, autorun cancels, and **one outbound movement event is sent** —
|
||||
seconds after the player was already released into the world at the old
|
||||
position. Presentation is never told: `_placement.Place` and
|
||||
`ObserveMaterialized` are unreachable (the anim event is one-shot, A1).
|
||||
- The commit publishes a `Place` receipt whose `Token.Portal` still names the
|
||||
ended reveal. `RuntimeWorldTransitState.IsCurrentPlacementAuthority:258-275`
|
||||
requires `IsCurrentPortalDestination` (`:870-882`), which requires
|
||||
`_teleportActive` — cleared by `EndTeleport` (`:547-556`). It returns false
|
||||
**forever**.
|
||||
- `RuntimePlacementPresentationSink.TryApply:100-106` therefore returns
|
||||
`false`; `RuntimePlacementProjectionSubscription.OnPlacement:134-136` leaves
|
||||
the receipt at the FIFO head. Every later placement receipt **for every
|
||||
entity** is blocked behind it, and `AcceptedPositionDrivePendingCount` never
|
||||
returns to zero, so `GameWindowLifetime.DisposeGameRuntime` throws on
|
||||
shutdown.
|
||||
|
||||
That is precisely the failure mode P3 exists to rule out ("A receipt nothing
|
||||
can ever consume or retire is a FIFO wedge"). P3 was discharged only for the
|
||||
headless *happy path*; the graphical park was not walked.
|
||||
|
||||
Independently, **D-T2.4's explicit requirement is not implemented**: "its wake
|
||||
path must re-validate the portal authority before committing". Neither
|
||||
`Advance`'s `AwaitingCommitWake` branch (`:763-771`) nor its
|
||||
`IsPlacementCurrent` re-submit branch (`:813-819`) re-checks the portal
|
||||
authority — they pass `pending.Portal` straight through.
|
||||
|
||||
**Fix direction.** (a) Re-validate the portal authority at both wake points
|
||||
(`_entityObjects` has no transit handle today — the drive needs a
|
||||
`Func<bool>`-style currency predicate or the authority passed back through the
|
||||
transit owner); on failure take `AbandonPending`'s exact shape
|
||||
(`restoreCancelledPark: true` + `PublishCancellation`) so the park is retired
|
||||
rather than committed. (b) Independently, A1's fix must prevent the transit
|
||||
from ending while a portal park is outstanding.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM
|
||||
|
||||
### A3 — Headless discards the arm's status entirely; a failed placement is silent
|
||||
|
||||
`src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs:768-780`:
|
||||
|
||||
```csharp
|
||||
if (_acceptedPositionDrive is not null)
|
||||
{
|
||||
var authority = new RuntimePortalPlacementAuthority(...);
|
||||
_ = _acceptedPositionDrive.TryExecuteAcceptedPortalArrival(destination, authority);
|
||||
}
|
||||
```
|
||||
|
||||
Two problems. First, the status is discarded: any non-`Committed` outcome
|
||||
leaves the body unmoved while `TryCompletePortal`
|
||||
(`RuntimeLiveEntitySessionController.cs:530-585`) proceeds through
|
||||
`AcknowledgeDestinationReadiness` → `AcknowledgePortalMaterialized` →
|
||||
`Complete` → `TerminalProjected` → `LoginComplete` → `EndTeleport` — asserting
|
||||
a materialization that did not happen (§4 item 5) and telling ACE the login
|
||||
completed. The deleted `ResynchronizeLocalPlayerForPortalArrival` was
|
||||
unconditional and could not fail this way. Second, the `is not null` guard
|
||||
means a composition regression that fails to wire the drive silently disables
|
||||
headless portal placement with no signal at all; the previous code had no such
|
||||
mode.
|
||||
|
||||
**Fix direction.** Treat a non-`Committed` status as a hard failure on this
|
||||
path (`TryCompletePortal` already throws on every other Runtime refusal —
|
||||
match that), and make the drive a required constructor dependency for the
|
||||
production projection.
|
||||
|
||||
### A4 — Both inversions are hardcoded; the classifier route facts that encode them are never read
|
||||
|
||||
`ReconcileAndAcknowledgePortal(RuntimeEntityRecord, in RuntimeAuthoritativePositionRoute route, in RuntimePortalPlacementAuthority)`
|
||||
(`RuntimeAcceptedPositionDriveController.cs:667-698`) **never references
|
||||
`route`**. `PlayerMovementController.CommitCanonicalTeleportFrame:1987-2035`
|
||||
unconditionally zeroes velocity, runs `StopCompletelyAtPhysicsObjectBoundary`,
|
||||
`UnStick`/`UnConstrain`, and `RearmConstraintLeashAtCurrentPosition`.
|
||||
|
||||
So `route.ZeroVelocity`, `route.ConstrainPhase`, and `route.TeleportHookPhase`
|
||||
are recorded-not-consumed, even though D-T2.3 pinned "`route.ZeroVelocity` is
|
||||
honored at the commit". The visible behaviour is correct today only because
|
||||
the classifier's LocalPlayer-teleport branch happens to agree with the
|
||||
hardcoded method.
|
||||
|
||||
Consequence for test quality: the contract's own §8 item 11 sabotage —
|
||||
"hardcode the force route onto the portal arm (`ConstrainPhase.None`) → test
|
||||
5a fails" — **cannot fail**, because no code path reads `ConstrainPhase`. No
|
||||
test discriminates the classifier from the executor. A future classifier edit
|
||||
(the classifier is a shared surface routes 2/4b-2/4b-3 also consume) diverges
|
||||
from behaviour silently.
|
||||
|
||||
**Fix direction.** Either consume the route facts in the frame commit (branch
|
||||
on `ZeroVelocity`/`ConstrainPhase`) and add the discriminating test, or delete
|
||||
the unused `route` parameter and state explicitly, in the class doc and in
|
||||
AD-2, that the inversions are enforced by `CommitCanonicalTeleportFrame` and
|
||||
NOT by the classifier — so the next reader does not trust a route fact that
|
||||
nothing reads.
|
||||
|
||||
### A5 — Contract §8 item 8's committed-receipt presentation suite is missing; invariant 6's "the local-player collision shadow agrees" is asserted nowhere
|
||||
|
||||
No test in this diff drives a committed portal placement through the **real**
|
||||
`RuntimePlacementPresentationSink` plus the suffix.
|
||||
`tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs` is
|
||||
untouched; its only portal test
|
||||
(`PortalPlace_RequiresExactCurrentTransitHostAndSequence:467`) drives a
|
||||
synthetic token, not a local-player placement. The App teleport tests assert
|
||||
`harness.Placement.Called` against a *fake* placement.
|
||||
|
||||
That missing suite would have surfaced the following latent inconsistency,
|
||||
which route 3 newly makes reachable on the portal path:
|
||||
|
||||
- `RuntimePlacementPresentationSink.TryPublishPlace:226-232` writes
|
||||
`_localPlayerShadow.Set(entity.Position, entity.Rotation, record.FullCellId)`.
|
||||
`LocalPlayerShadowState.Set` updates **only the cache** — it never touches
|
||||
`PhysicsEngine.ShadowObjects`.
|
||||
- `LocalPlayerShadowSynchronizer.SyncPose:59-70` dedups against that same
|
||||
cache (`cellId` equal AND position within 1 cm AND orientation within
|
||||
tolerance ⇒ return without publishing).
|
||||
- `LocalPlayerProjectionController.Project:102` early-returns for
|
||||
`PlayerState.PortalSpace`, so the sink's cache write is the last word until
|
||||
the player re-enters the world.
|
||||
|
||||
Net: the first post-arrival `SyncShadow` sees a cache that already claims the
|
||||
resolved pose and resolved cell, skips, and the player's collision shadow row
|
||||
is not published at the destination. It self-heals the first time the player
|
||||
moves more than ~1 cm, so the window is short — but during it, other entities
|
||||
have no collider for the player at the destination. (The shape is pre-existing
|
||||
from route 2's force arm, where `Project` runs every frame so the window is
|
||||
one frame; route 3's portal-space skip widens it.)
|
||||
|
||||
**Fix direction.** Write the §8 item 8 suite. Independently, either have the
|
||||
suffix re-publish the shadow through `LocalPlayerShadowSynchronizer`
|
||||
(`force: true`) after writing the resolved entity pose, or stop the sink from
|
||||
writing a "last published" cache entry it did not publish.
|
||||
|
||||
### A6 — The one test that discriminated *which* destination gets placed lost its discriminator
|
||||
|
||||
`tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs`,
|
||||
superseded-teleport scenario:
|
||||
|
||||
```diff
|
||||
- Assert.Equal(new Vector3(2f), harness.Placement.Position);
|
||||
+ Assert.True(harness.Placement.Called);
|
||||
```
|
||||
|
||||
The old assertion proved the **second** destination `(2,2,2)` was placed and
|
||||
not the first `(1,1,1)`. `Assert.True(Called)` cannot distinguish them. That is
|
||||
exactly the property the `_pendingDestination` caching change puts at risk
|
||||
(caching an Aim-time value instead of re-reading). Two sibling tests lose the
|
||||
same class of assertion, and one now contradicts its own name:
|
||||
`SameLandblockDestination_DoesNotRecenterAndKeepsTranslatedPosition` no longer
|
||||
asserts any position or cell.
|
||||
|
||||
This is the route-5 "tests that assert only negatives"/weakened-successor
|
||||
defect class. The replacement assertion is **available**: the harness now owns
|
||||
a real Runtime `RuntimeLocalPlayerMovementState`, so
|
||||
`Assert.Equal(expected, movement.Controller!.Position)` and `.CellId` are
|
||||
reachable; the harness simply does not expose `movement`.
|
||||
|
||||
**Fix direction.** Expose the Runtime controller on the harness and restore a
|
||||
positive position/cell assertion in each of the three tests, at minimum in the
|
||||
superseded-teleport one.
|
||||
|
||||
### A7 — Dual-host parity (§8 item 6) is not met; the headless flip is untested
|
||||
|
||||
`tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs:413-424` now
|
||||
documents that the fixture's projection is built without a drive controller,
|
||||
so "the canonical portal arm this method now calls is a no-op here by
|
||||
construction", and defers the headless committed-portal test as "an open item,
|
||||
not attempted here given this session's time budget".
|
||||
|
||||
The contract states plainly: "The headless arm reuses the identical Runtime
|
||||
entry — dual-host parity is a test obligation, not an aspiration (§8)."
|
||||
Combined with A3, the entire headless production placement flip — the deletion
|
||||
of `ResynchronizeLocalPlayerForPortalArrival`, the new `portal` parameter, the
|
||||
new call — has zero behavioural coverage. The only surviving assertion change
|
||||
is `CenterCount` 3→2.
|
||||
|
||||
---
|
||||
|
||||
## MINOR
|
||||
|
||||
### A8 — Portal-test cleanup runs at the end of the test body, so the first assertion failure is masked by a teardown throw
|
||||
|
||||
`ConvergePortalHost` is invoked as the last statement of each portal test
|
||||
(`RuntimeAcceptedPositionDriveControllerTests.cs:1273/1380/1423/1519`). If any
|
||||
earlier `Assert` throws, cleanup is skipped, `StartedRuntime.Dispose()` throws
|
||||
during unwinding, and C# `using`/`try…finally` lets the finally-exception
|
||||
**replace** the in-flight one.
|
||||
|
||||
The implementer diagnosed their own instance of this correctly (claim 1 —
|
||||
verified: the mechanism is real and the two corrected assertions were genuine
|
||||
test bugs, since an accepted `TeleportAdvanced` merge rebases the world frame
|
||||
onto the destination per #283, and `ReconcileAndAcknowledgePortal` does
|
||||
legitimately send one movement event). But the pattern remains in the shipped
|
||||
tests, and it is exactly how a genuine host-projection leak would also present
|
||||
— which is why it is worth removing rather than remembering.
|
||||
|
||||
**Fix direction.** `try { … } finally { ConvergePortalHost(…); }`, or make
|
||||
`StartedRuntime.Dispose` record non-convergence and assert it explicitly.
|
||||
|
||||
`ConvergePortalHost` itself is otherwise sound: it cannot double-release —
|
||||
`AcknowledgeHostProjection`'s `TerminalProjected` branch removes the record
|
||||
(`RuntimeWorldTransitState.cs:343-355`) and a second call returns false — and
|
||||
it cannot leak, because a forgotten call throws at Dispose.
|
||||
|
||||
### A9 — Two sources for one teleport sequence
|
||||
|
||||
`LocalPlayerTeleportController.cs:613` builds the authority's
|
||||
`TeleportSequence` from `_transit.ActiveTeleportSequence` (passed in as
|
||||
`sequence`), while `ClassifyPortalArrival`
|
||||
(`RuntimeAcceptedPositionDriveController.cs:514-535`) derives its
|
||||
accepted/prior pair from `destination.TeleportSequence`. They agree today —
|
||||
`OfferTeleportDestination:490-497` refuses a second destination for an
|
||||
already-accepted active sequence — but two sources for one fact is the
|
||||
campaign's "mapping written against one caller's reachable set" shape.
|
||||
|
||||
**Fix direction.** Use `destination.TeleportSequence` in both, or assert
|
||||
equality at the producer.
|
||||
|
||||
### A10 — Two documentation statements assert behaviour the code does not have
|
||||
|
||||
(a) The new class doc on `LocalPlayerTeleportPlacement`
|
||||
(`LocalPlayerTeleportController.cs:188-193`) says the sink "snapshots whatever
|
||||
the entity already holds and writes no pose itself — this is the render
|
||||
entity's mover". `TryPublishPlace` writes no pose, but the sink's own upstream
|
||||
call chain does: `RuntimePlacementPresentationSink.TryApply:108` →
|
||||
`LiveEntityRuntime.TryApplyRuntimePlacementProjection:1301` →
|
||||
`TryApplyRuntimePlacementPlace:1386-1420`, which performs
|
||||
`entity.SetPosition(projection.WorldPosition)`, `entity.Rotation = …`,
|
||||
`entity.ParentCellId = token.ExactCellId`, **and** `RebucketLiveEntity` — all
|
||||
before `TryPublishPlace` snapshots. The suffix's entity writes and rebucket are
|
||||
therefore redundant repeats of a mutation the canonical receipt already made.
|
||||
|
||||
This is harmless at runtime today, but it is the "a doc asserting behaviour
|
||||
the code does not have" class — and the contract carries the same misreading
|
||||
(§3.4, D-T4, §12.5(b)), so correcting the code comment alone is not enough.
|
||||
|
||||
(b) The plan correction in `docs/plans/2026-08-02-placement-cutover.md` says
|
||||
"`RuntimeAcceptedPositionDriveController`'s portal arm reading it — was
|
||||
already live before route 3 (from route 2's shared drive controller)". The
|
||||
portal arm was added by **this** slice. A correction that itself asserts a
|
||||
false fact is worse than the line it corrects.
|
||||
|
||||
### A11 — `PhysicsDiagnostics.LocalTeleportHostKind` is a process-global mutable set from a host
|
||||
|
||||
`src/AcDream.Core/Physics/PhysicsDiagnostics.cs` + `HeadlessSessionHost.cs:625`.
|
||||
Correct under K3/K4 (all sessions in a headless process are headless), and the
|
||||
doc comment says so — noted only so it is not later mistaken for per-session
|
||||
state. No action required this slice.
|
||||
|
||||
### A12 — `AddSyntheticIndoorCell` is representative enough to pass the gate, and no more
|
||||
|
||||
The helper registers a `CellPhysics` with an empty `Resolved` polygon
|
||||
dictionary, one `PortalInfo(0,0,0)`, and a leaf-only BSP root. It is not shaped
|
||||
to make a specific assertion pass — it mirrors
|
||||
`RuntimeSetPositionStateTests.AddSyntheticCell` and its only effect is to make
|
||||
`PhysicsEngine.IsSpawnCellReady` return true for an indoor cell, which is a
|
||||
genuine fixture gap (a bare `AddLandblock` passes an empty `CellSurface` list,
|
||||
so indoor destinations parked `DeferredCell` forever). Accepted.
|
||||
|
||||
The caveat: because the destination cell has no geometry, the App-layer tests
|
||||
prove "the arm returned `Committed`", not "the destination resolved somewhere
|
||||
sane". That makes A6's missing position assertions more load-bearing, not
|
||||
less.
|
||||
|
||||
---
|
||||
|
||||
## Verified correct (checked against source, not taken on report)
|
||||
|
||||
- **P1 (the D-T3 duty map).** `RuntimeSetPositionState.cs:5036-5058` calls
|
||||
`PhysicsObjUpdate.CommitSetPositionContactTransition` unconditionally inside
|
||||
the canonical commit, and `CommitSetPositionContactPrefix`
|
||||
(`PhysicsObjUpdate.cs:153-175`) derives `Contact`/`OnWalkable`/
|
||||
`WaterContact` from the placement result's own `InContact`/`OnWalkable`. Not
|
||||
re-seeding `TransientState` in `CommitCanonicalTeleportFrame` is correct and
|
||||
is more faithful than `SetPositionCore`'s unconditional
|
||||
`Contact|OnWalkable|Active` overwrite, exactly as claimed.
|
||||
- **P3, happy path, both hosts.** `RuntimeEntityObjectEventStream
|
||||
.PublishPlacement:164-171` → `EnqueueAndDrain` →
|
||||
`RuntimePlacementProjectionSubscription.OnPlacement:122-150` is
|
||||
**synchronous**, inside `CommitCanonical`. Headless's placement therefore
|
||||
commits and its receipt is consumed inside `PrepareDestination`, strictly
|
||||
before `AcknowledgePortalMaterialized`/`Complete`/`EndTeleport`. The
|
||||
receipt-past-`EndTeleport` hazard is discharged for the committed path. (The
|
||||
residual is A2's park.)
|
||||
- **Trap T7 / route-2 blast radius.** No portal pending reaches
|
||||
`SettlePending` or `_newestForce`: the three `pending.Portal.Present` guards
|
||||
at `:763`, `:794`, and `:820` fence every terminal path, and
|
||||
`SubmitAndResolvePortal` is a genuine sibling of `SubmitAndResolve` rather
|
||||
than an overload of it. `git diff` shows **zero** expectation changes in any
|
||||
force-arm test — the contract's §4 item 8 tripwire is clean.
|
||||
- **Implementer claim 1 (the teardown throw was a test bug).** Mechanism
|
||||
verified. Both corrected assertions were genuinely wrong for the stated
|
||||
reasons, and the `using`-finally exception-replacement is real. See A8 for
|
||||
the residual.
|
||||
- **Sabotage B's asymmetry.** Verified structurally:
|
||||
`PortalProducerInvalidAuthority_ArmDoesNotRunAndNothingMutates` builds an
|
||||
authority with `RevealGeneration: 0`, which fails
|
||||
`RuntimePortalPlacementAuthority.IsValid` at
|
||||
`TryExecuteAcceptedPortalArrival:459` — **before** `ClassifyPortalArrival` is
|
||||
reached. Forcing the classifier to reject cannot change that test's outcome,
|
||||
so the 4-of-5 asymmetry is exactly what the code shape predicts. Good
|
||||
evidence.
|
||||
- **Register bookkeeping.** AD-42's deletion is justified (its last citation
|
||||
was D2's two-call `Resolve`+`ResolvePlacement`, which is gone); AD-2's
|
||||
amendment states the deferred-place adaptation, the T8 tolerance, and the
|
||||
leash-anchor nuance as D-T9 required; the `:2276` stale comment correction
|
||||
landed; the 2026-07-16 pseudocode `enter_world` correction landed. Row count
|
||||
49→48 is consistent.
|
||||
- **Build.** `dotnet build AcDream.slnx -c Debug` exit 0 at the reviewed tree.
|
||||
|
||||
---
|
||||
|
||||
## Judgment on the `_pendingDestination` lifetime
|
||||
|
||||
**The cached destination's lifetime is correct. I found no way to Place
|
||||
against a superseded destination, and the Place-time re-read it replaced
|
||||
protected against nothing.**
|
||||
|
||||
The reasoning, checked against source:
|
||||
|
||||
1. **The bug was real and total.** `RuntimeWorldTransitState
|
||||
.TryBeginPortalReveal:159-183` clears `_hasAcceptedDestination` and
|
||||
`_acceptedDestination` at `:180-181` on success.
|
||||
`TryGetAcceptedTeleportDestination:522-527` returns `_teleportActive &&
|
||||
_hasAcceptedDestination`. Since `AimDestination` drives
|
||||
`TryBeginPortalReveal` through `WorldRevealCoordinator.TryBeginPortal`
|
||||
(`:742`), the slot is empty at every Place edge. The old re-read could
|
||||
never succeed — every real portal placement would have refused with
|
||||
`cause=host-token-unavailable`. Not a stale-destination guard; a hard
|
||||
failure.
|
||||
|
||||
2. **Write/clear is in exact lockstep with `_pendingCell`.**
|
||||
`_pendingRotation`/`_pendingCell`/`_pendingDestination`/
|
||||
`_hasPendingDestination` are written together at `:780-783` and cleared
|
||||
together at `:801-805` in `ResetTransit`. `_pendingCell != 0u` is itself the
|
||||
`haveDestination` predicate (`:481`), so the two cannot diverge.
|
||||
`_pendingDestination.Position.ObjCellId` **is** `_pendingCell` by
|
||||
construction (`Position position = destination.Position;` at `:715`).
|
||||
|
||||
3. **A second Aim cannot produce a mismatched pair.** Supersession by a new
|
||||
F751 goes through `OnTeleportStarted` → `ResetTransit(clearSession: false)`,
|
||||
which clears all four fields and bumps `_lifetimeGeneration`. Supersession
|
||||
by a second destination on the *same* sequence is impossible:
|
||||
`OfferTeleportDestination:490-497` returns `false` once `_destinationAccepted`
|
||||
is set, and `TryBeginPortalReveal` clears only `_hasAcceptedDestination`,
|
||||
leaving `_destinationAccepted` latched for the life of the reveal. So the
|
||||
destination is pinned from Aim to terminal, by the transit itself.
|
||||
|
||||
4. **The one torn window fails closed.** `_pendingRevealGeneration` is written
|
||||
at `:749`, before the `IsCurrentLifetime`/recenter guards at `:750`,
|
||||
`:757`, `:764`, while the other three are written at `:780-783`. A `false`
|
||||
return from any of those guards leaves a NEW generation paired with an OLD
|
||||
cell/destination. Both Place-edge gates then refuse:
|
||||
`CanPlacePortalDestination(newGen, seq, oldCell)` fails
|
||||
`IsCurrentPortalDestination`'s `destinationCell == _snapshot.DestinationCell`
|
||||
check, and `TryRegisterHostProjection(newGen, oldCell)` fails the same
|
||||
comparison at `RuntimeWorldTransitState.cs:197-209`. Neither can commit a
|
||||
stale pair. (This tearing predates the slice — `_pendingCell` already had
|
||||
it; `_pendingDestination` does not worsen it.)
|
||||
|
||||
5. **Terminal clearing.** Commit → `FireLoginComplete` → `ResetTransit`.
|
||||
Session reset / generation reset → `ResetSession` / `ResetGenerationPresentation`
|
||||
→ `ResetTransit`. Cancellation through `ResetTransit(clearSession: false)`.
|
||||
A *refused* Place leaves the fields set — but so does `_pendingCell`, and
|
||||
both Place-edge gates are keyed on the reveal generation, so a retained
|
||||
value is inert until a new Aim overwrites it or a reset clears it.
|
||||
|
||||
The one thing the caching genuinely costs is test coverage, not correctness:
|
||||
A6 removed the only assertion that could have distinguished a stale cached
|
||||
destination from a fresh one. Restore it.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| # | Severity | Finding |
|
||||
|---|---|---|
|
||||
| A1 | MAJOR | Refused Place does not stop the anim stream; player released into the world unplaced. P4 undischarged. |
|
||||
| A2 | MAJOR | `DeferredCell` park commits after `EndTeleport`: body/presentation split + unconsumable Place receipt (P3 graphical half, D-T2.4 re-validation missing). |
|
||||
| A3 | MEDIUM | Headless discards the arm's status; `is not null` guard silently disables placement. |
|
||||
| A4 | MEDIUM | `route.ZeroVelocity`/`ConstrainPhase`/`TeleportHookPhase` never read; inversions hardcoded; contract sabotage 3 cannot fail. |
|
||||
| A5 | MEDIUM | §8 item 8 presentation suite missing; local-player shadow invariant unasserted, and a real cache/publish desync sits behind it. |
|
||||
| A6 | MEDIUM | Superseded-teleport test lost its destination discriminator; two sibling tests weakened, one now contradicts its name. |
|
||||
| A7 | MEDIUM | Dual-host parity test obligation (§8 item 6) not met; headless flip untested. |
|
||||
| A8 | MINOR | Portal-test cleanup outside `finally` masks the first failure behind a Dispose throw. |
|
||||
| A9 | MINOR | Two sources for the teleport sequence (transit vs destination). |
|
||||
| A10 | MINOR | Class doc and plan correction each assert behaviour the code does not have. |
|
||||
| A11 | MINOR | Process-global `LocalTeleportHostKind` (accepted, noted). |
|
||||
| A12 | MINOR | Synthetic indoor cell is geometry-free — fine as a gate, weak as a placement oracle. |
|
||||
434
docs/research/2026-08-04-c4-route-3-retail-review-round2.md
Normal file
434
docs/research/2026-08-04-c4-route-3-retail-review-round2.md
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
# C4 route 3 — retail-conformance review, round 2 (delta) — 2026-08-05
|
||||
|
||||
**Verdict: FAIL — but a near miss.** All three round-1 MAJOR retail
|
||||
findings (R1, R3, R8) are genuinely fixed, and the R1 fix is the right
|
||||
shape for the right retail reason. What blocks is small and cheap: one
|
||||
unsound "the park committed" inference that reopens R1's failure mode on
|
||||
a narrow path (**N1**), one approximation shipped with a code comment
|
||||
instead of the register row the project's binding rule requires
|
||||
(**R7**), and one transient condition converted into a fatal exception on
|
||||
the headless endurance path (**N3**).
|
||||
|
||||
**R4/A5 (the presentation suite) explicitly does NOT block** — see §D.
|
||||
That call changed from round 1 because the A10 correction is true: I
|
||||
verified the canonical Place receipt, not the suffix, is the render
|
||||
entity's mover, and both halves of that path already have tests.
|
||||
|
||||
Scope: delta against my round-1 report
|
||||
(`2026-08-04-c4-route-3-retail-review.md`). Same working tree, HEAD
|
||||
`cd3129e9`, uncommitted. Review only.
|
||||
|
||||
---
|
||||
|
||||
## §A — round-1 findings: disposition
|
||||
|
||||
| # | round-1 finding | round-2 status |
|
||||
|---|---|---|
|
||||
| R1 | `Place` one-shot; no re-attempt driver; reveal completes with an unplaced body | **FIXED** — §B, and the fix is retail-correct |
|
||||
| R2 | headless discards the arm's status, acks a materialization that never happened | **FIXED** — §B.3 |
|
||||
| R3 | probe's `leash` field can never read `armed` | **FIXED** — `Constraint?.IsConstrained` at `RuntimeAcceptedPositionDriveController.cs:838` |
|
||||
| R4 | App presentation suite missing; assertions weakened | **PARTIALLY closed, does not block** — §D |
|
||||
| R5 | headless dual-host parity untested | **FIXED** — `HeadlessPortalPrepareDestinationParksThenCommitsOnCollisionGenerationWake` |
|
||||
| R6 | `enter_world` caller-sweep correction mis-stated the retail record | **FIXED and independently re-verified** — §C.4 |
|
||||
| R7 | retail's movement refresh is autonomy-gated; acdream's was not | **PARTIALLY fixed — BLOCKS on register discipline** — §C.3 |
|
||||
| R8 | probe fired only on commit; refusals invisible under the pinned env var | **MOSTLY fixed** — two App-side causes remain invisible, §E.2 |
|
||||
| R9 | stale "TryBeginPortal (below)" | **FIXED** (`:751` now reads "above") |
|
||||
| R10 | `AD-131` does not exist | **FIXED** → `AP-131` |
|
||||
| R11 | probe hardcoded `autorunCancelled: true` | **FIXED** — reads `CancelAutoRun()`'s bool |
|
||||
|
||||
---
|
||||
|
||||
## §B — the R1 fix: correct, and correct for the right retail reason
|
||||
|
||||
`LocalPlayerTeleportController.cs:527`
|
||||
|
||||
```csharp
|
||||
bool placementReady = dataReady && TryAdvancePortalCommit(sequence);
|
||||
...
|
||||
var (_, events) = _presentation.Tick(deltaSeconds, placementReady);
|
||||
```
|
||||
|
||||
Inverting the readiness feed instead of touching the sequencer is the
|
||||
right call, for a reason worth recording: **it is retail's own shape.**
|
||||
Retail holds the player in portal space on `CellManager::blocking_for_cells`
|
||||
and `SmartBox::UseTime` @0x00455410 runs only `CheckPrefetchStatus` until
|
||||
the destination is usable — the hold lives in the *readiness predicate*,
|
||||
not in the animation. Feeding the sequencer "the canonical commit has
|
||||
already happened" rather than "the data is ready" reproduces that hold
|
||||
without changing a single sequencer timing (stop condition 2 respected).
|
||||
`TeleportAnimSequencer.cs` is untouched — confirmed by `git diff`.
|
||||
|
||||
### B.1 — arrival ORDER is preserved; Inversion B intact
|
||||
|
||||
Verified by tracing the single tick on which the commit succeeds:
|
||||
|
||||
1. `TryAdvancePortalCommit` → `TryExecuteAcceptedPortalArrival` →
|
||||
`TryPrepareAndSubmitAuthoredPlacement` → **canonical body commit**
|
||||
(`CommitCanonical`, retail `SetPositionSimple` @0x005162B0).
|
||||
2. `ReconcileAndAcknowledgePortal` → `CommitCanonicalTeleportFrame`
|
||||
(UnStick @0x00514EEE / UnConstrain @0x00514F02 / re-arm @0x0045418A,
|
||||
velocity zero @0x004541B4, StopCompletely) — **after** the placement.
|
||||
3. `CancelAutoRun()` + movement refresh — the `PlayerTeleported`
|
||||
@0x006B32B0 port, **after** the hook tail.
|
||||
4. Only then does the sequencer see `worldReady=true`, emit `Place`, and
|
||||
run the presentation suffix (`NotifyTeleported`, camera reset,
|
||||
reconcile) and `ObserveMaterialized`.
|
||||
|
||||
Retail: `SetPositionSimple` @0x00453924 → `PlayerPositionUpdated`
|
||||
@0x00453932 → `teleport_hook` @0x004538AE → `PlayerTeleported`
|
||||
@0x004538B3 → `set_viewer` @0x004538D5. **Same order.** Inversion B
|
||||
(local hook AFTER placement) holds; the gating did not move it.
|
||||
|
||||
Two sub-order deltas versus retail, both traced and both **unobservable**
|
||||
— stated so a future reader does not re-derive them:
|
||||
|
||||
- Retail's `ConstrainTo` @0x0045418A and `set_velocity` @0x004541B4 run
|
||||
in `HandleReceivedPosition` *after* `PlayerPositionUpdated` returns,
|
||||
i.e. after `SendMovementEvent` and `set_viewer`. acdream runs both
|
||||
inside `CommitCanonicalTeleportFrame`, before them. Nothing reads the
|
||||
leash between those points, and `MoveToStatePack` @0x006B4720 packs
|
||||
`InqRawMotionState` + `m_position` + contact + longjump + timestamps —
|
||||
**no velocity** — so the outbound bytes are identical either way.
|
||||
- `NotifyTeleported()` (teleport_hook's TargetManager teardown) now runs
|
||||
in the presentation suffix, i.e. after `PlayerTeleported`, where retail
|
||||
runs the whole hook before it. Purely local; no interaction with the
|
||||
outbound send.
|
||||
|
||||
### B.2 — the refusal path is now genuinely held, and tested with teeth
|
||||
|
||||
`RefusedPlace_HoldsTheStreamAndConvergesOnlyAfterContentionClears`
|
||||
(App.Tests) forces a real Runtime `Contention` by taking the entity's
|
||||
placement token, drives **100 ticks at 0.1 s** — 10 s, roughly 3× the
|
||||
tunnel's own 2–5 s timing, well past where the pre-fix code fired
|
||||
`FireLoginComplete` — and asserts the positive facts: body unmoved,
|
||||
`IsActive`, `Snapshot.Completed == false`, `LoginCompleteCount == 0`.
|
||||
It then releases the competing operation and asserts the very next tick
|
||||
commits at the offered destination. That is a real discriminator, not a
|
||||
negative-only assertion, and it directly kills R1.
|
||||
|
||||
A permanent refusal now holds in the tunnel showing retail's centered
|
||||
wait cue (`_holdSeconds` accumulates on `!placementReady`) until
|
||||
supersession or session reset — which is exactly what contract D-T5
|
||||
pinned and §4 item 4 requires.
|
||||
|
||||
### B.3 — headless
|
||||
|
||||
`PrepareDestination` now throws if no drive is wired, gates the attempt
|
||||
on `_collision.IsReady(destination.CellId)`, and returns
|
||||
`IsCollisionReady: committed`;
|
||||
`RuntimeLiveEntitySessionController.TryAdvancePortalCompletion` returns
|
||||
early on `!IsCollisionReady`, retried by `PumpPortalCompletion` from
|
||||
`HeadlessSessionHost.Tick`. The materialization ack can no longer
|
||||
precede a placement. R2 closed.
|
||||
|
||||
### B.4 — P3 (no wedgeable portal receipt) is now ESTABLISHED
|
||||
|
||||
Neither review had closed this. `RuntimePlacementProjectionSubscription.OnPlacement`
|
||||
(`:122-150`) calls `_sink.TryApply(in head)` **synchronously on publish**,
|
||||
so a portal-carrying Place receipt is applied and acknowledged inside the
|
||||
commit call, while the transit is still active and
|
||||
`IsCurrentPlacementAuthority` still holds. This matters most on headless,
|
||||
where `TryAdvancePortalCompletion` runs `Complete` + `EndTeleport`
|
||||
synchronously right after the commit and the FIFO is only republished at
|
||||
the end of the same `Tick` — had delivery been deferred, that receipt
|
||||
would have failed the portal gate forever and wedged the placement FIFO
|
||||
for every entity. It does not. **P3 satisfied on both hosts.**
|
||||
|
||||
---
|
||||
|
||||
## §C — retail verification of the round-2 changes
|
||||
|
||||
### C.1 — A4: the route fields carry retail semantics (with one granularity defect)
|
||||
|
||||
Verified in `RuntimeAuthoritativePositionRouteClassifier.cs:164-171`:
|
||||
|
||||
- `ConstrainAfterRouting => ConstrainPhase is AfterPositionOperation` —
|
||||
**correct and discriminating.** It separates the three real retail
|
||||
cases: force (`None`, early return @0x0045409D), local teleport
|
||||
(`After`, `ConstrainTo` @0x0045418A), local non-teleport (`Before`,
|
||||
`ConstrainTo` @0x004541EC). The sabotage the contract's §8 item 11
|
||||
demanded can now fail as designed.
|
||||
- `ZeroVelocity` — read directly; retail `set_velocity` @0x004541B4.
|
||||
|
||||
**N4 (LOW) — `RunsTeleportHook` gates too much.**
|
||||
`RuntimeAcceptedPositionDriveController.cs:795-801` uses
|
||||
`route.RunsTeleportHook` (`TeleportHookPhase is not None`) to gate the
|
||||
**entire** `CommitCanonicalTeleportFrame` call. But that method does far
|
||||
more than retail's `teleport_hook` @0x00514ED0 (CancelMoveTo, UnStick,
|
||||
StopInterpolating, UnConstrain, TargetManager, report_collision_end): it
|
||||
also resets the render-lerp anchors, publishes `UpdateCellId`, runs
|
||||
StopCompletely, resets the input edges, and resets the object clock —
|
||||
none of which retail conditions on the hook. Retail's `SetPositionInternal`
|
||||
@0x00515330 does the frame/cell work unconditionally. Today the portal
|
||||
route always sets the phase, so there is no live effect; but a future
|
||||
`TeleportHookPhase.None` would silently skip the render-root cell publish
|
||||
— the doorway-FLAP class. Gate only the `UnStick`/`UnConstrain`/re-arm
|
||||
block on the hook phase.
|
||||
|
||||
`RunsTeleportHook` also collapses `Before` and `After` into one boolean.
|
||||
Harmless here because the call site is unconditionally post-commit, but
|
||||
it means the field cannot express Inversion B by itself.
|
||||
|
||||
### C.2 — the "sequence" plumbing
|
||||
|
||||
`TryExecuteCanonicalPortalPlacementCore` now takes the authority's
|
||||
`TeleportSequence` from `_pendingDestination` and `Debug.Assert`s it
|
||||
equals the transit's `ActiveTeleportSequence`. Sound: the two can only
|
||||
diverge through a bug, and `RuntimeWorldTransitState.OfferTeleportDestination:495-503`
|
||||
accepts **exactly one** destination per active sequence
|
||||
(`if (_destinationAccepted) return false;`), so the Aim-time snapshot is
|
||||
unique per sequence by construction. (This also disposes of a hazard I
|
||||
went looking for: a second destination cannot supersede within a
|
||||
sequence, and a new sequence routes through `OnTeleportStarted` →
|
||||
`ResetTransit`, which clears `_placementCommitted`/`_awaitingDeferredWake`
|
||||
at `:932`.)
|
||||
|
||||
### C.3 — R7: the autonomy gate — **the approximation is real and needs its register row (BLOCKING)**
|
||||
|
||||
The retail reading is now exactly right, and I verified both halves:
|
||||
|
||||
- `CommandInterpreter::UsePositionFromServer` @0x006B3B40 (pseudo-C
|
||||
`:699506-699510`) is literally `return this->autonomy_level != 2`.
|
||||
acdream's `RuntimeCharacterState.UsePositionFromServer` (`:122`) is
|
||||
`AutonomyLevel != FullAutonomyLevel(2)` — **an exact port.**
|
||||
- `CommandInterpreter::SendMovementEvent` @0x006B4680 gates on
|
||||
`this->autonomy_level != 0` (@0x006B46BB, pseudo-C `:700283`).
|
||||
|
||||
So `!UsePositionFromServer` sends at level 2 only; retail sends at levels
|
||||
1 **and** 2. The implementer's characterisation is accurate, and the
|
||||
divergence is currently unreachable — `TrySetAutonomyLevel` has **zero
|
||||
production callers** (only `RuntimeCharacterStateTests`), so
|
||||
`AutonomyLevel` is always 2 and the two gates agree. Retail's own default
|
||||
is 2 (`command_line_autonomy_level = 0x2`, pseudo-C `:1088429`).
|
||||
|
||||
That is precisely what an approximation is: correct today, wrong the day
|
||||
someone wires level 1. CLAUDE.md's register rule is binding and admits no
|
||||
implementer discretion — *"Any commit that introduces a deviation (an
|
||||
adaptation, an approximation, a stopgap, a 'retail does X but we…') adds
|
||||
its register row IN THE SAME COMMIT. … A deviation found without a row is
|
||||
a bug twice over."* The shipped disposition is a code comment at
|
||||
`RuntimeAcceptedPositionDriveController.cs:812-824` saying *"not
|
||||
register-worthy on its own (no user-visible#-labeled symptom yet)"*. A
|
||||
symptom is not the threshold; a deviation is. Either thread the raw
|
||||
`AutonomyLevel` through (the exact port, and the constructor already
|
||||
takes two optional funcs so the marginal cost is one more) or add the
|
||||
row. A comment is not the register.
|
||||
|
||||
### C.4 — R6: the corrected `enter_world` banner is now accurate
|
||||
|
||||
Re-verified independently against the pseudo-C, not merely re-read:
|
||||
|
||||
- `:93797` @0x004550EC and `:93824` @0x00455095 both sit inside
|
||||
**`SmartBox::HandleCreateObject` @0x00454C80**. ✓
|
||||
- `CObjectMaint::CreateObject` is invoked at @0x00454FD8 *inside* that
|
||||
function — a callee, not the enclosing scope. ✓
|
||||
- @0x004550EC is in the `if (arg3 != this->player_id)` **non-player**
|
||||
branch; only @0x00455095 follows `SmartBox::init_player` +
|
||||
`CellManager::ChangePosition` in the player branch. ✓
|
||||
- The load-bearing negative still holds: the `Position*` overload
|
||||
@0x00516310 has exactly those two callers and the `int` overload
|
||||
@0x00516170 is reached only from @0x00516327 — `enter_world` is not on
|
||||
the portal path.
|
||||
|
||||
The banner now states all four facts correctly. This citation is safe for
|
||||
future sessions to cite.
|
||||
|
||||
---
|
||||
|
||||
## §D — R4/A5: still open, and it does NOT block. Here is why the call changed.
|
||||
|
||||
Round 1 rated this MAJOR on the premise (taken from contract §3.4) that
|
||||
the sink *"writes no pose"*, making the suffix the render entity's only
|
||||
mover. **That premise was wrong, and the A10 correction is right.** I
|
||||
verified `LiveEntityRuntime.TryApplyRuntimePlacementPlace`
|
||||
(`LiveEntityRuntime.cs:1386-1420`): a `Place` receipt calls
|
||||
`entity.SetPosition(projection.WorldPosition)`, sets `entity.Rotation`,
|
||||
sets `entity.ParentCellId = token.ExactCellId`, and calls
|
||||
`_spatial.RebucketLiveEntity` — `commitPose` defaults true and is passed
|
||||
`false` only for `WithdrawalRestored` (`:1364-1372`). The canonical
|
||||
receipt IS the mover; the suffix's writes are redundant repeats, exactly
|
||||
as the rewritten class comment now says. The comment is verified true.
|
||||
|
||||
With that established, the coverage picture is materially different from
|
||||
round 1:
|
||||
|
||||
- the sink's Place-receipt render-entity reframe + rebucket —
|
||||
**covered** (`RuntimePlacementPresentationSinkTests.Place_ReframesAndRebucketsExactSidecarWithoutMutatingRuntimePhysics`);
|
||||
- the sink's **portal gate discriminating** — **covered**
|
||||
(`PortalPlace_RequiresExactCurrentTransitHostAndSequence`, which drives
|
||||
a valid authority and a mismatched sequence);
|
||||
- the suffix's own entity write + destination bucket ordering —
|
||||
**covered** (the two `ConcretePlacement_*` tests, adapted to the new
|
||||
signature against a real `LiveEntityRuntime`/`WorldEntity`/`GpuWorldState`);
|
||||
- the **canonical body** pose and cell after an App-driven portal commit
|
||||
— **now covered**: the four weakened assertions were restored against
|
||||
`harness.Movement.Controller.Position`/`.CellId`, including A6's
|
||||
superseded-destination discriminator, and the App harness now runs a
|
||||
real Runtime rather than a fake placement.
|
||||
|
||||
What remains uncovered is narrower than "the presentation suite was not
|
||||
built": no single test composes teleport-controller → canonical commit →
|
||||
real sink → suffix; the **local-player collision shadow** after a portal
|
||||
commit (#312's own layer) is unasserted; the T8 overwrite ordering is
|
||||
unasserted; and the refused-Place test asserts body/LoginComplete/IsActive
|
||||
but not the render entity.
|
||||
|
||||
That is a genuine gap and it is #312-adjacent, so it must be recorded and
|
||||
closed in C5 — the implementer flagged it explicitly rather than claiming
|
||||
closure, which is the right behaviour. But it is no longer a
|
||||
"nothing asserts the only mover" hole, and it does not block this slice.
|
||||
|
||||
---
|
||||
|
||||
## §E — new findings
|
||||
|
||||
### N1 — MEDIUM — `PendingCount == 0` does not mean "the portal committed"; R1's failure mode survives on a narrow path
|
||||
|
||||
`LocalPlayerTeleportController.cs:653-657`
|
||||
|
||||
```csharp
|
||||
if (_awaitingDeferredWake)
|
||||
{
|
||||
if (_acceptedPositionDrive.PendingCount != 0)
|
||||
return false;
|
||||
_awaitingDeferredWake = false;
|
||||
_placementCommitted = true; // ← inference
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
and identically `HeadlessSessionWorldProjection.cs:818`
|
||||
(`committed = _acceptedPositionDrive.PendingCount == 0;`).
|
||||
|
||||
`PendingCount` is `_pending is null ? 0 : 1`
|
||||
(`RuntimeAcceptedPositionDriveController.cs:330`) — a shared, arm-agnostic
|
||||
counter. Three `Advance` paths clear a **portal** pending, and only one
|
||||
of them commits:
|
||||
|
||||
| `Advance` site | commits? | logs? |
|
||||
|---|---|---|
|
||||
| `:918` completed-wake, authority current | yes → `ReconcileAndAcknowledgePortal` | yes |
|
||||
| `:918` completed-wake, authority stale | body committed by `RetryDeferred`, **suffix skipped** | `AbandonedAtWake` |
|
||||
| `:973` **watch died** — "most likely a subsequent accepted Position's merge-time Forget" | **no** | **no line at all** |
|
||||
| `:1020` `!IsPlacementCurrent` on the prepare-retry | **no** | **no line at all** |
|
||||
|
||||
On the last two the caller latches `_placementCommitted = true` for a
|
||||
placement that never happened: the sequencer is released, `Place` fires,
|
||||
`ObserveMaterialized` acks a materialization that did not occur, and the
|
||||
player is revealed at the **origin**. That is exactly R1's shape,
|
||||
re-entered through the deferred door.
|
||||
|
||||
Reachability is genuinely low — both hosts now gate the attempt behind
|
||||
collision readiness (`_worldReveal.Evaluate(...).IsReady` graphically,
|
||||
`_collision.IsReady(...)` headless), so a park is rare — and the
|
||||
Runtime-layer test `PortalDeferredCell_WakeAbandonsInsteadOfReconcilingWhenAuthorityWentStale`
|
||||
proves at least one non-committing convergence exists
|
||||
(`Assert.Equal(0, drive.PendingCount)` with no reconcile). Nothing tests
|
||||
the caller's inference against it.
|
||||
|
||||
The fix is small: have the drive report a portal-specific terminal
|
||||
outcome (it already distinguishes them well enough to log
|
||||
`AbandonedAtWake`) instead of the caller inferring commit from a shared
|
||||
counter. Also give the two silent branches a probe line — under D-T8 they
|
||||
are portal-arrival attempts that ended.
|
||||
|
||||
### N2 — LOW/MEDIUM — headless `IsUnhydratable` is now hardcoded false
|
||||
|
||||
`HeadlessSessionWorldProjection.cs:874` reports `IsUnhydratable: false`
|
||||
unconditionally, where it previously reported `!ready`. The old value was
|
||||
itself a conflation (not-yet-resident ≠ unhydratable), so this is not a
|
||||
regression in meaning — but headless can now never report an
|
||||
unhydratable destination, so a genuinely unhydratable claim spins in
|
||||
`PumpPortalCompletion` forever instead of taking AD-2's stated "loud
|
||||
unhydratable-placement path". Either derive the real predicate or state
|
||||
in the method's doc that headless does not model it.
|
||||
|
||||
### N3 — MEDIUM — a transient headless condition is now fatal
|
||||
|
||||
`HeadlessSessionWorldProjection.cs:854` throws `InvalidOperationException`
|
||||
on the `default:` arm, which covers **`NotApplicable`** as well as
|
||||
`Rejected`. `NotApplicable` is returned by
|
||||
`TryExecuteAcceptedPortalArrival` for `record.PhysicsBody is null` and
|
||||
for an active initial-Create residence
|
||||
(`RuntimeAcceptedPositionDriveController.cs:243-251`) — hydration-race
|
||||
conditions, not "the reveal is stale". The exception message asserts a
|
||||
diagnosis ("the reveal itself is stale or the local player has no
|
||||
canonical body, neither recoverable by waiting") that is true for
|
||||
`Rejected` and not established for `NotApplicable`.
|
||||
|
||||
Refusing to fake success is right; converting a possibly-transient
|
||||
condition into a process-killing throw on the host that must survive
|
||||
K4's 30-session / two-hour endurance profile is the wrong end of that
|
||||
trade. Split the arm: throw on `Rejected`, treat `NotApplicable` as a
|
||||
retryable wait with a bounded attempt budget (or a loud log plus
|
||||
`IsCollisionReady: false`).
|
||||
|
||||
### N4 — LOW — `RunsTeleportHook` over-gates the frame commit (§C.1)
|
||||
|
||||
### N5 — LOW — the plan-doc correction contradicts itself
|
||||
|
||||
`docs/plans/2026-08-02-placement-cutover.md` now opens *"This line was
|
||||
accurate for both halves at the time it was written"*, explains that
|
||||
route 3 added the consumer and producer together, and then closes with
|
||||
*"'Zero producing call sites; the adapter does not exist' was accurate
|
||||
only for the producer half"* — which contradicts the opening sentence.
|
||||
The substance is right (the type, `IsValid`, `Pending.Portal`, the sinks'
|
||||
gates and `BeginAcceptedPlacementCore`'s gate pre-dated route 3; the arm
|
||||
did not); the paragraph needs one pass so a future reader can cite it.
|
||||
|
||||
---
|
||||
|
||||
## §E.2 — R8 residual
|
||||
|
||||
`LogPortalArrivalAttempt` now fires on every Runtime-side exit under the
|
||||
gate's own `ACDREAM_PROBE_LOCAL_TELEPORT`, and a live `Contention`
|
||||
refusal was observed — R8's substance is closed and **§9's gate is now
|
||||
passable as specified**: `leash=armed` reads from
|
||||
`ConstraintManager.IsConstrained`, which `ConstrainTo` @0x00556240 sets
|
||||
unconditionally, so a committed arrival prints `armed`.
|
||||
|
||||
Two App-side refusal causes still never reach Runtime and therefore emit
|
||||
no `[local-tp]` line: `cause=stale-reveal`
|
||||
(`LocalPlayerTeleportController.cs:666`, the `CanPlacePortalDestination`
|
||||
preflight) and `cause=host-token-unavailable` (`:737`). Both log through
|
||||
`PhysicsDiagnostics.LogTeleport`, gated by the *different*
|
||||
`ACDREAM_PROBE_TELEPORT`. Under the pinned gate environment these are
|
||||
invisible. Either route them through `LogLocalTeleportArrival` or add
|
||||
`ACDREAM_PROBE_TELEPORT=1` to §9's environment line.
|
||||
|
||||
---
|
||||
|
||||
## §F — gate evidence
|
||||
|
||||
- **Release build**: green, 0 warnings / 0 errors.
|
||||
- **`AcDream.Runtime.Tests`**: 1,164 passed / 0 failed / **0 skipped**.
|
||||
- **`AcDream.Headless.Tests`**: 85 passed / 0 failed / **0 skipped**.
|
||||
- **`LocalPlayerTeleportControllerTests`**: 21 passed / 0 failed / **0 skipped**.
|
||||
- No `Skip` attribute in any touched test file. No test weakened: the
|
||||
four round-1 weakenings were restored with stronger targets (the real
|
||||
canonical body rather than the deleted fake's captured argument), and
|
||||
three genuinely new discriminators were added (`RefusedPlace_…`,
|
||||
`PortalCommitted_UnderServerControlSendsNoMovementEvent`,
|
||||
`PortalDeferredCell_WakeAbandonsInsteadOfReconcilingWhenAuthorityWentStale`,
|
||||
plus the headless park test).
|
||||
- Per process rule 3, none of this is evidence of correctness — it is
|
||||
evidence that nothing regressed while the above holes remain.
|
||||
|
||||
---
|
||||
|
||||
## §G — what must land to pass
|
||||
|
||||
1. **N1** — stop inferring commit from `PendingCount`; report a
|
||||
portal-specific terminal outcome, and log the two silent
|
||||
non-committing `Advance` branches.
|
||||
2. **R7** — add the AD register row for the autonomy approximation, or
|
||||
thread the raw `AutonomyLevel` through and make it exact. Binding
|
||||
project rule; not an implementer judgment call.
|
||||
3. **N3** — do not throw on `NotApplicable`; split it from `Rejected`.
|
||||
|
||||
Cheap follow-ups, non-blocking: N2, N4 (gate only the UnStick/UnConstrain/
|
||||
re-arm block on `RunsTeleportHook`), N5, and R8's two App-side causes.
|
||||
|
||||
Record as a dated, named C5 item: R4/A5's residual — the end-to-end
|
||||
composition test, the local-player collision shadow after a portal
|
||||
commit, and the T8 overwrite ordering.
|
||||
515
docs/research/2026-08-04-c4-route-3-retail-review.md
Normal file
515
docs/research/2026-08-04-c4-route-3-retail-review.md
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
# C4 route 3 — retail-conformance review (2026-08-04)
|
||||
|
||||
**Verdict: FAIL.**
|
||||
|
||||
Reviewer scope: the uncommitted working-tree diff at HEAD `cd3129e9`
|
||||
(`git diff HEAD` + untracked), branch
|
||||
`claude/acdream-physics-divergence-5aa784`. Review only — no edits made.
|
||||
|
||||
The retail *reading* in this slice is excellent. Every §1 claim in the
|
||||
pinned contract reproduces line-for-line in
|
||||
`docs/research/named-retail/acclient_2013_pseudo_c.txt` (§A below), both
|
||||
inversions are implemented in the right direction, the D-T3 duty map is
|
||||
complete, and the implementer's P1 finding on TransientState is not just
|
||||
correct — it retires a real pre-existing divergence.
|
||||
|
||||
The slice fails on **what happens when the placement does not commit**.
|
||||
`TeleportAnimEvent.Place` is one-shot, so every non-`Committed` outcome
|
||||
silently skips the placement, the presentation suffix, and the
|
||||
materialization acknowledgement while the animation stream marches on to
|
||||
reveal the world and fire LoginComplete anyway. The headless host has the
|
||||
same hole with the extra property that it *asserts a materialization that
|
||||
did not happen*. Neither is covered by a test, because the App-layer
|
||||
presentation suite the contract made mandatory (§8 items 8/9/10, closing
|
||||
route 2's B2 gap) was not written — the existing App assertions were
|
||||
weakened instead. And the one probe field the connected gate keys on
|
||||
(`leash=armed`) can never be true as coded.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### R1 — MAJOR — `TeleportAnimEvent.Place` is one-shot; there is no re-attempt driver, and the reveal completes anyway
|
||||
|
||||
`src/AcDream.Core/World/TeleportAnimSequencer.cs:136-141`
|
||||
|
||||
```csharp
|
||||
case TeleportAnimState.Tunnel:
|
||||
if (worldReady)
|
||||
{
|
||||
evts.Add(TeleportAnimEvent.Place);
|
||||
Advance(TeleportAnimState.TunnelContinue, enterTunnel: false);
|
||||
```
|
||||
|
||||
The state advances in the **same tick** the event is emitted. `Place`
|
||||
never fires again for that reveal.
|
||||
|
||||
`src/AcDream.App/Streaming/LocalPlayerTeleportController.cs:505-522`
|
||||
|
||||
```csharp
|
||||
case TeleportAnimEvent.Place:
|
||||
if (!_worldReveal.CanPlacePortalDestination(...)) return;
|
||||
if (!TryExecuteCanonicalPortalPlacement(sequence)) return; // :513
|
||||
...
|
||||
_placement.Place(_pendingRotation); // :517
|
||||
...
|
||||
_worldReveal.ObserveMaterialized(...); // :520
|
||||
```
|
||||
|
||||
`TryExecuteCanonicalPortalPlacement` returns `true` **only** on
|
||||
`RuntimeAcceptedPositionExecutionStatus.Committed`
|
||||
(`LocalPlayerTeleportController.cs:597-598`). Every other status —
|
||||
`Contention`, `Rejected`, `NotApplicable`, and notably `DeferredCell` —
|
||||
returns `false` and the `Tick` returns.
|
||||
|
||||
Consequences, all reachable:
|
||||
|
||||
- `_placement.Place` never runs → no `entity.SetPosition` /
|
||||
`ParentCellId` / `RebucketLiveEntity`, no `NotifyTeleported()`, no
|
||||
camera reset, no `_spatial.Reconcile()`.
|
||||
- `_worldReveal.ObserveMaterialized` never runs →
|
||||
`RuntimeWorldTransitState.AcknowledgePortalMaterialized` never fires →
|
||||
`Materialized` stays false.
|
||||
- The **next** tick still advances the sequencer:
|
||||
`TunnelContinue` → `TunnelFadeOut` → `PlayExitSound`
|
||||
(`RevealWorldViewport`) → `WorldFadeIn` → `FireLoginComplete`
|
||||
(`_mode.EnterWorld()` + `SendLoginComplete()` + `_worldReveal.Complete()`
|
||||
+ `ResetTransit`).
|
||||
- `RuntimeWorldTransitState.Complete` then trips
|
||||
`FailInvariant("portal-complete-before-materialized")`
|
||||
(`RuntimeWorldTransitState.cs:701-706`) and returns `false`;
|
||||
`WorldRevealCoordinator.Complete()` (`:268-276`) **discards** that
|
||||
`false`. `ResetTransit(clearSession:false)` then calls
|
||||
`_transit.EndTeleport()` + `_worldReveal.Cancel()`, so the ledger
|
||||
converges — but the reveal is recorded cancelled, not completed, with
|
||||
one invariant failure logged.
|
||||
|
||||
User-visible outcome on `Contention`/`Rejected`: the player is revealed
|
||||
into the destination world **standing at the origin position**, with
|
||||
LoginComplete sent. On `DeferredCell`: the body commits later at the
|
||||
collision-generation wake (`Advance` → `ReconcileAndAcknowledgePortal`),
|
||||
but the presentation suffix, camera reset, rebucket, and materialization
|
||||
ack are gone forever.
|
||||
|
||||
This is exactly what contract §4 items 4 and 5 forbid ("a refused
|
||||
placement must NOT … must not advance the anim-event stream's terminal
|
||||
events"; "never a half-state", "never a silent wedge in portal space")
|
||||
and it is the D-T5/P4 obligation the contract flagged in advance: *"if
|
||||
the Place anim event is one-shot, the re-attempt must be driven by the
|
||||
same Tick predicate that produced it, and THAT mechanism must be stated
|
||||
in the commit."* It is one-shot, and no mechanism was added.
|
||||
|
||||
The comment shipped in its place is false. `LocalPlayerTeleportController.cs:566-572`:
|
||||
|
||||
> "On any refusal this returns `false` without mutating anything — the
|
||||
> D-T5 refusal shape: … the transit's own cancellation/supersession
|
||||
> machinery is the authority on what happens next."
|
||||
|
||||
The transit is not the authority on what happens next. The animation
|
||||
sequencer is, and it does not wait.
|
||||
|
||||
**Correct behaviour:** either re-drive the Place edge from the same
|
||||
`ready`-gated Tick predicate until it commits (holding the sequencer in
|
||||
`Tunnel` — which is what retail's `blocking_for_cells` hold is), or
|
||||
cancel the reveal explicitly on refusal so the player is never revealed
|
||||
without a committed placement. Retail has no third option: it places
|
||||
unconditionally and immediately (`SmartBox::TeleportPlayer` @0x00453910)
|
||||
and only the *simulation* waits on prefetch.
|
||||
|
||||
---
|
||||
|
||||
### R2 — MAJOR — headless discards the arm's status and acknowledges a materialization that did not happen
|
||||
|
||||
`src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs:775`
|
||||
|
||||
```csharp
|
||||
_ = _acceptedPositionDrive.TryExecuteAcceptedPortalArrival(
|
||||
destination,
|
||||
authority);
|
||||
```
|
||||
|
||||
The status is dropped on the floor. `PrepareDestination` then
|
||||
unconditionally returns a ready readiness report, and
|
||||
`RuntimeLiveEntitySessionController.TryCompletePortal` continues its
|
||||
fully-synchronous suffix: `AcknowledgeDestinationReadiness` →
|
||||
`AcknowledgePortalMaterialized` → `SimulationReleaseProjected` →
|
||||
`Complete` → `SendGameAction(LoginComplete)` → `EndTeleport`.
|
||||
|
||||
So on any refusal or park, the headless host **fires
|
||||
`AcknowledgePortalMaterialized` for a placement that never committed** —
|
||||
contract §4 item 5 and D-T5 rows 1/2 both state in terms that the
|
||||
materialization ack must fire only from the committed outcome. The bot
|
||||
reports a completed teleport while standing where it started, with no
|
||||
log line of any kind (see R8).
|
||||
|
||||
Retail contradiction is indirect but real: retail's
|
||||
`SmartBox::PlayerPositionUpdated` @0x00453870 clears
|
||||
`waiting_for_teleport` **inside the same call that performed
|
||||
`SetPositionSimple`** (@0x00453924 → @0x0045389A). The "wait is over"
|
||||
edge is downstream of the placement in retail; here it can precede a
|
||||
placement that never occurred.
|
||||
|
||||
---
|
||||
|
||||
### R3 — MAJOR — the D-T8 probe's `leash` field can never read `armed`; the connected gate as pinned is unpassable
|
||||
|
||||
`src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs:694`
|
||||
|
||||
```csharp
|
||||
leashArmed: controller.PositionManager?.IsFullyConstrained() ?? false,
|
||||
```
|
||||
|
||||
Retail `ConstraintManager::IsFullyConstrained` @0x005560D0 is
|
||||
`constraint_distance_max * 0.9 < constraint_pos_offset` — "has strained
|
||||
past 90 % of the leash", the predicate `jump_is_allowed` reads. It is not
|
||||
"is the leash armed". The acdream port says so explicitly
|
||||
(`src/AcDream.Core/Physics/Motion/ConstraintManager.cs:79-89`).
|
||||
|
||||
Retail `ConstraintManager::ConstrainTo` @0x00556240 (pseudo-C
|
||||
:353528-353537) ends with
|
||||
`constraint_pos_offset = Position::distance(anchor, physics_obj->m_position)`;
|
||||
acdream mirrors it at `ConstraintManager.cs:65-71`. Because
|
||||
`RearmConstraintLeashAtCurrentPosition`
|
||||
(`PlayerMovementController.cs:1836-1845`) anchors at the body's **own**
|
||||
`CellPosition`, that distance is 0. `max * 0.9 < 0` is false.
|
||||
|
||||
Therefore every committed portal arrival prints `leash=unarmed`. The
|
||||
contract's §9 pass criterion — *"Pass requires ALL of … `leash=armed`"* —
|
||||
cannot be met, and a future reader hitting `leash=unarmed` would chase a
|
||||
phantom missing leash (the exact 4b-3 A1 defect class the contract
|
||||
warned about, inverted).
|
||||
|
||||
The correct observable is `ConstraintManager.IsConstrained`, which the
|
||||
Runtime test itself uses
|
||||
(`RuntimeAcceptedPositionDriveControllerTests`,
|
||||
`Assert.True(controller.PositionManager.Constraint!.IsConstrained)`).
|
||||
`PositionManager` does not currently surface it; it needs to.
|
||||
|
||||
---
|
||||
|
||||
### R4 — MAJOR — the mandatory App-layer presentation suite is missing, and the existing App assertions were weakened
|
||||
|
||||
`tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs`
|
||||
gained **zero** new `[Fact]`s. All five new tests in the diff are in
|
||||
`tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs`.
|
||||
|
||||
What changed in the App file is assertion *strength*, downward:
|
||||
|
||||
```diff
|
||||
- Assert.Equal(new Vector3(7f, 8f, 9f), harness.Placement.Position);
|
||||
+ Assert.True(harness.Placement.Called);
|
||||
```
|
||||
|
||||
(and the same substitution at eight further sites). The interface change
|
||||
makes the literal old assertion impossible, which is fine — but the
|
||||
contract required the replacement coverage and named it as load-bearing:
|
||||
|
||||
- §8 item 8: "after a committed portal placement through the REAL sink +
|
||||
suffix, the render `WorldEntity` position/rotation/`ParentCellId` equal
|
||||
the resolved body, the draw bucket moved, the local-player shadow
|
||||
agrees, and the sink's Place receipt was consumed with a VALID portal
|
||||
authority … **route 2's B2 coverage gap … becomes load-bearing here and
|
||||
MUST close**."
|
||||
- §8 item 9: the T8 overwrite ordering (wire pose then resolved pose).
|
||||
- §8 item 10: refused Place edge presentation.
|
||||
|
||||
None exist. Since `RuntimePlacementPresentationSink.TryPublishPlace`
|
||||
writes no pose (contract §3.4, re-confirmed), the suffix in
|
||||
`LocalPlayerTeleportPlacement.Place` is now the render entity's **only**
|
||||
mover — and nothing asserts it moves the entity to the resolved pose.
|
||||
Combined with R1 (where that suffix is skipped entirely on refusal), this
|
||||
is the #312 shape verbatim: process rule 4, "tests must assert the layer
|
||||
that broke."
|
||||
|
||||
---
|
||||
|
||||
### R5 — MAJOR — headless dual-host parity (§8 item 6, D-T6) has no coverage, self-declared open
|
||||
|
||||
`tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs:413-422`
|
||||
(added in this diff):
|
||||
|
||||
> "no accepted-position drive controller is wired into this fixture's
|
||||
> projection … so the canonical portal arm this method now calls is a
|
||||
> no-op here by construction (`_acceptedPositionDrive` is null) … a
|
||||
> headless-host-specific committed-portal test is an open item, not
|
||||
> attempted here given this session's time budget."
|
||||
|
||||
D2 (`ResynchronizeLocalPlayerForPortalArrival`, ~40 non-comment lines and
|
||||
AD-42's last citation) was deleted and its replacement has **zero**
|
||||
headless test coverage. D-T6 pinned this: *"dual-host parity is a test
|
||||
obligation, not an aspiration."* The honest disclosure is appreciated and
|
||||
does not change the finding.
|
||||
|
||||
Related, and unremarked in the diff: the deleted method also performed
|
||||
`controller.LocalEntityId = record.LocalEntityId ?? 0u`. Verified safe —
|
||||
`RuntimeLocalPlayerPhysicsPublicationState.cs:214` sets it at publication
|
||||
and the entity key is stable across a portal — but the drop deserves a
|
||||
line in the commit message.
|
||||
|
||||
---
|
||||
|
||||
### R6 — MEDIUM — the `enter_world` caller-sweep correction mis-states the retail record it is correcting
|
||||
|
||||
`docs/research/2026-07-16-portal-completion-pseudocode.md` §2.1 banner:
|
||||
|
||||
> "a caller sweep … shows both `CPhysicsObj::enter_world` call sites
|
||||
> living inside `CObjectMaint::CreateObject`'s player branch
|
||||
> (`SmartBox::init_player` + `CellManager::ChangePosition` immediately
|
||||
> precede it)"
|
||||
|
||||
Verified independently. The two call sites are pseudo-C :93797
|
||||
(@0x004550EC) and :93824 (@0x00455095). Both live inside
|
||||
**`SmartBox::HandleCreateObject` @0x00454C80**, not
|
||||
`CObjectMaint::CreateObject` — the latter is merely a *callee* at
|
||||
@0x00454FD8 inside that same function. And they are **not both in the
|
||||
player branch**: @0x004550EC is in the `if (arg3 != this->player_id)`
|
||||
NON-player branch (`PhysicsDesc::get_position` → `enter_world(var_bc, …)`
|
||||
for a newly created remote object); only @0x00455095 sits in the player
|
||||
branch after `init_player` + `ChangePosition`.
|
||||
|
||||
The load-bearing NEGATIVE is **CONFIRMED**: the `Position*` overload
|
||||
@0x00516310 has exactly those two callers, the `int` overload @0x00516170
|
||||
is reached only from @0x00516327, and neither is on the portal path.
|
||||
`SmartBox::TeleportPlayer` → `SetPositionSimple` is correct.
|
||||
|
||||
But this banner is explicitly a correction to the retail record that
|
||||
future sessions will cite, and it is wrong in two of its three factual
|
||||
clauses. Same defect class as "a register row asserting behaviour the
|
||||
code does not have," applied to a research doc — the exact reason the
|
||||
contract ordered the correction in-slice.
|
||||
|
||||
---
|
||||
|
||||
### R7 — MEDIUM — retail's post-teleport movement refresh is autonomy-gated; acdream's is not
|
||||
|
||||
Contract open question (c), answered.
|
||||
`CommandInterpreter::SendMovementEvent` @0x006B4680 (pseudo-C
|
||||
:700274-700313):
|
||||
|
||||
```
|
||||
if ((player != 0 && this->smartbox != 0) && CPhysicsObj::InqRawMotionState(player) != 0)
|
||||
if (this->autonomy_level != 0)
|
||||
MoveToStatePack::MoveToStatePack(...)
|
||||
SendMoveToStateEvent(...)
|
||||
```
|
||||
|
||||
Two gates: a non-null raw motion state, and **`autonomy_level != 0`**.
|
||||
Under server control retail sends nothing.
|
||||
|
||||
`RuntimeAcceptedPositionDriveController.cs:678-681` calls
|
||||
`_localPlayerOutbound.TrySendMovement(...)` unconditionally;
|
||||
`LocalPlayerOutboundController.TrySendMovement:187-229` gates only on a
|
||||
resolvable outbound position. The controller already holds
|
||||
`UsePositionFromServer` (retail's `UsePositionFromServer()`), and
|
||||
`_usePositionFromServer` is already a field on this very class — the
|
||||
autonomy fact is in scope.
|
||||
|
||||
Everything else about the port checks out: the message family is
|
||||
`MoveToState` (retail packs `InqRawMotionState` into `MoveToStatePack`),
|
||||
the contact byte is `Contact && OnWalkable` in both, exactly one is sent,
|
||||
and no `AutonomousPosition` goes out (retail's teleport branch returns
|
||||
before `SendPositionEvent` — verified at @0x004541C0). Order is right:
|
||||
`CommitCanonicalTeleportFrame` (hook tail) → `CancelAutoRun` →
|
||||
movement send, matching @0x004538AE → @0x004538B3 → tail-jump.
|
||||
|
||||
Either add the autonomy gate or file the delta as a register row.
|
||||
|
||||
---
|
||||
|
||||
### R8 — MEDIUM — D-T8 emits one line per *committed* arrival, not per attempt; refusals are invisible under the pinned gate environment
|
||||
|
||||
`PhysicsDiagnostics.LogLocalTeleportArrival` is called from exactly one
|
||||
site, `ReconcileAndAcknowledgePortal`
|
||||
(`RuntimeAcceptedPositionDriveController.cs:687-696`), reached only on
|
||||
`CommittedHostAcknowledgementPending`. Its `placementStatus` argument is
|
||||
the literal `"Committed"`.
|
||||
|
||||
The graphical refusal path logs via `PhysicsDiagnostics.LogTeleport`
|
||||
(`LocalPlayerTeleportController.cs:582-583`, `:598-599`), which is gated
|
||||
by **`ACDREAM_PROBE_TELEPORT`** (`PhysicsDiagnostics.cs:1160-1161`) — a
|
||||
different env var from the `ACDREAM_PROBE_LOCAL_TELEPORT` the gate pins.
|
||||
The headless refusal path logs nothing at all (R2).
|
||||
|
||||
Net: with the contract's pinned gate environment, a refusal produces zero
|
||||
output on either host. D-T8 specified "One line per portal-arrival
|
||||
attempt: cause … placement status", and §9 requires "zero
|
||||
`Refused`/`Contention` lines in ordinary play" — unobservable as built.
|
||||
Given R1, an unobserved refusal is precisely the failure that would ship.
|
||||
|
||||
---
|
||||
|
||||
### R9 — LOW — stale directional reference in a comment added by this diff
|
||||
|
||||
`src/AcDream.App/Streaming/LocalPlayerTeleportController.cs:775`:
|
||||
"TryBeginPortal (below) drives …". `_worldReveal.TryBeginPortal` is
|
||||
called **above** this comment, at `:741`, in the same method. Process
|
||||
rule 6.
|
||||
|
||||
### R10 — LOW — `AD-131` does not exist
|
||||
|
||||
`src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:2292`
|
||||
cites "AD-2/AD-131/#275". The AD section has 48 rows. The row is
|
||||
**AP-131** (`docs/architecture/retail-divergence-register.md:283`), which
|
||||
is what the contract itself says. Introduced by this diff, in the very
|
||||
comment the slice rewrote to fix a stale comment.
|
||||
|
||||
### R11 — LOW — the probe asserts more than it observes
|
||||
|
||||
`RuntimeAcceptedPositionDriveController.cs:693-695` hardcodes
|
||||
`hookTailRan: true` and `autorunCancelled: true`.
|
||||
`RuntimeLocalPlayerMovementState.CancelAutoRun():226-234` returns `false`
|
||||
when autorun was already off (correctly mirroring retail's
|
||||
`SetAutoRun` @0x006B4850, which acts only on a state *change* at
|
||||
@0x006B4871). The field reports the action ran, not the state changed —
|
||||
report the returned bool.
|
||||
|
||||
---
|
||||
|
||||
## §A — retail claims verified independently (do not re-derive)
|
||||
|
||||
All against `docs/research/named-retail/acclient_2013_pseudo_c.txt`.
|
||||
|
||||
| claim | where | result |
|
||||
|---|---|---|
|
||||
| `SmartBox::TeleportPlayer` @0x00453910 = `SetPositionSimple(player, dest, 1)` @0x00453924 + `PlayerPositionUpdated(this, 1, FLT_MAX)` @0x00453932, nothing else | :92514-92523 | **CONFIRMED** — the generic path, route 2's exact primitive, third route running |
|
||||
| `PlayerPositionUpdated` teleport arm order: `position_update_complete=0` @0x00453890, `waiting_for_teleport=0` @0x0045389A, `has_been_teleported=0` @0x004538A4, `teleport_hook` @0x004538AE, `cmdinterp->PlayerTeleported()` @0x004538B3, `set_viewer` @0x004538D5, `LScape::update_viewpoint` @0x004538E2, `CellManager::ChangePosition` @0x00453903 | :92469-92509 | **CONFIRMED**, exactly the contract's order |
|
||||
| `CommandInterpreter::PlayerTeleported` @0x006B32B0 = `SetAutoRun(0,1)` + tail-jump `SendMovementEvent` | :699036-699041 | **CONFIRMED**. New: `SetAutoRun` @0x006B4850 only acts when `(arg2==0) != (auto_run==0)` (@0x006B4871) — acdream's `CancelAutoRun` early-return matches |
|
||||
| **Inversion A** — local TELEPORT branch @0x0045415F: `TeleportPlayer(&var_48)` @0x00454168 → `ConstrainTo(arg2, &var_48, start, max)` @0x0045418A → `set_velocity(player, {0,0,0}, 1)` @0x004541B4 → return | :93013-93023 | **CONFIRMED**, including the WIRE-destination anchor |
|
||||
| FORCE_POSITION branch returns @0x0045409D before every `ConstrainTo` | :92925-92933 | **CONFIRMED** — route 2's no-re-arm rule intact and correctly left force-scoped |
|
||||
| **Inversion B** — the local hook runs AFTER the placement (from `PlayerPositionUpdated`), opposite to the remote arm's @0x005163EF | :92497 vs 4b-3's citation | **CONFIRMED** |
|
||||
| `enter_world` is NOT on the portal path | :93797, :93824 | **NEGATIVE CONFIRMED** — but the attribution in the new correction banner is wrong; see R6 |
|
||||
| **P1 (TransientState not re-seeded)** — retail `CPhysicsObj::SetPositionInternal(CTransition*)` @0x00515330 derives Contact from `collision_info.contact_plane_valid` @0x00515430, WaterContact from `contact_plane_is_water` @0x00515453, OnWalkable from `set_on_walkable(contact_plane.N.z vs floor_z)` @0x00515467-0x0051548E, Sliding from `sliding_normal_valid` @0x005154E1. **No unconditional `Contact\|OnWalkable` seed anywhere.** | :283484-283519 | **THE FINDING IS CORRECT AND IS A FIDELITY GAIN.** `PhysicsObjUpdate.CommitSetPositionContactPrefix` (`src/AcDream.Core/Physics/PhysicsObjUpdate.cs:154-176`) is that exact port, and runs inside the canonical commit (`RuntimeSetPositionState.cs:5038`). The old `SetPositionCore` seed (`PlayerMovementController.cs:1859-1862`) was the divergence; dropping it is right. The `Active` argument also holds — `PlayerMovementController.cs:2069` and `:2449` re-set it every frame. |
|
||||
| `ConstraintManager::ConstrainTo` @0x00556240 initializes `constraint_pos_offset = distance(anchor, m_position)` | :353528-353537 | **CONFIRMED** — acdream matches; also the basis for R3 |
|
||||
| `CommandInterpreter::SendMovementEvent` @0x006B4680 is autonomy-gated | :700274-700313 | **CONFIRMED** — see R7 |
|
||||
|
||||
## §B — implementation facts verified correct
|
||||
|
||||
- **D-T3 duty map (P1) is complete.** All nine `SetPositionCore` duties
|
||||
land in `CommitCanonicalTeleportFrame`
|
||||
(`PlayerMovementController.cs:1993-2041`) or the canonical commit, in
|
||||
`SetPositionCore`'s own order (StopCompletely → input/mouse resets →
|
||||
UnStick/UnConstrain/re-arm → edge resets → clock reset). Nothing
|
||||
silently dropped except the TransientState seed, which is correct
|
||||
(§A).
|
||||
- **Inversions implemented in the right directions.** The portal arm
|
||||
consumes the classifier's dormant LocalPlayer-teleport branch
|
||||
(`RuntimeAuthoritativePositionRouteClassifier.cs:336-356`) unchanged;
|
||||
`ConstrainPhase.AfterPositionOperation` + `ZeroVelocity: true` +
|
||||
`SendPositionImmediately: false` + `TeleportHookPhase.AfterPositionOperation`
|
||||
all flow through. The hook tail runs only from the committed receipt.
|
||||
- **The force arm is untouched.** The only route-2 edits are one
|
||||
force-scoping doc sentence (`:133-137`) and a non-`required`
|
||||
`Portal { get; init; }` on `Pending` that defaults empty. Zero route-2
|
||||
test expectation changes — §4 item 8's tripwire is clean.
|
||||
- **The synthetic `priorTeleport = accepted - 1`** in
|
||||
`ClassifyPortalArrival` is sound: `TeleportAdvanced` reads only the
|
||||
boolean `PhysicsTimestampGate.IsNewer(prev, accepted)`, the branch's
|
||||
resulting route does not depend on the previous stamp's magnitude, and
|
||||
wrap is safe at `accepted == 0`.
|
||||
- **AD-42 deleted** (row gone, header 49 → 48 rows), **AD-2 amended in
|
||||
place** with the deferred-place timing, the T8 tolerated-overwrite
|
||||
note, and the leash-anchor nuance. Plan gap-line correction present.
|
||||
Register rules satisfied.
|
||||
- **Release build green.** Focused
|
||||
`RuntimeAcceptedPositionDriveControllerTests`: 22 passed / 0 failed /
|
||||
**0 skipped**. No `Skip` attribute remains in any of the four touched
|
||||
test files, and no Runtime test was weakened — the five new ones are
|
||||
strong (the happy path pre-arms the leash at a *stale* anchor so a
|
||||
missing re-arm fails the `ConstraintPos` assertion; the refusal tests
|
||||
assert positive "nothing moved, transit still active, no packets"
|
||||
facts).
|
||||
|
||||
## §C — the production bug fix: correct, complete, no stale-destination hazard
|
||||
|
||||
Verified. `RuntimeWorldTransitState.TryBeginPortalReveal:159-182` sets
|
||||
`_hasAcceptedDestination = false` and `_acceptedDestination = default`
|
||||
the instant it claims the generation, and
|
||||
`TryGetAcceptedTeleportDestination:522-527` returns
|
||||
`_teleportActive && _hasAcceptedDestination`. So the pre-fix Place-time
|
||||
re-read was **guaranteed** to fail — the canonical portal arm was 100 %
|
||||
dead code, refusing with `cause=host-token-unavailable` before ever
|
||||
reaching Runtime. The diagnosis is right and this was a real production
|
||||
bug, not a fixture artifact.
|
||||
|
||||
The fix is the right lifetime:
|
||||
|
||||
- `_pendingDestination` is written in `AimDestination:781-784`, in the
|
||||
same statement block as `_pendingCell`/`_pendingRotation`, only after
|
||||
`TryBeginPortal` succeeded (`:741-748`) — so the four Aim-time
|
||||
snapshots are mutually consistent by construction.
|
||||
- **Cancellation:** `ResetTransit:801-804` clears all four; every
|
||||
cancellation path funnels through it.
|
||||
- **Supersession:** a second accepted destination re-enters
|
||||
`TryAimAcceptedDestination` → `AimDestination` →
|
||||
`WorldRevealCoordinator.TryBeginPortal` → `WithdrawHostForReplacement`
|
||||
+ a **new** generation, overwriting all four snapshots together. A
|
||||
superseded destination cannot survive.
|
||||
- **Staleness at Place:** three independent gates still validate —
|
||||
`CanPlacePortalDestination(_pendingRevealGeneration, sequence, _pendingCell)`
|
||||
(`:507-511`), the idempotent
|
||||
`TryRegisterHostProjection` re-derivation (generation ==
|
||||
`_snapshot.Generation`, cell == `_snapshot.DestinationCell`,
|
||||
`!Cancelled`, `!Completed` — `RuntimeWorldTransitState.cs:189-227`),
|
||||
and `BeginAcceptedPlacementCore`'s own
|
||||
`portal.Projection.DestinationCell == acceptedPosition.LandblockId`
|
||||
against the **latest merged** snapshot
|
||||
(`RuntimeSetPositionState.cs:1528-1534`).
|
||||
|
||||
The re-read was protecting nothing. `WorldRevealCoordinator.BeginHostLifetime`
|
||||
throws if the Aim-time registration fails, so the Place-time
|
||||
re-derivation is genuinely idempotent and can never mint a second host
|
||||
projection in production.
|
||||
|
||||
## §D — contract open questions, answered
|
||||
|
||||
**(b) leash anchor — keep the resolved anchor as shipped.** Retail's
|
||||
`constraint_pos` is write-only (never read by `adjust_offset`, confirmed
|
||||
in the port's own doc at `ConstraintManager.cs:41-44` and against ACE);
|
||||
the only downstream consumer of `ConstrainTo`'s inputs is
|
||||
`constraint_pos_offset = distance(anchor, m_position)`, which is the
|
||||
placement adjustment (centimetres) in retail and exactly 0 in acdream.
|
||||
Both are orders of magnitude inside the `0.9 * max` band, so no behaviour
|
||||
in the leash's brake taper can distinguish them. The AD-2 note is the
|
||||
right disposition; do **not** switch anchors.
|
||||
|
||||
**(c) `SendMovementEvent` shape — see R7.** Message family, contact
|
||||
derivation, count, and ordering are all correct; the missing
|
||||
`autonomy_level` gate is the one delta.
|
||||
|
||||
## §E — errors in the contract itself
|
||||
|
||||
1. **§4 item 3 / D-T5's re-attempt reasoning is the proximate cause of
|
||||
R1.** D-T5 offered "the anim event re-fires while `ready` holds" as
|
||||
the leading case and demoted the one-shot case to a parenthetical
|
||||
verify-and-state. It is one-shot. The contract should have read the
|
||||
sequencer before writing the row and pinned the driver. This is
|
||||
process rule 1 ("the contract causes the defect") recurring for the
|
||||
third documented time.
|
||||
2. **§9's `leash=armed` criterion is unachievable** with any
|
||||
`IsFullyConstrained`-shaped observable; the contract should have named
|
||||
`ConstraintManager.IsConstrained`. See R3.
|
||||
3. **§3.5 overstates the change:** "`AcknowledgePortalMaterialized` fires
|
||||
from the committed placement receipt instead of rubber-stamping after
|
||||
a host mutation." As built it still fires from the host Place edge,
|
||||
merely gated on a committed status. Substantively equivalent on the
|
||||
commit path; wording should be corrected so a future reader does not
|
||||
look for a receipt-driven ack that does not exist.
|
||||
4. **§1's `enter_world` row** carries the same wrong caller attribution
|
||||
the banner does (R6) — it says "its local-player caller is the initial
|
||||
login path only (@0x00455095)", which is right about that site but
|
||||
silently drops the *other* site @0x004550EC and mis-names the
|
||||
enclosing function in the derived correction.
|
||||
|
||||
## What must land before this can pass
|
||||
|
||||
1. A re-attempt (or explicit-cancel) mechanism for a non-`Committed`
|
||||
Place edge, with the driver named and tested — R1.
|
||||
2. Headless must consume the arm's status and must not acknowledge a
|
||||
materialization for a placement that did not commit — R2.
|
||||
3. Fix the probe's `leash` observable (and emit a line per *attempt*,
|
||||
under the gate's own env var) — R3, R8.
|
||||
4. Write the App-layer presentation suite (§8 items 8/9/10) closing route
|
||||
2's B2 gap, plus one headless committed-portal test — R4, R5.
|
||||
5. Correct the `enter_world` caller-sweep banner — R6.
|
||||
6. Gate the movement refresh on autonomy, or file the register row — R7.
|
||||
7. Comment/citation cleanups — R9, R10, R11.
|
||||
|
|
@ -586,7 +586,21 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
() => d.PlayerIdentity.ServerGuid,
|
||||
() => d.PlayerController.Controller,
|
||||
() => d.Character.UsePositionFromServer,
|
||||
() => liveSessionSource.CurrentSession);
|
||||
() => liveSessionSource.CurrentSession,
|
||||
// C4 route 3: the portal arm's PlayerTeleported port needs the
|
||||
// J5.4 autorun latch owner, one level above the raw controller.
|
||||
() => d.PlayerController,
|
||||
// A2/D-T2.4 review fix (2026-08-05): the SAME idempotent query
|
||||
// the Place edge itself uses (WorldRevealCoordinator.
|
||||
// CanPlacePortalDestination -> RuntimeWorldTransitState) lets a
|
||||
// DeferredCell wake re-validate before reconciling instead of
|
||||
// running the ack suffix against a reveal that ended or was
|
||||
// superseded while the park sat outstanding.
|
||||
isPortalAuthorityCurrent: portal => live.WorldTransit
|
||||
.CanPlacePortalDestination(
|
||||
portal.RevealGeneration,
|
||||
portal.TeleportSequence,
|
||||
portal.Projection.DestinationCell));
|
||||
// C4 route 4b-2 (2026-08-04): the graphical remote-placement drive
|
||||
// controller — route 4b-1's dormant owner, now driven by the remote
|
||||
// far snap. Its service window is the graphical host's near-tier
|
||||
|
|
@ -886,16 +900,17 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
live.WorldTransit,
|
||||
worldReveal,
|
||||
new LocalPlayerTeleportPlacement(
|
||||
d.PhysicsEngine,
|
||||
live.LiveEntities,
|
||||
d.PlayerIdentity,
|
||||
d.PlayerController,
|
||||
d.PlayerHost,
|
||||
d.ChaseCameraInput,
|
||||
d.WorldOrigin,
|
||||
liveSpatialReconciler),
|
||||
new LocalPlayerTeleportSession(liveSessionSource),
|
||||
presentation);
|
||||
presentation,
|
||||
// C4 route 3: the portal arm shares route 2's Runtime
|
||||
// SetPosition drive controller.
|
||||
acceptedPositionDrive);
|
||||
|
||||
LocalPlayerTeleportController CreateLocalTeleportWithTunnel(
|
||||
PortalTunnelPresentation portalTunnel) =>
|
||||
|
|
|
|||
|
|
@ -2273,13 +2273,28 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
// Position resolves to NoPositionOperation (retail's airborne
|
||||
// no-op — writes nothing at all) or Interpolate (retail's near
|
||||
// InterpolateTo queue — no direct body write here) never receives
|
||||
// the generic write below. The local player never reaches this
|
||||
// generic-remote code path at all. C4 route 4b-2 routes the >=96 m
|
||||
// far snap and C4 route 4b-3 routes the teleport/cell-less
|
||||
// classification through the canonical Runtime placement owner
|
||||
// the generic write below. C4 route 4b-2 routes the >=96 m far snap
|
||||
// and C4 route 4b-3 routes the teleport/cell-less classification
|
||||
// through the canonical Runtime placement owner
|
||||
// (ApplyRemoteContactRouting); a rejected authority or payload, and
|
||||
// "no classification at all", take the stated UnroutedCatchUp
|
||||
// policy (RuntimeRemoteFarSnapPosition.ResolveArm).
|
||||
//
|
||||
// CORRECTED 2026-08-04 (C4 route 3, process rule 6 — this comment
|
||||
// used to claim "the local player never reaches this generic-remote
|
||||
// code path at all", which is FALSE): for the local player,
|
||||
// `earlyRemoteRoute` is null and `OwnsSteadyState(null)` is false
|
||||
// (RuntimeRemoteSteadyStatePosition.cs — both pattern matches fail
|
||||
// on null), so every accepted local Apply — including the portal
|
||||
// DESTINATION Position itself — reaches and runs the generic write
|
||||
// + rebucket below, writing the raw wire pose onto the local
|
||||
// player's WorldEntity while portal space still covers the
|
||||
// viewport. This is pre-existing, tolerated (AD-2/AP-131/#275
|
||||
// territory — see the D-T7 discussion in
|
||||
// docs/research/2026-08-04-c4-route-3-contract.md), and overwritten
|
||||
// by the committed portal Place receipt's presentation suffix
|
||||
// (LocalPlayerTeleportPlacement.Place) once the canonical Runtime
|
||||
// commit lands. Route 3 does not suppress it.
|
||||
|
||||
TryApplyGenericRemoteRenderPose(
|
||||
entity,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ using AcDream.Core.Physics;
|
|||
using AcDream.Core.Rendering;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Physics;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.Runtime.World;
|
||||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
|
@ -173,68 +175,67 @@ internal sealed class LocalPlayerTeleportStreamingOperations
|
|||
|
||||
internal interface ILocalPlayerTeleportPlacement
|
||||
{
|
||||
void Place(Vector3 position, uint cellId, Quaternion rotation);
|
||||
void Place(Quaternion rotation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commits the local player's deferred portal arrival. It owns the exact
|
||||
/// Place -> root/controller/camera mutation -> spatial reconcile edge.
|
||||
/// C4 route 3: acknowledges the local player's deferred portal arrival —
|
||||
/// the canonical placement itself now runs through
|
||||
/// <see cref="RuntimeAcceptedPositionDriveController.TryExecuteAcceptedPortalArrival"/>
|
||||
/// (a portal arm sharing route 2's Runtime SetPosition owner), retiring the
|
||||
/// duplicate Resolve/SetPosition authority this class used to own (D1;
|
||||
/// docs/research/2026-08-04-c4-route-3-contract.md D-T4). This class runs
|
||||
/// AFTER that commit succeeds. A10 review fix (2026-08-05): the render
|
||||
/// <see cref="WorldEntity"/> pose write and rebucket this method performs
|
||||
/// are REDUNDANT repeats of a mutation the canonical Place receipt already
|
||||
/// made — <c>RuntimePlacementPresentationSink.TryApply</c> →
|
||||
/// <c>LiveEntityRuntime.TryApplyRuntimePlacementPlace</c> already calls
|
||||
/// <c>entity.SetPosition</c>/sets <c>Rotation</c>/<c>ParentCellId</c> and
|
||||
/// rebuckets, synchronously, before <c>TryPublishPlace</c>'s OWN snapshot
|
||||
/// even runs (proof obligation P2's ordering). This method's writes are
|
||||
/// therefore harmless-but-duplicate, not the render entity's ONLY mover as
|
||||
/// an earlier revision of this comment claimed; kept because they cost
|
||||
/// nothing extra and this is also where the retail teleport_hook tail's
|
||||
/// remaining local-player-visible actions run (target-watcher
|
||||
/// notification, camera reset, spatial reconcile).
|
||||
/// <paramref name="rotation"/> is the retained accepted destination's wire
|
||||
/// orientation — the resolved body orientation was already committed
|
||||
/// identically by <c>CommitCanonical</c> (retail's teleport branch does not
|
||||
/// independently reorient the mover), so re-deriving it here would only add
|
||||
/// a second copy of the same source of truth.
|
||||
/// </summary>
|
||||
internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlacement
|
||||
{
|
||||
private readonly PhysicsEngine _physics;
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ILocalPlayerIdentitySource _identity;
|
||||
private readonly IRuntimeLocalPlayerControllerSource _controller;
|
||||
private readonly ILocalPlayerPhysicsHostSource _host;
|
||||
private readonly ChaseCameraInputState _cameras;
|
||||
private readonly LiveWorldOriginState _origin;
|
||||
private readonly ILiveSpatialReconcilePhase _spatial;
|
||||
|
||||
public LocalPlayerTeleportPlacement(
|
||||
PhysicsEngine physics,
|
||||
LiveEntityRuntime liveEntities,
|
||||
ILocalPlayerIdentitySource identity,
|
||||
IRuntimeLocalPlayerControllerSource controller,
|
||||
ILocalPlayerPhysicsHostSource host,
|
||||
ChaseCameraInputState cameras,
|
||||
LiveWorldOriginState origin,
|
||||
ILiveSpatialReconcilePhase spatial)
|
||||
{
|
||||
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
||||
_controller = controller ?? throw new ArgumentNullException(nameof(controller));
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
_cameras = cameras ?? throw new ArgumentNullException(nameof(cameras));
|
||||
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
||||
_spatial = spatial ?? throw new ArgumentNullException(nameof(spatial));
|
||||
}
|
||||
|
||||
public void Place(Vector3 position, uint cellId, Quaternion rotation)
|
||||
public void Place(Quaternion rotation)
|
||||
{
|
||||
PlayerMovementController controller = _controller.Controller
|
||||
?? throw new InvalidOperationException(
|
||||
"Teleport Place ran without the local player controller.");
|
||||
var resolved = _physics.Resolve(
|
||||
position,
|
||||
cellId,
|
||||
Vector3.Zero,
|
||||
controller.StepUpHeight);
|
||||
var snapped = new Vector3(
|
||||
resolved.Position.X,
|
||||
resolved.Position.Y,
|
||||
resolved.Position.Z);
|
||||
|
||||
uint playerGuid = _identity.ServerGuid;
|
||||
controller.SetPosition(
|
||||
snapped,
|
||||
resolved.CellId,
|
||||
CellLocalForSeed(snapped, resolved.CellId));
|
||||
|
||||
// SnapToCell owns the retail Position frame and may normalize an
|
||||
// outdoor land-cell index from the cell-local origin. Publish that
|
||||
// canonical result, not the pre-snap resolver hint, to rendering.
|
||||
if (_liveEntities.TryGetWorldEntity(
|
||||
playerGuid,
|
||||
out WorldEntity? entity))
|
||||
|
|
@ -245,12 +246,12 @@ internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlaceme
|
|||
|
||||
// Retail CPhysicsObj::enter_world installs the object in its
|
||||
// destination CObjCell before hidden scripts/particles resume.
|
||||
// The accepted Position packet has already advanced FullCellId,
|
||||
// but that wire fact alone does not move acdream's retained
|
||||
// projection out of its source/pending GPU bucket. Commit both
|
||||
// halves of the spatial move here, while portal space still owns
|
||||
// the viewport, so CPhysicsObj::update_object's cell-gated tail
|
||||
// can advance the Hidden/UnHide PES chain at retail's boundary.
|
||||
// The canonical commit has already advanced FullCellId, but that
|
||||
// fact alone does not move acdream's retained projection out of
|
||||
// its source/pending GPU bucket. Commit both halves of the
|
||||
// spatial move here, while portal space still owns the
|
||||
// viewport, so CPhysicsObj::update_object's cell-gated tail can
|
||||
// advance the Hidden/UnHide PES chain at retail's boundary.
|
||||
if (!_liveEntities.RebucketLiveEntity(playerGuid, controller.CellId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
|
|
@ -260,9 +261,13 @@ internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlaceme
|
|||
}
|
||||
|
||||
// Retail teleport_hook tail @ 0x00514ED0 clears the local target and
|
||||
// notifies every watcher that this object teleported.
|
||||
// notifies every watcher that this object teleported. The body's
|
||||
// constraint leash re-arm and orientation are already the canonical
|
||||
// commit's job (RuntimeAcceptedPositionDriveController
|
||||
// .ReconcileAndAcknowledgePortal -> PlayerMovementController
|
||||
// .CommitCanonicalTeleportFrame), so this suffix only acknowledges
|
||||
// the result into presentation.
|
||||
_host.Host?.NotifyTeleported();
|
||||
controller.SetBodyOrientation(rotation);
|
||||
|
||||
_cameras.Legacy?.Update(controller.Position, controller.Yaw);
|
||||
_cameras.Retail?.ResetViewerToPlayer(controller.Position, controller.Yaw);
|
||||
|
|
@ -276,17 +281,6 @@ internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlaceme
|
|||
$"live: teleport materialized - snapped to {controller.Position} "
|
||||
+ $"cell=0x{controller.CellId:X8}");
|
||||
}
|
||||
|
||||
private Vector3 CellLocalForSeed(Vector3 worldPosition, uint cellId)
|
||||
{
|
||||
int landblockX = (int)((cellId >> 24) & 0xFFu);
|
||||
int landblockY = (int)((cellId >> 16) & 0xFFu);
|
||||
var origin = new Vector3(
|
||||
(landblockX - _origin.CenterX) * 192f,
|
||||
(landblockY - _origin.CenterY) * 192f,
|
||||
0f);
|
||||
return worldPosition - origin;
|
||||
}
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerTeleportSession
|
||||
|
|
@ -397,11 +391,28 @@ internal sealed class LocalPlayerTeleportController
|
|||
private readonly ILocalPlayerTeleportPlacement _placement;
|
||||
private readonly ILocalPlayerTeleportSession _session;
|
||||
private readonly ILocalPlayerTeleportPresentation _presentation;
|
||||
private readonly RuntimeAcceptedPositionDriveController _acceptedPositionDrive;
|
||||
|
||||
private Vector3 _pendingPosition;
|
||||
private uint _pendingCell;
|
||||
private Quaternion _pendingRotation = Quaternion.Identity;
|
||||
private long _pendingRevealGeneration;
|
||||
private RuntimeTeleportDestination _pendingDestination;
|
||||
private bool _hasPendingDestination;
|
||||
|
||||
/// <summary>
|
||||
/// A1 review fix (2026-08-05): true once the canonical Runtime commit
|
||||
/// for THIS teleport lifetime has actually happened. See the class doc
|
||||
/// on <see cref="TryAdvancePortalCommit"/> for why this exists.
|
||||
/// </summary>
|
||||
private bool _placementCommitted;
|
||||
|
||||
/// <summary>
|
||||
/// A1 review fix: true while a <c>DeferredCell</c> park is outstanding
|
||||
/// for the local player's one possible pending operation. See
|
||||
/// <see cref="TryAdvancePortalCommit"/>.
|
||||
/// </summary>
|
||||
private bool _awaitingDeferredWake;
|
||||
|
||||
private float _holdSeconds;
|
||||
private long _lifetimeGeneration;
|
||||
private bool _disposed;
|
||||
|
|
@ -415,7 +426,8 @@ internal sealed class LocalPlayerTeleportController
|
|||
WorldRevealCoordinator worldReveal,
|
||||
ILocalPlayerTeleportPlacement placement,
|
||||
ILocalPlayerTeleportSession session,
|
||||
ILocalPlayerTeleportPresentation presentation)
|
||||
ILocalPlayerTeleportPresentation presentation,
|
||||
RuntimeAcceptedPositionDriveController acceptedPositionDrive)
|
||||
{
|
||||
_authority = authority ?? throw new ArgumentNullException(nameof(authority));
|
||||
_input = input ?? throw new ArgumentNullException(nameof(input));
|
||||
|
|
@ -426,6 +438,8 @@ internal sealed class LocalPlayerTeleportController
|
|||
_placement = placement ?? throw new ArgumentNullException(nameof(placement));
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_presentation = presentation ?? throw new ArgumentNullException(nameof(presentation));
|
||||
_acceptedPositionDrive = acceptedPositionDrive
|
||||
?? throw new ArgumentNullException(nameof(acceptedPositionDrive));
|
||||
}
|
||||
|
||||
public bool IsActive => _transit.IsTeleportActive;
|
||||
|
|
@ -488,21 +502,41 @@ internal sealed class LocalPlayerTeleportController
|
|||
|
||||
bool haveDestination = _pendingCell != 0u;
|
||||
bool originReady = !_streaming.IsRecenterPending;
|
||||
bool ready = haveDestination
|
||||
bool dataReady = haveDestination
|
||||
&& originReady
|
||||
&& _worldReveal.Evaluate(_pendingCell).IsReady;
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return;
|
||||
|
||||
if (haveDestination && !ready)
|
||||
// A1 review fix (2026-08-05, retail/architecture review): the
|
||||
// sequencer's Tunnel -> TunnelContinue transition
|
||||
// (TeleportAnimSequencer.cs:134-141) is unconditional and
|
||||
// irreversible the instant it observes `worldReady` true; by the
|
||||
// time a failed placement is discovered the stream has already left
|
||||
// Tunnel with no path back, and TeleportAnimSequencer itself is
|
||||
// untouched (stop condition 2 forbids sequencer timing changes). So
|
||||
// the boolean fed into the sequencer must never mean "the data is
|
||||
// ready" alone - it must mean "the canonical Runtime commit has
|
||||
// ALREADY happened", checked/attempted fresh every tick via
|
||||
// TryAdvancePortalCommit. This makes D-T5 row 2's "the NEXT Tick
|
||||
// re-attempts the Place edge" real: the sequencer simply never
|
||||
// leaves Tunnel while the commit keeps refusing, and by the time it
|
||||
// finally does leave Tunnel and fire Place, TryAdvancePortalCommit
|
||||
// has already made the Runtime side succeed - the Place-event
|
||||
// handler below only ever runs the presentation suffix.
|
||||
bool placementReady = dataReady && TryAdvancePortalCommit(sequence);
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return;
|
||||
|
||||
if (haveDestination && !placementReady)
|
||||
_holdSeconds += deltaSeconds;
|
||||
_presentation.SetWaitCue(
|
||||
haveDestination
|
||||
&& !ready
|
||||
&& !placementReady
|
||||
&& _worldReveal.ObserveWait(
|
||||
TimeSpan.FromSeconds(_holdSeconds)));
|
||||
|
||||
var (_, events) = _presentation.Tick(deltaSeconds, ready);
|
||||
var (_, events) = _presentation.Tick(deltaSeconds, placementReady);
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return;
|
||||
|
||||
|
|
@ -511,17 +545,32 @@ internal sealed class LocalPlayerTeleportController
|
|||
switch (teleportEvent)
|
||||
{
|
||||
case TeleportAnimEvent.Place:
|
||||
// TryAdvancePortalCommit above is the only path that
|
||||
// makes `placementReady` (and, with the REAL sequencer,
|
||||
// this event) true, so the canonical Runtime commit has
|
||||
// ALREADY succeeded by construction on that path - this
|
||||
// only runs the presentation suffix (D-T4). The
|
||||
// _placementCommitted re-check stays defensive: it is
|
||||
// the exact same shape of guard IsCurrentLifetime below
|
||||
// already applies to every other step of this case, for
|
||||
// a transit that goes stale between the gate above and
|
||||
// this line being reached.
|
||||
if (!_placementCommitted)
|
||||
return;
|
||||
// B7 review fix (2026-08-05): re-derived, not assumed -
|
||||
// if the reveal was cancelled/superseded in the window
|
||||
// between the commit above and this event being
|
||||
// processed, ObserveMaterialized below would refuse but
|
||||
// Place/the presentation suffix would already have run
|
||||
// against a reveal that is no longer current. Same
|
||||
// check TryExecuteCanonicalPortalPlacementCore itself
|
||||
// gates on; idempotent to repeat here.
|
||||
if (!_worldReveal.CanPlacePortalDestination(
|
||||
_pendingRevealGeneration,
|
||||
sequence,
|
||||
_pendingCell))
|
||||
_pendingRevealGeneration, sequence, _pendingCell))
|
||||
{
|
||||
return;
|
||||
}
|
||||
_placement.Place(
|
||||
_pendingPosition,
|
||||
_pendingCell,
|
||||
_pendingRotation);
|
||||
_placement.Place(_pendingRotation);
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return;
|
||||
_worldReveal.ObserveMaterialized(
|
||||
|
|
@ -569,6 +618,193 @@ internal sealed class LocalPlayerTeleportController
|
|||
_presentation.TickTunnel(deltaSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A1 review fix (2026-08-05): the one gate that decides whether the
|
||||
/// anim sequencer is allowed to see <c>worldReady=true</c>. Returns
|
||||
/// <see langword="true"/> ONLY once the canonical Runtime commit for
|
||||
/// THIS teleport lifetime has actually happened — never speculatively,
|
||||
/// never optimistically. Three states:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Already committed
|
||||
/// (<see cref="_placementCommitted"/>) — returns
|
||||
/// <see langword="true"/> immediately, every subsequent Tick.</description></item>
|
||||
/// <item><description>A <c>DeferredCell</c> park is outstanding
|
||||
/// (<see cref="_awaitingDeferredWake"/>) — polls
|
||||
/// <see cref="RuntimeAcceptedPositionDriveController.PendingCount"/>
|
||||
/// only to decide whether to re-attempt the Runtime call: Runtime's own
|
||||
/// Begin would just refuse a second overlapping attempt with
|
||||
/// <c>Contention</c> while a park is outstanding (the drive tracks at
|
||||
/// most one pending operation for the local player), so retrying blind
|
||||
/// would only add noise. B1 review fix (2026-08-05): once
|
||||
/// <c>PendingCount</c> returns to 0 the park is DONE, but "done" is not
|
||||
/// "committed" — the drive's own doc names a merge-time <c>Forget</c>
|
||||
/// (an ordinary ACE broadcast arriving mid-park) as the EXPECTED way a
|
||||
/// park resolves without committing, and A2's own abandon branches are
|
||||
/// a second way. The OLD code inferred commit from the empty slot
|
||||
/// alone; this now asks
|
||||
/// <see cref="RuntimeAcceptedPositionDriveController.TryConsumePortalCommit"/>,
|
||||
/// which the drive latches ONLY inside a REAL
|
||||
/// <c>ReconcileAndAcknowledgePortal</c> call, keyed to this exact
|
||||
/// reveal generation/sequence. A "no" here is NOT a failure — it just
|
||||
/// means the park ended without placing, so <see cref="_awaitingDeferredWake"/>
|
||||
/// clears and the method falls through to a fresh attempt below,
|
||||
/// safely (nothing is pending anymore).</description></item>
|
||||
/// <item><description>Neither — attempt the canonical placement fresh
|
||||
/// this tick. <c>Committed</c> latches
|
||||
/// <see cref="_placementCommitted"/>; <c>DeferredCell</c> latches
|
||||
/// <see cref="_awaitingDeferredWake"/>; every other status (Contention,
|
||||
/// Rejected, NotApplicable, or the transit no longer owning this
|
||||
/// reveal) is the D-T5 refusal shape — nothing mutates, and the SAME
|
||||
/// predicate retries automatically on the NEXT Tick, which is what
|
||||
/// makes D-T5 row 2's "the next Tick re-attempts the Place edge"
|
||||
/// mechanism real without ever touching
|
||||
/// <see cref="AcDream.Core.World.TeleportAnimSequencer"/>.</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private bool TryAdvancePortalCommit(ushort sequence)
|
||||
{
|
||||
if (_placementCommitted)
|
||||
return true;
|
||||
|
||||
if (_awaitingDeferredWake)
|
||||
{
|
||||
if (_acceptedPositionDrive.PendingCount != 0)
|
||||
return false;
|
||||
_awaitingDeferredWake = false;
|
||||
if (_acceptedPositionDrive.TryConsumePortalCommit(
|
||||
_pendingRevealGeneration, sequence))
|
||||
{
|
||||
_placementCommitted = true;
|
||||
return true;
|
||||
}
|
||||
// The park ended without placing (Forgotten, or abandoned by
|
||||
// A2's re-validation). Fall through to the fresh-attempt path
|
||||
// below in this SAME call — nothing is pending, so it is safe.
|
||||
}
|
||||
|
||||
if (!_worldReveal.CanPlacePortalDestination(
|
||||
_pendingRevealGeneration,
|
||||
sequence,
|
||||
_pendingCell))
|
||||
{
|
||||
PhysicsDiagnostics.LogTeleport(
|
||||
"REFUSED", _pendingCell, "cause=stale-reveal");
|
||||
// R8 residual fix (2026-08-05): this refusal previously only
|
||||
// logged through PhysicsDiagnostics.LogTeleport, gated by the
|
||||
// DIFFERENT ACDREAM_PROBE_TELEPORT flag — invisible under
|
||||
// ACDREAM_PROBE_LOCAL_TELEPORT, the gate the rest of this
|
||||
// route's arrival/commit lines use. No placement was attempted,
|
||||
// so there is no resolved cell/leash/autorun fact to report.
|
||||
PhysicsDiagnostics.LogLocalTeleportArrival(
|
||||
cause: "stale-reveal",
|
||||
placementStatus: "Refused",
|
||||
portalGeneration: _pendingRevealGeneration,
|
||||
teleportSequence: sequence,
|
||||
destinationCell: _pendingCell,
|
||||
resolvedCell: 0u,
|
||||
hookTailRan: false,
|
||||
leashArmed: false,
|
||||
autorunCancelled: false);
|
||||
return false;
|
||||
}
|
||||
|
||||
RuntimeAcceptedPositionExecutionStatus status =
|
||||
TryExecuteCanonicalPortalPlacementCore(sequence);
|
||||
switch (status)
|
||||
{
|
||||
case RuntimeAcceptedPositionExecutionStatus.Committed:
|
||||
_placementCommitted = true;
|
||||
return true;
|
||||
case RuntimeAcceptedPositionExecutionStatus.DeferredCell:
|
||||
_awaitingDeferredWake = true;
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3 (D-T1/D-T2): builds the producer's
|
||||
/// <see cref="RuntimePortalPlacementAuthority"/> from live transit facts
|
||||
/// and drives the canonical Runtime portal arm. No new
|
||||
/// <see cref="WorldRevealCoordinator"/> exposure is needed — the host
|
||||
/// token is RE-DERIVED through the transit owner's idempotent
|
||||
/// <c>TryRegisterHostProjection</c> (the same generation+cell returns
|
||||
/// the token <see cref="WorldRevealCoordinator.TryBeginPortal"/> already
|
||||
/// registered at Aim time; a stale generation, wrong cell, cancelled, or
|
||||
/// completed reveal refuses), which makes a superseded token unobtainable
|
||||
/// by construction.
|
||||
///
|
||||
/// <para>
|
||||
/// The destination itself is <see cref="_pendingDestination"/> — the
|
||||
/// value <see cref="AimDestination"/> captured at Aim time — and NOT a
|
||||
/// fresh <c>_transit.TryGetAcceptedTeleportDestination</c> read.
|
||||
/// <see cref="RuntimeWorldTransitState.TryBeginPortalReveal"/> atomically
|
||||
/// CONSUMES the transit's one accepted-destination slot the instant Aim
|
||||
/// claims the reveal generation (it clears
|
||||
/// <c>_hasAcceptedDestination</c> so a stale destination can never be
|
||||
/// re-claimed by a later portal) — by Place time that slot is already
|
||||
/// empty, so re-querying it here always fails. This mirrors why
|
||||
/// <see cref="_pendingCell"/>/<see cref="_pendingRotation"/>/
|
||||
/// <see cref="_pendingRevealGeneration"/> are themselves Aim-time
|
||||
/// snapshots rather than live transit reads.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// A9 review fix (2026-08-05): <paramref name="sequence"/> is
|
||||
/// <see cref="_pendingDestination"/>'s OWN
|
||||
/// <see cref="RuntimeTeleportDestination.TeleportSequence"/>, not the
|
||||
/// transit's separately-tracked <c>ActiveTeleportSequence</c> the caller
|
||||
/// otherwise threads through — one source for the fact this method's
|
||||
/// authority carries, asserted equal to the caller's copy so the two
|
||||
/// can never silently diverge.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private RuntimeAcceptedPositionExecutionStatus
|
||||
TryExecuteCanonicalPortalPlacementCore(ushort sequence)
|
||||
{
|
||||
System.Diagnostics.Debug.Assert(
|
||||
!_hasPendingDestination
|
||||
|| _pendingDestination.TeleportSequence == sequence,
|
||||
"The transit's active sequence and the Aim-time destination's "
|
||||
+ "own sequence must never diverge (A9).");
|
||||
if (!_hasPendingDestination
|
||||
|| !_transit.TryRegisterHostProjection(
|
||||
_pendingRevealGeneration,
|
||||
_pendingCell,
|
||||
out RuntimeWorldHostProjectionToken hostToken))
|
||||
{
|
||||
PhysicsDiagnostics.LogTeleport(
|
||||
"REFUSED", _pendingCell, "cause=host-token-unavailable");
|
||||
// R8 residual fix (2026-08-05): same rationale as the
|
||||
// stale-reveal refusal above — route through
|
||||
// LogLocalTeleportArrival too, so ACDREAM_PROBE_LOCAL_TELEPORT
|
||||
// alone is enough to see every App-side refusal cause.
|
||||
PhysicsDiagnostics.LogLocalTeleportArrival(
|
||||
cause: "host-token-unavailable",
|
||||
placementStatus: "Refused",
|
||||
portalGeneration: _pendingRevealGeneration,
|
||||
teleportSequence: sequence,
|
||||
destinationCell: _pendingCell,
|
||||
resolvedCell: 0u,
|
||||
hookTailRan: false,
|
||||
leashArmed: false,
|
||||
autorunCancelled: false);
|
||||
return RuntimeAcceptedPositionExecutionStatus.Rejected;
|
||||
}
|
||||
|
||||
RuntimeTeleportDestination destination = _pendingDestination;
|
||||
var portal = new RuntimePortalPlacementAuthority(
|
||||
Present: true,
|
||||
RevealGeneration: _pendingRevealGeneration,
|
||||
TeleportSequence: destination.TeleportSequence,
|
||||
Projection: hostToken);
|
||||
return _acceptedPositionDrive.TryExecuteAcceptedPortalArrival(
|
||||
destination,
|
||||
portal);
|
||||
}
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
|
@ -695,7 +931,6 @@ internal sealed class LocalPlayerTeleportController
|
|||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return false;
|
||||
|
||||
Vector3 worldPosition;
|
||||
if (transition.ChangesStreamingCenter)
|
||||
{
|
||||
bool isSealedDungeon = _streaming.IsSealedDungeon(
|
||||
|
|
@ -709,19 +944,24 @@ internal sealed class LocalPlayerTeleportController
|
|||
isSealedDungeon);
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return false;
|
||||
worldPosition = new Vector3(
|
||||
position.Frame.Origin.X,
|
||||
position.Frame.Origin.Y,
|
||||
position.Frame.Origin.Z);
|
||||
}
|
||||
else
|
||||
{
|
||||
worldPosition = translated;
|
||||
}
|
||||
|
||||
// C4 route 3: the App-frame-translated `translated`/`worldPosition`
|
||||
// vector is no longer carried past this point — the canonical
|
||||
// portal arm resolves the placement through Runtime's OWN world
|
||||
// frame (resolveWorldOffsetFromRuntimeFrame: true), using the
|
||||
// cell-local `destination` Position captured HERE rather than an
|
||||
// App-translated snapshot (trap T4). It must be captured here and
|
||||
// NOT re-read from the transit at the Place edge:
|
||||
// _worldReveal.TryBeginPortal (above) drives
|
||||
// RuntimeWorldTransitState.TryBeginPortalReveal, which atomically
|
||||
// CONSUMES the transit's one accepted-destination slot the instant
|
||||
// it claims this reveal generation — a later
|
||||
// TryGetAcceptedTeleportDestination call always finds it empty.
|
||||
_pendingRotation = position.Frame.Orientation;
|
||||
_pendingPosition = worldPosition;
|
||||
_pendingCell = position.ObjCellId;
|
||||
_pendingDestination = destination;
|
||||
_hasPendingDestination = true;
|
||||
_holdSeconds = 0f;
|
||||
PhysicsDiagnostics.LogTeleport(
|
||||
"AIM",
|
||||
|
|
@ -739,10 +979,13 @@ internal sealed class LocalPlayerTeleportController
|
|||
{
|
||||
long generation = checked(++_lifetimeGeneration);
|
||||
|
||||
_pendingPosition = default;
|
||||
_pendingCell = 0u;
|
||||
_pendingRotation = Quaternion.Identity;
|
||||
_pendingRevealGeneration = 0;
|
||||
_pendingDestination = default;
|
||||
_hasPendingDestination = false;
|
||||
_placementCommitted = false;
|
||||
_awaitingDeferredWake = false;
|
||||
_holdSeconds = 0f;
|
||||
|
||||
_streaming.ResetRecenter(clearSession);
|
||||
|
|
|
|||
|
|
@ -102,7 +102,22 @@ internal sealed class RuntimePlacementPresentationSink
|
|||
projection.Token.Portal,
|
||||
projection.Token.ExactCellId))
|
||||
{
|
||||
return false;
|
||||
// B2 review fix (2026-08-05): acknowledge-and-ignore, same shape
|
||||
// as Discard/ExecutorCompleted/WithdrawalRestored above. A Place
|
||||
// whose portal authority went stale (the transit ended or was
|
||||
// superseded WHILE a DeferredCell park sat outstanding — the
|
||||
// residual A1's readiness-hold does not close, since it only
|
||||
// protects the ORDINARY in-flight case) must not be left
|
||||
// refused at the FIFO head: RuntimePlacementProjectionSubscription
|
||||
// .OnPlacement never calls Acknowledge on a `false` return, so a
|
||||
// refused receipt wedges EVERY later entity's placement receipt
|
||||
// behind it forever. This runs SYNCHRONOUSLY at publish
|
||||
// (RuntimeAcceptedPositionDriveController's A2 re-validation, by
|
||||
// contrast, only runs downstream of a receipt this gate ALREADY
|
||||
// let through — it cannot protect this path). The canonical
|
||||
// body already committed via RetryDeferred; there is simply no
|
||||
// live presentation authority left to apply it to.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!_liveEntities.TryApplyRuntimePlacementProjection(in projection))
|
||||
|
|
|
|||
|
|
@ -1171,6 +1171,63 @@ public static class PhysicsDiagnostics
|
|||
$"[tp-probe] {point,-6} id=0x{id:X8} t={Environment.TickCount64} {extra}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3 D-T8 (2026-08-04 — TEMPORARY, strip with the rest of the
|
||||
/// physics-probe family once the connected gate is scored). One line per
|
||||
/// local-player portal-arrival attempt from
|
||||
/// <c>RuntimeAcceptedPositionDriveController.ReconcileAndAcknowledgePortal</c>
|
||||
/// — the single Runtime chokepoint both the graphical and headless hosts
|
||||
/// share, so this is dual-host parity evidence, not per-host guesswork.
|
||||
/// Initial state from <c>ACDREAM_PROBE_LOCAL_TELEPORT=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeLocalTeleportEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_LOCAL_TELEPORT") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// Which host process is running — set once at composition startup by
|
||||
/// each host's own entry point (<c>SessionPlayerComposition</c> for
|
||||
/// graphical, <c>HeadlessSessionHost</c> for headless). Runtime itself
|
||||
/// stays presentation-agnostic (Slice K); this is a diagnostics-only
|
||||
/// label so <see cref="LogLocalTeleportArrival"/> can report which
|
||||
/// process produced a given line without threading a host parameter
|
||||
/// through the drive controller's constructor.
|
||||
/// </summary>
|
||||
public static string LocalTeleportHostKind { get; set; } = "graphical";
|
||||
|
||||
/// <summary>
|
||||
/// One <c>[local-tp]</c> line: cause, host, placement status, portal
|
||||
/// generation/sequence, destination cell, resolved cell, and the three
|
||||
/// D-T8 booleans confirming the reconcile suffix actually ran
|
||||
/// (<paramref name="hookTailRan"/> = <c>CommitCanonicalTeleportFrame</c>
|
||||
/// executed, <paramref name="leashArmed"/> = the constraint leash is
|
||||
/// armed post-commit, <paramref name="autorunCancelled"/> =
|
||||
/// <c>CancelAutoRun</c> ran). Self-guards on
|
||||
/// <see cref="ProbeLocalTeleportEnabled"/>. <paramref name="cause"/> is
|
||||
/// always <c>"portal"</c> today — ACE's recall/admin teleports arrive as
|
||||
/// the identical TeleportAdvanced Position and are indistinguishable
|
||||
/// from a doorway portal at this layer; the parameter exists so a future
|
||||
/// wire-level cause signal has somewhere to land without a probe
|
||||
/// signature change.
|
||||
/// </summary>
|
||||
public static void LogLocalTeleportArrival(
|
||||
string cause,
|
||||
string placementStatus,
|
||||
long portalGeneration,
|
||||
ushort teleportSequence,
|
||||
uint destinationCell,
|
||||
uint resolvedCell,
|
||||
bool hookTailRan,
|
||||
bool leashArmed,
|
||||
bool autorunCancelled)
|
||||
{
|
||||
if (!ProbeLocalTeleportEnabled) return;
|
||||
string hookTailText = hookTailRan ? "ran" : "skipped";
|
||||
string leashText = leashArmed ? "armed" : "unarmed";
|
||||
string autorunText = autorunCancelled ? "cancelled" : "unchanged";
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[local-tp] cause={cause} host={LocalTeleportHostKind} status={placementStatus} gen={portalGeneration} seq={teleportSequence} dest=0x{destinationCell:X8} resolved=0x{resolvedCell:X8} hookTail={hookTailText} leash={leashText} autorun={autorunText}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A6.P3 issue #98 step-walk investigation (2026-05-23). When true,
|
||||
/// emits one <c>[step-walk]</c> line at selected points in the transition
|
||||
|
|
|
|||
|
|
@ -97,16 +97,26 @@ internal sealed class HeadlessRuntimePlacementProjectionSink
|
|||
if (projection.Kind is not RuntimePlacementProjectionKind.Place)
|
||||
return false;
|
||||
|
||||
return record.PositionAuthorityVersion
|
||||
== token.PositionAuthorityVersion
|
||||
&& record.SpatialAuthorityVersion
|
||||
== token.SpatialAuthorityVersion
|
||||
&& record.PlacementCommitVersion
|
||||
== token.PlacementCommitVersion
|
||||
&& record.FullCellId == token.ExactCellId
|
||||
&& _runtime.TransitOwner.IsCurrentPlacementAuthority(
|
||||
if (record.PositionAuthorityVersion != token.PositionAuthorityVersion
|
||||
|| record.SpatialAuthorityVersion != token.SpatialAuthorityVersion
|
||||
|| record.PlacementCommitVersion != token.PlacementCommitVersion
|
||||
|| record.FullCellId != token.ExactCellId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_runtime.TransitOwner.IsCurrentPlacementAuthority(
|
||||
token.Portal,
|
||||
token.ExactCellId);
|
||||
token.ExactCellId))
|
||||
{
|
||||
// B2 review fix (2026-08-05): acknowledge-and-ignore, the same
|
||||
// shape and reasoning as the graphical sink's identical fix
|
||||
// (RuntimePlacementPresentationSink.TryApply) — a stale portal
|
||||
// authority must not wedge the ordered FIFO for every entity.
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasValidPortalShape(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using AcDream.Headless.Credentials;
|
|||
using AcDream.Headless.Diagnostics;
|
||||
using AcDream.Headless.Policies;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Physics;
|
||||
|
|
@ -143,6 +144,16 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
private RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
|
||||
private AcDream.Core.Net.WorldSession? _currentSession;
|
||||
private HeadlessSessionWorldProjection? _worldProjection;
|
||||
/// <summary>
|
||||
/// A1/A3 review fix (2026-08-05): retained so <see cref="Tick"/> can
|
||||
/// pump <see cref="RuntimeLiveEntitySessionController.PumpPortalCompletion"/>
|
||||
/// alongside <see cref="_worldProjection"/>'s own
|
||||
/// <c>PumpFirstEntry</c> — a parked portal placement must retry on the
|
||||
/// host's own per-tick cadence rather than the completion sequence
|
||||
/// running unconditionally the instant it is first attempted. Reassigned
|
||||
/// on every reconnect exactly like <see cref="_worldProjection"/>.
|
||||
/// </summary>
|
||||
private RuntimeLiveEntitySessionController? _entities;
|
||||
/// <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
|
||||
|
|
@ -332,6 +343,12 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
// collision-generation progress and freshly accepted Creates both
|
||||
// surface here, mirroring the graphical per-frame retry phase.
|
||||
_worldProjection?.PumpFirstEntry();
|
||||
// A1/A3 review fix (2026-08-05): retry a parked portal completion
|
||||
// (see RuntimeLiveEntitySessionController.PumpPortalCompletion) on
|
||||
// the SAME per-tick cadence, after first-entry so a DeferredCell
|
||||
// wake first-entry's own pump just resolved is picked up the same
|
||||
// tick.
|
||||
_entities?.PumpPortalCompletion();
|
||||
// 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
|
||||
|
|
@ -621,6 +638,13 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
Radius: 0.48f,
|
||||
Height: 1.835f,
|
||||
RuntimeLocalPlayerShadowDisposition.ProvenShapeless));
|
||||
// D-T8 (temporary probe): labels every subsequent
|
||||
// PhysicsDiagnostics.LogLocalTeleportArrival line from THIS
|
||||
// process as headless — Runtime itself has no host-kind concept
|
||||
// (Slice K keeps it presentation-agnostic), so this is a
|
||||
// diagnostics-only label set once at composition time, not a
|
||||
// Runtime dependency.
|
||||
PhysicsDiagnostics.LocalTeleportHostKind = "headless";
|
||||
// C4 route 2: one drive controller per host, mirroring
|
||||
// _firstEntryDrive exactly — same persistent Runtime lifetime,
|
||||
// collision source, and clock.
|
||||
|
|
@ -633,7 +657,20 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
() => Runtime.PlayerIdentity.ServerGuid,
|
||||
() => Runtime.MovementOwner.Controller,
|
||||
() => Runtime.CharacterOwner.UsePositionFromServer,
|
||||
() => _currentSession);
|
||||
() => _currentSession,
|
||||
// C4 route 3: the portal arm's PlayerTeleported port needs
|
||||
// the J5.4 autorun latch owner, one level above the raw
|
||||
// controller.
|
||||
() => Runtime.MovementOwner,
|
||||
// A2/D-T2.4 review fix (2026-08-05): same wiring as the
|
||||
// graphical composition (SessionPlayerComposition.cs) — the
|
||||
// SAME idempotent query TryCompletePortal/PrepareDestination
|
||||
// themselves use.
|
||||
isPortalAuthorityCurrent: portal => Runtime.TransitOwner
|
||||
.CanPlacePortalDestination(
|
||||
portal.RevealGeneration,
|
||||
portal.TeleportSequence,
|
||||
portal.Projection.DestinationCell));
|
||||
var projection = new HeadlessSessionWorldProjection(
|
||||
Runtime,
|
||||
content,
|
||||
|
|
@ -651,6 +688,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
Runtime.Generation.Value),
|
||||
worldProjection,
|
||||
_acceptedPositionDrive);
|
||||
_entities = entities;
|
||||
var route = new LiveSessionEventRouter(
|
||||
session,
|
||||
entities.CreateSink(),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ using AcDream.Runtime.Entities;
|
|||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Physics;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.Runtime.World;
|
||||
|
||||
namespace AcDream.Headless.Hosting;
|
||||
|
||||
|
|
@ -585,9 +586,6 @@ internal sealed class HeadlessCollisionNeighborhood
|
|||
internal sealed class HeadlessSessionWorldProjection
|
||||
: IRuntimeDirectWorldProjection
|
||||
{
|
||||
private const float DefaultRadius = 0.48f;
|
||||
private const float DefaultHeight = 1.835f;
|
||||
|
||||
private readonly GameRuntime _runtime;
|
||||
private readonly IHeadlessCollisionNeighborhood _collision;
|
||||
private readonly RuntimeFirstEntryDriveController? _firstEntry;
|
||||
|
|
@ -749,90 +747,228 @@ internal sealed class HeadlessSessionWorldProjection
|
|||
controller.State = PlayerState.PortalSpace;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A1/A3 review fix (2026-08-05): true while a <c>DeferredCell</c> park
|
||||
/// from a PRIOR call to this method is outstanding for the local
|
||||
/// player's one possible pending drive operation. Mirrors
|
||||
/// <c>LocalPlayerTeleportController._awaitingDeferredWake</c> on the
|
||||
/// graphical side — avoids re-attempting
|
||||
/// <c>TryExecuteAcceptedPortalArrival</c> while parked (Runtime's own
|
||||
/// Begin would just refuse a second overlapping attempt with
|
||||
/// <c>Contention</c>) by polling
|
||||
/// <see cref="RuntimeAcceptedPositionDriveController.PendingCount"/>
|
||||
/// instead.
|
||||
/// </summary>
|
||||
private bool _awaitingPortalWake;
|
||||
|
||||
/// <summary>
|
||||
/// B3 review fix (2026-08-05): keys <see cref="_awaitingPortalWake"/> to
|
||||
/// the exact reveal it was armed for. The graphical twin
|
||||
/// (<c>_awaitingDeferredWake</c>) is naturally reset per teleport via
|
||||
/// <c>ResetTransit</c>; this class is constructed per SESSION, not per
|
||||
/// teleport, so without this a stale latch from reveal N could silently
|
||||
/// steal reveal N+1's <c>PrepareDestination</c> call into polling a park
|
||||
/// that belongs to a different, already-abandoned reveal — skipping the
|
||||
/// new reveal's placement attempt entirely.
|
||||
/// </summary>
|
||||
private long _awaitingPortalWakeGeneration;
|
||||
private ushort _awaitingPortalWakeSequence;
|
||||
|
||||
/// <summary>
|
||||
/// N3 review fix (2026-08-05): bounds how many consecutive
|
||||
/// <c>NotApplicable</c> attempts this host tolerates before treating the
|
||||
/// condition as unrecoverable. <c>NotApplicable</c> covers hydration-race
|
||||
/// transients (no canonical body yet, an active initial-Create residence
|
||||
/// still owning the record) as well as a genuinely stale reveal — unlike
|
||||
/// <c>Rejected</c>, it is not established to be permanent, and this host
|
||||
/// must survive K4's 30-session / two-hour endurance profile without a
|
||||
/// transient becoming fatal.
|
||||
/// </summary>
|
||||
private int _notApplicableRetryCount;
|
||||
private const int NotApplicableRetryBudget = 50;
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3 (D-T6): the portal-arrival placement runs through the
|
||||
/// SAME canonical Runtime portal arm the graphical host drives
|
||||
/// (<see cref="RuntimeAcceptedPositionDriveController.TryExecuteAcceptedPortalArrival"/>),
|
||||
/// retiring the duplicate Resolve/ResolvePlacement/SetPosition authority
|
||||
/// this method used to own directly (D2;
|
||||
/// docs/research/2026-08-04-c4-route-3-contract.md D-T6).
|
||||
///
|
||||
/// <para>
|
||||
/// A1/A3 review fix (2026-08-05): the first pass discarded the arm's
|
||||
/// returned status entirely (<c>_ = ...</c>) and always reported
|
||||
/// success, so <c>RuntimeLiveEntitySessionController.TryCompletePortal</c>
|
||||
/// acknowledged a materialization that never happened on ANY refusal
|
||||
/// (architecture review A3). This method now:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>throws if no drive controller was wired — a
|
||||
/// composition regression must not silently disable placement, never
|
||||
/// pretend success (A3's second finding);</description></item>
|
||||
/// <item><description>does not even ATTEMPT the placement until
|
||||
/// <see cref="_collision"/> reports the destination resident — this
|
||||
/// host's narrow collision window makes a premature attempt a
|
||||
/// guaranteed <c>DeferredCell</c>, and <see cref="_collision"/>'s own
|
||||
/// readiness IS the signal <see cref="IRuntimeDirectWorldProjection.CenterOnAcceptedForcePosition"/>'s
|
||||
/// doc names as the precondition for a park to ever resolve;</description></item>
|
||||
/// <item><description>reports <c>IsCollisionReady: false</c> — never
|
||||
/// success — for every non-<c>Committed</c> outcome, so the caller's
|
||||
/// retry loop (<c>RuntimeLiveEntitySessionController.PumpPortalCompletion</c>,
|
||||
/// A1's headless-side fix) keeps calling this method instead of the
|
||||
/// completion sequence running against an unplaced body; a genuine
|
||||
/// <c>DeferredCell</c> is therefore never an error, only a wait — and
|
||||
/// throws only for the two statuses that mean something is actually
|
||||
/// wrong (<c>Rejected</c>/<c>NotApplicable</c> — the reveal itself is
|
||||
/// stale, or the local player has no canonical body, neither of which
|
||||
/// a headless bot can recover from by waiting).</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public RuntimeDestinationReadiness PrepareDestination(
|
||||
long revealGeneration,
|
||||
RuntimeTeleportDestination destination)
|
||||
RuntimeTeleportDestination destination,
|
||||
RuntimeWorldHostProjectionToken portal)
|
||||
{
|
||||
_collision.CenterOn(destination.CellId);
|
||||
if (_runtime.EntityObjects.Entities.TryGetActive(
|
||||
destination.EntityGuid,
|
||||
out RuntimeEntityRecord record))
|
||||
if (_acceptedPositionDrive is null)
|
||||
{
|
||||
ResynchronizeLocalPlayerForPortalArrival(record);
|
||||
throw new InvalidOperationException(
|
||||
"Headless portal placement requires a wired "
|
||||
+ "RuntimeAcceptedPositionDriveController - a composition "
|
||||
+ "regression must not silently disable placement (A3).");
|
||||
}
|
||||
if (_runtime.MovementOwner.Controller is { } controller)
|
||||
|
||||
// B3: a latch armed for a DIFFERENT reveal must not be consulted
|
||||
// for this one — fall through to a fresh attempt below instead.
|
||||
if (_awaitingPortalWake
|
||||
&& (_awaitingPortalWakeGeneration != revealGeneration
|
||||
|| _awaitingPortalWakeSequence != destination.TeleportSequence))
|
||||
{
|
||||
_awaitingPortalWake = false;
|
||||
}
|
||||
|
||||
bool committed;
|
||||
if (_awaitingPortalWake)
|
||||
{
|
||||
if (_acceptedPositionDrive.PendingCount != 0)
|
||||
{
|
||||
committed = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// B1 review fix (2026-08-05): "not pending" is not
|
||||
// "committed" — the drive's own doc names a merge-time
|
||||
// Forget (an ordinary ACE broadcast arriving mid-park) as
|
||||
// the EXPECTED way a park resolves without committing.
|
||||
// TryConsumePortalCommit is the drive's OWN record of
|
||||
// whether ITS commit actually happened for this exact
|
||||
// reveal/sequence, latched only inside a real
|
||||
// ReconcileAndAcknowledgePortal call — never inferred.
|
||||
_awaitingPortalWake = false;
|
||||
committed = _acceptedPositionDrive.TryConsumePortalCommit(
|
||||
revealGeneration, destination.TeleportSequence);
|
||||
// A "no" here falls through to committed=false below; the
|
||||
// NEXT PrepareDestination call re-attempts fresh since
|
||||
// _awaitingPortalWake is now false and nothing is pending.
|
||||
}
|
||||
}
|
||||
else if (!_collision.IsReady(destination.CellId))
|
||||
{
|
||||
committed = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
var authority = new RuntimePortalPlacementAuthority(
|
||||
Present: true,
|
||||
RevealGeneration: revealGeneration,
|
||||
TeleportSequence: destination.TeleportSequence,
|
||||
Projection: portal);
|
||||
RuntimeAcceptedPositionExecutionStatus status =
|
||||
_acceptedPositionDrive.TryExecuteAcceptedPortalArrival(
|
||||
destination,
|
||||
authority);
|
||||
switch (status)
|
||||
{
|
||||
case RuntimeAcceptedPositionExecutionStatus.Committed:
|
||||
committed = true;
|
||||
_notApplicableRetryCount = 0;
|
||||
break;
|
||||
case RuntimeAcceptedPositionExecutionStatus.DeferredCell:
|
||||
_awaitingPortalWake = true;
|
||||
_awaitingPortalWakeGeneration = revealGeneration;
|
||||
_awaitingPortalWakeSequence = destination.TeleportSequence;
|
||||
committed = false;
|
||||
break;
|
||||
case RuntimeAcceptedPositionExecutionStatus.Contention:
|
||||
// Transient - some other operation still owns the
|
||||
// entity's placement token. Retried next pump; never a
|
||||
// hard error, matching the graphical arm's D-T5
|
||||
// refusal shape.
|
||||
committed = false;
|
||||
break;
|
||||
case RuntimeAcceptedPositionExecutionStatus.NotApplicable:
|
||||
// N3 review fix (2026-08-05): NotApplicable covers
|
||||
// hydration-race transients (record.PhysicsBody is
|
||||
// null, or an active initial-Create residence still
|
||||
// owns the record — RuntimeAcceptedPositionDriveController's
|
||||
// own guard) as well as a genuinely stale reveal; unlike
|
||||
// Rejected it is not established to be permanent.
|
||||
// Bounded, loud retry rather than an immediate throw —
|
||||
// this host must survive K4's 30-session/two-hour
|
||||
// endurance profile without a transient becoming fatal.
|
||||
_notApplicableRetryCount++;
|
||||
PhysicsDiagnostics.LogTeleport(
|
||||
"REFUSED",
|
||||
destination.CellId,
|
||||
$"cause=NotApplicable attempt={_notApplicableRetryCount}");
|
||||
if (_notApplicableRetryCount > NotApplicableRetryBudget)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Headless portal placement stayed NotApplicable "
|
||||
+ $"for {_notApplicableRetryCount} consecutive "
|
||||
+ "attempts (no canonical body, or an active "
|
||||
+ "initial-Create residence still owns the "
|
||||
+ "record) - exceeded the bounded retry budget.");
|
||||
}
|
||||
committed = false;
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Headless portal placement refused with "
|
||||
+ $"status={status} even though the destination's "
|
||||
+ "collision neighborhood reported ready - the "
|
||||
+ "reveal itself is stale, not recoverable by "
|
||||
+ "waiting (contract §4 item 5 forbids "
|
||||
+ "acknowledging a materialization that did not "
|
||||
+ "happen).");
|
||||
}
|
||||
}
|
||||
|
||||
if (committed && _runtime.MovementOwner.Controller is { } controller)
|
||||
controller.State = PlayerState.InWorld;
|
||||
|
||||
bool ready = _collision.IsReady(destination.CellId);
|
||||
bool indoor = (destination.CellId & 0xFFFFu) >= 0x0100u;
|
||||
return new RuntimeDestinationReadiness(
|
||||
revealGeneration,
|
||||
destination.CellId,
|
||||
indoor,
|
||||
IsUnhydratable: !ready,
|
||||
// N2 review fix (2026-08-05): hardcoded false, NOT DERIVED.
|
||||
// AD-2's "loud unhydratable-placement path" (a claim beyond
|
||||
// NumCells) is a graphical-only concept today —
|
||||
// WorldRevealReadinessBarrier's render/composite-texture domains
|
||||
// have no headless analogue, so there is no local predicate this
|
||||
// no-window host could evaluate. A genuinely unhydratable
|
||||
// destination therefore reports IsCollisionReady=false forever
|
||||
// (via the bounded DeferredCell retry above) rather than taking
|
||||
// AD-2's loud path — headless does not model unhydratable
|
||||
// claims. If headless ever gains its own resident-cell-set
|
||||
// concept, derive the real predicate here instead of leaving
|
||||
// this hardcoded.
|
||||
IsUnhydratable: false,
|
||||
RequiredRenderRadius: indoor ? 0 : 1,
|
||||
IsRenderNeighborhoodReady: true,
|
||||
AreCompositeTexturesReady: true,
|
||||
IsCollisionReady: ready);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TODO-C4 (route 3): portal-arrival re-synchronization only. The
|
||||
/// route-1/8 initial-entry hand-copy (controller construction + first
|
||||
/// resolve/placement) was deleted at C3c — the first-entry conductor's
|
||||
/// publication chain owns it — but the portal route is unflipped, so its
|
||||
/// arrival re-resolve keeps today's exact behavior against the
|
||||
/// already-published controller until C4 routes it through
|
||||
/// RuntimePortalPlacementAuthority.
|
||||
/// </summary>
|
||||
private void ResynchronizeLocalPlayerForPortalArrival(
|
||||
RuntimeEntityRecord record)
|
||||
{
|
||||
if (record.ServerGuid
|
||||
!= _runtime.PlayerIdentity.ServerGuid
|
||||
|| record.Snapshot.Position is not { } position
|
||||
|| _runtime.MovementOwner.Controller is not { } controller)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_collision.CenterOn(position.LandblockId);
|
||||
Vector3 wirePosition = new(
|
||||
position.PositionX,
|
||||
position.PositionY,
|
||||
position.PositionZ);
|
||||
Quaternion orientation = new(
|
||||
position.RotationX,
|
||||
position.RotationY,
|
||||
position.RotationZ,
|
||||
position.RotationW);
|
||||
|
||||
ResolveResult resolved =
|
||||
_runtime.EntityObjects.Physics.Engine.Resolve(
|
||||
wirePosition,
|
||||
position.LandblockId,
|
||||
Vector3.Zero,
|
||||
100f);
|
||||
ResolveResult placement =
|
||||
_runtime.EntityObjects.Physics.Engine.ResolvePlacement(
|
||||
resolved.Position,
|
||||
resolved.CellId,
|
||||
DefaultRadius,
|
||||
DefaultHeight,
|
||||
controller.StepUpHeight,
|
||||
controller.StepDownHeight,
|
||||
ObjectInfoState.IsPlayer
|
||||
| ObjectInfoState.EdgeSlide,
|
||||
record.LocalEntityId ?? 0u);
|
||||
if (placement.Ok)
|
||||
resolved = placement;
|
||||
|
||||
controller.LocalEntityId = record.LocalEntityId ?? 0u;
|
||||
controller.SetPosition(
|
||||
resolved.Position,
|
||||
resolved.CellId,
|
||||
wirePosition);
|
||||
controller.SetBodyOrientation(orientation);
|
||||
IsCollisionReady: committed);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1949,6 +1949,145 @@ public sealed class PlayerMovementController
|
|||
UpdateCellId(_body.CellPosition.ObjCellId, "force-position");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3: the controller-local half of a portal-teleport commit
|
||||
/// whose body write, cell install, and orientation already happened
|
||||
/// inside Runtime's canonical <c>RuntimeSetPositionState.CommitCanonical</c>
|
||||
/// (retail <c>CPhysicsObj::SetPositionSimple</c> @0x005162B0 with flags
|
||||
/// <c>0x1012</c>, called from <c>SmartBox::TeleportPlayer</c> @0x00453910,
|
||||
/// acclient_2013_pseudo_c.txt:284276/92528). Unlike
|
||||
/// <see cref="CommitCanonicalForcePositionFrame"/> (which the FORCE_POSITION
|
||||
/// branch's early return at @0x0045409D exempts from every
|
||||
/// <c>ConstrainTo</c>), the local TELEPORT branch of
|
||||
/// <c>SmartBox::HandleReceivedPosition</c> (@0x0045415F) DOES re-arm the
|
||||
/// leash (@0x0045418A, anchored at the received destination) and DOES
|
||||
/// zero velocity (@0x004541B4) — the inversion is deliberate, not a
|
||||
/// missed exemption; see docs/research/2026-08-04-c4-route-3-contract.md
|
||||
/// §2 Inversion A.
|
||||
///
|
||||
/// Performs every <see cref="SetPositionCore"/> duty NOT already covered
|
||||
/// by the canonical commit (P1's duty map): render-lerp anchor reset,
|
||||
/// <c>UpdateCellId</c> publication, the retail teleport_hook tail
|
||||
/// (UnStick @0x00514eee / UnConstrain @0x00514f02 / re-arm @0x0045418A),
|
||||
/// the retail StopCompletely full stop (0x00527e40, zeroes velocity and
|
||||
/// resets fwd/sidestep/turn commands so input resumes at rest), the
|
||||
/// input-edge/mouse press-edge reset, and the physics-clock reset for a
|
||||
/// fresh <c>update_object</c> boundary. TransientState (Contact/OnWalkable/
|
||||
/// Sliding/WaterContact) is deliberately NOT re-seeded here — the canonical
|
||||
/// commit's <c>PhysicsObjUpdate.CommitSetPositionContactTransition</c>
|
||||
/// already derives those bits from the SLIDE placement's OWN resolved
|
||||
/// contact result, which is more retail-faithful than the old
|
||||
/// <see cref="SetPositionCore"/>'s unconditional
|
||||
/// <c>Contact|OnWalkable|Active</c> overwrite (that overwrite could mark a
|
||||
/// portal arrival grounded even when the destination placement actually
|
||||
/// resolved airborne). <c>Active</c> is untouched because a live in-world
|
||||
/// local player already carries it; the canonical commit only sets it on
|
||||
/// entry from a celless residence, which a portal arrival never is.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// A4 review fix (2026-08-05): the two inversions this method embodies
|
||||
/// are named, retail-cited facts on the classifier's
|
||||
/// <c>RuntimeAuthoritativePositionRoute</c> — <c>ZeroVelocity</c> and
|
||||
/// <c>ConstrainPhase.AfterPositionOperation</c> — and this method must
|
||||
/// actually READ them rather than assume the LocalPlayer-teleport
|
||||
/// branch's values are the only ones that will ever reach it. Both
|
||||
/// parameters are the route's own facts, passed by the one caller
|
||||
/// (<c>RuntimeAcceptedPositionDriveController.ReconcileAndAcknowledgePortal</c>);
|
||||
/// a future classifier edit that changes either value now changes this
|
||||
/// method's behaviour instead of silently disagreeing with it.
|
||||
/// <para>
|
||||
/// Coordinator note (round-3 closeout, 2026-08-05): the two parameters
|
||||
/// are read, not hardcoded — but they are NOT equally load-bearing.
|
||||
/// <paramref name="zeroVelocity"/> is read and applied, then
|
||||
/// <see cref="StopCompletelyAtPhysicsObjectBoundary"/> runs
|
||||
/// UNCONDITIONALLY on the very next line and zeroes velocity again — so
|
||||
/// a <c>zeroVelocity: false</c> sabotage changes nothing observable
|
||||
/// here; the field is proven read but not proven DISCRIMINATING.
|
||||
/// <paramref name="rearmConstraintLeash"/> (<c>ConstrainAfterRouting</c>)
|
||||
/// has no such unconditional fallback and IS the load-bearing one —
|
||||
/// it alone decides whether the leash re-arms. Do not read this doc
|
||||
/// comment as proving both fields equally; only the leash flag is.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="runTeleportHookTail">
|
||||
/// N4 review fix (2026-08-05): before this parameter, the caller gated
|
||||
/// the ENTIRE method call on <c>route.RunsTeleportHook</c> — but retail's
|
||||
/// <c>SetPositionInternal</c> @0x00515330 does the frame/cell/stop/input-
|
||||
/// reset/clock work UNCONDITIONALLY; only retail's <c>teleport_hook</c>
|
||||
/// @0x00514ED0 (UnStick/UnConstrain/re-arm, mapped below) is itself
|
||||
/// conditional on the hook phase. Gating the whole call meant a future
|
||||
/// <see cref="AcDream.Runtime.Physics.RuntimeAuthoritativePositionRoute.TeleportHookPhase"/>
|
||||
/// of <c>None</c> would silently skip the render-root <c>UpdateCellId</c>
|
||||
/// publish too — the doorway-FLAP class. Today the portal route always
|
||||
/// sets a non-None phase, so this parameter is always <c>true</c> in
|
||||
/// production and there is no live behavior change; it exists so a
|
||||
/// future <c>None</c> phase changes only the hook tail, not the frame
|
||||
/// commit.
|
||||
/// </param>
|
||||
internal void CommitCanonicalTeleportFrame(
|
||||
bool zeroVelocity,
|
||||
bool rearmConstraintLeash,
|
||||
bool runTeleportHookTail = true)
|
||||
{
|
||||
EnsurePublishedForRuntimeOperation();
|
||||
_prevPhysicsPos = _body.Position;
|
||||
_currPhysicsPos = _body.Position;
|
||||
UpdateCellId(_body.CellPosition.ObjCellId, "teleport");
|
||||
|
||||
// Retail set_velocity(player, 0, 1) @0x004541B4 — route.ZeroVelocity.
|
||||
if (zeroVelocity)
|
||||
_body.Velocity = Vector3.Zero;
|
||||
// Retail teleport idle is a FULL stop (StopCompletely 0x00527e40):
|
||||
// resets fwd/sidestep/turn COMMANDS and zeroes velocity again so the
|
||||
// motion interpreter cannot reconstruct the pre-teleport run vector
|
||||
// the instant input resumes.
|
||||
StopCompletelyAtPhysicsObjectBoundary();
|
||||
_activeInputTurnCommand = null;
|
||||
_activeInputTurnSpeed = 0f;
|
||||
_activeInputTurnFromMouse = false;
|
||||
_activeInputSidestepCommand = null;
|
||||
_activeInputSidestepUsesRunHold = false;
|
||||
_mouseLookActive = false;
|
||||
_mouseTurnSamplePending = false;
|
||||
_mouseTurnAdjustment = 0f;
|
||||
_mouseMovementEventCandidate = false;
|
||||
_mouseMovementEventPending = false;
|
||||
|
||||
// Retail teleport_hook @0x00514ed0 tears down any active stick/leash
|
||||
// unconditionally, then HandleReceivedPosition's TELEPORT branch
|
||||
// immediately re-arms the leash anchored to the just-committed
|
||||
// position ONLY when ConstrainPhase is AfterPositionOperation
|
||||
// (Inversion A — the opposite of
|
||||
// CommitCanonicalForcePositionFrame's no-re-arm rule, itself
|
||||
// route.ConstrainPhase.None for FORCE_POSITION). N4 review fix: this
|
||||
// is the ONLY part of this method retail actually conditions on the
|
||||
// teleport-hook phase — everything above runs unconditionally.
|
||||
if (runTeleportHookTail)
|
||||
{
|
||||
PositionManager?.UnStick();
|
||||
PositionManager?.UnConstrain();
|
||||
if (rearmConstraintLeash)
|
||||
RearmConstraintLeashAtCurrentPosition();
|
||||
}
|
||||
|
||||
// Reset the edge tracker: the stop wiped the motion state, so keys
|
||||
// still physically held must re-fire as press edges on the next
|
||||
// Update (matches SetPositionCore's walking-straight-out-of-a-
|
||||
// teleport behavior while W stays held).
|
||||
_prevForwardHeld = false;
|
||||
_prevBackwardHeld = false;
|
||||
_prevStrafeLeftHeld = false;
|
||||
_prevStrafeRightHeld = false;
|
||||
_prevTurnLeftHeld = false;
|
||||
_prevTurnRightHeld = false;
|
||||
_prevRunHeld = false;
|
||||
_hasInputSnapshot = false;
|
||||
|
||||
// Reset physics clock so any subsequent update_object calls start fresh.
|
||||
_body.LastUpdateTime = 0.0;
|
||||
_objectClock.ResetForEnterWorld();
|
||||
}
|
||||
|
||||
private Vector3 ComputeRenderPosition()
|
||||
{
|
||||
float alpha = Math.Clamp(
|
||||
|
|
|
|||
|
|
@ -130,6 +130,11 @@ internal enum RuntimeAcceptedPositionExecutionStatus : byte
|
|||
/// early return (@0x0045409D) never reaches; the deleted
|
||||
/// <c>PlayerMovementController.BlipPosition</c>'s re-arm was an unbacked
|
||||
/// deviation this route retires (docs/research/2026-08-03-c4-route-2-implementation-plan.md §1b).
|
||||
/// This no-re-arm rule is scoped to FORCE_POSITION only — C4 route 3's
|
||||
/// portal arm (<see cref="TryExecuteAcceptedPortalArrival"/>) DOES re-arm,
|
||||
/// because retail's local TELEPORT branch of the same function reaches
|
||||
/// <c>ConstrainTo</c> @0x0045418A; see
|
||||
/// docs/research/2026-08-04-c4-route-3-contract.md §2 Inversion A.
|
||||
///
|
||||
/// One instance per host session route (graphical/headless), constructed
|
||||
/// once per host process and reused across reconnects exactly like
|
||||
|
|
@ -170,8 +175,26 @@ public sealed class RuntimeAcceptedPositionDriveController
|
|||
/// begin: that marker stands for a packet whose placement was never
|
||||
/// begun, so its ack is owed by the eventual re-issue's own terminal
|
||||
/// outcome, not by the marker.
|
||||
///
|
||||
/// C4 route 3: always <c>false</c> for a portal pending — the
|
||||
/// portal route's <see cref="RuntimeAuthoritativePositionRoute.SendPositionImmediately"/>
|
||||
/// is always <c>false</c> (retail's teleport branch never sends
|
||||
/// <c>AutonomousPosition</c>), so there is never an owed position
|
||||
/// event to carry.
|
||||
/// </summary>
|
||||
internal required bool PositionEventOwed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3: <see cref="RuntimePortalPlacementAuthority.Present"/>
|
||||
/// when this descriptor is the trap T7 (2026-08-04 contract) portal
|
||||
/// arm's own DeferredCell park — a SIBLING use of this same
|
||||
/// retained-operation machinery, not a repurposed force pending.
|
||||
/// <see cref="Advance"/> and <see cref="SubmitAndResolvePortal"/> are
|
||||
/// the only writers/readers that branch on it; the force funnel
|
||||
/// (<see cref="SettlePending"/>, <see cref="_newestForce"/>) never
|
||||
/// sees or produces a portal pending.
|
||||
/// </summary>
|
||||
internal RuntimePortalPlacementAuthority Portal { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -218,6 +241,40 @@ public sealed class RuntimeAcceptedPositionDriveController
|
|||
private readonly Func<bool> _usePositionFromServer;
|
||||
private readonly Func<WorldSession?> _session;
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3: the D-T3 <c>PlayerTeleported</c> port
|
||||
/// (<c>CommandInterpreter::PlayerTeleported</c> @0x006B32B0 =
|
||||
/// <c>SetAutoRun(0,1)</c> + <c>SendMovementEvent</c>) needs the J5.4
|
||||
/// autorun latch owner, which lives one level above
|
||||
/// <see cref="PlayerMovementController"/> and is not reachable from
|
||||
/// <see cref="_localController"/>. Late-bound like every other
|
||||
/// dependency here so this controller does not need to outlive a
|
||||
/// specific movement-owner instance across a reconnect.
|
||||
/// </summary>
|
||||
private readonly Func<RuntimeLocalPlayerMovementState?> _localMovementState;
|
||||
|
||||
/// <summary>
|
||||
/// A2 review fix (2026-08-05, D-T2.4): re-validates a retained portal
|
||||
/// authority against the transit owner's CURRENT reveal before either
|
||||
/// wake site in <see cref="Advance"/> acts on it. A <c>DeferredCell</c>
|
||||
/// park commits asynchronously (<c>RuntimeSetPositionState.RetryDeferred</c>,
|
||||
/// driven entirely by an unrelated collision-generation wake) — nothing
|
||||
/// in this class can prevent that body-level commit once it starts. What
|
||||
/// this predicate CAN prevent is running the reconcile/ack suffix (or a
|
||||
/// stale resubmission) against a reveal that ended or was superseded
|
||||
/// while the park was outstanding — exactly the D-T2.4 requirement this
|
||||
/// slice's first pass never implemented (architecture review A2). Wired
|
||||
/// by each host composition to
|
||||
/// <c>RuntimeWorldTransitState.CanPlacePortalDestination</c> (the SAME
|
||||
/// idempotent query <see cref="TryExecuteAcceptedPortalArrival"/>'s
|
||||
/// caller already uses at the Place edge); left <see langword="null"/>
|
||||
/// by fixtures that do not exercise the DeferredCell wake, in which case
|
||||
/// every retained portal pending is treated as still current (today's
|
||||
/// unconditional behaviour, preserved for callers that never park).
|
||||
/// </summary>
|
||||
private readonly Func<RuntimePortalPlacementAuthority, bool>?
|
||||
_isPortalAuthorityCurrent;
|
||||
|
||||
/// <summary>
|
||||
/// The drive's at-most-one in-flight placement for the local player.
|
||||
/// Round 2 unified mechanism (2026-08-03): exactly THREE members write
|
||||
|
|
@ -244,7 +301,9 @@ public sealed class RuntimeAcceptedPositionDriveController
|
|||
Func<uint> localPlayerServerGuid,
|
||||
Func<PlayerMovementController?> localController,
|
||||
Func<bool> usePositionFromServer,
|
||||
Func<WorldSession?> session)
|
||||
Func<WorldSession?> session,
|
||||
Func<RuntimeLocalPlayerMovementState?>? localMovementState = null,
|
||||
Func<RuntimePortalPlacementAuthority, bool>? isPortalAuthorityCurrent = null)
|
||||
{
|
||||
_entityObjects = entityObjects
|
||||
?? throw new ArgumentNullException(nameof(entityObjects));
|
||||
|
|
@ -262,12 +321,53 @@ public sealed class RuntimeAcceptedPositionDriveController
|
|||
_usePositionFromServer = usePositionFromServer
|
||||
?? throw new ArgumentNullException(nameof(usePositionFromServer));
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_localMovementState = localMovementState ?? (static () => null);
|
||||
_isPortalAuthorityCurrent = isPortalAuthorityCurrent;
|
||||
_entityObjects.RegisterAcceptedPositionDriveOwnership(
|
||||
() => _pending is null ? 0 : 1);
|
||||
}
|
||||
|
||||
internal int PendingCount => _pending is null ? 0 : 1;
|
||||
|
||||
/// <summary>
|
||||
/// B1 review fix (2026-08-05): the drive's own record of the LAST portal
|
||||
/// authority <see cref="ReconcileAndAcknowledgePortal"/> actually
|
||||
/// committed — set only there, so this is never an inference. Both host
|
||||
/// gates were latching "committed" from <see cref="PendingCount"/>
|
||||
/// reaching zero, but that global (force-arm-shared) slot ALSO clears on
|
||||
/// three non-committing paths (a merge-time <c>Forget</c> — the drive's
|
||||
/// own doc names this the EXPECTED outcome of a park surviving one ACE
|
||||
/// broadcast interval — and both of A2's new abandon branches), so
|
||||
/// "not pending" never implied "this specific reveal placed". Consumed
|
||||
/// exactly once per commit via <see cref="TryConsumePortalCommit"/>.
|
||||
/// </summary>
|
||||
private (long RevealGeneration, ushort TeleportSequence)? _lastCommittedPortal;
|
||||
|
||||
/// <summary>
|
||||
/// B1 review fix: the host gate's ONLY correct way to learn "did MY
|
||||
/// specific reveal commit" — never infer it from
|
||||
/// <see cref="PendingCount"/>. Returns <see langword="true"/> and
|
||||
/// consumes the fact exactly once when the drive's last portal commit
|
||||
/// matches the caller's own (revealGeneration, teleportSequence); a
|
||||
/// mismatch (nothing committed yet, a DIFFERENT reveal committed, or
|
||||
/// this generation's park was abandoned/forgotten instead) returns
|
||||
/// <see langword="false"/> without side effects, so the caller keeps
|
||||
/// retrying or falls through to a fresh attempt.
|
||||
/// </summary>
|
||||
internal bool TryConsumePortalCommit(
|
||||
long revealGeneration,
|
||||
ushort teleportSequence)
|
||||
{
|
||||
if (_lastCommittedPortal is not { } committed
|
||||
|| committed.RevealGeneration != revealGeneration
|
||||
|| committed.TeleportSequence != teleportSequence)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_lastCommittedPortal = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C3c-R1-style one-route-at-a-time latch (mirrors
|
||||
/// <see cref="RuntimeFirstEntryDriveController.AttachRoute"/>): this
|
||||
|
|
@ -308,10 +408,22 @@ public sealed class RuntimeAcceptedPositionDriveController
|
|||
/// force observation dies with it: a reconnect re-merges its own
|
||||
/// positions, and a stale observation must never survive to authorize a
|
||||
/// re-issue against a later session's record.
|
||||
/// <para>
|
||||
/// Coordinator hygiene fix (round-3 closeout, 2026-08-05): also clears
|
||||
/// <see cref="_lastCommittedPortal"/>. An unconsumed latch surviving a
|
||||
/// session reset was harmless only because the transit's own
|
||||
/// generation counter is monotonic across resets within one
|
||||
/// <c>GameRuntime</c> lifetime, so a stale entry could never match a
|
||||
/// later reveal's generation/sequence pair by construction — a
|
||||
/// correctness argument resting on an invariant this method never
|
||||
/// declared. Clearing it here lets the ledger converge to zero on
|
||||
/// every reset instead of relying on that invariant to stay true.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void AbandonPending()
|
||||
{
|
||||
_newestForce = null;
|
||||
_lastCommittedPortal = null;
|
||||
if (_pending is not { } pending)
|
||||
return;
|
||||
_pending = null;
|
||||
|
|
@ -401,6 +513,411 @@ public sealed class RuntimeAcceptedPositionDriveController
|
|||
return SubmitAndResolve(record, token, route);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3: executes the local player's portal arrival against the
|
||||
/// canonical Runtime SetPosition owner. Retail
|
||||
/// <c>SmartBox::TeleportPlayer</c> @0x00453910 =
|
||||
/// <c>CPhysicsObj::SetPositionSimple(player, dest, 1)</c> — the SAME
|
||||
/// generic primitive route 2 already routes through
|
||||
/// <see cref="TryExecuteAcceptedLocalPosition"/> — plus
|
||||
/// <c>PlayerPositionUpdated</c>. <paramref name="destination"/> must be
|
||||
/// the transit's OWN retained accepted destination
|
||||
/// (<c>RuntimeWorldTransitState.TryGetAcceptedTeleportDestination</c>),
|
||||
/// never re-derived from live per-tick timestamps: by the time the Place
|
||||
/// edge fires, the packet merged seconds ago and nothing is "advancing"
|
||||
/// anymore (docs/research/2026-08-04-c4-route-3-contract.md D-T2.2).
|
||||
/// </summary>
|
||||
internal RuntimeAcceptedPositionExecutionStatus TryExecuteAcceptedPortalArrival(
|
||||
in RuntimeTeleportDestination destination,
|
||||
in RuntimePortalPlacementAuthority portal)
|
||||
{
|
||||
if (!portal.IsValid
|
||||
|| !_entityObjects.Entities.TryGetActive(
|
||||
_localPlayerServerGuid(), out RuntimeEntityRecord record)
|
||||
|| record.PhysicsBody is null
|
||||
|| record.Key is not { } key
|
||||
// Route 1 owns an active initial-Create residence exactly like
|
||||
// route 2's equivalent guard above — the residence executor's
|
||||
// own tail action already carries any position it needs.
|
||||
|| _entityObjects.TryGetInitialCreateResidence(record, out _))
|
||||
{
|
||||
LogPortalArrivalAttempt(
|
||||
RuntimeAcceptedPositionExecutionStatus.NotApplicable,
|
||||
portal,
|
||||
resolvedCell: 0u);
|
||||
return RuntimeAcceptedPositionExecutionStatus.NotApplicable;
|
||||
}
|
||||
|
||||
RuntimeAuthoritativePositionRoute route = ClassifyPortalArrival(
|
||||
record, key, destination, _generation());
|
||||
if (!route.Accepted)
|
||||
{
|
||||
LogPortalArrivalAttempt(
|
||||
RuntimeAcceptedPositionExecutionStatus.Rejected,
|
||||
portal,
|
||||
record.FullCellId);
|
||||
return RuntimeAcceptedPositionExecutionStatus.Rejected;
|
||||
}
|
||||
|
||||
RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition;
|
||||
ulong acceptedVersion = record.PositionAuthorityVersion;
|
||||
RuntimeEntityPlacementToken token =
|
||||
setPosition.TryBeginExclusiveAuthoredPlacement(
|
||||
record,
|
||||
acceptedVersion,
|
||||
route.OperationKind,
|
||||
portal);
|
||||
if (!token.IsValid)
|
||||
{
|
||||
// Either a concurrent placement authority already owns the
|
||||
// entity, or Begin's own portal-vs-latest-cell gate refused
|
||||
// (D-T5's Begin cell-mismatch edge — a second local Position
|
||||
// merged between the offer and this Place edge). Neither is
|
||||
// staleness; the caller's D-T5 refusal handling owns what
|
||||
// happens next.
|
||||
LogPortalArrivalAttempt(
|
||||
RuntimeAcceptedPositionExecutionStatus.Contention,
|
||||
portal,
|
||||
record.FullCellId);
|
||||
return RuntimeAcceptedPositionExecutionStatus.Contention;
|
||||
}
|
||||
|
||||
return SubmitAndResolvePortal(record, token, route, portal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3: the classifier's LocalPlayer-teleport route
|
||||
/// (<see cref="RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition"/>,
|
||||
/// <c>request.Authority.TeleportAdvanced</c> branch) built from the
|
||||
/// retained destination rather than a live merge. Retail's
|
||||
/// <c>PhysicsTimestampGate.IsNewer(PreviousTeleportSequence,
|
||||
/// AcceptedTeleportSequence)</c> gate only needs to be TRUE — its exact
|
||||
/// magnitude is not read anywhere past that boolean (the classifier's
|
||||
/// resulting <see cref="RuntimeAuthoritativePositionRoute"/> for this
|
||||
/// branch does not depend on the previous stamp's value, and the drive
|
||||
/// controller's own `expectedPositionAuthorityVersion` — not this
|
||||
/// authority's — gates Begin), so a synthetic strictly-older sequence
|
||||
/// forces retail's exact branch without any second copy of the merge-time
|
||||
/// timestamp pair having to survive from offer to Place.
|
||||
/// </summary>
|
||||
private static RuntimeAuthoritativePositionRoute ClassifyPortalArrival(
|
||||
RuntimeEntityRecord record,
|
||||
RuntimeEntityKey key,
|
||||
in RuntimeTeleportDestination destination,
|
||||
RuntimeGenerationToken generation)
|
||||
{
|
||||
ushort acceptedTeleport = destination.TeleportSequence;
|
||||
ushort priorTeleport = unchecked((ushort)(acceptedTeleport - 1));
|
||||
var authority = new RuntimeAuthoritativePositionAuthority(
|
||||
generation,
|
||||
key,
|
||||
record.PositionAuthorityVersion,
|
||||
destination.PositionSequence,
|
||||
priorTeleport,
|
||||
acceptedTeleport,
|
||||
PositionTimestampDisposition.Apply);
|
||||
|
||||
bool hasAnimations = (record.Snapshot.MotionTableId
|
||||
?? record.Snapshot.Physics?.MotionTableId) is { } motionTableId
|
||||
&& motionTableId != 0u;
|
||||
|
||||
var wirePosition = new CreateObject.ServerPosition(
|
||||
destination.CellId,
|
||||
destination.Position.Frame.Origin.X,
|
||||
destination.Position.Frame.Origin.Y,
|
||||
destination.Position.Frame.Origin.Z,
|
||||
destination.Position.Frame.Orientation.W,
|
||||
destination.Position.Frame.Orientation.X,
|
||||
destination.Position.Frame.Orientation.Y,
|
||||
destination.Position.Frame.Orientation.Z);
|
||||
|
||||
var request = new RuntimeAcceptedPositionRouteRequest(
|
||||
authority,
|
||||
RuntimePositionEntityKind.LocalPlayer,
|
||||
RuntimeAcceptedPositionSource.PositionEvent,
|
||||
wirePosition,
|
||||
PlacementFrame: null,
|
||||
PositionPackVelocity: null,
|
||||
CommittedCellId: record.FullCellId,
|
||||
HasContact: false,
|
||||
PlayerDistance: 0f,
|
||||
UsePositionFromServer: false,
|
||||
hasAnimations,
|
||||
new RuntimePositionPlacementFacts(
|
||||
record.FinalPhysicsState,
|
||||
record.Snapshot.SetupTableId is not null));
|
||||
|
||||
return RuntimeAuthoritativePositionRouteClassifier
|
||||
.ClassifyAcceptedPosition(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3 (trap T7): the portal SIBLING of <see cref="SubmitAndResolve"/>
|
||||
/// — shares Begin/Submit/status handling, deliberately does NOT touch
|
||||
/// <see cref="_newestForce"/> or route through <see cref="SettlePending"/>'s
|
||||
/// force-shaped re-issue funnel. ACE sends one destination per teleport;
|
||||
/// a portal placement that fails to commit is never re-applied.
|
||||
/// </summary>
|
||||
private RuntimeAcceptedPositionExecutionStatus SubmitAndResolvePortal(
|
||||
RuntimeEntityRecord record,
|
||||
in RuntimeEntityPlacementToken token,
|
||||
in RuntimeAuthoritativePositionRoute route,
|
||||
in RuntimePortalPlacementAuthority portal)
|
||||
{
|
||||
RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition;
|
||||
RuntimeSetPositionMoverPreparationStatus status =
|
||||
setPosition.TryPrepareAndSubmitAuthoredPlacement(
|
||||
record,
|
||||
token,
|
||||
route.OperationKind,
|
||||
route.SetPositionFlags,
|
||||
_collisionSource,
|
||||
_clock.SimulationTimeSeconds,
|
||||
out RuntimeSetPositionOutcome outcome,
|
||||
portal: portal,
|
||||
resolveWorldOffsetFromRuntimeFrame: true);
|
||||
|
||||
if (status != RuntimeSetPositionMoverPreparationStatus.Prepared)
|
||||
{
|
||||
if (status.IsRetryable())
|
||||
{
|
||||
RetainPending(setPosition, new Pending
|
||||
{
|
||||
Record = record,
|
||||
Token = token,
|
||||
Route = route,
|
||||
AwaitingCommitWake = false,
|
||||
PositionEventOwed = false,
|
||||
Portal = portal,
|
||||
});
|
||||
LogPortalArrivalAttempt(
|
||||
RuntimeAcceptedPositionExecutionStatus.Contention,
|
||||
portal,
|
||||
record.FullCellId);
|
||||
return RuntimeAcceptedPositionExecutionStatus.Contention;
|
||||
}
|
||||
|
||||
CancelToken(setPosition, token);
|
||||
LogPortalArrivalAttempt(
|
||||
RuntimeAcceptedPositionExecutionStatus.Rejected,
|
||||
portal,
|
||||
record.FullCellId);
|
||||
return RuntimeAcceptedPositionExecutionStatus.Rejected;
|
||||
}
|
||||
|
||||
switch (outcome.Status)
|
||||
{
|
||||
case RuntimeSetPositionStatus.CommittedHostAcknowledgementPending:
|
||||
ReconcileAndAcknowledgePortal(record, route, portal);
|
||||
return RuntimeAcceptedPositionExecutionStatus.Committed;
|
||||
|
||||
case RuntimeSetPositionStatus.DeferredCell:
|
||||
// D-T2.4: a park should be rare (the destination was already
|
||||
// centered by the host before submit), but must never leak —
|
||||
// same drain-stale-Withdraw-then-watch shape as the force arm.
|
||||
while (setPosition.TryPeekProjection(
|
||||
out RuntimePlacementProjectionSnapshot parked)
|
||||
&& parked.Token.Entity == token.Entity
|
||||
&& parked.Kind is RuntimePlacementProjectionKind.Withdraw)
|
||||
{
|
||||
if (!setPosition.AcknowledgeProjection(parked.Token))
|
||||
break;
|
||||
}
|
||||
if (!setPosition.WatchPlacementCompletion(token))
|
||||
{
|
||||
CancelToken(setPosition, token);
|
||||
LogPortalArrivalAttempt(
|
||||
RuntimeAcceptedPositionExecutionStatus.Rejected,
|
||||
portal,
|
||||
record.FullCellId);
|
||||
return RuntimeAcceptedPositionExecutionStatus.Rejected;
|
||||
}
|
||||
RetainPending(setPosition, new Pending
|
||||
{
|
||||
Record = record,
|
||||
Token = token,
|
||||
Route = route,
|
||||
AwaitingCommitWake = true,
|
||||
PositionEventOwed = false,
|
||||
Portal = portal,
|
||||
});
|
||||
LogPortalArrivalAttempt(
|
||||
RuntimeAcceptedPositionExecutionStatus.DeferredCell,
|
||||
portal,
|
||||
record.FullCellId);
|
||||
return RuntimeAcceptedPositionExecutionStatus.DeferredCell;
|
||||
|
||||
default:
|
||||
// Rejected/Cancelled — authority moved out from under this
|
||||
// operation; the body never moved. Unlike the force arm,
|
||||
// retail's teleport branch has no unconditional ack to send,
|
||||
// so there is nothing left to do here.
|
||||
CancelToken(setPosition, token);
|
||||
LogPortalArrivalAttempt(
|
||||
RuntimeAcceptedPositionExecutionStatus.Rejected,
|
||||
portal,
|
||||
record.FullCellId);
|
||||
return RuntimeAcceptedPositionExecutionStatus.Rejected;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R8 review fix (2026-08-05): D-T8 specified "one line per
|
||||
/// portal-arrival ATTEMPT", but the first pass logged only from
|
||||
/// <see cref="ReconcileAndAcknowledgePortal"/> — reached solely on
|
||||
/// <c>Committed</c> — so every refusal was invisible under the gate's
|
||||
/// own pinned <c>ACDREAM_PROBE_LOCAL_TELEPORT</c> env var (the graphical
|
||||
/// refusal path logged under the DIFFERENT <c>ACDREAM_PROBE_TELEPORT</c>,
|
||||
/// and headless logged nothing at all). Every non-terminal/refusal exit
|
||||
/// from <see cref="TryExecuteAcceptedPortalArrival"/> and
|
||||
/// <see cref="SubmitAndResolvePortal"/> now emits through this one
|
||||
/// helper; the richer hookTail/leash/autorun facts remain
|
||||
/// <see cref="ReconcileAndAcknowledgePortal"/>'s own line on the
|
||||
/// <c>Committed</c> path, since those three booleans are meaningless
|
||||
/// before a commit.
|
||||
/// </summary>
|
||||
private static void LogPortalArrivalAttempt(
|
||||
RuntimeAcceptedPositionExecutionStatus status,
|
||||
in RuntimePortalPlacementAuthority portal,
|
||||
uint resolvedCell)
|
||||
{
|
||||
PhysicsDiagnostics.LogLocalTeleportArrival(
|
||||
cause: "portal",
|
||||
placementStatus: status.ToString(),
|
||||
portalGeneration: portal.RevealGeneration,
|
||||
teleportSequence: portal.TeleportSequence,
|
||||
destinationCell: portal.Projection.DestinationCell,
|
||||
resolvedCell: resolvedCell,
|
||||
hookTailRan: false,
|
||||
leashArmed: false,
|
||||
autorunCancelled: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A2/D-T2.4 review fix (2026-08-05): treats a portal authority as
|
||||
/// current when no re-validation predicate was wired (today's
|
||||
/// unconditional behaviour, preserved for fixtures that never park), and
|
||||
/// otherwise defers to <see cref="_isPortalAuthorityCurrent"/> — which
|
||||
/// production wires to the SAME
|
||||
/// <c>RuntimeWorldTransitState.CanPlacePortalDestination</c> query the
|
||||
/// App/headless Place edge itself uses.
|
||||
/// </summary>
|
||||
private bool IsPortalAuthorityCurrent(
|
||||
in RuntimePortalPlacementAuthority portal) =>
|
||||
_isPortalAuthorityCurrent is null || _isPortalAuthorityCurrent(portal);
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3: the committed-portal-placement controller-local
|
||||
/// reconciliation and outbound tail. Runs
|
||||
/// <see cref="PlayerMovementController.CommitCanonicalTeleportFrame"/>
|
||||
/// (the re-homed <c>SetPositionCore</c> duties, D-T3), then the
|
||||
/// <c>PlayerTeleported</c> port (<c>CommandInterpreter::PlayerTeleported</c>
|
||||
/// @0x006B32B0 = <c>SetAutoRun(0,1)</c> + <c>SendMovementEvent</c>) — two
|
||||
/// named behaviour changes versus the deleted App/Headless placement
|
||||
/// authorities: autorun now cancels on portal arrival, and exactly one
|
||||
/// movement-event refresh goes out (never an <c>AutonomousPosition</c> —
|
||||
/// the route's <c>SendPositionImmediately</c> is always false).
|
||||
///
|
||||
/// <para>
|
||||
/// A4 review fix (2026-08-05): <paramref name="route"/>'s
|
||||
/// <c>ZeroVelocity</c>/<c>ConstrainPhase</c>/<c>TeleportHookPhase</c> are
|
||||
/// now READ, not assumed — <see cref="RuntimeAuthoritativePositionRoute.RunsTeleportHook"/>
|
||||
/// gates whether the hook tail runs at all, and its
|
||||
/// <c>ZeroVelocity</c>/<see cref="RuntimeAuthoritativePositionRoute.ConstrainAfterRouting"/>
|
||||
/// drive <c>CommitCanonicalTeleportFrame</c>'s two conditional duties.
|
||||
/// The LocalPlayer-teleport branch's values are unchanged today
|
||||
/// (<c>AfterPositionOperation</c>/<c>AfterPositionOperation</c>/<c>true</c>),
|
||||
/// so this is purely a wiring correction: a future classifier edit now
|
||||
/// changes this method's behaviour instead of silently disagreeing with
|
||||
/// it, and the contract's own §8 item 11 sabotage (force the classifier
|
||||
/// onto <c>ConstrainPhase.None</c> — the leash must not re-arm) can
|
||||
/// finally fail as designed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void ReconcileAndAcknowledgePortal(
|
||||
RuntimeEntityRecord record,
|
||||
in RuntimeAuthoritativePositionRoute route,
|
||||
in RuntimePortalPlacementAuthority portal)
|
||||
{
|
||||
// B1 review fix (2026-08-05): this method is called ONLY from the
|
||||
// two sites that just observed Runtime's canonical
|
||||
// CommittedHostAcknowledgementPending outcome for THIS portal
|
||||
// authority (SubmitAndResolvePortal's first-attempt commit and
|
||||
// Advance's re-validated deferred wake) — so the commit fact is
|
||||
// true here regardless of whether the two guards below decline the
|
||||
// REST of this method's App-level suffix work. Latching it FIRST,
|
||||
// unconditionally, is what lets TryConsumePortalCommit replace the
|
||||
// unsound PendingCount==0 inference both host gates used to make.
|
||||
_lastCommittedPortal = (portal.RevealGeneration, portal.TeleportSequence);
|
||||
if (record.ServerGuid != _localPlayerServerGuid())
|
||||
return;
|
||||
if (_localController() is not { } controller)
|
||||
return;
|
||||
// N4 review fix (2026-08-05): the frame/cell/stop/input-reset/clock
|
||||
// commit runs UNCONDITIONALLY (retail's SetPositionInternal
|
||||
// @0x00515330 has no hook-phase gate); only the UnStick/UnConstrain/
|
||||
// re-arm tail inside it is conditioned on the hook phase, via
|
||||
// runTeleportHookTail. Previously this whole call was skipped when
|
||||
// RunsTeleportHook was false, which would have silently dropped the
|
||||
// render-root UpdateCellId publish too (the doorway-FLAP class) the
|
||||
// day a route ever sets TeleportHookPhase.None — today's portal
|
||||
// route always sets a non-None phase, so this is a structural fix
|
||||
// with no live behavior change yet.
|
||||
bool hookTailRan = route.RunsTeleportHook;
|
||||
controller.CommitCanonicalTeleportFrame(
|
||||
zeroVelocity: route.ZeroVelocity,
|
||||
rearmConstraintLeash: route.ConstrainAfterRouting,
|
||||
runTeleportHookTail: hookTailRan);
|
||||
bool autorunCancelled = _localMovementState()?.CancelAutoRun() ?? false;
|
||||
// R7 review fix (2026-08-05): retail CommandInterpreter::SendMovementEvent
|
||||
// @0x006B4680 (PlayerTeleported's tail-jump) gates on TWO facts — a
|
||||
// non-null raw motion state (TryGetOutboundPosition/TryGetOutboundMotion
|
||||
// already cover that) AND `autonomy_level != 0`. This call was
|
||||
// unconditional. This is route 3's OWN call site only —
|
||||
// LocalPlayerOutboundController.TrySendMovement is shared with
|
||||
// route 2's DIFFERENT retail function
|
||||
// (CommandInterpreter::SendPositionEvent) and is not touched.
|
||||
//
|
||||
// Known approximation, filed AP-144 (2026-08-05, R7 round-3 review —
|
||||
// CLAUDE.md's register rule is binding, not an implementer's call):
|
||||
// this class only has RuntimeCharacterState.UsePositionFromServer in
|
||||
// scope (`AutonomyLevel != FullAutonomyLevel(2)`, retail's
|
||||
// `autonomy_level != 2`), not the raw AutonomyLevel — so
|
||||
// `!UsePositionFromServer` sends only when AutonomyLevel==2,
|
||||
// whereas retail's actual gate (`autonomy_level != 0`) ALSO sends
|
||||
// at AutonomyLevel==1. The two agree everywhere except that one mid
|
||||
// level, currently unreachable because TrySetAutonomyLevel has zero
|
||||
// production callers. Retire by threading the raw AutonomyLevel
|
||||
// through this constructor (and both host compositions) and gating
|
||||
// on `!= 0` directly instead of reusing UsePositionFromServer.
|
||||
if (!_usePositionFromServer())
|
||||
{
|
||||
_localPlayerOutbound.TrySendMovement(
|
||||
_session(),
|
||||
controller,
|
||||
controller.CapturePresentationResult());
|
||||
}
|
||||
|
||||
// D-T8 probe (temporary): confirms the reconcile suffix actually
|
||||
// ran its three named duties on THIS commit, not just that the
|
||||
// commit was reached. R3 review fix: the leash observable is
|
||||
// ConstraintManager.IsConstrained ("has a leash"), not
|
||||
// IsFullyConstrained ("has strained past 90% of it") — the latter
|
||||
// reads false immediately after ConstrainTo re-anchors at distance
|
||||
// 0, so every committed arrival printed leash=unarmed as coded.
|
||||
PhysicsDiagnostics.LogLocalTeleportArrival(
|
||||
cause: "portal",
|
||||
placementStatus: "Committed",
|
||||
portalGeneration: portal.RevealGeneration,
|
||||
teleportSequence: portal.TeleportSequence,
|
||||
destinationCell: portal.Projection.DestinationCell,
|
||||
resolvedCell: record.FullCellId,
|
||||
hookTailRan: hookTailRan,
|
||||
leashArmed: controller.PositionManager?.Constraint?.IsConstrained
|
||||
?? false,
|
||||
autorunCancelled: autorunCancelled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Host cadence pump: resolves a parked DeferredCell operation once its
|
||||
/// destination landblock's collision generation eventually commits it
|
||||
|
|
@ -461,6 +978,42 @@ public sealed class RuntimeAcceptedPositionDriveController
|
|||
// Raced against a concurrent consumer; retry next pump.
|
||||
return;
|
||||
}
|
||||
// C4 route 3 (trap T7): a portal pending never enters the
|
||||
// force funnel — SettlePending's _newestForce re-issue
|
||||
// decision belongs to the force arm only. The deferred
|
||||
// commit's own reconciliation is the portal wake's entire
|
||||
// terminal action.
|
||||
if (pending.Portal.Present)
|
||||
{
|
||||
_pending = null;
|
||||
// A2/D-T2.4 re-validation: RetryDeferred already moved
|
||||
// the body (asynchronously, outside this class's
|
||||
// control — nothing here can prevent that). What this
|
||||
// CAN prevent is running the reconcile/ack suffix
|
||||
// against a reveal that ended or was superseded while
|
||||
// the park sat outstanding, which would otherwise
|
||||
// publish a Place receipt naming a dead portal
|
||||
// authority (architecture review A2's FIFO-wedge
|
||||
// shape).
|
||||
if (!IsPortalAuthorityCurrent(pending.Portal))
|
||||
{
|
||||
PhysicsDiagnostics.LogLocalTeleportArrival(
|
||||
cause: "portal",
|
||||
placementStatus: "AbandonedAtWake",
|
||||
portalGeneration: pending.Portal.RevealGeneration,
|
||||
teleportSequence: pending.Portal.TeleportSequence,
|
||||
destinationCell:
|
||||
pending.Portal.Projection.DestinationCell,
|
||||
resolvedCell: pending.Record.FullCellId,
|
||||
hookTailRan: false,
|
||||
leashArmed: false,
|
||||
autorunCancelled: false);
|
||||
return;
|
||||
}
|
||||
ReconcileAndAcknowledgePortal(
|
||||
pending.Record, pending.Route, pending.Portal);
|
||||
return;
|
||||
}
|
||||
// Retail order: the deferred commit's own reconciliation and
|
||||
// ack come first, THEN the funnel decides whether a newer
|
||||
// accepted force is still owed a placement (B1).
|
||||
|
|
@ -480,9 +1033,39 @@ public sealed class RuntimeAcceptedPositionDriveController
|
|||
}
|
||||
|
||||
// The watch died — most likely a subsequent accepted Position's
|
||||
// merge-time Forget. The funnel owns what happens next, including
|
||||
// this packet's still-unsent position event (retail acks whether
|
||||
// or not the placement took — see SettlePending).
|
||||
// merge-time Forget. A portal pending owes no re-issue and no
|
||||
// ack (SendPositionImmediately is always false for the portal
|
||||
// route), so it simply clears — matching the D-T5 refusal shape
|
||||
// (nothing mutates; the transit's own cancellation/supersession
|
||||
// machinery is the authority on what happens next).
|
||||
//
|
||||
// B1/N1 review fix (2026-08-05): this is the drive's own
|
||||
// documented MODAL park outcome (ACE's 5-10 Hz broadcast Forgets
|
||||
// any park surviving one interval — "the exact far-destination
|
||||
// case the park exists to serve"), not a corner case. It does
|
||||
// NOT set _lastCommittedPortal — nothing committed — so the host
|
||||
// gate's TryConsumePortalCommit correctly reports "not yet" and
|
||||
// either re-attempts fresh or converges through the transit's
|
||||
// own cancellation, instead of the old PendingCount==0
|
||||
// inference latching a false "committed".
|
||||
if (pending.Portal.Present)
|
||||
{
|
||||
_pending = null;
|
||||
PhysicsDiagnostics.LogLocalTeleportArrival(
|
||||
cause: "portal",
|
||||
placementStatus: "WatchDied",
|
||||
portalGeneration: pending.Portal.RevealGeneration,
|
||||
teleportSequence: pending.Portal.TeleportSequence,
|
||||
destinationCell: pending.Portal.Projection.DestinationCell,
|
||||
resolvedCell: pending.Record.FullCellId,
|
||||
hookTailRan: false,
|
||||
leashArmed: false,
|
||||
autorunCancelled: false);
|
||||
return;
|
||||
}
|
||||
// The funnel owns what happens next, including this packet's
|
||||
// still-unsent position event (retail acks whether or not the
|
||||
// placement took — see SettlePending).
|
||||
SettlePending(
|
||||
pending.Record,
|
||||
pending.Token,
|
||||
|
|
@ -493,14 +1076,54 @@ public sealed class RuntimeAcceptedPositionDriveController
|
|||
|
||||
if (setPosition.IsPlacementCurrent(pending.Token))
|
||||
{
|
||||
_ = SubmitAndResolve(pending.Record, pending.Token, pending.Route);
|
||||
if (pending.Portal.Present
|
||||
&& !IsPortalAuthorityCurrent(pending.Portal))
|
||||
{
|
||||
// A2/D-T2.4: unlike the AwaitingCommitWake branch above,
|
||||
// this retry has NOT submitted yet — re-validating here
|
||||
// genuinely prevents a stale commit rather than only
|
||||
// suppressing its suffix.
|
||||
_pending = null;
|
||||
CancelToken(setPosition, pending.Token);
|
||||
PhysicsDiagnostics.LogLocalTeleportArrival(
|
||||
cause: "portal",
|
||||
placementStatus: "AbandonedAtWake",
|
||||
portalGeneration: pending.Portal.RevealGeneration,
|
||||
teleportSequence: pending.Portal.TeleportSequence,
|
||||
destinationCell: pending.Portal.Projection.DestinationCell,
|
||||
resolvedCell: pending.Record.FullCellId,
|
||||
hookTailRan: false,
|
||||
leashArmed: false,
|
||||
autorunCancelled: false);
|
||||
return;
|
||||
}
|
||||
_ = pending.Portal.Present
|
||||
? SubmitAndResolvePortal(
|
||||
pending.Record, pending.Token, pending.Route, pending.Portal)
|
||||
: SubmitAndResolve(pending.Record, pending.Token, pending.Route);
|
||||
return;
|
||||
}
|
||||
|
||||
// The prepare-retry operation died the same way. (A re-issue retry
|
||||
// marker also lands here, carrying PositionEventOwed: false — its
|
||||
// packet's placement was never begun, so its ack belongs to the
|
||||
// eventual re-issue's terminal outcome.)
|
||||
// The prepare-retry operation died the same way. B1/N1: no commit,
|
||||
// no _lastCommittedPortal write — see the watch-died branch above.
|
||||
if (pending.Portal.Present)
|
||||
{
|
||||
_pending = null;
|
||||
PhysicsDiagnostics.LogLocalTeleportArrival(
|
||||
cause: "portal",
|
||||
placementStatus: "PrepareRetryLost",
|
||||
portalGeneration: pending.Portal.RevealGeneration,
|
||||
teleportSequence: pending.Portal.TeleportSequence,
|
||||
destinationCell: pending.Portal.Projection.DestinationCell,
|
||||
resolvedCell: pending.Record.FullCellId,
|
||||
hookTailRan: false,
|
||||
leashArmed: false,
|
||||
autorunCancelled: false);
|
||||
return;
|
||||
}
|
||||
// (A re-issue retry marker also lands here, carrying
|
||||
// PositionEventOwed: false — its packet's placement was never begun,
|
||||
// so its ack belongs to the eventual re-issue's terminal outcome.)
|
||||
SettlePending(
|
||||
pending.Record,
|
||||
pending.Token,
|
||||
|
|
|
|||
|
|
@ -39,9 +39,18 @@ public interface IRuntimeDirectWorldProjection
|
|||
|
||||
void BeginTeleport();
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3 (D-T6): <paramref name="portal"/> is the SAME host token
|
||||
/// <see cref="RuntimeLiveEntitySessionController.TryCompletePortal"/>
|
||||
/// just registered via <c>TryRegisterHostProjection</c> — the producer's
|
||||
/// generation/sequence/projection are all already in scope here, so no
|
||||
/// new <c>WorldRevealCoordinator</c>-style exposure is needed on this
|
||||
/// side either.
|
||||
/// </summary>
|
||||
RuntimeDestinationReadiness PrepareDestination(
|
||||
long revealGeneration,
|
||||
RuntimeTeleportDestination destination);
|
||||
RuntimeTeleportDestination destination,
|
||||
RuntimeWorldHostProjectionToken portal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -490,6 +499,43 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
acknowledgeProjection: null,
|
||||
out _);
|
||||
|
||||
/// <summary>
|
||||
/// A1/A3 review fix (2026-08-05): the generation/destination/projection
|
||||
/// of an accepted portal reveal that registered its host projection but
|
||||
/// has not yet actually placed the local player. Headless is
|
||||
/// message-driven, not per-frame — <see cref="TryCompletePortal"/> used
|
||||
/// to run the ENTIRE completion sequence (readiness ack, materialized
|
||||
/// ack, complete, LoginComplete, EndTeleport) unconditionally in one
|
||||
/// synchronous call, discarding the canonical portal arm's own status
|
||||
/// (architecture review A3). A <c>DeferredCell</c> park is a NORMAL
|
||||
/// headless outcome — <see cref="IRuntimeDirectWorldProjection.CenterOnAcceptedForcePosition"/>'s
|
||||
/// doc explains why the narrow collision window makes a park real
|
||||
/// rather than a dead end — so this field lets
|
||||
/// <see cref="PumpPortalCompletion"/> retry on the host's own per-tick
|
||||
/// cadence (<c>HeadlessSessionHost.Tick</c>) instead of either
|
||||
/// completing a materialization that never happened or throwing on
|
||||
/// every ordinary "destination not resident yet" park.
|
||||
/// </summary>
|
||||
private (long Generation,
|
||||
RuntimeTeleportDestination Destination,
|
||||
RuntimeWorldHostProjectionToken Projection)? _pendingPortalCompletion;
|
||||
|
||||
/// <summary>
|
||||
/// B4 review fix (2026-08-05): the retry count for the CURRENT
|
||||
/// <see cref="_pendingPortalCompletion"/>, reset whenever a NEW portal
|
||||
/// begins. Graphical's equivalent wait has a user-visible cue (AD-2's
|
||||
/// centered wait state) when a park runs long; headless had neither a
|
||||
/// cue, a bound, nor a log — an indefinitely stuck park (a destination
|
||||
/// landblock whose collision generation never publishes) was silent and
|
||||
/// undiagnosable. This does not make the retry fatal — K4's 30-session
|
||||
/// endurance profile must survive a legitimately slow-publishing
|
||||
/// landblock — it only makes a stuck park OBSERVABLE via periodic log
|
||||
/// lines instead of running forever in silence.
|
||||
/// </summary>
|
||||
private int _pendingPortalCompletionRetryCount;
|
||||
|
||||
private const int PendingPortalCompletionLogInterval = 100;
|
||||
|
||||
private void TryCompletePortal()
|
||||
{
|
||||
RuntimeWorldTransitState transit = _runtime.TransitOwner;
|
||||
|
|
@ -517,11 +563,39 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
projection,
|
||||
RuntimeWorldHostAcknowledgementStage.ProjectionRegistered);
|
||||
|
||||
_pendingPortalCompletion = (generation, destination, projection);
|
||||
_pendingPortalCompletionRetryCount = 0;
|
||||
TryAdvancePortalCompletion();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A1/A3 review fix: the retryable second half of
|
||||
/// <see cref="TryCompletePortal"/>. Attempts the canonical placement
|
||||
/// (via <see cref="_worldProjection"/>, which owns the drive controller)
|
||||
/// exactly once per call; if it has not committed yet, this returns
|
||||
/// having mutated nothing beyond what the attempt itself did (a
|
||||
/// DeferredCell park, safely retryable by construction — see
|
||||
/// <see cref="HeadlessSessionWorldProjection.PrepareDestination"/>'s own
|
||||
/// doc), and <see cref="PumpPortalCompletion"/> calls this again on the
|
||||
/// next host tick. Once <c>IsCollisionReady</c> comes back true — which
|
||||
/// only happens after a genuine <c>Committed</c> status — the full
|
||||
/// readiness/materialized/complete/LoginComplete/EndTeleport sequence
|
||||
/// runs exactly as before this fix, unconditionally, in one call.
|
||||
/// </summary>
|
||||
private void TryAdvancePortalCompletion()
|
||||
{
|
||||
if (_pendingPortalCompletion is not { } pending)
|
||||
return;
|
||||
(long generation, RuntimeTeleportDestination destination,
|
||||
RuntimeWorldHostProjectionToken projection) = pending;
|
||||
|
||||
RuntimeWorldTransitState transit = _runtime.TransitOwner;
|
||||
bool indoor = (destination.CellId & 0xFFFFu) >= 0x0100u;
|
||||
RuntimeDestinationReadiness readiness =
|
||||
_worldProjection?.PrepareDestination(
|
||||
generation,
|
||||
destination)
|
||||
destination,
|
||||
projection)
|
||||
?? new RuntimeDestinationReadiness(
|
||||
generation,
|
||||
destination.CellId,
|
||||
|
|
@ -531,6 +605,32 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
IsRenderNeighborhoodReady: true,
|
||||
AreCompositeTexturesReady: true,
|
||||
IsCollisionReady: true);
|
||||
if (!readiness.IsCollisionReady)
|
||||
{
|
||||
// Still parked - PrepareDestination attempted (or is waiting on
|
||||
// an outstanding DeferredCell wake) and has not committed yet.
|
||||
// Nothing acknowledged, nothing completed; PumpPortalCompletion
|
||||
// retries next tick.
|
||||
//
|
||||
// B4 review fix: periodic diagnostic so an indefinitely-stuck
|
||||
// park is observable instead of silent. Not bounded to a throw -
|
||||
// a slow-publishing landblock is a legitimate transient this
|
||||
// host must ride out (N3's lesson: don't make a transient
|
||||
// fatal).
|
||||
_pendingPortalCompletionRetryCount++;
|
||||
if (_pendingPortalCompletionRetryCount % PendingPortalCompletionLogInterval == 0)
|
||||
{
|
||||
_log(
|
||||
$"headless: portal completion still parked after "
|
||||
+ $"{_pendingPortalCompletionRetryCount} retries "
|
||||
+ $"generation={generation} cell=0x{destination.CellId:X8}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingPortalCompletion = null;
|
||||
_pendingPortalCompletionRetryCount = 0;
|
||||
|
||||
if (!transit.AcknowledgeDestinationReadiness(
|
||||
readiness))
|
||||
{
|
||||
|
|
@ -581,6 +681,14 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
+ $"cell=0x{destination.CellId:X8}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A1/A3 review fix: called from <c>HeadlessSessionHost.Tick</c>
|
||||
/// alongside <c>HeadlessSessionWorldProjection.PumpFirstEntry</c> —
|
||||
/// retries a parked portal completion on the host's own per-tick
|
||||
/// cadence. A no-op whenever nothing is pending.
|
||||
/// </summary>
|
||||
public void PumpPortalCompletion() => TryAdvancePortalCompletion();
|
||||
|
||||
private static void Acknowledge(
|
||||
RuntimeWorldTransitState transit,
|
||||
RuntimeWorldHostProjectionToken projection,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
using System.Numerics;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.Update;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Physics;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.Runtime.World;
|
||||
using DatReaderWriter.Enums;
|
||||
|
||||
namespace AcDream.App.Tests.Streaming;
|
||||
|
||||
|
|
@ -45,7 +51,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
y: 34f,
|
||||
z: 5f);
|
||||
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
destination,
|
||||
teleportTimestampAdvanced: true);
|
||||
Assert.Empty(harness.Streaming.Reservations);
|
||||
|
|
@ -61,7 +67,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
Assert.True(harness.Reveal.Snapshot.IsActive);
|
||||
Assert.Equal(RuntimePortalKind.Portal, harness.Reveal.Snapshot.Kind);
|
||||
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
destination,
|
||||
teleportTimestampAdvanced: false);
|
||||
Assert.Single(harness.Streaming.Reservations);
|
||||
|
|
@ -74,7 +80,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
harness.Mode.Controller = null;
|
||||
|
||||
harness.Controller.OnTeleportStarted(2);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 2, 7f, 8f, 9f),
|
||||
teleportTimestampAdvanced: true);
|
||||
|
||||
|
|
@ -103,7 +109,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
Assert.True(harness.Controller.IsActive);
|
||||
|
||||
harness.Mode.Controller = null;
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 14, 7f, 8f, 9f),
|
||||
teleportTimestampAdvanced: true);
|
||||
|
||||
|
|
@ -118,12 +124,25 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
Assert.Single(harness.Streaming.Reservations);
|
||||
}
|
||||
|
||||
// C4 route 3 root-caused (closed): the earlier "bare-fixture InvalidData"
|
||||
// diagnosis was WRONG - these never reached
|
||||
// RuntimeSetPositionMoverPreparer.TryBuild at all. The real defect was in
|
||||
// LocalPlayerTeleportController.TryExecuteCanonicalPortalPlacement: it
|
||||
// re-read _transit.TryGetAcceptedTeleportDestination at Place time, but
|
||||
// RuntimeWorldTransitState.TryBeginPortalReveal (driven by AimDestination
|
||||
// via WorldRevealCoordinator.TryBeginPortal) atomically CONSUMES that one
|
||||
// accepted-destination slot the instant Aim claims the reveal generation
|
||||
// - so the Place-time re-read always found it empty and refused with
|
||||
// "cause=host-token-unavailable" before ever building a mover. Fixed by
|
||||
// caching the destination at Aim time (_pendingDestination), mirroring
|
||||
// the existing _pendingCell/_pendingRotation/_pendingRevealGeneration
|
||||
// pattern in the same method. Production fix, not a fixture workaround.
|
||||
[Fact]
|
||||
public void ControllerWithdrawnAfterAim_HoldsPresentationUntilModeRebuildsIt()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Controller.OnTeleportStarted(15);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 15, 7f, 8f, 9f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Mode.Controller = null;
|
||||
|
|
@ -131,7 +150,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
|
||||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.Equal(default, harness.Placement.Position);
|
||||
Assert.False(harness.Placement.Called);
|
||||
Assert.Empty(harness.Presentation.WorldReadyValues);
|
||||
|
||||
harness.Mode.RebuildOnEnter = () =>
|
||||
|
|
@ -143,15 +162,28 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.NotNull(harness.Mode.Controller);
|
||||
Assert.Equal(new Vector3(7f, 8f, 9f), harness.Placement.Position);
|
||||
Assert.True(harness.Placement.Called);
|
||||
// A6 review fix: assert the REAL canonical body resolved the
|
||||
// offered destination (7,8,9), not just that the acknowledge-only
|
||||
// suffix ran.
|
||||
Assert.Equal(
|
||||
new Vector3(7f, 8f, 9f),
|
||||
harness.Movement.Controller!.Position);
|
||||
Assert.Equal(0x20210001u, harness.Movement.Controller.CellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameLandblockDestination_DoesNotRecenterAndKeepsTranslatedPosition()
|
||||
{
|
||||
var harness = new Harness(centerX: 0x20, centerY: 0x21);
|
||||
// 0x20210123 is an INDOOR cell (low word 0x0123 >= 0x0100) within the
|
||||
// same landblock the constructor already committed. It still needs
|
||||
// its own synthetic CellStruct - PhysicsEngine.IsSpawnCellReady
|
||||
// requires DataCache.GetCellStruct to resolve for indoor cells, and
|
||||
// a bare AddLandblock never populates one.
|
||||
harness.AddSyntheticIndoorCell(0x20210123u);
|
||||
harness.Controller.OnTeleportStarted(3);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210123u, 3, 20f, 30f, 4f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Presentation.EmitPlaceWhenReady = true;
|
||||
|
|
@ -159,21 +191,175 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.Empty(harness.Streaming.Recenters);
|
||||
Assert.Equal(new Vector3(20f, 30f, 4f), harness.Placement.Position);
|
||||
Assert.Equal(0x20210123u, harness.Placement.CellId);
|
||||
// C4 route 3: the canonical Runtime arm - not this fake - resolves
|
||||
// and commits the destination; the acknowledge-only suffix carries
|
||||
// no position/cell to capture (D-T4). The observable fact left at
|
||||
// this layer is that the committed receipt reached the suffix.
|
||||
Assert.True(harness.Placement.Called);
|
||||
// A6/R4 review fix: assert the REAL canonical body resolved the
|
||||
// offered destination, not just that the suffix ran.
|
||||
Assert.Equal(
|
||||
new Vector3(20f, 30f, 4f),
|
||||
harness.Movement.Controller!.Position);
|
||||
Assert.Equal(0x20210123u, harness.Movement.Controller.CellId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A1/R1 review fix (2026-08-05): the retail/architecture review's own
|
||||
/// required test — "refused arm -> the anim stream does not reach
|
||||
/// RevealWorldViewport/FireLoginComplete with an unplaced body." Forces
|
||||
/// a genuine Runtime-level Contention refusal (a competing operation
|
||||
/// already owns the entity's placement token, same shape as the Runtime
|
||||
/// layer's <c>PortalContention_WhenTheEntityAlreadyOwnsAnActiveOperation</c>)
|
||||
/// and drives several ticks past where the OLD unconditional-march code
|
||||
/// would have already fired LoginComplete. The player must still be
|
||||
/// standing at the origin, in portal space, with the transit still
|
||||
/// active — never released into the world unplaced.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RefusedPlace_HoldsTheStreamAndConvergesOnlyAfterContentionClears()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Controller.OnTeleportStarted(50);
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 50, 11f, 12f, 13f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Presentation.EmitPlaceWhenReady = true;
|
||||
Vector3 positionBefore = harness.Movement.Controller!.Position;
|
||||
|
||||
// A competing operation already owns the entity's placement token -
|
||||
// the canonical Runtime Begin call inside TryAdvancePortalCommit
|
||||
// refuses with Contention, exactly like the Runtime-layer
|
||||
// PortalContention_WhenTheEntityAlreadyOwnsAnActiveOperation test.
|
||||
RuntimeEntityRecord record = harness.Lifetime.Entities
|
||||
.TryGetActive(0x50000001u, out RuntimeEntityRecord active)
|
||||
? active
|
||||
: throw new InvalidOperationException("fixture entity missing");
|
||||
RuntimeEntityPlacementToken displaced = harness.Lifetime.Physics
|
||||
.SetPosition.TryBeginExclusiveAuthoredPlacement(
|
||||
record,
|
||||
record.PositionAuthorityVersion,
|
||||
RuntimeSetPositionOperationKind.LocalAuthoritative);
|
||||
Assert.True(displaced.IsValid);
|
||||
|
||||
// Several ticks, well past where the pre-fix code would already
|
||||
// have reached PlayExitSound -> WorldFadeIn -> FireLoginComplete
|
||||
// and reset the transit (the tunnel's own retail timing is 2-5s;
|
||||
// this drives 3x that in 0.1s steps).
|
||||
for (int i = 0; i < 100; i++)
|
||||
harness.Controller.Tick(0.1f);
|
||||
|
||||
Assert.False(harness.Placement.Called);
|
||||
Assert.Equal(positionBefore, harness.Movement.Controller.Position);
|
||||
Assert.True(harness.Controller.IsActive);
|
||||
Assert.False(harness.Reveal.Snapshot.Completed);
|
||||
Assert.Equal(0, harness.Session.LoginCompleteCount);
|
||||
|
||||
// Release the competing operation - the SAME `dataReady` predicate
|
||||
// that already held the stream in Tunnel now lets the commit
|
||||
// succeed on the very next tick (D-T5 row 2's "next Tick
|
||||
// re-attempts the Place edge", made real without ever touching
|
||||
// TeleportAnimSequencer).
|
||||
RuntimePlacementCancellationReceipt cancellation = harness.Lifetime
|
||||
.Physics.SetPosition.ForgetExactPlacement(displaced);
|
||||
if (cancellation.IsValid)
|
||||
harness.Lifetime.Physics.SetPosition.PublishCancellation(cancellation);
|
||||
|
||||
harness.Controller.Tick(0.1f);
|
||||
|
||||
Assert.True(harness.Placement.Called);
|
||||
Assert.Equal(
|
||||
new Vector3(11f, 12f, 13f),
|
||||
harness.Movement.Controller.Position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B1 review fix (2026-08-05): the required test #1 — a DeferredCell
|
||||
/// park killed by an ORDINARY merge-time <c>Forget</c> (not a
|
||||
/// collision-generation commit) must NOT be latched as "committed".
|
||||
/// The destination landblock is never committed
|
||||
/// (<c>CommitLandblockCollision</c> is deliberately not called), so the
|
||||
/// portal parks; an unrelated ordinary accepted Position for the same
|
||||
/// entity then Forgets the outstanding operation exactly like a normal
|
||||
/// 5-10 Hz ACE broadcast would — <c>RuntimeSetPositionState</c>'s own
|
||||
/// doc names this the EXPECTED way a far-destination park resolves
|
||||
/// without committing. Before the fix, <c>PendingCount</c> hitting 0
|
||||
/// made the host gate latch <c>_placementCommitted = true</c>, release
|
||||
/// the sequencer, and run the ENTIRE completion sequence
|
||||
/// (materialize/reveal/LoginComplete) against the unmoved body.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParkedPlace_ForgottenByOrdinaryMergeDoesNotLatchAsCommitted()
|
||||
{
|
||||
var harness = new Harness();
|
||||
const uint destinationLandblock = 0x40410000u;
|
||||
harness.Controller.OnTeleportStarted(60);
|
||||
harness.OfferDestination(
|
||||
Position(destinationLandblock | 0x0001u, 60, 21f, 22f, 23f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Presentation.EmitPlaceWhenReady = true;
|
||||
|
||||
// First tick: destinationLandblock's collision generation was never
|
||||
// committed, so the canonical arm parks DeferredCell.
|
||||
harness.Controller.Tick(0.016f);
|
||||
Assert.False(harness.Placement.Called);
|
||||
Assert.Equal(1, harness.AcceptedPositionDrive.PendingCount);
|
||||
|
||||
// An ordinary, UNRELATED accepted Position for the same entity -
|
||||
// no new teleport, just a normal broadcast - Forgets the parked
|
||||
// operation the same way ACE's 5-10 Hz cadence would.
|
||||
harness.MergeOrdinaryPosition(
|
||||
new Vector3(48f, 49f, 50f), 0x20210001u, teleportSequence: 60);
|
||||
// Forget (inside TryApplyPosition) cancels the underlying
|
||||
// RuntimeSetPositionState operation immediately, but the drive's
|
||||
// OWN _pending cache only notices on its next Advance() pump - the
|
||||
// App host's real per-frame retry lease does this; the test drives
|
||||
// it explicitly.
|
||||
harness.AcceptedPositionDrive.Advance();
|
||||
Assert.Equal(0, harness.AcceptedPositionDrive.PendingCount);
|
||||
|
||||
// Drive well past where the pre-fix inference would have latched
|
||||
// "committed" on the very next tick and then marched to
|
||||
// LoginComplete.
|
||||
for (int i = 0; i < 100; i++)
|
||||
harness.Controller.Tick(0.1f);
|
||||
|
||||
// NOTE: the body's Position/CellId are NOT asserted unchanged here.
|
||||
// The DeferredCell park's dormant stage (RuntimeSetPositionState's
|
||||
// SubmitPreparedPlacementCore deferred-commit path,
|
||||
// StageDormantCellFrame) already writes the staged destination onto
|
||||
// the body's controller while body.InWorld stays false and the
|
||||
// canonical placement has not committed - the reader-visible
|
||||
// Position/CellId moving early is that benign staging mechanism, not
|
||||
// evidence the placement committed (see the analogous headless test
|
||||
// HeadlessPortalPrepareDestinationParksThenCommitsOnCollisionGenerationWake
|
||||
// for the same finding). The discriminators below - the presentation
|
||||
// suffix never running, no materialize/reveal/LoginComplete, and the
|
||||
// controller staying active in an incomplete transit - are what
|
||||
// actually distinguish "committed" from "parked-then-forgotten".
|
||||
Assert.False(harness.Placement.Called);
|
||||
Assert.Equal(0, harness.Reveal.PortalMaterializationCount);
|
||||
Assert.Equal(0, harness.Session.LoginCompleteCount);
|
||||
Assert.True(harness.Controller.IsActive);
|
||||
Assert.False(harness.Reveal.Snapshot.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossLandblockDestination_RecentersBeforeItCanBecomeReady()
|
||||
{
|
||||
var harness = new Harness(centerX: 0x20, centerY: 0x21);
|
||||
harness.CommitLandblockCollision(0x30310000u);
|
||||
// 0x30310100 is an INDOOR cell (low word 0x0100 >= 0x0100); see the
|
||||
// AddSyntheticIndoorCell doc comment for why CommitLandblockCollision
|
||||
// alone (terrain-only) cannot make it spawn-ready.
|
||||
harness.AddSyntheticIndoorCell(0x30310100u);
|
||||
harness.Mode.Controller!.SetPosition(
|
||||
Vector3.Zero,
|
||||
0x20210001u,
|
||||
Vector3.Zero);
|
||||
harness.Controller.OnTeleportStarted(4);
|
||||
harness.Streaming.RecenterPending = true;
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x30310100u, 4, -2f, 8f, 9f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Presentation.EmitPlaceWhenReady = true;
|
||||
|
|
@ -182,11 +368,17 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
|
||||
Assert.Equal((0x30, 0x31, true), Assert.Single(harness.Streaming.Recenters));
|
||||
Assert.False(Assert.Single(harness.Presentation.WorldReadyValues));
|
||||
Assert.Equal(default, harness.Placement.Position);
|
||||
Assert.False(harness.Placement.Called);
|
||||
|
||||
harness.Streaming.RecenterPending = false;
|
||||
harness.Controller.Tick(0.016f);
|
||||
Assert.Equal(new Vector3(-2f, 8f, 9f), harness.Placement.Position);
|
||||
Assert.True(harness.Placement.Called);
|
||||
// A6/R4 review fix: assert the REAL canonical body resolved the
|
||||
// cross-landblock destination, not just that the suffix ran.
|
||||
Assert.Equal(
|
||||
new Vector3(-2f, 8f, 9f),
|
||||
harness.Movement.Controller!.Position);
|
||||
Assert.Equal(0x30310100u, harness.Movement.Controller.CellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -194,7 +386,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
{
|
||||
var harness = new Harness(worldReady: false);
|
||||
harness.Controller.OnTeleportStarted(5);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 5, 1f, 2f, 3f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Presentation.EmitPlaceWhenReady = true;
|
||||
|
|
@ -202,14 +394,14 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
harness.Controller.Tick(4.9f);
|
||||
Assert.False(Assert.Single(harness.Presentation.WorldReadyValues));
|
||||
Assert.False(harness.Presentation.WaitCueValues[^1]);
|
||||
Assert.Equal(default, harness.Placement.Position);
|
||||
Assert.False(harness.Placement.Called);
|
||||
|
||||
harness.Controller.Tick(0.1f);
|
||||
harness.Controller.Tick(30f);
|
||||
|
||||
Assert.All(harness.Presentation.WorldReadyValues, Assert.False);
|
||||
Assert.True(harness.Presentation.WaitCueValues[^1]);
|
||||
Assert.Equal(default, harness.Placement.Position);
|
||||
Assert.False(harness.Placement.Called);
|
||||
Assert.True(harness.Reveal.WaitCueShown);
|
||||
}
|
||||
|
||||
|
|
@ -219,7 +411,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
var order = new List<string>();
|
||||
var harness = new Harness(order: order);
|
||||
harness.Controller.OnTeleportStarted(8);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 8, 4f, 5f, 6f),
|
||||
teleportTimestampAdvanced: true);
|
||||
order.Clear();
|
||||
|
|
@ -236,7 +428,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
{
|
||||
var harness = new Harness();
|
||||
harness.Controller.OnTeleportStarted(81);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 81, 4f, 5f, 6f),
|
||||
teleportTimestampAdvanced: true);
|
||||
long generation = harness.Reveal.Snapshot.Generation;
|
||||
|
|
@ -245,7 +437,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
|
||||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.Equal(default, harness.Placement.Position);
|
||||
Assert.False(harness.Placement.Called);
|
||||
Assert.Equal(0, harness.Reveal.PortalMaterializationCount);
|
||||
}
|
||||
|
||||
|
|
@ -255,7 +447,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
var order = new List<string>();
|
||||
var harness = new Harness(order: order);
|
||||
harness.Controller.OnTeleportStarted(9);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 9, 4f, 5f, 6f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
|
||||
|
|
@ -281,7 +473,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
var order = new List<string>();
|
||||
var harness = new Harness(order: order);
|
||||
harness.Controller.OnTeleportStarted(91);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 91, 4f, 5f, 6f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Streaming.ReservationEnds.Clear();
|
||||
|
|
@ -300,22 +492,35 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
{
|
||||
var harness = new Harness();
|
||||
harness.Controller.OnTeleportStarted(10);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 10, 1f, 1f, 1f),
|
||||
teleportTimestampAdvanced: true);
|
||||
|
||||
harness.Controller.OnTeleportStarted(11);
|
||||
harness.Controller.Tick(30f);
|
||||
|
||||
Assert.Equal(default, harness.Placement.Position);
|
||||
Assert.False(harness.Placement.Called);
|
||||
Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
|
||||
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 11, 2f, 2f, 2f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
|
||||
harness.Controller.Tick(0.016f);
|
||||
Assert.Equal(new Vector3(2f), harness.Placement.Position);
|
||||
Assert.True(harness.Placement.Called);
|
||||
// A6 review fix: this is the specific test A6 named — the removed
|
||||
// assertion was the ONLY thing proving the SECOND destination
|
||||
// (2,2,2) placed and not the superseded first one (1,1,1).
|
||||
// Assert.True(Placement.Called) alone cannot distinguish them. X/Y
|
||||
// are exact; Z settles to the Harness's uniform terrain height (5)
|
||||
// because both candidate wire Z values (1 and 2) sit BELOW the
|
||||
// floor and the placement resolve's anti-penetration push-out is
|
||||
// instant (unlike gravity settling from ABOVE the floor, which
|
||||
// takes further physics ticks this test never runs) - X/Y alone
|
||||
// already discriminate (1,1) from (2,2) unambiguously.
|
||||
Assert.Equal(2f, harness.Movement.Controller!.Position.X);
|
||||
Assert.Equal(2f, harness.Movement.Controller.Position.Y);
|
||||
Assert.Equal(0x20210001u, harness.Movement.Controller.CellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -323,7 +528,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
{
|
||||
var harness = new Harness();
|
||||
harness.Controller.OnTeleportStarted(12);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 12, 1f, 2f, 3f),
|
||||
teleportTimestampAdvanced: true);
|
||||
|
||||
|
|
@ -359,7 +564,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
{
|
||||
var harness = new Harness();
|
||||
harness.Controller.OnTeleportStarted(20);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 20, 1f, 2f, 3f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Streaming.ReservationEnds.Clear();
|
||||
|
|
@ -379,7 +584,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
{
|
||||
var harness = new Harness();
|
||||
harness.Controller.OnTeleportStarted(30);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 30, 1f, 2f, 3f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Session.OnSend = () => harness.Controller.OnTeleportStarted(31);
|
||||
|
|
@ -435,14 +640,22 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
{
|
||||
Controller = new PlayerMovementController(new PhysicsEngine()),
|
||||
};
|
||||
controllerSlot.Controller.SetPosition(Vector3.Zero, cell, Vector3.Zero);
|
||||
var position = new Vector3(12f, 24f, 6f);
|
||||
Quaternion rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f);
|
||||
// C4 route 3: the canonical Runtime commit (RuntimeSetPositionState
|
||||
// .CommitCanonical, driven by RuntimeAcceptedPositionDriveController
|
||||
// .ReconcileAndAcknowledgePortal -> PlayerMovementController
|
||||
// .CommitCanonicalTeleportFrame in production) already resolves the
|
||||
// body/cell/orientation BEFORE this suffix runs. Reproduce that
|
||||
// pre-state directly rather than re-resolving it here - Place() no
|
||||
// longer resolves anything (D-T4).
|
||||
controllerSlot.Controller.SetPosition(position, cell, position);
|
||||
controllerSlot.Controller.SetBodyOrientation(rotation);
|
||||
var cameras = new ChaseCameraInputState
|
||||
{
|
||||
Legacy = new ChaseCamera(),
|
||||
Retail = new RetailChaseCamera(),
|
||||
};
|
||||
var origin = new LiveWorldOriginState();
|
||||
origin.SetPlaceholder(0x20, 0x21);
|
||||
var spatial = new FakeSpatialReconcile(() => new PlacementSnapshot(
|
||||
entity.Position,
|
||||
entity.ParentCellId ?? 0u,
|
||||
|
|
@ -451,18 +664,14 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
controllerSlot.Controller.CellId,
|
||||
controllerSlot.Controller.BodyOrientation));
|
||||
var placement = new LocalPlayerTeleportPlacement(
|
||||
new PhysicsEngine(),
|
||||
runtime,
|
||||
identity,
|
||||
controllerSlot,
|
||||
new LocalPlayerPhysicsHostSlot(),
|
||||
cameras,
|
||||
origin,
|
||||
spatial);
|
||||
var position = new Vector3(12f, 24f, 6f);
|
||||
Quaternion rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f);
|
||||
|
||||
placement.Place(position, cell, rotation);
|
||||
placement.Place(rotation);
|
||||
|
||||
Assert.Equal(entity.Position, controllerSlot.Controller.Position);
|
||||
Assert.Equal(entity.ParentCellId, controllerSlot.Controller.CellId);
|
||||
|
|
@ -522,13 +731,18 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
Vector3.Zero,
|
||||
sourceCell,
|
||||
Vector3.Zero);
|
||||
// C4 route 3: reproduce the canonical Runtime commit's pre-state
|
||||
// (body/cell already resolved) - Place() no longer resolves it.
|
||||
var destinationPosition = new Vector3(12f, 24f, 6f);
|
||||
controllerSlot.Controller.SetPosition(
|
||||
destinationPosition,
|
||||
destinationCell,
|
||||
destinationPosition);
|
||||
var cameras = new ChaseCameraInputState
|
||||
{
|
||||
Legacy = new ChaseCamera(),
|
||||
Retail = new RetailChaseCamera(),
|
||||
};
|
||||
var origin = new LiveWorldOriginState();
|
||||
origin.SetPlaceholder(0x30, 0x31);
|
||||
LiveEntityRecord? recordAtReconcile = null;
|
||||
var spatial = new FakeSpatialReconcile(() =>
|
||||
{
|
||||
|
|
@ -542,19 +756,14 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
controllerSlot.Controller.BodyOrientation);
|
||||
});
|
||||
var placement = new LocalPlayerTeleportPlacement(
|
||||
new PhysicsEngine(),
|
||||
runtime,
|
||||
identity,
|
||||
controllerSlot,
|
||||
new LocalPlayerPhysicsHostSlot(),
|
||||
cameras,
|
||||
origin,
|
||||
spatial);
|
||||
|
||||
placement.Place(
|
||||
new Vector3(12f, 24f, 6f),
|
||||
destinationCell,
|
||||
Quaternion.Identity);
|
||||
placement.Place(Quaternion.Identity);
|
||||
|
||||
Assert.NotNull(recordAtReconcile);
|
||||
uint resolvedDestinationCell = controllerSlot.Controller.CellId;
|
||||
|
|
@ -652,8 +861,26 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
uint ControllerCell,
|
||||
Quaternion ControllerRotation);
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3: the Place edge now drives a REAL
|
||||
/// <see cref="RuntimeAcceptedPositionDriveController"/> portal arm
|
||||
/// against a REAL (bare, session-less) local-player canonical record -
|
||||
/// the class this file's <c>LocalPlayerTeleportController</c> fake
|
||||
/// wiring cannot substitute (it is a sealed Runtime type, not an
|
||||
/// interface). <see cref="OfferDestination"/> also performs the SAME
|
||||
/// upstream merge production runs before ever offering a destination
|
||||
/// (<c>RuntimeEntityObjectLifetime.TryApplyPosition</c>, called by
|
||||
/// <c>LiveEntityNetworkUpdateController.OnPosition</c> before
|
||||
/// <c>OfferDestination</c>) so <c>BeginAcceptedPlacementCore</c>'s
|
||||
/// portal-vs-latest-cell gate sees the SAME destination the offer
|
||||
/// carries - exactly the production ordering, not a shortcut.
|
||||
/// </summary>
|
||||
private sealed class Harness
|
||||
{
|
||||
private const uint PlayerGuid = 0x50000001u;
|
||||
private const uint HomeCell = 0x20210001u;
|
||||
private ushort _mergePositionSequence = 1;
|
||||
|
||||
public readonly FakeAuthority Authority = new();
|
||||
public readonly FakeInput Input = new();
|
||||
public readonly FakeMode Mode;
|
||||
|
|
@ -664,6 +891,17 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
public readonly RuntimeWorldTransitState Transit;
|
||||
public readonly WorldRevealCoordinator Reveal;
|
||||
public readonly LocalPlayerTeleportController Controller;
|
||||
public readonly RuntimeEntityObjectLifetime Lifetime;
|
||||
public readonly RuntimeAcceptedPositionDriveController AcceptedPositionDrive;
|
||||
/// <summary>
|
||||
/// A6/R4/A5 review fix (2026-08-05): exposed so tests can assert the
|
||||
/// REAL canonical body's resolved position/cell after a committed
|
||||
/// portal placement, not just <c>Placement.Called</c> — the property
|
||||
/// that let A6's superseded-teleport test lose its destination
|
||||
/// discriminator (which of two destinations actually placed).
|
||||
/// </summary>
|
||||
public readonly RuntimeLocalPlayerMovementState Movement;
|
||||
public IPreparedCollisionSource DiagnosticCollisionSource => new UnusedCollisionSource();
|
||||
|
||||
public Harness(
|
||||
int centerX = 0x20,
|
||||
|
|
@ -688,6 +926,99 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
invalidateCompositeTextures: () => { },
|
||||
isSpawnClaimUnhydratable: _ => false,
|
||||
streaming: Streaming);
|
||||
|
||||
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
||||
var heights = new byte[81];
|
||||
Array.Fill(heights, (byte)5f);
|
||||
var heightTable = new float[256];
|
||||
for (int i = 0; i < heightTable.Length; i++)
|
||||
heightTable[i] = i;
|
||||
engine.AddLandblock(
|
||||
HomeCell & 0xFFFF0000u,
|
||||
new TerrainSurface(heights, heightTable),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
Lifetime = new RuntimeEntityObjectLifetime(engine);
|
||||
// The bare AddLandblock above only seeds the ENGINE's terrain -
|
||||
// TryPrepareAndSubmitAuthoredPlacement's DeferredCell park keys
|
||||
// off the SEPARATE collision-GENERATION ledger
|
||||
// (RuntimeSetPositionState.BeginCollisionGeneration/
|
||||
// CommitCollisionGeneration), which a bare AddLandblock never
|
||||
// touches. Commit it too, or every placement parks forever.
|
||||
Lifetime.Physics.SetPosition.BeginCollisionGeneration(
|
||||
HomeCell & 0xFFFF0000u, 1UL);
|
||||
Lifetime.Physics.SetPosition.CommitCollisionGeneration(
|
||||
HomeCell & 0xFFFF0000u, 1UL, ready: true);
|
||||
Lifetime.Physics.ObserveLocalWorldFrame(HomeCell, teleportAdvanced: false);
|
||||
Lifetime.BindEventContext(
|
||||
static () => new RuntimeGenerationToken(1UL),
|
||||
static () => 1UL);
|
||||
Movement = new RuntimeLocalPlayerMovementState();
|
||||
RuntimeLocalPlayerMovementState movement = Movement;
|
||||
var identity = new RuntimeLocalPlayerIdentityState
|
||||
{
|
||||
ServerGuid = PlayerGuid,
|
||||
};
|
||||
movement.AttachPhysicsPublication(new RuntimeLocalPlayerPhysicsPublicationState(
|
||||
Lifetime.Entities, Lifetime.Physics, movement, identity));
|
||||
Lifetime.LocalPlayerFirstEntry.BindPublication(movement.PhysicsPublication);
|
||||
Lifetime.BindLiveInputs(() => false, () => movement.Controller?.Position);
|
||||
var clock = new GameRuntimeClock();
|
||||
// C4 route 3 fixture note: RuntimeFirstEntryDriveController's
|
||||
// ctor subscribes to RuntimeEntityObjectLifetime
|
||||
// .BindInitialResidenceBeginNotification - it must exist BEFORE
|
||||
// RegisterEntityWithInitialResidence runs, or it misses the
|
||||
// notification and never learns the residence exists at all
|
||||
// (production constructs it once per host session, ahead of
|
||||
// every Create).
|
||||
var firstEntry = new RuntimeFirstEntryDriveController(
|
||||
Lifetime,
|
||||
clock,
|
||||
new UnusedCollisionSource(),
|
||||
() => PlayerMovementConstructionOptions.Fallback,
|
||||
static _ => new RuntimeLocalPlayerPhysicsActivationPreparation(
|
||||
0.48f, 1.835f, RuntimeLocalPlayerShadowDisposition.ProvenShapeless));
|
||||
// Production's OnSpawned shape: RegisterEntityWithInitialResidence
|
||||
// then ApplyAcceptedSpawn (RuntimeLiveEntitySessionController.cs).
|
||||
RuntimeEntityRegistrationResult registration =
|
||||
Lifetime.RegisterEntityWithInitialResidence(
|
||||
Spawn(PlayerGuid, HomeCell), isLocalPlayer: true);
|
||||
RuntimeEntityRecord canonical = registration.Canonical
|
||||
?? throw new InvalidOperationException(
|
||||
"fixture failed to register the local player");
|
||||
Lifetime.ApplyAcceptedSpawn(
|
||||
canonical,
|
||||
canonical.CreateIntegrationVersion,
|
||||
canonical.Snapshot,
|
||||
replaceGeneration: registration.Inbound.Disposition
|
||||
is CreateObjectTimestampDisposition.NewGeneration);
|
||||
for (int attempt = 0; attempt < 8 && firstEntry.PendingCount != 0; attempt++)
|
||||
{
|
||||
firstEntry.DriveAll();
|
||||
DrainPlacementFifo();
|
||||
}
|
||||
Assert.Equal(0, firstEntry.PendingCount);
|
||||
Assert.True(
|
||||
Lifetime.Entities.TryGetActive(PlayerGuid, out RuntimeEntityRecord seeded),
|
||||
"fixture failed to register the local player");
|
||||
Assert.False(
|
||||
Lifetime.TryGetInitialCreateResidence(seeded, out _),
|
||||
"fixture left the local player's initial-create residence open");
|
||||
|
||||
AcceptedPositionDrive = new RuntimeAcceptedPositionDriveController(
|
||||
Lifetime,
|
||||
clock,
|
||||
new UnusedCollisionSource(),
|
||||
new LocalPlayerOutboundController((_, _, _, _, _, _) => { }),
|
||||
() => new RuntimeGenerationToken(1UL),
|
||||
() => PlayerGuid,
|
||||
() => movement.Controller,
|
||||
() => false,
|
||||
() => null,
|
||||
() => movement);
|
||||
|
||||
Controller = new LocalPlayerTeleportController(
|
||||
Authority,
|
||||
Input,
|
||||
|
|
@ -697,7 +1028,258 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
Reveal,
|
||||
Placement,
|
||||
Session,
|
||||
Presentation);
|
||||
Presentation,
|
||||
AcceptedPositionDrive);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges the destination into the SAME canonical record the portal
|
||||
/// arm reads from, exactly like production's upstream merge, then
|
||||
/// offers it to the controller.
|
||||
/// </summary>
|
||||
public void OfferDestination(
|
||||
RuntimeTeleportDestination destination,
|
||||
bool teleportTimestampAdvanced)
|
||||
{
|
||||
_mergePositionSequence++;
|
||||
var update = new WorldSession.EntityPositionUpdate(
|
||||
destination.EntityGuid,
|
||||
new CreateObject.ServerPosition(
|
||||
destination.Position.ObjCellId,
|
||||
destination.Position.Frame.Origin.X,
|
||||
destination.Position.Frame.Origin.Y,
|
||||
destination.Position.Frame.Origin.Z,
|
||||
destination.Position.Frame.Orientation.W,
|
||||
destination.Position.Frame.Orientation.X,
|
||||
destination.Position.Frame.Orientation.Y,
|
||||
destination.Position.Frame.Orientation.Z),
|
||||
Velocity: null,
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: _mergePositionSequence,
|
||||
TeleportSequence: destination.TeleportSequence,
|
||||
ForcePositionSequence: 0);
|
||||
Lifetime.TryApplyPosition(
|
||||
update,
|
||||
isLocalPlayer: true,
|
||||
forcePositionRotation: Quaternion.Identity,
|
||||
currentLocalVelocity: Vector3.Zero,
|
||||
acknowledgeProjection: null,
|
||||
out _,
|
||||
out _,
|
||||
out _);
|
||||
Controller.OfferDestination(destination, teleportTimestampAdvanced);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B1 review fix test support (2026-08-05): merges an ORDINARY
|
||||
/// accepted Position for the local player — no portal offer, no
|
||||
/// transit involvement — the same
|
||||
/// <c>RuntimeEntityObjectLifetime.TryApplyPosition</c> call every
|
||||
/// real inbound ACE broadcast makes. Its doc names this the
|
||||
/// mechanism that <c>Forget</c>s any outstanding SetPosition
|
||||
/// operation for the entity, including a DeferredCell park — "the
|
||||
/// exact far-destination case the park exists to serve".
|
||||
/// </summary>
|
||||
public void MergeOrdinaryPosition(
|
||||
Vector3 position, uint cellId, ushort teleportSequence)
|
||||
{
|
||||
_mergePositionSequence++;
|
||||
var update = new WorldSession.EntityPositionUpdate(
|
||||
PlayerGuid,
|
||||
new CreateObject.ServerPosition(
|
||||
cellId,
|
||||
position.X,
|
||||
position.Y,
|
||||
position.Z,
|
||||
1f, 0f, 0f, 0f),
|
||||
Velocity: null,
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: _mergePositionSequence,
|
||||
// The SAME already-accepted teleport sequence, not a
|
||||
// regression - an ordinary ACE broadcast Apply during an
|
||||
// active teleport still carries it unchanged; only a NEW
|
||||
// portal advances it.
|
||||
TeleportSequence: teleportSequence,
|
||||
ForcePositionSequence: 0);
|
||||
Lifetime.TryApplyPosition(
|
||||
update,
|
||||
isLocalPlayer: true,
|
||||
forcePositionRotation: Quaternion.Identity,
|
||||
currentLocalVelocity: Vector3.Zero,
|
||||
acknowledgeProjection: null,
|
||||
out _,
|
||||
out _,
|
||||
out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commits a SECOND landblock's collision so a cross-landblock
|
||||
/// portal destination can resolve <c>Committed</c> instead of
|
||||
/// parking <c>DeferredCell</c> - the constructor's own home
|
||||
/// landblock is the only one committed by default.
|
||||
/// </summary>
|
||||
public void CommitLandblockCollision(uint landblockId)
|
||||
{
|
||||
var heights = new byte[81];
|
||||
Array.Fill(heights, (byte)5f);
|
||||
var heightTable = new float[256];
|
||||
for (int i = 0; i < heightTable.Length; i++)
|
||||
heightTable[i] = i;
|
||||
Lifetime.Physics.SetPosition.BeginCollisionGeneration(landblockId, 1UL);
|
||||
Lifetime.Physics.Engine.AddLandblock(
|
||||
landblockId,
|
||||
new TerrainSurface(heights, heightTable),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
Lifetime.Physics.SetPosition.CommitCollisionGeneration(
|
||||
landblockId, 1UL, ready: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a minimal synthetic indoor cell so a portal destination
|
||||
/// whose low word is >= 0x0100 can resolve <c>Committed</c> on its
|
||||
/// FIRST placement attempt instead of parking <c>DeferredCell</c>
|
||||
/// forever. <see cref="PhysicsEngine.IsSpawnCellReady"/> treats every
|
||||
/// outdoor cell (low word < 0x0100) as trivially ready, but an
|
||||
/// indoor cell requires <c>DataCache.GetCellStruct</c> to resolve a
|
||||
/// real entry - the bare fixture's <c>AddLandblock</c> calls only
|
||||
/// ever pass an empty <c>CellSurface</c> list, so without this an
|
||||
/// indoor destination cell is permanently un-spawnable regardless of
|
||||
/// how many times collision generation is committed for its
|
||||
/// landblock. Mirrors
|
||||
/// <c>RuntimeSetPositionStateTests.AddSyntheticCell</c>.
|
||||
/// </summary>
|
||||
public void AddSyntheticIndoorCell(uint envCellId)
|
||||
{
|
||||
Lifetime.Physics.DataCache.RegisterCellStructForTest(
|
||||
envCellId,
|
||||
new CellPhysics
|
||||
{
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
Portals = [new PortalInfo(0, 0, 0)],
|
||||
CellBSP = new DatReaderWriter.Types.CellBSPTree
|
||||
{
|
||||
Root = new DatReaderWriter.Types.CellBSPNode
|
||||
{
|
||||
Type = BSPNodeType.Leaf,
|
||||
},
|
||||
},
|
||||
});
|
||||
Lifetime.Physics.DataCache.CellGraph.Add(
|
||||
new AcDream.Core.World.Cells.EnvCell(
|
||||
envCellId,
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.Identity,
|
||||
Vector3.Zero,
|
||||
Vector3.One,
|
||||
Array.Empty<AcDream.Core.World.Cells.CellPortal>(),
|
||||
Array.Empty<uint>(),
|
||||
seenOutside: false,
|
||||
containmentBsp: null));
|
||||
}
|
||||
|
||||
public void DrainPlacementFifo()
|
||||
{
|
||||
while (Lifetime.Physics.SetPosition.TryPeekProjection(
|
||||
out RuntimePlacementProjectionSnapshot head))
|
||||
{
|
||||
if (!Lifetime.Physics.SetPosition.AcknowledgeProjection(head.Token))
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid, uint cell)
|
||||
{
|
||||
var position = new CreateObject.ServerPosition(
|
||||
cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
|
||||
var timestamps = new PhysicsTimestamps(
|
||||
Position: 1,
|
||||
Movement: 1,
|
||||
State: 1,
|
||||
Vector: 1,
|
||||
Teleport: 0,
|
||||
ServerControlledMove: 1,
|
||||
ForcePosition: 0,
|
||||
ObjDesc: 1,
|
||||
Instance: 1);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
Position: position,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: null,
|
||||
MotionTableId: null,
|
||||
SoundTableId: null,
|
||||
PhysicsScriptTableId: null,
|
||||
Parent: null,
|
||||
Children: null,
|
||||
Scale: null,
|
||||
Friction: null,
|
||||
Elasticity: null,
|
||||
Translucency: null,
|
||||
Velocity: null,
|
||||
Acceleration: null,
|
||||
AngularVelocity: null,
|
||||
DefaultScriptType: null,
|
||||
DefaultScriptIntensity: null,
|
||||
Timestamps: timestamps);
|
||||
return new WorldSession.EntitySpawn(
|
||||
guid,
|
||||
position,
|
||||
null,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
"teleport-controller-fixture",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
PhysicsState: physics.RawState,
|
||||
InstanceSequence: 1,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1,
|
||||
Physics: physics);
|
||||
}
|
||||
|
||||
private sealed class UnusedCollisionSource : IPreparedCollisionSource
|
||||
{
|
||||
public PreparedAssetPresence ProbeCollision(
|
||||
AcDream.Content.Pak.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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -800,22 +1382,29 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3: acknowledge-only per D-T4 - no position/cell arguments
|
||||
/// (the canonical Runtime commit already resolved them; this suffix only
|
||||
/// runs on a COMMITTED receipt, never authors one). <see cref="Called"/>
|
||||
/// stands in for the deleted <c>Position</c>/<c>CellId</c> capture: it is
|
||||
/// the fact that matters now (whether the arm committed and the Place
|
||||
/// edge acknowledged it), never a value this fake could plausibly
|
||||
/// mis-record.
|
||||
/// </summary>
|
||||
private sealed class FakePlacement : ILocalPlayerTeleportPlacement
|
||||
{
|
||||
private readonly List<string> _order;
|
||||
|
||||
public FakePlacement(List<string> order) => _order = order;
|
||||
|
||||
public Vector3 Position;
|
||||
public uint CellId;
|
||||
public bool Called;
|
||||
public Quaternion Rotation;
|
||||
public Action? OnPlace;
|
||||
|
||||
public void Place(Vector3 position, uint cellId, Quaternion rotation)
|
||||
public void Place(Quaternion rotation)
|
||||
{
|
||||
_order.Add("placement");
|
||||
Position = position;
|
||||
CellId = cellId;
|
||||
Called = true;
|
||||
Rotation = rotation;
|
||||
OnPlace?.Invoke();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -463,8 +463,22 @@ public sealed class RuntimePlacementPresentationSinkTests
|
|||
Assert.True(replacement.IsSpatiallyVisible);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B2 review fix (2026-08-05): a Place whose portal authority is stale
|
||||
/// (the transit ended or was superseded while a park sat outstanding)
|
||||
/// must be ACKNOWLEDGED-AND-IGNORED, not refused. Before the fix this
|
||||
/// test asserted <c>TryApply</c> returns <c>false</c> for both the
|
||||
/// superseded-host and wrong-sequence cases — but
|
||||
/// <c>RuntimePlacementProjectionSubscription.OnPlacement</c> only calls
|
||||
/// <c>Acknowledge</c> when <c>TryApply</c> returns <c>true</c>; a
|
||||
/// <c>false</c> here leaves the stale receipt at the FIFO head
|
||||
/// PERMANENTLY, wedging every later entity's placement receipt behind
|
||||
/// it. The entity's world position must still never move to the stale
|
||||
/// receipt's coordinates — "ignore" means the receipt is retired
|
||||
/// without being applied, not silently accepted as real.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PortalPlace_RequiresExactCurrentTransitHostAndSequence()
|
||||
public void PortalPlace_StaleTransitHostOrSequenceIsAcknowledgedAndIgnored()
|
||||
{
|
||||
Fixture fixture = Fixture.Create();
|
||||
LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell));
|
||||
|
|
@ -491,7 +505,10 @@ public sealed class RuntimePlacementPresentationSinkTests
|
|||
{
|
||||
WorldPosition = new Vector3(80f, 81f, 82f),
|
||||
};
|
||||
Assert.False(fixture.Sink.TryApply(in superseded));
|
||||
// Acknowledged (true) - the stale-authority Place is retired from
|
||||
// the FIFO head, not left wedged - but the position never moves to
|
||||
// the stale receipt's coordinates.
|
||||
Assert.True(fixture.Sink.TryApply(in superseded));
|
||||
Assert.Equal(current.WorldPosition, record.WorldEntity.Position);
|
||||
|
||||
RuntimePlacementProjectionSnapshot wrongSequence = current with
|
||||
|
|
@ -502,7 +519,7 @@ public sealed class RuntimePlacementPresentationSinkTests
|
|||
},
|
||||
WorldPosition = new Vector3(90f, 91f, 92f),
|
||||
};
|
||||
Assert.False(fixture.Sink.TryApply(in wrongSequence));
|
||||
Assert.True(fixture.Sink.TryApply(in wrongSequence));
|
||||
Assert.Equal(current.WorldPosition, record.WorldEntity.Position);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -383,10 +383,20 @@ public sealed class HeadlessSessionHostTests
|
|||
record.Snapshot,
|
||||
replaceGeneration: false));
|
||||
var collision = new FixtureCollisionNeighborhood();
|
||||
// R5/A7 review fix (2026-08-05): the drive controller is now wired
|
||||
// (was omitted in the first pass, leaving the canonical portal arm
|
||||
// a no-op by construction here and dual-host parity with zero
|
||||
// coverage). Mirrors production composition
|
||||
// (HeadlessSessionHost.cs's own construction order): the SAME
|
||||
// RuntimeAcceptedPositionDriveController drives both hosts through
|
||||
// the identical TryExecuteAcceptedPortalArrival entry point.
|
||||
RuntimeAcceptedPositionDriveController acceptedPositionDrive =
|
||||
CreateAcceptedPositionDrive(runtime);
|
||||
var projection = new HeadlessSessionWorldProjection(
|
||||
runtime,
|
||||
collision,
|
||||
firstEntry);
|
||||
firstEntry,
|
||||
acceptedPositionDrive);
|
||||
|
||||
projection.ProjectSpawn(record, isLocalPlayer: true);
|
||||
PlayerMovementController controller =
|
||||
|
|
@ -410,6 +420,15 @@ public sealed class HeadlessSessionHostTests
|
|||
projection.BeginTeleport();
|
||||
Assert.Equal(PlayerState.PortalSpace, controller.State);
|
||||
|
||||
// R5/A7: the destination cell 0xA9B40001 is in the SAME landblock
|
||||
// (0xA9B40000) whose collision generation the test already
|
||||
// committed above, so the canonical portal arm resolves
|
||||
// Committed synchronously - no DeferredCell park needed to exercise
|
||||
// the real headless placement path. A1's headless retry loop
|
||||
// (RuntimeLiveEntitySessionController.PumpPortalCompletion) is
|
||||
// covered separately by
|
||||
// HeadlessPortalDeferredCellCommitsOnPumpAfterCollisionGenerationWake
|
||||
// below.
|
||||
RuntimeDestinationReadiness readiness =
|
||||
projection.PrepareDestination(
|
||||
revealGeneration: 7,
|
||||
|
|
@ -422,13 +441,393 @@ public sealed class HeadlessSessionHostTests
|
|||
new Position(
|
||||
0xA9B40001u,
|
||||
new Vector3(96f, 97f, 50f),
|
||||
Quaternion.Identity)));
|
||||
Quaternion.Identity)),
|
||||
// R5/A7: a real, valid token - `default` was fine for the
|
||||
// old no-op arm but RuntimePortalPlacementAuthority.IsValid
|
||||
// now genuinely gates TryExecuteAcceptedPortalArrival on it.
|
||||
new RuntimeWorldHostProjectionToken(7, 0xA9B40001u));
|
||||
|
||||
Assert.True(readiness.IsCollisionReady);
|
||||
Assert.False(readiness.IsUnhydratable);
|
||||
Assert.Equal(PlayerState.InWorld, controller.State);
|
||||
Assert.Equal(3, collision.CenterCount);
|
||||
Assert.Equal(2, collision.CenterCount);
|
||||
Assert.Equal(0xA9B40001u, collision.LastCell);
|
||||
// A4/dual-host parity: the canonical placement actually committed -
|
||||
// the body moved to the destination Position, not just the
|
||||
// collision-neighborhood bookkeeping that CenterCount/LastCell
|
||||
// alone would have proven even with the earlier no-op arm. Z
|
||||
// settles 0.005 above the wire value (the foot sphere's bottom
|
||||
// sits at origin + 0.475 - 0.48, LoadedSetupCollisionSource's own
|
||||
// doc comment, ISSUES.md #285) - X/Y are exact, Z is asserted
|
||||
// within that settle tolerance.
|
||||
Assert.Equal(96f, controller.Position.X);
|
||||
Assert.Equal(97f, controller.Position.Y);
|
||||
Assert.Equal(50f, controller.Position.Z, 0.01f);
|
||||
// The resolved outdoor sub-cell index is derived from X/Y within
|
||||
// the landblock (not the wire placeholder 0xA9B40001), same as the
|
||||
// FIRST ProjectPosition assertion above (":406-409") only checks
|
||||
// landblock+indoor-vs-outdoor, not the exact sub-cell.
|
||||
Assert.Equal(0xA9B40000u, controller.CellId & 0xFFFF0000u);
|
||||
Assert.True((controller.CellId & 0xFFFFu) < 0x0100u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A1/R5/A7 review fix (2026-08-05): headless has no per-frame anim
|
||||
/// sequencer the way the graphical host does, so its OWN equivalent of
|
||||
/// A1's "hold until committed" mechanism is
|
||||
/// <c>HeadlessSessionWorldProjection.PrepareDestination</c>'s
|
||||
/// <c>_awaitingPortalWake</c> polling. This proves it end to end: a
|
||||
/// destination in a landblock whose collision generation is NOT yet
|
||||
/// committed parks (<c>IsCollisionReady: false</c>, body unmoved, no
|
||||
/// throw — DeferredCell is a normal headless outcome per
|
||||
/// <c>PrepareDestination</c>'s own doc), and once the destination
|
||||
/// landblock's collision generation commits, the SAME park resolves on
|
||||
/// a later attempt WITHOUT a second concurrent Begin (Runtime's own
|
||||
/// Begin would refuse that with Contention if this class re-attempted
|
||||
/// blindly instead of polling <c>PendingCount</c>).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HeadlessPortalPrepareDestinationParksThenCommitsOnCollisionGenerationWake()
|
||||
{
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations);
|
||||
GameRuntime runtime = host.Runtime;
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
host.Start().Status);
|
||||
const uint player = 0x50000009u;
|
||||
const uint destinationLandblock = 0xAAB40000u;
|
||||
runtime.PlayerIdentity.ServerGuid = player;
|
||||
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
|
||||
0xA9B40000u, 1UL);
|
||||
AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
|
||||
runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
|
||||
0xA9B40000u, 1UL, ready: true);
|
||||
AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry =
|
||||
CreateFirstEntryDrive(runtime);
|
||||
RuntimeEntityRecord record = runtime.EntityObjects
|
||||
.RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true)
|
||||
.Canonical!;
|
||||
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
|
||||
record,
|
||||
record.CreateIntegrationVersion,
|
||||
record.Snapshot,
|
||||
replaceGeneration: false));
|
||||
var collision = new FixtureCollisionNeighborhood();
|
||||
RuntimeAcceptedPositionDriveController acceptedPositionDrive =
|
||||
CreateAcceptedPositionDrive(runtime);
|
||||
var projection = new HeadlessSessionWorldProjection(
|
||||
runtime,
|
||||
collision,
|
||||
firstEntry,
|
||||
acceptedPositionDrive);
|
||||
|
||||
projection.ProjectSpawn(record, isLocalPlayer: true);
|
||||
PlayerMovementController controller =
|
||||
Assert.IsType<PlayerMovementController>(
|
||||
runtime.MovementOwner.Controller);
|
||||
controller.SetPosition(
|
||||
new Vector3(48f, 49f, 50f),
|
||||
0xA9B40001u);
|
||||
projection.ProjectPosition(
|
||||
record,
|
||||
isLocalPlayer: true,
|
||||
PositionTimestampDisposition.Apply);
|
||||
projection.BeginTeleport();
|
||||
|
||||
var destination = new RuntimeTeleportDestination(
|
||||
player,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: 2,
|
||||
TeleportSequence: 1,
|
||||
ForcePositionSequence: 0,
|
||||
new Position(
|
||||
destinationLandblock | 0x0001u,
|
||||
new Vector3(10f, 10f, 50f),
|
||||
Quaternion.Identity));
|
||||
var projectionToken = new RuntimeWorldHostProjectionToken(
|
||||
7, destinationLandblock | 0x0001u);
|
||||
|
||||
// Begin's own portal-vs-latest-cell gate (D-T5) requires the
|
||||
// destination's landblock to match the record's LATEST MERGED
|
||||
// Position, not just the transit's retained destination - mirror
|
||||
// what the real inbound Position handler already does before
|
||||
// TryCompletePortal ever runs (LiveEntityNetworkUpdateController's
|
||||
// App-side equivalent).
|
||||
Assert.True(runtime.EntityObjects.TryApplyPosition(
|
||||
new WorldSession.EntityPositionUpdate(
|
||||
player,
|
||||
new CreateObject.ServerPosition(
|
||||
destination.Position.ObjCellId,
|
||||
destination.Position.Frame.Origin.X,
|
||||
destination.Position.Frame.Origin.Y,
|
||||
destination.Position.Frame.Origin.Z,
|
||||
destination.Position.Frame.Orientation.W,
|
||||
destination.Position.Frame.Orientation.X,
|
||||
destination.Position.Frame.Orientation.Y,
|
||||
destination.Position.Frame.Orientation.Z),
|
||||
Velocity: null,
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: 3,
|
||||
TeleportSequence: destination.TeleportSequence,
|
||||
ForcePositionSequence: 0),
|
||||
isLocalPlayer: true,
|
||||
forcePositionRotation: Quaternion.Identity,
|
||||
currentLocalVelocity: Vector3.Zero,
|
||||
acknowledgeProjection: null,
|
||||
out _,
|
||||
out _,
|
||||
out _));
|
||||
|
||||
// First attempt: destinationLandblock's collision generation was
|
||||
// never begun/committed, so the canonical arm parks DeferredCell.
|
||||
// Must NOT throw (a park is normal, not an error). The dormant
|
||||
// stage (RuntimeSetPositionState's SubmitPreparedPlacementCore
|
||||
// deferred-commit path) already stages the body's Position/CellId
|
||||
// at the destination while it waits (StageDormantCellFrame,
|
||||
// body.InWorld=false) - the reader-visible Position moving early is
|
||||
// that mechanism, not evidence the placement committed; only
|
||||
// PlayerState/IsCollisionReady distinguish "staged" from
|
||||
// "committed" here.
|
||||
RuntimeDestinationReadiness parked = projection.PrepareDestination(
|
||||
revealGeneration: 7, destination, projectionToken);
|
||||
Assert.False(parked.IsCollisionReady);
|
||||
Assert.Equal(PlayerState.PortalSpace, controller.State);
|
||||
|
||||
// A SECOND attempt while still parked must not double-Begin -
|
||||
// Runtime's own Begin would refuse a genuine second attempt with
|
||||
// Contention, but PrepareDestination's _awaitingPortalWake polls
|
||||
// PendingCount instead of re-attempting, so this must also report
|
||||
// not-ready without throwing.
|
||||
RuntimeDestinationReadiness stillParked =
|
||||
projection.PrepareDestination(
|
||||
revealGeneration: 7, destination, projectionToken);
|
||||
Assert.False(stillParked.IsCollisionReady);
|
||||
|
||||
// Commit the destination landblock's collision generation and pump
|
||||
// the drive's wake (mirrors HeadlessSessionHost.Tick's own
|
||||
// PumpFirstEntry -> _acceptedPositionDrive.Advance() ordering).
|
||||
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
|
||||
destinationLandblock, 1UL);
|
||||
var heights = new byte[81];
|
||||
Array.Fill(heights, (byte)50);
|
||||
var heightTable = new float[256];
|
||||
for (int index = 0; index < heightTable.Length; index++)
|
||||
heightTable[index] = index;
|
||||
runtime.EntityObjects.Physics.Engine.AddLandblock(
|
||||
destinationLandblock,
|
||||
new TerrainSurface(heights, heightTable),
|
||||
[],
|
||||
[],
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
|
||||
destinationLandblock, 1UL, ready: true);
|
||||
// RuntimeAcceptedPositionDriveControllerTests.CommitLandblockCollision's
|
||||
// exact proven-working shape: the wake path's
|
||||
// resolveWorldOffsetFromRuntimeFrame requires the destination
|
||||
// landblock's world-frame offset to already be resolvable.
|
||||
runtime.EntityObjects.Physics.ObserveLocalWorldFrame(
|
||||
destinationLandblock | 0x0001u,
|
||||
teleportAdvanced: false);
|
||||
// Drain the placement projection FIFO AFTER committing (exact order
|
||||
// from RuntimeAcceptedPositionDriveControllerTests.DrainPlacementFifo's
|
||||
// call site: commit collision -> drain FIFO -> Advance) - the
|
||||
// deferred park's Withdraw notification is published as part of the
|
||||
// collision-generation commit, not before it.
|
||||
while (runtime.EntityObjects.Physics.SetPosition.TryPeekProjection(
|
||||
out RuntimePlacementProjectionSnapshot head))
|
||||
{
|
||||
if (!runtime.EntityObjects.Physics.SetPosition
|
||||
.AcknowledgeProjection(head.Token))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
acceptedPositionDrive.Advance();
|
||||
|
||||
RuntimeDestinationReadiness committed =
|
||||
projection.PrepareDestination(
|
||||
revealGeneration: 7, destination, projectionToken);
|
||||
Assert.True(committed.IsCollisionReady);
|
||||
Assert.Equal(PlayerState.InWorld, controller.State);
|
||||
Assert.Equal(10f, controller.Position.X);
|
||||
Assert.Equal(10f, controller.Position.Y);
|
||||
Assert.Equal(destinationLandblock, controller.CellId & 0xFFFF0000u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B1 review fix (2026-08-05): headless's required test #2 — the same
|
||||
/// unsound-commit-inference defect
|
||||
/// <see cref="HeadlessPortalPrepareDestinationParksThenCommitsOnCollisionGenerationWake"/>
|
||||
/// proves the HAPPY path for, exercised on the FORGOTTEN path instead. A
|
||||
/// DeferredCell park killed by an ordinary, unrelated accepted Position
|
||||
/// merge (exactly the ACE 5-10 Hz broadcast <c>RuntimeSetPositionState</c>'s
|
||||
/// own doc names as the expected way a far-destination park resolves
|
||||
/// without committing) must leave <c>PrepareDestination</c> reporting
|
||||
/// NOT ready, <see cref="PlayerState.PortalSpace"/> unchanged, and the
|
||||
/// body never moved to the destination - before the fix,
|
||||
/// <c>PendingCount</c> hitting 0 made <c>PrepareDestination</c> infer
|
||||
/// "committed" and run the full readiness/materialize/LoginComplete
|
||||
/// sequence against an unmoved body.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HeadlessPortalPrepareDestinationForgottenByOrdinaryMergeDoesNotLatchAsCommitted()
|
||||
{
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations);
|
||||
GameRuntime runtime = host.Runtime;
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
host.Start().Status);
|
||||
const uint player = 0x50000009u;
|
||||
const uint destinationLandblock = 0xAAB40000u;
|
||||
runtime.PlayerIdentity.ServerGuid = player;
|
||||
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
|
||||
0xA9B40000u, 1UL);
|
||||
AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
|
||||
runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
|
||||
0xA9B40000u, 1UL, ready: true);
|
||||
AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry =
|
||||
CreateFirstEntryDrive(runtime);
|
||||
RuntimeEntityRecord record = runtime.EntityObjects
|
||||
.RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true)
|
||||
.Canonical!;
|
||||
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
|
||||
record,
|
||||
record.CreateIntegrationVersion,
|
||||
record.Snapshot,
|
||||
replaceGeneration: false));
|
||||
var collision = new FixtureCollisionNeighborhood();
|
||||
RuntimeAcceptedPositionDriveController acceptedPositionDrive =
|
||||
CreateAcceptedPositionDrive(runtime);
|
||||
var projection = new HeadlessSessionWorldProjection(
|
||||
runtime,
|
||||
collision,
|
||||
firstEntry,
|
||||
acceptedPositionDrive);
|
||||
|
||||
projection.ProjectSpawn(record, isLocalPlayer: true);
|
||||
PlayerMovementController controller =
|
||||
Assert.IsType<PlayerMovementController>(
|
||||
runtime.MovementOwner.Controller);
|
||||
controller.SetPosition(
|
||||
new Vector3(48f, 49f, 50f),
|
||||
0xA9B40001u);
|
||||
projection.ProjectPosition(
|
||||
record,
|
||||
isLocalPlayer: true,
|
||||
PositionTimestampDisposition.Apply);
|
||||
projection.BeginTeleport();
|
||||
|
||||
var destination = new RuntimeTeleportDestination(
|
||||
player,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: 2,
|
||||
TeleportSequence: 1,
|
||||
ForcePositionSequence: 0,
|
||||
new Position(
|
||||
destinationLandblock | 0x0001u,
|
||||
new Vector3(10f, 10f, 50f),
|
||||
Quaternion.Identity));
|
||||
var projectionToken = new RuntimeWorldHostProjectionToken(
|
||||
7, destinationLandblock | 0x0001u);
|
||||
|
||||
Assert.True(runtime.EntityObjects.TryApplyPosition(
|
||||
new WorldSession.EntityPositionUpdate(
|
||||
player,
|
||||
new CreateObject.ServerPosition(
|
||||
destination.Position.ObjCellId,
|
||||
destination.Position.Frame.Origin.X,
|
||||
destination.Position.Frame.Origin.Y,
|
||||
destination.Position.Frame.Origin.Z,
|
||||
destination.Position.Frame.Orientation.W,
|
||||
destination.Position.Frame.Orientation.X,
|
||||
destination.Position.Frame.Orientation.Y,
|
||||
destination.Position.Frame.Orientation.Z),
|
||||
Velocity: null,
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: 3,
|
||||
TeleportSequence: destination.TeleportSequence,
|
||||
ForcePositionSequence: 0),
|
||||
isLocalPlayer: true,
|
||||
forcePositionRotation: Quaternion.Identity,
|
||||
currentLocalVelocity: Vector3.Zero,
|
||||
acknowledgeProjection: null,
|
||||
out _,
|
||||
out _,
|
||||
out _));
|
||||
|
||||
// First attempt: destinationLandblock's collision generation was
|
||||
// never begun/committed, so the canonical arm parks DeferredCell.
|
||||
RuntimeDestinationReadiness parked = projection.PrepareDestination(
|
||||
revealGeneration: 7, destination, projectionToken);
|
||||
Assert.False(parked.IsCollisionReady);
|
||||
Assert.Equal(PlayerState.PortalSpace, controller.State);
|
||||
Assert.Equal(1, acceptedPositionDrive.PendingCount);
|
||||
|
||||
// An ordinary, UNRELATED accepted Position for the same entity - no
|
||||
// new teleport, just a normal broadcast at the SAME already-accepted
|
||||
// teleport sequence - Forgets the parked operation the same way
|
||||
// ACE's 5-10 Hz cadence would (RuntimeSetPositionState.Forget, called
|
||||
// from TryApplyPosition for every accepted, non-Rejected Position).
|
||||
Assert.True(runtime.EntityObjects.TryApplyPosition(
|
||||
new WorldSession.EntityPositionUpdate(
|
||||
player,
|
||||
new CreateObject.ServerPosition(
|
||||
0x20210001u, 48f, 49f, 50f, 1f, 0f, 0f, 0f),
|
||||
Velocity: null,
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: 4,
|
||||
TeleportSequence: destination.TeleportSequence,
|
||||
ForcePositionSequence: 0),
|
||||
isLocalPlayer: true,
|
||||
forcePositionRotation: Quaternion.Identity,
|
||||
currentLocalVelocity: Vector3.Zero,
|
||||
acknowledgeProjection: null,
|
||||
out _,
|
||||
out _,
|
||||
out _));
|
||||
// Forget (inside TryApplyPosition) cancels the underlying
|
||||
// RuntimeSetPositionState operation immediately, but the drive's OWN
|
||||
// _pending cache only notices on its next Advance() pump - the real
|
||||
// host does this every HeadlessSessionHost.Tick via PumpFirstEntry;
|
||||
// the test drives it explicitly, same as the App-level equivalent.
|
||||
acceptedPositionDrive.Advance();
|
||||
Assert.Equal(0, acceptedPositionDrive.PendingCount);
|
||||
|
||||
// Drive well past where the pre-fix inference would have latched
|
||||
// "committed" on the very next PrepareDestination call and then
|
||||
// marched to the full readiness/materialize/LoginComplete sequence.
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
RuntimeDestinationReadiness stillNotReady =
|
||||
projection.PrepareDestination(
|
||||
revealGeneration: 7, destination, projectionToken);
|
||||
Assert.False(stillNotReady.IsCollisionReady);
|
||||
}
|
||||
|
||||
Assert.Equal(PlayerState.PortalSpace, controller.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -1207,6 +1207,580 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
|
|||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
#region C4 route 3 - portal arm
|
||||
|
||||
/// <summary>
|
||||
/// The main D-T2/D-T3 happy path, asserting the positive facts §8 item 2
|
||||
/// requires rather than only <c>InWorld</c>/clock: the body moved to the
|
||||
/// resolved destination, velocity zeroed (retail
|
||||
/// <c>set_velocity(player, 0, 1)</c> @0x004541B4), the leash re-armed
|
||||
/// EXACTLY ONCE at the resolved position (Inversion A — proven by
|
||||
/// pre-arming at a stale anchor first, so a stale anchor surviving would
|
||||
/// fail the assertion), autorun cancelled (the <c>PlayerTeleported</c>
|
||||
/// port), and exactly one outbound movement event with ZERO
|
||||
/// AutonomousPosition packets (route's <c>SendPositionImmediately</c> is
|
||||
/// always false).
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// R7 review fix (2026-08-05): retail <c>CommandInterpreter::SendMovementEvent</c>
|
||||
/// @0x006B4680 (<c>PlayerTeleported</c>'s tail-jump) gates on
|
||||
/// <c>autonomy_level != 0</c> — under server control (this class's
|
||||
/// <c>_usePositionFromServer</c>, retail's <c>UsePositionFromServer()</c>)
|
||||
/// retail sends nothing. Everything else about the commit (body move,
|
||||
/// leash re-arm, autorun cancel) is unaffected by autonomy; only the
|
||||
/// wire send is gated.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PortalCommitted_UnderServerControlSendsNoMovementEvent()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
(RuntimeEntityRecord record, PlayerMovementController controller) =
|
||||
EnterLocalPlayer(runtime);
|
||||
Assert.True(runtime.CharacterOwner.TrySetAutonomyLevel(0u));
|
||||
Assert.True(runtime.CharacterOwner.UsePositionFromServer);
|
||||
|
||||
const ushort teleportSequence = 6;
|
||||
var destinationPosition = new Vector3(31f, 33f, SpawnHeight);
|
||||
WorldSession.EntityPositionUpdate destinationUpdate = PortalDestinationUpdate(
|
||||
destinationPosition, SpawnLandblock | 0x0001u, teleportSequence);
|
||||
MergeAccepted(runtime, controller, destinationUpdate);
|
||||
(RuntimePortalPlacementAuthority portal, RuntimeTeleportDestination destination) =
|
||||
BeginPortal(runtime, SpawnLandblock | 0x0001u, teleportSequence, destinationUpdate);
|
||||
// B8/A8 review fix: guarantees ConvergePortalHost runs even if an
|
||||
// assertion below fails first - see PortalHostConvergenceGuard's doc.
|
||||
using var portalHostGuard = new PortalHostConvergenceGuard(
|
||||
runtime, portal.RevealGeneration, portal.Projection);
|
||||
RuntimeAcceptedPositionDriveController drive =
|
||||
CreateAcceptedPositionDrive(runtime, out List<byte[]> gameActions);
|
||||
|
||||
RuntimeAcceptedPositionExecutionStatus status =
|
||||
drive.TryExecuteAcceptedPortalArrival(destination, portal);
|
||||
|
||||
Assert.Equal(RuntimeAcceptedPositionExecutionStatus.Committed, status);
|
||||
Assert.Equal(destinationPosition, controller.Position);
|
||||
Assert.False(runtime.MovementOwner.AutoRunActive);
|
||||
Assert.Empty(gameActions);
|
||||
ConvergePortalHost(runtime, portal.RevealGeneration, portal.Projection);
|
||||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortalCommitted_MovesBodyArmsLeashOnceCancelsAutorunAndSendsExactlyOneMovementEvent()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
(RuntimeEntityRecord record, PlayerMovementController controller) =
|
||||
EnterLocalPlayer(runtime);
|
||||
|
||||
// Pre-arm the leash at a STALE anchor - a re-arm that merely leaves
|
||||
// the OLD anchor in place (i.e. no re-arm at all) would fail the
|
||||
// ConstraintPos assertion below.
|
||||
var staleAnchor = new Position(
|
||||
SpawnLandblock | 0x0001u,
|
||||
new Vector3(1f, 1f, SpawnHeight),
|
||||
Quaternion.Identity);
|
||||
controller.PositionManager!.ConstrainTo(staleAnchor, 1f, 2f);
|
||||
Assert.True(controller.PositionManager.Constraint!.IsConstrained);
|
||||
|
||||
runtime.MovementOwner.Execute(RuntimeMovementCommand.ToggleRunLock);
|
||||
Assert.True(runtime.MovementOwner.AutoRunActive);
|
||||
|
||||
const ushort teleportSequence = 5;
|
||||
var destinationPosition = new Vector3(30f, 32f, SpawnHeight);
|
||||
WorldSession.EntityPositionUpdate destinationUpdate = PortalDestinationUpdate(
|
||||
destinationPosition, SpawnLandblock | 0x0001u, teleportSequence);
|
||||
(PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) =
|
||||
MergeAccepted(runtime, controller, destinationUpdate);
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||
Assert.True(timestamps.TeleportAdvanced);
|
||||
|
||||
(RuntimePortalPlacementAuthority portal, RuntimeTeleportDestination destination) =
|
||||
BeginPortal(runtime, SpawnLandblock | 0x0001u, teleportSequence, destinationUpdate);
|
||||
// B8/A8 review fix: guarantees ConvergePortalHost runs even if an
|
||||
// assertion below fails first - see PortalHostConvergenceGuard's doc.
|
||||
using var portalHostGuard = new PortalHostConvergenceGuard(
|
||||
runtime, portal.RevealGeneration, portal.Projection);
|
||||
RuntimeAcceptedPositionDriveController drive =
|
||||
CreateAcceptedPositionDrive(runtime, out List<byte[]> gameActions);
|
||||
|
||||
RuntimeAcceptedPositionExecutionStatus status =
|
||||
drive.TryExecuteAcceptedPortalArrival(destination, portal);
|
||||
|
||||
Assert.Equal(RuntimeAcceptedPositionExecutionStatus.Committed, status);
|
||||
Assert.Equal(destinationPosition, controller.Position);
|
||||
Assert.Equal(Vector3.Zero, controller.BodyVelocity);
|
||||
Assert.True(controller.PositionManager.Constraint!.IsConstrained);
|
||||
Assert.Equal(
|
||||
controller.Position,
|
||||
controller.PositionManager.Constraint.ConstraintPos.Frame.Origin);
|
||||
Assert.False(runtime.MovementOwner.AutoRunActive);
|
||||
// Exactly one outbound wire packet total: the movement-event refresh.
|
||||
// Zero AutonomousPosition - the portal route never sends one.
|
||||
Assert.Single(gameActions);
|
||||
ConvergePortalHost(runtime, portal.RevealGeneration, portal.Projection);
|
||||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// D-T1's own safety property, proved live: a portal authority whose
|
||||
/// generation the transit no longer recognizes (this generation was
|
||||
/// never begun) is structurally invalid, so the arm returns
|
||||
/// <c>NotApplicable</c> without writing anything - the D-T5 refusal
|
||||
/// shape. A superseded token is unobtainable by construction; this
|
||||
/// exercises the same <c>IsValid</c> gate a stale re-derivation would
|
||||
/// fail on.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PortalProducerInvalidAuthority_ArmDoesNotRunAndNothingMutates()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
(RuntimeEntityRecord record, PlayerMovementController controller) =
|
||||
EnterLocalPlayer(runtime);
|
||||
Vector3 positionBefore = controller.Position;
|
||||
RuntimeAcceptedPositionDriveController drive =
|
||||
CreateAcceptedPositionDrive(runtime, out List<byte[]> gameActions);
|
||||
|
||||
var destination = new RuntimeTeleportDestination(
|
||||
PlayerGuid,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: 2,
|
||||
TeleportSequence: 5,
|
||||
ForcePositionSequence: 0,
|
||||
new Position(
|
||||
SpawnLandblock | 0x0001u,
|
||||
new Vector3(30f, 32f, SpawnHeight),
|
||||
Quaternion.Identity));
|
||||
// Present but structurally invalid: RevealGeneration 0 fails
|
||||
// RuntimePortalPlacementAuthority.IsValid outright - the exact shape
|
||||
// a stale-generation TryRegisterHostProjection re-derivation refusal
|
||||
// would leave the producer holding (default).
|
||||
var invalidAuthority = new RuntimePortalPlacementAuthority(
|
||||
Present: true,
|
||||
RevealGeneration: 0,
|
||||
TeleportSequence: 5,
|
||||
Projection: default);
|
||||
|
||||
RuntimeAcceptedPositionExecutionStatus status =
|
||||
drive.TryExecuteAcceptedPortalArrival(destination, invalidAuthority);
|
||||
|
||||
Assert.Equal(RuntimeAcceptedPositionExecutionStatus.NotApplicable, status);
|
||||
Assert.Equal(positionBefore, controller.Position);
|
||||
Assert.Empty(gameActions);
|
||||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// D-T5's genuinely new edge: transit pins the FIRST accepted
|
||||
/// destination per generation, while <c>BeginAcceptedPlacementCore</c>
|
||||
/// validates the portal's destination cell against the LATEST merged
|
||||
/// snapshot. A second local Position merging a DIFFERENT landblock
|
||||
/// between the offer and the Place edge makes Begin refuse. Positive
|
||||
/// half: nothing mutates and the transit stays exactly as it was
|
||||
/// (D-T5's "no half-state" invariant).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PortalBeginCellMismatch_RefusesWithoutMutatingBodyOrTransit()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
(RuntimeEntityRecord record, PlayerMovementController controller) =
|
||||
EnterLocalPlayer(runtime);
|
||||
|
||||
const ushort teleportSequence = 6;
|
||||
var destinationPosition = new Vector3(30f, 32f, SpawnHeight);
|
||||
WorldSession.EntityPositionUpdate destinationUpdate = PortalDestinationUpdate(
|
||||
destinationPosition, SpawnLandblock | 0x0001u, teleportSequence);
|
||||
MergeAccepted(runtime, controller, destinationUpdate);
|
||||
(RuntimePortalPlacementAuthority portal, RuntimeTeleportDestination destination) =
|
||||
BeginPortal(runtime, SpawnLandblock | 0x0001u, teleportSequence, destinationUpdate);
|
||||
// B8/A8 review fix: guarantees ConvergePortalHost runs even if an
|
||||
// assertion below fails first - see PortalHostConvergenceGuard's doc.
|
||||
using var portalHostGuard = new PortalHostConvergenceGuard(
|
||||
runtime, portal.RevealGeneration, portal.Projection);
|
||||
|
||||
// A SECOND local Position merges a DIFFERENT landblock after the
|
||||
// portal offer/registration but before Place - record.Snapshot's
|
||||
// latest accepted position no longer matches the portal's
|
||||
// destination cell. SAME teleport sequence (an ordinary in-flight
|
||||
// Apply, not a fresh teleport) so PhysicsTimestampGate admits it.
|
||||
const uint otherLandblock = 0x02020000u;
|
||||
(PositionTimestampDisposition secondDisposition, _) = MergeAccepted(
|
||||
runtime,
|
||||
controller,
|
||||
PortalDestinationUpdate(
|
||||
new Vector3(1f, 1f, SpawnHeight),
|
||||
otherLandblock | 0x0001u,
|
||||
teleportSequence,
|
||||
positionSequence: 3));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, secondDisposition);
|
||||
Vector3 positionBefore = controller.Position;
|
||||
RuntimeAcceptedPositionDriveController drive =
|
||||
CreateAcceptedPositionDrive(runtime, out List<byte[]> gameActions);
|
||||
|
||||
RuntimeAcceptedPositionExecutionStatus status =
|
||||
drive.TryExecuteAcceptedPortalArrival(destination, portal);
|
||||
|
||||
Assert.Equal(RuntimeAcceptedPositionExecutionStatus.Contention, status);
|
||||
Assert.Equal(positionBefore, controller.Position);
|
||||
Assert.Empty(gameActions);
|
||||
// The transit is untouched by the refusal - still active, still
|
||||
// holding the SAME accepted destination, not cancelled.
|
||||
Assert.True(runtime.TransitOwner.IsTeleportActive);
|
||||
Assert.False(runtime.TransitOwner.Snapshot.Cancelled);
|
||||
ConvergePortalHost(runtime, portal.RevealGeneration, portal.Projection);
|
||||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortalContention_WhenTheEntityAlreadyOwnsAnActiveOperation()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
(RuntimeEntityRecord record, PlayerMovementController controller) =
|
||||
EnterLocalPlayer(runtime);
|
||||
Vector3 positionBefore = controller.Position;
|
||||
|
||||
const ushort teleportSequence = 7;
|
||||
WorldSession.EntityPositionUpdate destinationUpdate = PortalDestinationUpdate(
|
||||
new Vector3(30f, 32f, SpawnHeight), SpawnLandblock | 0x0001u, teleportSequence);
|
||||
MergeAccepted(runtime, controller, destinationUpdate);
|
||||
(RuntimePortalPlacementAuthority portal, RuntimeTeleportDestination destination) =
|
||||
BeginPortal(runtime, SpawnLandblock | 0x0001u, teleportSequence, destinationUpdate);
|
||||
// B8/A8 review fix: guarantees ConvergePortalHost runs even if an
|
||||
// assertion below fails first - see PortalHostConvergenceGuard's doc.
|
||||
using var portalHostGuard = new PortalHostConvergenceGuard(
|
||||
runtime, portal.RevealGeneration, portal.Projection);
|
||||
|
||||
RuntimeEntityPlacementToken displaced = runtime.EntityObjects.Physics
|
||||
.SetPosition.TryBeginExclusiveAuthoredPlacement(
|
||||
record,
|
||||
record.PositionAuthorityVersion,
|
||||
RuntimeSetPositionOperationKind.LocalAuthoritative);
|
||||
Assert.True(displaced.IsValid);
|
||||
RuntimeAcceptedPositionDriveController drive =
|
||||
CreateAcceptedPositionDrive(runtime, out List<byte[]> gameActions);
|
||||
|
||||
RuntimeAcceptedPositionExecutionStatus status =
|
||||
drive.TryExecuteAcceptedPortalArrival(destination, portal);
|
||||
|
||||
Assert.Equal(RuntimeAcceptedPositionExecutionStatus.Contention, status);
|
||||
Assert.Equal(positionBefore, controller.Position);
|
||||
Assert.Empty(gameActions);
|
||||
|
||||
RuntimePlacementCancellationReceipt cancellation = runtime.EntityObjects
|
||||
.Physics.SetPosition.ForgetExactPlacement(displaced);
|
||||
if (cancellation.IsValid)
|
||||
{
|
||||
runtime.EntityObjects.Physics.SetPosition
|
||||
.PublishCancellation(cancellation);
|
||||
}
|
||||
ConvergePortalHost(runtime, portal.RevealGeneration, portal.Projection);
|
||||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// D-T2.4: a portal DeferredCell park reuses the SAME retained-operation
|
||||
/// machinery the force arm uses, but on wake it commits WITHOUT the force
|
||||
/// funnel's re-issue decision (trap T7 - <see cref="_newestForce"/> is
|
||||
/// never touched) and WITHOUT a double commit however many times
|
||||
/// <see cref="RuntimeAcceptedPositionDriveController.Advance"/> is
|
||||
/// pumped afterward.
|
||||
///
|
||||
/// <para>Root-cause note (closed): an earlier revision of this test
|
||||
/// asserted the wrong post-commit position (the FORCE arm's
|
||||
/// cross-landblock +192/+192 delta) and asserted zero outbound game
|
||||
/// actions after a commit that legitimately sends one. Because both
|
||||
/// were WRONG, the assertion failure fired before this method's own
|
||||
/// <c>ConvergePortalHost</c> cleanup call ever ran, which left the
|
||||
/// portal's host projection unconverged - and <c>StartedRuntime</c>'s
|
||||
/// `using`-triggered <c>Dispose()</c> then threw "hosts=1, pending=1"
|
||||
/// while unwinding, MASKING the real (first) failure as a teardown
|
||||
/// defect. The actual cause: an accepted Position merge with
|
||||
/// <c>TeleportAdvanced</c> (this is one - #283) rebases
|
||||
/// <c>RuntimePhysicsState</c>'s world-frame center onto the DESTINATION
|
||||
/// landblock immediately, before the placement itself ever resolves -
|
||||
/// exactly as <c>ObserveLocalWorldFrame</c>'s doc comment states ("only
|
||||
/// an accepted teleport moves it afterward"). So by the time this
|
||||
/// deferred park's retry runs, the destination landblock IS the frame
|
||||
/// center and the correct expected offset is zero, not the FORCE
|
||||
/// arm's stale-frame cross-landblock delta. No production code
|
||||
/// changed to fix this; both fixes were test-assertion corrections.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PortalDeferredCell_ParksThenCommitsExactlyOnceOnTheCollisionGenerationWake()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
(RuntimeEntityRecord record, PlayerMovementController controller) =
|
||||
EnterLocalPlayer(runtime);
|
||||
RuntimeAcceptedPositionDriveController drive =
|
||||
CreateAcceptedPositionDrive(runtime, out List<byte[]> gameActions);
|
||||
|
||||
const uint deferredLandblock = 0x02020000u;
|
||||
var deferredPosition = new Vector3(10f, 10f, SpawnHeight);
|
||||
const ushort teleportSequence = 8;
|
||||
WorldSession.EntityPositionUpdate destinationUpdate = PortalDestinationUpdate(
|
||||
deferredPosition, deferredLandblock | 0x0001u, teleportSequence);
|
||||
(PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps mergeTimestamps) =
|
||||
MergeAccepted(runtime, controller, destinationUpdate);
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||
// #283: RuntimePhysicsState.ObserveLocalWorldFrame rebases the world
|
||||
// frame center to the DESTINATION landblock the instant an accepted
|
||||
// Position merges with TeleportAdvanced (RuntimeEntityObjectLifetime
|
||||
// .TryApplyPosition calls it with teleportAdvanced: timestamps
|
||||
// .TeleportAdvanced) - by design, "only an accepted teleport moves
|
||||
// it afterward" (RuntimePhysicsState.cs doc comment). This merge IS
|
||||
// that accepted teleport, so by the time the placement itself runs,
|
||||
// TryGetWorldFrameOffset(deferredLandblock) is already (0,0) - the
|
||||
// frame is centered ON the destination, not still on SpawnLandblock.
|
||||
Assert.True(mergeTimestamps.TeleportAdvanced);
|
||||
(RuntimePortalPlacementAuthority portal, RuntimeTeleportDestination destination) =
|
||||
BeginPortal(runtime, deferredLandblock | 0x0001u, teleportSequence, destinationUpdate);
|
||||
// B8/A8 review fix: guarantees ConvergePortalHost runs even if an
|
||||
// assertion below fails first - see PortalHostConvergenceGuard's doc.
|
||||
using var portalHostGuard = new PortalHostConvergenceGuard(
|
||||
runtime, portal.RevealGeneration, portal.Projection);
|
||||
|
||||
RuntimeAcceptedPositionExecutionStatus parked =
|
||||
drive.TryExecuteAcceptedPortalArrival(destination, portal);
|
||||
|
||||
Assert.Equal(RuntimeAcceptedPositionExecutionStatus.DeferredCell, parked);
|
||||
Assert.Empty(gameActions);
|
||||
Assert.Equal(1, drive.PendingCount);
|
||||
|
||||
drive.Advance();
|
||||
Assert.Equal(1, drive.PendingCount);
|
||||
|
||||
CommitLandblockCollision(runtime, deferredLandblock);
|
||||
DrainPlacementFifo(runtime);
|
||||
drive.Advance();
|
||||
|
||||
Assert.Equal(0, drive.PendingCount);
|
||||
// Zero offset, not +192/+192: the accepted-teleport merge above
|
||||
// already rebased the world frame onto deferredLandblock (#283),
|
||||
// so the placement resolves in a frame ALREADY centered on the
|
||||
// destination. Asserting the old cross-landblock delta here would
|
||||
// be asserting a stale frame the merge already retired.
|
||||
Assert.Equal(deferredPosition, controller.Position);
|
||||
|
||||
// The committed portal placement sends its ONE outbound movement
|
||||
// event exactly like PortalCommitted_* asserts (D-T3's
|
||||
// PlayerTeleported port; never an AutonomousPosition -
|
||||
// SendPositionImmediately is always false for the portal route).
|
||||
Assert.Single(gameActions);
|
||||
|
||||
// No re-issue path exists for a portal pending (trap T7): further
|
||||
// pumps are pure no-ops, never a second commit or a stray ack.
|
||||
drive.Advance();
|
||||
drive.Advance();
|
||||
Assert.Single(gameActions);
|
||||
ConvergePortalHost(runtime, portal.RevealGeneration, portal.Projection);
|
||||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A2/D-T2.4 review fix (2026-08-05): the wake path must re-validate
|
||||
/// the portal authority before committing, so a park that resolves
|
||||
/// AFTER something has made the authority stale (transit ended or was
|
||||
/// superseded) does not run the reconcile/ack suffix — architecture
|
||||
/// review A2's FIFO-wedge shape ("a Place receipt whose Token.Portal
|
||||
/// still names the ended reveal"). The stub predicate below always
|
||||
/// reports stale, standing in for that condition without needing to
|
||||
/// actually end the transit mid-park.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PortalDeferredCell_WakeAbandonsInsteadOfReconcilingWhenAuthorityWentStale()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
(RuntimeEntityRecord record, PlayerMovementController controller) =
|
||||
EnterLocalPlayer(runtime);
|
||||
RuntimeAcceptedPositionDriveController drive =
|
||||
CreateAcceptedPositionDrive(
|
||||
runtime,
|
||||
out List<byte[]> gameActions,
|
||||
isPortalAuthorityCurrent: static _ => false);
|
||||
|
||||
const uint deferredLandblock = 0x02020000u;
|
||||
var deferredPosition = new Vector3(10f, 10f, SpawnHeight);
|
||||
const ushort teleportSequence = 9;
|
||||
WorldSession.EntityPositionUpdate destinationUpdate = PortalDestinationUpdate(
|
||||
deferredPosition, deferredLandblock | 0x0001u, teleportSequence);
|
||||
MergeAccepted(runtime, controller, destinationUpdate);
|
||||
(RuntimePortalPlacementAuthority portal, RuntimeTeleportDestination destination) =
|
||||
BeginPortal(runtime, deferredLandblock | 0x0001u, teleportSequence, destinationUpdate);
|
||||
// B8/A8 review fix: guarantees ConvergePortalHost runs even if an
|
||||
// assertion below fails first - see PortalHostConvergenceGuard's doc.
|
||||
using var portalHostGuard = new PortalHostConvergenceGuard(
|
||||
runtime, portal.RevealGeneration, portal.Projection);
|
||||
|
||||
RuntimeAcceptedPositionExecutionStatus parked =
|
||||
drive.TryExecuteAcceptedPortalArrival(destination, portal);
|
||||
Assert.Equal(RuntimeAcceptedPositionExecutionStatus.DeferredCell, parked);
|
||||
Assert.Equal(1, drive.PendingCount);
|
||||
|
||||
CommitLandblockCollision(runtime, deferredLandblock);
|
||||
DrainPlacementFifo(runtime);
|
||||
drive.Advance();
|
||||
|
||||
// The park resolved (PendingCount converges to zero either way —
|
||||
// the wake always retires its retained operation) but because the
|
||||
// stub reports the authority stale, NO reconcile/ack ran: zero
|
||||
// outbound movement events, autorun untouched. The underlying body
|
||||
// commit (RetryDeferred) is a SEPARATE, asynchronous mechanism this
|
||||
// class cannot prevent (see the class doc on
|
||||
// IsPortalAuthorityCurrent) — it still lands.
|
||||
Assert.Equal(0, drive.PendingCount);
|
||||
Assert.Empty(gameActions);
|
||||
Assert.Equal(deferredPosition, controller.Position);
|
||||
|
||||
ConvergePortalHost(runtime, portal.RevealGeneration, portal.Projection);
|
||||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
private static WorldSession.EntityPositionUpdate PortalDestinationUpdate(
|
||||
Vector3 position,
|
||||
uint landblockId,
|
||||
ushort teleportSequence,
|
||||
ushort positionSequence = 2) =>
|
||||
new(
|
||||
PlayerGuid,
|
||||
new CreateObject.ServerPosition(
|
||||
landblockId,
|
||||
position.X,
|
||||
position.Y,
|
||||
position.Z,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0f),
|
||||
Velocity: null,
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: positionSequence,
|
||||
TeleportSequence: teleportSequence,
|
||||
ForcePositionSequence: 0);
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors <see cref="RuntimePlacementPresentationSinkTests.Fixture.BeginPortal"/>'s
|
||||
/// shape against <see cref="GameRuntime.TransitOwner"/> directly - F751 →
|
||||
/// offer → begin reveal → register the host token exactly like the
|
||||
/// graphical/headless producers (D-T1).
|
||||
/// </summary>
|
||||
private static (RuntimePortalPlacementAuthority Portal, RuntimeTeleportDestination Destination)
|
||||
BeginPortal(
|
||||
GameRuntime runtime,
|
||||
uint destinationCell,
|
||||
ushort teleportSequence,
|
||||
in WorldSession.EntityPositionUpdate destinationUpdate)
|
||||
{
|
||||
RuntimeWorldTransitState transit = runtime.TransitOwner;
|
||||
Assert.True(transit.TryQueueTeleportStart(teleportSequence));
|
||||
Assert.True(transit.ActivateQueuedTeleport());
|
||||
var destination = new RuntimeTeleportDestination(
|
||||
PlayerGuid,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: destinationUpdate.PositionSequence,
|
||||
TeleportSequence: teleportSequence,
|
||||
ForcePositionSequence: 0,
|
||||
new Position(
|
||||
destinationCell,
|
||||
new Vector3(
|
||||
destinationUpdate.Position.PositionX,
|
||||
destinationUpdate.Position.PositionY,
|
||||
destinationUpdate.Position.PositionZ),
|
||||
Quaternion.Identity));
|
||||
Assert.True(transit.OfferTeleportDestination(
|
||||
destination,
|
||||
teleportTimestampAdvanced: true));
|
||||
Assert.True(transit.TryBeginPortalReveal(
|
||||
teleportSequence,
|
||||
destinationCell,
|
||||
out long generation));
|
||||
Assert.True(transit.TryRegisterHostProjection(
|
||||
generation,
|
||||
destinationCell,
|
||||
out RuntimeWorldHostProjectionToken host));
|
||||
return (
|
||||
new RuntimePortalPlacementAuthority(true, generation, teleportSequence, host),
|
||||
destination);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only host-side convergence: cancels the reveal generation and
|
||||
/// drains the 4-stage host acknowledgement suffix
|
||||
/// (<see cref="RuntimeWorldTransitState.RequireTerminalHostProjection"/>'s
|
||||
/// SimulationReleaseProjected + DestinationReservationReleased +
|
||||
/// TerminalProjected) so <see cref="RuntimeWorldTransitState.ResetSession"/>
|
||||
/// does not throw during <c>StartedRuntime.Dispose()</c> teardown. A REAL
|
||||
/// host (graphical/headless) always runs this suffix itself
|
||||
/// (<c>LocalPlayerTeleportController.ResetTransit</c> /
|
||||
/// <c>RuntimeLiveEntitySessionController.TryCompletePortal</c>'s
|
||||
/// <c>Complete</c> path); this fixture stands in for that host exactly
|
||||
/// like <c>DrainPlacementFifo</c> stands in for the placement
|
||||
/// subscription.
|
||||
/// </summary>
|
||||
private static void ConvergePortalHost(
|
||||
GameRuntime runtime,
|
||||
long generation,
|
||||
RuntimeWorldHostProjectionToken projection)
|
||||
{
|
||||
RuntimeWorldTransitState transit = runtime.TransitOwner;
|
||||
if (!transit.Snapshot.Cancelled && !transit.Snapshot.Completed)
|
||||
transit.Cancel(generation);
|
||||
transit.AcknowledgeHostProjection(new RuntimeWorldHostAcknowledgement(
|
||||
projection, RuntimeWorldHostAcknowledgementStage.SimulationReleaseProjected));
|
||||
transit.AcknowledgeHostProjection(new RuntimeWorldHostAcknowledgement(
|
||||
projection, RuntimeWorldHostAcknowledgementStage.DestinationReservationReleased));
|
||||
transit.AcknowledgeHostProjection(new RuntimeWorldHostAcknowledgement(
|
||||
projection, RuntimeWorldHostAcknowledgementStage.TerminalProjected));
|
||||
transit.EndTeleport();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B8/A8 review fix (2026-08-05): a plain trailing-statement call to
|
||||
/// <see cref="ConvergePortalHost"/> is masked whenever an assertion
|
||||
/// EARLIER in the same test body fails first - the real failure never
|
||||
/// reaches xUnit because <c>StartedRuntime.Dispose()</c>'s own
|
||||
/// <c>RuntimeWorldTransitState.ResetSession</c> throws a SECOND,
|
||||
/// unrelated-looking "hosts=1, pending=1" exception while unwinding,
|
||||
/// which is the one that actually surfaces. A <c>using var</c> local of
|
||||
/// this guard, declared immediately after <see cref="BeginPortal"/>
|
||||
/// returns, disposes on EVERY exit path (normal return AND exception
|
||||
/// unwind) via C#'s own <c>using</c> semantics - equivalent to a
|
||||
/// try/finally wrapping the rest of the method without the nesting.
|
||||
/// <see cref="ConvergePortalHost"/> is idempotent against an already-
|
||||
/// converged host (every step it performs is a no-op past the first
|
||||
/// successful run), so a test that ALSO calls it explicitly on its own
|
||||
/// success path is safe to leave as-is; this guard exists purely to
|
||||
/// guarantee the call still happens when that explicit call is never
|
||||
/// reached.
|
||||
/// </summary>
|
||||
private readonly struct PortalHostConvergenceGuard : IDisposable
|
||||
{
|
||||
private readonly GameRuntime _runtime;
|
||||
private readonly long _generation;
|
||||
private readonly RuntimeWorldHostProjectionToken _projection;
|
||||
|
||||
public PortalHostConvergenceGuard(
|
||||
GameRuntime runtime,
|
||||
long generation,
|
||||
RuntimeWorldHostProjectionToken projection)
|
||||
{
|
||||
_runtime = runtime;
|
||||
_generation = generation;
|
||||
_projection = projection;
|
||||
}
|
||||
|
||||
public void Dispose() =>
|
||||
ConvergePortalHost(_runtime, _generation, _projection);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static void AssertConverged(GameRuntime runtime)
|
||||
{
|
||||
RuntimeEntityObjectOwnershipSnapshot ownership =
|
||||
|
|
@ -1353,7 +1927,8 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
|
|||
/// </summary>
|
||||
private static RuntimeAcceptedPositionDriveController CreateAcceptedPositionDrive(
|
||||
GameRuntime runtime,
|
||||
out List<byte[]> gameActions)
|
||||
out List<byte[]> gameActions,
|
||||
Func<RuntimePortalPlacementAuthority, bool>? isPortalAuthorityCurrent = null)
|
||||
{
|
||||
var captured = new List<byte[]>();
|
||||
gameActions = captured;
|
||||
|
|
@ -1372,7 +1947,15 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
|
|||
() => runtime.PlayerIdentity.ServerGuid,
|
||||
() => runtime.MovementOwner.Controller,
|
||||
() => runtime.CharacterOwner.UsePositionFromServer,
|
||||
() => liveSession);
|
||||
() => liveSession,
|
||||
// C4 route 3: the portal arm's PlayerTeleported port needs the
|
||||
// autorun latch owner. Unused on the force arm this factory has
|
||||
// always served, so every existing route-2 test is unaffected.
|
||||
() => runtime.MovementOwner,
|
||||
// A2/D-T2.4 review fix (2026-08-05): defaults to null (existing
|
||||
// callers unaffected - every retained portal pending is treated
|
||||
// as current, today's unconditional behaviour).
|
||||
isPortalAuthorityCurrent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1614,7 +2197,8 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
|
|||
|
||||
public RuntimeDestinationReadiness PrepareDestination(
|
||||
long revealGeneration,
|
||||
RuntimeTeleportDestination destination) =>
|
||||
RuntimeTeleportDestination destination,
|
||||
RuntimeWorldHostProjectionToken portal) =>
|
||||
new(
|
||||
revealGeneration,
|
||||
destination.CellId,
|
||||
|
|
|
|||
|
|
@ -901,7 +901,8 @@ public sealed class RuntimeLiveEntitySessionControllerTests
|
|||
|
||||
public RuntimeDestinationReadiness PrepareDestination(
|
||||
long revealGeneration,
|
||||
RuntimeTeleportDestination destination)
|
||||
RuntimeTeleportDestination destination,
|
||||
RuntimeWorldHostProjectionToken portal)
|
||||
{
|
||||
PrepareCount++;
|
||||
LastDestination = destination;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue