From 7f1c1f5aa6cd7842726d2edd909564d620eb587f Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 4 Aug 2026 07:55:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(physics):=20C4=20route=204b-2=20=E2=80=94?= =?UTF-8?q?=20remote=20far=20snap=20through=20the=20canonical=20placement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips the SetPositionSimple classification (contact, PlayerDistance >= 96 m) for remotes onto 4b-1's drive controller and deletes both legacy far blocks, both duplicated 96f/4f constant pairs, and both `?? Vector3.Zero` fabrications. The 4 m constant now exists exactly once. Teleport and cell-less stay legacy for 4b-3. Retail: MoveOrTeleport @0x00516330's far branch runs StopInterpolating @0x005163CB before SetPositionSimple @0x005163D9 and returns 1 @0x005163E8 regardless — the SetPositionError is discarded — so HandleReceivedPosition arms ConstrainTo @0x00454272 post-move on commit AND on failure. The x87 parity decode at @0x00516393-@0x0051639E puts exactly 96.0 on the far branch. SetPositionSimple @0x005162B0 builds flags 0x1012 at @0x005162C4. Non-commit outcomes still advance the body, because retail's SetPositionInternal @0x00515BD0 commits the destination via store_position @0x00515CE2 when no cell resolves. The partition is by STAGE, not heuristic, enforced by an exhaustive switch: Refused/Contention/NotApplicable/RejectedPreparation store (the placement never executed); Committed/Deferred/RejectedByPlacement do not (the engine ran and refused, matching retail's non-storing returns @0x00515CB2 and @0x00515CD5). Without this a refused far snap froze the remote with an emptied queue. Also fixes a shipped defect this route made live: ParkDeferred's quiescence parks withdrew the entity (InWorld=false, clock suspended, residency removed) and were never restorable, while Forget(restoreCancelledPark: true) runs for every accepted Position on every entity. The restorable decision now lives inside ParkDeferred AFTER SnapToCell, reading body.CellPosition.ObjCellId — the value RestoreParkWithdrawal actually restores at — against every live quiescence rather than one minimum-OperationId token. The three pre-snap fields are hoisted into locals because SnapToCell ends with InWorld = true. ParkCollisionResidents passes restorableOnCancel: false explicitly; the plain unplaceable park is provably unchanged. RestoreParkWithdrawal re-tests the prefix at restore time so a retained route-2 park cannot re-admit into a prefix that began quiescing during the park. CanAttemptDestination is retained as an OPTIMISATION only, with the two Core predicates it cannot reproduce written down at the pre-flight, plus the two properties that depend on it staying there. Four fix rounds and eight Opus reviews. The slice was fully green at 10,990, 10,997 and 11,004 while containing real defects — a frozen remote pinned as correct by its own test, a fallback that over-wrote on the exact retail paths that decline to store, and a park guard incomplete on two independent axes. Register: AP-137 (leftover classifications take AP-87's catch-up; states the cell-less enqueue-vs-place delta deferred to 4b-3, that RejectedData is applied anyway, and the headless divergence), AP-138 (the refusable far placement), AP-136 narrowed to match the relocation. #309's acceptance steps rewritten — step 5 previously asserted a recovery the code does not perform — and gated on a new ACDREAM_PROBE_PARK=1 signal so the check cannot pass while broken. Suite 11,009 passed / 4 skipped / 0 failed against a measured 10,968 baseline. The 10,973 figure recorded earlier was wrong and is corrected here. Connected gate outstanding: the two-client far-snap walk and #309. Co-Authored-By: Claude Opus 5 --- docs/ISSUES.md | 65 +- .../retail-divergence-register.md | 8 +- ...-04-c4-route-4b-2-delta-review-findings.md | 232 ++++ ...026-08-04-c4-route-4b-2-review-findings.md | 206 +++ ...6-08-04-c4-route-4b-2-round3-correction.md | 211 +++ ...6-08-04-c4-route-4b-2-round4-correction.md | 174 +++ .../Composition/SessionPlayerComposition.cs | 16 +- .../Net/GraphicalSessionEventRoute.cs | 20 +- .../Net/LiveSessionRuntimeFactory.cs | 6 +- .../LiveEntityNetworkUpdateController.cs | 563 +++++--- .../Physics/PhysicsDiagnostics.cs | 24 + .../Entities/RuntimeEntityObjectLifetime.cs | 15 +- .../Physics/RuntimeRemoteFarSnapPosition.cs | 208 +++ .../RuntimeRemoteSteadyStatePosition.cs | 32 +- .../Physics/RuntimeSetPositionState.cs | 255 +++- .../RuntimeAcceptedPositionDriveController.cs | 14 +- .../RuntimeRemotePlacementDriveController.cs | 817 +++++++++++- ...ityNetworkRemoteFarSnapIntegrationTests.cs | 582 +++++++++ ...etworkRemoteSteadyStateIntegrationTests.cs | 56 +- .../Physics/RemotePlacementDriveFixture.cs | 218 ++++ .../RuntimeRemoteFarSnapPositionTests.cs | 312 +++++ .../RuntimeRemoteSteadyStatePositionTests.cs | 43 +- ...imeAcceptedPositionDriveControllerTests.cs | 384 +++++- ...timeRemotePlacementDriveControllerTests.cs | 1148 ++++++++++++++++- 24 files changed, 5234 insertions(+), 375 deletions(-) create mode 100644 docs/research/2026-08-04-c4-route-4b-2-delta-review-findings.md create mode 100644 docs/research/2026-08-04-c4-route-4b-2-review-findings.md create mode 100644 docs/research/2026-08-04-c4-route-4b-2-round3-correction.md create mode 100644 docs/research/2026-08-04-c4-route-4b-2-round4-correction.md create mode 100644 src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs create mode 100644 tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteFarSnapIntegrationTests.cs create mode 100644 tests/AcDream.App.Tests/Physics/RemotePlacementDriveFixture.cs create mode 100644 tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteFarSnapPositionTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 54403e6a..2d6d85ce 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -84,9 +84,28 @@ route 4b-1 contract's "no connected gate" line does not apply to the park fix. core behind EVERY production placement, and the merge-time `restoreCancelledPark: true` is on the accepted-Position path every remote and the local player traverse. So shipped behaviour changes for any entity whose -placement parks. +placement parks — including the local player, whose route-2 ForcePosition +corrections now roll a cancelled quiescence park back instead of leaving the +character withdrawn for the rest of the session. + +Proposed connected check (two clients, local ACE). Scope widened 2026-08-04 at +C4 route 4b-2 round 3: the slice made `SubmitPreparedPlacementCore`'s two +collision-prefix QUIESCENCE parks restorable as well, which the original steps +(the plain unplaceable-destination park only) never exercised, and made the +LOCAL PLAYER traverse the same restore rather than only remotes. + +**Run the whole gate with `ACDREAM_PROBE_PARK=1`** (added 2026-08-04, round 4). +Steps 4 and 5 both need a ForcePosition to land INSIDE a transient +collision-prefix quiescence window, which the tester cannot synchronise with a +teleport or portal arrival — so without a signal a clean teleport and a +correctly-parked one look identical and those steps pass while broken. The flag +emits one `[park]` line per park (guid, cause, the caller's pre-snap +`resultCell`, the POST-snap `restoreCell` the rollback would use, `eligible` = +the caller's half, `captured` = the final decision) and one `[park-restore]` +line per rollback (`residency` = whether canonical residency was re-taken). +Nothing is emitted for an ordinary committing placement, so an empty log means +the window was never entered — retry the step; do not record a pass. -Proposed connected check (two clients, local ACE): 1. Walk the observed character to a landblock boundary so a remote sits in a landblock the observer has not streamed, forcing a `DeferredCell` park. 2. Confirm the remote no longer vanishes permanently — the pre-fix symptom was @@ -94,9 +113,45 @@ Proposed connected check (two clients, local ACE): 3. Confirm it appears at the SERVER-authoritative destination pose, not at a stale pre-park pose, and that it becomes collidable once the landblock publishes. -4. Confirm the local player's own ForcePosition corrections (route 2) still - land unchanged — that path shares the same cancel. -Steps 1-3 are the user-visible acceptance for AP-136's residual. +4. **Quiescing swept NEIGHBOUR (new).** Stand/run within about a metre of a + landblock seam while the neighbouring landblock across that seam is being + retired or republished by streaming (recentre by travelling, then provoke a + server ForcePosition — a `/teleport`-class correction or a portal arrival — + at the seam). The sweep footprint reaches the quiescing neighbour, so the + placement parks even though neither the source nor the destination is + quiescing. Confirm the LOCAL PLAYER is not left frozen/invisible after the + next server Position: it must stay in the world, keep simulating, and stay + collidable. **Performed only when the log shows BOTH** `[park] … + cause=quiescence:0x … eligible=True captured=True` **and a + later** `[park-restore] … residency=True` for the same guid. This is the + shape round 3 measured as reachable; the "quiescing source landblock" shape + is NOT reachable through either accepted-Position caller on a first submit, + because both commit the accepted wire cell to `record.FullCellId` before + submitting. +5. **Quiescing DESTINATION (new).** Provoke a ForcePosition into a landblock + that is mid-retirement. The park is deliberately NOT restored here (AP-136's + reason applies exactly): the character is left withdrawn — out of world, + object clock suspended, not a spatial root. + **Corrected 2026-08-04 (round 4).** The earlier text asked the tester to + confirm the player "recovers on the next server Position rather than staying + withdrawn indefinitely". It does not recover on that packet, and + `QuiescingDestinationPrefix_ForcePositionParkIsNotRestored` pins the + opposite: route 2 dispatches only for `ForcePosition`, so the ordinary + `Apply` that follows merges behind the drive and cancels the park WITHOUT + restoring it. Recovery needs a later packet that actually runs a placement + and commits — another accepted ForcePosition (correction, teleport, or + portal arrival) once that landblock's quiescence has released. Confirm + exactly that, and confirm streaming's retirement of that landblock still + COMPLETES rather than stalling; the retirement is the thing the + non-restorable park exists to protect. **Performed only when the log shows** + `[park] … cause=quiescence:0x … eligible=True + captured=False` (eligible-but-declined is the decision under test) **and no** + `[park-restore]` **line for that guid until the recovering ForcePosition.** +6. Confirm the local player's own ordinary ForcePosition corrections (route 2) + still land unchanged — that path shares the same cancel — including the + case where the correction lands in a landblock that is NOT quiescing, which + must be indistinguishable from pre-slice behaviour. +Steps 1-5 are the user-visible acceptance for AP-136's residual. ## #310 — Retained preparation retry stalls landblock retirement with no bound diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index cfef3311..b9859318 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -159,7 +159,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 94 active rows (AP-136 filed 2026-08-04, C4 route 4b-1 review — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 96 active rows (AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review and NARROWED 2026-08-04 at the C4 route 4b-2 delta review — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -239,7 +239,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). No current mover sets PerfectClip: players never do, and shipped ordinary missiles add PathClipped only. The non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified. Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping that mover | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` | | AP-91 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port reads the unreadable x87 tail from ACE `Sphere.CollideWithPoint`/`FindTimeOfCollision`; no current mover sets PerfectClip, and shipped ordinary missiles add PathClipped only | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint`; `FindSphereTimeOfCollision`) | Load-bearing non-PerfectClip behavior is named-decomp verified; the adapted branch remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, an ACE/retail TOI delta could cause clip-through or wrong sphere-target deflection | `CSphere::collide_with_point @ 0x00537230`; ACE `Sphere.CollideWithPoint` | | AP-86 | **Remote SHADOW-follows-resolved via a pose/cell-gated per-tick re-flood** (remote-creature de-overlap #184): every remote's collision shadow is rewritten at the resolved body position by the DR tick or authoritative UP tail, so collision remains where the creature renders and de-overlap persists. The effect matches retail, but acdream runs the full multipart cell flood whenever the body moved more than 1 cm, changed complete orientation, or crossed a cell instead of translating the existing shadow in place and relinking only when its crossed-cell set changes. Cross-cell motion now commits body/root/full-cell before the canonical rebucket callback; local and authoritative remote publishers prove exact-record spatial residency after that callback; pending projection suspends the retained shadow and cannot re-add it, including initial-pending and callback GUID-reuse cases. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs`; `src/AcDream.App/Physics/LiveEntityShadowPublisher.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (local projection); `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (authoritative UP tails); `src/AcDream.App/World/LiveEntityPresentationController.cs` (ordinary projection residency); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The pose/cell gate is exact at de-overlap equilibrium, preserves offset/multipart shapes during in-place turns, and the resulting registered cell set matches retail; loaded/pending residency is symmetric and incarnation-scoped | A dense moving or turning crowd can still perform a full registration flood per creature per tick and create CPU/Gen0 pressure; a still crowd is gated out. Retire with an in-place move plus cell-relink-on-change implementation | `CPhysicsObj::SetPositionInternal(CTransition const*)` 0x00515330 → `change_cell`, then `remove_shadows_from_cells`/`add_shadows_to_cells` after the resolved frame/contact commit | -| AP-87 | **Remote MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07; unified across player-remote and NPC-remote by C4 route 4a, 2026-08-03 — retail's disassembly makes no `this==player` distinction here either, so the two formerly-duplicated per-kind copies are now the SAME decision): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. A third condition `firstUp` (`LastServerPosTime <= 0`) is RETAINED, not dropped, in the unified seam: it is a belt hint only — the 4 m guard is the load-bearing backstop — and it is structurally false for player remotes because the player-remote caller stamps `LastServerPosTime` in its diagnostic roll-forward block before it routes, so unifying the two copies on all three conditions leaves the player branch's own behaviour bit-identical | `src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs` (`ApplyInterpolate`, `BodySnapThreshold`); called from `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` for both the player-remote and NPC-remote near-Interpolate branches. The legacy far/cell-less/rejected fallbacks in that file still carry their own pre-existing copies of these constants until C4 route 4b deletes them | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare. **2026-08-04 observed live**: the route 4a two-client test caught exactly this risk — a player remote jumping onto a house roof plants there (the landing block's unconditional `OnWalkable` forces the body to treat the steep roof as walkable, so it never slides) and sits until it has drifted >4 m from the server's actual slid-down position, at which point this backstop fires and blips it to that position instead of sliding smoothly (Bug B, `docs/ISSUES.md` #32) | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) | +| AP-87 | **Remote MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07; unified across player-remote and NPC-remote by C4 route 4a, 2026-08-03 — retail's disassembly makes no `this==player` distinction here either, so the two formerly-duplicated per-kind copies are now the SAME decision): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. A third condition `firstUp` (`LastServerPosTime <= 0`) is RETAINED, not dropped, in the unified seam: it is a belt hint only — the 4 m guard is the load-bearing backstop — and it is structurally false for player remotes because the player-remote caller stamps `LastServerPosTime` in its diagnostic roll-forward block before it routes, so unifying the two copies on all three conditions leaves the player branch's own behaviour bit-identical | `src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs` (`ApplyInterpolate`, `BodySnapThreshold`); called from `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` for both the player-remote and NPC-remote near-Interpolate branches. C4 route 4b-2 (2026-08-04) deleted the App's two duplicated `MaxPhysicsDistance = 96f` / `BodySnapThreshold = 4f` constant pairs and both `_playerController?.Position ?? Vector3.Zero` fabrications: the far branch is now a canonical Runtime placement, and the cell-less/rejected/unclassified leftovers call this same seam (AP-137). The 4 m constant exists in exactly one place | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare. **2026-08-04 observed live**: the route 4a two-client test caught exactly this risk — a player remote jumping onto a house roof plants there (the landing block's unconditional `OnWalkable` forces the body to treat the steep roof as walkable, so it never slides) and sits until it has drifted >4 m from the server's actual slid-down position, at which point this backstop fires and blips it to that position instead of sliding smoothly (Bug B, `docs/ISSUES.md` #32) | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) | | AP-89 | **TransparentPartHook fade multiplies the SAMPLED TEXTURE alpha, not a separate material alpha channel** (#188, 2026-07-08 — the fading-wall secret-passage doors, e.g. "Pedestal Weak Spot"): retail's `CPhysicsPart::SetTranslucency` (0x0050e670) → `CMaterial::SetTranslucencySimple` (0x005396f0) REPLACES the D3D9 material's 4 alpha channels wholesale (`Ambient.a = Diffuse.a = Specular.a = Emissive.a = 1 − translucency`) — a per-material alpha that composes with, but is conceptually separate from, the surface's own sampled texture alpha. acdream's `mesh_modern.frag` has no material-alpha concept at all; the port multiplies the runtime fade's opacity multiplier directly against the already-sampled `color.a` (`FragColor = vec4(rgb, color.a * vOpacityMultiplier)`) | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (final `FragColor` line); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`ClassifyBatches` `opacityMultiplier` param, `InstanceGroup.Opacities`); `src/AcDream.Core/Rendering/TranslucencyFadeManager.cs` | Observably identical to retail for any surface whose base texture alpha is 1.0 everywhere — the Pedestal Weak Spot's stone-wall texture, and the overwhelming majority of AC surfaces, since `color.a * 1.0 == color.a` and the fade multiplier alone then drives the ramp exactly as `1 − translucency` would | A hypothetical object that is BOTH already alpha-keyed/blended from its own texture (stained glass, a flame surface) AND plays a TransparentPartHook fade simultaneously would compound the two alphas (texture-alpha × fade-multiplier) instead of the fade cleanly replacing/overriding the surface's own alpha as retail's material-replace does — such an object would fade darker / more-transparent than retail, not just at retail's rate | `CPhysicsPart::SetTranslucency` 0x0050e670; `CMaterial::SetTranslucencySimple` 0x005396f0 (`alpha = 1 − translucency`, applied to all 4 D3D9 material alpha channels) | | AP-90 | **Radar fellowship/allegiance relationship state is modeled but not yet delivered at runtime.** `RetailRadar.GetBlipShape` and `RadarBlipColors.For` implement retail's leader/member/allegiance precedence, and `RadarSnapshotProvider` exposes a `relationshipFor(guid)` seam, but acdream does not yet maintain live fellowship membership and its `AllegianceTree` is not wired into GameWindow. PK/PKLite relationship shapes do work from PWD flags. | `src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs`; `src/AcDream.Core/Ui/RetailRadar.cs`; `src/AcDream.Core/Ui/RadarBlipColors.cs` | Preserve the exact model/seam now and avoid inventing membership from names or chat; connect it when the social game-event state is ported | Fellowship members render their ordinary player color/shape instead of bright-green leader/member triangles; allegiance members render an ordinary plus instead of a hollow box | `gmRadarUI::GetBlipColor` 0x004D76F0; `gmRadarUI::GetBlipShape` 0x004D7B60 | | AP-92 | Private creature viewports (paperdoll and examination) render through isolated `IGpuRenderTarget`s and blit into `UiViewport`; retail renders each `CreatureMode` directly and advances a cloned `CPhysicsObj`, while examination currently refreshes its clone from the live target's animated mesh pose. **V6l narrowing (2026-07-28), V11 update (2026-07-29):** the target is backend-neutral and the blit's V origin is not assumed — `IUiViewportRenderer.TextureIsBottomUp` derives it from the backend that made the texture. With GL deleted the only answer in the tree is Vulkan's top-left origin, but the seam is kept rather than folded flat, because it costs one property and it is what let the origin question be answered by data instead of by assumption. | `src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs`; `src/AcDream.App/Rendering/PaperdollFramePresenter.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.App/UI/UiViewport.cs` | Vulkan render-to-texture is the modern backend equivalent; shared live pose data provides animation without registering a second gameplay entity or duplicating the world sequencer | Alpha, lighting, state isolation, or an assessment-time cloned motion diverging later from the live target can differ from direct retail CreatureMode presentation. The origin half of this risk is closed; a FUTURE backend would have to answer `TextureIsBottomUp` for itself | `CPhysicsObj::makeObject(CPhysicsObj const*) @ 0x005144B0`; `gmPaperDollUI::PostInit @ 0x004A5360`; `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` | @@ -282,7 +282,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-133 | **Filed 2026-08-03 (#282).** A retail `CPhysicsObj` has exactly ONE `cell`; `ShouldDrawParticles` @0x0050fe60 reads that same field and calls `IsInView` on it, and `set_cell_id` @0x0050f4f0 / `change_cell` @0x00513390 are the only things that move it. acdream splits the concept into `WorldEntity.ParentCellId` (render parent, null for outdoor dat stabs and building shells) and `WorldEntity.EffectCellId` (authored landcell for those parentless stabs). Every consumer now resolves through the single `WorldEntity.VisibilityCellId` accessor (`ParentCellId ?? EffectCellId`); live entities carry `ParentCellId` only. | `src/AcDream.Core/World/WorldEntity.cs` (`VisibilityCellId`); writers `LandblockLoader.cs:80,97`, `LandblockBuildFactory.cs:408` | Outdoor dat stabs deliberately keep a null render parent so portal visibility does not filter them as interior geometry, yet retail still gives their physics object a landcell for particle gating. One accessor keeps the two fields from being read in conflicting orders, which is exactly how #282 arose - `EntityEffectPoseRegistry` preferred `EffectCellId` while `WbDrawDispatcher` and the remote spawn seed preferred `ParentCellId`. | A future writer that sets `EffectCellId` on a live entity re-creates #282: it wins `VisibilityCellId` while the 11 per-tick `ParentCellId` writers leave it frozen, stranding that entity's particles and lights on a stale cell so they fail `IsInView` after it crosses a boundary. | `CPhysicsObj::ShouldDrawParticles` 0x0050fe60; `CPhysicsObj::set_cell_id` 0x0050f4f0; `CPhysicsObj::change_cell` 0x00513390 | | AP-134 | **Filed 2026-08-03 (#297).** Retail keeps ONE `PublicWeenieDesc::_bitfield` per object and mutates it in place — `SetPlayerKillerStatus` @0x005AC7C0 rewrites bits 5/21/25 (PK `0x20` / Free `0x200000` / PKLite `0x2000000`, mutually exclusive), driven from `ACCWeenieObject::OnStatUpdated` @0x0058DF20 `case 0x86`, and `IsPK`/`IsImpenetrable`/`IsPKLite` @0x0058C8xx read that same field. acdream replicates the value into FIVE stores: `ClientObject.PublicWeenieBitfield` (the source, written only by `ClientObjectTable.UpdateIntProperty` on PropertyInt 134), `InboundPhysicsStateController._snapshots[guid].ObjectDescriptionFlags`, `RuntimeEntityRecord.Snapshot.ObjectDescriptionFlags`, the decoded `ShadowObjectRegistry` registration + per-cell `ShadowEntry.Flags`, and the local player's `RuntimeMovementSkillState` own-PWD bitfield. Coherence is maintained by two `ObjectUpdated` subscribers (`RuntimeEntityPvpBitfieldSnapshotSync` for the two snapshot stores, `LiveEntityPvpBitfieldSync` for the decoded shadow flags) plus the appearance-rebuild path re-deriving from the snapshot. The two shadow-flag writers are the SAME invalidation applied at the two edges that can invalidate it, not competing authorities. | `src/AcDream.Runtime/Entities/RuntimeEntityPvpBitfieldSnapshotSync.cs`; `src/AcDream.App/Physics/LiveEntityPvpBitfieldSync.cs`; source writer `src/AcDream.Core/Items/ClientObjectTable.cs` (`UpdateIntProperty`, PropertyInt 134); decode `EntityCollisionFlagsExt.FromPwdBitfield` | ACE never re-sends a `PublicWeenieDesc` after login (`EnqueueBroadcastUpdateObject` has zero live callers), so PropertyInt 134 over 0x02CE/0x02CD is the ONLY signal a PK status changed — a client cannot learn it from the bitfield itself. The replication exists because acdream separates wire snapshots, canonical records, and the collision shadow registry, which retail does not; each layer needs the decoded value at a different lifetime. Before #297 the snapshot stores were immutable wire captures; this commit is what converts them into write-through caches, and therefore what creates the invariant. | Any future write path that sets `ClientObject.PublicWeenieBitfield` outside `UpdateIntProperty`, or any NEW decoded cache of the PK bits, silently re-creates #297: the player walks through PKLite opponents and melee/missile admission refuses them, with no test failing. Note the same class already exists one field over — `Properties.Ints[134]` is written by `UpsertProperties` (PlayerDescription 0x0013) and `UpdateProperties` (IdentifyObjectResponse) WITHOUT mirroring into the bitfield (#300), and retail's `OnStatUpdated` also rewrites `_blipColor` (`case 0x5f`) and `_radar_enum` (`case 0x85`) which acdream ignores entirely (#301). | `PublicWeenieDesc::SetPlayerKillerStatus` 0x005AC7C0; `ACCWeenieObject::OnStatUpdated` 0x0058DF20 (`case 0x86`); `ACCWeenieObject::IsPK`/`IsImpenetrable`/`IsPKLite` 0x0058C8xx; retail `PKStatusEnum` `acclient.h:6412-6427` | | AP-135 | **Filed 2026-08-03 (C4 route 4a).** Retail `CPhysicsObj::MoveOrTeleport` 0x00516330 writes NOTHING on the airborne no-op (`arg4 == 0` -> `return 0` @0x0051636D), and `SmartBox::HandleReceivedPosition` 0x00453FD0 skips `ConstrainTo` with it (@0x00454272 sits inside `if (MoveOrTeleport(...) != 0)` @0x00454254). acdream honours that for every retail-modeled write — body pose, interpolation queue, leash, render entity, collision shadow, and the AP-80 velocity-derived animation cycle — but deliberately KEEPS two acdream-only per-packet bookkeeping writes on that branch: `RemoteMotion.CellId = wire landblock` and the `LastServerPos`/`LastServerPosTime` sample. This was pre-existing player-remote behaviour; route 4a extends it to NPC remotes so both arms are identical | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition`, both remote airborne-no-op returns) | The cell id is what acdream's OWN per-tick free-fall `ResolveWithTransition` sweep gates on (`rm.CellId != 0`); without it an airborne remote's sphere sweep is skipped and it falls through the floor (#42's neighbourhood). The server sample is what the first grounded packet after the arc synthesizes its velocity from; dropping it would make that velocity span the whole jump. Neither is a retail `CPhysicsObj` field being written | A remote's cell membership tracks the server's landblock during an arc where retail would keep the cell its own physics last resolved. Visible only if the server's mid-arc landblock disagrees with the client's swept cell — the wire cell is authoritative in every case acdream has observed. Retire together with the free-fall sweep gate, when the remote arc is resolved by the same transition machinery the local player uses | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x0051636D `return 0`); `SmartBox::HandleReceivedPosition` 0x00453FD0 (@0x00454254/@0x00454272) | -| AP-136 | **Filed 2026-08-04 (C4 route 4b-1 review).** Retail has NO cancel for a lost-cell park. `CPhysicsObj::SetPositionInternal` @0x00515BD0 commits the destination pose with `store_position` @0x00515CE2 and registers the object via `CObjectMaint::GotoLostCell` @0x00515CF2 (@0x00508210); the registration is removed by exactly one thing, `CObjectMaint::InitObjCell` @0x00508260, which drains the lost list on cell load and calls `CPhysicsObj::reenter_visibility` @0x00508296 (@0x00516250) to re-place at the committed pose. An update that performs no SetPosition leaves the registration untouched, so retail keeps the object HIDDEN until its cell loads. acdream's accepted-Position merge cancels the park instead (a shipped, tested invariant), so on cancel we roll the withdrawal back — `InWorld`, object clock, canonical residency — and the entity becomes VISIBLE IMMEDIATELY at the committed destination pose, uncollidable until its landblock publishes. The pose itself is retail-exact and is deliberately not rolled back. The `ShadowObjectRegistry.Suspend` applied by `WithdrawCanonical` is also not lifted, because un-suspending needs a real placement dispatch (`ReplacePositionRows`); the entity rejoins the broadphase on its next placement | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s `restorableOnCancel`, `Forget`, `RestoreParkWithdrawal`) | The alternative — leaving the cancelled park's withdrawal in place — strands the entity invisible AND intangible for the rest of the session, because `CancelCoreDeferred` restores none of it and the operation that was the only thing able to wake it is gone. Restoring at the pre-park pose was tried and is wrong: retail commits the destination pose, and route 2's tests pin that the pose survives the cancel. Restore is scoped to the plain unplaceable-destination park only; quiescence/retirement parks are deliberately NOT restored, since re-admitting a spatial root into a retiring prefix blocks the retirement | A remote that teleports into a non-resident landblock and then STOPS MOVING stays visible at the destination without collision, where retail would hide it and re-show it on cell load — ACE stops broadcasting for a stationary entity, so no later packet corrects it. At 5-10 Hz the ordinary case is superseded within ~150 ms. Retire by making the park SURVIVE cancellation (issue #309), which is blocked on re-deciding the newer-Position-cancels-the-park invariant pinned by `NewerPositionPickupAndParentEachCancelExactLostOperation` and on teardown convergence. **This row carries a user-observable change to shipped paths** — `restorableOnCancel: true` sits in `SubmitPreparedPlacementCore`, the shared core behind every production placement — so it needs the two-client connected check written up in #309 (force a remote to park across a landblock boundary; confirm it no longer vanishes permanently, appears at the server-authoritative destination pose rather than a stale one, becomes collidable once the landblock publishes, and that route 2's local ForcePosition corrections are unchanged) | `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D/@0x00515CDA/@0x00515CE2/@0x00515CF2/@0x00515CF7/@0x00515D07); `CObjectMaint::GotoLostCell` @0x00508210; `CObjectMaint::InitObjCell` @0x00508260 (@0x00508296); `CPhysicsObj::reenter_visibility` @0x00516250 | +| AP-136 | **Filed 2026-08-04 (C4 route 4b-1 review).** Retail has NO cancel for a lost-cell park. `CPhysicsObj::SetPositionInternal` @0x00515BD0 commits the destination pose with `store_position` @0x00515CE2 and registers the object via `CObjectMaint::GotoLostCell` @0x00515CF2 (@0x00508210); the registration is removed by exactly one thing, `CObjectMaint::InitObjCell` @0x00508260, which drains the lost list on cell load and calls `CPhysicsObj::reenter_visibility` @0x00508296 (@0x00516250) to re-place at the committed pose. An update that performs no SetPosition leaves the registration untouched, so retail keeps the object HIDDEN until its cell loads. acdream's accepted-Position merge cancels the park instead (a shipped, tested invariant), so on cancel we roll the withdrawal back — `InWorld`, object clock, canonical residency — and the entity becomes VISIBLE IMMEDIATELY at the committed destination pose, uncollidable until its landblock publishes. The pose itself is retail-exact and is deliberately not rolled back. The `ShadowObjectRegistry.Suspend` applied by `WithdrawCanonical` is also not lifted, because un-suspending needs a real placement dispatch (`ReplacePositionRows`); the entity rejoins the broadphase on its next placement | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s `restorableOnCancel`, `Forget`, `RestoreParkWithdrawal`) | The alternative — leaving the cancelled park's withdrawal in place — strands the entity invisible AND intangible for the rest of the session, because `CancelCoreDeferred` restores none of it and the operation that was the only thing able to wake it is gone. Restoring at the pre-park pose was tried and is wrong: retail commits the destination pose, and route 2's tests pin that the pose survives the cancel. Restore covers the plain unplaceable-destination park and — **narrowed 2026-08-04 at the C4 route 4b-2 delta review, then relocated at that slice's round 3** — `SubmitPreparedPlacementCore`'s two collision-prefix-QUIESCENCE parks. **Corrected round 4 (D1): NOT "unconditionally" for any of the three.** Since the relocation, the same post-snap quiescence test gates EVERY park including the plain one, which the row's own next sentences already described; the word contradicted them. The original blanket "no quiescence park is restorable" was over-broad: its stated reason — re-admitting a spatial root into a retiring prefix blocks the retirement — is exact for `ParkCollisionResidents`, where the entity's OWN cell is retiring, but `TryGetBlockingQuiescence` also fires on prefixes the placement merely TOUCHES (any `QueriedCellIds` entry, i.e. a NEIGHBOUR landblock the sweep crossed a seam into; and the request's `CurrentCellId`, which on a FIRST submit names the destination rather than the departed source because both accepted-Position callers commit the accepted wire cell to `record.FullCellId` before submitting — scoped at round 4 (D5): a retained retry re-submits with no fresh merge, and `RemoteTeleportController`'s rollback can rebucket that field to the pre-teleport landblock, so the arm is live). The decision is taken inside `ParkDeferred`, AFTER `SnapToCell`, as `!IsCollisionPrefixQuiescing(body.CellPosition.ObjCellId)` — the cell `RestoreParkWithdrawal` will actually restore residency into, tested against EVERY live quiescence rather than against the single minimum-`OperationId` token `TryGetBlockingQuiescence` happened to return, and read after `LandDefs.AdjustToOutside` may have moved it (the reachable half of that, and the one a test now pins, is the re-derived cell: a wire (cell, position) pair whose position lies past its own named block's seam is exactly the pair #107's re-derivation distrusts, and it lands residency in a NEIGHBOUR landblock — see `QuiescingOwnPrefix_SeamCrossingParkIsRestoredAtTheReDerivedNeighbourCell`). A cell id of 0 is `AdjustToOutside`'s map-edge failure sentinel, never landblock (0,0), so the map is not consulted with it (C3c-F3). **Round 4 (D6) added the same test at RESTORE time**, in `RestoreParkWithdrawal`'s residency arm: the park-time answer is a snapshot, and route 2's park is RETAINED until the next packet's merge-time `Forget` ~150 ms later, so a prefix clean when the park was taken can be quiescing when the rollback runs. The `InWorld`/transient/clock half is still restored unconditionally — it is per-entity simulation state, not a claim on any landblock's collision generation. So the rollback re-admits nothing into ANY quiescing prefix, at the moment it actually writes residency rather than only as of when the park was taken, while leaving these parks non-restorable stranded the entity `InWorld = false` / clock suspended / not a spatial root with the only operation able to wake it destroyed by its own next accepted Position. A retirement park (`ParkCollisionResidents`) is still never restored, and now says so explicitly rather than relying on a parameter default | A remote — or the LOCAL PLAYER, which traverses the same shared core through route 2 — that teleports into a non-resident landblock and then STOPS MOVING stays visible at the destination without collision, where retail would hide it and re-show it on cell load — ACE stops broadcasting for a stationary entity, so no later packet corrects it. At 5-10 Hz the ordinary case is superseded within ~150 ms. Retire by making the park SURVIVE cancellation (issue #309), which is blocked on re-deciding the newer-Position-cancels-the-park invariant pinned by `NewerPositionPickupAndParentEachCancelExactLostOperation` and on teardown convergence. **This row carries a user-observable change to shipped paths** — `restorableOnCancel: true` sits in `SubmitPreparedPlacementCore`, the shared core behind every production placement — so it needs the two-client connected check written up in #309 — **rewritten by this slice, not merely extended (round-4 D2 correction: this summary used to describe only the original three remote steps plus "route 2's corrections are unchanged", which no longer matches the issue's own body)**. #309 is now six steps run with `ACDREAM_PROBE_PARK=1`: the three original remote-park steps, plus a quiescing swept-NEIGHBOUR step and a quiescing-DESTINATION step that both exercise the LOCAL PLAYER through route 2 and both carry a stated `[park]`/`[park-restore]` confirmation signal (a quiescence window cannot be synchronised by hand, so without one the step passes while broken), plus the unchanged-ordinary-correction step. Step 5 also asks the tester to confirm the destination landblock's retirement still COMPLETES, and states correctly that the deliberately non-restored park does NOT recover on the next ordinary Position | `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D/@0x00515CDA/@0x00515CE2/@0x00515CF2/@0x00515CF7/@0x00515D07); `CObjectMaint::GotoLostCell` @0x00508210; `CObjectMaint::InitObjCell` @0x00508260 (@0x00508296); `CPhysicsObj::reenter_visibility` @0x00516250 | +| AP-137 | **Filed 2026-08-04 (C4 route 4b-2); rewritten same day at the dual Opus review.** acdream can classify a remote's accepted Position into three states retail cannot reach, and they now share ONE stated handler instead of a duplicated near/far block. The states: (a) **no classification at all** — `RuntimeAcceptedPositionRouteRequests.TryBuild` refuses to fabricate a local-player position, so `ClassifyRemoteAcceptedPosition` returns null for EVERY remote packet until the local movement controller exists (the login window) and whenever the canonical record has not claimed a local id; (b) **`RejectedAuthority`/`RejectedData`** — acdream validates wire authority and payload finiteness, retail validates neither; (c) the **cell-less `SetPosition`** half, which retail routes through `this_1->cell == 0` @0x00516386 and route 4b-3 will own. All three take AP-87's shared `ApplyInterpolate` catch-up (`RuntimeRemoteFarSnapPosition.ResolveArm`'s `UnroutedCatchUp`). **R1 — what the deleted far test actually computed (the first version of this row was wrong).** It claimed the deleted `_playerController?.Position ?? Vector3.Zero` distance had "no relationship to `player_distance`". Not true: `worldPos` is streaming-origin-relative (local position + `(landblock − _origin.Center) × 192 m`) and the streaming origin recentres on the local player's landblock, so the fabricated distance measured the remote's range from the ORIGIN LANDBLOCK'S CORNER — a biased but genuinely correlated proxy, error bounded by the player's own offset inside that landblock (0–192 m per axis). It is deleted anyway because a silently-biased proxy for an exact 96 m threshold is not a threshold: the bias reaches ~2.8× the threshold, so the arm it selects is frequently not the arm retail selects, and correcting it needs exactly the player position the classifier declined to fabricate. **R2 — the cell-less delta, stated.** Retail's cell-less arm is an UNCONDITIONAL placement sitting AHEAD of the contact test (`teleport_hook` @0x005163EF, `SetPosition` flags `0x1012` @0x00516420, `return 1` @0x00516438); acdream now ENQUEUES that classification whenever `!firstUp && willBeDrTicked && bodyToTarget <= 4 m`, at ANY distance, not only ≥96 m. **Deliberately not changed to place in 4b-2**: retail's arm is not a pose write, it is `teleport_hook` @0x00514ED0 — the COMPLETE call list, corrected at the 2026-08-04 delta review, which found the earlier enumeration had dropped the last entry: `MovementManager::CancelMoveTo` @0x00514EDF, `PositionManager::UnStick` @0x00514EEE, `PositionManager::StopInterpolating` @0x00514EFD, `PositionManager::UnConstrain` @0x00514F0C, `TargetManager::ClearTarget` @0x00514F1B + `TargetManager::NotifyVoyeurOfEvent(Teleported_TargetStatus)` @0x00514F28, and `CPhysicsObj::report_collision_end(this, 1)` @0x00514F31 — followed by the canonical flags-`0x1012` `SetPosition` — the classifier itself records this as `TeleportHookPhase.BeforePositionOperation`. Writing only the pose would leave a live moveto, a live stick, and a leash anchored to the old cell, strictly worse than the queue. Porting the whole arm is route 4b-3's entire scope. **R3 — `RejectedData` is APPLIED anyway.** It is the one classification meaning "this payload failed validation" (`ClassifyAcceptedPosition` emits it for a `ValidPosition` failure and for a non-finite/negative derived `player_distance`), and `UnroutedCatchUp` hands the same payload to `ApplyInterpolate`. Not a regression — the legacy block did the same — but the slice's stated purpose was an explicit handler, so it is named. **Headless (contract item 6) is satisfied vacuously and that is stated, not implied:** nothing in `AcDream.Headless` constructs `RuntimeRemotePlacementDriveController` (`SessionPlayerComposition` is the only construction site) and `RuntimeLiveEntitySessionController.OnPositionUpdated` returns early for every non-local GUID, so the far snap is a graphical-host-only path | `src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs` (`ResolveArm`, `RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp`); applied at `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ApplyRemoteContactRouting`'s default arm); headless statement on `IRuntimeRemotePlacementServiceWindow` | AP-87's own snap conditions (`firstUp \|\| !willBeDrTicked \|\| bodyToTarget > 4 m`) still PLACE an unplaced or badly-lagging body, so a remote keeps tracking the server through the login window and through a rejected packet — this arm is never frozen. The deleted far test could not have been preserved honestly: one of its three residual inputs is an uncorrectably biased fabrication, one is unreachable (the classifier returns before evaluating distance), and the third contradicted retail's own branch order | A leftover-classified remote beyond 96 m that is already tracking catches up over a packet interval instead of snapping — invisible in practice at that range, but a real change to the cell-less path that route 4b-3 must re-check when it takes ownership, together with the two rejections' own arm. If AP-87's 4 m backstop were ever weakened, this arm would become the silent-freeze path the route 4b scoping named as its trap | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x00516386 cell-0/teleport, @0x005163AF near, @0x005163C1-E8 far); `CPhysicsObj::teleport_hook` @0x00514ED0; `RuntimeAcceptedPositionRouteRequests.TryBuild`; `GameRuntime.cs:288-290` (the no-fabricated-Vector3.Zero rule) | +| AP-138 | **Filed 2026-08-04 (C4 route 4b-2, dual Opus review).** Retail's remote far snap is unconditional and unrefusable: `CPhysicsObj::MoveOrTeleport` @0x005163D9 calls `SetPositionSimple`, discards its `SetPositionError`, and returns 1 @0x005163E8, so `SmartBox::HandleReceivedPosition` arms `ConstrainTo` @0x00454272 every time. acdream's far snap is a canonical Runtime placement that can decline for reasons retail has no analogue for, and this row records the complete residual. **(1) An outcome that never reached the engine is a `store_position`; one that did is not.** Retail's `SetPositionInternal` @0x00515BD0 has exactly two shapes and acdream now represents both (**corrected 2026-08-04 at the delta review, which found the first version of this row asserting — wrongly — that no acdream non-commit outcome could represent the second**). STORES, because the resolve never ran: `Refused` (the pre-flight declined the destination), `Contention` (another authority owns the operation, or the Setup/world-frame preparation is retryable), `RejectedPreparation` (`RejectedAuthority`/`InvalidData` — preparation refused before anything was submitted), and `NotApplicable`. For those `ApplyAcceptedRemoteFarSnap` writes the accepted destination pose to the canonical body, exactly as retail commits it on the no-transition branch — `prepare_to_leave_visibility` @0x00515CDA, `store_position` @0x00515CE2, `GotoLostCell` @0x00515CF2, `return 0` @0x00515D07 — so the remote keeps tracking the server at 5-10 Hz, at the destination, with no resolved cell; retail would additionally have hidden it until cell load, which is AP-136's scope, not this one. DOES NOT STORE, because the resolve DID run and refused: `RejectedByPlacement` (`PhysicsEngine.SetPosition` returned a non-Ok error, acdream's port of retail's `CheckPositionInternal == 0` @0x00515C85/@0x00515CD5 and `curr_cell == 0` @0x00515C8F/@0x00515CB2, neither of which stores; or authority displaced after the engine ran, which includes the `CommitCanonical`-already-settled shape) and `Deferred` (Core parked, and `ParkDeferred` has ALREADY snapped the body to the parked result — the accepted destination for the pre-sweep park, the collision-settled `spherePath.CurPos` for the post-sweep one — which `RestoreParkWithdrawal` deliberately leaves alone). **(2) A quiescence park a far snap can provoke is now restorable at the source, not refused by a pre-flight.** **Rewritten 2026-08-04 at the delta review.** `CanAttemptDestination` (service window + Core's own `IsCollisionPrefixQuiescing`) reads ONE prefix, the destination's, and stays as an optimisation. It cannot be the correctness mechanism: Core's `PlacementTouchesPrefix` also matches the request's `CurrentCellId` (see the round-3 measurement below for what that arm actually names), and `ResultTouchesPrefix` scans every `QueriedCellIds` entry, a sweep footprint that spans NEIGHBOUR landblocks (`CellTransit.AddOutsideCell` re-derives the block id from the global lcoord and has no same-block filter) and does not EXIST until the sweep has run. Worse, the post-sweep check is `result.IsSuccessful && TryGetBlockingQuiescence(result, …)` and sits ahead of the restorable `result.IsDeferred` park, so a healthy about-to-COMMIT far snap near a seam was rewritten to `DeferredCell` and parked non-restorably. The fix is in `SubmitPreparedPlacementCore`: both quiescence parks are restorable, and `ParkDeferred` decides safety on the cell it will actually restore into — see AP-136 for the exact predicate and for why it does not re-open the retirement stall AP-136's blanket scoping was protecting against. On a FIRST submit the `CurrentCellId` half of `PlacementTouchesPrefix` is NOT the "source landblock a far snap is leaving": both accepted-Position callers commit the accepted wire cell to `record.FullCellId` before submitting (the graphical remote path through `LiveEntityRuntime.RebucketLiveEntity` in its shared prologue, route 2 through the merge), so that arm names the destination — measured 2026-08-04 at round 3. **Scoped at round 4 (D5): that is a first-submit property only, and the arm is live rather than dead code.** A RETAINED operation re-submits from its own cadence pump with no fresh merge (both drives re-read `record.FullCellId` at submit), and `RemoteTeleportController`'s rollback is a shipped writer that rebuckets it back to the PRE-teleport landblock, so a retry can genuinely name a third landblock — which `CanAttemptDestination`'s own doc already said and the two summaries elsewhere contradicted. **(3) The leash is not armed through a superseded incarnation.** Retail arms unconditionally on the nonzero return; acdream re-validates position ownership after the placement (the receipt is published synchronously and the projection sink can replace or delete the incarnation from inside it) and returns without arming if the owner moved. Both remote arms now run that check BEFORE their arming call — the player arm used to arm first, the NPC arm second, and one of the two mirror images had to be wrong | `src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs` (`RuntimeRemotePlacementExecutionStatus` + `StoresAcceptedDestination`, `ApplyAcceptedRemoteFarSnap`, `StoreAcceptedDestinationPose`, `Advance`'s window-drop path, `CanAttemptDestination`, `SubmitAndResolve`); `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s post-snap restorable decision and the two `SubmitPreparedPlacementCore` quiescence parks); `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (both arms' re-validate-then-arm order) | The alternative to (1) is the shipped pre-review state: an emptied interpolation queue plus a stale body pose, i.e. a frozen remote that the next packet reproduces identically, since nothing about a refusal reason changes at packet cadence. That is strictly further from retail than either the deleted legacy block (which always tracked) or retail itself. The alternative tried and rejected in between — storing on EVERY non-commit outcome — is worse still in the other direction: it teleports the canonical body into a destination the engine's own sweep just refused, and overwrites a freshly settled pose (contact plane, step-down) whenever `CommitCanonical` landed and only the projection ownership was displaced. The alternative to (2) — keeping the pre-flight as the correctness mechanism and widening it — is structurally impossible, because the swept footprint half of Core's predicate does not exist until the sweep has run; the alternative of leaving the parks non-restorable strands the remote outright. The alternative to (3) — arming a leash on a host that is no longer the entity's canonical position owner — is a write through superseded state, the exact class the re-validation exists to prevent, and retail has no superseded-incarnation state for its unconditional arm to arbitrate | A remote whose destination this host cannot place into keeps moving and rendering but does not become collidable or cell-resident until a later packet commits — it can be walked through at range. Bounded by the 5-10 Hz packet stream and by how long the destination stays unpublished/quiescing. A remote whose destination the ENGINE refuses, or whose commit was displaced, keeps its last resolved pose for that packet instead of tracking — retail-exact, but it means a remote can look one packet stale near geometry it cannot be placed into. A quiescence park whose blocking prefix is a swept neighbour re-shows the entity immediately at the destination rather than hiding it until cell load (AP-136's own residual, now reachable through this path and through route 2's local-player corrections). A superseded incarnation's leash is left unarmed for one packet; the replacement incarnation arms its own on its next accepted Position. Retire (1) by making the far arm's failure path open retail's lost-cell registration instead of a bare pose write, which is issue #309's territory (the park must survive cancellation first) | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x005163D9, @0x005163E8); `CPhysicsObj::SetPositionSimple` @0x005162B0 (flags `0x1012` @0x005162C4); `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D, @0x00515CDA, @0x00515CE2, @0x00515CF2, @0x00515CB2, @0x00515CD5, @0x00515D07); `SmartBox::HandleReceivedPosition` @0x00453FD0 (@0x00454254, @0x00454272) | ## 4. Temporary stopgap (TS) — 36 active rows (TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) diff --git a/docs/research/2026-08-04-c4-route-4b-2-delta-review-findings.md b/docs/research/2026-08-04-c4-route-4b-2-delta-review-findings.md new file mode 100644 index 00000000..d95be9cb --- /dev/null +++ b/docs/research/2026-08-04-c4-route-4b-2-delta-review-findings.md @@ -0,0 +1,232 @@ +# C4 route 4b-2 — delta review FAIL (round 2 correction), 2026-08-04 + +Both delta Opus reviews returned **FAIL** on the fix round. Original slice is WIP +`dfd27896`; the fix round is uncommitted on top of it. + +The fix round did real work and most of it is verified correct — see +"Do not churn" at the end. Two defects block, and one of them is worse than the +defect the round was fixing. + +## MAJOR A — the `store_position` fallback fires on the two retail paths that decline to store + +The round's load-bearing claim (`RuntimeRemotePlacementDriveController.cs:549-553` +and AP-138 part 1) is that retail's non-storing returns "are reached only after a +cell resolved and the transition ran — states this controller's non-commit +outcomes never represent, because they all mean the placement never executed." + +**The second clause is false for `Rejected`.** + +Retail, `CPhysicsObj::SetPositionInternal` @0x00515BD0: +- `CheckPositionInternal == 0` @0x00515C85 → `handle_all_collisions` @0x00515CC2 + → `return ((eax_14 - eax_14) & 2) + 2` @0x00515CD5 → **2 or 4**. No store. +- `sphere_path.curr_cell == 0` @0x00515C8F → `return 3` @0x00515CB2. No store. + +acdream ports that enum literally — `PhysicsSetPosition.cs:12-20` +(`NoValidPosition = 2, NoCell = 3, Collided = 4`, doc-commented against +`acclient.h` enum 491). The producing chain: +`PhysicsEngine.cs:1548-1575` / `:1580-1584` → `RuntimeSetPositionState.cs:3034-3039` +(`if (!result.IsSuccessful) return Outcome(Rejected, …)`), which is **after** +`_physics.Engine.SetPosition(...)` at `:2983` → `RuntimeRemotePlacementDriveController.cs:775-780` +`default:` → `Rejected` → `:578-579` stores. + +So a far snap whose destination the engine's own sweep refuses — a remote +server-snapped into geometry, or into a cell the sphere path cannot resolve — +now teleports the canonical body into that refused destination. Retail leaves the +object where it was. Reachable in the ordinary case: `CanAttemptDestination` +passing means collision *is* published, which is exactly when the engine runs. + +**Worse sub-case:** `RuntimeSetPositionState.cs:3057-3081` returns `Cancelled` +**after `CommitCanonical` succeeded** ("retail lets that physical commit land +regardless"). `Cancelled` also falls into `default:` → `Rejected` → the fallback +overwrites the just-settled body (contact plane, step-down) with the raw +unresolved destination. `RemoteMotion.Body` **is** `record.PhysicsBody` in +production (`RuntimePhysicsState.cs:1039`, sole construction site). + +**Zero of the five new non-commit tests cover `Rejected`.** The 10-row +discrimination table has no row for the one outcome where the fallback is wrong. + +### The required partition + +- **Store** (placement genuinely never executed → retail @0x00515C1D + `store_position`): `Refused`, `Contention`, `NotApplicable`, **and** `Rejected` + arising from a *preparation* failure (`InvalidData`/`RejectedAuthority`, + `:770-773`) which never reached the engine. +- **Do not store** (engine ran and refused → retail @0x00515CB2 / @0x00515CD5): + `Rejected` arising from `outcome.Status`, and `Cancelled`-after-commit, which + must not overwrite a settled pose. + +**The status enum is too coarse to express this.** Widen it or thread the +originating outcome; do not approximate it with a heuristic. + +## MAJOR B — the quiescence pre-flight is incomplete on BOTH sides + +Both reviewers found this independently by different routes. `CanAttemptDestination` +(`RuntimeRemotePlacementDriveController.cs:911-915`) tests one prefix, and +`IsCollisionPrefixQuiescing` (`RuntimeSetPositionState.cs:827-829`) masks to that +one prefix, called with the **destination** only. + +Core's predicates are broader in two independent ways: + +1. **Source landblock** — `PlacementTouchesPrefix` (`:3836-3841`) also matches + `request.CurrentCellId`, populated from `operation.Record.FullCellId` whenever + `body.InWorld` (`:2884-2888`). A far snap *out of* a quiescing landblock passes + the pre-flight and parks at `:2946`/`:2974`. **This is the likelier shape** — + streaming retires and republishes continuously, and a remote at >=96 m sits + near the window edge where retirement happens. +2. **Swept neighbour** — `ResultTouchesPrefix` (`:3843-3858`) scans **every** + `QueriedCellIds` entry. That footprint provably spans neighbour landblocks: + `PhysicsEngine.cs:1347` binds it to the transition's candidate array, + materialized at `:1384`; `CellArray.Add` mirrors every id + (`CellArray.cs:43-52`); `CellTransit.FindCellSet` (`TransitionTypes.cs:3521-3527`) + reaches `AddAllOutsideCells` (`CellTransit.cs:918`, `:985`); and + `AddOutsideCell` (`CellTransit.cs:375-384`) states explicitly there is **no + same-block filter** — neighbour cells come out with the neighbour's prefix, + added whenever the sphere is within its radius of a boundary + (`AddAllOutsideCells:337-350`). + +Either route reaches `ParkDeferred` with `restorableOnCancel` defaulting **false** +(`:4276`), so `CancelToken`'s `restoreCancelledPark: true` finds +`ParkWithdrawal.Captured` false (`:3399-3410`, `:4285`) and `RestoreParkWithdrawal` +never runs. The remote is left `InWorld = false`, `Active` cleared, clock +suspended, `FullCellId = 0`, spatial projection withdrawn (`:4294-4311`), with the +only operation that could wake it destroyed. The fallback pose write does not +help — it writes a pose to a withdrawn body, which merely makes the stranding +invisible to a position assertion. + +**A pre-flight cannot close this.** The sweep footprint does not exist until the +sweep has run. This is the same structural conclusion 4b-1's review reached, and +`SubmitAndResolve`'s own comment at `:768-770` cites `TryGetBlockingQuiescence` by +name as the residual — recording the hole while the code claims to close it. + +### MAJOR C — even a would-COMMIT placement is converted to a non-restorable park + +`:3012`'s check is `result.IsSuccessful && TryGetBlockingQuiescence(...)`, and +`IsSuccessful` is `Error == Ok` (`PhysicsSetPosition.cs:151`) — true for a fully +committed result. It sits **ahead** of the restorable `result.IsDeferred` park at +`:3050-3054`. So a healthy, resident, about-to-commit far snap near a seam is +rewritten to `DeferredCell` at `:3017-3020` and parked non-restorably. The claim +that "the restorable engine-result park stays reachable" holds only when no +quiescing prefix is touched; the quiescence branch pre-empts it. + +### The pinned fix — at the source, as the campaign already decided + +AP-136's blanket "every quiescence/retirement park is non-restorable" is +**over-broad for the two `SubmitPreparedPlacementCore` parks**. Its stated reason +— re-admitting a spatial root into a retiring prefix would block the retirement — +is about `ParkCollisionResidents` (`:3683`), where the entity's **own** cell is +retiring and `IsAffectedCollisionResident` (`:3722-3738`) / +`HasOldPrefixPlacementDebt` (`:3809-3834`) would pin the prefix forever. + +It does **not** hold at `:2974`/`:3025`: `RestoreParkWithdrawal` restores residency +at `body.CellPosition.ObjCellId` (`:3475`) — the destination cell — while the +blocking prefix is the merely-swept neighbour or the departed source. Restoring +there re-admits nothing into the retiring prefix. + +**Make `:2974`/`:3025` restorable when the restore cell's prefix differs from the +blocking quiescence prefix.** Keep `CanAttemptDestination` as an optimisation — +it uses Core's own read-only predicate, is exact within its subset, adds no +timer/retry/flag, and strictly shrinks the reachable set. It must simply stop +being the correctness mechanism. Update AP-136 and AP-138(2) to match. + +## MAJOR D — `StoreAcceptedDestinationPose` writes through a possibly-superseded incarnation + +`CancelToken` publishes its receipt **synchronously** +(`RuntimeRemotePlacementDriveController.cs:925-930`), and this same diff's own doc +(`LiveEntityNetworkUpdateController.cs:1005-1018`) states a caller "MUST +re-validate position ownership … on EVERY placement status, before writing +anything else for the packet". `:576-580` calls `StoreAcceptedDestinationPose` +immediately after that dispatch with **no currency check**. `RestoreParkWithdrawal` +does guard (`RuntimeSetPositionState.cs:3468-3469`). The App-side re-validation +happens only after the seam returns — too late. + +This is the exact rule the R5 fix in this same diff introduced. One +`IsCurrent(record)` test closes it. + +## MINOR + +- **N1 — `Advance()`'s window-drop path is asymmetric.** `:692-696` calls + `CancelToken` without `StoreAcceptedDestinationPose`, so a retained retry whose + destination leaves the window reproduces a smaller version of the freeze this + round exists to fix. Its comment at `:685` still claims the entry point "keeps + its last committed pose" — no longer true. +- **N2 — post-sweep quiescence park overwrites a settled pose.** At `:3017` the + parked `result` is the committed one, whose `Position` is `spherePath.CurPos` + (`PhysicsEngine.cs:1605`) — collision-settled. `ParkDeferred:4290` snaps there; + `StoreAcceptedDestinationPose:627-630` then writes the raw destination over it. + Latent while MAJOR B stands; live the moment the park becomes restorable. +- **N3 — `Advance()` reopens the `CurrentCellId` condition.** `:690-702` re-checks + only `CanAttemptDestination(destination)`. Non-Position rebucket paths + (`RemoteTeleportController.cs:532`, `DatLiveEntityProjectionMaterializer.cs:777`, + `EquippedChildRenderController.cs:408`) can move `record.FullCellId` to a third, + quiescing landblock in between. +- **N4 — five stale comments, inside the round meant to end the disease.** + `:777-778` ("Rejected/Cancelled — … so the body never moved") is the one whose + correctness would have exposed MAJOR A. Also `:685` (N1), `:91-93` + (`Rejected`'s doc omits the engine-refusal producer), `:2144-2151` (doesn't + mention the guard now in front of it), `:2183-2187` (provenance claim fine, + value claim false). And `LiveEntityNetworkUpdateController.cs:1078-1079` cites + `:1886`/`:2130` where the actual early returns are `:1977`/`:2241` — wrong + citations *in the fix for the stale-comment finding*. +- **N5 — `NotApplicable` via `record.PhysicsBody is null`** (`:436`) still runs the + fallback, writing a `RemoteMotion` body that in that state is not the canonical + one (`RemoteMotion.cs:271`). +- **N6 — `teleport_hook` enumeration incomplete.** @0x00514ED0 also calls + `report_collision_end(this, 1)` @0x00514F31, omitted from + `RuntimeRemoteFarSnapPosition.cs:454-457` and AP-137. +- **N7 — a test assertion is order-dependent.** + `RemotePlacementLedger_ConvergesAcrossGuidReuse_WithoutTeardown:708-711` — + `Assert.Equal(0, drive.PendingCount)` is zero only because the preceding + `CaptureOwnership()` invoked `CountLivePending`, which **mutates** `_pending`. + Swap the asserts and it fails. Also omits `AssertConverged`. + +## Required tests + +- `Rejected` from an engine refusal: assert the body does **not** move. +- `Cancelled`-after-commit: assert the settled pose survives. +- `Rejected` from a preparation failure: assert the body **does** advance. +- Quiescing **source** landblock: far-snap out of it, assert + `body.InWorld && record.ObjectClock.IsActive && record.FullCellId != 0`. +- Quiescing **neighbour**: far-snap to a point one sphere radius inside a + landblock seam with the neighbour quiescing, same assertions. +- `Advance()`'s window-drop path: assert the pose advances. + +## Do not churn — independently verified correct + +- The retail decode of `SetPositionInternal`'s storing branch (@0x00515CDA → + @0x00515CE2 → @0x00515CF2 → @0x00515CF7 → `return 0` @0x00515D07). Exact. +- **R2's deferral to 4b-3 is the right call**, and both reviewers agree. + `teleport_hook` @0x00514ED0 runs *before* the placement @0x00516420, so a + pose-only half-port would strand a live moveto, stick, and leash. + `RuntimeTeleportHookPhase.BeforePositionOperation` exists + (`RuntimeAuthoritativePositionRouteClassifier.cs:410`) and is recorded-and-dropped + for the remote arm — a representable delta with the data model already present. +- **The carried `SEND_POSITION_EVENT_SPF = 0x1000` question is SETTLED: the flag + is inert.** @0x00515330 takes `(CPhysicsObj*, CTransition const*)` — no struct; + @0x00515BD0 reads only bit 5; @0x00516040 tests only bits 8/9; a tree-wide + search finds no bit-12 test on a `SetPositionStruct`. +- **Pose parity and the decoy.** `TryGetWorldFrameOffset` is the source of + `ShadowWorldOffsetX/Y`; `StoreAcceptedDestinationPose:627-635` composes + identically to `RuntimeSetPositionMoverPreparer.TryBuild` + (`RuntimeSetPositionMoverPreparation.cs:155-168`). `worldPos` is **not** an + input — the decoy retains its discriminating power. +- The status is genuinely no longer discarded; both arms thread + `RemoteContactRouting(Arm, Placement)`. +- Register bookkeeping: `grep -c "^| AP-"` = 96, header updated, no duplicates, + nothing retired kept a row. +- R5's ordering is identical in both arms, and its "not constructible" claim + holds — though it is the standard signal the guard belongs behind a testable + Runtime seam rather than duplicated at two App call sites. Name that for 4b-3. +- The `StopInterpolating` "unobservable" claim is **true**: + `TryExecuteAcceptedRemotePosition` never receives `remote`, and no placement or + projection path reads or writes the queue. Stating it beats a false pin. +- R7's `Assert.True(remote.Interp.IsActive)` genuinely closes its hole. R8's two + park tests provoke their states honestly. +- AP-136's scoping was read correctly and was not retrofitted (untouched in the + diff). + +## Gate + +Complete Release suite. **Baseline 10,968 / 4 / 0** at `1b631f12` (App 4088, +Runtime 1073), measured. The fix round measured 10,997 / 4 / 0 while containing +every defect above. Known flakes: **#302** (`PortalProjectionTests…`, App.Tests) +and **#308** (`NakEmissionTests.LossSoak_…`, Core.Net.Tests). Do not conflate. diff --git a/docs/research/2026-08-04-c4-route-4b-2-review-findings.md b/docs/research/2026-08-04-c4-route-4b-2-review-findings.md new file mode 100644 index 00000000..c68a2749 --- /dev/null +++ b/docs/research/2026-08-04-c4-route-4b-2-review-findings.md @@ -0,0 +1,206 @@ +# C4 route 4b-2 — dual review FAIL + the pinned correction (2026-08-04) + +Both mandated Opus reviews returned **FAIL**. Nothing is committed as final; the +work sits at WIP `dfd27896`. + +Review A (retail faithfulness + deletions) confirmed **every retail citation** +independently, including the x87 parity decode proving exactly 96.0 takes the +FAR branch. The deletions are complete and exact, the "Do NOT touch" list was +respected, AP-87 was neither carried onto the far branch nor deleted from the +near branch, and the two-site `ConstrainTo` partition is exhaustive and disjoint. +Review B confirmed the `worldPos` decoy genuinely discriminates, both test +removals are real replacements rather than drops, and `OwnsFarSnap` is exact +against every classifier emission site. **Do not churn any of that.** + +## The one root defect — everything else is downstream + +**A refused / contended / rejected far snap leaves the remote frozen with an +emptied interpolation queue.** + +`ApplyRemoteContactRouting` discards the status +(`LiveEntityNetworkUpdateController.cs:1002`, `_ = placementDrive.…`), while +`Interp.Clear()` has already run unconditionally on the route flag +(`RuntimeRemotePlacementDriveController.cs:485-487`). Three of five outcomes +move nothing: `Refused` (`:405`, `:624`), `Contention` (`:414`, `:588`), +`Rejected` (`:592`, `:630`). The body keeps its stale pose, the queue is empty, +and the next 5-10 Hz packet reproduces the state. + +**Reachable, not theoretical.** The graphical service window is +`GpuWorldState.IsNearTier` — collision-published *right now* +(`GraphicalRemotePlacementServiceWindow.cs:89-90`) — with default near radius 4 +(`GameWindow.cs:145`). A remote that is rendered but still streaming is refused +while genuinely beyond 96 m. `RetrySetupUnavailable` is reachable for any remote +whose prepared Setup collision has not resolved. + +**Retail never does this.** The far branch always calls `SetPositionSimple` +@0x005163D9, and even when `SetPositionInternal` finds no cell it still commits +the destination pose — `store_position` @0x00515CE2, then +`GotoLostCell` @0x00515CF2 and `reenter_visibility` on cell arrival. That is the +finding AP-136 is built on. The deleted legacy block also always tracked. **The +shipped state is strictly further from retail than either.** + +### Both reviewers' second MAJOR collapses into this one + +Review B rated the `TryAdoptWireCellAfterRouting` arm-asymmetry MAJOR, arguing a +refused player far snap leaves `record.FullCellId` a landblock away from the +body and that `rmState.CellId` seeds the per-tick sweep — the #184 +invisible-but-solid producer. + +Review A found the same asymmetry and rated it MINOR with the mechanism: +`RebucketLiveEntity(update.Guid, p.LandblockId)` at `:1592` has **already** +committed the wire full cell to canonical +(`LiveEntityRuntime.cs:895-909`), and `RemoteMotion.CellId` reads through to +`record.FullCellId` for every bound remote (`RemoteMotion.cs:169-176` + +`RuntimePhysicsState.cs:965-984`). **Review A is right on the mechanism.** The +suppressed write is a no-op on refusal. + +**Resolution: the cell/body divergence is real but is caused by the frozen body, +not by the suppression.** Fix the freeze and it disappears. Do not restructure +the suppression to chase it. + +## The correction — pinned, not open for redesign + +**On every non-commit outcome the far arm must still advance the body to the +accepted destination pose.** That is retail's `store_position`, and it restores +the tracking the deleted legacy block had. + +Constraints on the fix: + +- The status must stop being discarded at `:1002`. +- The service window stays an **optimisation**, not a correctness mechanism — + 4b-1's review already ordered that framing corrected and it shipped + uncorrected. Do not delete it; do not let it be the reason a remote stops + tracking. +- Do **not** add a timer, retry, settle window, or suppression flag. The + fallback is a pose write because retail writes the pose, not because it makes + a symptom go away. +- `LiveEntityNetworkRemoteFarSnapIntegrationTests.cs:120-147` currently + **pins the freeze as correct** (`Assert.Equal(before, body.Position)`). It + must be inverted, not deleted quietly. + +## Also required + +**R1 — AP-137's justification is factually wrong (review A M2).** The row claims +the deleted `_playerController?.Position ?? Vector3.Zero` distance had "no +relationship to `player_distance`". It was streaming-origin-relative +(`LiveEntityNetworkUpdateController.cs:1360-1366`) and the streaming origin +recentres on the player's landblock — a biased but genuinely correlated proxy, +error bounded by roughly one landblock. The policy may still be right; the +stated reason is not true. Rewrite it to say what the deleted test actually +computed. + +**R2 — AP-137 must state the cell-less delta (review A M3).** Retail routes a +cell-less body through @0x00516386 → `SetPosition` @0x00516420, an +**unconditional placement** sitting *before* the contact test. 4b-2 routes that +classification into `ApplyInterpolate`, which **enqueues** whenever +`!firstUp && willBeDrTicked && bodyToTarget <= 4 m`. For a cell-less remote +already tracking, acdream now queues where retail places — at *any* distance, +not only >=96 m. State it. If it should instead place, say so and change it. + +**R3 — AP-137 must state that `RejectedData` is applied anyway.** It is the one +classification meaning "this payload failed validation" +(`classifier.cs:535-547`), and `UnroutedCatchUp` hands the same payload to +`ApplyInterpolate`. Not a regression — the legacy block did the same — but the +slice's stated purpose was an explicit handler, and the row omits it. + +**R4 — a register row for the refusable far placement.** `grep` for +"Refused|service window" in the register returns zero. 4b-1 was dormant and owed +nothing; **4b-2 is the commit that makes it live and therefore owes the row** +(CLAUDE.md register rule 1). Whatever residual divergence survives the fix above +gets measured against retail's `store_position`/`GotoLostCell` behaviour. + +**R5 — the two arms' guard/arm ordering must be identical (review A M4).** + +| arm | routing | re-entrancy guard | post-op arm | +|---|---|---|---| +| player | `:2020` | `:2047-2054` (`return`) | `:2037` — **before** the guard | +| NPC | `:2202` | `:2214-2221` (`return`) | `:2235` — **after** the guard | + +The NPC arm therefore arms `ConstrainTo` **zero** times on a superseded +incarnation, where retail arms unconditionally on the nonzero return +@0x00454254/@0x00454272. The player arm arms the leash on a possibly-superseded +`rmState.Host`. This also contradicts the diff's own new remarks at `:958-966` +("MUST re-validate … before writing anything else for the packet") — the player +arm writes the leash first. + +**R6 — six comments the change falsifies** (review A m6; review B found the same +class independently as N6). This is the **fifth** consecutive slice shipping +stale comments asserting behaviour the code no longer has: +`LiveEntityNetworkUpdateController.cs:835`, `:868`, `:1589-1591`, `:2076-2081`; +`RuntimeEntityObjectLifetime.cs:600-605`; and +`RuntimeRemotePlacementDriveController.cs:106-143`, which still headlines "The +central decision — refuse, do not park" and asserts a cancelled park leaves the +entity invisible and intangible for the session — falsified at HEAD by the park +restore **this same class's** `CancelToken` uses (`:698-703`). 4b-1's review +already ordered that one corrected. + +**R7 — three tests assert less than their names claim (review B N2/N3).** +- `NoClassificationAtAll_NearAndTicked_EnqueuesInsteadOfSnapping` + (`:191-213`) never inspects `remote.Interp`. A regression to "snap when + `bodyToTarget > 4 m`, else do nothing" passes it *and* its sibling. One + `Assert.True(remote.Interp.IsActive)` closes it. +- The `StopInterpolating` ordering test (`RuntimeRemotePlacementDriveControllerTests.cs:830-871`) + proves the clear is unconditional, not that it precedes the placement. Its doc + comment claims proof it does not have. Either pin the order or state plainly + that it is unobservable in acdream. + +**R8 — the `DeferredCell` park branch has zero coverage (review B N5).** The +opt-in chain is correct by reading (`:623` → `:698-703` → +`RuntimeSetPositionState.CancelCore` → `RestoreParkWithdrawal`), but no test in +4b-1 or 4b-2 exercises it, and the far arm is the first production path that can +provoke a park. Add the test: commit the destination collision generation, open +a `CollisionPrefixQuiescence` on the destination prefix, far-snap, then assert +`body.InWorld && record.ObjectClock.IsActive && record.FullCellId != 0`. + +**R9 — currency across GUID reuse / incarnation / generation is undelivered +(review B N4).** Contract item 7 named five dimensions; two shipped +(interleaving, teardown). `_pending` and `_awaitingAcknowledgement` are keyed by +`RuntimeEntityKey` (guid+incarnation), so a reused GUID makes a *new* key and the +old entry self-heals only on a ledger read (`:670-688`) or in `Advance()` +(`:513-525`), which early-returns unless something is pending. Dead-incarnation +entries accumulate until `DetachRoute`. Converges at teardown; does **not** +converge in-session. Add the test: far-snap `G` at incarnation 1, retire it, +re-create `G` at incarnation 2, assert `RemotePlacementDrivePendingCount == 0` +**without** a teardown. + +**R10 — `AirborneNoOperation` falls into `default:`** (review A m7). Unreachable +today — both callers early-return (`:1876-1881`, `:2130-2136`) — but the +`default:` comment asserts unreachability where an explicit case enforces it. + +## Not blocking, recorded + +- **n8**: `OwnsFarSnap` also matches the classifier's FORCE_POSITION shape + (`classifier.cs:329-345`), unreachable for remotes via `ValidAcceptedAuthority` + (`:522-525`) and `PhysicsTimestampGate` (`:190`). Defense-in-depth; note only. +- **NOT VERIFIED, carried**: what `SetPositionInternal` @0x00515330 does with the + 0x1000 (`SendPositionEvent`) bit, which reaches + `TryPrepareAndSubmitAuthoredPlacement` for the far snap while the controller + doc argues acdream has "no ack" for remotes. Those are two different things + (the `SetPositionStruct` flag vs `CommandInterpreter::SendPositionEvent` + @0x00454091); 4b-1's review cleared only the latter. Settle with a read of + @0x00515330-@0x00515543. +- **Process**: `ParkCollisionResidents_StaysUnreachable_AfterRefusalAndAfterCommit` + (`RuntimeRemotePlacementDriveControllerTests.cs:782`) still calls the method + directly at `:824` — the shape 4b-1's finding B2 declared void. It shipped at + `2e8e09ac`, so it is outside this diff, but it is in the tree being counted as + evidence. +- **Headless (contract item 6) is satisfied vacuously.** Nothing in headless + constructs the drive controller and `RuntimeLiveEntitySessionController.OnPositionUpdated` + returns early for every non-local GUID (`:212-217`). The contract allowed this + only if stated plainly; AP-137 does not mention headless, and + `IRuntimeRemotePlacementServiceWindow`'s doc still describes the headless + implementation as a live consumer. State it. + +## Gate + +Complete Release suite, not a subset. **Corrected baseline: 10,968 / 4 / 0** at +`1b631f12`, measured independently in a throwaway worktree (App 4088, Runtime +1073). The 10,973 figure in the 4b-2 contract was wrong and propagated from an +earlier mis-record — an inflated baseline is exactly what would let a future +slice delete tests and still look green. The WIP state measured 10,990 / 4 / 0 +while containing the freeze defect above. + +Two known flakes, do not chase and do NOT conflate: **#302** +(`PortalProjectionTests…`, GC-allocation assertion, App.Tests) and **#308** +(`NakEmissionTests.LossSoak_…`, wall-clock deadline, Core.Net.Tests, full-suite +load only). diff --git a/docs/research/2026-08-04-c4-route-4b-2-round3-correction.md b/docs/research/2026-08-04-c4-route-4b-2-round3-correction.md new file mode 100644 index 00000000..f6900149 --- /dev/null +++ b/docs/research/2026-08-04-c4-route-4b-2-round3-correction.md @@ -0,0 +1,211 @@ +# C4 route 4b-2 — round 3 correction (2026-08-04) + +Both round-2 reviews returned **FAIL**. The findings are now small and local; +this is a closing-out round, not a structural change. + +**Do NOT revert the shared-core park change.** Both reviewers verified it removes +a session-permanent strand from routes 1 and 2: +`RuntimeEntityObjectLifetime.cs:1767-1769` calls +`Forget(canonical, restoreCancelledPark: true)` on **every** accepted Position for +**every** entity, so before this round a local-player ForcePosition or a +first-entry placement that hit either quiescence park was left `InWorld = false`, +clock suspended, `FullCellId = 0`, with its operation destroyed by the next +packet. The change is directionally right and fixes shipped behaviour. + +## A1 — one relocation closes both shared-core MAJORs + +`IsRestorableQuiescencePark` (`RuntimeSetPositionState.cs:3877-3880`) has two +independent defects, and moving the decision fixes both: + +1. **It compares against one quiescence, not the set.** It tests + `quiescence.Token.LandblockPrefix` — the token `TryGetBlockingQuiescence` + returned — but both overloads (`:3906-3925`, `:3927-3949`) return the + **minimum-`OperationId`** match, not the only one. `_collisionPrefixQuiescence` + is a per-prefix dictionary (`:589`, `:885`) and streaming opens one per + landblock mutation (activation `RuntimePhysicsState.cs:1820-1824`, retirement + `:2025-2030`), so a recenter has several live. Source `S` quiescing before + destination `D`: the predicate tests `D != S`, returns true, and the restore + re-admits the spatial root into `D`, which *is* quiescing — the AP-136 pin. At + 5-10 Hz the park→cancel→restore→re-park cycle can starve `D`'s mutation + permission for as long as `S` stays quiescing (`:927`). +2. **Its input is not the value the restore uses.** It is fed `deferred.CellId` / + `held.CellId` (`:2982`, `:3036`), but `RestoreParkWithdrawal` reads + `body.CellPosition.ObjCellId` (`:3481`), set by `ParkDeferred`'s + `body.SnapToCell` (`:4346-4349`) → `PhysicsBody.StageDormantCellFrame` + (`PhysicsBody.cs:212-223`), which for any outdoor cell runs + `LandDefs.AdjustToOutside`. That function's own doc (`LandDefs.cs:115-122`) + says the resulting cell id **may belong to a neighbour landblock**. + Reachability NOT VERIFIED, and the relocation removes the question. + +**Fix: move the restorable decision inside `ParkDeferred`, after `SnapToCell`, +and express it as `!IsCollisionPrefixQuiescing(body.CellPosition.ObjCellId)`.** +That method (`:827-829`) already exists, is `internal`, masks its own argument, +and is the pre-flight's own predicate; it subsumes the current test exactly. +Make it non-static as needed; the `CollisionPrefixQuiescence` parameter then goes +away. + +**Blast radius note that must not be lost:** the remote far-snap arm is *masked* +from defect 1 by `CanAttemptDestination`'s destination pre-flight +(`RuntimeRemotePlacementDriveController.cs:599`, `:1155`). The exposed callers are +**routes 1 and 2**, which have no such pre-flight +(`RuntimeAcceptedPositionDriveController.TryExecuteAcceptedLocalPosition:330-392` +goes straight from `ClassifyForcePosition` to `TryBeginExclusiveAuthoredPlacement`). +All three new quiescence tests drive the remote arm and explicitly assert the +**safe** configuration (`:1193`, `:1268`), so none of them can see it. + +## A2 — two tests the round is missing + +- **Concurrent quiescences**, source `OperationId` < destination `OperationId`, + far snap `S → D`: assert the record is not re-admitted as a spatial root in `D`, + or that `D`'s mutation permission still converges. +- **A route-2 local-player ForcePosition** parking at `:2948` under a quiescing + source: assert `body.InWorld && record.ObjectClock.IsActive && + record.FullCellId != 0` after the next packet's merge-time `Forget`. + +## A3 — say "blocking prefix", or make it true + +`IsRestorableQuiescencePark`'s doc (`:3866-3868`) and AP-136's narrowed row both +claim "the rollback re-admits nothing into the retiring prefix." It re-admits +nothing into **the blocking** prefix, which is a different statement. A1's fix +makes the stronger claim true; if any wording survives it, scope it. + +## A4 — #309's acceptance steps are now stale + +AP-136 says this needs the two-client connected check in #309, but +`docs/ISSUES.md` is **unmodified in this diff**. #309's steps (`docs/ISSUES.md:82-95`) +describe only "a landblock the observer has not streamed" — the plain +unplaceable park. They do not exercise a quiescing **source** or a quiescing +**swept neighbour**, which are exactly the shapes this round newly makes +restorable, and step 4 asks only that route 2's corrections "still land +unchanged" without naming the local-player-parks-into-a-quiescing-landblock case +that is now behaviourally different. **Deferring the run is fine; leaving its +scope stale is not.** Update the steps in this commit. Also AP-136's Risk column +still says "a remote" — the local player now traverses the same restore. + +## A5 — record the dependency at `CanAttemptDestination` + +N3's "a retained retry can no longer strand" property and MAJOR 1's masking +**both** depend on the pre-flight staying in place. The class doc calls it "an +optimisation" with no mention of either. If it is ever deleted as redundant, both +re-open silently. Write that down at the pre-flight. + +(The round's stated reason for declining N3's `CurrentCellId` re-check — that it +would "re-derive a private Core predicate outside Core" — is false; +`CanAttemptDestination` already calls Core's own `internal` +`IsCollisionPrefixQuiescing`. The conclusion still holds, for the reason above. +Correct the reason.) + +## B1 — a 36-line doc block was detached from its method + +`RuntimeRemotePlacementDriveController.cs:1009-1085`. `CountLivePending()` was +inserted **between** `CountLiveAwaitingAcknowledgement`'s existing doc and its +method. Now `:1009-1044` (which describes pruning `_awaitingAcknowledgement`, the +**second** ownership registration, and `_awaitingAcknowledgementScratch`) +documents `CountLivePending()` at `:1066` — which prunes `_pending`, backs the +**first** registration, and uses `_pendingScratch`. Every specific is wrong for +the member it now sits on. `:1045-1065` is a second `` on the same +member, and `CountLiveAwaitingAcknowledgement()` at `:1085` has **no** doc. + +## B2 — the stage rule is true by coincidence and stated as a falsehood + +`:172-188` and `:993-1005` assert that `RejectedByPlacement`'s producers are +"returned AFTER `_physics.Engine.SetPosition` ran". `RuntimeSetPositionStatus.Rejected` +has **three** producers and only one is post-engine: + +| site | what | stage | +|---|---|---| +| `:2854` | entry validation (`!ownsToken`, stage mismatch, null body, stale `IsPreparationAuthorityCurrent`, velocity-version mismatch) | **pre-engine** | +| `:2945` | `!IsStructurallyValid(canonicalRequest)` | **pre-engine** | +| `:3044` | `!result.IsSuccessful` after the engine ran at `:2997` | post-engine | + +Both pre-engine sites reach `SubmitAndResolve`'s `default:` and are filed +`RejectedByPlacement` → **no store** — contradicting the round's own rule +(`RejectedPreparation`'s doc `:128-142`) that a producer which never reached the +engine stores. + +The reviewer traced both **unreachable today**, but only by duplicated guards: +`PrepareMover` (`:1563-1577`) re-checks everything `:2830-2855` re-checks with +nothing reentrant between; `IsPreparationAuthorityCurrent` (`:5290-5306`) catches +all seven version dimensions one stage earlier as `RejectedAuthority` → +`RejectedPreparation`; the `AwaitingCell` divergence needs `DormantLocalActivation`, +which `:1380` forbids for a record with a body; and `:2945` is shadowed by +`PrepareMover:1636`'s identical check. + +**Either state in the `default:` comment that `:3044` is the only reachable +producer and why, or thread the stage out of Core so the split is enforced.** As +written the comment says something false about Core — the same shape as round 1's +MAJOR A, which was also a false clause in that same comment. + +## B3 — the deleted phrase came back + +`RuntimeRemotePlacementDriveControllerTests.cs:1191`, an **added** line: +`// The complete pre-flight passes: it reads only the destination.` +`CanAttemptDestination`'s doc in the same diff (`:1111-1113`) says it is +"correcting this comment's earlier 'the complete pre-flight' framing", and the +whole of MAJOR B is that no pre-flight can be complete. The sentence also +contradicts itself in eight words. + +## Minor + +- **m1** — `TryAdoptWireCellAfterRouting`'s new scope paragraph + (`LiveEntityNetworkUpdateController.cs:1128-1143`) enumerates commit and + `RejectedByPlacement` and "every outcome that resolved NO cell". `Deferred` is + in neither: `ParkDeferred:4345-4349` snaps to the parked result cell, + `WithdrawCanonical` zeroes `FullCellId`, and `RestoreParkWithdrawal:3485-3491` + re-commits from `body.CellPosition.ObjCellId` — the **swept** cell for the + post-sweep park, not necessarily the wire cell. Behaviour is unaffected (the + suppression keys on the arm); the claim is not. +- **m2** — `Contention`'s doc (`:118-127`) names two producers; there are at + least four. `TryBeginExclusiveAuthoredPlacement:1349-1354` also refuses on + `HasRetainedCompletion(key)` — ordinary right after a `Committed` far snap + whose projection sink declined — and `BeginAcceptedPlacementCore:1404-1418` on + `!_entities.IsCurrent(record)`. Both pre-engine, so the store side is right. +- **m3** — the MAJOR D test (`:1516-1552`) reaches the superseded state via + `lifetime.Entities.RemoveActive(record)` and picks `Refused`, which never calls + `CancelToken`. It pins that the guard exists, not that it sits **after** the + synchronous cancellation receipt — the defect's actual mechanism. +- **m4** — `NotApplicable` via `record.Key is null` (body non-null) stores + (`:576-581` → `:800`); the enum doc `:97-101` justifies only the no-body + sub-case. Unreachable in production + (`RuntimeEntityObjectLifetime.cs:600-612`). +- **m5** — `ParkCollisionResidents` (`:3689-3696`) relies on `restorableOnCancel` + defaulting false. The old blanket doc was itself the guard against a forgetful + caller; now that it is gone, pass `false` explicitly. +- **n1** — `:644` cites classifier `:467` for `StopInterpolating: !nearby`; it is + `:468`. Pre-existing. +- **n2** — `ParkDeferred` carries two adjacent `` blocks + (`:4292-4301`, `:4302-4325`); only the second is emitted. +- **n3** — the Runtime test class doc (`:30-33`) asserts every test was + revert-verified, with no per-test mapping in the tree. Either map them or drop + the claim to what the file can show. + +## Do not churn — verified across both reviews + +Retail decode of `SetPositionInternal` @0x00515BD0 (both storing and non-storing +branches) is exact and matches the enum docs line-for-line. The 7-value switch is +exhaustive with `_ => throw`, and no consumer outside the controller reads the +value for a decision, so the `Rejected` split cannot silently miss a case. +`Deferred`-not-storing is right, with a stronger unreachability proof than the +comment gives (`ParkCollisionResidents:3563-3568` **throws** rather than parking +an entity that already holds an operation). MAJOR D's guard is the correct +predicate (`RuntimeEntityDirectory.cs:75-77`), sufficient, and correctly placed +after the receipt at both sites. **N5 is genuinely unconstructible** — +`:917-923`/`:935-940` throw on binding a component whose body differs from +canonical. Pose parity survives the `remote.Body` → `record.PhysicsBody` change +byte-for-byte. The neighbour test's construction is arithmetically verified +(`191.95 mod 24 = 23.95 > 24 - 0.1` → `lx+1` → `0xB3`), it provably hits the +post-sweep park, and its Z arm discriminates (terrain 7, authored 6). N7's +reframing is accurate. All six required tests discriminate; the +cancelled-after-commit construction is genuinely clever. Route 2's re-issue +funnel does **not** interact with the restore (it decides on +`PositionAuthorityVersion`/`_newestForce`, none of which the restore moves). +`RestoreParkWithdrawal` writes exactly five things and no sweep footprint, +cross-cell registration, or BSP residency among them. **No added line in the diff +carries a source line-number citation** — that failure mode is fixed. + +## Gate + +Complete Release suite. Baseline **10,968 / 4 / 0** at `1b631f12`. Round 2 +measured **11,004 / 4 / 0** while containing everything above. Known flakes, do +not chase or conflate: **#302** (`PortalProjectionTests…`, App.Tests), **#308** +(`NakEmissionTests.LossSoak_…`, Core.Net.Tests). diff --git a/docs/research/2026-08-04-c4-route-4b-2-round4-correction.md b/docs/research/2026-08-04-c4-route-4b-2-round4-correction.md new file mode 100644 index 00000000..47e8940a --- /dev/null +++ b/docs/research/2026-08-04-c4-route-4b-2-round4-correction.md @@ -0,0 +1,174 @@ +# C4 route 4b-2 — round 4 correction (final), 2026-08-04 + +Both round-3 reviews returned **FAIL**, and both bound the failure to the same +place. **No shared-core edit is warranted.** Review A verified the relocation +line by line and states plainly: the code is correct. + +Round 4 is one test, one deleted sentence, two `docs/ISSUES.md` fixes, and a +handful of doc corrections. + +## The MAJOR — both reviewers, independently + +**Nothing in the tree discriminates round 3's predicate from round 2's**, and a +test doc cites a sibling test that does not exist. + +`RuntimeRemotePlacementDriveControllerTests.cs:1343` names +`ConcurrentQuiescences_ParkIsNotRestoredIntoTheQuiescingDestination`. That +identifier occurs exactly twice in the tree, both inside that one comment. + +Review A evaluated round 2's form +(`blockingToken.LandblockPrefix != (result.CellId & 0xFFFF0000)`) against all six +quiescence tests. **Every row agrees with round 3.** Revert the relocation to the +single-token comparison and the suite is still green at 11,007. So A1's two +stated defects — comparing one token instead of the live set, and reading the +pre-snap cell instead of the post-snap one — have no automated floor under them +at all, while the tree asserts otherwise. + +**Write the test.** Both reviewers identified the same and only discriminating +construction, in `RuntimeAcceptedPositionDriveControllerTests` (route 2 has no +pre-flight, which is what makes it the caller that can reach the shape): + +- two live quiescences, source `OperationId` < destination `OperationId` +- park at `:2948` under the source +- assert `!IsSpatialRoot(record)` after the next packet's merge-time `Forget` + +It fails under the round-2 form (`Source != Dest` → restorable → re-admitted into +the retiring destination) and passes under round 3. Then delete or correct the +`:1343` cross-reference. + +## #309 — the gate currently passes while broken + +**S1.** Step 5 (`docs/ISSUES.md:115-119`) says the player "recovers on the next +server Position rather than staying withdrawn indefinitely". +`QuiescingDestinationPrefix_ForcePositionParkIsNotRestored` (`:906-912`) asserts +the opposite for that packet — `InWorld == false`, clock inactive, +`!IsSpatialRoot`. Route 2's drive dispatches **only** for `ForcePosition`; an +ordinary `Apply` merges behind it (`RuntimeAcceptedPositionDriveController.cs:337`). +So the packet the step names is precisely the one that does not recover it. Name +what actually does — a later ForcePosition, or the quiescence releasing plus a +placement. + +**S2.** Steps 4 and 5 both need a ForcePosition to land *inside* a transient +collision-prefix quiescence window, which the user cannot synchronise with a +teleport or portal arrival, and neither step names a signal confirming the park +fired. A tester sees a clean teleport and records a pass. **Give each step a +stated confirmation signal** — a probe line, or a documented +`CaptureOwnership().DeferredCellCount` check. If a deterministic way to hold a +prefix quiescing for the duration is cheap, prefer it. + +Step 5's retirement-still-completes clause is good; steps 1-3 and 6 are +performable as written. + +## Doc corrections + +- **D1** — AP-136 says restore covers the plain unplaceable park + "**unconditionally**". It does not: `ParkDeferred` now gates *every* park, + including `:3063`, on `!IsCollisionPrefixQuiescing(body.CellPosition.ObjCellId)` + (`:4358-4360`). The same row states it correctly two sentences later — the row + contradicts itself. +- **D2** — AP-136's trailing parenthetical still summarises #309 as the original + three steps plus "route 2's corrections are unchanged". This commit rewrote + #309. The row's body says the scope widened; its own summary does not. +- **D3** — `RuntimeRemotePlacementDriveController.cs:1064-1066` attributes the + null-body re-test to `PrepareMover`'s guard clause + (`RuntimeSetPositionState.cs:1563-1577`), which has **no** body test. The real + dependency is `RuntimeRemotePlacementDriveController.cs:625` + (`record.PhysicsBody is null` → `NotApplicable`). Conclusion holds; the + attribution points a maintainer at the wrong guard. +- **D4 — B2's chosen value was not delivered.** Choosing "state it" over "enforce + it" is sound and both reviewers concur with the decision — but **none** of the + four dependencies is annotated at its own site: `PrepareMover`'s guard + (`:1563-1577`), `IsPreparationAuthorityCurrent` (`:5304-5320`), + `PrepareDormantLocalActivationOwnership`'s `record.PhysicsBody is not null` + (`:1380`), `TryBeginExclusiveAuthoredPlacement`'s existing-operation refusal + (`:1349-1351`). Deleting any one is invisible from the site, which is the whole + thing "state it" was supposed to buy. Four one-line back-references close it. +- **D5 — scope the `CurrentCellId` claim to the first submit.** `ParkDeferred`'s + doc (`:4300-4307`, `:4320-4325`), AP-136 and AP-138(2) all say the pre-engine + arm "names the destination" in production. True on the first submit, **false + for a retained retry**: `Advance()` re-reads `record.FullCellId` at submit with + no fresh merge (`RuntimeRemotePlacementDriveController.cs:941-954`, + `RuntimeAcceptedPositionDriveController.cs:663-667`), and + `RemoteTeleportController.cs:532` is a shipped writer that moves it to the + pre-teleport landblock. `CanAttemptDestination`'s own doc (`:1234-1241`) already + says this correctly — the three others contradict it. Otherwise the next reader + concludes the `CurrentCellId` arm is dead code. + Same family: `:1252-1253` ("every shape this predicate cannot see is a + merely-swept NEIGHBOUR") is contradicted by the third-landblock shape named ten + lines above it. + +## D6 — make the `Captured` claim exact rather than caveated + +`Captured` is a **park-time** snapshot. For the far snap the window is +nanoseconds (`SubmitAndResolve`'s `DeferredCell` arm cancels synchronously, +`:1038`). For **route 2** the park is *retained* +(`RuntimeAcceptedPositionDriveController.cs:745-753`, `AwaitingCommitWake: true`) +and the restore lands on the next packet's merge-time `Forget`, ~150 ms later. +Streaming opens a quiescence per landblock mutation, so a prefix clean at park +time can be quiescing at restore time, and `RestoreParkWithdrawal` re-admits into +it unconditionally. + +It self-heals and is no worse than the baseline, so it is a residual rather than +a regression — but **add the re-test**: one `IsCollisionPrefixQuiescing(residentCellId)` +condition at `RuntimeSetPositionState.cs:3488`, in the method that already reads +that variable. That makes the docs' claim true at the moment it matters instead +of requiring a "when the park was taken" caveat everywhere it appears. Prefer the +exact claim over the caveat. + +## NIT + +- **N1** — `IsCollisionPrefixQuiescing(0)` re-opens the C3c-F3 sentinel + conflation. `AdjustToOutside` zeroes the cell id on map-edge failure + (`LandDefs.cs:139`), and prefix `0x00000000` is the *legitimate* corner + landblock (0,0) per `BeginCollisionPrefixQuiescence:840-846`, not "no + landblock". Review A could not construct a reachable path, but this file + already carries a shipped-crash comment about exactly this conflation. Add the + `!= 0u` guard. +- **N2** — `FarSnap_ConcurrentQuiescences_RefusesBeforeOpeningAPark` is + behaviourally redundant with the destination-prefix test (both fail identically + if the pre-flight's `IsCollisionPrefixQuiescing` half is removed), and its + `sourceQuiescence.OperationId < destinationQuiescence.OperationId` assert never + influences an observed outcome. Keep it, but its doc should not imply stronger + A5 evidence than it carries. +- **N3** — `RuntimeAcceptedPositionExecutionStatus.DeferredCell`'s doc ("the + destination landblock's collision generation was not ready") is the status two + new route-2 tests assert for a *quiescence* park. Pre-existing, in a file this + diff does not touch — the next stale comment in line. Fix it or file it. + +## Do not churn — verified across both reviews + +The A1 relocation is mechanically correct: `IsCollisionPrefixQuiescing` consults +the full map (`:827-829`; one live entry per prefix via Remove-then-Add +`:857`/`:885`); the post-snap cell is exactly what `RestoreParkWithdrawal:3484` +restores at, with no writer in between; `SnapToCell` really does set +`InWorld = true` (`PhysicsBody.cs:201-205`) so the three-local hoist is required, +not stylistic; `RetryDeferred` re-parks without a second `SnapToCell`, keeping +the decision bound to the cell it was taken against; the plain park at `:3063` is +**provably** unchanged (the `:3017` quiescence check already returned false, and +`AdjustToOutside` is idempotent on an adjusted pair); `ParkCollisionResidents` +passes `false` explicitly; no prefix pin or streaming stall is possible +(`HasOldPrefixPlacementDebt:3829` skips `WakeableLostCell`, and the operation is +retired before the restore). All four `ParkDeferred` call sites accounted for. + +**The pushback is a legitimate correction**, traced independently through +`TryApplyPosition:1771-1774` → `RefreshDerivedState:230-237` → `SetFullCell`, and +the remote leg through `RebucketLiveEntity` → `CommitRebucket:1816`. Route 1 and +remote creates cannot reach the source shape at all (`CurrentCellId` requires +`body.InWorld`, false for a first-entry body). + +Both route-2 tests drive production (`MergeAccepted` → real `TryApplyPosition`, +real `PhysicsTimestampGate`, then `TryExecuteAcceptedLocalPosition`); nothing is +hand-built. Keeping `FarSnap_QuiescingSourceLandblock_` is right — the state is +constructible in the fixture, only production cannot reach it — and its caveat is +accurate. A5 is satisfied at the pre-flight itself. B1, B3, m1-m5, n2, n3 all +fixed and accurate. **n1 in the round-3 correction was MINE and wrong** — +classifier `:467` is correct, `:468` is `ConstrainPhase`, and the member-name +replacement is exact. No added line carries a source line-number citation — +verified by scanning every `+` line. + +## Gate + +Complete Release suite. Baseline **10,968 / 4 / 0** at `1b631f12`; round 3 +measured **11,007 / 4 / 0**. Known flakes, do not chase or conflate: **#302** +(`PortalProjectionTests…`, App.Tests), **#308** (`NakEmissionTests.LossSoak_…`, +Core.Net.Tests). diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index a0495a6d..58614a89 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -588,6 +588,18 @@ internal sealed class SessionPlayerCompositionPhase () => d.PlayerController.Controller, () => d.Character.UsePositionFromServer, () => liveSessionSource.CurrentSession); + // 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 + // residency (GraphicalRemotePlacementServiceWindow's own remarks + // justify IsNearTier over IsNearTierOrPending). Shares the SAME entity + // lifetime, clock, and prepared-collision source as the two drive + // controllers above. + var remotePlacementDrive = new RuntimeRemotePlacementDriveController( + d.EntityObjects, + d.Runtime.Clock, + firstEntryCollision, + new GraphicalRemotePlacementServiceWindow(live.WorldState)); var hydration = new LiveEntityHydrationController( live.LiveEntities, d.EntityObjects, @@ -654,6 +666,7 @@ internal sealed class SessionPlayerCompositionPhase localPhysicsTimestamps.Publish, d.MovementDiagnostics, acceptedPositionDrive, + remotePlacementDrive, worldDropProjection); var liveness = new LiveEntityLivenessController( live.LiveEntities, @@ -977,7 +990,8 @@ internal sealed class SessionPlayerCompositionPhase live.PlacementProjection, placementProjectionRetry, firstEntryDrive, - acceptedPositionDrive), + acceptedPositionDrive, + remotePlacementDrive), liveSessionCommands, d.Log); LiveSessionHost sessionHost = sessionRuntimeFactory.Create( diff --git a/src/AcDream.App/Net/GraphicalSessionEventRoute.cs b/src/AcDream.App/Net/GraphicalSessionEventRoute.cs index 91549de3..5500e564 100644 --- a/src/AcDream.App/Net/GraphicalSessionEventRoute.cs +++ b/src/AcDream.App/Net/GraphicalSessionEventRoute.cs @@ -18,6 +18,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting private readonly RuntimePlacementProjectionRetrySlot _retries; private readonly RuntimeFirstEntryDriveController? _firstEntry; private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive; + private readonly RuntimeRemotePlacementDriveController? _remotePlacementDrive; private readonly Action? _localPlayerCompleted; private RuntimePlacementProjectionSubscription? _subscription; private IDisposable? _retryLease; @@ -32,7 +33,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting RuntimePlacementProjectionRetrySlot retries, RuntimeFirstEntryDriveController? firstEntry = null, Action? localPlayerCompleted = null, - RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null) + RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null, + RuntimeRemotePlacementDriveController? remotePlacementDrive = null) : this( events, () => new RuntimePlacementProjectionSubscription( @@ -43,7 +45,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting retries, firstEntry, localPlayerCompleted, - acceptedPositionDrive) + acceptedPositionDrive, + remotePlacementDrive) { ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(placements); @@ -56,7 +59,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting RuntimePlacementProjectionRetrySlot retries, RuntimeFirstEntryDriveController? firstEntry = null, Action? localPlayerCompleted = null, - RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null) + RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null, + RuntimeRemotePlacementDriveController? remotePlacementDrive = null) { _events = events ?? throw new ArgumentNullException(nameof(events)); _createSubscription = createSubscription @@ -67,6 +71,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting _firstEntry = firstEntry; _localPlayerCompleted = localPlayerCompleted; _acceptedPositionDrive = acceptedPositionDrive; + _remotePlacementDrive = remotePlacementDrive; } public void Attach() @@ -84,6 +89,10 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting // Position drive controller (RuntimeAcceptedPositionDriveController // .AttachRoute's doc comment). _acceptedPositionDrive?.AttachRoute(this); + // C4 route 4b-2: route 4b-1's controller gained its first production + // caller, so its one-route-at-a-time latch and its route-scoped + // cancellation on detach are load-bearing rather than dormant. + _remotePlacementDrive?.AttachRoute(this); _events.Attach(); RuntimePlacementProjectionSubscription? subscription = null; @@ -106,6 +115,10 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting { _firstEntry?.DriveAll(); _acceptedPositionDrive?.Advance(); + // C4 route 4b-2: resolves a retained remote preparation + // retry (Setup or world frame not resolved yet) on the + // same cadence, before the FIFO head is republished. + _remotePlacementDrive?.Advance(); return boundSubscription.RetryPending(); }); _subscription = subscription; @@ -136,6 +149,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting // route's entries. _firstEntry?.DetachRoute(this); _acceptedPositionDrive?.DetachRoute(this); + _remotePlacementDrive?.DetachRoute(this); if (!_eventsDisposed) { _events.Dispose(); diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 8977a972..3dc0c2b4 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -88,7 +88,8 @@ internal sealed record LiveSessionWorldRuntime( RuntimePlacementPresentationSink PlacementProjection, RuntimePlacementProjectionRetrySlot PlacementRetries, RuntimeFirstEntryDriveController FirstEntryDrive, - RuntimeAcceptedPositionDriveController AcceptedPositionDrive); + RuntimeAcceptedPositionDriveController AcceptedPositionDrive, + RuntimeRemotePlacementDriveController RemotePlacementDrive); /// /// Builds the exact per-generation route/reset graph for the canonical live @@ -266,7 +267,8 @@ internal sealed class LiveSessionRuntimeFactory _world.PlacementRetries, _world.FirstEntryDrive, _ => session.SendGameAction(GameActionLoginComplete.Build()), - _world.AcceptedPositionDrive); + _world.AcceptedPositionDrive, + _world.RemotePlacementDrive); } private LiveInventorySessionBindings CreateInventoryBindings() => new( diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index e65b26c6..314a4e93 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -65,6 +65,13 @@ internal sealed class LiveEntityNetworkUpdateController private readonly InventoryWorldDropProjectionController? _worldDropProjection; private readonly RuntimeAcceptedPositionDriveController _acceptedPositionDrive; + /// + /// C4 route 4b-2: the Runtime-owned remote placement seam. Route 4b-1 + /// landed it with no production caller; the remote far snap + /// (SetPositionSimple, player_distance >= 96 m) is its + /// first, so its ownership ledger stops being tautologically zero here. + /// + private readonly RuntimeRemotePlacementDriveController _remotePlacementDrive; private PlayerMovementController? _playerController => _playerControllerSource.Controller; private EntityPhysicsHost? _playerHost => _playerHostSource.Host; @@ -106,6 +113,7 @@ internal sealed class LiveEntityNetworkUpdateController Action publishTimestamps, IMovementTruthDiagnosticSink movementTruthDiagnostics, RuntimeAcceptedPositionDriveController acceptedPositionDrive, + RuntimeRemotePlacementDriveController remotePlacementDrive, InventoryWorldDropProjectionController? worldDropProjection = null) { _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); @@ -143,6 +151,8 @@ internal sealed class LiveEntityNetworkUpdateController ?? throw new ArgumentNullException(nameof(movementTruthDiagnostics)); _acceptedPositionDrive = acceptedPositionDrive ?? throw new ArgumentNullException(nameof(acceptedPositionDrive)); + _remotePlacementDrive = remotePlacementDrive + ?? throw new ArgumentNullException(nameof(remotePlacementDrive)); _worldDropProjection = worldDropProjection; } @@ -822,9 +832,19 @@ internal sealed class LiveEntityNetworkUpdateController /// App contributes only retail's player_distance — the live /// physics-controller distance, and (never a /// fabricated Vector3.Zero) when no controller exists yet, which - /// makes Runtime decline and leaves the legacy path untouched. Callers - /// must only invoke this for a genuinely remote (never local-player) - /// entity whose remotePlacementRequired gate is already false. + /// makes Runtime decline. Callers must only invoke this for a genuinely + /// remote (never local-player) entity whose + /// remotePlacementRequired gate is already false. + /// + /// + /// C4 route 4b-2 review fix — this comment used to end "and leaves the + /// legacy path untouched". There is no legacy path left: the duplicated + /// App-side near/far blocks were deleted with this slice, and a declined + /// classification now takes the stated UnroutedCatchUp policy + /// (AP-137) through ApplyRemoteContactRouting's default arm. The + /// return is still "Runtime has no opinion", never + /// "rejected"; what changed is what the caller does with it. + /// /// private RuntimeAuthoritativePositionRoute? ClassifyRemoteAcceptedPosition( AcDream.Core.Net.WorldSession.EntityPositionUpdate update, @@ -854,8 +874,20 @@ internal sealed class LiveEntityNetworkUpdateController /// and the airborne no-op writes nothing at all (retail /// MoveOrTeleport 0x00516330 returns 0 @0x0051636D). Writing the /// wire pose here first would be the second writer route 2's original - /// defect consisted of. Every other classification keeps the pre-existing - /// write, unchanged, until route 4b. + /// defect consisted of. + /// + /// + /// + /// C4 route 4b-2 review fix — this comment used to end "Every other + /// classification keeps the pre-existing write, unchanged, until route + /// 4b", which is now false in both halves. The gate is still + /// OwnsSteadyState, so the FAR snap (4b-2's own arm) DOES take the + /// wire-pose write here even though it goes on to place canonically. That + /// is deliberate and is not a second writer in the route 2 sense: the far + /// arm's tail re-syncs the render entity from the RESOLVED body + /// afterwards, so this write only covers the window before the placement + /// commits, exactly as it did before the slice. Route 4b-3 revisits the + /// gate when it takes the cell-less half. /// /// /// @@ -893,12 +925,12 @@ internal sealed class LiveEntityNetworkUpdateController return true; } - /// Which arm of the non-player-remote contact routing claimed a - /// packet. The value is the seam's observable outcome, asserted by the - /// acceptance tests; production only distinguishes - /// , but the finer result is what - /// makes the PRECEDENCE testable and must not be collapsed to a - /// bool. + /// Which arm of the remote contact routing claimed a packet. The + /// value is the seam's observable outcome, asserted by the acceptance + /// tests; production only distinguishes + /// (which alone can be + /// re-entrant), but the finer result is what makes the PRECEDENCE and the + /// arm selection testable and must not be collapsed to a bool. internal enum RemoteContactArm : byte { /// The body was airborne. Hard-snapped, exactly as before @@ -914,33 +946,87 @@ internal sealed class LiveEntityNetworkUpdateController /// Route 4a's near InterpolateTo branch. SteadyStateInterpolate, - /// Neither: the caller runs its own untouched legacy - /// near/far routing. - Legacy, + /// C4 route 4b-2: retail's far snap — StopInterpolating + /// @0x005163CB then SetPositionSimple @0x005163D9 — executed + /// through the canonical Runtime placement owner. + FarSnapPlacement, + + /// The acdream-only leftover set (null, Rejected*, the + /// cell-less SetPosition half 4b-3 will own). See + /// for + /// the stated policy. + UnroutedCatchUp, } /// - /// C4 route 4a: the ORDERING carve-out for the non-player-remote arm. An - /// airborne body's contact packet keeps its pre-existing authoritative - /// hard-snap and is decided BEFORE route 4a's near-Interpolate branch can - /// claim it — the player-remote arm gets the same precedence structurally, - /// because its landing block sits ahead of its own routing and returns. + /// The complete observable outcome of one + /// call: which arm claimed the + /// packet, and — for + /// alone — what the + /// canonical Runtime placement actually did. /// /// - /// This exists as one entry point precisely so the precedence is - /// observable: a landing packet classifies Interpolate, so if the - /// 4a test came first it would ENQUEUE a body that must PLANT, and a - /// creature knocked off a ledge would glide down over a packet interval. - /// Landing is not a behaviour route 4a is scoped to change. + /// C4 route 4b-2 review fix: the placement status used to be discarded at + /// the call site (_ = placementDrive.…), which made the far arm's + /// non-commit outcomes invisible from outside and hid the freeze + /// the review found. Production still takes no DECISION from it — + /// retail's MoveOrTeleport likewise discards + /// SetPositionSimple's SetPositionError and returns 1 + /// @0x005163E8 — but the value is now carried out of the seam so the + /// acceptance tests assert the commit path and the + /// store_position fallback path apart from each other. + /// is for every arm that + /// performs no placement. /// /// - internal static RemoteContactArm ApplyRemoteContactRouting( + internal readonly record struct RemoteContactRouting( + RemoteContactArm Arm, + RuntimeRemotePlacementExecutionStatus? Placement); + + /// + /// The complete remote grounded/contact routing for ONE accepted Position, + /// shared by the player-remote and NPC-remote arms — retail's + /// CPhysicsObj::MoveOrTeleport (0x00516330) makes no + /// this == player distinction on any of these branches. + /// + /// + /// C4 route 4a contributed the ORDERING carve-out: an airborne body's + /// contact packet keeps its pre-existing authoritative hard-snap and is + /// decided BEFORE the near-Interpolate branch can claim it. A landing + /// packet classifies Interpolate, so if the 4a test came first it + /// would ENQUEUE a body that must PLANT, and a creature knocked off a + /// ledge would glide down over a packet interval. The player-remote caller + /// reaches this method only with Airborne == false (its landing + /// block sits ahead of its routing and returns), so the carve-out is inert + /// there and the two callers stay one decision. + /// + /// + /// + /// C4 route 4b-2 added and + /// deleted the two duplicated App-side near/far blocks that used to follow + /// this call. The far arm is the only re-entrant one — a canonical + /// placement publishes its Place receipt synchronously, and a + /// non-commit outcome publishes a cancellation receipt just as + /// synchronously, and the production placement-projection sink can delete + /// or replace the incarnation from inside either — so a caller MUST + /// re-validate position ownership after this returns + /// , on EVERY placement + /// status, before writing anything else for the packet. That includes the + /// ConstrainTo leash: both arms therefore run the re-validation + /// FIRST and arm second (see AP-138). + /// + /// + internal static RemoteContactRouting ApplyRemoteContactRouting( + RuntimeRemotePlacementDriveController placementDrive, + RuntimeEntityRecord canonical, RemoteMotion remote, RuntimeAuthoritativePositionRoute? route, System.Numerics.Vector3 worldPos, System.Numerics.Quaternion rotation, bool willBeDrTicked) { + ArgumentNullException.ThrowIfNull(placementDrive); + ArgumentNullException.ThrowIfNull(canonical); ArgumentNullException.ThrowIfNull(remote); if (remote.Airborne) { @@ -959,19 +1045,126 @@ internal sealed class LiveEntityNetworkUpdateController // the queue is empty here could delete it. remote.Body.Position = worldPos; remote.Body.Orientation = rotation; - return RemoteContactArm.AirborneSnap; + return new RemoteContactRouting( + RemoteContactArm.AirborneSnap, Placement: null); } - if (!RuntimeRemoteSteadyStatePosition.IsNearInterpolate(route)) - return RemoteContactArm.Legacy; + switch (RuntimeRemoteFarSnapPosition.ResolveArm(route)) + { + case RuntimeRemoteAcceptedPositionArm.FarSnapPlacement: + return new RemoteContactRouting( + RemoteContactArm.FarSnapPlacement, + placementDrive.ApplyAcceptedRemoteFarSnap( + canonical, + remote, + route!.Value)); - RuntimeRemoteSteadyStatePosition.ApplyInterpolate( - remote, - worldPos, - rotation, - isMovingTo: remote.Movement.IsMovingTo(), - willBeDrTicked); - return RemoteContactArm.SteadyStateInterpolate; + case RuntimeRemoteAcceptedPositionArm.NearInterpolate: + RuntimeRemoteSteadyStatePosition.ApplyInterpolate( + remote, + worldPos, + rotation, + isMovingTo: remote.Movement.IsMovingTo(), + willBeDrTicked); + return new RemoteContactRouting( + RemoteContactArm.SteadyStateInterpolate, Placement: null); + + case RuntimeRemoteAcceptedPositionArm.AirborneNoOperation: + // R10 review fix: explicit rather than folded into `default`, + // where the comment ASSERTED unreachability that no code + // enforced. Retail's arg4 == 0 branch writes NOTHING at all + // (@0x0051636D returns 0), so there is no operation this + // method could perform; both production callers early-return + // on IsAirborneNoOperation before they route (the player arm's + // AIRBORNE NO-OP block, the NPC arm's mirror of it — the two + // `IsAirborneNoOperation` call sites in this file, cited here + // by name because line numbers went stale within one review + // round). Reaching here means a caller + // skipped that gate, and the only faithful answer is to say + // so — ApplyInterpolate's own doc likewise forbids being + // called for this disposition. + throw new InvalidOperationException( + "A NoPositionOperation (airborne no-op) classification " + + "must be handled by the caller's own early return " + + "before routing; retail's MoveOrTeleport writes nothing " + + "at all on that branch (@0x0051636D)."); + + default: + // UnroutedCatchUp takes the SAME AP-87 catch-up the near + // branch uses — the stated policy (AP-137), and the reason + // the App's two duplicated 96 m / 4 m constant pairs and both + // fabricated Vector3.Zero player positions are gone. + RuntimeRemoteSteadyStatePosition.ApplyInterpolate( + remote, + worldPos, + rotation, + isMovingTo: remote.Movement.IsMovingTo(), + willBeDrTicked); + return new RemoteContactRouting( + RemoteContactArm.UnroutedCatchUp, Placement: null); + } + } + + /// + /// C4 route 4b-2: the NPC-remote arm's post-routing wire-cell adoption, + /// extracted so its ONE suppression rule is exercised by production and by + /// test through the same entry point rather than restated in a test body. + /// + /// + /// writes THROUGH to the canonical + /// FullCellId (RuntimePhysicsState.CommitCanonicalCell). + /// After a far snap the canonical placement is the cell authority — retail + /// CPhysicsObj::SetPositionInternal (0x00515BD0) resolves the + /// destination cell through AdjustPosition/set_cell and + /// nothing writes the wire cell over it afterwards — so this write is + /// suppressed for that arm alone. Unlike the player arm, whose identical + /// write sits BEFORE its routing, the NPC one sits after; leaving it + /// unguarded would discard a resolved cell that differs from the wire + /// cell. Every other arm performs no placement, so the wire cell is still + /// the newest truth there. + /// + /// + /// + /// Scope, stated precisely (C4 route 4b-2 review; corrected at the + /// delta review). The suppression bites whenever the canonical + /// placement RESOLVED a cell different from the wire cell. That is the + /// commit, and also the RejectedByPlacement shape where + /// CommitCanonical settled the body (and wrote + /// record.FullCellId) before the projection ownership was + /// displaced — the earlier "only when the placement COMMITTED" wording + /// missed that one. It also bites on Deferred, which round 3 + /// (correction m1) adds to this enumeration: ParkDeferred snaps + /// the body to the PARKED result cell and + /// RestoreParkWithdrawal re-commits residency from + /// body.CellPosition.ObjCellId, which for a post-sweep park is the + /// swept/settled cell and need not be the wire cell. The remaining + /// outcomes — Refused, Contention, + /// RejectedPreparation, NotApplicable, and the + /// RejectedByPlacement shape the engine's own sweep refused — + /// resolve no cell, and there the suppression is a no-op: the per-UP + /// RebucketLiveEntity above already committed the wire full cell + /// to canonical, and RemoteMotion.CellId reads through to the same + /// FullCellId, so the suppressed write would have written the + /// value that is already there. Keying on the ARM rather than the + /// placement status is therefore exact as well as simpler — and the + /// body/cell divergence a refusal used to produce was the frozen body, + /// which the store_position fallback fixes at its source. + /// + /// + /// + /// Returns true when the wire cell was adopted. + /// + /// + internal static bool TryAdoptWireCellAfterRouting( + RemoteMotion remote, + RemoteContactArm arm, + uint wireCellId) + { + ArgumentNullException.ThrowIfNull(remote); + if (arm is RemoteContactArm.FarSnapPlacement) + return false; + remote.CellId = wireCellId; + return true; } /// @@ -1465,11 +1658,12 @@ internal sealed class LiveEntityNetworkUpdateController // near InterpolateTo queue — no direct body write here) never // receives it. remotePlacementRequired guarantees the classifier's // teleport (SetPosition) disposition never reaches here — that stays - // route 4b. The local player never reaches this generic-remote code - // path at all. Every classification 4a does NOT own — the >=96 m - // far snap, a cell-less remote, a rejected authority or payload, and - // "no classification at all" — falls through to the pre-existing - // legacy routing completely unchanged; 4b deletes that fallback. + // route 4b-3. The local player never reaches this generic-remote code + // path at all. C4 route 4b-2 additionally routes the >=96 m far snap + // through the canonical Runtime placement owner; a cell-less remote, a + // rejected authority or payload, and "no classification at all" take + // the stated UnroutedCatchUp policy + // (RuntimeRemoteFarSnapPosition.ResolveArm) until 4b-3. RuntimeAuthoritativePositionRoute? earlyRemoteRoute = update.Guid != _playerServerGuid && !remotePlacementRequired ? ClassifyRemoteAcceptedPosition( @@ -1491,9 +1685,18 @@ internal sealed class LiveEntityNetworkUpdateController // ordinary moving remote's draw bucket, commits its canonical // FullCellId (which feeds back as the classifier's own // CommittedCellId and as the ConstraintDistance cell key), and - // recovers a pending bucket promotion. Neither 4a branch performs a - // placement, so unlike route 2 there is no committed placement - // receipt that could project this in its stead. + // recovers a pending bucket promotion. + // + // C4 route 4b-2 review fix — this used to end "Neither 4a branch + // performs a placement, so unlike route 2 there is no committed + // placement receipt that could project this in its stead". The far + // arm DOES perform a placement now, and this call still runs ahead of + // it for every classification. That ordering is what makes the NPC + // arm's post-routing wire-cell suppression a no-op on a non-commit + // outcome (TryAdoptWireCellAfterRouting): the wire full cell is + // already canonical by the time routing starts. A COMMITTED placement + // resolves its own destination cell afterwards, which is the case the + // suppression exists for. if (!_liveEntities!.RebucketLiveEntity(update.Guid, p.LandblockId) || !_liveEntities.TryGetRecord( update.Guid, @@ -1707,10 +1910,14 @@ internal sealed class LiveEntityNetworkUpdateController // a nonzero MoveOrTeleport return (@0x00454272, inside the // `if (MoveOrTeleport(...) != 0)` at @0x00454254) — so this // pre-operation, unconditional arming is now the LEGACY shape and - // runs only for the classifications 4a does not own. The two 4a - // classifications arm it after their own operation instead, or - // (the airborne no-op) not at all. 4b deletes this fallback. - if (!RuntimeRemoteSteadyStatePosition.OwnsSteadyState(earlyRemoteRoute) + // runs only for the classifications the post-operation arm does + // not own. C4 route 4b-2 added the far snap to that set, leaving + // this fallback for the cell-less half, the two rejections, and + // "no classification at all"; 4b-3 deletes it. The gate reads the + // SAME predicate TryArmConstraintAfterOperation does, so exactly + // one of the two sites arms any given classification. + if (!RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint( + earlyRemoteRoute) && rmState.Host is { } remoteConstraintHost) { RuntimeRemoteSteadyStatePosition.ArmConstraintAfterOperation( @@ -1911,72 +2118,66 @@ internal sealed class LiveEntityNetworkUpdateController } // ── GROUNDED ROUTING (CPhysicsObj::MoveOrTeleport) ──────────── - // C4 route 4a: the near (Interpolate) decision — including the - // AP-87 placement-snap backstop — now lives in - // RuntimeRemoteSteadyStatePosition.ApplyInterpolate, shared - // with the NPC branch below. Every OTHER classification (the - // >=96 m far snap, a cell-less remote, a rejection, or none at - // all) keeps the pre-existing legacy near/far routing below, - // unchanged, until route 4b — "not Interpolate" is NOT "far". + // C4 routes 4a + 4b-2: the complete near/far/leftover decision + // is the SAME shared entry point the NPC arm below calls — + // retail's disassembly makes no `this == player` distinction + // on any of these branches. The player arm reaches it only + // with Airborne == false (the landing block above returns), so + // the airborne carve-out inside is inert here. bool willBeDrTicked = WillAdvanceRemoteMotion(update.Guid, rmState); - if (RuntimeRemoteSteadyStatePosition.IsNearInterpolate( - earlyRemoteRoute)) - { - RuntimeRemoteSteadyStatePosition.ApplyInterpolate( - rmState, - worldPos, - rot, - isMovingTo: rmState.Movement.IsMovingTo(), - willBeDrTicked); + RemoteContactRouting playerRouting = ApplyRemoteContactRouting( + _remotePlacementDrive, + acceptedPositionCanonical, + rmState, + earlyRemoteRoute, + worldPos, + rot, + willBeDrTicked); - // D2: ConstrainTo arms strictly AFTER the operation, - // anchored post-move — retail arms it only once - // MoveOrTeleport returns nonzero (@0x00454254/@0x00454272). - RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( - earlyRemoteRoute, - rmState); - } - else + // C4 route 4b-2: the far arm is re-entrant — the canonical + // placement publishes its Place receipt (or, on a non-commit + // outcome, its cancellation receipt) synchronously, and the + // production projection sink can delete or replace this + // incarnation from inside either. Re-validate before ANY + // further write for this packet, exactly as the landing block + // does after MovementManager.HitGround. + // + // R5 review fix: this now sits BEFORE the leash arming, which + // is the same order the NPC arm has always had — the two arms + // were mirror images of each other and one of them had to be + // wrong. Arming is a write (it stamps rmState.Host's + // PositionManager), and this class's own rule is that nothing + // may be written through a superseded owner. The residual + // versus retail's unconditional arm on a nonzero + // MoveOrTeleport return is AP-138. + if (playerRouting.Arm is RemoteContactArm.FarSnapPlacement + && (!IsCurrentPositionOwner(entity) + || !ReferenceEquals( + positionRecord.RemoteMotionRuntime, + rmState))) { - // LEGACY near/far routing, unchanged. Its leash was - // already armed by the legacy pre-operation call above. - const float MaxPhysicsDistance = 96f; // retail player_distance far-snap - const float BodySnapThreshold = 4f; // large correction / teleport / unplaced -> snap - var localPlayerPos = _playerController?.Position ?? System.Numerics.Vector3.Zero; - float dist = System.Numerics.Vector3.Distance(worldPos, localPlayerPos); - // #184 Slice 2b: the player UP routing gains the SAME placement-snap - // backstop the NPC routing got in Slice 1 (AP-87). The 4 m - // bodyToTarget guard is the LOAD-BEARING backstop; - // !willBeDrTicked snaps a no-Sequencer player whose queue nothing - // would consume; dist>96 is retail's far-snap. - float bodyToTarget = System.Numerics.Vector3.Distance( - rmState.Body.Position, worldPos); - - if (dist > MaxPhysicsDistance || !willBeDrTicked - || bodyToTarget > BodySnapThreshold) - { - // Beyond view bubble / large correction / unplaced body: - // SetPositionSimple slide-snap. Clear queue. - rmState.Interp.Clear(); - rmState.Body.Position = worldPos; - rmState.Body.Orientation = rot; - } - else - { - // Within view bubble, placed + near: enqueue waypoint for - // adjust_offset to walk to. - System.Numerics.Quaternion? immediateOrientation = - rmState.Interp.Enqueue( - worldPos, - rot, - isMovingTo: rmState.Movement.IsMovingTo(), - currentBodyPosition: rmState.Body.Position, - currentBodyOrientation: rmState.Body.Orientation); - if (immediateOrientation is { } closeOrientation) - rmState.Body.Orientation = closeOrientation; - } + return; } + // D2: ConstrainTo arms strictly AFTER the operation, anchored + // post-move — retail arms it only once MoveOrTeleport returns + // nonzero (@0x00454254/@0x00454272), which the near AND far + // branches both do (@0x005163BE, @0x005163E8). The far branch + // arms on EVERY placement outcome, including a failed one: + // retail discards SetPositionSimple's SetPositionError and + // returns 1 regardless. Every remaining classification already + // armed through the legacy pre-operation call above. + // + // Delta review N4: retail's arm is unconditional, acdream's is + // not — the currency guard immediately above returns without + // arming when the far arm's synchronous receipt replaced or + // deleted this incarnation. That one-packet gap is the third + // part of AP-138, and this comment must not read as though the + // arm below is reached on every far-snap outcome. + RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( + earlyRemoteRoute, + rmState); + // Track the UP-derived synth velocity for diagnostics // ([VEL_DIAG] pace comparison). L.2g S5 (2026-07-02): the // #39-era cycle-refinement call that used to live here is @@ -1997,12 +2198,30 @@ internal sealed class LiveEntityNetworkUpdateController } } - // Sync the visible entity to the body — overrides the unconditional - // entity.SetPosition(worldPos) snap at the top of this function. - // For the far-snap branch this is a no-op (body == worldPos); for - // the near-enqueue branch this prevents a 1-frame teleport-then- - // yank-back rubber-band as TickAnimations chases worldPos via the - // queue. + // Sync the visible entity to the body — overrides the + // entity.SetPosition(worldPos) write at the top of this + // function (TryApplyGenericRemoteRenderPose, suppressed for + // the two 4a classifications). This prevents a 1-frame + // teleport-then-yank-back rubber-band as TickAnimations + // chases worldPos via the queue. + // + // C4 route 4b-2 review fix — this used to claim "For the + // far-snap branch this is a no-op (body == worldPos)". The + // PROVENANCE claim behind that was always wrong and is what + // matters here: the far arm's body pose comes from the + // canonical accepted destination resolved through Runtime's + // world frame (a committed placement, a park's snap, or + // store_position), never from the caller's separately-derived + // worldPos, and this write is what carries that canonical pose + // to the render entity. + // + // Delta review N4 — the VALUE claim, stated correctly: on the + // store_position path the two happen to be equal, because #283 + // proved App's streaming origin and Runtime's world frame + // cannot disagree and both compose the same accepted origin. + // It is genuinely NOT a no-op on the committed path, where the + // body carries the collision-settled spherePath.CurPos. Do not + // "simplify" this write away on the strength of the equal case. // // #184 Slice 2b: sync the player-remote shadow to the RESOLVED/placed // body (mirrors the NPC UP-branch tail). Now that grounded players run @@ -2105,21 +2324,18 @@ internal sealed class LiveEntityNetworkUpdateController Console.WriteLine(FormattableString.Invariant( $"[sticky-snap-skip] guid=0x{update.Guid:X8} d={snapDist:F3} srv=({worldPos.X:F2},{worldPos.Y:F2}) body=({rmState.Body.Position.X:F2},{rmState.Body.Position.Y:F2})")); } + var npcRouting = new RemoteContactRouting( + RemoteContactArm.UnroutedCatchUp, Placement: null); if (!snapSuppressedByStick) { - // C4 route 4a: the near (Interpolate) decision — including the - // AP-87 placement-snap backstop — is now the SAME Runtime - // owner the player-remote branch above calls; retail's - // MoveOrTeleport (0x00516330) makes no `this == player` - // distinction, so the two per-kind copies became one. TS-44's - // sticky suppression stays an NPC-only CALLER gate (this - // `if`), which is what its register row describes and what - // the player arm has never had. + // C4 routes 4a + 4b-2: the complete near/far/leftover decision + // is the SAME shared entry point the player-remote branch + // above calls; retail's MoveOrTeleport (0x00516330) makes no + // `this == player` distinction, so the two per-kind copies + // became one. TS-44's sticky suppression stays an NPC-only + // CALLER gate (this `if`), which is what its register row + // describes and what the player arm has never had. // - // Every OTHER classification (the >=96 m far snap, a - // cell-less remote, a rejection, or none at all) keeps the - // pre-existing legacy routing below, unchanged, until route - // 4b — "not Interpolate" is NOT "far". // #184 (2026-07-07): an AIRBORNE body keeps its authoritative // hard-snap (the arc integrates locally, K-fix15), and that // decision is taken FIRST — a landing packet classifies @@ -2127,73 +2343,42 @@ internal sealed class LiveEntityNetworkUpdateController // airborne test would enqueue a body that must plant, and a // creature knocked off a ledge would glide down over a packet // interval. Physics digest 2026-07-07 banner. - if (ApplyRemoteContactRouting( - rmState, - earlyRemoteRoute, - worldPos, - rot, - WillAdvanceRemoteMotion(update.Guid, rmState)) - is RemoteContactArm.Legacy) - { - // LEGACY NPC near/far routing, unchanged. Its leash was - // already armed by the legacy pre-operation call above. - // The body is PLACED (hard-snapped) whenever it is not - // already tracking NEAR the server position — the first - // UP, a large correction / teleport, an out-of-view - // creature (>96 m from the local player), or an entity the - // DR loop won't tick — otherwise the server point is a - // GENTLE dead-reckoning TARGET the per-tick interp - // catch-up walks to, and the KEPT sweep de-overlaps that - // movement. - const float MaxPhysicsDistanceNpc = 96f; // retail player_distance far-snap - const float BodySnapThresholdNpc = 4f; // large correction / teleport -> snap - var localPlayerPosNpc = _playerController?.Position - ?? System.Numerics.Vector3.Zero; - float distNpc = System.Numerics.Vector3.Distance(worldPos, localPlayerPosNpc); - float bodyToTargetNpc = System.Numerics.Vector3.Distance( - rmState.Body.Position, worldPos); - bool firstUpNpc = rmState.LastServerPosTime <= 0.0; - bool willBeDrTickedNpc = WillAdvanceRemoteMotion( - update.Guid, - rmState); + npcRouting = ApplyRemoteContactRouting( + _remotePlacementDrive, + acceptedPositionCanonical, + rmState, + earlyRemoteRoute, + worldPos, + rot, + WillAdvanceRemoteMotion(update.Guid, rmState)); - if (firstUpNpc || !willBeDrTickedNpc - || distNpc > MaxPhysicsDistanceNpc - || bodyToTargetNpc > BodySnapThresholdNpc) - { - // Placement / far / large-correction: SNAP + clear queue. - rmState.Interp.Clear(); - rmState.Body.Position = worldPos; - rmState.Body.Orientation = rot; - } - else - { - // Near DR correction: enqueue the waypoint for the per-tick - // catch-up (Path B consumes it via ComputeOffset). - System.Numerics.Quaternion? immediateOrientation = - rmState.Interp.Enqueue( - worldPos, - rot, - isMovingTo: rmState.Movement.IsMovingTo(), - currentBodyPosition: rmState.Body.Position, - currentBodyOrientation: rmState.Body.Orientation); - if (immediateOrientation is { } closeOrientation) - rmState.Body.Orientation = closeOrientation; - } + // C4 route 4b-2: the far arm is re-entrant (see + // ApplyRemoteContactRouting's own remarks). Re-validate before + // any further write for this packet — including the leash + // arming below, which is why the player arm now runs this + // check in the SAME position relative to its own arming call + // (R5 review fix). + if (npcRouting.Arm is RemoteContactArm.FarSnapPlacement + && (!IsCurrentPositionOwner(entity) + || !ReferenceEquals( + positionRecord.RemoteMotionRuntime, + rmState))) + { + return; } } // D2: ConstrainTo arms strictly AFTER the operation above, // anchored post-move (@0x00454272, inside the // `if (MoveOrTeleport(...) != 0)` at @0x00454254), and only for a - // classification route 4a owns — every other one already armed - // the legacy pre-operation call above, exactly as before. Retail's - // ConstraintManager leash is independent of the acdream-only TS-44 - // sticky suppression (which only concerns the enqueue/snap above), - // so this deliberately sits OUTSIDE the snapSuppressedByStick - // gate: a stuck NPC's leash still re-arms every accepted Position - // exactly as it did before this route split the single call into a - // per-branch pair. + // classification the post-operation arm owns — every other one + // already armed the legacy pre-operation call above, exactly as + // before. Retail's ConstraintManager leash is independent of the + // acdream-only TS-44 sticky suppression (which only concerns the + // enqueue/snap/placement above), so this deliberately sits OUTSIDE + // the snapSuppressedByStick gate: a stuck NPC's leash still + // re-arms every accepted Position exactly as it did before this + // route split the single call into a per-branch pair. RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( earlyRemoteRoute, rmState); @@ -2222,7 +2407,11 @@ internal sealed class LiveEntityNetworkUpdateController // per-tick ResolveWithTransition sweep then advances CheckCellId // as the sphere crosses cells and writes the new cell back into // rmState.CellId so the NEXT frame starts in the correct cell. - rmState.CellId = p.LandblockId; + // + // C4 route 4b-2: NOT after a far snap — see + // TryAdoptWireCellAfterRouting for the rule and why it applies to + // this arm and not the player one. + TryAdoptWireCellAfterRouting(rmState, npcRouting.Arm, p.LandblockId); // Near UpdatePosition orientation is carried by the same complete // interpolation Frame as translation. Placement, airborne, and diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index 93992a7a..2eef3b31 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -61,6 +61,29 @@ public static class PhysicsDiagnostics public static bool ProbeCellEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELL") == "1"; + /// + /// Issue #309's connected-gate confirmation signal (2026-08-04, C4 route + /// 4b-2). The gate's quiescence steps need a ForcePosition to land INSIDE + /// a transient collision-prefix quiescence window, which a tester cannot + /// synchronise with a teleport or portal arrival — so without a signal a + /// clean teleport and a correctly-parked one look identical and the gate + /// passes while broken. + /// + /// When true, RuntimeSetPositionState emits one + /// [park] line when a placement parks (entity, park cause, the + /// blocking landblock prefix if any, the caller's pre-snap cell, the + /// POST-snap cell the rollback would restore into, and whether the + /// rollback was captured or declined), and one [park-restore] line + /// when a cancelled park's withdrawal is rolled back or its residency arm + /// is declined at restore time. Low volume: parks are rare, and nothing + /// is emitted for an ordinary committing placement. Zero cost when off + /// (one static-bool read per park). + /// + /// Initial state from ACDREAM_PROBE_PARK=1. + /// + public static bool ProbeParkEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_PARK") == "1"; + /// /// #283 reachability probe (2026-08-03). Runtime rebases its world frame /// the instant an accepted Position carries TeleportAdvanced, while App's @@ -721,6 +744,7 @@ public static class PhysicsDiagnostics { ProbeResolveEnabled = false; ProbeCellEnabled = false; + ProbeParkEnabled = false; ProbeBuildingEnabled = false; ProbeCellSetEnabled = false; ProbeStickyEnabled = false; diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index 453d32e9..6b080fa2 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -600,9 +600,18 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable /// Returns when no classification can honestly be /// made: the lifetime has no bound generation yet, the canonical record /// has not claimed a local id, or there is no live local-player position - /// to derive retail's player_distance from. In every one of those - /// cases the caller's pre-existing legacy path runs completely unchanged - /// — a null here is "route 4a has no opinion", never "rejected". + /// to derive retail's player_distance from. A null here is + /// "Runtime has no opinion", never "rejected". + /// + /// + /// + /// C4 route 4b-2 review fix — this used to add "In every one of those + /// cases the caller's pre-existing legacy path runs completely + /// unchanged". That path no longer exists: 4b-2 deleted the graphical + /// caller's duplicated near/far blocks, and a null now takes the stated + /// UnroutedCatchUp policy (AP-137; + /// RuntimeRemoteFarSnapPosition.ResolveArm), which is AP-87's + /// shared catch-up rather than a re-derived 96 m test. /// /// internal RuntimeAuthoritativePositionRoute? ClassifyRemoteAcceptedPosition( diff --git a/src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs b/src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs new file mode 100644 index 00000000..2599f44a --- /dev/null +++ b/src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs @@ -0,0 +1,208 @@ +using AcDream.Core.Physics; + +namespace AcDream.Runtime.Physics; + +/// +/// Which arm of retail CPhysicsObj::MoveOrTeleport (0x00516330) an +/// already-classified REMOTE accepted Position takes. Total over every +/// classification a remote caller can hold, including the two acdream-only +/// ones retail cannot produce (see +/// ). +/// +internal enum RuntimeRemoteAcceptedPositionArm : byte +{ + /// Retail's arg4 == 0 branch: return 0 + /// @0x0051636D, nothing written. Route 4a. + AirborneNoOperation, + + /// Retail's player_distance < 96f + /// InterpolateTo @0x005163AF. Route 4a. + NearInterpolate, + + /// Retail's player_distance >= 96f + /// StopInterpolating @0x005163CB + SetPositionSimple + /// @0x005163D9. Route 4b-2 — this slice. + FarSnapPlacement, + + /// + /// acdream-only. No retail arm corresponds to it, because retail + /// cannot reach the states that produce it: a classification of + /// RejectedAuthority/RejectedData (retail validates no wire + /// frame this way), (retail always has a player, so + /// player_distance always exists — acdream returns null for every + /// remote packet during the login window before the local movement + /// controller exists), or the cell-less SetPosition half that route + /// 4b-3 will own. See 's remarks for the stated + /// policy. + /// + UnroutedCatchUp, +} + +/// +/// C4 route 4b-2 (2026-08-04): the remaining MoveOrTeleport arm +/// selection for a REMOTE accepted Position, beside route 4a's +/// . Route 4a owns the two +/// dispositions that perform no SetPosition; this owns the far snap +/// (player_distance >= 96 m with wire contact, classified +/// ) +/// and states what happens to everything left over. +/// +internal static class RuntimeRemoteFarSnapPosition +{ + /// + /// True when route 4b-2 owns this classification: retail's far branch, + /// @0x005163C1-@0x005163E8. + /// + /// + /// The disposition alone is NOT sufficient — the classifier emits + /// SetPositionSimple for the LOCAL PLAYER's FORCE_POSITION + /// (RuntimeAuthoritativePositionRouteClassifier.cs:332) and + /// teleport-advanced (:355) branches too, both carrying + /// RuntimeSetPositionOperationKind.LocalAuthoritative. The + /// Teleport flag bit is the same discriminator + /// + /// uses (a remote top-level Create carries Placement|Slide, an + /// accepted remote Position carries retail's + /// Teleport|Slide|SendPositionEvent = 0x1012, exactly the + /// flags CPhysicsObj::SetPositionSimple @0x005162B0 builds for + /// arg3 != 0), so this predicate is a strict narrowing of + /// OwnsPlacement to its far half. + /// + /// + internal static bool OwnsFarSnap(RuntimeAuthoritativePositionRoute? route) => + route is + { + Disposition: + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + OperationKind: + RuntimeSetPositionOperationKind.RemoteAuthoritative, + } + && (route.Value.SetPositionFlags & PhysicsSetPositionFlags.Teleport) + != 0; + + /// + /// The ONE predicate deciding whether retail's single + /// SmartBox::HandleReceivedPosition ConstrainTo site + /// (@0x00454272, inside if (MoveOrTeleport(...) != 0) @0x00454254) + /// is armed AFTER the position operation for this classification, rather + /// than by the legacy pre-operation App call. + /// + /// + /// Route 4a moved its two dispositions onto the post-operation arm; this + /// slice adds the far snap. Every remaining classification still arms + /// through the legacy pre-operation call site, unchanged. Both the legacy + /// gate and + /// + /// read THIS predicate, so a classification can never be armed twice or + /// not at all. + /// + /// + internal static bool OwnsAfterOperationConstraint( + RuntimeAuthoritativePositionRoute? route) => + RuntimeRemoteSteadyStatePosition.OwnsSteadyState(route) + || OwnsFarSnap(route); + + /// + /// Selects the arm for one already-classified remote accepted Position. + /// + /// + /// The + /// policy, stated. Before this slice, every classification route 4a + /// did not own fell into a duplicated App-side near/far block that + /// re-derived retail's player_distance >= 96 m test from + /// _playerController?.Position ?? Vector3.Zero. That test is + /// deleted here rather than preserved, because in every residual case it + /// was already meaningless or wrong: + /// + /// + /// — the classifier returned no + /// route precisely BECAUSE there is no local-player position + /// (RuntimeAcceptedPositionRouteRequests.TryBuild refuses to + /// fabricate one; GameRuntime states the rule). The legacy block + /// then measured the distance from a fabricated Vector3.Zero. + /// Review fix — what that actually computed: worldPos is + /// streaming-origin-relative + /// (LiveEntityNetworkUpdateController.cs: local position plus + /// (landblock − _origin.Center) * 192 m), and the streaming origin + /// recentres on the local player's landblock, so the fabricated distance + /// was the remote's distance from the ORIGIN LANDBLOCK'S CORNER — a + /// biased but genuinely correlated proxy for player_distance, + /// its error bounded by the player's own offset inside that landblock + /// (0-192 m per axis). The earlier claim that it had "no relationship to + /// player_distance" was false. It is deleted anyway because a + /// silently-biased proxy for retail's exact 96 m threshold is not a + /// threshold: the bias is up to ~2.8x the threshold itself, so the arm it + /// selects is not the arm retail would select, and there is no honest way + /// to correct for it without the player position the classifier already + /// declined to fabricate. + /// Cell-less SetPosition — the classifier + /// returns at :399-417, BEFORE it ever evaluates the distance. + /// Retail's cell-less body takes the this_1->cell == 0 branch + /// @0x00516386 (SetPosition, flags 0x1012), never the far + /// branch, so applying a far test to it was a divergence in its own + /// right. Review fix — the residual delta, stated: retail's + /// cell-less arm is an UNCONDITIONAL placement sitting ahead of the + /// contact test — teleport_hook @0x005163EF then SetPosition + /// @0x00516420, return 1 @0x00516438 — while acdream now routes + /// that classification here, where ENQUEUES + /// whenever !firstUp && willBeDrTicked && + /// bodyToTarget <= 4 m, at any distance. It is deliberately not + /// changed to place here. Retail's arm is not a pose write: it is + /// teleport_hook @0x00514ED0 — the COMPLETE call list, read from + /// the decomp (delta review N6 added the last of these, which the earlier + /// enumeration dropped): MovementManager::CancelMoveTo @0x00514EDF, + /// PositionManager::UnStick @0x00514EEE, + /// PositionManager::StopInterpolating @0x00514EFD, + /// PositionManager::UnConstrain @0x00514F0C, + /// TargetManager::ClearTarget @0x00514F1B + + /// NotifyVoyeurOfEvent(Teleported_TargetStatus) @0x00514F28, and + /// CPhysicsObj::report_collision_end(this, 1) @0x00514F31 — followed + /// by the canonical flags-0x1012 + /// SetPosition. Writing only the pose here would leave a live + /// moveto, a live stick, a live constraint leash pointing at the old + /// cell, and every collision partner still believing it is in contact, + /// which is strictly worse than the recorded queue. Porting the + /// whole arm is route 4b-3's entire scope; claiming half of it here is + /// route 4a's "'not Interpolate' is not 'far'" mistake one level up. + /// Recorded as AP-137. + /// RejectedAuthority/RejectedData — the + /// packet's authority or payload was refused; retail, which validates + /// neither, has no state here at all. Review fix — stated plainly: + /// this arm therefore APPLIES a payload the classifier just marked + /// invalid. ClassifyAcceptedPosition emits RejectedData from + /// two sites a remote can reach — an accepted wire position that fails + /// ValidPosition (non-finite origin/orientation or a frame + /// PositionFrameValidation refuses), and a non-finite or negative + /// derived player_distance — and both then take this arm. That is + /// not a regression — the deleted legacy block fed the same payload to the + /// same snap/enqueue pair — but this slice's stated purpose was an + /// EXPLICIT handler, so it is named rather than left implied. Retiring it + /// means giving the two rejections their own no-operation arm, which is a + /// behaviour change route 4b-3 must decide with the cell-less half, not a + /// tidy-up. Recorded as AP-137. + /// + /// + /// What remains is exactly AP-87 — the shared + /// catch-up + /// the near branch already uses, whose unconditional snap on + /// firstUp || !willBeDrTicked || bodyToTarget > 4 m still PLACES + /// an unplaced or badly-lagging body. So a remote keeps tracking the + /// server through the login window and through a rejected packet; it is + /// never silently frozen, which is what deleting the legacy block without + /// a replacement would have done. The single 4 m constant now lives only + /// in RuntimeRemoteSteadyStatePosition; the App's two duplicated + /// copies are gone. + /// + /// + internal static RuntimeRemoteAcceptedPositionArm ResolveArm( + RuntimeAuthoritativePositionRoute? route) + { + if (RuntimeRemoteSteadyStatePosition.IsAirborneNoOperation(route)) + return RuntimeRemoteAcceptedPositionArm.AirborneNoOperation; + if (RuntimeRemoteSteadyStatePosition.IsNearInterpolate(route)) + return RuntimeRemoteAcceptedPositionArm.NearInterpolate; + return OwnsFarSnap(route) + ? RuntimeRemoteAcceptedPositionArm.FarSnapPlacement + : RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp; + } +} diff --git a/src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs b/src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs index 892d394c..8e27797e 100644 --- a/src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs +++ b/src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs @@ -16,16 +16,15 @@ namespace AcDream.Runtime.Physics; /// incarnations — retail's disassembly makes no this==player /// distinction on this path (see ConstraintDistance) — so one Runtime /// owner decides and applies both, replacing the two independent per-kind -/// copies that used to live in LiveEntityNetworkUpdateController. The -/// far (>=96 m) and teleport/cell-less branches remain the legacy App path -/// until C4 route 4b. +/// copies that used to live in LiveEntityNetworkUpdateController. /// /// /// Exactly two dispositions are owned here. Everything else — /// SetPositionSimple, SetPosition, RejectedAuthority, /// RejectedData, and "not classified at all" () — -/// falls through to the untouched legacy App routing. "Not Interpolate" must -/// never be read as "far". +/// belongs to , which owns the far +/// (>=96 m) snap from C4 route 4b-2 and states the policy for the +/// leftovers. "Not Interpolate" must never be read as "far". /// /// internal static class RuntimeRemoteSteadyStatePosition @@ -157,9 +156,23 @@ internal static class RuntimeRemoteSteadyStatePosition /// does NOT run on the airborne no-op. /// /// - /// Route-gated so it can only fire for a classification route 4a owns: - /// every other classification still arms the leash through the legacy - /// pre-operation call site, unchanged, until 4b moves them too. + /// Route-gated by + /// + /// — route 4a's two dispositions plus (C4 route 4b-2) the far snap. Every + /// other classification still arms the leash through the legacy + /// pre-operation call site, unchanged, until 4b-3 moves them too. The + /// legacy site reads the SAME predicate, so no classification can be + /// armed twice or left unarmed. + /// + /// + /// + /// This deliberately does not consult the outcome of the operation it + /// follows. Retail's far branch returns 1 @0x005163E8 unconditionally — + /// MoveOrTeleport discards SetPositionSimple's + /// enum SetPositionError return entirely — so + /// HandleReceivedPosition arms the leash even when the placement + /// FAILED. "Arm only on commit" is the natural misreading and is the same + /// shape as the already-recorded unarmed-leash bug. /// /// internal static bool TryArmConstraintAfterOperation( @@ -168,7 +181,8 @@ internal static class RuntimeRemoteSteadyStatePosition { ArgumentNullException.ThrowIfNull(remote); if (route is not { } selected - || !OwnsSteadyState(selected) + || !RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint( + selected) || !selected.ConstrainAfterRouting || remote.Host is not { } host) { diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index 79eb7b18..41e796b9 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -1,4 +1,4 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.Numerics; using AcDream.Content; @@ -1346,6 +1346,14 @@ internal sealed class RuntimeSetPositionState : IDisposable { EnsureNotDisposed(); ArgumentNullException.ThrowIfNull(record); + // B2 dependency (C4 route 4b-2): the `_operations.ContainsKey` + // refusal is what makes an INHERITED park impossible for any + // operation begun here, which is in turn what lets + // RuntimeRemotePlacementDriveController document a pre-engine + // Rejected as unreachable on its far-snap path (the + // WakeableLostCell-only `with` rewrite in + // SubmitPreparedPlacementCore cannot apply). Loosening it to admit a + // second concurrent operation invalidates that argument. if (record.Key is not { } key || _operations.ContainsKey(key) || HasRetainedCompletion(key)) @@ -1377,6 +1385,12 @@ internal sealed class RuntimeSetPositionState : IDisposable || operation.Stage is not RuntimeEntityPlacementStage .AwaitingPreparation || !ReferenceEquals(operation.Record, record) + // B2 dependency (C4 route 4b-2): this `is not null` is what makes + // DormantLocalActivation and "the record has a body" mutually + // exclusive, which is the step + // RuntimeRemotePlacementDriveController's pre-engine-Rejected + // argument uses to dismiss the AwaitingCell stage divergence + // between PrepareMover and SubmitPreparedPlacementCore. || record.PhysicsBody is not null || !IsCurrent(operation) || body.InWorld @@ -1560,6 +1574,15 @@ internal sealed class RuntimeSetPositionState : IDisposable command = default; // Round 3 audit: safe - fresh lookup + Token check earlier in this // SAME guard clause precede IsCurrent, nothing reentrant in between. + // + // B2 dependency (C4 route 4b-2): this guard clause SHADOWS + // SubmitPreparedPlacementCore's entry validation one call later, and + // RuntimeRemotePlacementDriveController's far-snap default arm + // documents a pre-engine `Rejected` as unreachable BECAUSE of that + // shadowing - every term dropped from here re-surfaces there as a + // non-storing Rejected instead of a storing RejectedAuthority. The + // one term it deliberately does NOT shadow is the null-body test; + // that caller's own entry check covers it, and says so. if (!token.IsValid || !_operations.TryGetValue(token.Entity, out Operation? operation) || operation.Token != token @@ -2977,7 +3000,12 @@ internal sealed class RuntimeSetPositionState : IDisposable collisionGenerationOverride: quiescence!.Token.CollisionGeneration, collisionPrefixOverride: - quiescence.Token.LandblockPrefix); + quiescence.Token.LandblockPrefix, + // A quiescence park is a momentarily-unavailable destination, + // not a world going away, so it is eligible for the rollback. + // ParkDeferred is what decides whether the cell it would + // restore into is itself quiescing. + restorableOnCancel: true); } PhysicsSetPositionResult result; @@ -3028,7 +3056,11 @@ internal sealed class RuntimeSetPositionState : IDisposable collisionGenerationOverride: queriedQuiescence!.Token.CollisionGeneration, collisionPrefixOverride: - queriedQuiescence.Token.LandblockPrefix); + queriedQuiescence.Token.LandblockPrefix, + // Same reason as the pre-sweep park above, plus the swept + // footprint: ResultTouchesPrefix scans every QueriedCellIds + // entry, and that set spans NEIGHBOUR landblocks. + restorableOnCancel: true); } operation.Result = result; if (!result.IsSuccessful) @@ -3048,9 +3080,9 @@ internal sealed class RuntimeSetPositionState : IDisposable _preparedMovers[operation.Key] = canonicalRequest; if (result.IsDeferred) - // The one restorable park: the destination is not placeable right - // now. Every other ParkDeferred caller is a quiescence/retirement - // withdrawal - see ParkDeferred's restorableOnCancel doc. + // The plain restorable park: the destination is not placeable + // right now. The only NON-restorable caller is the retirement + // park - see ParkDeferred's restorableOnCancel doc. return ParkDeferred(operation, result, restorableOnCancel: true); if (!CommitCanonical(operation, result)) @@ -3418,6 +3450,13 @@ internal sealed class RuntimeSetPositionState : IDisposable /// cancelled park cannot leave the entity invisible and intangible with /// nothing able to wake it. /// + /// Residency specifically is re-tested against the live quiescence + /// map HERE as well as at park time, because this runs on a later packet + /// for a retained park - see the inline comment for the window. The + /// InWorld/transient/clock half is restored unconditionally: it is + /// per-entity simulation state, not a claim on any landblock's collision + /// generation, so it cannot block a retirement. + /// /// The committed pose is deliberately left alone. /// snaps the body to the destination before /// withdrawing, which is precisely retail's lost-cell behaviour: @@ -3476,13 +3515,39 @@ internal sealed class RuntimeSetPositionState : IDisposable } if (withdrawal.ClockActive) _entities.ResumeObjectClock(record); - if (residentCellId != 0u && record.FullCellId == 0u) + // Round 4 (D6): the park-time decision in ParkDeferred is a SNAPSHOT, + // and for a RETAINED park it can be stale by the time this runs. The + // far snap's park is cancelled synchronously (SubmitAndResolve's + // DeferredCell arm), so its window is nanoseconds - but route 2's + // park is retained (AwaitingCommitWake) and this restore lands on the + // NEXT packet's merge-time Forget, ~150 ms later at ACE's 5-10 Hz. + // Streaming opens a quiescence per landblock mutation, so a prefix + // that was clean when the park was taken can be quiescing now. + // Re-testing here is what makes AP-136's claim - "the rollback never + // re-admits a spatial root into a retiring prefix" - exact at the + // moment residency is actually written, instead of true only as of + // when the park was taken. The `!= 0u` test ahead of it is also the + // C3c-F3 sentinel guard ParkDeferred applies: AdjustToOutside zeroes + // the cell id on map-edge failure, and prefix 0x00000000 is the + // legitimate corner landblock. + bool residencyRestored = false; + if (residentCellId != 0u + && !IsCollisionPrefixQuiescing(residentCellId) + && record.FullCellId == 0u) { _entities.SetFullCell( record, residentCellId, (residentCellId & 0xFFFF0000u) | 0xFFFFu); _physics.AcknowledgeSpatialProjection(record, spatial: true); + residencyRestored = true; + } + // Issue #309's connected-gate confirmation signal; see the [park] + // line's own comment in ParkDeferred. + if (PhysicsDiagnostics.ProbeParkEnabled) + { + Console.WriteLine(FormattableString.Invariant( + $"[park-restore] guid=0x{record.ServerGuid:X8} restoreCell=0x{residentCellId:X8} inWorld={withdrawal.InWorld} residency={residencyRestored}")); } } @@ -3687,7 +3752,13 @@ internal sealed class RuntimeSetPositionState : IDisposable collisionGenerationOverride: quiescence?.Token.CollisionGeneration, collisionPrefixOverride: - quiescence?.Token.LandblockPrefix); + quiescence?.Token.LandblockPrefix, + // Stated, not inherited from the parameter default: a + // RETIREMENT park withdraws the entity because its own world + // is going away, which is the one shape AP-136's reason + // covers exactly. Round 3 (m5) - the blanket doc that used to + // guard a forgetful caller here is gone. + restorableOnCancel: false); if (_pendingProjection.TryGetValue( parked.Projection.Sequence, out RuntimePlacementProjectionSnapshot staged)) @@ -4252,20 +4323,97 @@ internal sealed class RuntimeSetPositionState : IDisposable /// 0x00000000: a corner-landblock quiescence override read as "absent", /// so derived false and /// the parked operation skipped the QuiescenceHeld stage entirely. - /// - /// - /// opts this park into - /// 's rollback. It is true for exactly one caller: - /// the plain "destination is not placeable right now" park, which is the - /// one that strands an entity when a later packet cancels it. /// - /// It is deliberately FALSE for every quiescence/retirement park. - /// Those withdraw the entity precisely because its own landblock prefix - /// is being retired or held, so restoring canonical residency there would - /// re-admit a spatial root into the prefix that is trying to quiesce and - /// block the retirement outright - a streaming stall traded for a - /// stranded entity. Those entities are withdrawn because their world is - /// going away, not because a destination was momentarily unavailable. + /// + /// is the caller's HALF of the + /// opt-in into 's rollback: "this park is a + /// momentarily-unavailable destination, not a world going away". It is + /// true for the plain "destination is not placeable right now" park and + /// for 's two collision-prefix + /// QUIESCENCE parks; it is false for a retirement park + /// (), which withdraws the entity + /// precisely because its own world is being retired. + /// + /// + /// + /// The OTHER half is decided here, after + /// has committed the parked pose, and it is the only test AP-136's stated + /// reason actually needs: the rollback restores canonical residency at + /// body.CellPosition.ObjCellId + /// (), so it is safe exactly when THAT + /// cell's prefix is not itself quiescing + /// ( — the pre-flight's own + /// predicate). Deciding it here rather than at the call sites is what + /// makes the claim true rather than approximately true, for two + /// independent reasons (C4 route 4b-2, round-3 correction A1): + /// + /// + /// A blocking-quiescence token is the MINIMUM- + /// OperationId match, not the only live one — + /// _collisionPrefixQuiescence is a per-prefix dictionary and a + /// streaming recentre opens one per landblock mutation. Comparing the + /// restore cell against that single token proves nothing about the + /// others, so it could answer "safe" for a destination that is itself + /// retiring. (Round 4 bounded the reach rather than asserting it. On a + /// FIRST submit it is unreachable, and provably so: the pre-engine check + /// can match at most the request's own prefix and its + /// CurrentCellId, which are the same prefix there, so its token IS + /// the restore cell's; and the post-sweep check runs only once the + /// pre-engine check has proven the destination prefix is NOT quiescing, + /// so its token can never be. What is NOT ruled out is a RETAINED + /// operation whose CurrentCellId has since been moved to another + /// landblock by a non-Position rebucket — see the arm's own scoping + /// below — with concurrent quiescences on both prefixes. That shape is + /// unmeasured, and it is exactly the shape the far arm's pre-flight + /// refuses on the only route that has one. Testing the live map here + /// makes the decision free of the question rather than dependent on + /// it.) + /// The restore cell is not the caller's + /// result.CellId. + /// runs for + /// any outdoor cell, whose own contract says the re-derived id may belong + /// to a NEIGHBOUR landblock. Only the post-snap value is the value the + /// rollback will use. This is the reachable half, and it is pinned by + /// QuiescingOwnPrefix_SeamCrossingParkIsRestoredAtTheReDerivedNeighbourCell: + /// a wire (cell, position) pair whose position lies past its own named + /// block's seam is exactly the pair #107's re-derivation exists to + /// distrust, and the entity is stranded without this. + /// + /// + /// + /// AP-136's blanket "no quiescence park is restorable" was over-broad + /// because also fires on prefixes a + /// placement merely TOUCHES — chiefly any QueriedCellIds entry + /// (a NEIGHBOUR the sweep reached across a seam; + /// CellTransit.AddOutsideCell has no same-block filter), and the + /// request's CurrentCellId. + /// + /// + /// + /// What CurrentCellId names, scoped to the submit (round 4, + /// D5). On the FIRST submit of an accepted Position it names the + /// DESTINATION, not a departed source: both accepted-Position callers + /// commit the accepted wire cell to record.FullCellId ahead of the + /// drive (route 2 through the merge, the graphical remote path through + /// LiveEntityRuntime.RebucketLiveEntity), and this request field is + /// read from that same record field. It is NOT invariant for a RETAINED + /// operation: both drives re-submit from their own cadence pump with no + /// fresh merge in between (SubmitAndResolve re-reads the record as + /// it then stands), and RemoteTeleportController's rollback is a + /// shipped writer that rebuckets FullCellId back to the + /// PRE-teleport landblock. RuntimeRemotePlacementDriveController's + /// CanAttemptDestination doc states this correctly; treat the arm + /// as live, not as dead code. + /// + /// + /// + /// In those touched-prefix + /// shapes the retiring prefix is not the one residency is restored into, + /// so the rollback pins nothing, while leaving the park non-restorable + /// stranded the entity (InWorld = false, clock suspended, + /// FullCellId = 0) with the only operation able to wake it + /// destroyed by its own next accepted Position. + /// /// private RuntimeSetPositionOutcome ParkDeferred( Operation operation, @@ -4276,21 +4424,55 @@ internal sealed class RuntimeSetPositionState : IDisposable bool restorableOnCancel = false) { PhysicsBody body = operation.Body!; - // Captured BEFORE the withdrawal below clears it. Only the - // WITHDRAWAL is captured, never the pose: the SnapToCell on the next - // line deliberately commits the destination pose and that commit - // stands, exactly as retail's store_position @0x00515CE2 commits it - // on the lost-cell branch and never un-commits it. - operation.ParkWithdrawal = new ParkWithdrawal( - Captured: restorableOnCancel, - InWorld: body.InWorld, - TransientState: body.TransientState, - ClockActive: operation.Record.ObjectClock.IsActive); + // Read BEFORE the withdrawal below clears them - and before the + // SnapToCell, which sets InWorld = true itself. Only the WITHDRAWAL + // is captured, never the pose: the SnapToCell deliberately commits + // the destination pose and that commit stands, exactly as retail's + // store_position @0x00515CE2 commits it on the lost-cell branch and + // never un-commits it. + bool priorInWorld = body.InWorld; + TransientStateFlags priorTransientState = body.TransientState; + bool priorClockActive = operation.Record.ObjectClock.IsActive; body.Orientation = result.Orientation; body.SnapToCell( result.CellId, result.Position, result.CellLocalPosition); + // The restorable decision reads the POST-snap cell, because that is + // the cell RestoreParkWithdrawal will restore residency into - see + // this method's own doc for why the caller's result.CellId and a + // single blocking-quiescence token are both the wrong inputs. + // + // C3c-F3 sentinel guard (round 4, N1): cell id 0 is + // AdjustToOutside's map-edge FAILURE sentinel + // (LandDefs.AdjustToOutside's `cellId = 0u; return false` tail), and + // prefix 0x00000000 is simultaneously the LEGITIMATE corner landblock + // (0,0) - which is exactly why BeginCollisionPrefixQuiescence rejects + // landblock id 0 rather than treating it as "absent". So the + // quiescence map must never be consulted with the sentinel: a corner + // landblock quiescing would otherwise make every map-edge failure + // read as "restoring into a retiring prefix". A sentinel cell has no + // residency to re-admit anywhere - RestoreParkWithdrawal applies the + // same `!= 0u` test before it restores residency at all - so the + // decision falls back to the caller's half alone. + uint parkedCellId = body.CellPosition.ObjCellId; + operation.ParkWithdrawal = new ParkWithdrawal( + Captured: restorableOnCancel + && (parkedCellId == 0u + || !IsCollisionPrefixQuiescing(parkedCellId)), + InWorld: priorInWorld, + TransientState: priorTransientState, + ClockActive: priorClockActive); + // Issue #309's connected-gate confirmation signal. Off by default; + // one line per park, nothing at all for a committing placement. + if (PhysicsDiagnostics.ProbeParkEnabled) + { + string parkCause = collisionPrefixOverride is uint blockedPrefix + ? FormattableString.Invariant($"quiescence:0x{blockedPrefix:X8}") + : "unplaceable"; + Console.WriteLine(FormattableString.Invariant( + $"[park] guid=0x{operation.Record.ServerGuid:X8} cause={parkCause} resultCell=0x{result.CellId:X8} restoreCell=0x{parkedCellId:X8} eligible={restorableOnCancel} captured={operation.ParkWithdrawal.Captured}")); + } body.InWorld = false; body.TransientState &= ~TransientStateFlags.Active; if (operation.Record.RemoteMotion is IRuntimeRemotePlacement remote) @@ -5231,6 +5413,17 @@ internal sealed class RuntimeSetPositionState : IDisposable orientation.Y, orientation.Z); + /// + /// B2 dependency (C4 route 4b-2): all SEVEN version dimensions below are + /// load-bearing for RuntimeRemotePlacementDriveController's + /// documented invariant that a PRE-engine Rejected can never reach + /// its far-snap default arm — it relies on + /// catching every authority divergence one call earlier, as + /// RejectedAuthority (which STORES), rather than + /// catching it as + /// Rejected (which does not). Dropping a dimension here silently + /// moves that shape to the non-storing arm. + /// private static bool IsPreparationAuthorityCurrent( Operation operation, in MoverPreparationAuthority authority) => diff --git a/src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs b/src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs index 1af11389..b6ef2c68 100644 --- a/src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs +++ b/src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs @@ -64,10 +64,20 @@ internal enum RuntimeAcceptedPositionExecutionStatus : byte Contention, /// - /// The destination landblock's collision generation was not ready; the - /// operation parked. The ack fires later, once + /// The operation parked instead of committing. The ack fires later, once /// observes /// the deferred commit. + /// + /// Round 4 (2026-08-04, N3): the previous text named only one cause — + /// "the destination landblock's collision generation was not ready" — + /// which is SubmitPreparedPlacementCore's plain + /// result.IsDeferred park. Two other causes reach this same + /// status and are asserted by this route's own tests: the PRE-engine + /// collision-prefix quiescence park (the destination prefix is + /// mid-retirement) and the POST-sweep one (a landblock the sweep merely + /// TOUCHED is mid-retirement, which can rewrite an otherwise + /// about-to-commit placement). All three are "parked, not committed"; + /// none of them means the placement failed. /// DeferredCell, diff --git a/src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs b/src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs index 6c16f5f5..b91c7369 100644 --- a/src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs +++ b/src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs @@ -1,3 +1,4 @@ +using System.Numerics; using AcDream.Content; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; @@ -19,16 +20,41 @@ namespace AcDream.Runtime.Session; /// can /// ask, BEFORE attempting any canonical SetPosition, whether the accepted /// destination is one this host can actually place a remote into right now. +/// +/// +/// Headless is not a live consumer, stated plainly (C4 route 4b-2, review +/// fix). The headless implementation above exists and is exact, but +/// NOTHING in AcDream.Headless constructs a +/// : the graphical +/// composition root (SessionPlayerComposition) is the only +/// construction site, and +/// RuntimeLiveEntitySessionController.OnPositionUpdated returns early +/// for every non-local GUID before any remote routing could occur. Route +/// 4b-2's contract item 6 ("both hosts drive the identical Runtime entry +/// point, or the divergence is stated plainly rather than satisfied +/// vacuously") is therefore satisfied by THIS statement, not by a shared +/// caller. The far snap is a graphical-host-only path until a headless +/// remote-motion consumer exists. +/// /// public interface IRuntimeRemotePlacementServiceWindow { /// /// True when 's collision is currently - /// published by this host, so a canonical SetPosition into it can be - /// attempted without risking an un-wakeable DeferredCell park (see + /// published by this host, so a canonical SetPosition into it is worth + /// attempting rather than certain to park (see /// docs/research/2026-08-04-c4-route-4b-1-contract.md's "central /// decision"). may be a full cell id — the /// implementation canonicalizes to the containing landblock. + /// + /// + /// C4 route 4b-2 delta review: this answer is an OPTIMISATION only. It + /// cannot be a correctness gate — see + /// 's + /// CanAttemptDestination for the two Core predicates that are + /// strictly broader than any pre-flight, one of which reads a sweep + /// footprint that does not exist until the sweep has run. + /// /// bool IsWithinServiceWindow(uint landblockId); } @@ -36,36 +62,208 @@ public interface IRuntimeRemotePlacementServiceWindow /// /// Typed yields for /// . +/// +/// +/// The partition this enum exists to express (C4 route 4b-2, delta review +/// MAJOR A). Retail's CPhysicsObj::SetPositionInternal @0x00515BD0 +/// has exactly two shapes, and the earlier three-value enum could not tell +/// them apart: +/// +/// +/// Stores. AdjustPosition yielded no +/// transition @0x00515C1D, so the resolve never ran: +/// prepare_to_leave_visibility @0x00515CDA, +/// store_position(this, arg2) @0x00515CE2, +/// CObjectMaint::GotoLostCell @0x00515CF2, transient-state clear +/// @0x00515CF7, return 0 @0x00515D07. The object MOVES to the accepted +/// destination. +/// Does not store. The resolve DID run and refused: +/// CheckPositionInternal == 0 @0x00515C85 → +/// handle_all_collisions @0x00515CC2 → +/// return ((eax_14 - eax_14) & 2) + 2 @0x00515CD5 (2 or 4), and +/// sphere_path.curr_cell == 0 @0x00515C8F → return 3 +/// @0x00515CB2. Both leave the object where it was. +/// +/// +/// +/// is the single predicate that reads this partition; every value below +/// states which side it falls on and WHY, so a new value cannot be added +/// without deciding. +/// /// internal enum RuntimeRemotePlacementExecutionStatus : byte { - /// Out of this route's scope: not a disposition this - /// controller owns (), - /// no canonical body, or no incarnation key. + /// + /// Out of this route's scope: not a disposition this controller owns + /// (), + /// no canonical body, or no incarnation key. Stores — nothing ran. + /// + /// + /// The three sub-cases are not symmetric, and round 3 (correction m4) + /// states each. Not owned stores the accepted destination like any + /// other never-ran outcome. No canonical body stores nothing: + /// there is nowhere to write, which + /// + /// tests directly rather than inferring from this value. No + /// incarnation key, with a body DOES write, and that is correct on + /// retail's partition — nothing ran, so the pose must advance — but it is + /// unreachable in production regardless: the far arm requires an + /// already-classified route, and + /// RuntimeAcceptedPositionRouteRequests.TryBuild refuses to build + /// one for a record whose Key is null, so no caller can hold a + /// route for a keyless record. + /// + /// NotApplicable, /// /// The central decision (see the class doc on - /// ): the accepted - /// destination is not one this host can currently collision-publish, or - /// the canonical SetPosition attempted anyway and Core still deferred it. - /// No operation is retained either way — the entity keeps its last - /// committed pose and waits for the next accepted Position, which for a - /// remote is a 5-10 Hz stream away. + /// ): the pre-flight + /// declined to attempt this destination at all, so no operation was ever + /// begun and the canonical resolve never ran. + /// + /// + /// Stores. This is exactly retail's no-transition branch — "no cell + /// for it here", never "do not move". See + /// . + /// /// Refused, - /// The entity already holds an active operation — a concurrent - /// placement authority (portal/teleport/another host route) or this - /// controller's own still-outstanding preparation retry. + /// + /// + /// refused, or this controller retained a preparation retry. + /// + /// + /// Round 3 (correction m2) — the producers this route can actually reach, + /// not the two the earlier text named. The Begin refuses when the entity + /// already holds an active operation (a concurrent placement authority — + /// portal, teleport, another host route), when it holds a RETAINED + /// COMPLETION that has not been acknowledged yet + /// (HasRetainedCompletion — ordinary right after a + /// far snap whose projection sink declined), or + /// when the record is no longer the current incarnation + /// (!IsCurrent). Its remaining refusals cannot fire from here: + /// the expected position-authority version is read off the same record + /// one statement earlier, and the accepted destination was already + /// required non-null by the pre-flight. This controller adds the fourth + /// producer: a retryable preparation status + /// (RetrySetupUnavailable/RetryWorldFrameUnavailable). + /// + /// + /// + /// Stores in every one of those shapes: all four are decided + /// before _physics.Engine.SetPosition is called for THIS packet, + /// and retail never waits for an asset before committing the pose. + /// ( + /// re-validates currency itself, so the !IsCurrent producer cannot + /// write through a superseded record.) + /// + /// Contention, - /// The canonical SetPosition committed synchronously. + /// + /// Preparation refused TERMINALLY, before anything was submitted: + /// RuntimeSetPositionMoverPreparationStatus.RejectedAuthority (the + /// operation/authority pair went stale between Begin and prepare) or + /// InvalidData (the accepted frame fails + /// PositionFrameValidation, or the prepared mover could not be + /// built). + /// + /// + /// Stores. The engine was never called — this is the same + /// "placement never executed" state as , reached one + /// stage later. Kept distinct from + /// because that one is the opposite side of the partition. + /// + /// + RejectedPreparation, + + /// The canonical SetPosition committed synchronously. + /// Does not store: the committed pose IS the settled pose, and it + /// is already on the canonical body. Committed, - /// Rejected/cancelled by Core (invalid prepared data, authority - /// displaced mid-submit). - Rejected, + /// + /// Core parked the placement (RuntimeSetPositionStatus.DeferredCell) + /// and this controller cancelled-and-restored it immediately rather than + /// retaining a watch — no park survives this controller. + /// + /// + /// Does not store. ParkDeferred has ALREADY performed + /// retail's store_position: it snaps the body to the parked result + /// before withdrawing (and RestoreParkWithdrawal deliberately + /// leaves that pose alone). For the pre-sweep quiescence park that result + /// is the accepted destination; for the post-sweep one it is the + /// COLLISION-SETTLED spherePath.CurPos, and writing the raw + /// destination over it would undo the settle (delta review N2). The two + /// DeferredCell returns that park nothing + /// (InheritedLostDeadline, WakeableLostCell) are + /// unreachable here: TryBeginExclusiveAuthoredPlacement refuses + /// whenever an operation already exists for the key, so neither flag can + /// be inherited, and this controller's own retained entries are + /// preparation retries that have not parked. + /// + /// + Deferred, + + /// + /// The canonical placement EXECUTED and did not commit: + /// RuntimeSetPositionStatus.Rejected (the engine's own sweep + /// refused the destination — PhysicsEngine.SetPosition returned a + /// non-Ok error, acdream's port of @0x00515CD5/@0x00515CB2) or + /// Cancelled (authority was displaced after the engine ran, which + /// includes the case where CommitCanonical had already SETTLED the + /// body). + /// + /// + /// Does not store. Retail's matching returns (2/3/4) leave the + /// object where it was, and the Cancelled-after-commit shape has a + /// freshly settled pose (contact plane, step-down) that a raw destination + /// write would destroy. + /// + /// + /// + /// Round 3 (correction B2): "EXECUTED" is a reachability property of the + /// producers this route can reach, not a structural guarantee Core + /// enforces. RuntimeSetPositionStatus.Rejected has two other, + /// PRE-engine producers in SubmitPreparedPlacementCore, both + /// unreachable through this controller; the default: arm of + /// + /// enumerates exactly what each depends on, and why the failure direction + /// if one is ever broken is the safe one. + /// + /// + RejectedByPlacement, +} + +internal static class RuntimeRemotePlacementExecutionStatusExtensions +{ + /// + /// True when retail's store_position @0x00515CE2 is what this + /// outcome corresponds to: the canonical placement never reached the + /// engine, so nothing has committed a pose to the body and the remote + /// would otherwise be frozen at a superseded one. See the enum's own doc + /// for the retail partition and for why each value falls where it does. + /// + /// + /// Written as an exhaustive switch rather than a set test so adding a + /// value is a compile-time decision, not a silent default. + /// + /// + internal static bool StoresAcceptedDestination( + this RuntimeRemotePlacementExecutionStatus status) => + status switch + { + RuntimeRemotePlacementExecutionStatus.NotApplicable => true, + RuntimeRemotePlacementExecutionStatus.Refused => true, + RuntimeRemotePlacementExecutionStatus.Contention => true, + RuntimeRemotePlacementExecutionStatus.RejectedPreparation => true, + RuntimeRemotePlacementExecutionStatus.Committed => false, + RuntimeRemotePlacementExecutionStatus.Deferred => false, + RuntimeRemotePlacementExecutionStatus.RejectedByPlacement => false, + _ => throw new ArgumentOutOfRangeException(nameof(status)), + }; } /// @@ -85,7 +283,21 @@ internal enum RuntimeRemotePlacementExecutionStatus : byte /// SendPositionEventHandleReceivedPosition @0x00453FD0 calls /// it only on the local-player FORCE_POSITION branch. There is nothing here /// resembling route 2's PositionEventOwed/SendPositionEvent -/// pair. +/// pair. The separate question 4b-1's review left open is now settled +/// (C4 route 4b-2 review fix): the SetPositionStruct flag +/// SEND_POSITION_EVENT_SPF = 0x1000 — which +/// CPhysicsObj::SetPositionSimple @0x005162B0 sets as part of +/// 0x1012 @0x005162C4 and which therefore reaches +/// TryPrepareAndSubmitAuthoredPlacement here — is never read. +/// The commit path CPhysicsObj::SetPositionInternal +/// @0x00515330-@0x00515593 takes only this and the CTransition +/// and never sees the struct; the flag-reading overload @0x00515BD0 tests +/// only bit 5 (DONOTCREATECELLS_SPF = 0x20) @0x00515BF8/@0x00515C6C; +/// and no site anywhere in the decomp tests bit 12 of a +/// SetPositionStruct. It is a carried, inert flag, entirely distinct +/// from CommandInterpreter::SendPositionEvent @0x006B4770 (dispatched +/// from @0x00454091, the local-player branch 4b-1 already +/// cleared). /// No re-issue funnel. Route 2 re-issues a dead /// operation because a ForcePosition is a one-shot correction ACE never /// repeats. A remote Position is a REPEATED stream — re-issuing packet N @@ -104,42 +316,66 @@ internal enum RuntimeRemotePlacementExecutionStatus : byte /// /// /// -/// The central decision — refuse, do not park. +/// The central decision — avoid the PARK where it is cheap to, never the +/// tracking. /// 's DeferredCell park /// withdraws the entity from the world (ParkDeferred sets /// body.InWorld = false, suspends the object clock, and publishes a /// Withdraw) — and RuntimeEntityObjectLifetime.TryApplyPosition /// calls RuntimeSetPositionState.Forget on EVERY subsequent accepted -/// Position for that same entity, regardless of disposition. Forget's -/// CancelCoreDeferred removes the operation and rewrites the -/// Withdraw into a Discard WITHOUT restoring InWorld, -/// resuming the clock, or re-entering residency. Because ACE broadcasts a -/// remote's Position every 100-150 ms — almost always faster than the -/// collision-generation wake this park would need to resolve on its own — -/// any DeferredCell park opened here would be cancelled by the entity's own -/// next packet long before it could wake, leaving the entity invisible AND -/// intangible for the rest of the session. -/// exists to prevent this controller from ever attempting a SetPosition -/// whose destination cannot be placed right now: +/// Position for that same entity, regardless of disposition. Because ACE +/// broadcasts a remote's Position every 100-150 ms — almost always faster +/// than the collision-generation wake this park would need to resolve on its +/// own — any DeferredCell park opened here would be cancelled by the +/// entity's own next packet long before it could wake. +/// Correcting this paragraph's earlier claim (4b-1's review ordered it +/// and it shipped uncorrected): that cancellation no longer strands the +/// entity "invisible AND intangible for the rest of the session". +/// passes restoreCancelledPark: true, and +/// RuntimeSetPositionState.RestoreParkWithdrawal rolls +/// InWorld, the object clock, and canonical residency back — the +/// AP-136 behaviour 634bc551 landed. The park is still worth avoiding +/// (a restored park re-shows the entity where retail keeps it hidden until +/// cell load, which is precisely what AP-136 records), so +/// stays — but it is an OPTIMISATION, not a +/// correctness mechanism, and it is never the reason a remote stops +/// tracking the server (see +/// 's store_position +/// fallback). /// checks it BEFORE calling /// , /// and if Core still returns DeferredCell despite the guard passing, /// the operation is cancelled immediately rather than retained, so the /// ledger still converges even though the guard's invariant did not hold for -/// that one packet. Why the guard can still miss (the co-extensivity -/// finding and its residual): a tier/residency-backed service window -/// (the graphical host's GpuWorldState.IsNearTier, headless's +/// that one packet. +/// +/// +/// +/// Why a pre-flight structurally cannot be the correctness mechanism (C4 +/// route 4b-2, delta review MAJOR B/C). A tier/residency-backed service +/// window (the graphical host's GpuWorldState.IsNearTier, headless's /// collision-published check) is co-extensive with collision PUBLICATION in /// both directions — verified by reading both tier-writer call sites /// (promotion cannot read Near before collision commits) and the retirement /// call site (the tier flips to Far as the FIRST, synchronous step of /// retirement, strictly before collision-side withdrawal). It is NOT -/// co-extensive with a live in-place collision-prefix MUTATION that leaves -/// the tier/residency reading unchanged while the prefix quiesces — -/// 's private TryGetBlockingQuiescence -/// is Core's own check for exactly that case, and it is what a placement can -/// still hit even after this guard passes. That one narrow window is the -/// residual this guard cannot close from outside Core. +/// co-extensive with a live in-place collision-prefix MUTATION, which is why +/// also reads Core's own +/// . But even +/// that pair only tests ONE prefix — the DESTINATION's — while Core's park +/// predicates are broader: ResultTouchesPrefix scans every +/// QueriedCellIds entry, a sweep footprint that provably spans +/// NEIGHBOUR landblocks (CellTransit.AddOutsideCell re-derives the +/// block id from the global lcoord and has no same-block filter). The sweep +/// footprint does not exist until the sweep has run, so no pre-flight can +/// close that half. The correctness mechanism is therefore at the source: +/// SubmitPreparedPlacementCore's two quiescence parks are RESTORABLE, +/// and ParkDeferred decides whether the rollback is safe by testing +/// the cell it will actually restore into — after the snap, against EVERY +/// live quiescence — so the rollback re-admits nothing into ANY quiescing +/// prefix, which is the only thing AP-136's blanket non-restorable scoping +/// was protecting. This guard merely shrinks the reachable set. Recorded as +/// AP-138(2). /// /// /// @@ -198,6 +434,13 @@ internal sealed class RuntimeRemotePlacementDriveController /// is mid-iteration can never corrupt its scratch buffer. /// private readonly List _awaitingAcknowledgementScratch = []; + /// + /// R9 review fix: 's own removal buffer, + /// kept separate from (owned by + /// ) for the same reason + /// is. + /// + private readonly List _pendingScratch = []; private bool _driving; private object? _routeOwner; @@ -215,7 +458,7 @@ internal sealed class RuntimeRemotePlacementDriveController _serviceWindow = serviceWindow ?? throw new ArgumentNullException(nameof(serviceWindow)); _entityObjects.RegisterRemotePlacementDriveOwnership( - () => _pending.Count); + CountLivePending); // B4 review fix: a second, independent registration — multiple // registrations sum (RegisterRemotePlacementDriveOwnership's own doc // comment) — so the awaiting-acknowledgement dimension is visible in @@ -230,6 +473,9 @@ internal sealed class RuntimeRemotePlacementDriveController /// that dimension is reported to the lifetime's ownership ledger via the /// constructor's second RegisterRemotePlacementDriveOwnership call /// (B4 review fix) and has no separate test-visible counter of its own. + /// Raw, unhealed: this is the map's literal size. The ownership ledger + /// reads instead, which prunes entries Core + /// has already retired (R9 review fix). /// internal int PendingCount => _pending.Count; @@ -398,10 +644,14 @@ internal sealed class RuntimeRemotePlacementDriveController CreateObject.ServerPosition? destination = record.Snapshot.Physics?.Position ?? record.Snapshot.Position; if (destination is not { } accepted - || !_serviceWindow.IsWithinServiceWindow(accepted.LandblockId)) + || !CanAttemptDestination(setPosition, accepted.LandblockId)) { - // The central decision: refuse rather than open a park this - // host's own service window could never wake. + // The central decision: skip a placement this host already knows + // will only park and be cancelled again. An OPTIMISATION — see + // CanAttemptDestination for the two Core predicates it cannot + // reproduce and where correctness actually lives — and the body + // still advances, because Refused is on the storing side of + // retail's partition. return RuntimeRemotePlacementExecutionStatus.Refused; } @@ -416,6 +666,218 @@ internal sealed class RuntimeRemotePlacementDriveController return SubmitAndResolve(record, token, route); } + /// + /// C4 route 4b-2: retail's far branch, end to end, for one remote whose + /// accepted Position already classified to + /// . This is the + /// route's FIRST production caller of + /// . + /// + /// + /// 005163c1 position_manager = this_1->position_manager; + /// 005163c9 if (position_manager != 0) + /// 005163cb PositionManager::StopInterpolating(position_manager); + /// 005163d9 CPhysicsObj::SetPositionSimple(this_1, arg2, 1); + /// 005163e8 return 1; + /// + /// + /// + /// The ORDER is load-bearing and is why this lives here rather than being + /// assembled by a caller: StopInterpolating runs strictly BEFORE + /// the placement (@0x005163CB before @0x005163D9). acdream's + /// InterpolationManager queue is the analogue — a stale near + /// waypoint surviving a far snap would immediately drag the freshly + /// placed body back toward it on the next per-tick catch-up. The + /// classifier's own StopInterpolating flag + /// (RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition's + /// remote tail, StopInterpolating: !nearby) is what gates it, so + /// the retail condition is read from the route rather than restated. + /// + /// + /// + /// The destination is NOT a parameter: it is read from the canonical + /// merged snapshot by and + /// resolved through Runtime's own accepted world frame + /// (resolveWorldOffsetFromRuntimeFrame: true). A caller's + /// separately-derived world position is not a second input — #283 proved + /// by measurement that App's streaming origin and Runtime's world frame + /// cannot disagree, and LiveWorldOriginState.EnsureAgreesWithRuntimeFrame + /// is terminal if they ever do. + /// + /// + /// + /// ConstrainTo is deliberately NOT armed here. + /// SmartBox::HandleReceivedPosition owns retail's single arming + /// site (@0x00454272) for all three nonzero-returning branches; acdream's + /// analogue is + /// , + /// called once by the caller after the routing block. Arming inside this + /// method would be the second site the route 4b scoping forbids. + /// + /// + /// + /// The store_position fallback (C4 route 4b-2 review fix — the + /// one root defect). Every outcome for which the canonical placement + /// never reached the engine + /// () + /// still advances the body to the accepted destination pose, through + /// . Without it a refused, + /// contended, or preparation-rejected far snap left the body at its stale + /// pose with the interpolation queue ALREADY cleared by the + /// StopInterpolating above — a frozen remote that the next 5-10 Hz + /// packet simply reproduces, because nothing about the refusal reason + /// changes at packet cadence. + /// + /// + /// This is retail, not a symptom patch. + /// CPhysicsObj::SetPositionInternal @0x00515BD0 takes the + /// no-transition branch @0x00515C1D whenever AdjustPosition + /// resolves no cell, and that branch commits the destination pose — + /// prepare_to_leave_visibility @0x00515CDA, + /// store_position(this, arg2) @0x00515CE2, + /// CObjectMaint::GotoLostCell @0x00515CF2 — and returns 0 + /// (success) @0x00515D07. "This host cannot resolve a cell for the + /// destination right now" is exactly that state; retail moves the object + /// and hides it pending cell load, and never leaves it standing at a + /// superseded pose. The deleted legacy far block also always tracked. + /// + /// + /// The other half of retail's partition is honoured too (delta review + /// MAJOR A — correcting this comment's earlier claim that no non-commit + /// outcome could represent it). Retail's failure returns that do NOT + /// store — curr_cell == 0 @0x00515C8F/@0x00515CB2 and the blocked + /// CheckPositionInternal @0x00515C85/@0x00515CD5 — are reached + /// AFTER the resolve ran, and acdream CAN represent that: + /// + /// is returned when PhysicsEngine.SetPosition itself refused, or + /// when authority was displaced after it ran (including after + /// CommitCanonical had already settled the body). Those do not + /// store, so a destination the engine's own sweep refuses no longer + /// teleports the canonical body into it, and a just-settled pose is never + /// overwritten. The residual is recorded as AP-138. + /// + /// + internal RuntimeRemotePlacementExecutionStatus ApplyAcceptedRemoteFarSnap( + RuntimeEntityRecord record, + RemoteMotion remote, + in RuntimeAuthoritativePositionRoute route) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(remote); + if (!RuntimeRemoteFarSnapPosition.OwnsFarSnap(route)) + { + throw new ArgumentException( + "Only a remote far-snap classification (SetPositionSimple, " + + "RemoteAuthoritative, Teleport-flagged) may be applied " + + "through the far-snap arm; the caller must select the arm " + + "with RuntimeRemoteFarSnapPosition.ResolveArm.", + nameof(route)); + } + + if (route.StopInterpolating) + remote.Interp.Clear(); + RuntimeRemotePlacementExecutionStatus status = + TryExecuteAcceptedRemotePosition(record, route); + if (status.StoresAcceptedDestination()) + StoreAcceptedDestinationPose(record); + return status; + } + + /// + /// Retail CPhysicsObj::store_position @0x00515CE2, reached from + /// SetPositionInternal's no-resolvable-cell branch @0x00515C1D. + /// Commits the SAME accepted destination + /// reads — the canonical + /// merged snapshot, resolved through Runtime's own accepted world frame, + /// which is the exact pair + /// RuntimeSetPositionMoverPreparer.TryBuild composes for a + /// committed placement (cellLocal + ShadowWorldOffset in X/Y, the + /// authored Z, the authored quaternion). A caller's separately-derived + /// wire pose is deliberately not an input here either, for the same + /// reason the committed path does not take one. + /// + /// + /// Currency is re-validated first (delta review MAJOR D). + /// publishes its cancellation receipt + /// SYNCHRONOUSLY, and the production placement-projection sink can delete + /// or replace this incarnation from inside that publish — so by the time + /// control returns to the record + /// may no longer be the canonical one for its GUID. Writing a pose + /// through a superseded incarnation is the exact class the R5 fix + /// introduced the rule for, and + /// RuntimeSetPositionState.RestoreParkWithdrawal already guards the + /// same way. The App-side re-validation happens only after this seam + /// returns, which is too late. + /// + /// + /// + /// The write target is the CANONICAL body (delta review N5). + /// Retail's store_position(this, arg2) writes the CPhysicsObj's own + /// position, and acdream's canonical CPhysicsObj is + /// . + /// RemoteMotion.Body IS that instance in production, by two + /// independent routes: RuntimePhysicsState.GetOrCreateRemoteMotion + /// passes record.PhysicsBody into the constructor, and when the + /// record has no body yet SetRemoteMotion ADOPTS the component's + /// private one as canonical (InitializeNewPhysicsBody + + /// SetPhysicsBody). So the divergent pair is not constructible + /// through the production seam and this change moves no production + /// behaviour — it removes the ability to write a pose into a body the + /// canonical record does not own, which is what + /// 's + /// no-body producer would otherwise permit for a caller-supplied + /// component. Reading the record directly makes "there is no canonical + /// body" and "there is nothing to store into" the same test. + /// + /// + /// + /// Returns false only when there is nothing to write, or nowhere to write + /// it: no canonical body, no accepted destination on the snapshot, a + /// superseded incarnation, or no Runtime world frame yet. The last case + /// reuses Core's own #284 policy — + /// makes a + /// frame that can never arrive terminal rather than silent — so the only + /// surviving false there is the genuine pre-local-player-Create window, + /// which the far arm cannot reach anyway: the classifier needs a live + /// player_distance to emit SetPositionSimple at all, and + /// that requires the local movement controller to exist. + /// + /// + private bool StoreAcceptedDestinationPose(RuntimeEntityRecord record) + { + if (!_entityObjects.Entities.IsCurrent(record) + || record.PhysicsBody is not { } body) + { + return false; + } + + CreateObject.ServerPosition? destination = + record.Snapshot.Physics?.Position ?? record.Snapshot.Position; + if (destination is not { } accepted) + return false; + + if (!_entityObjects.Physics.TryGetWorldFrameOffset( + accepted.LandblockId, + out float worldOffsetX, + out float worldOffsetY)) + { + _entityObjects.Physics.ThrowIfWorldFrameUnreachable( + accepted.LandblockId); + return false; + } + + body.Position = new Vector3( + accepted.PositionX + worldOffsetX, + accepted.PositionY + worldOffsetY, + accepted.PositionZ); + body.Orientation = new Quaternion( + accepted.RotationX, + accepted.RotationY, + accepted.RotationZ, + accepted.RotationW); + return true; + } + /// /// Host cadence pump: retries a preparation-only retry status /// (RetrySetupUnavailable/RetryWorldFrameUnavailable) by @@ -457,24 +919,35 @@ internal sealed class RuntimeRemotePlacementDriveController // B3 review fix: a retry can sit retained across many host // cadence pumps (bounded only by how long the asset stayed // unavailable) while its destination's collision publication - // retires out from under it. Re-check the SAME service-window - // guard the entry point uses BEFORE resubmitting — dropping - // (and cancelling the already-begun token) rather than - // resubmitting matches the entry point's own Refused - // semantics: no operation survives, the entity keeps its last - // committed pose, and the next packet supplies current truth - // on its own. Without this re-check a destination that fell - // out of the window would just keep coming back Contention - // forever (the asset source has not changed), never + // retires out from under it. Re-check the SAME pre-flight the + // entry point uses BEFORE resubmitting — dropping (and + // cancelling the already-begun token) rather than resubmitting + // matches the entry point's own Refused semantics: no + // operation survives and the next packet supplies current + // truth on its own. Without this re-check a destination that + // fell out of the window would just keep coming back + // Contention forever (the asset source has not changed), never // converging even though the window already knows better. + // + // Delta review N1: this path is the entry point's Refused + // semantics, so it must ALSO be the entry point's + // store_position — correcting this comment's earlier claim + // that "the entity keeps its last committed pose". A retained + // retry whose destination leaves the window is exactly the + // freeze the fallback exists to prevent, one cadence pump + // later; the pre-flight is an optimisation here for the same + // reason it is one at the entry point, and it may never be the + // reason a remote stops tracking. CreateObject.ServerPosition? destination = pending.Record.Snapshot.Physics?.Position ?? pending.Record.Snapshot.Position; if (destination is not { } accepted - || !_serviceWindow.IsWithinServiceWindow( + || !CanAttemptDestination( + setPosition, accepted.LandblockId)) { CancelToken(setPosition, pending.Token); + StoreAcceptedDestinationPose(pending.Record); continue; } @@ -517,8 +990,12 @@ internal sealed class RuntimeRemotePlacementDriveController return RuntimeRemotePlacementExecutionStatus.Contention; } + // Preparation refused TERMINALLY (RejectedAuthority/InvalidData). + // Nothing was submitted, so the engine never ran — this is the + // STORING side of retail's partition, one stage later than the + // pre-flight Refused. Delta review MAJOR A. CancelToken(setPosition, token); - return RuntimeRemotePlacementExecutionStatus.Rejected; + return RuntimeRemotePlacementExecutionStatus.RejectedPreparation; } switch (outcome.Status) @@ -541,25 +1018,141 @@ internal sealed class RuntimeRemotePlacementDriveController return RuntimeRemotePlacementExecutionStatus.Committed; case RuntimeSetPositionStatus.DeferredCell: - // Central decision: this branch means the service-window - // guard passed but Core still deferred the destination — the - // narrow residual the class doc's central-decision paragraph - // explains (a live in-place collision-prefix quiescence the - // tier/residency guard cannot see from outside Core, per - // RuntimeSetPositionState's private TryGetBlockingQuiescence). - // Cancel immediately rather than retain a watch: no - // re-issue, no park survives this controller. + // Central decision: the pre-flight passed but Core still + // parked the destination — the residual the class doc's + // central-decision paragraph explains, now that the pre-flight + // is explicitly only an optimisation (it tests the DESTINATION + // prefix; Core's own post-sweep predicate also matches the + // whole swept footprint, which spans neighbouring landblocks + // and does not exist until the sweep has run). Cancel + // immediately rather than + // retain a watch: no re-issue, no park survives this + // controller, and CancelToken's restoreCancelledPark rolls the + // withdrawal back at the destination cell. + // + // ParkDeferred has already performed retail's store_position — + // it snaps the body to the parked result before withdrawing, + // and RestoreParkWithdrawal deliberately leaves that pose + // alone — so the fallback must NOT fire here. For the + // post-sweep park that result is the collision-SETTLED + // position and a raw destination write would undo the settle + // (delta review N2). CancelToken(setPosition, token); - return RuntimeRemotePlacementExecutionStatus.Refused; + return RuntimeRemotePlacementExecutionStatus.Deferred; default: - // Rejected/Cancelled — authority moved out from under this - // operation, so the body never moved. + // Rejected/Cancelled. Correcting this comment's earlier claim + // that "the body never moved" (delta review MAJOR A — it was + // the claim that made the fallback fire here): the statuses + // that reach this arm are returned AFTER + // _physics.Engine.SetPosition ran. Rejected is the engine's + // own sweep refusing the destination (retail @0x00515CD5 / + // @0x00515CB2, neither of which stores), and Cancelled + // includes the shape where CommitCanonical already SETTLED + // the body and only the projection ownership was displaced. + // Neither may be overwritten with the raw accepted + // destination. + // + // Round 3 (correction B2) — that is a REACHABILITY claim, not + // a structural one, so state what it rests on. Core's + // SubmitPreparedPlacementCore has three Rejected producers + // and only the post-engine one (`!result.IsSuccessful` after + // the SetPosition call) can reach here. Its two PRE-engine + // producers are unreachable through this controller because + // each is shadowed by a check PrepareMover already made, with + // nothing reentrant in between: + // * the entry validation's identity/authority terms (token + // ownership, stage, preparation authority) re-test what + // PrepareMover's own guard clause tested one call + // earlier, and IsPreparationAuthorityCurrent catches + // all seven of its version dimensions there first — as + // RejectedAuthority, i.e. RejectedPreparation, which + // DOES store. The one stage divergence PrepareMover + // admits and SubmitPreparedPlacementCore refuses is + // AwaitingCell, which additionally requires + // DormantLocalActivation — forbidden for a record that + // has a body, and this route requires one. Its remaining + // terms (finite GameTime, matching Kind/Portal, the exact + // PreparedCommand, the velocity-version echo) are + // satisfied by construction: TryPrepareAndSubmitAuthored- + // Placement submits the very command PrepareMover just + // stored, built from the same operation, with nothing in + // between. + // * the NULL-BODY term is the one entry-validation test + // PrepareMover does NOT shadow (round 4, D3 — its guard + // clause has no body test at all). This route's own + // entry check does: TryExecuteAcceptedRemotePosition's + // `record.PhysicsBody is null → NotApplicable` above. + // The only two writers that CLEAR it are entity teardown + // (RuntimeEntityObjectLifetime's canonical leave-world + // tail) and local-player activation retirement, and each + // retires the operation as part of the same transaction — + // so a body lost in between surfaces as Cancelled, not + // as a pre-engine Rejected. + // * !IsStructurallyValid(canonicalRequest) re-tests the + // command PrepareMover just validated. The `with` between + // them only replaces fields IsStructurallyValid reads + // when WakeableLostCell is set, which requires an + // inherited park — and TryBeginExclusiveAuthoredPlacement + // refuses whenever an operation already exists for the + // key, so nothing can be inherited here. + // If a future change breaks one of those, the failure lands + // on the SAFE side: a pre-engine Rejected filed here does not + // store, so the body holds a stale pose for ONE packet (no + // operation is retained — CancelToken runs below — so the + // next 5-10 Hz packet starts clean). The opposite mistake, + // storing after the engine refused, teleports the canonical + // body into a destination the sweep just declined. That + // asymmetry is why this stays a documented invariant rather + // than a stage flag threaded out of Core: the flag would + // change the shared placement contract three shipped routes + // depend on, to make the already-safe direction slightly + // safer. CancelToken(setPosition, token); - return RuntimeRemotePlacementExecutionStatus.Rejected; + return RuntimeRemotePlacementExecutionStatus.RejectedByPlacement; } } + /// + /// R9 review fix — the same self-healing read + /// already performed, for + /// . Both maps are keyed by + /// (guid + incarnation), so a reused GUID + /// produces a NEW key and cannot displace a dead incarnation's entry by + /// itself. drops such entries, but it early-returns + /// whenever nothing is pending and is a host cadence call the ledger has + /// no right to assume ran; without this the count converged only at + /// , i.e. never in-session — contract item 7's + /// GUID-reuse/incarnation dimension. + /// + /// + /// Removal is gated on !IsPlacementCurrent, so this can only ever + /// drop entries Core has ALREADY retired. It never cancels anything and + /// therefore cannot hide the live-operation leak + /// 's own review fix exists to prevent. The + /// disposal guard matches its sibling's, for the same + /// post-Dispose() CaptureOwnership() contract. + /// + /// + private int CountLivePending() + { + if (_pending.Count == 0) + return 0; + if (_entityObjects.Physics.IsDisposed) + return _pending.Count; + + RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition; + _pendingScratch.Clear(); + foreach ((RuntimeEntityKey key, Pending entry) in _pending) + { + if (!setPosition.IsPlacementCurrent(entry.Token)) + _pendingScratch.Add(key); + } + foreach (RuntimeEntityKey key in _pendingScratch) + _pending.Remove(key); + return _pending.Count; + } + /// /// Self-healing read: prunes every /// entry whose token Core no longer considers current (the ack already @@ -616,6 +1209,96 @@ internal sealed class RuntimeRemotePlacementDriveController return _awaitingAcknowledgement.Count; } + /// + /// An OPTIMISATION, never the correctness mechanism: can this host place a + /// remote into RIGHT NOW without paying for + /// a park that will only be cancelled again? + /// + /// + /// Deliberately NOT complete, and it cannot be (C4 route 4b-2, delta + /// review MAJOR B/C — correcting this comment's earlier "the complete + /// pre-flight" framing). This tests ONE prefix, the destination's. + /// Core's own park predicates are broader in two independent ways that no + /// pre-flight can reproduce: + /// + /// + /// PlacementTouchesPrefix also matches the + /// request's CurrentCellId. Round 3 — the earlier text called + /// that "the SOURCE landblock the remote is snapping OUT of" and "the + /// likelier shape"; that is not what production supplies. + /// CurrentCellId is read from record.FullCellId, and both + /// accepted-Position callers commit the accepted WIRE cell to that field + /// before this seam is reached — the graphical remote path through + /// LiveEntityRuntime.RebucketLiveEntity → + /// RuntimeEntityObjectLifetime.CommitRebucket in its shared + /// prologue, route 2 through the merge itself. So on the FIRST submit + /// this arm names the DESTINATION, and it is the destination prefix — the + /// one this pre-flight DOES see — that it matches. Measured, not argued. + /// Scoped deliberately (round 4, D5): that is a property of the first + /// submit only. A RETAINED operation re-submits from a cadence pump with + /// no fresh merge, so see the paragraph below for + /// what the arm can name by then — the arm is live, not dead + /// code. + /// ResultTouchesPrefix scans every + /// QueriedCellIds entry, and that sweep footprint provably spans + /// NEIGHBOUR landblocks (CellTransit.AddOutsideCell re-derives the + /// block id from the global lcoord and states outright that there is no + /// same-block filter). The footprint does not EXIST until the sweep has + /// run. + /// + /// + /// re-reads this predicate and is subject to the + /// same two gaps, plus a third: a non-Position rebucket + /// (RemoteTeleportController, the projection materializer, the + /// equipped-child renderer) can move record.FullCellId to a THIRD + /// landblock between the retained submit and the retry. All three are + /// harmless for the same reason (delta review N3). That reason is the + /// paragraph below — NOT, as the round-2 text claimed, that re-reading + /// record.CurrentCellId here would "re-derive a private Core + /// predicate outside Core": this method already calls Core's own + /// internal , + /// so that objection was simply false (round-3 correction A5). + /// + /// + /// Correctness lives at the source instead. + /// SubmitPreparedPlacementCore's two quiescence parks are opted + /// into ParkDeferred's rollback, and ParkDeferred then + /// restores only when the cell it will actually restore into — + /// body.CellPosition.ObjCellId, read AFTER the snap and tested + /// against EVERY live quiescence — is not itself quiescing. + /// Corrected round 4 (D5): the earlier text said "every shape this + /// predicate cannot see is a merely-swept NEIGHBOUR", which contradicts + /// the third-landblock rebucket shape named ten lines above. The right + /// statement is weaker and does not need the enumeration: whatever the + /// blocking prefix turns out to be, the rollback is decided against the + /// RESTORE cell rather than against that prefix, so it re-admits nothing + /// into any quiescing prefix and AP-136's reason for the blanket + /// non-restorable scoping (a spatial root re-entering the prefix that is + /// trying to quiesce) cannot apply. This predicate therefore only + /// shrinks the reachable set, and the DeferredCell case in + /// handles whatever still reaches Core. + /// Recorded as AP-138(2). + /// + /// + /// Two other properties DEPEND on this pre-flight staying here + /// (round-3 correction A5). Do not delete it as redundant. First, + /// N3's "a retained retry can no longer strand" argument: a retry whose + /// destination has begun quiescing since the submit is refused HERE and + /// takes the window-drop store_position, + /// rather than reaching Core and opening a park into a prefix whose own + /// restore is (correctly) declined. Second, the destination-quiescing + /// shape is the one case where ParkDeferred would refuse to + /// restore, so refusing ahead of it is what keeps that shape off the + /// remote path entirely; without this call it becomes reachable and the + /// remote is left withdrawn until a later packet commits. + /// + /// + private bool CanAttemptDestination( + RuntimeSetPositionState setPosition, + uint landblockId) => + _serviceWindow.IsWithinServiceWindow(landblockId) + && !setPosition.IsCollisionPrefixQuiescing(landblockId); + private static void CancelToken( RuntimeSetPositionState setPosition, in RuntimeEntityPlacementToken token) diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteFarSnapIntegrationTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteFarSnapIntegrationTests.cs new file mode 100644 index 00000000..fe88e195 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteFarSnapIntegrationTests.cs @@ -0,0 +1,582 @@ +using System.Numerics; +using AcDream.App.Physics; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; + +namespace AcDream.App.Tests.Physics; + +/// +/// C4 route 4b-2: behavioural acceptance for the remote far snap. Every test +/// calls the PRODUCTION routing entry point +/// () +/// against a REAL , a real +/// canonical / pair, +/// and REAL +/// output. Nothing +/// is re-derived in a test body — that is the #292 gap route 2 left and route +/// 4a's first attempt repeated. +/// +/// +/// The load-bearing discriminator is the worldPos argument: it is +/// deliberately DIFFERENT from the accepted destination merged onto the +/// canonical snapshot. The deleted legacy far block wrote +/// Body.Position = worldPos; the canonical placement writes the +/// snapshot's destination resolved through Runtime's world frame. Any +/// regression back to the legacy write therefore fails +/// +/// on the exact position value. +/// +/// +public sealed class LiveEntityNetworkRemoteFarSnapIntegrationTests +{ + private static readonly Vector3 Destination = new(12f, 14f, 7f); + + /// A wire pose the caller passes but the far arm must ignore — + /// distinct from in every component. + private static readonly Vector3 DecoyWirePose = new(-70f, -80f, -90f); + + [Fact] + public void FarSnap_PlacesTheBodyFromTheCanonicalDestination_NotTheCallersWirePose() + { + using var fixture = new RemotePlacementDriveFixture(); + fixture.PublishDestinationCollision(); + fixture.AllowDestination(); + (RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) = + fixture.AddRemote(0x70004001u, Destination); + + LiveEntityNetworkUpdateController.RemoteContactRouting routing = + LiveEntityNetworkUpdateController.ApplyRemoteContactRouting( + fixture.Drive, + record, + remote, + Classify(hasContact: true, playerDistance: 200f), + DecoyWirePose, + Quaternion.Identity, + willBeDrTicked: true); + + Assert.Equal( + LiveEntityNetworkUpdateController.RemoteContactArm.FarSnapPlacement, + routing.Arm); + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Committed, + routing.Placement); + Assert.Equal( + Destination + RemotePlacementDriveFixture.DestinationWorldOffset, + body.Position); + Assert.NotEqual(DecoyWirePose, body.Position); + // The canonical placement — not the App — committed residency. + Assert.Equal(RemotePlacementDriveFixture.DestinationCell, record.FullCellId); + + fixture.DrainPlacementFifo(); + Assert.Equal(0, fixture.LiveOperationCount); + Assert.Equal(0, fixture.RemotePlacementLedger); + } + + /// + /// Retail's far branch stops the interpolation queue BEFORE placing + /// (@0x005163CB precedes @0x005163D9). A stale near waypoint surviving the + /// snap would drag the freshly placed body back on the next per-tick + /// catch-up. + /// + [Fact] + public void FarSnap_ClearsTheInterpolationQueue() + { + using var fixture = new RemotePlacementDriveFixture(); + fixture.PublishDestinationCollision(); + fixture.AllowDestination(); + (RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) = + fixture.AddRemote(0x70004002u, Destination); + remote.Interp.Enqueue( + new Vector3(40f, 40f, 7f), + Quaternion.Identity, + isMovingTo: false, + currentBodyPosition: body.Position, + currentBodyOrientation: body.Orientation); + Assert.True(remote.Interp.IsActive); + + _ = LiveEntityNetworkUpdateController.ApplyRemoteContactRouting( + fixture.Drive, + record, + remote, + Classify(hasContact: true, playerDistance: 200f), + DecoyWirePose, + Quaternion.Identity, + willBeDrTicked: true); + + Assert.False(remote.Interp.IsActive); + fixture.DrainPlacementFifo(); + } + + /// + /// The inverted test (C4 route 4b-2 review). This used to assert + /// Assert.Equal(before, body.Position) and so PINNED the defect + /// both reviews found: a refused far snap left the body at its stale pose + /// with the interpolation queue already cleared, i.e. a frozen remote + /// that the next 5-10 Hz packet reproduces identically. + /// + /// + /// Retail never leaves the object behind. When + /// CPhysicsObj::SetPositionInternal @0x00515BD0 resolves no cell, + /// it takes @0x00515C1D and still commits the destination — + /// store_position @0x00515CE2 — before GotoLostCell + /// @0x00515CF2. So a refusal advances the body to the accepted + /// destination, resolved through Runtime's world frame (NOT the caller's + /// wire pose), while retaining no operation and committing no cell. + /// + /// + [Fact] + public void FarSnap_RefusedDestination_StillAdvancesTheBodyToTheAcceptedDestination() + { + using var fixture = new RemotePlacementDriveFixture(); + fixture.PublishDestinationCollision(); + // Deliberately NOT AllowDestination(). + (RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) = + fixture.AddRemote(0x70004003u, Destination); + Vector3 before = body.Position; + + LiveEntityNetworkUpdateController.RemoteContactRouting routing = + LiveEntityNetworkUpdateController.ApplyRemoteContactRouting( + fixture.Drive, + record, + remote, + Classify(hasContact: true, playerDistance: 200f), + DecoyWirePose, + Quaternion.Identity, + willBeDrTicked: true); + + Assert.Equal( + LiveEntityNetworkUpdateController.RemoteContactArm.FarSnapPlacement, + routing.Arm); + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Refused, + routing.Placement); + // store_position: the body TRACKED, and from the canonical + // destination — not the caller's wire pose, and not its stale pose. + Assert.Equal( + Destination + RemotePlacementDriveFixture.DestinationWorldOffset, + body.Position); + Assert.NotEqual(before, body.Position); + Assert.NotEqual(DecoyWirePose, body.Position); + // …and it is a store_position, NOT a placement: no cell was + // committed, nothing was retained. + Assert.Equal(RemotePlacementDriveFixture.SourceCell, record.FullCellId); + Assert.True(body.InWorld); + Assert.True(record.ObjectClock.IsActive); + Assert.Equal(0, fixture.LiveOperationCount); + Assert.Equal(0, fixture.RemotePlacementLedger); + } + + /// + /// The same refusal repeated: a remote genuinely beyond the host's + /// service window keeps TRACKING every packet rather than freezing until + /// the window opens. This is the reachable production shape the review + /// named — a remote that is rendered but still streaming, refused while + /// genuinely beyond 96 m — and it is why the fallback cannot be a + /// one-shot. + /// + [Fact] + public void FarSnap_RepeatedRefusals_KeepTrackingEveryPacket() + { + using var fixture = new RemotePlacementDriveFixture(); + fixture.PublishDestinationCollision(); + (RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) = + fixture.AddRemote(0x7000400Cu, Destination); + + foreach (Vector3 step in new[] + { + new Vector3(20f, 14f, 7f), + new Vector3(28f, 14f, 7f), + new Vector3(36f, 14f, 7f), + }) + { + record.Snapshot = record.Snapshot with + { + Position = new CreateObject.ServerPosition( + RemotePlacementDriveFixture.DestinationCell, + step.X, + step.Y, + step.Z, + 1f, + 0f, + 0f, + 0f), + }; + + LiveEntityNetworkUpdateController.RemoteContactRouting routing = + LiveEntityNetworkUpdateController.ApplyRemoteContactRouting( + fixture.Drive, + record, + remote, + Classify(hasContact: true, playerDistance: 200f), + DecoyWirePose, + Quaternion.Identity, + willBeDrTicked: true); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Refused, + routing.Placement); + Assert.Equal( + step + RemotePlacementDriveFixture.DestinationWorldOffset, + body.Position); + } + + Assert.Equal(0, fixture.LiveOperationCount); + Assert.Equal(0, fixture.RemotePlacementLedger); + } + + // ── The null / Rejected* / cell-less policy ──────────────────────────── + + /// + /// The login-window trap. ClassifyRemoteAcceptedPosition returns + /// null for EVERY remote packet until the local movement controller + /// exists, because there is no player_distance to derive. Deleting + /// the legacy block without a replacement would freeze every remote for + /// that whole window. The stated policy routes it through AP-87's + /// catch-up, whose 4 m guard PLACES a body that has drifted. + /// + [Fact] + public void NoClassificationAtAll_StillTracksTheServer() + { + using var fixture = new RemotePlacementDriveFixture(); + (RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) = + fixture.AddRemote(0x70004004u, Destination); + var target = new Vector3(60f, 10f, 7f); // 50 m from the body. + + LiveEntityNetworkUpdateController.RemoteContactRouting routing = + LiveEntityNetworkUpdateController.ApplyRemoteContactRouting( + fixture.Drive, + record, + remote, + route: null, + target, + Quaternion.Identity, + willBeDrTicked: true); + + Assert.Equal( + LiveEntityNetworkUpdateController.RemoteContactArm.UnroutedCatchUp, + routing.Arm); + Assert.Equal(target, body.Position); + // No canonical placement was attempted for an unclassified packet. + Assert.Equal(0, fixture.LiveOperationCount); + } + + /// + /// …and the same policy ENQUEUES when the body is already tracking, which + /// is what proves it is AP-87's catch-up rather than an unconditional + /// snap bolted on to make the previous test pass. + /// + /// + /// R7 review fix: the earlier version asserted only that the body did not + /// move, which "snap when bodyToTarget > 4 m, else do nothing" + /// satisfies just as well as an enqueue — it and its sibling both passed + /// against that regression. The queue assertion below is what actually + /// distinguishes "enqueued" from "did nothing". + /// + /// + [Fact] + public void NoClassificationAtAll_NearAndTicked_EnqueuesInsteadOfSnapping() + { + using var fixture = new RemotePlacementDriveFixture(); + (RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) = + fixture.AddRemote(0x70004005u, Destination); + Vector3 before = body.Position; + var target = before + new Vector3(0.5f, 0f, 0f); + + LiveEntityNetworkUpdateController.RemoteContactRouting routing = + LiveEntityNetworkUpdateController.ApplyRemoteContactRouting( + fixture.Drive, + record, + remote, + route: null, + target, + Quaternion.Identity, + willBeDrTicked: true); + + Assert.Equal( + LiveEntityNetworkUpdateController.RemoteContactArm.UnroutedCatchUp, + routing.Arm); + Assert.Equal(before, body.Position); + Assert.True(remote.Interp.IsActive); + } + + [Fact] + public void RejectedData_TakesTheUnroutedCatchUp_NotTheFarSnap() + { + using var fixture = new RemotePlacementDriveFixture(); + fixture.PublishDestinationCollision(); + fixture.AllowDestination(); + (RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) = + fixture.AddRemote(0x70004006u, Destination); + var target = new Vector3(60f, 10f, 7f); + + RuntimeAuthoritativePositionRoute rejected = + Classify(hasContact: true, playerDistance: float.NaN); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.RejectedData, + rejected.Disposition); + + LiveEntityNetworkUpdateController.RemoteContactRouting routing = + LiveEntityNetworkUpdateController.ApplyRemoteContactRouting( + fixture.Drive, + record, + remote, + rejected, + target, + Quaternion.Identity, + willBeDrTicked: true); + + Assert.Equal( + LiveEntityNetworkUpdateController.RemoteContactArm.UnroutedCatchUp, + routing.Arm); + Assert.Equal(target, body.Position); + Assert.Equal(0, fixture.LiveOperationCount); + } + + /// + /// Retail's cell-less body takes this_1->cell == 0 @0x00516386 + /// (SetPosition), never the far branch — and route 4b-3, not this + /// slice, owns it. Claiming it here would be exactly route 4a's + /// "'not Interpolate' is not 'far'" finding one level up. + /// + [Fact] + public void CellLessRemote_TakesTheUnroutedCatchUp_AndNeverThePlacementOwner() + { + using var fixture = new RemotePlacementDriveFixture(); + fixture.PublishDestinationCollision(); + fixture.AllowDestination(); + (RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) = + fixture.AddRemote(0x70004007u, Destination); + var target = new Vector3(60f, 10f, 7f); + + RuntimeAuthoritativePositionRoute cellLess = Classify( + hasContact: true, + playerDistance: 200f, + committedCellId: 0u); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.SetPosition, + cellLess.Disposition); + + LiveEntityNetworkUpdateController.RemoteContactRouting routing = + LiveEntityNetworkUpdateController.ApplyRemoteContactRouting( + fixture.Drive, + record, + remote, + cellLess, + target, + Quaternion.Identity, + willBeDrTicked: true); + + Assert.Equal( + LiveEntityNetworkUpdateController.RemoteContactArm.UnroutedCatchUp, + routing.Arm); + Assert.Equal(target, body.Position); + Assert.Equal(0, fixture.LiveOperationCount); + Assert.Equal(0, fixture.RemotePlacementLedger); + } + + /// + /// The airborne precedence carve-out still outranks the far snap, not + /// only the near branch: a remote mid-arc whose packet happens to + /// classify far must keep the pre-existing authoritative hard-snap rather + /// than open a canonical placement. + /// + [Fact] + public void AirborneBody_OutranksTheFarSnap() + { + using var fixture = new RemotePlacementDriveFixture(); + fixture.PublishDestinationCollision(); + fixture.AllowDestination(); + (RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) = + fixture.AddRemote(0x70004008u, Destination); + remote.Airborne = true; + var target = new Vector3(60f, 10f, 7f); + + LiveEntityNetworkUpdateController.RemoteContactRouting routing = + LiveEntityNetworkUpdateController.ApplyRemoteContactRouting( + fixture.Drive, + record, + remote, + Classify(hasContact: true, playerDistance: 200f), + target, + Quaternion.Identity, + willBeDrTicked: true); + + Assert.Equal( + LiveEntityNetworkUpdateController.RemoteContactArm.AirborneSnap, + routing.Arm); + Assert.Equal(target, body.Position); + Assert.Equal(0, fixture.LiveOperationCount); + } + + /// + /// Per-entity independence across the two arms in the same tick: one + /// remote's committed placement must not touch another's body or leak + /// into its ledger. + /// + [Fact] + public void TwoRemotesInTheSameTick_FarAndNear_DoNotContaminateEachOther() + { + using var fixture = new RemotePlacementDriveFixture(); + fixture.PublishDestinationCollision(); + fixture.AllowDestination(); + (RuntimeEntityRecord farRecord, RemoteMotion farRemote, + PhysicsBody farBody) = fixture.AddRemote(0x70004009u, Destination); + (RuntimeEntityRecord nearRecord, RemoteMotion nearRemote, + PhysicsBody nearBody) = fixture.AddRemote(0x7000400Au, Destination); + Vector3 nearBefore = nearBody.Position; + + Assert.Equal( + LiveEntityNetworkUpdateController.RemoteContactArm.FarSnapPlacement, + LiveEntityNetworkUpdateController.ApplyRemoteContactRouting( + fixture.Drive, + farRecord, + farRemote, + Classify(hasContact: true, playerDistance: 200f), + DecoyWirePose, + Quaternion.Identity, + willBeDrTicked: true).Arm); + Assert.Equal( + LiveEntityNetworkUpdateController.RemoteContactArm + .SteadyStateInterpolate, + LiveEntityNetworkUpdateController.ApplyRemoteContactRouting( + fixture.Drive, + nearRecord, + nearRemote, + Classify(hasContact: true, playerDistance: 10f), + nearBefore + new Vector3(0.5f, 0f, 0f), + Quaternion.Identity, + willBeDrTicked: true).Arm); + + Assert.Equal( + Destination + RemotePlacementDriveFixture.DestinationWorldOffset, + farBody.Position); + Assert.Equal(nearBefore, nearBody.Position); + + fixture.DrainPlacementFifo(); + Assert.Equal(0, fixture.LiveOperationCount); + Assert.Equal(0, fixture.RemotePlacementLedger); + } + + /// + /// R10 review fix: AirborneNoOperation used to fall into + /// default:, whose comment ASSERTED unreachability that nothing + /// enforced — so a caller that skipped its own airborne-no-op early + /// return would have silently enqueued a waypoint on a branch where + /// retail writes nothing at all (@0x0051636D). The explicit case is what + /// makes the assertion true. + /// + [Fact] + public void AirborneNoOperationClassification_IsRejectedByTheRoutingSeam() + { + using var fixture = new RemotePlacementDriveFixture(); + (RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) = + fixture.AddRemote(0x7000400Du, Destination); + Vector3 before = body.Position; + + RuntimeAuthoritativePositionRoute airborne = + Classify(hasContact: false, playerDistance: 200f); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.NoPositionOperation, + airborne.Disposition); + + Assert.Throws(() => + LiveEntityNetworkUpdateController.ApplyRemoteContactRouting( + fixture.Drive, + record, + remote, + airborne, + new Vector3(60f, 10f, 7f), + Quaternion.Identity, + willBeDrTicked: true)); + + Assert.Equal(before, body.Position); + Assert.False(remote.Interp.IsActive); + Assert.Equal(0, fixture.LiveOperationCount); + } + + // ── The NPC arm's post-routing wire-cell adoption ────────────────────── + + /// + /// After a far snap the canonical placement owns the cell. The NPC arm's + /// wire-cell write sits AFTER its routing (the player arm's sits before), + /// and RemoteMotion.CellId writes through to the canonical + /// FullCellId — so an unguarded write would discard the resolved + /// cell the placement just committed. + /// + [Fact] + public void WireCellAdoption_IsSuppressedAfterAFarSnapAndRunsForEveryOtherArm() + { + using var fixture = new RemotePlacementDriveFixture(); + (RuntimeEntityRecord record, RemoteMotion remote, _) = + fixture.AddRemote(0x7000400Bu, Destination); + uint resolved = remote.CellId; + Assert.Equal(RemotePlacementDriveFixture.SourceCell, resolved); + + Assert.False( + LiveEntityNetworkUpdateController.TryAdoptWireCellAfterRouting( + remote, + LiveEntityNetworkUpdateController.RemoteContactArm + .FarSnapPlacement, + RemotePlacementDriveFixture.DestinationCell)); + Assert.Equal(resolved, remote.CellId); + Assert.Equal(resolved, record.FullCellId); + + foreach (LiveEntityNetworkUpdateController.RemoteContactArm arm in + new[] + { + LiveEntityNetworkUpdateController.RemoteContactArm.AirborneSnap, + LiveEntityNetworkUpdateController.RemoteContactArm + .SteadyStateInterpolate, + LiveEntityNetworkUpdateController.RemoteContactArm + .UnroutedCatchUp, + }) + { + remote.CellId = RemotePlacementDriveFixture.SourceCell; + Assert.True( + LiveEntityNetworkUpdateController.TryAdoptWireCellAfterRouting( + remote, + arm, + RemotePlacementDriveFixture.DestinationCell)); + Assert.Equal( + RemotePlacementDriveFixture.DestinationCell, remote.CellId); + } + } + + private static RuntimeAuthoritativePositionRoute Classify( + bool hasContact, + float playerDistance, + uint committedCellId = RemotePlacementDriveFixture.SourceCell) => + RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition( + new RuntimeAcceptedPositionRouteRequest( + new RuntimeAuthoritativePositionAuthority( + new RuntimeGenerationToken(7), + new RuntimeEntityKey(0x70000001u, 3), + PositionAuthorityVersion: 11UL, + AcceptedPositionSequence: 20, + PreviousTeleportSequence: 10, + AcceptedTeleportSequence: 10, + PositionTimestampDisposition.Apply), + RuntimePositionEntityKind.Remote, + RuntimeAcceptedPositionSource.PositionEvent, + new CreateObject.ServerPosition( + RemotePlacementDriveFixture.DestinationCell, + Destination.X, + Destination.Y, + Destination.Z, + 1f, + 0f, + 0f, + 0f), + PlacementFrame: 0u, + PositionPackVelocity: Vector3.Zero, + committedCellId, + hasContact, + playerDistance, + UsePositionFromServer: false, + HasAnimations: false, + default)); +} diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteSteadyStateIntegrationTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteSteadyStateIntegrationTests.cs index 14b25183..d254079f 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteSteadyStateIntegrationTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteSteadyStateIntegrationTests.cs @@ -157,11 +157,17 @@ public sealed class LiveEntityNetworkRemoteSteadyStateIntegrationTests RuntimeAuthoritativePositionDisposition.Interpolate, route.Disposition); - RemoteMotion remote = MakeLandingRemote(); + using var fixture = new RemotePlacementDriveFixture(); + (RuntimeEntityRecord record, RemoteMotion remote, _) = + fixture.AddRemote(0x70005001u, new Vector3(12f, 14f, 7f)); + remote.Body.Position = new Vector3(10f, 10f, 5f); + remote.Airborne = true; var landing = new Vector3(10.5f, 10f, 5f); // 0.5 m — well within 4 m. - LiveEntityNetworkUpdateController.RemoteContactArm arm = + LiveEntityNetworkUpdateController.RemoteContactRouting routing = LiveEntityNetworkUpdateController.ApplyRemoteContactRouting( + fixture.Drive, + record, remote, route, landing, @@ -170,7 +176,7 @@ public sealed class LiveEntityNetworkRemoteSteadyStateIntegrationTests Assert.Equal( LiveEntityNetworkUpdateController.RemoteContactArm.AirborneSnap, - arm); + routing.Arm); Assert.Equal(landing, remote.Body.Position); } @@ -181,12 +187,17 @@ public sealed class LiveEntityNetworkRemoteSteadyStateIntegrationTests // route 4a's own branch: queued for the per-tick catch-up, body // untouched. This is what proves the airborne test above is a // PRECEDENCE carve-out and not a blanket disable of route 4a. - RemoteMotion remote = MakeLandingRemote(); + using var fixture = new RemotePlacementDriveFixture(); + (RuntimeEntityRecord record, RemoteMotion remote, _) = + fixture.AddRemote(0x70005002u, new Vector3(12f, 14f, 7f)); + remote.Body.Position = new Vector3(10f, 10f, 5f); remote.Airborne = false; var target = new Vector3(10.5f, 10f, 5f); - LiveEntityNetworkUpdateController.RemoteContactArm arm = + LiveEntityNetworkUpdateController.RemoteContactRouting routing = LiveEntityNetworkUpdateController.ApplyRemoteContactRouting( + fixture.Drive, + record, remote, Classify(hasContact: true, playerDistance: 10f), target, @@ -196,43 +207,10 @@ public sealed class LiveEntityNetworkRemoteSteadyStateIntegrationTests Assert.Equal( LiveEntityNetworkUpdateController.RemoteContactArm .SteadyStateInterpolate, - arm); + routing.Arm); Assert.Equal(new Vector3(10f, 10f, 5f), remote.Body.Position); } - [Fact] - public void GroundedBodyOnAClassificationRoute4aDoesNotOwn_FallsThroughToLegacy() - { - RemoteMotion remote = MakeLandingRemote(); - remote.Airborne = false; - Vector3 before = remote.Body.Position; - - LiveEntityNetworkUpdateController.RemoteContactArm arm = - LiveEntityNetworkUpdateController.ApplyRemoteContactRouting( - remote, - Classify(hasContact: true, playerDistance: 200f), - new Vector3(10.5f, 10f, 5f), - Quaternion.Identity, - willBeDrTicked: true); - - Assert.Equal( - LiveEntityNetworkUpdateController.RemoteContactArm.Legacy, - arm); - // Nothing was written — the caller's own legacy routing owns it. - Assert.Equal(before, remote.Body.Position); - } - - private static RemoteMotion MakeLandingRemote() - { - var remote = new RemoteMotion(); - remote.Body.Position = new Vector3(10f, 10f, 5f); - remote.Body.Orientation = Quaternion.Identity; - // Past the AP-87 firstUp hint, so the near case really is near. - remote.LastServerPosTime = 1_700_000_000d; - remote.Airborne = true; - return remote; - } - private static void AssertRenderPoseSuppressed( RuntimeAuthoritativePositionRoute? route) { diff --git a/tests/AcDream.App.Tests/Physics/RemotePlacementDriveFixture.cs b/tests/AcDream.App.Tests/Physics/RemotePlacementDriveFixture.cs new file mode 100644 index 00000000..8938cace --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/RemotePlacementDriveFixture.cs @@ -0,0 +1,218 @@ +using System.Numerics; +using AcDream.Content; +using AcDream.Content.Pak; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; + +namespace AcDream.App.Tests.Physics; + +/// +/// C4 route 4b-2: a REAL +/// over a bare +/// , so App-layer acceptance tests +/// drive the production far-snap path end to end instead of simulating it. +/// Mirrors RuntimeRemotePlacementDriveControllerTests' own fixture. +/// +internal sealed class RemotePlacementDriveFixture : IDisposable +{ + internal const uint SourceLandblock = 0xB1000000u; + internal const uint SourceCell = SourceLandblock | 0x0001u; + internal const uint DestinationLandblock = 0xB2000000u; + internal const uint DestinationCell = DestinationLandblock | 0x0001u; + /// The +X world offset + /// gives the destination landblock, so a committed placement's world + /// position is the authored local position plus this. + internal static readonly Vector3 DestinationWorldOffset = new(192f, 0f, 0f); + internal const float SpawnHeight = 7f; + + private readonly ServiceWindow _window = new(); + + internal RemotePlacementDriveFixture() + { + Lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + Drive = new RuntimeRemotePlacementDriveController( + Lifetime, + new GameRuntimeClock(), + new UnusedCollisionSource(), + _window); + } + + internal RuntimeEntityObjectLifetime Lifetime { get; } + + internal RuntimeRemotePlacementDriveController Drive { get; } + + internal void AllowDestination() => _window.Allow(DestinationLandblock); + + /// + /// Commits the destination landblock's collision generation and observes + /// the source world frame once, so a placement into + /// can actually resolve. + /// + internal void PublishDestinationCollision() + { + var heights = new byte[81]; + Array.Fill(heights, (byte)SpawnHeight); + var heightTable = new float[256]; + for (int index = 0; index < heightTable.Length; index++) + heightTable[index] = index; + Lifetime.Physics.ObserveLocalWorldFrame( + SourceCell, teleportAdvanced: false); + Lifetime.Physics.SetPosition.BeginCollisionGeneration( + DestinationLandblock, 1UL); + Lifetime.Physics.Engine.AddLandblock( + DestinationLandblock, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + worldOffsetX: DestinationWorldOffset.X, + worldOffsetY: DestinationWorldOffset.Y); + Lifetime.Physics.SetPosition.CommitCollisionGeneration( + DestinationLandblock, 1UL, ready: true); + } + + /// + /// Registers one remote incarnation with a canonical body, its shared + /// , and the accepted destination already merged + /// onto its snapshot — exactly the state + /// RuntimeEntityObjectLifetime.TryApplyPosition leaves behind + /// before OnPosition routes the packet. + /// + internal (RuntimeEntityRecord Record, RemoteMotion Remote, PhysicsBody Body) + AddRemote(uint guid, Vector3 destination) + { + RuntimeEntityRecord record = Lifetime.RegisterEntity( + Spawn(guid)).Canonical!; + Lifetime.Entities.SetFinalPhysicsState(record, PhysicsStateFlags.Gravity); + Lifetime.Entities.SetFullCell( + record, SourceCell, (SourceCell & 0xFFFF0000u) | 0xFFFFu); + var body = new PhysicsBody + { + Position = new Vector3(10f, 10f, SpawnHeight), + Orientation = Quaternion.Identity, + LastUpdateTime = 1d, + State = PhysicsStateFlags.Gravity, + TransientState = TransientStateFlags.Active, + }; + body.SnapToCell(SourceCell, body.Position, body.Position); + Lifetime.Entities.SetPhysicsBody(record, body); + record.ObjectClock.Activate(); + Lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true); + record.Snapshot = record.Snapshot with + { + Position = new CreateObject.ServerPosition( + DestinationCell, + destination.X, + destination.Y, + destination.Z, + 1f, + 0f, + 0f, + 0f), + }; + RemoteMotion remote = Lifetime.Physics.GetOrCreateRemoteMotion(record); + // Past AP-87's firstUp hint, so the 4 m body-to-target guard is the + // condition under test rather than the first-sample one. + remote.LastServerPosTime = 1_700_000_000d; + return (record, remote, body); + } + + /// + /// Stands in for the production placement-projection subscription this + /// bare fixture never wires. + /// + internal void DrainPlacementFifo() + { + while (Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot head)) + { + if (!Lifetime.Physics.SetPosition.AcknowledgeProjection(head.Token)) + break; + } + } + + internal int LiveOperationCount => + Lifetime.Physics.CaptureOwnership().SetPositionOperationCount; + + internal int RemotePlacementLedger => + Lifetime.CaptureOwnership().RemotePlacementDrivePendingCount; + + public void Dispose() => Lifetime.Dispose(); + + private static WorldSession.EntitySpawn Spawn(uint guid) => new( + guid, + new CreateObject.ServerPosition( + SourceCell, 10f, 10f, SpawnHeight, 1f, 0f, 0f, 0f), + SetupTableId: null, + AnimPartChanges: Array.Empty(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "remote", + ItemType: null, + MotionState: null, + MotionTableId: 0x09000001u); + + private static PhysicsEngine FlatEngine() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + SourceLandblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + return engine; + } + + private sealed class ServiceWindow : IRuntimeRemotePlacementServiceWindow + { + private readonly HashSet _within = []; + + internal void Allow(uint landblockId) => + _within.Add((landblockId & 0xFFFF0000u) | 0xFFFFu); + + public bool IsWithinServiceWindow(uint landblockId) => + _within.Contains((landblockId & 0xFFFF0000u) | 0xFFFFu); + } + + private sealed class UnusedCollisionSource : IPreparedCollisionSource + { + public PreparedAssetPresence ProbeCollision( + PakAssetType type, + uint sourceFileId) => PreparedAssetPresence.Available; + + public PreparedCollisionReadResult ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + PreparedCollisionReadResult.Missing; + + public PreparedCollisionReadResult ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public PreparedCollisionReadResult + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public PreparedCollisionReadResult ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public PreparedCollisionSourceStats CollisionStats => default; + + public void Dispose() + { + } + } +} diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteFarSnapPositionTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteFarSnapPositionTests.cs new file mode 100644 index 00000000..b7acd2a8 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteFarSnapPositionTests.cs @@ -0,0 +1,312 @@ +using System.Numerics; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; + +namespace AcDream.Runtime.Tests.Physics; + +/// +/// C4 route 4b-2: the arm-selection half of the remote far snap. Every route +/// here is produced by the REAL classifier from a real request, including the +/// negative cases the classifier genuinely emits from other entry points (a +/// local-player SetPositionSimple, a remote top-level Create) and which +/// the far arm must decline. Exactly ONE route is hand-shaped — the +/// flags/disposition mismatch in +/// , +/// which no classifier path produces but a caller could express. +/// +/// +/// Each test was verified to discriminate by temporarily reverting the +/// corresponding guard in and +/// confirming the matching test failed, then restoring it. +/// +/// +public sealed class RuntimeRemoteFarSnapPositionTests +{ + private const uint Cell = 0x0101FFFFu; + + [Fact] + public void OwnsFarSnap_TrueForTheRemoteFarBranch() + { + RuntimeAuthoritativePositionRoute far = + Classify(hasContact: true, playerDistance: 200f); + + // Retail: player_distance >= 96f -> StopInterpolating @0x005163CB + + // SetPositionSimple @0x005163D9, whose arg3 != 0 builds flags 0x1012 + // (Teleport|Slide|SendPositionEvent) @0x005162C4. + Assert.Equal( + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + far.Disposition); + Assert.True(far.StopInterpolating); + Assert.True(RuntimeRemoteFarSnapPosition.OwnsFarSnap(far)); + } + + [Fact] + public void OwnsFarSnap_TrueExactlyAtTheRetailBoundary() + { + // Retail compares player_distance against 96f and takes the + // InterpolateTo branch only when strictly LESS (@0x00516393-@0x0051639E, + // the `x87_r7 < temp1` test). 96.0 itself is the far branch. + Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap( + Classify(hasContact: true, playerDistance: 95.99f))); + Assert.True(RuntimeRemoteFarSnapPosition.OwnsFarSnap( + Classify(hasContact: true, playerDistance: 96f))); + } + + [Fact] + public void OwnsFarSnap_FalseForEveryOtherRemoteClassification() + { + // Near InterpolateTo @0x005163AF. + Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap( + Classify(hasContact: true, playerDistance: 10f))); + // Airborne no-op @0x0051636D. + Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap( + Classify(hasContact: false, playerDistance: 10f))); + // Cell-less: retail's `this_1->cell == 0` branch @0x00516386 — + // SetPosition, NOT the far snap. 4b-3 owns it. + Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap( + Classify(hasContact: true, playerDistance: 200f, committedCellId: 0u))); + // Fresh TELEPORT_TS: the same @0x00516386 branch. 4b-3 owns it. + Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap( + Classify( + hasContact: true, + playerDistance: 200f, + previousTeleport: 10, + acceptedTeleport: 11))); + // Rejections and "no classification at all". + Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap( + Classify( + hasContact: true, + playerDistance: 200f, + disposition: PositionTimestampDisposition.Rejected))); + Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap( + Classify(hasContact: true, playerDistance: float.NaN))); + Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap(null)); + } + + /// + /// The disposition alone is not the discriminator: the classifier emits + /// SetPositionSimple for the LOCAL PLAYER's FORCE_POSITION branch + /// too (RuntimeAuthoritativePositionRouteClassifier.cs:332). Route + /// 4b-2 is a remote route; claiming that one would run a remote arm over + /// route 2's local-player transaction. + /// + [Fact] + public void OwnsFarSnap_FalseForTheLocalPlayerForcePositionRoute() + { + RuntimeAuthoritativePositionRoute force = ClassifyKind( + RuntimePositionEntityKind.LocalPlayer, + hasContact: true, + playerDistance: 200f, + disposition: PositionTimestampDisposition.ForcePosition); + + Assert.Equal( + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + force.Disposition); + Assert.Equal( + RuntimeSetPositionOperationKind.LocalAuthoritative, + force.OperationKind); + Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap(force)); + } + + /// + /// A REMOTE top-level initial Create is also RemoteAuthoritative; + /// it is excluded here by its disposition (SetPosition), which + /// route 4b-3 owns. + /// + [Fact] + public void OwnsFarSnap_FalseForARemoteTopLevelCreateRoute() + { + RuntimeAuthoritativePositionRoute create = + RuntimeAuthoritativePositionRouteClassifier.ClassifyCreate( + new RuntimeCreatePositionRouteRequest( + Authority(PositionTimestampDisposition.Apply, 10, 10), + RuntimePositionEntityKind.Remote, + RuntimeCreateResidenceKind.TopLevel, + new CreateObject.ServerPosition( + Cell, 10f, 20f, 30f, 1f, 0f, 0f, 0f), + default)); + + Assert.Equal( + RuntimeSetPositionOperationKind.RemoteAuthoritative, + create.OperationKind); + Assert.Equal( + PhysicsSetPositionFlags.Placement | PhysicsSetPositionFlags.Slide, + create.SetPositionFlags); + Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap(create)); + } + + /// + /// The invariant the Teleport-flag term actually carries: + /// must be a strict + /// SUBSET of + /// . The + /// far arm hands its route straight to that controller, so any route the + /// arm claims but the controller declines would execute + /// StopInterpolating and then silently do nothing + /// (NotApplicable) — a remote that stops tracking the server with + /// no diagnostic. The flags term is what keeps the two predicates + /// aligned; without it, the hand-shaped route below is claimed here and + /// declined there. + /// + [Fact] + public void OwnsFarSnap_IsAStrictSubsetOfThePlacementOwnersPredicate() + { + // Every route the real classifier produces. + foreach (RuntimeAuthoritativePositionRoute route in new[] + { + Classify(hasContact: true, playerDistance: 200f), + Classify(hasContact: true, playerDistance: 10f), + Classify(hasContact: false, playerDistance: 10f), + Classify(hasContact: true, playerDistance: 200f, committedCellId: 0u), + Classify(hasContact: true, playerDistance: float.NaN), + ClassifyKind( + RuntimePositionEntityKind.LocalPlayer, + hasContact: true, + playerDistance: 200f, + disposition: PositionTimestampDisposition.ForcePosition), + }) + { + if (RuntimeRemoteFarSnapPosition.OwnsFarSnap(route)) + { + Assert.True( + RuntimeRemotePlacementDriveController.OwnsPlacement(route)); + } + } + + // …and the shape a caller could construct that the classifier does + // not: the far disposition and operation kind with a Create's flags. + RuntimeAuthoritativePositionRoute mismatched = + Classify(hasContact: true, playerDistance: 200f) with + { + SetPositionFlags = PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Slide, + }; + Assert.False( + RuntimeRemotePlacementDriveController.OwnsPlacement(mismatched)); + Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap(mismatched)); + } + + // ── Arm selection is total, and the leftovers are named ───────────────── + + [Fact] + public void ResolveArm_MapsEveryRemoteClassificationToExactlyOneArm() + { + Assert.Equal( + RuntimeRemoteAcceptedPositionArm.AirborneNoOperation, + RuntimeRemoteFarSnapPosition.ResolveArm( + Classify(hasContact: false, playerDistance: 10f))); + Assert.Equal( + RuntimeRemoteAcceptedPositionArm.NearInterpolate, + RuntimeRemoteFarSnapPosition.ResolveArm( + Classify(hasContact: true, playerDistance: 10f))); + Assert.Equal( + RuntimeRemoteAcceptedPositionArm.FarSnapPlacement, + RuntimeRemoteFarSnapPosition.ResolveArm( + Classify(hasContact: true, playerDistance: 200f))); + + // The acdream-only leftovers, all four shapes. This is the stated + // policy: they are NOT far. Reading "not Interpolate" as "far" is + // route 4a's own review finding, one level up. + Assert.Equal( + RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp, + RuntimeRemoteFarSnapPosition.ResolveArm( + Classify(hasContact: true, playerDistance: 10f, committedCellId: 0u))); + Assert.Equal( + RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp, + RuntimeRemoteFarSnapPosition.ResolveArm( + Classify( + hasContact: true, + playerDistance: 10f, + disposition: PositionTimestampDisposition.Rejected))); + Assert.Equal( + RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp, + RuntimeRemoteFarSnapPosition.ResolveArm( + Classify(hasContact: true, playerDistance: float.NaN))); + Assert.Equal( + RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp, + RuntimeRemoteFarSnapPosition.ResolveArm(null)); + } + + // ── The single ConstrainTo site's gate ────────────────────────────────── + + [Fact] + public void OwnsAfterOperationConstraint_CoversTheThreeArmsThatRunAnOperation() + { + // Retail arms @0x00454272 for every nonzero MoveOrTeleport return. + // The airborne no-op returns 0 @0x0051636D, so it is excluded — and + // that exclusion is route 4a's, carried forward unchanged. + Assert.True(RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint( + Classify(hasContact: true, playerDistance: 10f))); + Assert.True(RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint( + Classify(hasContact: true, playerDistance: 200f))); + Assert.True(RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint( + Classify(hasContact: false, playerDistance: 10f))); + + // The leftovers still arm through the App's legacy pre-operation + // site; claiming them here would double-arm them. + Assert.False(RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint( + Classify(hasContact: true, playerDistance: 10f, committedCellId: 0u))); + Assert.False(RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint( + Classify(hasContact: true, playerDistance: float.NaN))); + Assert.False( + RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint(null)); + } + + private static RuntimeAuthoritativePositionAuthority Authority( + PositionTimestampDisposition disposition, + ushort previousTeleport, + ushort acceptedTeleport) => + new( + new RuntimeGenerationToken(7), + new RuntimeEntityKey(0x70000001u, 3), + PositionAuthorityVersion: 11UL, + AcceptedPositionSequence: 20, + previousTeleport, + acceptedTeleport, + disposition); + + private static RuntimeAuthoritativePositionRoute Classify( + bool hasContact, + float playerDistance, + uint committedCellId = Cell, + ushort previousTeleport = 10, + ushort acceptedTeleport = 10, + PositionTimestampDisposition disposition = + PositionTimestampDisposition.Apply) => + ClassifyKind( + RuntimePositionEntityKind.Remote, + hasContact, + playerDistance, + committedCellId, + previousTeleport, + acceptedTeleport, + disposition); + + private static RuntimeAuthoritativePositionRoute ClassifyKind( + RuntimePositionEntityKind kind, + bool hasContact, + float playerDistance, + uint committedCellId = Cell, + ushort previousTeleport = 10, + ushort acceptedTeleport = 10, + PositionTimestampDisposition disposition = + PositionTimestampDisposition.Apply) => + RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition( + new RuntimeAcceptedPositionRouteRequest( + Authority(disposition, previousTeleport, acceptedTeleport), + kind, + RuntimeAcceptedPositionSource.PositionEvent, + new CreateObject.ServerPosition( + Cell, 10f, 20f, 30f, 1f, 0f, 0f, 0f), + PlacementFrame: 0u, + PositionPackVelocity: Vector3.Zero, + committedCellId, + hasContact, + playerDistance, + UsePositionFromServer: false, + HasAnimations: false, + default)); +} diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteSteadyStatePositionTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteSteadyStatePositionTests.cs index e0c04a22..f155a080 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteSteadyStatePositionTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteSteadyStatePositionTests.cs @@ -317,17 +317,52 @@ public sealed class RuntimeRemoteSteadyStatePositionTests Assert.Null(host.PositionManager.Constraint); } + /// + /// C4 route 4b-2: the far branch moved onto the post-operation arm with + /// route 4a's two. Retail's `MoveOrTeleport` returns 1 @0x005163E8 there, + /// so `HandleReceivedPosition`'s single `ConstrainTo` @0x00454272 runs — + /// and the App's legacy PRE-operation call site now reads the same + /// predicate and skips it, so it is armed exactly once. + /// [Fact] - public void TryArmConstraintAfterOperation_SkipsClassificationsRoute4aDoesNotOwn() + public void TryArmConstraintAfterOperation_ArmsForTheFarSnapBranch() { - // The far branch still arms its leash — through the untouched legacy + (RemoteMotion remote, EntityPhysicsHost host) = MakeRemoteWithHost( + new Vector3(1f, 2f, 3f)); + + RuntimeAuthoritativePositionRoute far = + Classify(hasContact: true, playerDistance: 200f); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + far.Disposition); + + Assert.True(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( + far, + remote)); + Assert.True(host.PositionManager.Constraint?.IsConstrained); + } + + [Fact] + public void TryArmConstraintAfterOperation_SkipsClassificationsNoArmOwns() + { + // The cell-less half, the two rejections, and "no classification at + // all" still arm their leash through the untouched legacy // PRE-operation call site, not through here. Arming here too would - // double-arm it. + // double-arm them. (RemoteMotion remote, EntityPhysicsHost host) = MakeRemoteWithHost( new Vector3(1f, 2f, 3f)); Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( - Classify(hasContact: true, playerDistance: 200f), + Classify(hasContact: true, playerDistance: 10f, committedCellId: 0u), + remote)); + Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( + Classify( + hasContact: true, + playerDistance: 10f, + disposition: PositionTimestampDisposition.Rejected), + remote)); + Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( + Classify(hasContact: true, playerDistance: float.NaN), remote)); Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( null, diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs index 3208faf8..8fcec810 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using System.Numerics; using AcDream.Core.Combat; using AcDream.Core.Items; @@ -28,6 +28,30 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests private const uint SpawnLandblock = 0x01010000u; private const float SpawnHeight = 5f; + /// + /// A landblock whose collision generation is deliberately NEVER committed + /// in this fixture — the same id the deferred-park tests below use. The + /// pre-engine quiescence check runs ahead of the engine and therefore + /// ahead of any collision-readiness question, so its readiness is + /// irrelevant to the round-3 destination-quiescence test. + /// + private const uint DestinationLandblock = 0x02020000u; + + /// + /// 's +X neighbour: landblock ids pack the + /// block X index in bits 24-31, so 0x0101 → 0x0201 is one block east, and + /// LandDefs.LcoordToGid re-derives exactly this prefix for a global + /// lcoord one cell past the 192 m seam. + /// + private const uint NeighbourLandblock = 0x02010000u; + + /// + /// Cell (7, 0) of — block-local X in + /// [168, 192), Y in [0, 24). LandDefs.GidToLcoord's inverse: + /// low = (ly & 7) + ((lx & 7) << 3) + 1 = 0 + 56 + 1 = 57. + /// + private const uint SpawnSeamCell = SpawnLandblock | 57u; + [Fact] public void NotApplicable_WhenDispositionIsNotForcePosition() { @@ -830,6 +854,359 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests AssertConverged(runtime); } + /// + /// C4 route 4b-2, round-3 correction A2 (test 1 of 2) — the LOCAL PLAYER + /// traverses the shared-core quiescence-park change, on the shape that is + /// actually reachable through this route. + /// + /// + /// Reachability, measured rather than assumed. The correction asked + /// for a park under a quiescing SOURCE, reached through + /// PlacementTouchesPrefix's CurrentCellId arm. That shape is + /// not constructible: the merge this route requires + /// (RuntimeEntityObjectLifetime.TryApplyPosition) commits the + /// accepted wire cell to record.FullCellId BEFORE the drive is + /// called, and CurrentCellId is read from that field — so by submit + /// time it names the DESTINATION, not the landblock being left. The + /// reachable pre-sweep shape is therefore a quiescing destination; the + /// reachable post-sweep shape is a quiescing swept neighbour, which is the + /// sibling test below. + /// + /// + /// + /// A quiescing destination is the one shape where the rollback genuinely + /// would re-admit a spatial root into the prefix that is trying to + /// quiesce, so ParkDeferred declines it — AP-136's stated reason, + /// applied to the cell the restore would actually use. Route 2 has no + /// destination pre-flight of its own (unlike the remote far arm's + /// CanAttemptDestination), so it reaches this unmasked. + /// + /// + [Fact] + public void QuiescingDestinationPrefix_ForcePositionParkIsNotRestored() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + PhysicsBody body = Assert.IsType(record.PhysicsBody); + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out _); + + runtime.EntityObjects.Physics.SetPosition.BeginCollisionPrefixQuiescence( + DestinationLandblock, + collisionGeneration: 2UL, + includeOutdoorCells: true); + + WorldSession.EntityPositionUpdate correction = ForceUpdate( + new Vector3(10f, 10f, SpawnHeight), + landblockId: DestinationLandblock | 0x0001u); + (PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) = + MergeAccepted(runtime, controller, correction); + Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition); + + Assert.Equal( + RuntimeAcceptedPositionExecutionStatus.DeferredCell, + drive.TryExecuteAcceptedLocalPosition( + record, + correction, + disposition, + timestamps, + timestamps.PreviousTeleport)); + Assert.False(body.InWorld); + + // ACE's next ordinary broadcast, ~100-200 ms later. Its merge-time + // Forget cancels the park — and the rollback must decline, because the + // only cell it could restore into is the retiring one. + MergeAccepted( + runtime, + controller, + OrdinaryUpdate(new Vector3(31f, 33f, SpawnHeight), positionSequence: 3)); + + Assert.False(runtime.EntityObjects.Physics.IsSpatialRoot(record)); + Assert.False(body.InWorld); + Assert.False(record.ObjectClock.IsActive); + + drive.Advance(); + Assert.Equal(0, drive.PendingCount); + AssertConverged(runtime); + } + + /// + /// C4 route 4b-2, round-3 correction A2 (test 2 of 2) — the reachable + /// RESTORABLE quiescence park on the local-player route. + /// + /// + /// The correction is placed one sphere radius inside the seam between + /// and its +X neighbour, so + /// CellTransit.AddAllOutsideCells adds the neighbour's cells to the + /// sweep footprint (AddOutsideCell re-derives the block id from the + /// global lcoord and has no same-block filter). Core's + /// ResultTouchesPrefix scans every QueriedCellIds entry, so + /// a healthy, resident, about-to-COMMIT ForcePosition is rewritten to + /// DeferredCell by a quiescence in a landblock the player is + /// neither in nor going to. + /// + /// + /// + /// Before the shared-core change every quiescence park was non-restorable, + /// so the next packet's merge-time Forget destroyed the only + /// operation able to wake the player and left it InWorld = false, + /// clock suspended, not a spatial root — session-permanent, because + /// nothing else restores that state. The three asserts at the end are that + /// defect. Restoring is safe here for the reason + /// ParkDeferred tests directly: the cell it would restore into is + /// the destination, and the destination is not quiescing. + /// + /// + [Fact] + public void QuiescingSweptNeighbour_ForcePositionParkIsRestoredByTheNextPacket() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + CommitLandblockCollision( + runtime, NeighbourLandblock, worldOffsetX: 192f); + PhysicsBody body = Assert.IsType(record.PhysicsBody); + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out _); + + runtime.EntityObjects.Physics.SetPosition.BeginCollisionPrefixQuiescence( + NeighbourLandblock, + collisionGeneration: 2UL, + includeOutdoorCells: true); + // Neither the source nor the destination is quiescing — the whole + // point of the shape. + Assert.False( + runtime.EntityObjects.Physics.SetPosition.IsCollisionPrefixQuiescing( + SpawnLandblock)); + + // Block-local X = 191.95 m: inside cell (7, 0), 0.05 m from the 192 m + // seam, well inside the local player's own sphere radius, which is the + // `pointX > CellLength - radius` test AddAllOutsideCells applies + // before adding lx + 1. + WorldSession.EntityPositionUpdate correction = ForceUpdate( + new Vector3(191.95f, 10f, SpawnHeight), + landblockId: SpawnSeamCell); + (PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) = + MergeAccepted(runtime, controller, correction); + Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition); + + Assert.Equal( + RuntimeAcceptedPositionExecutionStatus.DeferredCell, + drive.TryExecuteAcceptedLocalPosition( + record, + correction, + disposition, + timestamps, + timestamps.PreviousTeleport)); + Assert.False(body.InWorld); + + MergeAccepted( + runtime, + controller, + OrdinaryUpdate(new Vector3(31f, 33f, SpawnHeight), positionSequence: 3)); + + Assert.True(body.InWorld); + Assert.True(record.ObjectClock.IsActive); + Assert.True(runtime.EntityObjects.Physics.IsSpatialRoot(record)); + + drive.Advance(); + Assert.Equal(0, drive.PendingCount); + AssertConverged(runtime); + } + + /// + /// C4 route 4b-2, round-4 correction (the MAJOR) — the DISCRIMINATING + /// test. Round 3 relocated the restorable-park decision out of the two + /// SubmitPreparedPlacementCore call sites and into + /// ParkDeferred, but nothing in the tree distinguished the new + /// predicate from the one it replaced. This does. + /// + /// + /// Round 2's form compared the blocking quiescence's own token + /// against the caller's pre-snap cell: + /// blockingToken.LandblockPrefix != (result.CellId & 0xFFFF0000). + /// Round 3's form tests the POST-snap cell against every live + /// quiescence: !IsCollisionPrefixQuiescing(body.CellPosition.ObjCellId). + /// Under round 2 this test's player is left withdrawn — InWorld + /// false, clock suspended, not a spatial root — for the rest of the + /// session. That is the exact stranding AP-136's rollback exists to + /// prevent, and it is reached without a second quiescence, purely because + /// the two forms read different cells. + /// + /// + /// + /// The shape. The server names a cell in the player's OWN + /// landblock but supplies a block-local X 5 cm PAST that block's 192 m + /// seam. A wire (cell, position) pair that disagrees is exactly the pair + /// PhysicsBody.StageDormantCellFrame's + /// LandDefs.AdjustToOutside exists to distrust (#107: "never trust + /// a server (cell, pos) pair without re-deriving the cell"), and + /// AdjustToOutside's own contract says the re-derived id may + /// belong to a NEIGHBOUR landblock — which is what happens here. So the + /// prefix the placement PARKED against (the player's own, mid-retirement) + /// and the prefix residency would be RESTORED into (the healthy + /// neighbour) are different landblocks, and only the second one is the + /// question RestoreParkWithdrawal actually asks. + /// + /// + /// + /// Route 2 is the home for it because it has no destination pre-flight: + /// the remote far arm's CanAttemptDestination refuses a quiescing + /// destination before any park is opened, so this park is unreachable + /// there. + /// + /// + [Fact] + public void QuiescingOwnPrefix_SeamCrossingParkIsRestoredAtTheReDerivedNeighbourCell() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + PhysicsBody body = Assert.IsType(record.PhysicsBody); + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out _); + + // Streaming begins retiring/republishing the player's OWN landblock, + // which is also the landblock the wire cell names. + runtime.EntityObjects.Physics.SetPosition.BeginCollisionPrefixQuiescence( + SpawnLandblock, + collisionGeneration: 2UL, + includeOutdoorCells: true); + + // Cell (7, 0) of SpawnLandblock covers block-local X in [168, 192). + // 192.05 m is 5 cm past its block's own seam, so AdjustToOutside + // re-derives lx = 8 + floor(192.05 / 24) = 16, i.e. block X index 2, + // and LcoordToGid rebuilds the id as NeighbourLandblock | 1. + WorldSession.EntityPositionUpdate correction = ForceUpdate( + new Vector3(192.05f, 10f, SpawnHeight), + landblockId: SpawnSeamCell); + (PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) = + MergeAccepted(runtime, controller, correction); + Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition); + + Assert.Equal( + RuntimeAcceptedPositionExecutionStatus.DeferredCell, + drive.TryExecuteAcceptedLocalPosition( + record, + correction, + disposition, + timestamps, + timestamps.PreviousTeleport)); + Assert.False(body.InWorld); + + // The two cells the two predicate forms read are DIFFERENT + // landblocks, and only the quiescing one is the caller's. + Assert.Equal(SpawnLandblock, correction.Position.LandblockId & 0xFFFF0000u); + Assert.Equal( + NeighbourLandblock, + body.CellPosition.ObjCellId & 0xFFFF0000u); + Assert.False( + runtime.EntityObjects.Physics.SetPosition.IsCollisionPrefixQuiescing( + NeighbourLandblock)); + + // ACE's next ordinary broadcast cancels the park at merge time. + MergeAccepted( + runtime, + controller, + OrdinaryUpdate(new Vector3(31f, 33f, SpawnHeight), positionSequence: 3)); + + // Restored, because the cell residency is restored INTO is healthy. + // All three of these fail under round 2's single-token form. + Assert.True(body.InWorld); + Assert.True(record.ObjectClock.IsActive); + Assert.True(runtime.EntityObjects.Physics.IsSpatialRoot(record)); + + drive.Advance(); + Assert.Equal(0, drive.PendingCount); + AssertConverged(runtime); + } + + /// + /// C4 route 4b-2, round-4 correction D6 — the restorable decision is + /// re-tested at RESTORE time, not only at park time. + /// + /// + /// ParkDeferred's decision is a snapshot. The far snap cancels its + /// park synchronously, so that snapshot cannot go stale; route 2's park is + /// RETAINED (AwaitingCommitWake) and the restore lands on the next + /// packet's merge-time Forget, ~150 ms later at ACE's 5-10 Hz. + /// Streaming opens a quiescence per landblock mutation, so the cell the + /// rollback would restore into can start quiescing inside that window. + /// + /// + /// + /// This drives exactly that: a swept-NEIGHBOUR quiescence parks a + /// placement whose destination is healthy (so the park IS restorable when + /// taken), then the destination's own prefix begins quiescing before the + /// next packet. The restore must decline — otherwise it re-admits a + /// spatial root into a prefix that is trying to retire, which is AP-136's + /// stated reason verbatim. The entity self-heals on a later packet, so + /// declining here is strictly the conservative half. + /// + /// + [Fact] + public void QuiescenceOpenedAfterTheParkDeclinesTheRestoreAtMergeTime() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + CommitLandblockCollision( + runtime, NeighbourLandblock, worldOffsetX: 192f); + PhysicsBody body = Assert.IsType(record.PhysicsBody); + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out _); + + runtime.EntityObjects.Physics.SetPosition.BeginCollisionPrefixQuiescence( + NeighbourLandblock, + collisionGeneration: 2UL, + includeOutdoorCells: true); + + WorldSession.EntityPositionUpdate correction = ForceUpdate( + new Vector3(191.95f, 10f, SpawnHeight), + landblockId: SpawnSeamCell); + (PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) = + MergeAccepted(runtime, controller, correction); + Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition); + + Assert.Equal( + RuntimeAcceptedPositionExecutionStatus.DeferredCell, + drive.TryExecuteAcceptedLocalPosition( + record, + correction, + disposition, + timestamps, + timestamps.PreviousTeleport)); + Assert.False(body.InWorld); + // The park WAS restorable when it was taken: the destination prefix + // was clean. (Its sibling test above asserts the restore that follows + // from exactly this state.) + Assert.Equal( + SpawnLandblock, + body.CellPosition.ObjCellId & 0xFFFF0000u); + + // …and now streaming retires that very landblock, mid-park. + runtime.EntityObjects.Physics.SetPosition.BeginCollisionPrefixQuiescence( + SpawnLandblock, + collisionGeneration: 3UL, + includeOutdoorCells: true); + + MergeAccepted( + runtime, + controller, + OrdinaryUpdate(new Vector3(31f, 33f, SpawnHeight), positionSequence: 3)); + + Assert.False(runtime.EntityObjects.Physics.IsSpatialRoot(record)); + + drive.Advance(); + Assert.Equal(0, drive.PendingCount); + AssertConverged(runtime); + } + private static void AssertConverged(GameRuntime runtime) { RuntimeEntityObjectOwnershipSnapshot ownership = @@ -1011,7 +1388,8 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests /// private static void CommitLandblockCollision( GameRuntime runtime, - uint landblockId) + uint landblockId, + float worldOffsetX = 0f) { // Mirrors HeadlessSessionHostTests.AddFlatLandblock's exact // proven-working shape (every heightmap byte and every table entry @@ -1029,7 +1407,7 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests new TerrainSurface(heights, heightTable), Array.Empty(), Array.Empty(), - worldOffsetX: 0f, + worldOffsetX, worldOffsetY: 0f); runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( landblockId, 1UL, ready: true); diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeRemotePlacementDriveControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeRemotePlacementDriveControllerTests.cs index 3d44298a..91a5f06c 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeRemotePlacementDriveControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeRemotePlacementDriveControllerTests.cs @@ -11,9 +11,14 @@ using AcDream.Runtime.Session; namespace AcDream.Runtime.Tests.Session; /// -/// C4 route 4b-1: focused tests for the Runtime-owned, per-entity remote -/// placement infrastructure. This route wires NO production caller — every -/// test below constructs an already-classified +/// C4 route 4b-1/4b-2: focused tests for the Runtime-owned, per-entity remote +/// placement infrastructure. 4b-1 wired no production caller at all; 4b-2 gave +/// the far arm one (LiveEntityNetworkUpdateController.ApplyRemoteContactRouting, +/// exercised end to end by LiveEntityNetworkRemoteFarSnapIntegrationTests +/// — correcting this comment's earlier "This route wires NO production +/// caller"). Every test in THIS file still drives the seam directly, because +/// each one constructs an already-classified +/// /// directly (the same shape /// /// would hand a real caller) so each scenario is exercised precisely, mirroring @@ -22,10 +27,14 @@ namespace AcDream.Runtime.Tests.Session; /// harness route 2's tests use — this controller has no local-player /// controller, outbound session, or generation dependency to bootstrap. /// -/// Each test was verified to actually discriminate its own fix by temporarily -/// reverting the corresponding guard/omission in -/// and confirming the -/// matching test failed, then restoring it. +/// Round 3 (correction n3) — the earlier version of this paragraph asserted +/// that "each test was verified to actually discriminate its own fix", which +/// is a claim about a procedure run outside the tree with no per-test mapping +/// anywhere in it. Reduced to what this file can actually show: every test +/// here names, in its own doc, the specific guard or omission it fails +/// without. Whether that revert was run, and what the run measured, belongs +/// in the slice's review record, not in a comment that cannot be checked +/// against the code beside it. /// public sealed class RuntimeRemotePlacementDriveControllerTests { @@ -33,6 +42,21 @@ public sealed class RuntimeRemotePlacementDriveControllerTests private const uint SourceCell = SourceLandblock | 0x0001u; private const uint DestinationLandblock = 0xB2000000u; private const uint DestinationCell = DestinationLandblock | 0x0001u; + + /// + /// The destination landblock's +X neighbour. Landblock ids pack the block + /// X index in bits 24-31, so 0xB2 → 0xB3 is one block east, and + /// LandDefs.LcoordToGid re-derives exactly this prefix for a global + /// lcoord one cell past the 192 m seam. + /// + private const uint NeighbourLandblock = 0xB3000000u; + + /// + /// Cell (7, 0) of the destination landblock — block-local X in [168, 192), + /// Y in [0, 24). LandDefs.GidToLcoord's inverse: + /// low = (ly & 7) + ((lx & 7) << 3) + 1 = 0 + 56 + 1 = 57. + /// + private const uint DestinationSeamCell = DestinationLandblock | 57u; private const float SpawnHeight = 7f; [Fact] @@ -772,7 +796,9 @@ public sealed class RuntimeRemotePlacementDriveControllerTests /// /// Gate item: ParkCollisionResidents throws on overlap for every /// spatial root in a retiring prefix that holds an active operation - /// (RuntimeSetPositionState.cs:3387-3395). This is the concrete + /// (RuntimeSetPositionState.ParkCollisionResidents — cited by + /// symbol, not by line: the delta review found line citations in this + /// slice going stale within a single review round). This is the concrete /// proof it stays unreachable under this route's design — both after a /// refusal (no operation ever opened) and after a normal commit /// (operation retired to zero), the SAME prefix can be "retired" without @@ -826,6 +852,1089 @@ public sealed class RuntimeRemotePlacementDriveControllerTests Assert.Null(thrown); } + // ── C4 route 4b-2: the far-snap arm ────────────────────────────────── + + /// + /// The queue clear does not depend on the placement having run: refuse + /// the placement and the queue is still empty afterwards. + /// + /// + /// R7 review fix — what this does and does not prove. Its earlier + /// name and doc claimed proof of retail's ORDER (StopInterpolating + /// @0x005163CB strictly before SetPositionSimple @0x005163D9). + /// It does not have that, and neither can any acdream test: the + /// interpolation queue is not an input to the canonical placement (the + /// destination comes from the merged snapshot), and the placement does + /// not read or write the queue, so swapping the two statements leaves + /// every observable identical. The order is preserved in + /// ApplyAcceptedRemoteFarSnap because retail has it, and is + /// asserted only by reading. What IS pinned here is the strictly weaker + /// and still load-bearing property that ruled out the natural + /// mis-implementation "clear the queue once the placement succeeds": + /// under that shape a refusal would leave a stale near waypoint behind to + /// drag the body back on the next catch-up. + /// + /// + [Fact] + public void FarSnap_ClearsTheInterpolationQueue_IndependentlyOfThePlacementOutcome() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + // Publishes the world frame as well, so the refusal's store_position + // fallback below has a coordinate system to write into — a live + // session always has one before any remote can classify far. + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003020u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + Vector3 positionBefore = body.Position; + remote.Interp.Enqueue( + new Vector3(40f, 40f, SpawnHeight), + Quaternion.Identity, + isMovingTo: false, + currentBodyPosition: body.Position, + currentBodyOrientation: body.Orientation); + Assert.True(remote.Interp.IsActive); + + // The service window allows nothing, so the placement refuses. + RuntimeRemotePlacementDriveController drive = + CreateDrive(lifetime, new FakeServiceWindow()); + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationCell, + destination, + stopInterpolating: true); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Refused, + drive.ApplyAcceptedRemoteFarSnap(record, remote, route)); + + Assert.False(remote.Interp.IsActive); + // Refusal is retail's no-resolvable-cell state, and that branch still + // commits the destination — store_position @0x00515CE2. Leaving the + // body at positionBefore with an emptied queue is the freeze both + // reviews found. + Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position); + Assert.NotEqual(positionBefore, body.Position); + Assert.True(body.InWorld); + AssertConverged(lifetime); + } + + /// + /// The second non-commit outcome: an entity whose SetPosition operation is + /// already owned by another placement authority contends, so no operation + /// of this route's opens at all — and the body still advances. + /// + [Fact] + public void FarSnap_ContendedByAnotherAuthority_StillStoresTheDestinationPose() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003024u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + Vector3 positionBefore = body.Position; + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + RuntimeEntityPlacementToken displaced = lifetime.Physics.SetPosition + .TryBeginExclusiveAuthoredPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(displaced.IsValid); + + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationCell, + destination, + stopInterpolating: true); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Contention, + drive.ApplyAcceptedRemoteFarSnap(record, remote, route)); + Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position); + Assert.NotEqual(positionBefore, body.Position); + Assert.Equal(0, drive.PendingCount); + + RuntimePlacementCancellationReceipt cancellation = lifetime.Physics + .SetPosition.ForgetExactPlacement(displaced); + if (cancellation.IsValid) + lifetime.Physics.SetPosition.PublishCancellation(cancellation); + AssertConverged(lifetime); + } + + /// + /// The retryable-preparation outcome (an unresolved Setup collision) is + /// also reported as Contention, and it RETAINS an operation for the + /// cadence pump — but the body must not wait for that pump. Retail's + /// analogue never waits: its Setup is always resident, and the object is + /// committed to the destination the moment the packet is handled. + /// + [Fact] + public void FarSnap_RetryablePreparation_StoresThePoseAndStillRetainsTheRetry() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord( + lifetime, 0x70003025u, setupTableId: 0x02000001u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + Vector3 positionBefore = body.Position; + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationCell, + destination, + stopInterpolating: true); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Contention, + drive.ApplyAcceptedRemoteFarSnap(record, remote, route)); + Assert.Equal(1, drive.PendingCount); + Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position); + Assert.NotEqual(positionBefore, body.Position); + + // The retained retry converges on its own terms; the pose write above + // is independent of it. + drive.Advance(); + Assert.Equal(1, drive.PendingCount); + RuntimePlacementCancellationReceipt cancellation = + lifetime.Physics.SetPosition.Forget(record); + if (cancellation.IsValid) + lifetime.Physics.SetPosition.PublishCancellation(cancellation); + drive.Advance(); + Assert.Equal(0, drive.PendingCount); + AssertConverged(lifetime); + } + + /// + /// R8 review fix, part 1 — the RESTORABLE DeferredCell park + /// (SubmitAndResolve's RuntimeSetPositionStatus.DeferredCell + /// case), which had ZERO coverage in 4b-1 or 4b-2 while the far snap is + /// the first production path that can provoke one. Provoked by letting + /// the service window say "yes" for a destination whose collision + /// generation was never committed, so the engine itself defers. + /// + /// + /// The park must be cancelled AND rolled back — CancelToken passes + /// restoreCancelledPark: true and this park opts in with + /// restorableOnCancel: true, so RestoreParkWithdrawal + /// returns InWorld, the object clock, and canonical residency + /// (AP-136). Without the rollback the entity would be left invisible and + /// intangible. + /// + /// + /// + /// Delta review: the pose assertion below is the PARK's own + /// store_position (ParkDeferred snaps the body to the + /// deferred result before withdrawing, and the restore deliberately leaves + /// that pose alone), NOT the far arm's fallback — a Deferred status + /// does not store, precisely so the post-sweep park's collision-settled + /// pose cannot be overwritten with the raw destination. + /// + /// + [Fact] + public void FarSnap_DeferredCellPark_IsCancelledAndRolledBackAtTheDestination() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + // The world frame only — the DESTINATION's collision generation is + // deliberately never committed, which is what makes the engine defer. + lifetime.Physics.ObserveLocalWorldFrame( + SourceCell, teleportAdvanced: false); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003026u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationCell, + destination, + stopInterpolating: true); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Deferred, + drive.ApplyAcceptedRemoteFarSnap(record, remote, route)); + + // The park was rolled back, not left standing. + Assert.True(body.InWorld); + Assert.True(record.ObjectClock.IsActive); + Assert.NotEqual(0u, record.FullCellId); + // …and the body still tracked the server. + Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position); + Assert.Equal( + 0, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + AssertConverged(lifetime); + } + + /// + /// R8 review fix, part 2 — the NON-restorable park, and why the pre-flight + /// now refuses ahead of it. + /// + /// + /// A live in-place collision-prefix quiescence is the residual the class + /// doc names: the tier/residency service window still reads "published", + /// but RuntimeSetPositionState.TryGetBlockingQuiescence defers. + /// When it is the DESTINATION'S OWN prefix that is quiescing — this test — + /// the park stays non-restorable, because AP-136's stated reason applies + /// exactly: RestoreParkWithdrawal would re-admit a spatial root + /// into the prefix that is trying to quiesce, and ParkDeferred + /// declines the rollback whenever the post-snap restore cell is itself + /// quiescing. CanAttemptDestination therefore reads + /// Core's own IsCollisionPrefixQuiescing as part of the SAME + /// pre-flight and refuses instead — and the store_position fallback keeps + /// the remote tracking the server while the prefix mutates. + /// + /// + /// + /// Delta review MAJOR B: this is the ONE quiescence shape the pre-flight + /// can see. The source-landblock and swept-neighbour shapes reach Core + /// anyway and are covered by the two tests below, which is why the + /// pre-flight is now an optimisation rather than the correctness + /// mechanism. + /// + /// + [Fact] + public void FarSnap_QuiescingDestinationPrefix_RefusesWithoutOpeningANonRestorablePark() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003028u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + lifetime.Physics.SetPosition.BeginCollisionPrefixQuiescence( + DestinationLandblock, + collisionGeneration: 1UL, + includeOutdoorCells: true); + // The window is unchanged and still says yes — this is precisely the + // case it cannot see. + Assert.True(window.IsWithinServiceWindow(DestinationCell)); + Assert.True( + lifetime.Physics.SetPosition.IsCollisionPrefixQuiescing( + DestinationLandblock)); + + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationCell, + destination, + stopInterpolating: true); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Refused, + drive.ApplyAcceptedRemoteFarSnap(record, remote, route)); + + // No park was ever opened, so there is nothing to fail to restore. + Assert.True(body.InWorld); + Assert.True(record.ObjectClock.IsActive); + Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position); + Assert.Equal( + 0, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + AssertConverged(lifetime); + } + + /// + /// Delta review MAJOR B, route 1 of 2 — the SOURCE landblock quiesces. + /// + /// + /// CanAttemptDestination tests the DESTINATION prefix and passes, + /// but Core's PlacementTouchesPrefix also matches the request's + /// CurrentCellId, populated from record.FullCellId whenever + /// the body is in world — so a far snap OUT of a quiescing landblock + /// parks at the pre-sweep site no pre-flight can see. + /// + /// + /// + /// Round 3 — reachability caveat, measured. The earlier text called + /// this "the likelier of the two shapes". It is not reached by the + /// graphical remote caller at all: that path commits the accepted WIRE + /// cell to record.FullCellId in its shared prologue + /// (LiveEntityRuntime.RebucketLiveEntity → + /// RuntimeEntityObjectLifetime.CommitRebucket) before it routes, so + /// CurrentCellId names the DESTINATION by submit time and the + /// source prefix never matches. This fixture reaches the shape because it + /// drives the seam directly and never rebuckets. The test still earns its + /// place — it is the only coverage of the pre-sweep quiescence park's + /// restore, which a future caller that submits without rebucketing (or a + /// prologue reordering) would reach — but it must not be cited as the + /// production-likely case. The reachable production shapes are the + /// quiescing DESTINATION (refused by the pre-flight here, parked + /// non-restorably on route 2) and the quiescing SWEPT NEIGHBOUR (the test + /// below). + /// + /// + /// + /// With the park non-restorable (the shipped state before this fix) the + /// remote is left InWorld = false, clock suspended, + /// FullCellId = 0, with the only operation able to wake it + /// destroyed by CancelToken. ParkDeferred now makes it + /// restorable because the cell it snapped to — the DESTINATION, which is + /// the cell RestoreParkWithdrawal restores residency into — is not + /// quiescing, so nothing is re-admitted into the retiring source. + /// + /// + [Fact] + public void FarSnap_QuiescingSourceLandblock_RestoresTheRemoteIntoTheWorld() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003030u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + lifetime.Physics.SetPosition.BeginCollisionPrefixQuiescence( + SourceLandblock, + collisionGeneration: 1UL, + includeOutdoorCells: true); + // Both halves of the pre-flight pass — it reads only the destination, + // and cannot see the source at all. + Assert.True(window.IsWithinServiceWindow(DestinationCell)); + Assert.False( + lifetime.Physics.SetPosition.IsCollisionPrefixQuiescing( + DestinationLandblock)); + Assert.True( + lifetime.Physics.SetPosition.IsCollisionPrefixQuiescing( + SourceLandblock)); + + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationCell, + destination, + stopInterpolating: true); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Deferred, + drive.ApplyAcceptedRemoteFarSnap(record, remote, route)); + + Assert.True(body.InWorld); + Assert.True(record.ObjectClock.IsActive); + Assert.NotEqual(0u, record.FullCellId); + Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position); + Assert.Equal( + 0, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + AssertConverged(lifetime); + } + + /// + /// Delta review MAJOR B route 2 of 2, and MAJOR C — a merely-SWEPT + /// NEIGHBOUR landblock quiesces, on a placement that would otherwise have + /// COMMITTED. + /// + /// + /// The destination sits one sphere radius inside the seam between + /// DestinationLandblock and its +X neighbour, so + /// CellTransit.AddAllOutsideCells adds the neighbour's cells to the + /// sweep footprint — AddOutsideCell re-derives the block id from + /// the global lcoord and states outright that there is no same-block + /// filter. Core's ResultTouchesPrefix scans every + /// QueriedCellIds entry, so the POST-sweep quiescence check fires + /// on a footprint that did not exist until the sweep ran. No pre-flight + /// can reproduce that, which is the structural half of MAJOR B. + /// + /// + /// + /// MAJOR C: that check is result.IsSuccessful && + /// TryGetBlockingQuiescence(result, …) and sits AHEAD of the + /// restorable result.IsDeferred park, so a healthy, resident, + /// about-to-commit far snap near a seam was rewritten to + /// DeferredCell and parked non-restorably. The + /// Assert.NotEqual(0u, record.FullCellId) below is what fails + /// without the fix. + /// + /// + [Fact] + public void FarSnap_QuiescingSweptNeighbour_RestoresTheRemoteIntoTheWorld() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + CommitLandblockCollision( + lifetime, NeighbourLandblock, worldOffsetX: 384f); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003031u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + lifetime.Physics.SetPosition.BeginCollisionPrefixQuiescence( + NeighbourLandblock, + collisionGeneration: 1UL, + includeOutdoorCells: true); + // Neither half of the pre-flight can see the neighbour. + Assert.True(window.IsWithinServiceWindow(DestinationSeamCell)); + Assert.False( + lifetime.Physics.SetPosition.IsCollisionPrefixQuiescing( + DestinationLandblock)); + + // Block-local X = 191.95 m: inside cell (7, 0) of the destination + // landblock, 0.05 m from the 192 m seam — closer than the 0.1 m dummy + // sphere radius, which is the `pointX > CellLength - radius` test + // AddAllOutsideCells applies before adding lx + 1. + // Z is deliberately 1 m BELOW the flat terrain so the placement sweep + // lifts the sphere back to the surface: the parked result then carries + // a settled Z that differs from the raw accepted one, which is what + // makes the "Deferred does not store" arm (delta review N2) + // discriminating rather than latent. + var destination = new Vector3(191.95f, 10f, SpawnHeight - 1f); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationSeamCell, + destination, + stopInterpolating: true); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Deferred, + drive.ApplyAcceptedRemoteFarSnap(record, remote, route)); + + Assert.True(body.InWorld); + Assert.True(record.ObjectClock.IsActive); + Assert.NotEqual(0u, record.FullCellId); + // ParkDeferred already performed retail's store_position, at the + // SETTLED pose; the far arm must not write the raw destination over it. + Assert.Equal(SpawnHeight, body.Position.Z); + Assert.Equal( + 0, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + AssertConverged(lifetime); + } + + /// + /// Round-3 correction A5 — why CanAttemptDestination is + /// load-bearing and must not be deleted as "just an optimisation". + /// + /// + /// Two live quiescences at once, SOURCE opened first so its + /// OperationId is the lower one. Core files the park under the + /// source, but the cell the rollback would restore into is the + /// DESTINATION, which is retiring — so ParkDeferred would + /// (correctly) refuse the rollback and the remote would be left withdrawn + /// until some later packet commits. + /// + /// + /// + /// The pre-flight is what keeps that shape off the remote path entirely: + /// it reads the destination prefix, refuses BEFORE any park is opened, + /// and the store_position fallback keeps the remote tracking the + /// server while both prefixes mutate. + /// + /// + /// + /// Round 4 — cross-reference corrected. The earlier text cited a + /// route-2 sibling named + /// ConcurrentQuiescences_ParkIsNotRestoredIntoTheQuiescingDestination. + /// No such test exists, and it cannot: this two-quiescence shape needs + /// the park to be filed under a prefix that is NOT the restore cell's, + /// which needs PlacementTouchesPrefix's CurrentCellId arm to + /// name a different landblock than the request — and route 2's merge + /// commits the accepted wire cell to record.FullCellId before the + /// drive runs, so on that route the two are the same prefix by + /// construction. (This fixture reaches the shape only because it drives + /// the seam directly and never rebuckets — the same caveat + /// FarSnap_QuiescingSourceLandblock_RestoresTheRemoteIntoTheWorld + /// carries.) The route-2 test that pins the restore decision itself is + /// RuntimeAcceptedPositionDriveControllerTests + /// .QuiescingOwnPrefix_SeamCrossingParkIsRestoredAtTheReDerivedNeighbourCell, + /// which discriminates on the OTHER half of the same predicate: the + /// post-snap restore cell versus the caller's pre-snap + /// result.CellId. + /// + /// + /// + /// Scope of this test's own evidence (round 4, N2). It is + /// behaviourally redundant with + /// FarSnap_QuiescingDestinationPrefix_RefusesWithoutOpeningANonRestorablePark + /// — both fail identically if the pre-flight's + /// IsCollisionPrefixQuiescing half is removed — and the + /// OperationId ordering asserted below never influences an + /// observed outcome here, because the pre-flight refuses before any + /// blocking token is selected. It documents the concurrent-quiescence + /// shape and pins the refusal; it does not prove which token Core would + /// have chosen. + /// + /// + [Fact] + public void FarSnap_ConcurrentQuiescences_RefusesBeforeOpeningAPark() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003037u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + RuntimeCollisionPrefixQuiescenceToken sourceQuiescence = + lifetime.Physics.SetPosition.BeginCollisionPrefixQuiescence( + SourceLandblock, + collisionGeneration: 2UL, + includeOutdoorCells: true); + RuntimeCollisionPrefixQuiescenceToken destinationQuiescence = + lifetime.Physics.SetPosition.BeginCollisionPrefixQuiescence( + DestinationLandblock, + collisionGeneration: 2UL, + includeOutdoorCells: true); + Assert.True( + sourceQuiescence.OperationId < destinationQuiescence.OperationId); + + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationCell, + destination, + stopInterpolating: true); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Refused, + drive.ApplyAcceptedRemoteFarSnap(record, remote, route)); + + // No park was opened, so nothing was ever withdrawn — and nothing was + // re-admitted into either retiring prefix. + Assert.True(body.InWorld); + Assert.True(record.ObjectClock.IsActive); + Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position); + Assert.Equal( + 0, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + AssertConverged(lifetime); + } + + /// + /// Delta review MAJOR A, the do-NOT-store side. The engine's own sweep + /// refuses the destination — retail + /// CPhysicsObj::SetPositionInternal @0x00515BD0 reaches + /// CheckPositionInternal == 0 @0x00515C85, + /// handle_all_collisions @0x00515CC2 and + /// return ((eax_14 - eax_14) & 2) + 2 @0x00515CD5 WITHOUT ever + /// calling store_position. The shipped fix round routed this + /// through the fallback, which teleported the canonical body into a + /// destination the engine had just refused. + /// + [Fact] + public void FarSnap_EngineRefusedTheDestination_LeavesTheBodyWhereItWas() + { + PhysicsEngine engine = FlatEngine(); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003032u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + Vector3 positionBefore = body.Position; + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + engine.TransitionCellCollisionTestHook = + static (_, _, _, _) => TransitionState.Collided; + + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationCell, + destination, + stopInterpolating: true); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.RejectedByPlacement, + drive.ApplyAcceptedRemoteFarSnap(record, remote, route)); + + // Retail leaves the object where it was on 2/3/4. + Assert.Equal(positionBefore, body.Position); + Assert.NotEqual(destination + new Vector3(192f, 0f, 0f), body.Position); + Assert.Equal( + 0, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + AssertConverged(lifetime); + } + + /// + /// Delta review MAJOR A, the worse sub-case: + /// SubmitPreparedPlacementCore returns Cancelled AFTER + /// CommitCanonical succeeded (F1 — "retail lets that physical + /// commit land regardless"), so the body carries a freshly SETTLED pose + /// while this caller's own operation is no longer canonical. The shipped + /// fix round mapped that to the fallback and overwrote the settle with the + /// raw accepted destination. + /// + /// + /// The displacement is provoked the same way + /// ReentrantGroundEdgePlacementCannotBeCancelledByDisplacedOperation + /// provokes it — through the ground-edge HitGround callback + /// CommitSetPositionContactTransition invokes mid-commit — and the + /// callback also merges a NEWER accepted Position into the snapshot, + /// which is the production shape (packet N+1 arriving while packet N is + /// still committing). That merge is what makes the assertion discriminate: + /// if the fallback fires it reads the snapshot at store time and lands the + /// body on the newer destination instead of the settled pose. + /// + /// + [Fact] + public void FarSnap_CancelledAfterTheCommitSettled_KeepsTheSettledPose() + { + PhysicsEngine engine = FlatEngine(); + engine.TransitionCellCollisionTestHook = + (transition, phase, _, observed) => + { + if (phase == TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, 0f), + DestinationCell); + } + return observed; + }; + using var lifetime = new RuntimeEntityObjectLifetime(engine); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003033u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + var destination = new Vector3(12f, 14f, SpawnHeight); + var supersedingDestination = new Vector3(77f, 88f, SpawnHeight); + Vector3 settled = Vector3.Zero; + RuntimeEntityPlacementToken displaced = default; + remote.Movement.Minterp.RemoveLinkAnimations = () => + { + settled = body.Position; + record.Snapshot = record.Snapshot with + { + Position = new CreateObject.ServerPosition( + DestinationCell, + supersedingDestination.X, + supersedingDestination.Y, + supersedingDestination.Z, + 1f, + 0f, + 0f, + 0f), + }; + displaced = lifetime.Physics.SetPosition.BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + }; + + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationCell, + destination, + stopInterpolating: true); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.RejectedByPlacement, + drive.ApplyAcceptedRemoteFarSnap(record, remote, route)); + + // The ground-edge callback really did run mid-commit, and the commit + // really did settle the body before it. + Assert.True(displaced.IsValid); + Assert.NotEqual(Vector3.Zero, settled); + Assert.Equal(settled, body.Position); + Assert.NotEqual( + supersedingDestination + new Vector3(192f, 0f, 0f), + body.Position); + + RuntimePlacementCancellationReceipt cancellation = lifetime.Physics + .SetPosition.ForgetExactPlacement(displaced); + if (cancellation.IsValid) + lifetime.Physics.SetPosition.PublishCancellation(cancellation); + AssertConverged(lifetime); + } + + /// + /// Delta review MAJOR A, the store side one stage later than the + /// pre-flight: preparation refuses TERMINALLY + /// (RuntimeSetPositionMoverPreparationStatus.InvalidData, here from + /// a destination cell id PositionFrameValidation rejects), so the + /// engine was never called and retail's no-transition branch — the one + /// that DOES store_position @0x00515CE2 — is what this state + /// corresponds to. + /// + [Fact] + public void FarSnap_PreparationRejected_StillStoresTheDestinationPose() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003034u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + Vector3 positionBefore = body.Position; + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + // Cell low 0 is outside LandDefs.CellLowInRange, so + // IsStructurallyValid refuses the prepared frame — while the landblock + // prefix is still the one the pre-flight and the world frame resolve. + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationLandblock, + destination, + stopInterpolating: true); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.RejectedPreparation, + drive.ApplyAcceptedRemoteFarSnap(record, remote, route)); + + Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position); + Assert.NotEqual(positionBefore, body.Position); + Assert.Equal(0, drive.PendingCount); + Assert.Equal( + 0, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + AssertConverged(lifetime); + } + + /// + /// Delta review MAJOR D: the store_position fallback must + /// re-validate position ownership before it writes, exactly as + /// RuntimeSetPositionState.RestoreParkWithdrawal does and exactly + /// as this route's own remarks demand of every caller "on EVERY placement + /// status, before writing anything else for the packet". + /// + /// + /// The production producer is CancelToken's SYNCHRONOUS + /// cancellation receipt: the placement-projection sink can delete or + /// replace the incarnation from inside the publish, so the record can stop + /// being canonical between the placement returning and the fallback + /// running, and the App-side re-validation only happens after the seam + /// returns. This test reaches that state deterministically by dropping the + /// record from the active directory while every other field it holds — the + /// key, the canonical body, the accepted snapshot — stays exactly as the + /// packet left it. That isolation matters: a full incarnation replacement + /// also clears the key and the body, so it would be caught by the null + /// checks and would prove nothing about the currency test itself. + /// + /// + /// + /// What this does and does NOT pin (round 3, correction m3). It + /// pins that the guard exists, that its predicate is + /// Entities.IsCurrent and not a null check, and that it runs on + /// the storing side. It does NOT pin the ORDERING that made the defect + /// reachable in production — the guard sitting AFTER + /// CancelToken's synchronous cancellation receipt — because the + /// status it drives to is , + /// which never calls CancelToken at all. Pinning the ordering + /// needs a placement-projection sink that supersedes the incarnation from + /// inside the publish; that sink is production infrastructure this bare + /// fixture does not install, + /// and building a stand-in here would test the stand-in. + /// + /// + [Fact] + public void FarSnap_SupersededIncarnation_DoesNotStoreThroughTheStaleRecord() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003036u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + Vector3 positionBefore = body.Position; + // The service window refuses, so the status is Refused — squarely on + // the STORING side of the partition. The currency guard inside + // StoreAcceptedDestinationPose is therefore the only thing standing + // between this packet and a pose written through a record that is no + // longer the canonical owner. + RuntimeRemotePlacementDriveController drive = + CreateDrive(lifetime, new FakeServiceWindow()); + + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationCell, + destination, + stopInterpolating: true); + + Assert.True(lifetime.Entities.RemoveActive(record)); + Assert.False(lifetime.Entities.IsCurrent(record)); + Assert.NotNull(record.PhysicsBody); + Assert.NotNull(record.Key); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Refused, + drive.ApplyAcceptedRemoteFarSnap(record, remote, route)); + + Assert.Equal(positionBefore, body.Position); + Assert.NotEqual(destination + new Vector3(192f, 0f, 0f), body.Position); + AssertConverged(lifetime); + } + + /// + /// Delta review N1: 's + /// window-drop path is the entry point's Refused semantics one + /// cadence pump later, so it owes the same store_position. Without + /// it a retained retry whose destination leaves the window reproduces a + /// smaller version of the freeze this round exists to fix — the remote + /// stops at whatever pose the FIRST packet stored and never advances + /// again. + /// + [Fact] + public void Advance_DestinationLeavesTheWindow_StoresTheNewestDestinationPose() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord( + lifetime, 0x70003035u, setupTableId: 0x02000001u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + // The Setup collision never resolves, so the entry point retains a + // preparation retry for the cadence pump. + var firstDestination = new Vector3(12f, 14f, SpawnHeight); + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Contention, + drive.ApplyAcceptedRemoteFarSnap( + record, + remote, + MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationCell, + firstDestination, + stopInterpolating: true))); + Assert.Equal(1, drive.PendingCount); + Assert.Equal( + firstDestination + new Vector3(192f, 0f, 0f), body.Position); + + // The server keeps broadcasting while the retry sits retained: the + // accepted snapshot moves on, and the destination's collision + // publication retires out from under the retry. + var newestDestination = new Vector3(40f, 50f, SpawnHeight); + record.Snapshot = record.Snapshot with + { + Position = new CreateObject.ServerPosition( + DestinationCell, + newestDestination.X, + newestDestination.Y, + newestDestination.Z, + 1f, + 0f, + 0f, + 0f), + }; + window.Forbid(DestinationLandblock); + + drive.Advance(); + + Assert.Equal(0, drive.PendingCount); + Assert.Equal( + newestDestination + new Vector3(192f, 0f, 0f), body.Position); + Assert.Equal( + 0, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + AssertConverged(lifetime); + } + + /// + /// R9 review fix: contract item 7 names currency across GUID reuse and + /// incarnation, and 4b-2 shipped only the interleaving and teardown + /// dimensions. 's two + /// maps are keyed by RuntimeEntityKey (guid + incarnation), so a + /// reused GUID produces a NEW key and cannot displace the old entry by + /// itself — a dead-incarnation entry would accumulate until + /// DetachRoute. + /// + /// + /// The convergence must therefore be IN-SESSION and must not need a + /// teardown: this test retires incarnation 1, re-creates the same GUID at + /// incarnation 2, and asserts the ledger reads zero with the route still + /// attached. + /// + /// + [Fact] + public void RemotePlacementLedger_ConvergesAcrossGuidReuse_WithoutTeardown() + { + const uint reusedGuid = 0x70003027u; + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + var route = new object(); + drive.AttachRoute(route); + + // Incarnation 1: a far snap whose Setup never resolves, so the + // controller retains a preparation retry keyed by (guid, 1). + RuntimeEntityRecord first = CreateRemoteRecord( + lifetime, reusedGuid, setupTableId: 0x02000001u); + AttachBody(lifetime, first, SourceCell); + RemoteMotion firstRemote = lifetime.Physics.GetOrCreateRemoteMotion(first); + uint firstIncarnation = first.Incarnation; + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Contention, + drive.ApplyAcceptedRemoteFarSnap( + first, + firstRemote, + MakeRoute( + first, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationCell, + new Vector3(12f, 14f, SpawnHeight), + stopInterpolating: true))); + Assert.Equal(1, drive.PendingCount); + + // The incarnation is replaced and the SAME guid comes back — the exact + // shape ACE produces when an entity leaves and re-enters radar range. + RuntimeEntityRecord second = CreateRemoteRecord( + lifetime, reusedGuid, instanceSequence: 1); + Assert.NotEqual(firstIncarnation, second.Incarnation); + AttachBody(lifetime, second, SourceCell); + + // Delta review N7 — the assertions below are ORDER-DEPENDENT and the + // order is now deliberate and documented, because the previous version + // presented them as two independent facts when they are not. + // `PendingCount` is the RAW map size (its own doc says so) and + // `_pending` still holds the dead incarnation-1 entry at this point; + // `CountLivePending`, the ledger's provider, is what prunes it. So: + // + // 1. The LEDGER read is the R9 claim, and it must converge with the + // route still attached, no teardown, no Advance() pump. + // 2. The raw count is then asserted as the POST-heal state — proof + // that the read pruned rather than merely reporting a filtered + // view while the map grew unbounded (the leak R9 describes). + Assert.Equal( + 0, + lifetime.CaptureOwnership().RemotePlacementDrivePendingCount); + Assert.Equal(0, drive.PendingCount); + Assert.Equal( + 0, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + AssertConverged(lifetime); + + drive.DetachRoute(route); + } + + /// + /// The classifier's StopInterpolating flag + /// (:467, !nearby) is what gates the queue clear — the + /// retail condition is READ from the route, never restated. A route + /// carrying must leave the queue alone. + /// + [Fact] + public void FarSnap_HonoursTheRoutesOwnStopInterpolatingFlag() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003021u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + remote.Interp.Enqueue( + new Vector3(40f, 40f, SpawnHeight), + Quaternion.Identity, + isMovingTo: false, + currentBodyPosition: body.Position, + currentBodyOrientation: body.Orientation); + + RuntimeRemotePlacementDriveController drive = + CreateDrive(lifetime, new FakeServiceWindow()); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationCell, + stopInterpolating: false); + + _ = drive.ApplyAcceptedRemoteFarSnap(record, remote, route); + + Assert.True(remote.Interp.IsActive); + } + + [Fact] + public void FarSnap_CommitsThroughTheCanonicalPlacementOwner() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003022u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + DestinationCell, + destination, + stopInterpolating: true); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Committed, + drive.ApplyAcceptedRemoteFarSnap(record, remote, route)); + + // RemoteMotion shares the canonical body, so the placement's own + // committed pose IS the remote's body pose — no second write. + Assert.Same(body, remote.Body); + Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position); + Assert.Equal(DestinationCell, record.FullCellId); + DrainPlacementFifo(lifetime); + Assert.Equal( + 0, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + AssertConverged(lifetime); + } + + /// + /// The far arm must never be handed a route it does not own — the caller + /// selects with RuntimeRemoteFarSnapPosition.ResolveArm. Failing + /// loudly is the alternative to silently clearing an interpolation queue + /// for a classification (cell-less, rejected, near) that has no business + /// stopping it. + /// + [Fact] + public void FarSnap_ThrowsForARouteThisArmDoesNotOwn() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003023u); + AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + RuntimeRemotePlacementDriveController drive = + CreateDrive(lifetime, new FakeServiceWindow()); + + foreach (RuntimeAuthoritativePositionDisposition disposition in + new[] + { + RuntimeAuthoritativePositionDisposition.SetPosition, + RuntimeAuthoritativePositionDisposition.Interpolate, + RuntimeAuthoritativePositionDisposition.NoPositionOperation, + RuntimeAuthoritativePositionDisposition.RejectedData, + }) + { + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, disposition, DestinationCell, stopInterpolating: true); + Assert.Throws( + () => drive.ApplyAcceptedRemoteFarSnap(record, remote, route)); + } + } + // ── Fixture ────────────────────────────────────────────────────────── /// @@ -867,7 +1976,8 @@ public sealed class RuntimeRemotePlacementDriveControllerTests Vector3? destinationPosition = null, RuntimeSetPositionOperationKind operationKind = RuntimeSetPositionOperationKind.RemoteAuthoritative, - PhysicsSetPositionFlags? setPositionFlags = null) + PhysicsSetPositionFlags? setPositionFlags = null, + bool stopInterpolating = false) { // Publishes the destination into the record's accepted Snapshot — // exactly what the upstream merge @@ -915,7 +2025,7 @@ public sealed class RuntimeRemotePlacementDriveControllerTests ApplyPlacementFrameBeforeRouting: false, LeaveWorld: false, TeleportHookPhase: RuntimeTeleportHookPhase.None, - StopInterpolating: false, + stopInterpolating, ConstrainPhase: RuntimePositionConstrainPhase.AfterPositionOperation, PreserveHeading: false, ZeroVelocity: false, @@ -926,10 +2036,11 @@ public sealed class RuntimeRemotePlacementDriveControllerTests private static RuntimeEntityRecord CreateRemoteRecord( RuntimeEntityObjectLifetime lifetime, uint guid, - uint? setupTableId = null) + uint? setupTableId = null, + ushort instanceSequence = 0) { RuntimeEntityRecord record = lifetime.RegisterEntity( - Spawn(guid, setupTableId)).Canonical!; + Spawn(guid, setupTableId, instanceSequence)).Canonical!; lifetime.Entities.SetFinalPhysicsState(record, PhysicsStateFlags.Gravity); return record; } @@ -956,7 +2067,10 @@ public sealed class RuntimeRemotePlacementDriveControllerTests return body; } - private static WorldSession.EntitySpawn Spawn(uint guid, uint? setupTableId) => + private static WorldSession.EntitySpawn Spawn( + uint guid, + uint? setupTableId, + ushort instanceSequence = 0) => new( guid, new CreateObject.ServerPosition( @@ -970,7 +2084,8 @@ public sealed class RuntimeRemotePlacementDriveControllerTests Name: "remote", ItemType: null, MotionState: null, - MotionTableId: 0x09000001u); + MotionTableId: 0x09000001u, + InstanceSequence: instanceSequence); private static PhysicsEngine FlatEngine() { @@ -998,7 +2113,8 @@ public sealed class RuntimeRemotePlacementDriveControllerTests /// private static void CommitLandblockCollision( RuntimeEntityObjectLifetime lifetime, - uint landblockId) + uint landblockId, + float worldOffsetX = 192f) { var heights = new byte[81]; Array.Fill(heights, (byte)SpawnHeight); @@ -1013,7 +2129,7 @@ public sealed class RuntimeRemotePlacementDriveControllerTests new TerrainSurface(heights, heightTable), Array.Empty(), Array.Empty(), - worldOffsetX: 192f, + worldOffsetX, worldOffsetY: 0f); lifetime.Physics.SetPosition.CommitCollisionGeneration( landblockId, 1UL, ready: true);