From 3aab05b0ccde313d13a08489477fd6c3f87e2b68 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 5 Aug 2026 23:54:59 +0200 Subject: [PATCH] fix(streaming): derive the portal reveal window from the live streaming radii (#280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user watched far terrain visibly assemble after portal space exits. The reveal gate was NOT missing a hold — Slice E's hold mechanism is correct and already in place. The hold was measuring the wrong domain: it opened at a hardcoded 3x3 landblock neighbourhood (~192 m) while the visible world extends to the fog end (~2,189 m at the shipped High preset, inside a 2,304 m Far window). An 11.4:1 ratio. Retail's equivalent ratio is 1:1 BY CONSTRUCTION. `LScape` owns one `mid_width x mid_width` array of `CLandBlock*` (`LScape::SetMidRadius` @0x00504C00, `LScape::update_block` @0x005063A0), `mid_radius` is assigned directly from the user's `Render.LandscapeDrawDistance` preference (`SmartBox::SetRegion` @0x004531F0; values `Render_LandscapeDrawDistance_Values` @0x007CA988 = {3,5,8,11,15,25}, default 8 — both byte-verified against the PDB-paired 2013 binary), and that same square is simultaneously the prefetched set (`LScape::PreFetchCells` @0x00505660), the drawn set (`block_draw_list` over the same array), and the set the simulation blocks on (`CellManager::blocking_for_cells`). There is no retail configuration in which the client streams farther than it gates, because there is only one number. So the fix derives rather than duplicates. Four coupled parts, which is why this is one commit and not four — D1 without D2 hangs the client and D2 without D1 is dead code: D1 `WorldRevealReadinessBarrier` takes a live `Func` and stops being static: outdoor requires `FarRadius`, indoor still 0 (retail's `CEnvCell::PreFetchCells` @0x0052D1E0 arm). Read per evaluation, never captured — the radii are runtime mutable through Settings, and retail's answer to a mid-hold radius change is to reset, re-radius, and re-arm the blocking prefetch at the NEW value (`SmartBox::set_mid_radius` @0x00453180). `OutdoorNeighborhoodRadius` is deleted; there is no constant left to drift. D2 `StreamingController.IsRenderNeighborhoodResident` becomes tiered, because acdream's loaded landscape is: inside `NearRadius`, `IsNearTier && IsRenderReady`; out to `FarRadius`, `IsRenderReady` only. Without this the fix cannot work at all — nothing outside the Near ring is ever promoted, so any radius above `NearRadius` was unsatisfiable and would have held the reveal forever. Proof obligation P1 (a Far-tier landblock genuinely satisfies `IsRenderReady`) is now a test driven through the real `PublicationKind.Far` pipeline against a real `LandblockSpawnAdapter`, not an inference. D7 `RuntimeWorldTransitState.AcknowledgeDestinationReadiness` re-derived `indoor ? 0 : 1` and failed `invalid-readiness-shape` on any other value, so changing the radius alone would have looked like "the fix hangs the client". It is now a SHAPE invariant (`indoor => 0`, `outdoor => >= 1`). Runtime does not own the graphical host's streaming configuration and must not learn it; plumbing App radii into Runtime to preserve the strict equality is exactly the assert-a-mechanism-that-does- not-exist failure C5b was built to stop. Both non-graphical producers keep emitting their centre-ring token and stay legal, annotated in place. D6 `PhysicsEngine.IsNeighborhoodTerrainResident` rebuilt a full-map `HashSet` on every call, every frame of every hold. At radius 1 that was invisible; at radius 12 (625 ring members) it violates Slice I1's 0 B/resolve standard. Now an engine-owned scratch set, cleared in place; measured at 0 bytes over 1,000 warmed radius-12 queries. Also: the destination reservation opens at exactly the gate's radius and reopens on the same generation when the radius changes mid-hold (retail has one square for both, and no concept of prioritising an inner ring differently). Composite warmup deliberately stays `NearRadius`-scoped — the composite domain is entity-scoped and Far builds carry no entities, so widening it would walk the outer window to warm nothing. `ACDREAM_PROBE_REVEAL_RADIUS` is a measurement probe in a diagnostic owner (CLAUDE.md rule 5) so the connected route can be run A/B on one binary; it is NOT a user-facing prefetch knob, since a low setting would reintroduce the decoupling this slice exists to close. Register: AD-2 amended with the derived window, the two-tier split, and the four new retail anchors. AP-149 FILED for the residual this does not close — the outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell, so a distant building can still pop in at Far-ring distances. Do not let a later closeout claim parity. Docs: `ACDREAM_STREAM_RADIUS`'s CLAUDE.md description was wrong on every clause (the default is unset, not 2; it forces `NearRadius`; it is silently discarded by any Settings save) — corrected, since that is the file every session reads. `reference_two_tier_streaming.md` corrected in four ways, including "Far tier = terrain only": Far also publishes terrain COLLISION, which is precisely what makes this fix viable. #280's issue text had the right conclusion from a wrong premise (it names a view-distance setting acdream does not have) — corrected, and the missing Viewing Distance option filed separately as #326, with #327 (DDD progress readout) and #328 (hardcoded 5000 f far plane vs retail's byte-verified 4000) filed alongside. Expect LONGER holds and the "In Portal Space - Please Wait..." cue on recalls MORE often. That is convergence toward retail, not away from it: retail emits the byte-identical string for the whole duration of a blocked prefetch and polls at 5 s intervals. The failure condition is non-convergence, not duration. Gates: Release build 0 errors. Complete suite 11,178 passed / 4 skipped / 0 failed, against a re-measured 11,142 / 4 / 0 baseline at 9ee9c1a1 — +36, reconciled exactly as 36 new tests (App +23, Runtime +10, Core +3), zero deleted, zero newly skipped. Nine discriminating tests sabotage-verified in both directions. The connected/visual gate is batched into C5's matrix; its recipe, its three positive artifacts, and its required recall leg are written into the campaign plan. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 19 +- docs/ISSUES.md | 65 +- .../retail-divergence-register.md | 5 +- docs/plans/2026-08-02-placement-cutover.md | 29 + docs/research/2026-08-05-280-contract.md | 1205 +++++++++++++++++ .../Composition/SessionPlayerComposition.cs | 9 + .../Streaming/StreamingController.cs | 48 +- .../Streaming/StreamingDiagnostics.cs | 56 + .../Streaming/WorldRevealCoordinator.cs | 51 +- .../Streaming/WorldRevealReadinessBarrier.cs | 108 +- src/AcDream.Core/Physics/PhysicsEngine.cs | 18 +- .../Hosting/HeadlessSessionWorldProjection.cs | 6 + .../RuntimeLiveEntitySessionController.cs | 6 + .../World/RuntimeWorldTransitState.cs | 22 +- .../Runtime/CurrentGameRuntimeAdapterTests.cs | 3 +- .../LocalPlayerTeleportControllerTests.cs | 13 +- .../StreamingControllerReadinessTests.cs | 238 +++- .../Streaming/WorldRevealCoordinatorTests.cs | 107 +- ...orldRevealDerivedWindowIntegrationTests.cs | 240 ++++ .../WorldRevealReadinessBarrierTests.cs | 151 ++- .../LiveEntityWorldOriginCoordinatorTests.cs | 3 +- .../NeighborhoodTerrainResidencyTests.cs | 95 ++ .../World/RuntimeWorldTransitStateTests.cs | 91 ++ 23 files changed, 2502 insertions(+), 86 deletions(-) create mode 100644 docs/research/2026-08-05-280-contract.md create mode 100644 src/AcDream.App/Streaming/StreamingDiagnostics.cs create mode 100644 tests/AcDream.App.Tests/Streaming/WorldRevealDerivedWindowIntegrationTests.cs create mode 100644 tests/AcDream.Core.Tests/Physics/NeighborhoodTerrainResidencyTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index c5c4d334..9d796086 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1422,8 +1422,23 @@ via `PlayerMovementController.ApplyServerRunRate`) or from - `ACDREAM_DUMP_MOTION=1` — dump every inbound `UpdateMotion` (guid, stance, cmd, speed) + resulting `SetCycle` call. Massive for remote- animation debugging. -- `ACDREAM_STREAM_RADIUS=N` — tune landblock visible-window radius - (default 2 = 5×5). +- `ACDREAM_STREAM_RADIUS=N` — **legacy** streaming-radius override + (`RuntimeOptions.LegacyStreamRadius`). **Default is UNSET**, not 2: the + shipped radii come from the quality preset + (`QualityPreset.High` = NearRadius 4 / FarRadius 12, i.e. a 9×9 Near ring + inside a 25×25 Far window). When set it FORCES `NearRadius = N` and only + ever RAISES `FarRadius` (`SessionPlayerComposition.ComposeCore`), and it is + silently discarded by any later Settings `ApplyQuality` + (`RuntimeSettingsTargets.ApplyQuality` → `ReconfigureRadii`). **Leave it + unset for any measurement or gate run** — with it set you are measuring a + different window than production. Per-axis overrides + `ACDREAM_NEAR_RADIUS` / `ACDREAM_FAR_RADIUS` (`QualitySettings.WithEnvOverrides`) + are the modern spelling. +- `ACDREAM_PROBE_REVEAL_RADIUS=N` — #280 A/B measurement probe + (`StreamingDiagnostics.RevealRadiusOverride`). Forces the outdoor reveal + gate to landblock radius N instead of the derived streaming window, so the + same binary can run a route once with the pre-#280 behaviour (`=1`) and once + without. Not a user setting; not surfaced in Settings; not persisted. - `ACDREAM_NO_AUDIO=1` — suppress OpenAL init for headless / driver- broken setups. - `ACDREAM_REMOTE_VEL_DIAG=1` — dump per-tick / per-UM remote motion diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 964dbd29..9a0e1e69 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -1684,14 +1684,63 @@ it. Do #297 FIRST — #298 depends on it. entity cell. Spell buffs, recalls, arrows, and combat spell projectiles were verified in the connected client; focused effect, projectile, and cell-transition tests cover the race. Landed at `f24532ad`. -- **#280 — OPEN — portal reveal can expose an incompletely streamed distant - landscape.** User-observed 2026-08-03: after some recalls, the nearby - destination is playable but terrain near the far end of the view continues - visibly building after portal space exits. The current outdoor reveal gate - is explicitly only `WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius = - 1` (a 3x3 landblock neighborhood), while the normal configured view extends - substantially farther; this permits the world viewport to open before its - visible static destination is complete. +- **#280 — FIXED (2026-08-05), pending the batched C5c connected/visual gate — + portal reveal could expose an incompletely streamed distant landscape.** + User-observed 2026-08-03: after some recalls, the nearby destination is + playable but terrain near the far end of the view continues visibly building + after portal space exits. **Premise correction (the original text named a + setting that does not exist):** acdream has no "configured view distance" — + `grep -rniE "viewdistance|view_distance|LandscapeDrawDistance|DrawDistance"` + over `src/` returns nothing. The correct premise is the configured + *streaming/fog* window, `QualitySettings.FarRadius`: at the shipped `High` + preset the user sees terrain out to the fog end + (`FarRadius * 192 m * 0.95` ≈ 2,189 m) inside a 2,304 m Far window, while the + outdoor reveal gate opened at a hardcoded radius-1 3×3 neighbourhood + (≈192 m) — an 11.4:1 ratio where retail's is 1:1 by construction, because + retail's prefetched, loaded and drawn squares are literally the same array + (`LScape::mid_radius`; `LScape::PreFetchCells` @0x00505660). The conclusion + in the original text was right; the premise was not. + **Fix correction:** raising the constant alone could not work, twice over. + `StreamingController.IsRenderNeighborhoodResident` demanded `IsNearTier` for + every ring member, so any radius above `NearRadius` was unsatisfiable and + would have held the reveal forever; and + `RuntimeWorldTransitState.AcknowledgeDestinationReadiness` re-derived and + asserted `indoor ? 0 : 1`, so a changed radius failed + `invalid-readiness-shape` and the reveal never opened. The landed change is a + radius derivation **and** a tier-aware predicate **and** an invariant + loosening, plus the forced allocation fix in + `PhysicsEngine.IsNeighborhoodTerrainResident`. Contract: + `docs/research/2026-08-05-280-contract.md`. Residual filed as register row + AP-149 (the outer ring accepts terrain-only publication where retail requires + LandBlockInfo and every building EnvCell). +- **#326 — OPEN — acdream has no Viewing Distance option.** Retail exposes one + user-facing landscape-extent preference, + `Render.LandscapeDrawDistance` — a six-position enum + (`Render_LandscapeDrawDistance_Values` @0x007CA988 = 3/5/8/11/15/25, labels + VeryLow/…/Extreme, **default 8**, both byte-verified), registered at + `UserPreferences::RegisterPreference` @0x0054ECBE and pushed into + `SmartBox::set_mid_radius` @0x004531D0. acdream's structural analogue is the + quality preset's `NearRadius`/`FarRadius` pair, which is not separately + user-controllable. Split out of #280 deliberately (§3/§14 of that contract): + #280 derives its reveal window from whatever feeds the streaming radii, so + this feature lands by changing what feeds them and #280's derivation keeps + working untouched. +- **#327 — OPEN — acdream has no analogue of retail's DDD prefetch progress + readout.** While `CellManager::blocking_for_cells` is latched, retail reports + `ECM_DDD::SendNotice_RuntimeDDDStatus(active, remaining, total)` @0x00692870 + into `gmPowerbarUI::RecvNotice_RuntimeDDDStatus` @0x004DA5C0, which drives a + powerbar progress bar with an "N of M" cell count (string id + `ID_Powerbar_DDDModeText`). acdream shows only the centered + "In Portal Space - Please Wait..." cue. #280 makes reveal holds longer and + more frequent, which makes the missing readout more noticeable. +- **#328 — OPEN — the camera far plane is a hardcoded 5000 f in four camera + classes.** `RetailChaseCamera.cs`, `ChaseCamera.cs`, `FlyCamera.cs`, + `OrbitCamera.cs` each hardcode it with no config path. Retail's + `Render::zfar` is statically initialised to **4000.0** (byte-verified at + `0x0081EC88`), and the only writers are `GameSky::Draw` @0x00507055 / + @0x005070EE, which temporarily multiply by 4 for the skybox and restore. + Independent of #280 — in both clients the landscape horizon is the landblock + window, not the frustum — but it is an uncited divergence. - **#281 — DONE (2026-08-03) — the stabilization commits left the automated suites red, and the world-frame contract they introduced had no coverage.** The 2026-08-03 handoff recorded "six selected fixture failures". A measured diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index ef494fe3..daf80d25 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -116,7 +116,7 @@ readiness/requeue adaptation. See | AD-52 | **Filed at Campaign N slice N6 (2026-07-29).** The inbound fragment assembler evicts incomplete partial messages 60 s after their last ACCEPTED fragment (swept on retail's 5 s flush cadence from `ReliableTransport.Sweep`) and remembers the last 64 completed multi-fragment sequences in a ring so a late duplicate fragment of an already-completed message drops instead of allocating a fresh partial that can never complete. Retail's prune target and horizon differ: its 5 s-TTL `FlushTimedOutEphInfo` table holds ephemeral-blob ORDERING stamps (the AD-49 deferral), not partial payloads. | `src/AcDream.Core.Net/Packets/FragmentAssembler.cs` (`SweepExpired`, `PartialTtlSeconds`, `CompletedRingSize`); cadence in `src/AcDream.Core.Net/Transport/ReliableTransport.cs` (`AssemblerSweepSeconds`) | N4's RejectRetransmit abandonment made an unrecoverable partial a REACHABLE permanent state: ACE pruned a fragment-bearing packet from its 120 s S2C cache and told us to stop asking, so that blob can never complete — without a TTL it leaks for the session's lifetime. 60 s is ≫ every recovery horizon (0.6 s NAK cadence, ACE's 2 s ack, the 120 s cache) and the stamp refreshes on every accepted fragment (retail's own re-stamp rule, `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00`), so only a server-abandoned partial can age out — a merely-slow one cannot. The ring is bounded (64 × 4 B) and its only false negative (a duplicate arriving after 64 later completions) degrades to the pre-N6 behavior, now reclaimed by the TTL. | If ACE ever legitimately re-served a fragment of a completed message under a REUSED fragment sequence within the ring window, it would be dropped — but fragment sequences are strictly monotonic per session (ACE SessionConnectionData.FragmentSequence), so reuse cannot happen inside one connection. An evicted partial whose fragments later straggle in re-partials and re-evicts — bounded churn, no corruption. | `Indicator::FlushTimedOutEphInfo @ 0x0054A3D0` (the 5.0 s flush gate at 0x0054A3DC); `ArrivedEphInfo::fTimedOut @ 0x0054AE30` (per-entry 5.0 s TTL); `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00` (re-stamp on update); retail has no partial-payload TTL — its blob layer trusts its own NAK persistence, which N4's ACE-mandated abandonment (`SharedNet::HandleEmptyAck @ 0x005448F0`) breaks | | AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` | | ~~AD-1~~ | **RETIRED 2026-08-05 (C5a deletion sweep).** The legacy recoverable outdoor demote (`Resolve`'s indoor-claim safety net) and the outdoor-restore `max(terrainZ, z)` lift this row described were `PhysicsEngine.Resolve`'s own body — deleted outright with the exhaustive C5a caller census proving zero production callers (every production placement writer reaches canonical `PhysicsEngine.SetPosition` only through `RuntimeSetPositionState`). The divergent mechanism is unreachable from production because it no longer exists. | `src/AcDream.Core/Physics/PhysicsEngine.cs` (deletion); `docs/research/2026-08-05-c5a-contract.md` | — | — | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945; `CPhysicsObj::handle_all_collisions` 0x00514780 | -| AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the required Near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. **C4 route 3 refinement (2026-08-04):** retail places the local player IMMEDIATELY on the accepted destination Position (`SmartBox::TeleportPlayer` @0x00453910) and blocks SIMULATION on DAT prefetch (`CellManager::blocking_for_cells`; `SmartBox::UseTime` @0x00455410 runs only `CheckPrefetchStatus`) behind the portal viewport; acdream defers the PLACEMENT itself to this reveal-ready Place edge, executed by the canonical `RuntimeAcceptedPositionDriveController` portal arm (`TryExecuteAcceptedPortalArrival`). Two load-bearing notes from that route: (1) every accepted local Apply — including the portal destination Position itself — still writes the raw wire pose onto the local player's `WorldEntity` via the ordinary generic-remote-render-pose path while portal space covers the viewport (`LiveEntityNetworkUpdateController.cs`, `OwnsSteadyState` false for the local player's null route); the committed Place receipt's presentation suffix overwrites it with the resolved pose — tolerated, not suppressed, since suppressing it would be an unowned behaviour change on the ordinary local Apply path (AP-131/#275 territory). (2) The constraint-leash re-arm on a committed portal placement anchors at the RESOLVED post-placement body position (`PlayerMovementController.CommitCanonicalTeleportFrame` → `RearmConstraintLeashAtCurrentPosition`), where retail's `ConstrainTo` @0x0045418A anchors at the received WIRE destination; the two differ by at most the placement adjustment (ring search/floor snap) and the anchor is write-only downstream, so the delta is not user-observable — switching to the wire-destination anchor is a deliberately deferred decision, not adopted here. **B4 round-3 review refinement (2026-08-05):** the wait cue's trigger predicate (`LocalPlayerTeleportController.Tick`'s `placementReady = dataReady && TryAdvancePortalCommit(sequence)`, gating the cue at `haveDestination && !placementReady`) now covers a SECOND, distinct cause beyond the original streaming/DAT-readiness gate this row described: `TryAdvancePortalCommit` returning false while a DeferredCell park is outstanding or a fresh placement attempt has not yet succeeded (B1's `TryConsumePortalCommit` gate). The cue's five-second trigger and centered-tunnel behavior are unchanged; only the SET of conditions that can hold it open grew from "world data not ready" to "world data not ready OR canonical placement not yet committed" — a slow-publishing destination-landblock collision generation now presents identically to a slow asset stream, which is the correct retail-faithful degradation (both are `blocking_for_cells` causes retail itself does not distinguish), but is worth naming here since a future debugging session seeing the cue must not assume streaming is the only possible cause. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 | +| AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency across the DERIVED reveal window. **#280 amendment (2026-08-05):** that outdoor window is no longer a hardcoded radius-1 neighbourhood. Retail has exactly ONE landscape square — `LScape::mid_radius`, assigned directly from the `Render.LandscapeDrawDistance` preference (`SmartBox::SetRegion` @0x004531F0; values `Render_LandscapeDrawDistance_Values` @0x007CA988 = {3,5,8,11,15,25}, default 8, byte-verified) — and that same square is simultaneously the loaded set, the drawn set, and the set `LScape::PreFetchCells` @0x00505660 blocks on, so retail structurally cannot stream farther than it gates. acdream now DERIVES the outdoor radius from the live streaming window (`QualitySettings.FarRadius`, read per evaluation from `StreamingController` so a mid-hold Settings change re-arms the gate the way `SmartBox::set_mid_radius` @0x00453180 does), and the render-completeness predicate is TIER-AWARE to match acdream's two-tier landscape: inside `NearRadius`, full Near publication (`IsNearTier && IsRenderReady`); out to `FarRadius`, terrain publication only (`IsRenderReady`, which a `PublicationKind.Far` landblock satisfies through its empty spawn-adapter registration). Composite-texture warmup stays `NearRadius`-scoped because it is entity-scoped and Far builds carry no entities. The destination reservation opens at exactly the gate's radius, since retail has one square for both. Runtime's readiness invariant is correspondingly a SHAPE check (`indoor ⇒ 0`, `outdoor ⇒ ≥1`), never a re-encoded value — Runtime does not own the graphical host's streaming configuration. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. **C4 route 3 refinement (2026-08-04):** retail places the local player IMMEDIATELY on the accepted destination Position (`SmartBox::TeleportPlayer` @0x00453910) and blocks SIMULATION on DAT prefetch (`CellManager::blocking_for_cells`; `SmartBox::UseTime` @0x00455410 runs only `CheckPrefetchStatus`) behind the portal viewport; acdream defers the PLACEMENT itself to this reveal-ready Place edge, executed by the canonical `RuntimeAcceptedPositionDriveController` portal arm (`TryExecuteAcceptedPortalArrival`). Two load-bearing notes from that route: (1) every accepted local Apply — including the portal destination Position itself — still writes the raw wire pose onto the local player's `WorldEntity` via the ordinary generic-remote-render-pose path while portal space covers the viewport (`LiveEntityNetworkUpdateController.cs`, `OwnsSteadyState` false for the local player's null route); the committed Place receipt's presentation suffix overwrites it with the resolved pose — tolerated, not suppressed, since suppressing it would be an unowned behaviour change on the ordinary local Apply path (AP-131/#275 territory). (2) The constraint-leash re-arm on a committed portal placement anchors at the RESOLVED post-placement body position (`PlayerMovementController.CommitCanonicalTeleportFrame` → `RearmConstraintLeashAtCurrentPosition`), where retail's `ConstrainTo` @0x0045418A anchors at the received WIRE destination; the two differ by at most the placement adjustment (ring search/floor snap) and the anchor is write-only downstream, so the delta is not user-observable — switching to the wire-destination anchor is a deliberately deferred decision, not adopted here. **B4 round-3 review refinement (2026-08-05):** the wait cue's trigger predicate (`LocalPlayerTeleportController.Tick`'s `placementReady = dataReady && TryAdvancePortalCommit(sequence)`, gating the cue at `haveDestination && !placementReady`) now covers a SECOND, distinct cause beyond the original streaming/DAT-readiness gate this row described: `TryAdvancePortalCommit` returning false while a DeferredCell park is outstanding or a fresh placement attempt has not yet succeeded (B1's `TryConsumePortalCommit` gate). The cue's five-second trigger and centered-tunnel behavior are unchanged; only the SET of conditions that can hold it open grew from "world data not ready" to "world data not ready OR canonical placement not yet committed" — a slow-publishing destination-landblock collision generation now presents identically to a slow asset stream, which is the correct retail-faithful degradation (both are `blocking_for_cells` causes retail itself does not distinguish), but is worth naming here since a future debugging session seeing the cue must not assume streaming is the only possible cause. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0; `LScape::PreFetchCells` 0x00505660; `LScape::SetMidRadius` 0x00504C00; `SmartBox::set_mid_radius` 0x00453180; `Render_LandscapeDrawDistance_Values` 0x007CA988 | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | | ~~AD-6~~ | **RETIRED 2026-07-31 (placement/streaming Slice 3B).** Cell/cache/topology/building/static-shadow publication plus every retained non-suspended owner touching or withdrawn from the prefix is one Runtime-owned collision generation. Retained includes dynamics and adjacent-root statics; only target-root statics are superseded by the authored replacement. App and Headless build one shared off-side `CollisionWorldState` through one-work-unit preparation/capture/seal cursors. Admission captures the active root in O(1); a stable landblock/owner slot suffix materializes non-target leaves incrementally, so resident-world size cannot become a synchronous clone spike. Reusable per-prefix owner slots and one Runtime-scoped versioned journal replace event-time exact-copy fanout: repeated live mutations coalesce by owner, every draft reconciles only that owner's latest exact state one owner per seal call, discovered relevant owners receive scoped exact updates, and visited unrelated owners receive only a cheap coalesced dirty notification before metered replay. Once topology sealing finishes, observed owners temporarily write through exactly until same-call activation; the finite pre-seal queue therefore drains even under continuous multi-owner movement. New drafts start at their captured journal suffix; old slots are superseded rather than reused behind live cursors and compact through the same meter. Unrelated churn therefore never restarts or starves target capture/sealing. Deterministically ordered concurrent preparations receive committed—not merely sealed—peer deltas and rebase one cache, graph, landblock, or owner leaf per seal step; cancellation therefore cannot leak unpublished topology. Demotion/withdrawal cancels a matching queued or active rebase, suppresses the prefix in unfinished source scans, and retires one owner/cache/graph/outdoor leaf per seal call. The complete previous generation remains queryable until one zero-managed-byte volatile root transfer in the same update-thread call as final reconciliation; that preserves PhysicsDataCache, CellGraph, PhysicsEngine, and ShadowObjectRegistry facade identity, revokes staging, and requires no quiet frame. A stale admission or staging failure disposes only that private generation and cannot withdraw the active world or invalidate a newer admission. Authored same-ID target statics, live-current-cell changes, owner departure/reuse, newly relevant seam-crossing statics, and teardown remain coherent across drafts; empty per-prefix owner containers are reclaimed without invalidating captured seal cursors. The commit clears repaired withdrawal markers before its single notification/readiness acknowledgement, so no optional hydration callback can omit reflood and no observer sees mixed old/new cells. | `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs` (`PrepareCollisionGeneration`, `AdvanceCollisionGenerationPreparation`, `AdvanceCollisionGenerationSeal`, `CommitCollisionGeneration`); `src/AcDream.Core/Physics/CollisionWorldState.cs`; `PhysicsDataCache.cs`; `PhysicsEngine.cs`; `ShadowObjectRegistry.cs`; `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs`; `tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs`; `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs` | — | — | `CObjCell::init_objects` → `CPhysicsObj::recalc_cross_cells`, 0x0052b420 / 0x00515a30; `CPhysicsObj::SetPositionInternal` shadow replacement tail 0x00515330 | | AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | **2026-08-04: file:line corrected** — the mechanism now lives in `src/AcDream.Core/Physics/RemoteMotionCombiner.cs` (`ComposeOffset` ~:65-72 for the interpolation-active boundary projection, the queue-empty fallback ~:163-168); the row's meaning is unchanged, the class was renamed/moved from the stale `PositionManager.cs:47` citation (see the class's own doc comment: "Renamed R5 (was PositionManager)") | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping; it also cannot see building/EnvCell geometry at all (terrain-only sample), so a remote landing on a house roof gets no slope response from this path regardless of `OnWalkable` — a contributing factor in the 2026-08-04 Bug B roof-plant observation (see `docs/ISSUES.md` #32) | `CTransition::adjust_offset` pc:272296-272346 | @@ -160,7 +160,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 102 active rows (AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; 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, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — 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-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) — 103 active rows (AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; 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, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — 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-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 @@ -175,6 +175,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-146 | **Filed 2026-08-05 (#319 fix, the local player's canonical cell prerequisite; follow-up filed as issue #320).** Retail writes the local player's cell on EVERY physics tick (`CPhysicsObj::SetPositionInternal` @0x00515330, unconditional for any moving body including the player). acdream's canonical `FullCellId` for the LOCAL player is written only at three edges: login activation (`RuntimeSetPositionState.cs:2741-2745`), the `OnPosition` generic tail's prologue rebucket after an accepted inbound Position (`LiveEntityNetworkUpdateController` → `LiveEntityRuntime.RebucketLiveEntity` → `RuntimeEntityObjectLifetime.CommitRebucket`; **amended 2026-08-05 by C5b/#275** — this writer was `RuntimeEntityDirectory.RefreshSnapshot` → `RuntimeEntityRecord.cs:234`, i.e. the merge itself, until C5b made the merge withhold the wire cell per AD-60; a ForcePosition, which returns before this tail, is now placement-receipt-authoritative instead), and a teleport/portal placement commit (`RuntimeSetPositionState.cs:5001-5007`; `LocalPlayerTeleportController.cs:255`). Ordinary WASD movement passes a LANDBLOCK id, not an exact cell (`LocalPlayerProjectionController.Project`, low 16 bits forced to `0xFFFF` in both branches), and `LiveEntityRuntime.cs:935-938` explicitly PRESERVES the prior canonical cell for that shape rather than writing the coarser value — so the local player's canonical cell is coarse and mostly-frozen between teleports, never per-crossing-fresh. #319's fix makes a player-parented equipped child inherit exactly this same value (D1/D2 propagate the PARENT's canonical cell to the child verbatim) — the child is stale-but-EQUAL wherever the player's own record already is, not a new staleness class. **AMENDED 2026-08-05 at the C5b architecture review's D1 fix: this three-edge enumeration was written from the graphical host and silently assumed both hosts shared it.** They do not — the two run parallel, non-shared inbound routes — and the second edge (the `OnPosition` prologue rebucket) lived in `AcDream.App`, so the no-window host had only TWO of the three, the login activation and the teleport/portal commit. It now has all three: `RuntimeLiveEntitySessionController.TryCommitAcceptedWireCell` commits the same value through the same shared owner, `RuntimeEntityObjectLifetime.CommitWireCellRebucket`. The no-window host reaches that edge on the local ordinary (`Apply`) Position and on a `ForcePosition` the accepted-Position drive declined (`NotApplicable`), mirroring the graphical route exactly — a force the drive HANDLED stays placement-receipt-authoritative. This row's COARSENESS claim is unchanged and applies identically to both hosts: the preserve branch now lives in `CommitWireCellRebucket` rather than at `LiveEntityRuntime.cs:935-938`, and the no-window host does not even have the per-frame landblock-shaped caller that motivates it. | `src/AcDream.App/Input/LocalPlayerProjectionController.cs` (`Project`); `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (`CommitWireCellRebucket` — the landblock-preserve branch, moved here verbatim from `LiveEntityRuntime.cs:935-938` at the D1 fix so both hosts share one rule); `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` (`TryCommitAcceptedWireCell` — the no-window host's inbound-Position edge); `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (activation `:2741-2745`, teleport commit `:5001-5007`) | Making the local player's canonical cell track ordinary movement exactly (an exact-cell rebucket rather than the landblock-only one) is a LARGER slice than #319's key fix alone — it touches the landblock-preserve contract, `Rebucketed` delta publication cadence (today the player never publishes one during WASD), the route-2/4b-3 `PreMergeCommittedCellId` classification inputs AP-136/AP-138 spent four review rounds pinning, and the portal-space frozen-source-cell race (`LocalPlayerProjectionController.Project:100-103`). Deliberately NOT bundled into #319; filed as its own follow-up, issue #320. | The player's own render/liveness/radar/picking paths already tolerate this staleness today (proven: the player renders correctly everywhere via `Source.ParentCellId`-driven visibility, not `FullCellId`) — verified safe for the EXISTING consumer set. UNRESOLVED (this row's own open item, carried into #320): whether `RuntimeSetPositionState.IsAffectedCollisionResident`'s `ParkCollisionResidents` sweep could retire a spatial-root local player on a stale cell after a long teleport-free WASD run beyond the streaming radius — not established either way; the connected routes exercised so far all teleport between stops, which refreshes the cell and may be masking it. If the player IS a spatial root and this is reachable, the same staleness this row accepts for render/child-inheritance would ALSO apply to collision retirement, which is a materially different risk class. **The D1 fix narrows that open item's URGENCY without answering it**: before the fix a no-window bot was strictly worse than the graphical client here, because it lacked the inbound-Position edge entirely — a bot running A→B without teleporting kept `FullCellId` at A for the whole session, so retiring A parked a body physically in B, and retiring B missed it. Both hosts now refresh on every accepted Position; what remains open is the same question this row always asked, at ACE's 5-10 Hz cadence rather than never. | `CPhysicsObj::SetPositionInternal` 0x00515330 (unconditional per-tick cell write) | | AP-147 | **Filed 2026-08-05 at the C5b architecture review (finding D3) — an unfiled delta-stream cardinality change C5b introduced, which its own conservation test could not see.** A cell-changing accepted steady-state Position now publishes **two** `RuntimeEntityDelta`s for the moved entity where it published one, and the intermediate one carries a torn cell/position pair. Pre-C5b the merge itself moved `FullCellId`, so it published `Rebucketed` and the `OnPosition` prologue rebucket's `CommitRebucket` then early-returned publish-less (`previous == fullCellId`) — stream `[Rebucketed]`. Post-C5b the merge moves nothing, so it publishes `Updated` and `CommitRebucket` publishes the `Rebucketed` — stream `[Updated, Rebucketed]`. The `Updated` element is assembled from the canonical record BETWEEN the two writes, so its `CellId` is the OLD (committed) cell while its `Position` is the NEW wire pose: a pair that did not previously exist on this stream, because pre-C5b both halves moved inside one publish. Total per packet is conserved in KIND and final VALUE — exactly one `Rebucketed`, at the same cell, from the same publisher — but not in COUNT, and not in intermediate consistency. **AMENDED 2026-08-05 at the C5b closeout (bookkeeping only — nothing in this row was false, it was un-updated).** This row was written from the graphical host at a moment when it was the only host producing the two-delta stream at all: pre-D1 the no-window host had no post-merge cell writer, so its accepted Position published `[Updated]` alone and simply LOST the `Rebucketed`. D1 gave that host its own `CommitWireCellRebucket` caller, so both hosts now produce `[Updated, Rebucketed]` with the same torn intermediate. The row's analysis, its "no production consumer identified today" verdict, and its retirement condition are unchanged; what changed is the population — a headless bot's event log is now a REAL instance of the "future consumer that SNAPSHOTS a delta" this row warns about, not a hypothetical one, because the no-window host is the one whose consumers are event streams by construction. | `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (`TryApplyPosition`'s terminal `AcknowledgeProjectionAndPublish`, and `CommitRebucket`); `src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs` (`Snapshot` — the `record.FullCellId` / `record.Snapshot.Position` pairing that makes the intermediate torn) | Retail has no delta stream at all, so there is no retail shape to match — this is acdream's own observer contract. The alternative, suppressing the merge's `Updated` when a rebucket is about to follow, is not available at that layer: the merge cannot know whether its caller will reach W2 (the local force arm, the missile arm, and the `ChildUnparentDisposition` Superseded/Pending arm all return before it), so suppressing would silently drop the pose delta on exactly the packets where it is the only one. Collapsing the merge's ternary to a constant `Updated` is likewise wrong — the retained `Rebucketed` arm has a real producer, the cancelled-park rollback inside the merge. | Any consumer that treats one accepted Position as one entity delta now sees two, and any consumer that reads `CellId` and `Position` from the SAME delta and assumes they agree can transiently pair a new position with the old cell. No production consumer identified today: `LiveEntityRuntime` and the plugin/world-event surfaces re-read canonical state rather than trusting a delta's paired fields, and the pair reconverges inside the same `OnPosition` call. A future consumer that SNAPSHOTS a delta — a recorder, a plugin, a headless bot event log — would capture the torn intermediate. Retire together with W2, if the local player's canonical cell ever becomes per-crossing-fresh (AP-146/#320) and the merge and the rebucket can be one write again. | No retail anchor — acdream-only observer contract. Evidence: `RuntimeSteadyStatePositionMergeTests.CellChangingAcceptedPosition_ConservesOneRebucketAndOneChildPropagation` asserts the complete ordered stream `[Updated, Rebucketed]` plus both elements' `CellId`/`Position.ObjCellId`, and `RuntimeSetPositionStateTests.AcceptedPositionCancellingWakeableParkPublishesRebucketedThroughTheMerge` pins the retained arm; both sabotage-verified in both directions at the C5b review. | | AP-148 | **Filed 2026-08-05 at the C5b closeout, from disassembly of the PDB-paired binary — NOT from the pseudo-C, which cannot show it.** acdream's local-player Gate A (the FORCE_POSITION self-echo shortcut) requires the wire TELEPORT_TS to be EXACTLY EQUAL to the stored one; retail requires only that it not be OLDER, so equal AND newer both take the shortcut. `SmartBox::HandleReceivedPosition` @0x0045402B-54 loads `player->update_times[4]` (TELEPORT_TS; base 0x164, 2 bytes/entry, confirmed by the POSITION_TS store `mov word [edx+0x164], ax` @0x00454084 and `acclient.h:6090`), takes `abs(stored - wire)`, picks a wrapped or unwrapped 16-bit compare on `> 0x7fff`, materialises the carry with `sbb eax,eax / neg eax`, and SKIPS Gate A on CF — where CF means the wire stamp is strictly older. It is `CPhysicsObj::newer_event` @0x00451B10's identical idiom with the compare operands swapped. **Binary Ninja drops the flag test and renders the whole sequence as `if (-((eax_7 - eax_7)) == 0)`, vacuously true**, which is why two C5b review rounds read this function carefully and both recorded the term backwards (`docs/research/2026-08-05-c5b-contract.md` §1 said first "teleport must NOT be newer", then "TELEPORT_TS equal"; both corrected at §15). **Consequence:** acdream's `ForcePosition` disposition is a strict SUBSET of retail's Gate A set. A local ForcePosition carrying a NEWER teleport stamp is misrouted into a full `Apply`, which is four separate behaviour changes at once — it takes the WIRE heading instead of preserving the body's (`InboundPhysicsStateController.ApplyAcceptedPosition:846-856`, force-gated), it UNPARENTS and may install a placement frame (`clearParent: !force`, `installPlacementFrame: !force && !hasAnimations` — C5b's own truth table), it sets `TeleportAdvanced` and therefore ZEROES local velocity (`:882-885`), and it advances TELEPORT_TS and calls `OfferTeleportDestination`, starting teleport/portal presentation for a packet retail never starts it for. Retail's Gate A deliberately lets a force ride PAST a pending teleport advance without consuming it (it returns @0x0045409D before `newer_event(arg2, TELEPORT_TS, arg8)` @0x00454158); the ordinary Position channel is what processes that teleport. **Not fixed in the filing commit**, deliberately: see issue #325 for why it is not a one-line comparison swap. **C5b made this marginally BETTER, not worse** — `clearParent` was unconditionally `true` pre-C5b and is unchanged for the misrouted packet, and `installPlacementFrame` went unconditional-`true` to `!force && !hasAnimations`, i.e. toward retail's "Gate A never reaches `SetPlacementFrame`". | `src/AcDream.Core/Physics/PhysicsTimestampGate.cs` (`TryAcceptPositionEvent:199`, the `teleport == _timestamps[Teleport]` term); `src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs` (`ValidAcceptedAuthority`, the `PreviousTeleportSequence == AcceptedTeleportSequence` term — the SAME predicate encoded a second time, and the reason the fix is not one line) | None argued — this is an unintended narrowing found at a closeout, not a chosen approximation. It is filed as an approximation rather than a defect only because the resulting behaviour is a strictly SMALLER shortcut set, i.e. more packets take the fully-processed path rather than fewer, which fails safe for pose correctness even where it is wrong about heading, parent, velocity, and presentation. The exact retail predicate already exists verbatim in the same file — `IsFreshTeleportStart:163` is `!IsNewer(teleport, _timestamps[Teleport])` — so the correction itself is trivial; the consumers are not. | A server correction that arrives while the client's TELEPORT_TS is behind ACE's (a teleport whose Position packet was lost, or arrived after the force) is promoted from "blip me in place" to a full teleporting apply: the player's facing snaps to the wire heading instead of staying where the mouse left it, local velocity is zeroed mid-stride, an equipped child is unparented, and the portal/transit presentation owner is offered a destination for a packet that is not a teleport. Reachability against ACE is UNMEASURED — ACE's two `ObjectForcePosition` bumps (`Player.cs:1148` PKLite re-placement, `Player_Tick.cs:488` z-hack correction) do not themselves bump the teleport sequence, but `PositionPack` serialises the CURRENT teleport sequence, so any client whose TELEPORT_TS lags ACE's is in the divergent window on its next force. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (Gate A's teleport test @0x0045402B-0x00454054; the return @0x0045409D; the TELEPORT_TS advance it skips @0x00454158); `CPhysicsObj::newer_event` 0x00451B10 (the same idiom, operands unswapped); `acclient.h:6090` (`update_times[4] == TELEPORT_TS`) | +| AP-149 | **Filed 2026-08-05 at the #280 fix (portal destination prefetch).** The reveal gate's OUTER ring accepts terrain-only publication where retail requires the landblock's full static-DAT closure. Retail's `LScape::PreFetchCells` @0x00505660 walks the whole `mid_radius` square and, for EVERY in-bounds landblock, requires (1) its terrain record resident, (2) its `LandBlockInfo` type-2 record resident, and (3) via `CLandBlock::PreFetchCells` @0x00530240 -> `CLandBlockInfo::PreFetchCells` @0x0052E7C0 -> `CBldPortal::PreFetchCells` @0x0053BD00, every EnvCell of every building it contains. acdream's outer ring is Far-tier: heightmap + terrain render mesh + terrain collision, with NO LandBlockInfo, no buildings, no building EnvCells and no procedural scenery, because the Far tier does not load them at all. The gate therefore converges on a strictly weaker condition than retail's out beyond `NearRadius`. **#280 closed the 11.4:1 reveal-window/visible-window ratio; it did NOT close this. Do not let a later closeout claim parity.** | `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`, the far arm); `src/AcDream.App/Streaming/LandblockBuildFactory.cs` (the Far build's contents); `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs` | Closing it would mean promoting the entire Far window to Near, i.e. deleting the two-tier streaming design that exists precisely because full hydration of a 25x25 window is unaffordable. Retail affords it because retail's ONE square is 17x17 at its default draw distance and it blocks the whole simulation while loading it (`CellManager::blocking_for_cells`), which acdream deliberately does not do (see AD-2). The residual is bounded to content that is only ever seen at Far distances. | A distant BUILDING, its interior EnvCell shells, or distant procedural scenery can still appear after the viewport opens, at Far-ring distances (beyond ~768 m at the shipped High preset), where retail would have kept blocking. Distant TERRAIN — the reported #280 symptom — no longer can. | `LScape::PreFetchCells` 0x00505660; `CLandBlock::PreFetchCells` 0x00530240; `CLandBlockInfo::PreFetchCells` 0x0052E7C0; `CBldPortal::PreFetchCells` 0x0053BD00 | | ~~AP-145~~ | **RETIRED 2026-08-05 (C5a commit 1, closing #318; corrected at the architecture-review re-pass, A1/A2).** `RuntimePlacementPresentationSink.TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose(entity, entity.Position, entity.Rotation, record.FullCellId, force: true)` — the SAME publisher ordinary per-tick movement uses — instead of writing `LocalPlayerShadowState.Set` directly. `SyncPose` calls `ShadowPositionSynchronizer.Sync` → `ShadowObjectRegistry.UpdatePosition` (the real `PhysicsEngine.ShadowObjects` publish) BEFORE it records the dedup cache as its own last step, so the cache can no longer be pre-seeded ahead of the real publish. `force: true` because this is the authoritative placement commit, not an ordinary refresh — it must never be skipped by `SyncPose`'s own dedup check. **`TryPublishWithdrawal` carried the exact mirror asymmetry** (a bare `_localPlayerShadow.Clear()` with no `ShadowObjects.Suspend`, leaving a live phantom row at the park's source cell for the whole park window — the #184 shape) and is fixed in the SAME commit, same one-call shape: `_localPlayerShadowSync.Suspend(entity)`. The sink no longer holds a direct `LocalPlayerShadowState` reference at all — both halves route exclusively through the one synchronizer, which owns the cache internally. One synchronizer instance is constructed in `LivePresentationComposition.cs` (before the sink) and threaded through `LivePresentationResult` to `SessionPlayerComposition.cs`, which no longer builds its own. `#318`'s composition test (`RuntimePlacementShadowCompositionTests.cs`, 4 facts) proves: the real `ShadowObjects` registry holds a row at the destination cell (not just the cache) after a bare `Place` with no subsequent tick; the SOURCE cell's row is gone, not duplicated; a subsequent ordinary per-tick `Sync` call is a correct no-op; a `Withdraw` suspends the real registry row (not just the cache) — the source cell carries zero rows and the retained (suspendable) registration survives for a later restore; and a Place for a **registered** non-local-player entity leaves its row at the source cell and does not pollute the player's cache (route 7 P4 — the fix lives entirely inside the pre-existing player-only gate; the first version of this fact registered nothing for the child and was vacuous under the gate's own removal, corrected at the review). Sabotage-verified all four facts, both directions: reverted, each fails at its own discriminating assertion; applied, all green. | `src/AcDream.App/World/RuntimePlacementPresentationSink.cs` (`TryPublishPlace`, `TryPublishWithdrawal`); `src/AcDream.App/Composition/LivePresentationComposition.cs` (`LocalPlayerShadowSynchronizer` construction + `LivePresentationResult` field); `src/AcDream.App/Composition/SessionPlayerComposition.cs` (consumes the shared instance); `tests/AcDream.App.Tests/World/RuntimePlacementShadowCompositionTests.cs` | — | — | No retail analogue — retail has no separate shadow-cache/publish split; this was an acdream-only two-object seam (`LocalPlayerShadowState` cache + `LocalPlayerShadowSynchronizer` publisher) that a direct `.Set()`/`.Clear()` call could desynchronize from | | ~~AP-1~~ | **RETIRED 2026-08-05 (C5a deletion sweep).** "Production zero-delta routes deliberately remain on the legacy resolver until 4B2" is false at HEAD: the exhaustive receiver census over `src/` shows zero `PhysicsEngine.Resolve`/`.ResolvePlacement` call sites, and every production placement writer reaches canonical `PhysicsEngine.SetPosition` only through `RuntimeSetPositionState` (three call sites total). C5a deleted `Resolve`, `ResolvePlacement`, and their `HasCellSurface` helper outright — the resolver-shaped entry points this row described no longer exist, so the condition is retired structurally, not just narrowed. The narrower survivors (#276 settle-cell discard, AD-61 force-seed, AD-62 non-commit outcomes) are separately filed rows and are unaffected. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (deletion); `docs/research/2026-08-05-c5a-contract.md` | — | — | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `CPhysicsObj::handle_all_collisions` 0x00514780; `track_object_collision` 0x00513F10; `report_collision_end` 0x00514620; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 | diff --git a/docs/plans/2026-08-02-placement-cutover.md b/docs/plans/2026-08-02-placement-cutover.md index d23ea930..b0359985 100644 --- a/docs/plans/2026-08-02-placement-cutover.md +++ b/docs/plans/2026-08-02-placement-cutover.md @@ -64,6 +64,16 @@ Remaining campaign work, in order: conversion remains a trigger-conditioned carry, not a completed item.** 3. Resolve #280 with retail's configured destination-prefetch window so the portal viewport never reveals visibly constructing far terrain. + **DONE 2026-08-05 (implementation + suite); the connected/visual gate is + batched into C5's matrix. Shape correction: retail has NO separate prefetch + window** — it has one landscape square (`LScape::mid_radius`) that is + simultaneously the loaded, drawn and blocked-on set, and whose configured + value is `Render.LandscapeDrawDistance`. acdream now derives its reveal + window from the live streaming radii (`QualitySettings.FarRadius`) and made + the render-completeness predicate tier-aware so the outer rings can satisfy + it. Contract: [`2026-08-05-280-contract.md`](../research/2026-08-05-280-contract.md). + Residual filed as AP-149; the missing user-facing Viewing Distance option is + filed separately as #326 and is explicitly NOT part of #280. 4. Run C5's complete Release suite, lifecycle/reconnect route, latest-binary nine-stop soak, two-client observation, and the remaining #269 slope-glide visual check. A pass from `01f4791e` is evidence for that fix, not a @@ -597,6 +607,25 @@ same commit) → docs/handoff commit. No workarounds; no fused slices. physics probe family strip (`REMOTE_LANDING`/`REMOTE_SLIDE`/`PARK`/ `REMOTE_TELEPORT`/`CHILD_CELL`/`LOCAL_TELEPORT`) — after, never before, the four owed gates consume them. + **#280's connected gate rides this matrix (added 2026-08-05).** Release, + `ACDREAM_RETAIL_UI=1`, `ACDREAM_STREAM_RADIUS` **UNSET** (it forces + `NearRadius` and only raises `FarRadius`, so a run with it set measures a + different window than production). Run the route TWICE on the same binary — + once with `ACDREAM_PROBE_REVEAL_RADIUS=1` (reproduces the pre-#280 gate) and + once without — and report BOTH. The user-facing observable is an ABSENCE, so + the pass criteria are three positive artifacts per stop, all from existing + machinery: (1) a `world-visible` checkpoint JSON whose + `StreamingWork.NearBacklog` / `.FarBacklog` / `.DestinationBacklog` / + `.PendingPublications` are zero for the destination window at the moment the + viewport opened; (2) a hold-duration pair — **the post-fix hold is EXPECTED + to be LONGER**, and a hold that is not longer means the gate did not widen + and the run proves nothing; (3) a paired screenshot per stop, where the + pre-fix run is the one that shows the defect. `wait world-visible 30000` in + `tools/connected-world-lifecycle.route.txt` is the convergence ceiling — a + trip is a failure, a longer pass is not. **The reported repro was a RECALL, + not `/teleloc`: the matrix needs a lifestone/recall leg**, and it must + include a first-login stop, because login shares the same barrier and its + gate widened too. After C5: AP-22 (authored collision shapes), then AD-10 (remote contact-plane projection), then the campaign's final matrix and ledger diff --git a/docs/research/2026-08-05-280-contract.md b/docs/research/2026-08-05-280-contract.md new file mode 100644 index 00000000..6e99eb53 --- /dev/null +++ b/docs/research/2026-08-05-280-contract.md @@ -0,0 +1,1205 @@ +# #280 — portal destination prefetch: pinned contract + +**Slice:** Placement cutover campaign, plan item 3 +(`docs/plans/2026-08-02-placement-cutover.md:65`). +**Base:** worktree `.claude/worktrees/peaceful-visvesvaraya-e0a196`, branch +`claude/acdream-physics-divergence-5aa784`, HEAD `9ee9c1a1`. +**Status:** contract only. No production or test code written; no commit made. + +**Retail binary used for byte verification:** +`C:\Users\erikn\Downloads\acclient.exe`, PE timestamp `0x52291f34` +(2013-09-06T00:17:56Z), CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`, +image base `0x00400000`. `py tools/pdb-extract/check_exe_pdb.py` prints +`=== MATCH: this exe pairs with our acclient.pdb ===`. Every retail constant +below that carries a "(byte-verified)" tag was read out of that image, not +out of Binary Ninja's pseudo-C. + +--- + +## 0. One-paragraph statement of the slice + +Retail loads, draws, and blocks on exactly one square of landblocks whose +half-width is the user's landscape draw-distance preference; there is no +second, smaller "reveal" radius, because the loaded set and the drawable +set are the same array. acdream splits that into a two-tier streaming +window (Near/Far) plus a reveal barrier gated at a hardcoded radius of 1. +The barrier therefore opens the viewport when one landblock ring is +complete while the user can see roughly twelve — which is #280. The fix +derives the reveal radius from the live streaming window instead of +hardcoding it, and makes the render-completeness predicate tier-aware so +the outer rings can actually satisfy it. It is **both** a radius change +and a predicate change; either alone is broken (§4). + +--- + +## 1. Retail ground truth + +### 1.1 There is exactly one radius, and it is the user's draw-distance setting + +`LScape` owns the loaded landscape as a flat `mid_width × mid_width` array +of `CLandBlock*`: + +- `LScape::SetMidRadius` @ `0x00504C00` (pseudo-C `:266528`): + ``` + if (arg2 < 1 || this->land_blocks != 0) return 0; + this->mid_radius = arg2; + this->mid_width = arg2 * 2 + 1; + return 1; + ``` + Note the second guard: **the radius cannot be changed while the block + array is allocated.** Callers must reset first (see §1.5). +- `LScape::LScape` @ `0x00505370` (`:267007`) constructs with + `mid_radius = 5`, `mid_width = 0xB`. + **(byte-verified** — file offset for `0x00505390` reads + `c706 05000000` = `mov dword [esi], 5`, `c74604 0b000000` = + `mov dword [esi+4], 0xB`.**)** This is the pre-preferences default only. +- `LScape::update_block` @ `0x005063A0` (`:267954`) allocates + `operator new[](mid_width * mid_width * 4)` — the array *is* the loaded + landscape. +- `LScape::update_viewpoint` / `LScape::set_viewer_block` @ `0x00505C70` + (`:267520`) index that same array by + `viewer_b_xoff/viewer_b_yoff = ((mid_radius << 3) - origin + coord) >> 3`, + and `block_draw_list` (allocated alongside `land_blocks`, freed together + at `0x00504BCB`) is the draw order over it. + +The value assigned to `mid_radius` comes from one place: + +- `SmartBox::SetRegion` @ `0x004531F0` (`:92064`): + `SmartBox::set_mid_radius(this, Render::m_RenderPrefs.LandscapeDrawDistance);` +- `Render::GRPCallback_OnRenderPreferenceChanged` @ `0x0054D9A8`–`0x0054DA43` + (`:344372`, `:344422`): when `Render::m_RenderPrefs.LandscapeDrawDistance` + differs from the cached `Current_Render_LandscapeDrawDistance`, it + re-issues `SmartBox::set_mid_radius(SmartBox::smartbox, + Render::m_RenderPrefs.LandscapeDrawDistance)`. + +`Render.LandscapeDrawDistance` is a registered user preference — +`UserPreferences::RegisterPreference(&Render::m_RenderPrefs.LandscapeDrawDistance, +&Render_LandscapeDrawDistance, …, 6, 0x86f2a4, +&Render_LandscapeDrawDistance_Values)` @ `0x0054ECBE` (`:345471`), string +`"Render.LandscapeDrawDistance"` @ `0x006C33CA`, UI string id +`ID_Graphics_LandscapeDrawDistanc…` @ `0x004041B7`. + +**The full ladder, byte-verified.** +`uint32_t const Render_LandscapeDrawDistance_Values[6]` @ `0x007CA988` +(`:1021469`) reads, from the image: + +| Choice label (@`0x006C363A`+) | Value | Loaded/drawn square | Half-extent @192 m/LB | +|---|---|---|---| +| `VeryLow` | 3 | 7×7 | 576 m | +| *(idx 1, `data_793f8c`)* | 5 | 11×11 | 960 m | +| `Medium` | 8 | 17×17 | 1536 m | +| `High` | 11 | 23×23 | 2112 m | +| `VeryHigh` | 15 | 31×31 | 2880 m | +| `Extreme` | 25 | 51×51 | 4800 m | + +**Default = 8** — two independent sites, both byte-verified: +`PlayerOptionPage::AddMenuOption(...)->SetDefaultValue(8)` @ `0x0049E70D` +(`:169449`; image bytes at `0x0049E6F0` contain `6a 08 … ff 92 d8020000` += `push 8; call [edx+0x2D8]`), and +`Render::m_RenderPrefs.LandscapeDrawDistance = 8` @ `0x0054EF0B` (`:345535`; +image bytes `c705 a4ef8100 08000000`). + +The overall-quality presets (`Render::SetOverallGraphicsQuality` +@ `0x0054B020`, `:341947`) map quality 1..5 onto the first five of those +values: 3, 5, 8, 11, 15 (`:341956`, `:341970`, `:341982`, `:341993`, +`:342004`). + +**Consequence, and this is the load-bearing retail fact for the whole +slice:** retail's prefetch window, its loaded window, and its drawable +landscape window are *the same square*. There is no retail configuration +in which the client streams farther than it gates, because there is only +one number. + +### 1.2 The far clip plane is not the landscape bound + +`float Render::zfar` is statically initialised to **4000.0** +(`:1101868`; **byte-verified** at `0x0081EC88` = `4000.0f`). The only +`Render::set_zfar` calls are `GameSky::Draw` @ `0x00507055` / +`0x005070EE` (`:268724`, `:268765`), which temporarily multiply it by 4 for +the skybox and restore it. At the default `mid_radius = 8` the landscape +ends at 1536 m, well inside `zfar`. **The landscape horizon is the +prefetch square, not the frustum.** + +### 1.3 `m_bUseViewDistance` is NOT a view-distance setting + +Because it was raised as a candidate: `SmartBox::SetOverrideFovDistance` +@ `0x00451BC0` (`:90783`) sets `m_bUseViewDistance` / `m_fViewDistFOV`, and +the two readers — `CreatureMode`-style camera setup @ `0x00452AFD` +(`:91723`) and `SmartBox::DrawNoBlit` @ `0x00453AE6` (`:92655`) — use it as +a **projection parameterisation switch**, not a distance: + +``` +if (m_bUseViewDistance == 0) Render::SetFOVRad(m_fGameFOV / (aspect - 0.1f)); +else Render::set_vdst(m_fViewDistFOV); +``` + +and `Render::set_vdst` @ `0x0054B240` (`:342121`) is +`SetFOVInternal(2 * atan(x))` with `znear = (x < 0.4) ? 0.1 : x * 0.25`. +It is "frame the camera to see an object this far away" — an FOV, used by +the creature/portrait camera path. It has no relationship to `mid_radius`, +to `LandscapeDrawDistance`, or to streaming. **Do not build anything on +it.** + +### 1.4 What "each required cell is in" actually tests + +`LScape::PreFetchCells` @ `0x00505660` (`:267154`) walks +`for dy in -mid_radius..mid_radius: for dx in -mid_radius..mid_radius` +(`:267172-267255`), computes each landblock DID +`(((x & ~7) << 5) | (y >> 3)) << 16 | 0xFFFF` (`:267199`), skips +out-of-bounds (`< 0 || >= 0x7F8`, i.e. off-map), and for each in-bounds +landblock: + +1. `DBObj::PreFetch(qdid(did, type 1))`. If the result is neither + `CACHE_OBJECT_IN_MEMORY` nor `CACHE_OBJECT_IN_FILE`, `result = 0`; if it + is `CACHE_OBJECT_LOOKING`, also `*waitingCount += 1`. +2. Otherwise `DBObj::Get(...)`. If `Get` returns null (present in the file + but not yet resident), it additionally prefetches the LandBlockInfo + record `(did & ~1) | 0xFFFE` as type 2, sets `result = 0`, and + `*waitingCount += 1`. +3. Otherwise `CLandBlock::PreFetchCells(block)` @ `0x00530240` (`:314195`), + which prefetches the LandBlockInfo type-2 record and, via + `CLandBlockInfo::PreFetchCells` @ `0x0052E7C0` (`:312329`), walks every + building and calls `CBldPortal::PreFetchCells` @ `0x0053BD00` + (`:325061`) for each, which prefetches every `stab_list[i]` as type 3 + (the building's EnvCells). Any failure propagates `result = 0`. + +So retail's completeness predicate is: **for every in-bounds landblock in +the `mid_radius` square — its terrain record, its LandBlockInfo, and every +EnvCell of every building it contains are resident.** It is a static-DAT +residency test, not a render-upload test (retail uploads synchronously), +and it does not include procedural scenery (derived from the terrain +record it already required). + +Indoor destinations take a different arm: `CellManager::PreFetchCells` +@ `0x00455820` (`:94471`) dispatches on `cellIndex >= 0x100` to +`CEnvCell::PreFetchCells` @ `0x0052D1E0` (`:310659`), which walks the +EnvCell's visible-cell graph recursively; and `CEnvCell::PreFetchCells` +@ `0x0052C460` (`:309754`) additionally requires the whole `mid_radius` +landscape square when `seen_outside != 0` (`:309759`). **Indoor cells that +can see outside still require the full outdoor square.** + +### 1.5 What `blocking_for_cells` gates, and how it clears + +`CellManager::PreFetchCells` @ `0x00455820`: + +- Early-outs to "available" if `DBCache::IsLoader()` (`:94477`). +- Outdoor arm only re-sweeps if `blocking != 0 || all_cells_available == 0 + || ((last_prefetch_cell_id ^ cellId) & 0xFFFF0000) != 0` — i.e. standing + still in an already-complete landblock is free (`:94496`). +- When called with `blocking != 0` and cells are missing, it reports + `ECM_DDD::SendNotice_RuntimeDDDStatus(1, remaining, total)` and latches + `this->blocking_for_cells = 1` (`:94538-94541`). +- When everything is in, it clears the latch and reports + `SendNotice_RuntimeDDDStatus(0,0,0)` (`:94549-94552`). +- `CellManager::Reset` @ `0x00455930` (`:94588`) also clears it. + +The latch gates the **entire simulation**. `SmartBox::UseTime` +@ `0x00455410` (`:94168`): + +``` +if (cell_manager->blocking_for_cells == 0) { + if (!all_cells_available && CheckPrefetchStatus()) UpdateLoadPoint(); + CellManager::ChangePosition(player->m_position, /*blocking*/ 0); + ... position_update_complete / has_been_teleported latch ... + CObjectMaint::UseTime(); CPhysics::UseTime(); + GameTime::UseTime(); LScape::UseTime(); Ambient::UseTime(); +} else { + CellManager::CheckPrefetchStatus(cell_manager); // and nothing else +} +SceneTool::Think(); +... drain the inbound NetBlob queue and dispatch ... +cmdinterp->UseTime(); Render::CalcDegLevel(); +``` + +So while blocked: **no object maintenance, no physics, no game clock, no +landscape update, no ambient sound.** Networking still drains and events +still dispatch. This is the exact behaviour AD-2 already cites. + +The retry is rate-limited. `CellManager::CheckPrefetchStatus` +@ `0x00455BE0` (`:94734`) compares `Timer::cur_time - last_prefetch_check` +against a qword constant at `0x007991B0`; **byte-verified as `5.0`** +(`0000000000001440`). The instruction sequence at `0x00455BE0` is +`fld qword [0x8369A8]; fsub qword [esi+0x10]; fcomp qword [0x7991B0]; +fnstsw ax; test ah,0x41; jnz` — `CF|ZF` after `fcomp` means +"elapsed < 5.0 or elapsed == 5.0", so the function returns 0 without +re-sweeping. **Retail's blocked hold is therefore quantised to 5-second +poll intervals.** (BN renders the tail `-((eax_3 - eax_3))`; the image is +`neg eax; sbb eax,eax; neg eax`, an ordinary boolean normalise of the +`PreFetchCells` result. This is one of the flag-test drops the C5b review +warned about; it is benign here.) + +Which callers block: + +| Site | Call | Blocking? | +|---|---|---| +| `SmartBox::HandleCreateObject` (initial player) @ `0x00455069` (`:93814`) | `ChangePosition(pos, 1)` | **yes** | +| `SmartBox::PlayerPositionUpdated` @ `0x00453903` (`:92508`), teleport arm | `ChangePosition(pos, arg2 != 0)` | **yes on teleport** | +| `SmartBox::PlayerPositionUpdated`, ordinary arm | `ChangePosition(pos, 0)` | no | +| `SmartBox::UseTime` @ `0x00455462` (`:94180`) | `ChangePosition(pos, 0)` | no | +| `SmartBox::set_mid_radius` @ `0x004531D0` (`:92053`) | `ChangePosition(pos, 1)` | **yes, if already blocking** | + +`CellManager::ChangePosition` @ `0x004559B0` (`:94601`) additionally +promotes any call to blocking while the latch is set +(`if (blocking_for_cells == 0) edi = arg3;` — i.e. once latched, always +blocking until cleared). + +**`SmartBox::set_mid_radius` @ `0x00453180` (`:92036`) is the retail +answer to "what if the radius changes mid-hold":** + +``` +ebx = cell_manager->blocking_for_cells; +CellManager::Reset(cell_manager); // clears the latch, releases lscape +ok = LScape::SetMidRadius(this->lscape, arg2) != 0; +if (ok && ebx != 0 && player && player->m_position.objcell_id != 0) + CellManager::ChangePosition(this->cell_manager, &player->m_position, 1); +``` + +Reset, re-radius, and **re-arm the blocking prefetch at the new radius**. +It does not finish the old hold at the old radius and it does not ignore +the change. + +### 1.6 What the user sees while blocked + +Two things, both confirmed: + +1. **The DDD progress readout.** + `ECM_DDD::SendNotice_RuntimeDDDStatus(active, remaining, total)` + @ `0x00692870` reaches `gmPowerbarUI::RecvNotice_RuntimeDDDStatus` + @ `0x004DA5C0` (`:222574`), which sets a text element to string id + `ID_Powerbar_DDDModeText` with `CURRENT`/`TOTAL` integer variables and + writes `current/total` into a float attribute `0x69` on element + `0x10000034` — a **progress bar with an "N of M" cell count on the + powerbar**. On `active == 0` it restores normal state. +2. **The portal-space notice.** `gmSmartBoxUI::UseTime` @ `0x004D6E30` + (`:219400`): while `teleportAnimState == TAS_TUNNEL`, each time the + current rotation segment expires + (`teleportRotationStartTime + teleportRotationDuration <= cur_time`, + `:004D6FC7`) it picks a fresh random segment and emits + `ECM_UI::SendNotice_DisplayStringInfo(0x1A, "In Portal Space - Please + Wait...")` (literal at `0x004D7064`, `:219516`). It repeats every + rotation segment for as long as the tunnel runs. + +Also relevant: on the initial-entry path `SmartBox::hidden = 1` is set +(`0x004553F7`, `:94139`) so `SmartBox::Draw` @ `0x00455570` returns +without drawing at all. On a mid-session teleport the portal tunnel +(`m_pPortalSpace`) is made visible and `SmartBox::Hide(m_pSmartBox)` is +called (`0x004D6FA3`/`0x004D6FB6`) — the world is not drawn behind the +tunnel either. + +**So: retail blocks. It does not reveal progressively.** It freezes +simulation, hides the world, shows a tunnel plus a repeating wait string +plus a cell-count progress bar, and only resumes once every landblock in +the full draw-distance square is resident. + +--- + +## 2. acdream ground truth at HEAD `9ee9c1a1` + +Every path and line below was read at this HEAD. Do not inherit line +numbers from the issue, the campaign plan, the streaming memory doc, or +this contract into a later session without re-verifying. + +### 2.1 The reveal barrier + +`src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs` — +`internal sealed class`, 147 lines, seven injected delegates. + +- `:38` `internal const int OutdoorNeighborhoodRadius = 1;` +- `:143-146` + ```csharp + internal static int RequiredRenderRadius(uint destinationCell) => + IsIndoor(destinationCell) ? 0 : OutdoorNeighborhoodRadius; + private static bool IsIndoor(uint cellId) => (cellId & 0xFFFFu) >= 0x0100u; + ``` + Note it is **`static`** — that is the first structural obstacle to a + derived radius. +- `:107-141` `Evaluate` short-circuits render → composites → collision and + returns a `WorldRevealReadinessSnapshot` (`:9`) whose `IsReady` is + `HasDestination && (IsUnhydratable || (render && composites && collision))`. +- `:84-92` `Prepare` calls `_prepareCompositeTextures(destinationCell, + radius)` with the **same** radius. + +Production wiring, `src/AcDream.App/Composition/SessionPlayerComposition.cs:371-396`: + +| Delegate | Implementation | +|---|---| +| `isRenderNeighborhoodReady` | `StreamingController.IsRenderNeighborhoodResident` (`src/AcDream.App/Streaming/StreamingController.cs:227`) | +| `isTerrainNeighborhoodReady` | `PhysicsEngine.IsNeighborhoodTerrainResident` (`src/AcDream.Core/Physics/PhysicsEngine.cs:129`) | +| `isSpawnCellReady` | `PhysicsEngine.IsSpawnCellReady` (`:1797`) | +| `areCompositeTexturesReady` | `WbDrawDispatcher.CompositeTexturesReady` | +| `prepareCompositeTextures` | `CompositeWarmupEntitySource.Refresh` + `WbDrawDispatcher.PrepareCompositeTextures` | +| `invalidateCompositeTextures` | `CompositeWarmupEntitySource.Reset` + `WbDrawDispatcher.InvalidateCompositeWarmupReadiness` | +| `isSpawnClaimUnhydratable` | `DatSpawnClaimHydrationClassifier.IsUnhydratable` | + +### 2.2 The radius `1` exists in FOUR places, three of them uncited + +| # | Site | Shape | +|---|---|---| +| 1 | `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs:38,144` | the named constant | +| 2 | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs:573` | `int requiredRenderRadius = isIndoor ? 0 : 1;` — **a validating invariant** | +| 3 | `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs:776` | `RequiredRenderRadius: indoor ? 0 : 1` (producer) | +| 4 | `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs:973` | `RequiredRenderRadius: indoor ? 0 : 1` (producer) | + +Site 2 is the sharp edge. `RuntimeWorldTransitState.AcknowledgeDestinationReadiness` +re-derives the expected radius and calls +`FailInvariant("invalid-readiness-shape", …)` on mismatch. **Changing +`OutdoorNeighborhoodRadius` alone makes every graphical readiness +acknowledgement fail this invariant, and the reveal never opens.** This is +the C5b lesson in its purest form — a contract asserting a mechanism, with +the mechanism's value copied rather than referenced. + +### 2.3 The render-completeness predicate refuses Far-tier landblocks + +`StreamingController.IsRenderNeighborhoodResident` (`:227-254`), the ring +body: + +```csharp +uint canonical = ((uint)nx << 24) | ((uint)ny << 16) | 0xFFFFu; +if (!_state.IsNearTier(canonical) || !_state.IsRenderReady(canonical)) + return false; +``` + +`GpuWorldState.IsNearTier` (`src/AcDream.App/Streaming/GpuWorldState.cs:176`) +is true only for `LandblockStreamTier.Near`. **A Far-tier landblock can +never satisfy this predicate.** Since the Near window is `NearRadius` +(4 at the default preset), any reveal radius above 4 would hang forever. +That is why #280 cannot be fixed by editing the constant. + +`GpuWorldState.IsRenderReady` (`:180`) is +`_loaded.ContainsKey(id) && (_wbSpawnAdapter?.IsLandblockRenderReady(id) ?? true)`. +Verified that a Far-tier landblock **does** get a spawn-adapter +registration and therefore *is* render-ready: `PublicationKind.Far` flows +through `LandblockPresentationPipeline.cs:900-923` → +`GpuWorldState.CommitLandblockSpatial` → `ActivateLandblockPresentation` +(`GpuWorldState.cs:1009`) → `_wbSpawnAdapter.OnLandblockLoaded(...)` +(`:1022`), which creates a registration with `WantsLoaded = true` +(`src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs:98-101`) and an +empty `Ordinary`/`Prepared` set, so `IsLandblockRenderReady` (`:135-152`) +returns true. **`IsNearTier` is the sole blocker.** + +`PhysicsEngine.IsNeighborhoodTerrainResident` (`:129-146`) is already +tier-agnostic: it keys off `PhysicsEngine._landblocks`, and Far-tier +publication does construct the terrain surface +(`src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:390-403`, reached +for `PublicationKind.Far` via `LandblockPresentationPipeline.cs:619`). +**It also allocates a fresh `HashSet` over every resident landblock +on every call** (`:131-133`) — see §7. + +### 2.4 The streaming window and what the user can actually see + +`src/AcDream.UI.Abstractions/Settings/QualityPreset.cs:29-36`: + +| Preset | NearRadius | FarRadius | Near ring | Far window | +|---|---|---|---|---| +| Low | 2 | 5 | 5×5 | 11×11 | +| Medium | 3 | 8 | 7×7 | 17×17 | +| **High (default)** | **4** | **12** | 9×9 | 25×25 | +| Ultra | 5 | 15 | 11×11 | 31×31 | + +Default preset is `High` (`src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs:52`). +Per-axis env overrides `ACDREAM_NEAR_RADIUS` / `ACDREAM_FAR_RADIUS` +(`QualityPreset.cs:45-46`). Radii are **runtime-mutable** via +`StreamingController.ReconfigureRadii` (`StreamingController.cs:377`), +driven from the Settings panel through +`RuntimeSettingsTargets.ApplyQuality` (`src/AcDream.App/Settings/RuntimeSettingsTargets.cs:247-253`). + +Tier contents: +- **Far** = the LandBlock heightmap record only + (`src/AcDream.App/Streaming/LandblockBuildFactory.cs:131-142`), empty + entity list, `PhysicsDatBundle.Empty`, and the prepared-collision closure + is skipped (`:97-101`) — but the terrain render mesh and terrain + collision surface *are* published. +- **Near** = LandBlock + LandBlockInfo, static entities, procedural + scenery, EnvCell shells, interior statics, prepared collision closure + (`LandblockBuildFactory.cs:144-202`). + +Visible extent: +- Far plane: hardcoded `5000f` in every camera + (`src/AcDream.App/Rendering/RetailChaseCamera.cs:56`, and identically in + `ChaseCamera.cs:64`, `FlyCamera.cs:37`, `OrbitCamera.cs:28`). No config + path. Retail's is 4000. +- Fog, derived from the streaming radii — + `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs:481-491` with + `LandblockSize = 192f` (`:418`): + `fogStart = NearRadius * 192 * FogStartMultiplier`, + `fogEnd = FarRadius * 192 * FogEndMultiplier`, defaults `0.7` / `0.95` + (`src/AcDream.App/RuntimeOptions.cs:145-146`). + At High: **fogStart 537.6 m, fogEnd 2188.8 m.** + +### 2.5 The defect, quantified + +At the shipped default the user can see terrain to roughly **2189 m** +(fog end, inside a 2304 m Far window). The reveal barrier opens once +**192 m** — the centre landblock plus one ring — is Near-tier complete. +That is an **11.4:1** gap, and the far end of it is exactly where the user +reported watching the world assemble. Retail's equivalent gap is 1:1 by +construction (§1.1). + +### 2.6 The reveal lifecycle owners (post-J6.2) + +Canonical: `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs` +(`public sealed class … : IRuntimePortalView`). + +| Concern | Owner | Line | +|---|---|---| +| reveal generation | `BeginRevealCore` (`checked(++_nextGeneration)`) | `:386` (entered `:135`, `:159`) | +| destination/readiness latch | `AcknowledgeDestinationReadiness` | `:558-593` | +| materialization/simulation edge | `AcknowledgePortalMaterialized` | `:595-638` | +| viewport | `AcknowledgeWorldViewportVisible` | `:640-660` | +| completion / cancellation | `Complete` / `Cancel` | `:685`, `:722` | +| wait-cue latch | `ObserveWait`, `RetailWaitCueDelay = 5 s` | `:662-683`, `:66` | +| host projection lifetime | `TryRegisterHostProjection` … | `:189`, `:282`, `:299`, `:309` | + +Graphical adapter: `WorldRevealCoordinator` +(`src/AcDream.App/Streaming/WorldRevealCoordinator.cs:38`); it owns the +barrier (`:58`, `:80-87`), bridges App readiness into the Runtime latch in +`Evaluate` (`:183-202`), and computes the reservation radius via the +static `WorldRevealReadinessBarrier.RequiredRenderRadius` at `:167`, +handing it to `IWorldRevealStreamingScheduler.BeginDestinationReservation` +at `:408-411`. + +Reveal decision (portal): `LocalPlayerTeleportController.Tick` +(`src/AcDream.App/Streaming/LocalPlayerTeleportController.cs:486`, +decision block `:503-539`): +```csharp +bool dataReady = haveDestination && originReady + && _worldReveal.Evaluate(_pendingCell).IsReady; +bool placementReady = dataReady && TryAdvancePortalCommit(sequence); +if (haveDestination && !placementReady) _holdSeconds += deltaSeconds; +_presentation.SetWaitCue(haveDestination && !placementReady + && _worldReveal.ObserveWait(TimeSpan.FromSeconds(_holdSeconds))); +``` +Reveal decision (login): `LivePlayerModeAutoEntryContext.IsWorldReady` +(`src/AcDream.App/Input/PlayerModeAutoEntry.cs:106-111`). + +**Slice E's hold mechanism is correct and already in place.** #280 is not +a missing hold — it is the hold measuring the wrong domain. Say this +plainly in the commit message; it is the difference between a two-file +change and a redesign. + +### 2.7 The destination reservation + +`StreamingController` `DestinationReservation(long RevealGeneration, +uint LandblockId, int Radius)` (`:24-27`), opened at `:145-161`, radius +supplied by the coordinator = the reveal radius. Two effects: +enqueue-order priority (`EnqueueLoadsByRevealPriority` `:994-1012`, +membership `IsDestinationWork` `:1079-1082`, Chebyshev ≤ radius) and a +budget lane reservation of `DestinationReserveFraction = 0.75` +(`src/AcDream.App/Streaming/StreamingWorkBudgetOptions.cs:33`; enforcement +`StreamingWorkBudget.cs:385-438`) capping non-destination work at 25% of +every dimension. + +--- + +## 3. The scoping question: derive from what? + +**Retail's `mid_radius` is derived from nothing — it *is* the user's +landscape-draw-distance preference, assigned directly** (§1.1). It is not a +function of `m_bUseViewDistance` (§1.3) and not a function of the frustum +(§1.2). Retail exposes exactly one landscape-extent number and uses it for +loading, drawing, and blocking. + +**acdream has no view-distance setting.** `ViewDistance` / `view_distance` +/ `LandscapeDrawDistance` / `DrawDistance` return zero hits under `src/`. +What acdream has is `QualitySettings.FarRadius` — a per-preset landblock +radius that bounds the loaded and drawn landscape and drives the fog end. +**`FarRadius` is acdream's structural analogue of retail's +`LandscapeDrawDistance`,** and the two ladders are strikingly close +(retail 3/5/8/11/15/25 vs acdream 5/8/12/15). + +Therefore: + +> **D0 — #280 derives its window from the live streaming radii +> (`NearRadius`, `FarRadius`) and does NOT depend on a Viewing Distance +> option existing.** + +Retail's Viewing Distance *option* — a dedicated, user-facing, +six-position enum with retail's exact values, replacing or reparameterising +the quality preset's radii — **is a genuinely separate missing feature.** +It is not required for #280 and must not be invented inside it. File it as +its own issue (suggested text in §14). When it lands, it lands by changing +what feeds `NearRadius`/`FarRadius`; #280's derivation keeps working +untouched, which is the point of deriving rather than duplicating. + +**No standalone prefetch knob.** The defect *is* the decoupling of the +reveal window from the visible window; a knob re-exposes it as a feature +and a low setting reintroduces the bug. A **diagnostic** override is +legitimate and belongs in a `PhysicsDiagnostics`-style owner per CLAUDE.md +rule 5 — see D5. + +**Runtime mutability.** The radii can change mid-session +(`ReconfigureRadii` `:377`). Retail's answer is unambiguous +(`SmartBox::set_mid_radius` @ `0x00453180`, §1.5): reset, re-radius, and +re-arm the blocking prefetch at the **new** value. So acdream must read +the radii **live**, per evaluation, not capture them at barrier +construction — which falls out for free from D1 and needs no extra +machinery, because `Evaluate` already runs every frame. See D4 for the +reservation half, which does need explicit handling. + +--- + +## 4. The change — pinned + +### D1 — the barrier's radius becomes an instance value read live from the streaming window + +`WorldRevealReadinessBarrier` gains an injected +`Func` (or equivalent two-int accessor) supplying +the **current** `NearRadius` and `FarRadius`, and +`RequiredRenderRadius(uint)` stops being `static`: + +``` +indoor -> 0 (unchanged; retail's EnvCell arm, §1.4) +outdoor -> FarRadius (retail's mid_radius, §1.1) +``` + +The snapshot record gains the near/far split so the predicate and the +Runtime acknowledgement carry the same shape. Composite preparation +(`Prepare`, `:84-92`) keeps using **`NearRadius`**, not `FarRadius` — see +D3. + +Rationale for `FarRadius` rather than `NearRadius`: retail gates on the +whole drawn square, and acdream's drawn square is the Far window +(fog end 2189 m < Far extent 2304 m at every preset, since +`FogEndMultiplier = 0.95 < 1`, so `FarRadius` always covers everything the +user can see). Gating at `NearRadius` would fix only the near-detail pop +and leave the reported far-terrain symptom intact. + +### D2 — the render predicate becomes tier-aware + +`StreamingController.IsRenderNeighborhoodResident` takes a near radius and +a far radius, and per ring member at Chebyshev distance `d`: + +``` +d <= nearRadius : require IsNearTier(canonical) && IsRenderReady(canonical) +d <= farRadius : require IsLoaded(canonical) && IsRenderReady(canonical) +``` + +Off-map coordinates keep being skipped (`nx/ny` outside `0..254`), matching +retail's `>= 0x7F8` bounds skip (§1.4) and the existing comment at +`StreamingController.cs:244-245`. + +This is the half without which D1 cannot work: today's `IsNearTier` +requirement makes any radius above `NearRadius` unsatisfiable (§2.3). +`IsRenderReady` is already true for a published Far landblock (verified, +§2.3), so the outer arm is a real test of "this landblock's terrain is +published and drawable", not a rubber stamp. + +`PhysicsEngine.IsNeighborhoodTerrainResident` needs no semantic change — +it is already tier-agnostic and Far publishes terrain collision — but it +needs the allocation fix in D6. + +### D3 — composites stay at the near radius, and this is a fact about the data, not a shortcut + +`WbDrawDispatcher.IsCompositeWarmupCandidate` +(`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:335-360`) filters +**entities** by `IsWithinLandblockRadius` (`:451-457`) and admits only +those with a `PaletteOverride` or per-mesh `SurfaceOverrides`. Far-tier +landblocks carry **zero entities** (`LandblockBuildFactory.cs:131-142`), so +widening the composite radius over Far rings warms nothing while walking +625 landblocks' worth of nothing. Passing `NearRadius` is the honest +domain, not a scope cut. Pin it explicitly so a later reader does not +"fix the inconsistency" by widening it. + +### D4 — the destination reservation uses the same radius as the gate + +`WorldRevealCoordinator` (`:167`, `:408-411`) hands +`BeginDestinationReservation` the new (Far) radius. Retail has one square +for prefetch and for blocking; it has no concept of prioritising an inner +ring differently, and splitting them here would invent a mechanism retail +does not have. + +Consequence to measure, not to assume (§8-P4): with the reservation +covering the whole Far window, "non-destination work" during a hold shrinks +to out-of-window traffic (retirements, unloads, live entities), which the +25% lane must still drain. If the measurement shows retirement/unload +backlog growth across a hold, that is a real finding and gets its own +issue — **not** a quiet radius split inside this slice. + +Runtime mutability: if the radii change while a reservation is open, +follow retail — end the reservation and reopen it at the new radius on the +same reveal generation. `EndDestinationReservation` already refuses a +generation mismatch (`:145-183`), so the reopen is generation-safe. + +### D5 — the diagnostic override + +One diagnostic-owner property (CLAUDE.md rule 5), read once at startup, +e.g. `StreamingDiagnostics.RevealRadiusOverride` from +`ACDREAM_PROBE_REVEAL_RADIUS`. Default unset ⇒ D1's derivation. Its only +purpose is A/B measurement of the stall (§7) and reproduction of the +pre-fix behaviour during the connected gate (§10). It is **not** a user +setting, is not surfaced in Settings, and is not persisted. Do not add it +to `RuntimeOptions` as a general knob — it is a probe. + +### D6 — `IsNeighborhoodTerrainResident` stops allocating per call + +`src/AcDream.Core/Physics/PhysicsEngine.cs:129-146` builds a +`HashSet` over every resident landblock on **every** call. It is +called every frame during a hold (via +`RenderFrameResourceController.Prepare`, +`src/AcDream.App/Rendering/RenderFrameResourceController.cs:256-265`). +Today that is 9 ring members and one set build; after D1 it is up to 625 +ring members and the same set build, at 30–60 Hz, against Slice I1's +"0 B/resolve" standard. Replace the set build with direct per-member +lookups against the existing landblock map. This is a direct cost of the +change, not opportunistic cleanup — it ships in the same commit. + +### D7 — the Runtime invariant stops re-encoding the App's value + +`RuntimeWorldTransitState.cs:573` currently asserts +`RequiredRenderRadius == (isIndoor ? 0 : 1)`. Runtime must not know the +App's streaming configuration — that is the J-slice ownership boundary. +Replace with a **shape** invariant that is still a real invariant: + +``` +indoor => RequiredRenderRadius == 0 +outdoor => RequiredRenderRadius >= 1 +``` + +plus the existing `IsIndoor` consistency check, which stays. The two +producers (`RuntimeLiveEntitySessionController.cs:776`, +`HeadlessSessionWorldProjection.cs:973`) keep emitting +`indoor ? 0 : 1` and remain legal: neither host has a streaming window, so +"the centre ring" is the honest token there (§5, class C). Do **not** +plumb the App's radii into Runtime to make the strict equality survive — +that would be exactly the mechanism-that-does-not-exist assertion C5b was +built to stop. + +### D8 — register bookkeeping, in the implementation commit + +AD-2 in `docs/architecture/retail-divergence-register.md` is the row that +covers this machinery and already cites `blocking_for_cells` and +`SmartBox::UseTime @0x00455410`. It currently describes the outdoor gate +as "terrain/collision residency for the required **Near** ring". Amend it +in the same commit to state the derived Far-window gate, the two-tier +completeness split (Near ring: full publication; Far ring: terrain +publication), the `NearRadius`-scoped composite domain, and the anchor +`LScape::PreFetchCells @0x00505660` / +`Render_LandscapeDrawDistance_Values @0x007CA988`. + +A **new** row is required for the residual deviation the fix does not +remove: acdream's outer reveal ring accepts terrain-only publication where +retail requires the landblock's LandBlockInfo and every building EnvCell +(§1.4). Risk column: a distant building or its interior shells can still +pop in after reveal, at Far-ring distances, where retail would have +blocked. This is a real, named, bounded residual — do not let the slice +close claiming parity it does not have. + +--- + +## 5. Blast radius, by consumer class, both hosts + +C5b's enumeration leaked because it was performed over one host's call +graph. This one is organised by host first. + +### Class A — graphical host, reveal-gating consumers. Verdict: INTENDED CHANGE. + +- `WorldRevealReadinessBarrier.Evaluate` / `IsReady` — the behaviour under + change. +- `LocalPlayerTeleportController.Tick:503-539` (portal `dataReady`) and + `LivePlayerModeAutoEntryContext.IsWorldReady` + (`PlayerModeAutoEntry.cs:106-111`) (login). Both hold longer. Login + already held behind the same barrier, so first-login also gets the wider + gate — **state this as intended**, and gate it (§10), because the issue + only reported recall. +- `RenderFrameResourceController.Prepare:256-265` — per-frame + `PrepareAndEvaluate`. Frequency unchanged; per-call cost rises (D6). +- `WorldRevealCoordinator.Evaluate:183-202` — forwards the wider snapshot + into the Runtime latch. + +### Class B — graphical host, streaming scheduling. Verdict: CHANGED BY DESIGN, measured. + +- `StreamingController.BeginDestinationReservation:145-161`, + `IsDestinationWork:1079-1082`, `EnqueueLoadsByRevealPriority:994-1012` — + the destination set grows from 9 to up to 625 landblocks (D4). +- `StreamingWorkBudget` destination/non-destination lanes (`:385-438`) — + the 25% non-destination cap now applies to a much larger fraction of the + frame's work during a hold. Measured, P4. + +### Class C — Runtime, canonical reveal state. Verdict: SHAPE LOOSENED, no behavioural change for existing producers. + +- `RuntimeWorldTransitState.AcknowledgeDestinationReadiness:558-593` — + invariant loosened (D7). Every value the two non-graphical producers + emit today remains legal. +- `RuntimeDestinationReadiness` (`src/AcDream.Runtime/GameRuntimeViews.cs:114`) + — carries the radius as data; its `IsReady` join is unchanged. +- `RuntimeLiveEntitySessionController.TryAdvancePortalCompletion:757-799` + — unchanged. + +### Class D — no-window host. Verdict: UNAFFECTED, and that is correct. + +`HeadlessSessionWorldProjection.PrepareDestination` +(`src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs:832`, +readiness at `:955-976`) returns render-ready and composites-ready +hardcoded `true`, with `IsCollisionReady` = "the placement committed". The +headless host **has no streaming window, no render publication, and no +composites**; there is nothing for a wider radius to mean. Its +`RequiredRenderRadius: indoor ? 0 : 1` at `:973` is a token that satisfies +the Runtime shape check, and D7 keeps it legal. + +Explicitly: **`AcDream.App` types must not appear in the headless path, +and the headless radius must not be derived from anything.** The only way +#280 can break headless is by tightening the Runtime invariant instead of +loosening it — hence D7, hence the headless assertion in §9. + +### Class E — presentation-only. Verdict: HARMLESS. + +`PortalTunnelPresentation` (`:290`, `:296-297`, `:381`), +`PortalWaitNoticeController`, `LocalPlayerTeleportPresentation:353`, +`RuntimeWorldFrameVisibilityPreparation.Begin` +(`src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs:332-342`). These +observe a longer hold; none of them gate anything. The wait cue's +*frequency of appearance* changes — §6. + +### Class F — tests. Verdict: MUST BE REWRITTEN, NOT RELAXED. + +`tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs` +(`:63`, `:69`, `:76`, `:140`) and +`tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs:65` +assert the constant. §9 replaces them with derivation and tier assertions. +Anything that "fixes" them by re-asserting a new literal is the trap in +T3. + +--- + +## 6. Interaction with the wait cue + +acdream's cue: `RuntimeWorldTransitState.ObserveWait:662-683`, threshold +`RetailWaitCueDelay = TimeSpan.FromSeconds(5)` (`:66`), driven only from +`LocalPlayerTeleportController.cs:536` via `WorldRevealCoordinator.ObserveWait:245-250`; +rendered as a centered `UiText` `"PortalSpaceWaitNotice"` +(`src/AcDream.App/UI/PortalWaitNoticeController.cs:10-37`) with the literal +`"In Portal Space - Please Wait..."` +(`src/AcDream.App/Rendering/PortalTunnelPresentation.cs:296-297`, `:381`). +It is graphical-host-only; headless has no `ObserveWait` caller anywhere +(its analogue is the parked-completion log at +`RuntimeLiveEntitySessionController.cs:792-799`). + +**Pinned expectation:** after the fix the user will see +`"In Portal Space - Please Wait..."` on recalls **more often**, and the +tunnel will run longer before the world appears. + +**This is retail behaviour, not a divergence.** The retail string is +byte-identical (§1.6) and retail emits it repeatedly for the whole duration +of a blocked prefetch, whose retry is quantised to 5 s (§1.5). A retail +client at `LandscapeDrawDistance = 8` recalling into a cold cache sits in +the tunnel with the cue and a `CURRENT/TOTAL` cell counter until all 289 +landblocks are in. acdream showing the cue on a recall is convergence +toward retail, not away from it. **No register row is required for the +longer hold or the more frequent cue.** + +Two adjacent facts, both **out of scope**, both worth writing down so a +later session does not mistake them for #280 regressions: + +1. acdream's 5-second threshold is not retail's trigger. Retail emits on + each tunnel rotation-segment boundary (`0x004D6FC7` → `0x004D70A1`), + not on a fixed elapsed threshold. The 5 s constant is an acdream + approximation. Do not change it in this slice; if it is ever revisited, + the anchor is `gmSmartBoxUI::UseTime @ 0x004D6E30`. +2. acdream has no analogue of retail's DDD progress bar + (`gmPowerbarUI::RecvNotice_RuntimeDDDStatus @ 0x004DA5C0`, string + `ID_Powerbar_DDDModeText`, `CURRENT`/`TOTAL`). With longer holds this + becomes more noticeable. File it; do not build it here. + +--- + +## 7. Performance risk, stated honestly + +**The change does not add streaming work.** The Far window is already +25×25 at the default preset; those landblocks are already queued and +already built. #280 makes the reveal *wait* for a tail that is already +being produced. What lengthens is the hold, not the workload — with three +exceptions, each of which must be measured: + +1. **Priority inversion at the reservation boundary (D4).** Growing the + destination set from 9 to 625 landblocks changes what the 75% reserved + lane prioritises. Best case it strictly helps (the tail we now wait on + is the tail we now prioritise). Worst case out-of-window retirement and + unload starve inside the 25% remainder. +2. **`IsNeighborhoodTerrainResident` per-call cost (D6).** Unfixed, this + grows from ~9 to ~625 membership tests *plus* a full-map `HashSet` + rebuild, every frame, during the hold. D6 removes the rebuild; the + remaining 625 lookups are the honest cost. +3. **Near-ring composite warmup.** D1 does not widen the composite radius + (D3), but the *reveal* now waits on `AreCompositeTexturesReady`, which + is already `NearRadius`-scoped — i.e. the composite domain is unchanged + and is not a new risk. Confirm rather than assume (P3). + +**The budget this is measured against.** The project already has an +explicit acceptance edge, written into +`src/AcDream.App/Streaming/StreamingWorkBudgetOptions.cs:26-30`: an +earlier `MaxEntityOperations: 256` was raised to `4096` precisely because +it "stretched destination publication past retail's five-second +wait-notice edge." So: + +- **Primary budget: destination convergence.** The hold must converge; the + automated ceiling already exists in the harness as + `wait world-visible 30000` in `tools/connected-world-lifecycle.route.txt`. + A hold that trips that timeout is a failure, not a slower pass. +- **Secondary budget: steady-state frame cost must not regress.** The + standing reference figures are the ordinary production profile at + 519.7 FPS with CPU/GPU p50 1.869 / 1.096 ms and 652.1 / 928.3 MiB + working/private (CLAUDE.md, Slice G5). The hold is a transient; the + post-reveal steady state must land inside those figures. +- **Tertiary: allocation.** Slice I1's 0 B/resolve standard is what D6 + protects. + +**Measurement, not judgement.** `WorldLifecycleCheckpoint` +(`src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs:101-110`) +already serialises `RuntimePortalSnapshot Reveal`, +`StreamingWorkDiagnostics StreamingWork`, `ResidencySnapshot Residency`, +`RenderFrameOutcome Render`, `Fps`, `FrameMilliseconds`. +`StreamingWorkDiagnostics` +(`src/AcDream.App/Streaming/StreamingWorkBudget.cs:166-184`) carries +`PendingPublications`, `PendingRetirements`, `DestinationBacklog`, +`ControlBacklog`, `UnloadBacklog`, `NearBacklog`, `FarBacklog`, +`WorkerCompletionBacklog`, `DeferredCompletions`, +`LifetimeFrameOverrunCount`, `MaximumFrameMilliseconds`. **Everything §8 +needs is already emitted; no new telemetry is required.** + +**The A/B.** Run the same route twice on the same binary — once with +`ACDREAM_PROBE_REVEAL_RADIUS=1` (pre-fix behaviour) and once without +(D5). The delta in hold duration per stop, and the delta in +`NearBacklog`/`FarBacklog` at the `world-visible` checkpoint, is the whole +performance story. Report both; do not report only the post-fix number. + +--- + +## 8. Proof obligations — must be proven, not assumed + +- **P1 — A Far-tier landblock satisfies `IsRenderReady`.** Traced + statically (§2.3: `PublicationKind.Far` → `CommitLandblockSpatial` → + `ActivateLandblockPresentation` → `OnLandblockLoaded` with an empty mesh + set → `IsLandblockRenderReady` true). **Prove it with a test**, because + the entire fix rests on it: if a Far landblock is not render-ready, D2's + outer arm never satisfies and the reveal hangs. +- **P2 — The outer ring converges within the Far window's own lifetime.** + The window unloads at `FarRadius + 2` Chebyshev + (`src/AcDream.App/Streaming/StreamingRegion.cs:208-209`), so an outer-ring + member cannot be evicted while it is inside `FarRadius`. Prove the gate + cannot deadlock against hysteresis, recenter (`IsRecenterPending`, + already gated at `LocalPlayerTeleportController.cs:505`), or dungeon + collapse (`StreamingController.IsCollapsedToDungeon`) — specifically: + an **outdoor** destination while the window is collapsed to a dungeon. +- **P3 — Composite readiness is `NearRadius`-scoped and unchanged.** + Confirm `IsCompositeWarmupCandidate` sees no Far-ring entities (Far + builds carry `Array.Empty()`), so D3 is a statement about + the data and not a scope cut. +- **P4 — The 25% non-destination lane still drains during a hold.** + Measured from the checkpoint's `PendingRetirements` / `UnloadBacklog` / + `ControlBacklog` across the hold. A monotonic rise is a finding. +- **P5 — Runtime and headless are untouched.** Assert the loosened + invariant accepts every radius both non-graphical producers emit, and + that the headless dependency guard still passes with no App reference. +- **P6 — The radii are read live.** Prove that changing the quality preset + mid-hold changes the gate on the next evaluation, and that the + reservation reopens at the new radius on the same generation (D4). + Retail anchor: `SmartBox::set_mid_radius @ 0x00453180`. +- **P7 — Login uses the same widened gate.** The barrier is shared; + confirm first-login readiness moves with it and does not regress the + `capped_login` checkpoint. +- **P8 — D6 restores 0 B/allocation** on the terrain-neighborhood path at + the new radius. + +--- + +## 9. Test plan + +Assert at layers that have broken historically. **No source-text pins. No +test that re-encodes the constant under test** — a test asserting +`RequiredRenderRadius == 12` is the same defect in a different file. + +1. **Derivation, not value** (`WorldRevealReadinessBarrierTests`). Drive + the barrier with a fake window reporting `(near: 3, far: 8)` and then + `(near: 5, far: 15)`; assert the outdoor required radius **equals the + fake's far radius** in both cases and that indoor is 0 in both. The + assertion references the fake's input, never a literal. +2. **Live re-read.** Mutate the fake window between two `Evaluate` calls + with no reconstruction; assert the second evaluation used the new + radius (P6). +3. **Tier-aware ring** (`StreamingController` / + `IsRenderNeighborhoodResident`). Three cases: (a) inner-ring member at + Far tier ⇒ **not** resident; (b) outer-ring member at Far tier and + render-ready ⇒ resident; (c) outer-ring member absent ⇒ not resident. + Case (a) is the discriminating one — it proves the near arm did not + get loosened into the far arm. +4. **P1 as a test.** A Far publication through the real pipeline, then + `GpuWorldState.IsRenderReady(farLandblock) == true`. If this needs the + real `LandblockSpawnAdapter`, use it; a fake here proves nothing. +5. **Off-map skip.** A destination at a map corner still converges — the + out-of-bounds members are skipped, not required (retail parity, §1.4). +6. **Runtime shape invariant** (`RuntimeWorldTransitStateTests`). Assert + `AcknowledgeDestinationReadiness` accepts `(indoor: false, radius: 1)`, + `(false, 12)`, `(false, 25)`; rejects `(indoor: true, radius: 1)` and + `(false, 0)`. This is the test that would have caught the four-site + duplication (§2.2). +7. **Headless producer stays legal** + (`HeadlessSessionWorldProjection` / `AcDream.Headless.Tests`): its + `indoor ? 0 : 1` acknowledgement is accepted, and the existing + dependency/loaded-assembly guards still pass. +8. **Login parity.** `LivePlayerModeAutoEntryContext.IsWorldReady` returns + false while an outer-ring member is missing and true once it lands. +9. **Allocation** (D6/P8): a warmed loop over + `IsNeighborhoodTerrainResident` at radius 12 measures 0 managed bytes, + matching the Slice I1 pattern. +10. **Reservation radius follows the gate** (D4): the tuple handed to + `BeginDestinationReservation` carries the derived far radius, asserted + against the fake window's input, not a literal. + +Delete — do not adapt — the two existing assertions that pin `1` +(`WorldRevealReadinessBarrierTests.cs:63,69,76,140`, +`LocalPlayerTeleportControllerTests.cs:65`). Test 1 replaces them. + +--- + +## 10. Gates + +- Focused tests above. +- **Release build**, then the complete Release suite: + `$env:ACDREAM_PAK_PATH` set, `dotnet test AcDream.slnx -c Release -m:1`. + **Re-measure the baseline at the implementation HEAD; do not inherit + it.** Known separately-filed flakes — **#302** + (`PortalProjectionTests.ClipToRegion_FrameOwnedStore_…`), **#308** + (`NakEmissionTests.LossSoak_…`), **#321** (`DatSoundCacheTests` + concurrent-decode-dedup). If one appears, re-run and name which; never + fold, never mask, never retry-loop. +- **Automated route, twice, same binary** — + `tools/run-connected-world-lifecycle-gate.ps1` over + `tools/connected-world-lifecycle.route.txt`, once with + `ACDREAM_PROBE_REVEAL_RADIUS=1` and once without (§7 A/B). The route + already covers the cases that matter: `capped_login`, a dense outdoor + island, a world-edge streaming transition, and an indoor Facility Hub + cell. Its `wait world-visible 30000` is the convergence ceiling. +- **Connected/visual gate: YES**, and **batched into C5c's visual matrix**, + which the campaign sequences after #280. Release, `ACDREAM_RETAIL_UI=1`, + two-client. + +### The visual gate's positive evidence + +The user-facing observable — "no visibly constructing far terrain after +portal space exits" — is an **absence**, and C5b's rule (g) says an absence +is not a pass criterion. So the gate produces three positive artifacts per +stop, all from machinery that already exists: + +1. **A checkpoint JSON captured at `world-visible`** whose + `StreamingWork.NearBacklog`, `.FarBacklog`, `.DestinationBacklog` and + `.PendingPublications` are **zero for the destination window at the + moment the viewport opened**. That is the positive form of "nothing was + still building." A screenshot alone cannot say this; the backlog counts + can. +2. **A hold-duration pair**: `_holdSeconds` (equivalently + `Reveal.WaitCueShown` plus the checkpoint timestamps) with the probe on + and off. The expected, *reportable* result is that the post-fix hold is + **longer**. A post-fix hold that is not longer means the gate did not + actually widen and the run proves nothing. +3. **A paired screenshot** at each stop from the two A/B runs. The pre-fix + run is expected to *show* the defect; that is the run that makes the + post-fix screenshot mean something. + +Recall specifically (the reported repro) must be in the matrix, not only +`/teleloc`: the user observed this "after some recalls". Add a lifestone / +recall leg to the C5c session even though the automated route uses +`/teleloc`. + +--- + +## 11. Traps + +- **T1 — the four-site radius.** `OutdoorNeighborhoodRadius` is not the + single source of truth (§2.2). Changing it alone trips + `FailInvariant("invalid-readiness-shape")` at + `RuntimeWorldTransitState.cs:573` and the reveal never opens — which + will look like "the fix hangs the client" and invite reverting the + radius instead of fixing the invariant. +- **T2 — bumping the constant without D2.** Any radius above `NearRadius` + is unsatisfiable while `IsRenderNeighborhoodResident` demands + `IsNearTier`. The symptom is an infinite hold with the wait cue up. The + wrong conclusion available at that moment is "the world can't stream + that far"; the right one is "the predicate refuses Far tier." +- **T3 — a test that re-encodes the new number.** Replacing + `Assert(radius == 1)` with `Assert(radius == 12)` reproduces the exact + defect class this slice exists to remove. Assert against the window's + reported value (§9-1). +- **T4 — plumbing App radii into Runtime** to keep the strict equality at + `:573`. That is the C5b mechanism-that-does-not-exist failure and it + breaks the J-slice ownership boundary and the headless dependency guard. + Loosen the invariant (D7). +- **T5 — widening the composite radius "for consistency."** Far-tier + landblocks have no entities; the composite domain is entity-scoped + (D3/P3). Widening it walks 625 landblocks to warm nothing and pressures + a 128 MiB physical budget for no benefit. +- **T6 — reading the radii once at construction.** Radii are runtime + mutable (`ReconfigureRadii:377`) and retail explicitly re-arms the + blocking prefetch on a radius change (`set_mid_radius @0x00453180`). A + captured radius is a latent bug that only appears when someone opens + Settings mid-portal. +- **T7 — `ACDREAM_STREAM_RADIUS` during measurement.** It forces + `NearRadius` to its value and only *raises* `FarRadius` + (`SessionPlayerComposition.cs:249-257`), and it is silently discarded by + any later `ApplyQuality` (`RuntimeSettingsTargets.cs:247-253`). A gate + run with it set is measuring a different window than production. Leave + it unset. +- **T8 — treating the longer hold as a regression.** It is the fix + working, and it is retail (§1.5, §6). The failure condition is + *non-convergence*, not duration. +- **T9 — "the far terrain is behind the fog, so don't gate it."** It is + not: `fogEnd = FarRadius * 192 * 0.95` is inside the Far extent at every + preset, so far-ring terrain is visible through fog, which is exactly what + the user watched assemble. Do not introduce a fog-derived radius; keep + the retail shape (one square). +- **T10 — declaring parity.** The outer ring accepts terrain-only + publication where retail requires LandBlockInfo and building EnvCells + (§1.4). A distant building can still pop. That residual gets its own + register row (D8) and must not be quietly dropped from the closeout. +- **T11 — scope creep into the Viewing Distance option.** §3 files it as a + separate feature. Adding a user-facing setting inside #280 makes the + slice unreviewable and re-opens the decoupling the slice exists to + close. + +--- + +## 12. Size and split call + +**One slice. Do not split.** + +Estimated production change, by site: + +| Site | Lines | +|---|---| +| `WorldRevealReadinessBarrier.cs` (instance radius, near/far split, snapshot shape) | ~40 | +| `StreamingController.IsRenderNeighborhoodResident` (tier-aware ring) | ~20 | +| `WorldRevealCoordinator.cs` (instance `RequiredRenderRadius`, reservation radius, radius-change reopen) | ~15 | +| `SessionPlayerComposition.cs` (wire the live window accessor) | ~10 | +| `PhysicsEngine.IsNeighborhoodTerrainResident` (D6, allocation) | ~15 | +| `RuntimeWorldTransitState.cs:573` (D7, shape invariant) | ~8 | +| Diagnostic owner (D5) | ~10 | +| **Total** | **~120** | + +Plus ~10 focused tests and two register edits (D8). + +Splitting is worse here, not better: D1 without D2 hangs the client (T2), +and D2 without D1 is dead code. D6 and D7 are both *forced* by D1 — D7 +because the acknowledgement fails otherwise, D6 because the per-frame cost +otherwise regresses against a standing budget. There is no bisectable +intermediate state, so a split produces commits that are individually +broken, which is worse for the campaign's bisect discipline than one +120-line behaviour commit. + +Sequence within the one commit: D7 (loosen) → D2 (predicate) → D1 +(derive) → D4/D5/D6. Register edits in the same commit (D8). + +--- + +## 13. Claims found false or stale at HEAD `9ee9c1a1` + +1. **The issue's "the normal configured view extends substantially + farther" names a setting that does not exist.** `grep -rniE + "viewdistance|view_distance|ViewDistance|LandscapeDrawDistance|DrawDistance"` + over `src/` returns nothing. The *conclusion* is right — the visible + extent is ~2189 m of fog inside a 2304 m Far window against a 192 m gate + — but the premise should read "the configured streaming/fog window + (`QualitySettings.FarRadius`)", not "the configured view distance." +2. **The issue's implied fix is incomplete.** It frames #280 as "the gate + is only radius 1." Raising that constant alone cannot work: any radius + above `NearRadius` is unsatisfiable while + `IsRenderNeighborhoodResident` requires `IsNearTier` + (`StreamingController.cs:250`), and the change also fails the Runtime + invariant at `RuntimeWorldTransitState.cs:573`. It is a radius change + **and** a predicate change **and** an invariant loosening. +3. **`OutdoorNeighborhoodRadius` is not the single source of truth.** + Three uncited `indoor ? 0 : 1` literals exist outside it, one of them + validating (§2.2). Any doc or comment implying one owner is wrong. +4. **CLAUDE.md's `ACDREAM_STREAM_RADIUS` description is stale.** It says + "tune landblock visible-window radius (default 2 = 5×5)". At HEAD it is + a *legacy* override (`RuntimeOptions.cs:113-115`) whose default is + `null`/unset; when set it forces `NearRadius` and only raises + `FarRadius` (`SessionPlayerComposition.cs:249-257`); and it is silently + discarded by any runtime `ApplyQuality` + (`RuntimeSettingsTargets.cs:247-253`). The shipped defaults are the + `QualityPreset.High` row, 4/12. +5. **`claude-memory/reference_two_tier_streaming.md` is stale in four + ways.** (a) N₁=4 / N₂=12 are the `High` row of a four-row preset table + (`QualityPreset.cs:29-36`), not standalone constants, and are runtime + mutable. (b) The Far tier is not "terrain render only" — it also + publishes terrain **collision** + (`LandblockPhysicsPublisher.cs:390-403`, reached for + `PublicationKind.Far` via `LandblockPresentationPipeline.cs:619`), which + is precisely why D2's outer arm is viable. (c) + `MaxCompletionsPerFrame` is no longer a per-frame completion drain; it + is a whole-profile scalar over a seven-dimension typed budget + (`StreamingController.cs:117-134`, + `StreamingWorkBudgetOptions.cs:79-96`). (d) It documents none of + `IsRenderReady`, `WorldRevealReadinessBarrier`, the destination + reservation, or the 75% reserved lane — all of which are now the + mechanism for "is this landblock done." +6. **The campaign plan's phrase "retail's configured destination-prefetch + window" (`2026-08-02-placement-cutover.md:65`) is right in intent and + slightly wrong in shape.** Retail has no separate prefetch window; it + has one landscape window (`mid_radius`) that is simultaneously loaded, + drawn, and blocked on, and its configured value is + `Render.LandscapeDrawDistance`. +7. **AD-2's outdoor-gate wording will be wrong after this slice.** It + describes the outdoor claim as requiring residency "for the required + Near ring." Amend in the implementation commit (D8). +8. **The `#280` issue text implies portal-only.** The barrier is shared + with login (`PlayerModeAutoEntry.cs:106-111`), so first-login reveal + widens too. Intended, but it must be gated (P7) and stated at closeout. +9. **acdream's `RetailWaitCueDelay = 5 s` is not retail's trigger.** The + *string* is exact; the *trigger* is retail's tunnel rotation-segment + boundary (`gmSmartBoxUI::UseTime @ 0x004D6E30`, `0x004D6FC7` → + `0x004D70A1`), not a fixed elapsed threshold. Out of #280's scope; + recorded so it is not mistaken for a #280 regression. +10. **acdream's far plane (5000 m) differs from retail's + `Render::zfar` (4000 m, byte-verified).** Independent of #280 — in + both clients the landscape horizon is the landblock window, not the + frustum — but it is an uncited divergence sitting in four camera + classes and should be filed. + +--- + +## 14. What #280 does NOT do + +- It does **not** add a user-facing Viewing Distance option. That is a + separate missing feature: a dedicated six-position enum matching retail's + `Render.LandscapeDrawDistance` (labels VeryLow/Low/Medium/High/VeryHigh/ + Extreme, values 3/5/8/11/15/25 @ `0x007CA988`, default 8) feeding the + streaming radii, replacing or reparameterising the quality preset's + Near/Far pair. File it. #280's derivation keeps working when it lands. +- It does **not** add a user-facing prefetch knob (§3). +- It does **not** implement retail's DDD progress readout (§6). +- It does **not** change the wait cue's threshold or trigger (§6). +- It does **not** make the outer ring require LandBlockInfo or building + EnvCells the way retail does; that residual gets a register row (D8, T10). +- It does **not** touch the far plane (§13-10). +- It does **not** change `blocking_for_cells`-equivalent semantics: + acdream holds in the portal tunnel where retail freezes simulation + behind a hidden world. That divergence is AD-2's and stays AD-2's. + +--- + +## 15. Retail facts I could NOT establish + +Flagged rather than guessed: + +- **The index-1 choice label.** `Render_LandscapeDrawDistance_Choices[1]` + is initialised from `&data_793f8c` rather than an inline literal + (`:718533`), so its text is not visible in the pseudo-C. By position + between `VeryLow` and `Medium` it is almost certainly `"Low"`, but I did + not decode the string. The **value** (5) is byte-verified and is what + matters here. +- **Whether retail draws anything of the destination during a blocked + mid-session teleport.** `SmartBox::Hide` is called when the portal space + becomes visible (`0x004D6FB6`) and `SmartBox::Draw @ 0x00455570` returns + early on `hidden`, which strongly implies the world is not drawn behind + the tunnel — but I did not trace every `hidden` transition + (`0x00451D30` / `0x00451D40` are its setters and I did not name their + callers). Nothing in this contract depends on the answer; acdream's + tunnel covers the viewport either way. +- **The exact retail tunnel rotation-segment duration.** `RandDouble` at + `0x004D701B` / `0x004D7049` has its arguments mangled by BN's FPU + handling; the nearby immediates (`0x40768000`, `0x3FFCCCCC`, + `0x3FE33333`) look like the double halves but I did not decode them. + Only relevant to §13-9, which is out of scope. +- **Whether `Render::zfar` is ever assigned outside `GameSky::Draw`.** + Grep found only the static initialiser (4000.0, byte-verified) and the + two sky calls. I did not exhaustively search for indirect writes. + Relevant only to §13-10. diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index a29b8846..40c12694 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -370,6 +370,15 @@ internal sealed class SessionPlayerCompositionPhase foundation.TextureCache.SetDestinationRevealUploadPriority); var worldReveal = new WorldRevealCoordinator( live.WorldTransit, + // #280: read the radii LIVE from the streaming controller rather + // than capturing them here. They are runtime mutable through + // Settings (StreamingController.ReconfigureRadii), and retail + // re-arms the blocking prefetch at the new radius on a mid-hold + // change (SmartBox::set_mid_radius @0x00453180). + () => StreamingDiagnostics.ApplyRevealRadiusOverride( + new StreamingRevealWindow( + streaming.NearRadius, + streaming.FarRadius)), streaming.IsRenderNeighborhoodResident, d.PhysicsEngine.IsSpawnCellReady, d.PhysicsEngine.IsNeighborhoodTerrainResident, diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 48a1ab81..bdef28e1 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -217,17 +217,38 @@ public sealed class StreamingController internal bool IsCollapsedToDungeon => _collapsed; /// - /// True once every in-bounds landblock in the requested Chebyshev ring has - /// crossed the render-thread publication barrier. Worker completion and + /// True once every in-bounds landblock in the requested Chebyshev window + /// has crossed the render-thread publication barrier. Worker completion and /// world-state registration are not sufficient: all static GfxObj and /// EnvCell shell meshes must have completed their render-thread upload. /// Portal-space exit uses this alongside physics residency so the world /// cannot be revealed while its render slots are still absent. + /// + /// + /// #280: the window is TIERED, because acdream's loaded landscape is. + /// Members inside must be Near-tier — the + /// full publication retail's LScape::PreFetchCells @0x00505660 + /// demands (terrain record, LandBlockInfo, building EnvCells). Members out + /// to need only their Far-tier terrain + /// publication to be drawable, which is what the user actually sees out + /// there. Requiring Near tier across the whole window would be + /// unsatisfiable: nothing outside the Near ring is ever promoted, so the + /// reveal would hold forever. + /// /// - public bool IsRenderNeighborhoodResident(uint cellOrLandblockId, int radius) + public bool IsRenderNeighborhoodResident( + uint cellOrLandblockId, + int nearRadius, + int farRadius) { - if (radius < 0) - throw new ArgumentOutOfRangeException(nameof(radius)); + if (nearRadius < 0) + throw new ArgumentOutOfRangeException(nameof(nearRadius)); + if (farRadius < nearRadius) + { + throw new ArgumentOutOfRangeException( + nameof(farRadius), + "Far radius must be greater than or equal to near radius."); + } // LandDefs::InboundValidCellId validates both map axes and the low-word // class (outdoor cell, EnvCell, or canonical landblock sentinel). @@ -235,18 +256,26 @@ public sealed class StreamingController return false; int cx = (int)((cellOrLandblockId >> 24) & 0xFFu); int cy = (int)((cellOrLandblockId >> 16) & 0xFFu); - for (int dx = -radius; dx <= radius; dx++) - for (int dy = -radius; dy <= radius; dy++) + for (int dx = -farRadius; dx <= farRadius; dx++) + for (int dy = -farRadius; dy <= farRadius; dy++) { int nx = cx + dx; int ny = cy + dy; // Match PhysicsEngine.IsNeighborhoodTerrainResident: the outer - // 0xFF coordinate has no loadable neighbour beyond it. + // 0xFF coordinate has no loadable neighbour beyond it. Retail skips + // the same way (LScape::PreFetchCells' >= 0x7F8 bounds test). if (nx < 0 || nx > 254 || ny < 0 || ny > 254) continue; uint canonical = ((uint)nx << 24) | ((uint)ny << 16) | 0xFFFFu; - if (!_state.IsNearTier(canonical) || !_state.IsRenderReady(canonical)) + // GpuWorldState.IsRenderReady already implies IsLoaded; a Far-tier + // publication registers with the spawn adapter and an empty mesh + // set, so this is a real drawability test out there, not a stamp. + if (!_state.IsRenderReady(canonical)) + return false; + bool isInnerRing = Math.Abs(dx) <= nearRadius + && Math.Abs(dy) <= nearRadius; + if (isInnerRing && !_state.IsNearTier(canonical)) return false; } @@ -691,6 +720,7 @@ public sealed class StreamingController _destinationReservation is not null && !IsRenderNeighborhoodResident( DestinationLandblockId, + Math.Min(NearRadius, DestinationRadius), DestinationRadius); if (!ConvergePendingPublications( preferDestination: destinationPublicationIncomplete)) diff --git a/src/AcDream.App/Streaming/StreamingDiagnostics.cs b/src/AcDream.App/Streaming/StreamingDiagnostics.cs new file mode 100644 index 00000000..0d5cb79a --- /dev/null +++ b/src/AcDream.App/Streaming/StreamingDiagnostics.cs @@ -0,0 +1,56 @@ +using System; + +namespace AcDream.App.Streaming; + +/// +/// Diagnostic owner for the streaming/reveal probe family (CLAUDE.md Code +/// Structure Rules §5 — one static class per subsystem, typed properties read +/// from the environment once at startup, never per-call-site +/// GetEnvironmentVariable reads). +/// +internal static class StreamingDiagnostics +{ + /// + /// #280 A/B measurement probe. When set, the outdoor reveal gate uses this + /// landblock radius instead of the derived streaming window, so the same + /// binary can run the connected route once with the pre-fix behaviour + /// (ACDREAM_PROBE_REVEAL_RADIUS=1, the old + /// OutdoorNeighborhoodRadius) and once without. It is NOT a user + /// setting: it is not surfaced in Settings, not persisted, and not part of + /// RuntimeOptions. A shipped prefetch knob would re-expose exactly + /// the reveal-window/visible-window decoupling #280 exists to close. + /// Unset or unparseable leaves the derivation in charge. + /// + public static int? RevealRadiusOverride { get; } = + ParseRadius( + Environment.GetEnvironmentVariable("ACDREAM_PROBE_REVEAL_RADIUS")); + + /// + /// Applies to a live streaming window. + /// + public static StreamingRevealWindow ApplyRevealRadiusOverride( + StreamingRevealWindow window) => + ApplyRevealRadiusOverride(window, RevealRadiusOverride); + + /// + /// Pure override application, separated so the clamp is testable without + /// mutating process-wide diagnostic state. The near radius is clamped to + /// the (possibly overridden) far radius: the gate's inner arm demands + /// Near-tier publication, so it can never exceed the outer arm. + /// + public static StreamingRevealWindow ApplyRevealRadiusOverride( + StreamingRevealWindow window, + int? overrideRadius) + { + if (overrideRadius is not { } radius) + return window; + + int far = Math.Max(0, radius); + return new StreamingRevealWindow( + Math.Clamp(window.NearRadius, 0, far), + far); + } + + private static int? ParseRadius(string? raw) => + int.TryParse(raw, out int value) && value >= 0 ? value : null; +} diff --git a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs index e23bf3e0..d14117ff 100644 --- a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs +++ b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs @@ -43,7 +43,15 @@ internal sealed class WorldRevealCoordinator in WorldGenerationQuiescenceEdge quiescenceEdge) { public RuntimeWorldHostProjectionToken Token { get; } = token; - public int RequiredRenderRadius { get; } = requiredRenderRadius; + + /// + /// The radius this projection's streaming reservation is currently + /// open at. Mutable because the quality preset can be changed + /// mid-hold; retail re-arms the blocking prefetch at the new radius + /// (SmartBox::set_mid_radius @0x00453180) rather than finishing + /// the old hold at the old one. + /// + public int RequiredRenderRadius { get; set; } = requiredRenderRadius; public WorldGenerationQuiescenceEdge QuiescenceEdge { get; } = quiescenceEdge; public bool QuiescenceCommitted { get; set; } @@ -65,7 +73,8 @@ internal sealed class WorldRevealCoordinator public WorldRevealCoordinator( RuntimeWorldTransitState transit, - Func isRenderNeighborhoodReady, + Func revealWindow, + Func isRenderNeighborhoodReady, Func isSpawnCellReady, Func isTerrainNeighborhoodReady, Func areCompositeTexturesReady, @@ -78,6 +87,7 @@ internal sealed class WorldRevealCoordinator { _transit = transit ?? throw new ArgumentNullException(nameof(transit)); _readiness = new WorldRevealReadinessBarrier( + revealWindow, isRenderNeighborhoodReady, isSpawnCellReady, isTerrainNeighborhoodReady, @@ -164,8 +174,7 @@ internal sealed class WorldRevealCoordinator _hostProjections.Add(new HostProjection( token, - WorldRevealReadinessBarrier.RequiredRenderRadius( - destinationCell), + _readiness.RequiredRenderRadius(destinationCell), quiescenceEdge)); RetryPendingHostWork(); } @@ -184,6 +193,7 @@ internal sealed class WorldRevealCoordinator { RetryPendingHostWork(); WorldRevealReadinessSnapshot snapshot = _readiness.Evaluate(destinationCell); + ReconcileDestinationReservationRadius(snapshot); RuntimePortalSnapshot portal = _transit.Snapshot; if (portal.Generation != 0) { @@ -499,6 +509,39 @@ internal sealed class WorldRevealCoordinator .DestinationReservationReleased); } + /// + /// Keeps the open destination reservation on the same square the gate is + /// measuring after a mid-hold quality change. Retail's + /// SmartBox::set_mid_radius @0x00453180 resets the cell manager, + /// re-radiuses the landscape, and re-arms the blocking prefetch at the NEW + /// value; acdream's equivalent is to close and reopen the reservation on + /// the same reveal generation. EndDestinationReservation already + /// refuses a generation mismatch, so the reopen is generation-safe. + /// + private void ReconcileDestinationReservationRadius( + in WorldRevealReadinessSnapshot snapshot) + { + if (_streaming is null || !snapshot.HasDestination) + return; + + HostProjection? host = FindCurrentHostProjection(); + if (host is null + || !host.StreamingRegistered + || host.StreamingReleased + || host.Token.DestinationCell != snapshot.DestinationCell + || host.RequiredRenderRadius == snapshot.RequiredRenderRadius) + { + return; + } + + _streaming.EndDestinationReservation(host.Token.Generation); + _streaming.BeginDestinationReservation( + host.Token.Generation, + host.Token.DestinationCell, + snapshot.RequiredRenderRadius); + host.RequiredRenderRadius = snapshot.RequiredRenderRadius; + } + private void WithdrawHostForReplacement() { HostProjection? host = FindCurrentHostProjection(); diff --git a/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs b/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs index 057b1ec9..b02047ec 100644 --- a/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs +++ b/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs @@ -1,5 +1,23 @@ namespace AcDream.App.Streaming; +/// +/// The live landblock window the reveal gate measures. Retail has exactly one +/// landscape square — LScape::mid_radius, assigned directly from the +/// user's Render.LandscapeDrawDistance preference +/// (SmartBox::SetRegion @0x004531F0; +/// Render_LandscapeDrawDistance_Values @0x007CA988 = {3,5,8,11,15,25}, +/// default 8) — and that same square is simultaneously the loaded set, the +/// drawn set, and the set LScape::PreFetchCells @0x00505660 blocks on. +/// acdream splits the loaded landscape into a Near tier (full publication) and +/// a Far tier (terrain render + terrain collision), so the reveal window is a +/// pair rather than a single number; is the structural +/// analogue of retail's mid_radius because it bounds everything the +/// user can see (fog end is FarRadius * 192 m * 0.95). +/// +internal readonly record struct StreamingRevealWindow( + int NearRadius, + int FarRadius); + /// /// One evaluation of the destination domains guarded by /// . Keeping the individual facts in @@ -11,6 +29,7 @@ internal readonly record struct WorldRevealReadinessSnapshot( bool IsIndoor, bool IsUnhydratable, int RequiredRenderRadius, + int RequiredNearRadius, bool IsRenderNeighborhoodReady, bool AreCompositeTexturesReady, bool IsCollisionReady) @@ -32,12 +51,21 @@ internal readonly record struct WorldRevealReadinessSnapshot( /// (SmartBox::UseTime, 0x00455410). acdream loads those domains /// asynchronously, so login and portal arrival must share this explicit /// equivalent of retail's single blocking-cell edge. +/// +/// +/// #280: the outdoor gate is DERIVED from the live streaming window, never +/// hardcoded. Retail cannot stream farther than it gates because it has only +/// one number (LScape::SetMidRadius @0x00504C00 sets +/// mid_width = radius * 2 + 1 and LScape::update_block +/// @0x005063A0 allocates exactly that array). A fixed reveal radius under a +/// configurable streaming window reintroduces the decoupling the moment the +/// preset changes, which is the whole defect. +/// /// internal sealed class WorldRevealReadinessBarrier { - internal const int OutdoorNeighborhoodRadius = 1; - - private readonly Func _isRenderNeighborhoodReady; + private readonly Func _revealWindow; + private readonly Func _isRenderNeighborhoodReady; private readonly Func _isSpawnCellReady; private readonly Func _isTerrainNeighborhoodReady; private readonly Func _areCompositeTexturesReady; @@ -46,7 +74,8 @@ internal sealed class WorldRevealReadinessBarrier private readonly Func _isSpawnClaimUnhydratable; public WorldRevealReadinessBarrier( - Func isRenderNeighborhoodReady, + Func revealWindow, + Func isRenderNeighborhoodReady, Func isSpawnCellReady, Func isTerrainNeighborhoodReady, Func areCompositeTexturesReady, @@ -54,6 +83,8 @@ internal sealed class WorldRevealReadinessBarrier Action invalidateCompositeTextures, Func isSpawnClaimUnhydratable) { + _revealWindow = revealWindow + ?? throw new ArgumentNullException(nameof(revealWindow)); _isRenderNeighborhoodReady = isRenderNeighborhoodReady ?? throw new ArgumentNullException(nameof(isRenderNeighborhoodReady)); _isSpawnCellReady = isSpawnCellReady @@ -86,9 +117,20 @@ internal sealed class WorldRevealReadinessBarrier if (destinationCell == 0 || _isSpawnClaimUnhydratable(destinationCell)) return; - int radius = RequiredRenderRadius(destinationCell); - if (_isRenderNeighborhoodReady(destinationCell, radius)) - _prepareCompositeTextures(destinationCell, radius); + StreamingRevealWindow required = RequiredWindow(destinationCell); + if (_isRenderNeighborhoodReady( + destinationCell, + required.NearRadius, + required.FarRadius)) + { + // D3: the composite domain is entity-scoped + // (WbDrawDispatcher.IsCompositeWarmupCandidate filters entities by + // Chebyshev landblock radius) and Far-tier builds carry no + // entities at all (LandblockBuildFactory), so the honest composite + // domain is the NEAR radius. Widening it over Far rings would walk + // the whole outer window to warm nothing. + _prepareCompositeTextures(destinationCell, required.NearRadius); + } } /// @@ -110,38 +152,76 @@ internal sealed class WorldRevealReadinessBarrier return default; bool isIndoor = IsIndoor(destinationCell); - int radius = RequiredRenderRadius(destinationCell); + StreamingRevealWindow required = RequiredWindow(destinationCell); if (_isSpawnClaimUnhydratable(destinationCell)) { return new WorldRevealReadinessSnapshot( destinationCell, isIndoor, IsUnhydratable: true, - radius, + required.FarRadius, + required.NearRadius, IsRenderNeighborhoodReady: false, AreCompositeTexturesReady: false, IsCollisionReady: false); } - bool renderReady = _isRenderNeighborhoodReady(destinationCell, radius); + bool renderReady = _isRenderNeighborhoodReady( + destinationCell, + required.NearRadius, + required.FarRadius); bool compositesReady = renderReady && _areCompositeTexturesReady(); bool collisionReady = renderReady && compositesReady && (isIndoor ? _isSpawnCellReady(destinationCell) - : _isTerrainNeighborhoodReady(destinationCell, radius)); + : _isTerrainNeighborhoodReady( + destinationCell, + required.FarRadius)); return new WorldRevealReadinessSnapshot( destinationCell, isIndoor, IsUnhydratable: false, - radius, + required.FarRadius, + required.NearRadius, renderReady, compositesReady, collisionReady); } - internal static int RequiredRenderRadius(uint destinationCell) => - IsIndoor(destinationCell) ? 0 : OutdoorNeighborhoodRadius; + /// + /// The window this destination must satisfy, read LIVE from the streaming + /// configuration on every call. Retail's answer to a mid-hold radius + /// change is SmartBox::set_mid_radius @0x00453180: reset the cell + /// manager, re-radius the landscape, and re-arm the blocking prefetch at + /// the NEW value — never finish the old hold at the old radius. + /// + /// + /// Indoor destinations take retail's EnvCell arm + /// (CEnvCell::PreFetchCells @0x0052D1E0), which walks the cell's own + /// visible-cell graph rather than the landscape square, so the outdoor + /// ring is not required and the radius is zero. + /// + /// + internal StreamingRevealWindow RequiredWindow(uint destinationCell) + { + if (IsIndoor(destinationCell)) + return new StreamingRevealWindow(0, 0); + + StreamingRevealWindow window = _revealWindow(); + int far = Math.Max(0, window.FarRadius); + int near = Math.Clamp(window.NearRadius, 0, far); + return new StreamingRevealWindow(near, far); + } + + /// + /// The outdoor gate's outer radius — retail's mid_radius. Callers + /// that reserve destination streaming capacity must use exactly this value + /// so the prefetch square and the blocked square stay identical, as they + /// are in retail by construction. + /// + internal int RequiredRenderRadius(uint destinationCell) => + RequiredWindow(destinationCell).FarRadius; private static bool IsIndoor(uint cellId) => (cellId & 0xFFFFu) >= 0x0100u; } diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 60609609..2f5c47df 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -45,6 +45,12 @@ public sealed class PhysicsEngine _collisionWorld.Current.LandblockFreeSlots; private readonly TransitionScratchArena? _transitionScratch; + // #280 (D6): reusable landblock-prefix scratch for + // IsNeighborhoodTerrainResident. Physics is single-threaded per engine and + // this method is a leaf, so one instance-owned set is safe and keeps the + // per-frame reveal gate allocation-free. + private readonly HashSet _terrainResidencyScratch = new(); + public PhysicsEngine() : this(reuseTransitionScratch: true) { @@ -128,7 +134,17 @@ public sealed class PhysicsEngine /// public bool IsNeighborhoodTerrainResident(uint cellOrLandblockId, int radius) { - var resident = new HashSet(); + // #280 (D6): this runs every frame for the whole duration of a reveal + // hold, and the hold's radius is now the streaming Far radius (12 at + // the shipped High preset = 625 ring members) instead of 1. Building a + // fresh HashSet per call would allocate on every frame of every hold, + // against Slice I1's 0 B standard. The scratch set is owned by this + // engine, cleared and refilled in place, so a warmed call allocates + // nothing; the prefix-masked membership semantics are unchanged + // (callers register landblocks under canonical, cell-resolved, or bare + // ids and this gate has always compared on the high 16 bits). + HashSet resident = _terrainResidencyScratch; + resident.Clear(); foreach ((uint key, _) in _landblocks) resident.Add(key & 0xFFFF0000u); diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs index f0697b4e..66d40639 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs @@ -970,6 +970,12 @@ internal sealed class HeadlessSessionWorldProjection // concept, derive the real predicate here instead of leaving // this hardcoded. IsUnhydratable: false, + // #280: this "centre ring" token is NOT the graphical host's + // derived reveal radius and must not be made to track it. This + // host has no streaming window, no render publication and no + // composites, so there is nothing wider for a radius to mean + // here. Runtime validates only the SHAPE (indoor => 0, + // outdoor => >= 1), which this satisfies by construction. RequiredRenderRadius: indoor ? 0 : 1, IsRenderNeighborhoodReady: true, AreCompositeTexturesReady: true, diff --git a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs index cd3b66b7..a487f413 100644 --- a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs +++ b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs @@ -773,6 +773,12 @@ public sealed class RuntimeLiveEntitySessionController destination.CellId, indoor, IsUnhydratable: false, + // #280: this "centre ring" token is NOT the graphical host's + // derived reveal radius and must not be made to track it. This + // host has no streaming window, no render publication and no + // composites, so there is nothing wider for a radius to mean + // here. Runtime validates only the SHAPE (indoor => 0, + // outdoor => >= 1), which this satisfies by construction. RequiredRenderRadius: indoor ? 0 : 1, IsRenderNeighborhoodReady: true, AreCompositeTexturesReady: true, diff --git a/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs b/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs index 9a6a889c..43d4a34d 100644 --- a/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs +++ b/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs @@ -569,18 +569,30 @@ public sealed class RuntimeWorldTransitState if (_snapshot.Readiness.IsReady) return false; + // #280: this is a SHAPE invariant, not a value invariant. Runtime owns + // the canonical reveal lifetime; it does not own — and must never + // learn — the graphical host's streaming configuration. The outdoor + // radius is now derived from that host's live Near/Far window + // (WorldRevealReadinessBarrier.RequiredWindow), so re-encoding a + // literal here would assert a mechanism that no longer exists and fail + // every graphical acknowledgement. What remains genuinely invariant, + // and is still worth failing on, is the shape: an indoor destination + // takes retail's EnvCell arm and requires no landscape ring at all, + // while an outdoor destination must require at least its own + // landblock's ring. The no-window host has no streaming window, so its + // "centre ring" token (indoor ? 0 : 1) stays legal by construction. bool isIndoor = IsIndoor(acknowledgement.DestinationCell); - int requiredRenderRadius = isIndoor ? 0 : 1; - if (acknowledgement.IsIndoor != isIndoor - || acknowledgement.RequiredRenderRadius - != requiredRenderRadius) + bool radiusShapeValid = isIndoor + ? acknowledgement.RequiredRenderRadius == 0 + : acknowledgement.RequiredRenderRadius >= 1; + if (acknowledgement.IsIndoor != isIndoor || !radiusShapeValid) { FailInvariant( "invalid-readiness-shape", $"indoor={acknowledgement.IsIndoor} " + $"expectedIndoor={isIndoor} " + $"radius={acknowledgement.RequiredRenderRadius} " - + $"expectedRadius={requiredRenderRadius}"); + + $"expectedRadius={(isIndoor ? "0" : ">=1")}"); return false; } diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index e048ab06..39774fee 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -692,7 +692,8 @@ public sealed class CurrentGameRuntimeAdapterTests Clock = new UpdateFrameClock(_gameRuntime.Clock); WorldReveal = new WorldRevealCoordinator( WorldTransit, - static (_, _) => true, + static () => new StreamingRevealWindow(1, 1), + static (_, _, _) => true, static _ => true, static (_, _) => true, static () => true, diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs index f08a6a8b..48f4657b 100644 --- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -62,7 +62,7 @@ public sealed class LocalPlayerTeleportControllerTests Assert.Equal(1, harness.Mode.EnterPortalCount); Assert.Equal(Matrix4x4.Identity, harness.Presentation.BeginProjection); Assert.Equal( - (1L, 0x20210001u, WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius), + (1L, 0x20210001u, harness.RevealWindow.FarRadius), Assert.Single(harness.Streaming.Reservations)); Assert.True(harness.Reveal.Snapshot.IsActive); Assert.Equal(RuntimePortalKind.Portal, harness.Reveal.Snapshot.Kind); @@ -890,6 +890,14 @@ public sealed class LocalPlayerTeleportControllerTests public readonly FakePresentation Presentation; public readonly RuntimeWorldTransitState Transit; public readonly WorldRevealCoordinator Reveal; + + /// + /// The live streaming window this harness's reveal barrier derives its + /// outdoor radius from (#280). Assertions read this rather than a + /// literal so the test cannot silently re-encode the value under test. + /// + public StreamingRevealWindow RevealWindow { get; set; } = + new(NearRadius: 1, FarRadius: 1); public readonly LocalPlayerTeleportController Controller; public readonly RuntimeEntityObjectLifetime Lifetime; public readonly RuntimeAcceptedPositionDriveController AcceptedPositionDrive; @@ -918,7 +926,8 @@ public sealed class LocalPlayerTeleportControllerTests Transit = new RuntimeWorldTransitState(order.Add); Reveal = new WorldRevealCoordinator( Transit, - isRenderNeighborhoodReady: (_, _) => worldReady, + revealWindow: () => RevealWindow, + isRenderNeighborhoodReady: (_, _, _) => worldReady, isSpawnCellReady: _ => worldReady, isTerrainNeighborhoodReady: (_, _) => worldReady, areCompositeTexturesReady: () => worldReady, diff --git a/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs index 20add222..8c01f752 100644 --- a/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs +++ b/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs @@ -23,11 +23,11 @@ public sealed class StreamingControllerReadinessTests AddPublished(state, 0x12 + dx, 0x36 + dy); } - Assert.False(controller.IsRenderNeighborhoodResident(0x12360022u, 1)); + Assert.False(controller.IsRenderNeighborhoodResident(0x12360022u, 1, 1)); AddPublished(state, 0x13, 0x37); - Assert.True(controller.IsRenderNeighborhoodResident(0x12360022u, 1)); + Assert.True(controller.IsRenderNeighborhoodResident(0x12360022u, 1, 1)); } [Fact] @@ -37,8 +37,8 @@ public sealed class StreamingControllerReadinessTests StreamingController controller = CreateController(state); AddPublished(state, 0x8C, 0x04); - Assert.True(controller.IsRenderNeighborhoodResident(0x8C0401ADu, 0)); - Assert.False(controller.IsRenderNeighborhoodResident(0x8D0401ADu, 0)); + Assert.True(controller.IsRenderNeighborhoodResident(0x8C0401ADu, 0, 0)); + Assert.False(controller.IsRenderNeighborhoodResident(0x8D0401ADu, 0, 0)); } [Fact] @@ -51,7 +51,7 @@ public sealed class StreamingControllerReadinessTests AddPublished(state, 1, 0); AddPublished(state, 1, 1); - Assert.True(controller.IsRenderNeighborhoodResident(0x00000001u, 1)); + Assert.True(controller.IsRenderNeighborhoodResident(0x00000001u, 1, 1)); } [Fact] @@ -60,7 +60,7 @@ public sealed class StreamingControllerReadinessTests StreamingController controller = CreateController(new GpuWorldState()); Assert.Throws( - () => controller.IsRenderNeighborhoodResident(0x1236FFFFu, -1)); + () => controller.IsRenderNeighborhoodResident(0x1236FFFFu, -1, -1)); } [Theory] @@ -73,7 +73,7 @@ public sealed class StreamingControllerReadinessTests { StreamingController controller = CreateController(new GpuWorldState()); - Assert.False(controller.IsRenderNeighborhoodResident(cellId, 0)); + Assert.False(controller.IsRenderNeighborhoodResident(cellId, 0, 0)); } [Fact] @@ -97,11 +97,11 @@ public sealed class StreamingControllerReadinessTests new LoadedLandblock(id, new LandBlock(), new[] { entity }), new[] { envCellGeometryId }); - Assert.False(controller.IsRenderNeighborhoodResident(id, 0)); + Assert.False(controller.IsRenderNeighborhoodResident(id, 0, 0)); meshes.ReadyIds.Add(0x01000010ul); - Assert.False(controller.IsRenderNeighborhoodResident(id, 0)); + Assert.False(controller.IsRenderNeighborhoodResident(id, 0, 0)); meshes.ReadyIds.Add(envCellGeometryId); - Assert.True(controller.IsRenderNeighborhoodResident(id, 0)); + Assert.True(controller.IsRenderNeighborhoodResident(id, 0, 0)); } [Fact] @@ -120,9 +120,9 @@ public sealed class StreamingControllerReadinessTests state.AddLandblock(new LoadedLandblock(id, new LandBlock(), Array.Empty())); Assert.True(state.IsNearTier(id)); - Assert.False(controller.IsRenderNeighborhoodResident(id, 0)); + Assert.False(controller.IsRenderNeighborhoodResident(id, 0, 0)); meshes.ReadyIds.Add(envCellGeometryId); - Assert.True(controller.IsRenderNeighborhoodResident(id, 0)); + Assert.True(controller.IsRenderNeighborhoodResident(id, 0, 0)); } [Fact] @@ -137,15 +137,15 @@ public sealed class StreamingControllerReadinessTests new LoadedLandblock(id, new LandBlock(), Array.Empty()), tier: LandblockStreamTier.Far); - Assert.False(controller.IsRenderNeighborhoodResident(id, 0)); + Assert.False(controller.IsRenderNeighborhoodResident(id, 0, 0)); state.AddEntitiesToExistingLandblock( id, Array.Empty(), new[] { envCellGeometryId }); - Assert.False(controller.IsRenderNeighborhoodResident(id, 0)); + Assert.False(controller.IsRenderNeighborhoodResident(id, 0, 0)); meshes.ReadyIds.Add(envCellGeometryId); - Assert.True(controller.IsRenderNeighborhoodResident(id, 0)); + Assert.True(controller.IsRenderNeighborhoodResident(id, 0, 0)); } [Fact] @@ -170,7 +170,7 @@ public sealed class StreamingControllerReadinessTests new LoadedLandblock(id, new LandBlock(), new[] { entity }), new[] { envCellGeometryId }, LandblockStreamTier.Near); - Assert.True(controller.IsRenderNeighborhoodResident(id, 0)); + Assert.True(controller.IsRenderNeighborhoodResident(id, 0, 0)); state.AddLandblock( new LoadedLandblock(id, new LandBlock(), Array.Empty()), @@ -178,7 +178,7 @@ public sealed class StreamingControllerReadinessTests Assert.True(state.IsNearTier(id)); Assert.Contains(entity, state.Entities); - Assert.True(controller.IsRenderNeighborhoodResident(id, 0)); + Assert.True(controller.IsRenderNeighborhoodResident(id, 0, 0)); } [Fact] @@ -412,6 +412,200 @@ public sealed class StreamingControllerReadinessTests Assert.Empty(appliedBuilds[1].Landblock.Entities); } + // ── #280: the tiered reveal window ─────────────────────────────────── + // + // Retail loads, draws, and blocks on ONE square (LScape::mid_radius, + // LScape::PreFetchCells @0x00505660). acdream's loaded landscape is + // tiered, so the equivalent predicate is tiered: full publication inside + // the Near ring, terrain publication out to the Far radius. Requiring Near + // tier across the whole window is unsatisfiable — nothing outside the Near + // ring is ever promoted — and holds the reveal forever. + + /// + /// Case (a), the discriminating one: an INNER-ring member that only has + /// its Far-tier terrain must still fail. If this passes while the near arm + /// has been loosened into the far arm, the gate silently stops requiring + /// scenery/statics at the destination itself. + /// + [Fact] + public void TieredWindow_InnerRingFarTierMemberIsNotResident() + { + var state = new GpuWorldState(); + StreamingController controller = CreateController(state); + + for (int dx = -2; dx <= 2; dx++) + for (int dy = -2; dy <= 2; dy++) + { + AddPublished( + state, + 0x12 + dx, + 0x36 + dy, + dx == 1 && dy == 0 + ? LandblockStreamTier.Far + : LandblockStreamTier.Near); + } + + Assert.False( + controller.IsRenderNeighborhoodResident(0x12360022u, 1, 2)); + } + + /// + /// Case (b): an OUTER-ring member at Far tier satisfies the gate. This is + /// the half that makes a derived Far-radius reveal window reachable at all. + /// + [Fact] + public void TieredWindow_OuterRingFarTierMemberIsResident() + { + var state = new GpuWorldState(); + StreamingController controller = CreateController(state); + + for (int dx = -2; dx <= 2; dx++) + for (int dy = -2; dy <= 2; dy++) + { + bool inner = Math.Abs(dx) <= 1 && Math.Abs(dy) <= 1; + AddPublished( + state, + 0x12 + dx, + 0x36 + dy, + inner ? LandblockStreamTier.Near : LandblockStreamTier.Far); + } + + Assert.True( + controller.IsRenderNeighborhoodResident(0x12360022u, 1, 2)); + } + + /// + /// Case (c): an absent outer-ring member still fails. The outer arm is a + /// real publication test, not a rubber stamp. + /// + [Fact] + public void TieredWindow_AbsentOuterRingMemberIsNotResident() + { + var state = new GpuWorldState(); + StreamingController controller = CreateController(state); + + for (int dx = -2; dx <= 2; dx++) + for (int dy = -2; dy <= 2; dy++) + { + if (dx == 2 && dy == -2) + continue; + bool inner = Math.Abs(dx) <= 1 && Math.Abs(dy) <= 1; + AddPublished( + state, + 0x12 + dx, + 0x36 + dy, + inner ? LandblockStreamTier.Near : LandblockStreamTier.Far); + } + + Assert.False( + controller.IsRenderNeighborhoodResident(0x12360022u, 1, 2)); + + AddPublished(state, 0x14, 0x34, LandblockStreamTier.Far); + + Assert.True( + controller.IsRenderNeighborhoodResident(0x12360022u, 1, 2)); + } + + /// + /// A far radius below the near radius is a caller bug, not something to + /// clamp silently inside the predicate — the barrier clamps before it gets + /// here. + /// + [Fact] + public void TieredWindow_RejectsAFarRadiusBelowTheNearRadius() + { + StreamingController controller = CreateController(new GpuWorldState()); + + Assert.Throws( + () => controller.IsRenderNeighborhoodResident(0x1236FFFFu, 3, 2)); + } + + /// + /// Retail parity (LScape::PreFetchCells' >= 0x7F8 bounds skip): a + /// map-corner destination converges because the out-of-bounds members are + /// skipped, not required. Exercised at a WIDE window, which is where the + /// derived radius now puts the gate. + /// + [Fact] + public void TieredWindow_MapCornerDestinationConvergesAtAWideRadius() + { + var state = new GpuWorldState(); + StreamingController controller = CreateController(state); + + for (int x = 0; x <= 4; x++) + for (int y = 0; y <= 4; y++) + { + bool inner = x <= 2 && y <= 2; + AddPublished( + state, + x, + y, + inner ? LandblockStreamTier.Near : LandblockStreamTier.Far); + } + + Assert.True( + controller.IsRenderNeighborhoodResident(0x00000001u, 2, 4)); + } + + /// + /// #280 proof obligation P1 — the whole fix rests on this. A Far-tier + /// publication must satisfy ; if + /// it did not, the tiered gate's outer arm could never be satisfied and + /// the reveal would hang. Driven through the real presentation pipeline's + /// PublicationKind.Far path against a real + /// . + /// + [Fact] + public void FarPublication_IsRenderReadyThroughTheRealPipeline() + { + const uint landblockId = 0x1236FFFFu; + var meshes = new ReadinessMeshAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + var pipeline = new LandblockPresentationPipeline( + publishBeforeSpatialCommit: (_, _) => { }, + state); + + // A Near-shaped completion that the streaming window has since demoted + // to Far: entities and physics payload are stripped by PublishAsFar, + // which is exactly the shape an outer-ring landblock is published in. + var entity = new WorldEntity + { + Id = 1, + ServerGuid = 0, + SourceGfxObjOrSetupId = 0x01000010u, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = [new MeshRef(0x01000010u, System.Numerics.Matrix4x4.Identity)], + }; + var source = new LandblockBuild( + new LoadedLandblock(landblockId, new LandBlock(), new[] { entity })); + var mesh = new AcDream.Core.Terrain.LandblockMeshData( + Array.Empty(), + Array.Empty()); + var accepted = new LandblockStreamResult.Loaded( + landblockId, + LandblockStreamTier.Near, + source, + mesh); + + pipeline.PublishAsFar(accepted, source, mesh); + + Assert.True(state.IsLoaded(landblockId)); + Assert.False(state.IsNearTier(landblockId)); + // The Far registration carries an EMPTY desired mesh set, so it is + // render-ready without any IWbMeshAdapter upload ever completing — + // note ReadinessMeshAdapter.ReadyIds is untouched below. + Assert.Empty(meshes.ReadyIds); + Assert.True(state.IsRenderReady(landblockId)); + + // ...and the tier check is therefore the SOLE thing keeping it out of + // the inner arm, which is what makes D2's split meaningful rather than + // decorative. + StreamingController controller = CreateController(state); + Assert.False( + controller.IsRenderNeighborhoodResident(landblockId, 0, 0)); + } + private static StreamingController CreateController(GpuWorldState state) => new( (_, _) => { }, @@ -422,10 +616,16 @@ public sealed class StreamingControllerReadinessTests nearRadius: 1, farRadius: 2); - private static void AddPublished(GpuWorldState state, int x, int y) + private static void AddPublished( + GpuWorldState state, + int x, + int y, + LandblockStreamTier tier = LandblockStreamTier.Near) { uint id = ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu; - state.AddLandblock(new LoadedLandblock(id, new LandBlock(), Array.Empty())); + state.AddLandblock( + new LoadedLandblock(id, new LandBlock(), Array.Empty()), + tier: tier); } private sealed class ReadinessMeshAdapter : IWbMeshAdapter diff --git a/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs b/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs index 1235d12e..69c2fc98 100644 --- a/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs +++ b/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs @@ -19,6 +19,15 @@ public sealed class WorldRevealCoordinatorTests public int InvalidateCount { get; private set; } public int PrepareCount { get; private set; } + /// + /// The live streaming window (#280). Mutable so a test can change the + /// quality preset mid-hold, which retail answers by re-arming the + /// blocking prefetch at the NEW radius + /// (SmartBox::set_mid_radius @0x00453180). + /// + public StreamingRevealWindow Window { get; set; } = + new(NearRadius: 1, FarRadius: 1); + public WorldRevealCoordinator Build( List? logs = null, IWorldRevealStreamingScheduler? streaming = null, @@ -30,7 +39,8 @@ public sealed class WorldRevealCoordinatorTests Transit = transit; return new WorldRevealCoordinator( transit, - isRenderNeighborhoodReady: (_, _) => RenderReady, + revealWindow: () => Window, + isRenderNeighborhoodReady: (_, _, _) => RenderReady, isSpawnCellReady: _ => CollisionReady, isTerrainNeighborhoodReady: (_, _) => CollisionReady, areCompositeTexturesReady: () => CompositesReady, @@ -195,7 +205,8 @@ public sealed class WorldRevealCoordinatorTests audio); var coordinator = new WorldRevealCoordinator( transit, - isRenderNeighborhoodReady: (_, _) => true, + revealWindow: () => new StreamingRevealWindow(1, 1), + isRenderNeighborhoodReady: (_, _, _) => true, isSpawnCellReady: _ => true, isTerrainNeighborhoodReady: (_, _) => true, areCompositeTexturesReady: () => true, @@ -241,7 +252,8 @@ public sealed class WorldRevealCoordinatorTests var scheduler = new RecordingDestinationScheduler(); var coordinator = new WorldRevealCoordinator( transit, - isRenderNeighborhoodReady: (_, _) => true, + revealWindow: () => new StreamingRevealWindow(1, 1), + isRenderNeighborhoodReady: (_, _, _) => true, isSpawnCellReady: _ => true, isTerrainNeighborhoodReady: (_, _) => true, areCompositeTexturesReady: () => true, @@ -301,7 +313,8 @@ public sealed class WorldRevealCoordinatorTests var scheduler = new RecordingDestinationScheduler(); var coordinator = new WorldRevealCoordinator( transit, - isRenderNeighborhoodReady: (_, _) => true, + revealWindow: () => new StreamingRevealWindow(1, 1), + isRenderNeighborhoodReady: (_, _, _) => true, isSpawnCellReady: _ => true, isTerrainNeighborhoodReady: (_, _) => true, areCompositeTexturesReady: () => true, @@ -360,7 +373,8 @@ public sealed class WorldRevealCoordinatorTests var scheduler = new RecordingDestinationScheduler(); var coordinator = new WorldRevealCoordinator( transit, - isRenderNeighborhoodReady: (_, _) => false, + revealWindow: () => new StreamingRevealWindow(1, 1), + isRenderNeighborhoodReady: (_, _, _) => false, isSpawnCellReady: _ => false, isTerrainNeighborhoodReady: (_, _) => false, areCompositeTexturesReady: () => false, @@ -591,7 +605,8 @@ public sealed class WorldRevealCoordinatorTests audio); var coordinator = new WorldRevealCoordinator( transit, - isRenderNeighborhoodReady: (_, _) => true, + revealWindow: () => new StreamingRevealWindow(1, 1), + isRenderNeighborhoodReady: (_, _, _) => true, isSpawnCellReady: _ => true, isTerrainNeighborhoodReady: (_, _) => true, areCompositeTexturesReady: () => true, @@ -731,6 +746,86 @@ public sealed class WorldRevealCoordinatorTests } } + /// + /// #280 (D4/§9-10): retail has ONE square — the prefetched set and the + /// blocked-on set are literally the same array. So the destination + /// reservation must open at exactly the radius the gate measures, asserted + /// against the fake window's own input rather than a literal. + /// + [Theory] + [InlineData(3, 8)] + [InlineData(4, 12)] + [InlineData(5, 15)] + public void DestinationReservation_OpensAtTheDerivedGateRadius( + int nearRadius, + int farRadius) + { + const uint outdoorCell = 0x11340021u; + var streaming = new RecordingDestinationScheduler(); + var state = new State + { + Window = new StreamingRevealWindow(nearRadius, farRadius), + }; + WorldRevealCoordinator coordinator = state.Build(streaming: streaming); + + long generation = coordinator.BeginLogin(outdoorCell); + + Assert.Equal( + (generation, outdoorCell, state.Window.FarRadius), + Assert.Single(streaming.Begins)); + } + + [Fact] + public void IndoorDestinationReservation_OpensAtTheEnvCellArmsZeroRadius() + { + const uint indoorCell = 0x11340100u; + var streaming = new RecordingDestinationScheduler(); + var state = new State { Window = new StreamingRevealWindow(4, 12) }; + WorldRevealCoordinator coordinator = state.Build(streaming: streaming); + + long generation = coordinator.BeginLogin(indoorCell); + + Assert.Equal( + (generation, indoorCell, 0), + Assert.Single(streaming.Begins)); + } + + /// + /// P6, reservation half: the radii are runtime mutable through Settings. + /// Retail's SmartBox::set_mid_radius @0x00453180 resets, re-radiuses + /// and re-arms the blocking prefetch at the NEW value rather than + /// finishing the old hold at the old one; acdream's equivalent closes and + /// reopens the reservation on the SAME reveal generation. + /// + [Fact] + public void MidHoldRadiusChange_ReopensTheReservationOnTheSameGeneration() + { + const uint outdoorCell = 0x11340021u; + var streaming = new RecordingDestinationScheduler(); + var state = new State { Window = new StreamingRevealWindow(3, 8) }; + WorldRevealCoordinator coordinator = state.Build(streaming: streaming); + + long generation = coordinator.BeginLogin(outdoorCell); + coordinator.Evaluate(outdoorCell); + Assert.Equal( + (generation, outdoorCell, 8), + Assert.Single(streaming.Begins)); + Assert.Empty(streaming.Ends); + + state.Window = new StreamingRevealWindow(5, 15); + coordinator.Evaluate(outdoorCell); + + Assert.Equal( + [(generation, outdoorCell, 8), (generation, outdoorCell, 15)], + streaming.Begins); + Assert.Equal([generation], streaming.Ends); + + // Idempotent: an unchanged window does not churn the reservation. + coordinator.Evaluate(outdoorCell); + Assert.Equal(2, streaming.Begins.Count); + Assert.Single(streaming.Ends); + } + private sealed class RecordingRenderResourceScheduler : IWorldRevealRenderResourceScheduler { diff --git a/tests/AcDream.App.Tests/Streaming/WorldRevealDerivedWindowIntegrationTests.cs b/tests/AcDream.App.Tests/Streaming/WorldRevealDerivedWindowIntegrationTests.cs new file mode 100644 index 00000000..2d0a2d96 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/WorldRevealDerivedWindowIntegrationTests.cs @@ -0,0 +1,240 @@ +using AcDream.App.Streaming; +using AcDream.Core.Physics; +using AcDream.Core.World; +using AcDream.Runtime.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Streaming; + +/// +/// #280 end-to-end: the derived reveal window, the tiered render predicate, +/// the terrain-residency gate, the destination reservation, and the loosened +/// Runtime shape invariant, wired together with the REAL +/// , , and +/// rather than fakes. +/// +/// +/// This is the test that fails if any single piece is missing: raising the +/// radius without the tier-aware predicate hangs forever (T2), and loosening +/// neither the Runtime invariant nor the predicate makes +/// AcknowledgeDestinationReadiness fail +/// invalid-readiness-shape (T1). +/// +/// +public sealed class WorldRevealDerivedWindowIntegrationTests +{ + private const int CenterX = 0x40; + private const int CenterY = 0x40; + private const uint DestinationCell = (uint)CenterX << 24 + | (uint)CenterY << 16 + | 0x0021u; + + [Theory] + [InlineData(1, 2)] + [InlineData(2, 4)] + public void OutdoorReveal_HoldsUntilTheWholeDerivedWindowIsPublished( + int nearRadius, + int farRadius) + { + var world = new GpuWorldState(); + var physics = new PhysicsEngine(); + var transit = new RuntimeWorldTransitState(); + var streaming = new RecordingReservations(); + StreamingController controller = CreateController( + world, + nearRadius, + farRadius); + WorldRevealCoordinator coordinator = CreateCoordinator( + transit, + controller, + physics, + streaming); + + long generation = coordinator.BeginLogin(DestinationCell); + + // The reservation opens on the same square the gate measures. + Assert.Equal( + (generation, DestinationCell, farRadius), + Assert.Single(streaming.Begins)); + + // Publish the Near window only. Pre-#280 this was the entire gate; it + // must no longer be enough. + for (int radius = 0; radius <= nearRadius; radius++) + PublishRing(world, physics, radius, LandblockStreamTier.Near); + Assert.False(coordinator.Evaluate(DestinationCell).IsReady); + + // Fill the outer rings with Far-tier terrain publication, one ring at + // a time, so the hold is proven to track the outer boundary and not + // just the first missing member. + for (int radius = nearRadius + 1; radius < farRadius; radius++) + { + PublishRing(world, physics, radius, LandblockStreamTier.Far); + Assert.False(coordinator.Evaluate(DestinationCell).IsReady); + } + + PublishRing(world, physics, farRadius, LandblockStreamTier.Far); + WorldRevealReadinessSnapshot ready = + coordinator.Evaluate(DestinationCell); + + Assert.True(ready.IsReady); + Assert.Equal(farRadius, ready.RequiredRenderRadius); + Assert.Equal(nearRadius, ready.RequiredNearRadius); + // The loosened Runtime shape invariant accepted the derived radius. + Assert.Equal(0, transit.Snapshot.InvariantFailureCount); + Assert.True(transit.Snapshot.IsReady); + } + + /// + /// P7/§9-8: login and portal share the barrier, so first login gets the + /// same widened gate. Asserted through the production readiness predicate + /// the login auto-entry context calls. + /// + [Fact] + public void LoginReveal_UsesTheSameWidenedGateAsPortalArrival() + { + const int nearRadius = 1; + const int farRadius = 3; + var world = new GpuWorldState(); + var physics = new PhysicsEngine(); + var transit = new RuntimeWorldTransitState(); + StreamingController controller = CreateController( + world, + nearRadius, + farRadius); + WorldRevealCoordinator coordinator = CreateCoordinator( + transit, + controller, + physics, + streaming: null); + + coordinator.BeginLogin(DestinationCell); + for (int radius = 0; radius < farRadius; radius++) + { + PublishRing( + world, + physics, + radius, + radius <= nearRadius + ? LandblockStreamTier.Near + : LandblockStreamTier.Far); + } + + Assert.False(coordinator.Evaluate(DestinationCell).IsReady); + + PublishRing(world, physics, farRadius, LandblockStreamTier.Far); + + Assert.True(coordinator.Evaluate(DestinationCell).IsReady); + Assert.Equal(0, transit.Snapshot.InvariantFailureCount); + } + + /// + /// Indoor destinations keep retail's EnvCell arm: no landscape ring at + /// all, regardless of how wide the streaming window is. + /// + [Fact] + public void IndoorReveal_IgnoresTheStreamingWindowEntirely() + { + const uint indoorCell = (uint)CenterX << 24 + | (uint)CenterY << 16 + | 0x0100u; + var world = new GpuWorldState(); + var physics = new PhysicsEngine(); + var transit = new RuntimeWorldTransitState(); + StreamingController controller = CreateController(world, 2, 6); + WorldRevealCoordinator coordinator = CreateCoordinator( + transit, + controller, + physics, + streaming: null); + + coordinator.BeginLogin(indoorCell); + Assert.False(coordinator.Evaluate(indoorCell).IsReady); + + // Only the destination's own landblock, at Near tier. + PublishRing(world, physics, 0, LandblockStreamTier.Near); + WorldRevealReadinessSnapshot snapshot = coordinator.Evaluate(indoorCell); + + Assert.True(snapshot.IsRenderNeighborhoodReady); + Assert.Equal(0, snapshot.RequiredRenderRadius); + Assert.Equal(0, transit.Snapshot.InvariantFailureCount); + } + + private sealed class RecordingReservations : IWorldRevealStreamingScheduler + { + public List<(long Generation, uint Cell, int Radius)> Begins { get; } = []; + public List Ends { get; } = []; + + public void BeginDestinationReservation( + long revealGeneration, + uint destinationCell, + int requiredRenderRadius) => + Begins.Add((revealGeneration, destinationCell, requiredRenderRadius)); + + public void EndDestinationReservation(long revealGeneration) => + Ends.Add(revealGeneration); + } + + private static WorldRevealCoordinator CreateCoordinator( + RuntimeWorldTransitState transit, + StreamingController controller, + PhysicsEngine physics, + IWorldRevealStreamingScheduler? streaming) => + new( + transit, + () => new StreamingRevealWindow( + controller.NearRadius, + controller.FarRadius), + controller.IsRenderNeighborhoodResident, + physics.IsSpawnCellReady, + physics.IsNeighborhoodTerrainResident, + () => true, + (_, _) => { }, + () => { }, + _ => false, + streaming: streaming); + + private static StreamingController CreateController( + GpuWorldState state, + int nearRadius, + int farRadius) => + new( + (_, _, _) => { }, + (_, _) => { }, + _ => Array.Empty(), + (_, _) => { }, + state, + nearRadius: nearRadius, + farRadius: farRadius); + + private static void PublishRing( + GpuWorldState world, + PhysicsEngine physics, + int radius, + LandblockStreamTier tier) + { + for (int dx = -radius; dx <= radius; dx++) + for (int dy = -radius; dy <= radius; dy++) + { + if (Math.Abs(dx) != radius && Math.Abs(dy) != radius) + continue; + + uint id = ((uint)(CenterX + dx) << 24) + | ((uint)(CenterY + dy) << 16) + | 0xFFFFu; + world.AddLandblock( + new LoadedLandblock(id, new LandBlock(), Array.Empty()), + tier: tier); + // The Far tier publishes terrain COLLISION as well as terrain + // render (LandblockPhysicsPublisher, reached for + // PublicationKind.Far), which is what makes the outer arm of the + // reveal gate satisfiable on the collision side too. + physics.AddLandblock( + id, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + } + } +} diff --git a/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs b/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs index 8ac3e9a0..bd130400 100644 --- a/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs +++ b/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs @@ -15,13 +15,24 @@ public sealed class WorldRevealReadinessBarrierTests public int Preparations; public uint PreparedCell; public int PreparedRadius = -1; - public int RenderRadius = -1; + public int RenderNearRadius = -1; + public int RenderFarRadius = -1; public int TerrainRadius = -1; + /// + /// The live streaming window the barrier derives from. Mutable so a + /// test can prove the radii are re-read per evaluation rather than + /// captured at construction (#280 T6 / P6). + /// + public StreamingRevealWindow Window { get; set; } = + new(NearRadius: 1, FarRadius: 1); + public WorldRevealReadinessBarrier Build() => new( - isRenderNeighborhoodReady: (cell, radius) => + revealWindow: () => Window, + isRenderNeighborhoodReady: (cell, nearRadius, farRadius) => { - RenderRadius = radius; + RenderNearRadius = nearRadius; + RenderFarRadius = farRadius; return RenderReady; }, isSpawnCellReady: _ => SpawnCellReady, @@ -52,28 +63,103 @@ public sealed class WorldRevealReadinessBarrierTests Assert.Equal(1, state.Invalidations); } - [Fact] - public void OutdoorReveal_JoinsNearRenderTexturesAndTerrain() + /// + /// #280: the outdoor gate is DERIVED from the live streaming window, never + /// a constant. Every assertion below references the fake window's own + /// input — a test asserting a literal radius would reproduce the exact + /// defect class this slice removes. + /// + [Theory] + [InlineData(3, 8)] + [InlineData(5, 15)] + [InlineData(4, 12)] + public void OutdoorRequiredWindow_IsTheLiveStreamingWindow( + int nearRadius, + int farRadius) { const uint outdoorCell = 0x11340021u; - var state = new State(); + var window = new StreamingRevealWindow(nearRadius, farRadius); + var state = new State { Window = window }; var barrier = state.Build(); Assert.False(barrier.IsReady(outdoorCell)); - Assert.Equal(WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius, state.RenderRadius); + + Assert.Equal(window.FarRadius, state.RenderFarRadius); + Assert.Equal(window.NearRadius, state.RenderNearRadius); + Assert.Equal(window.FarRadius, barrier.RequiredRenderRadius(outdoorCell)); + Assert.Equal(window, barrier.RequiredWindow(outdoorCell)); + } + + [Theory] + [InlineData(3, 8)] + [InlineData(5, 15)] + public void IndoorRequiredWindow_IsZeroRegardlessOfTheStreamingWindow( + int nearRadius, + int farRadius) + { + const uint indoorCell = 0x11340100u; + var state = new State + { + Window = new StreamingRevealWindow(nearRadius, farRadius), + }; + var barrier = state.Build(); + + Assert.Equal( + new StreamingRevealWindow(0, 0), + barrier.RequiredWindow(indoorCell)); + Assert.Equal(0, barrier.RequiredRenderRadius(indoorCell)); + } + + /// + /// P6: the radii are runtime mutable through Settings, and retail re-arms + /// the blocking prefetch at the NEW radius mid-hold + /// (SmartBox::set_mid_radius @0x00453180). A window captured at + /// construction would only misbehave when someone opens Settings during a + /// portal, so it must be proven live. + /// + [Fact] + public void RequiredWindow_IsRereadOnEveryEvaluationWithoutReconstruction() + { + const uint outdoorCell = 0x11340021u; + var state = new State { Window = new StreamingRevealWindow(3, 8) }; + var barrier = state.Build(); + + barrier.Evaluate(outdoorCell); + Assert.Equal(8, state.RenderFarRadius); + + state.Window = new StreamingRevealWindow(5, 15); + WorldRevealReadinessSnapshot second = barrier.Evaluate(outdoorCell); + + Assert.Equal(state.Window.FarRadius, state.RenderFarRadius); + Assert.Equal(state.Window.NearRadius, state.RenderNearRadius); + Assert.Equal(state.Window.FarRadius, second.RequiredRenderRadius); + Assert.Equal(state.Window.NearRadius, second.RequiredNearRadius); + } + + [Fact] + public void OutdoorReveal_JoinsRenderTexturesAndTerrainOverTheDerivedWindow() + { + const uint outdoorCell = 0x11340021u; + var state = new State { Window = new StreamingRevealWindow(4, 12) }; + var barrier = state.Build(); + + Assert.False(barrier.IsReady(outdoorCell)); + Assert.Equal(state.Window.FarRadius, state.RenderFarRadius); state.RenderReady = true; barrier.Prepare(outdoorCell); Assert.Equal(1, state.Preparations); Assert.Equal(outdoorCell, state.PreparedCell); - Assert.Equal(WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius, state.PreparedRadius); + // D3: the composite domain is entity-scoped and Far-tier builds carry + // no entities, so composites warm over the NEAR radius only. + Assert.Equal(state.Window.NearRadius, state.PreparedRadius); state.CompositeReady = true; Assert.False(barrier.IsReady(outdoorCell)); state.TerrainReady = true; Assert.True(barrier.IsReady(outdoorCell)); - Assert.Equal(WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius, state.TerrainRadius); + Assert.Equal(state.Window.FarRadius, state.TerrainRadius); } [Fact] @@ -82,6 +168,7 @@ public sealed class WorldRevealReadinessBarrierTests const uint indoorCell = 0x11340100u; var state = new State { + Window = new StreamingRevealWindow(4, 12), RenderReady = true, CompositeReady = true, TerrainReady = true, @@ -95,7 +182,8 @@ public sealed class WorldRevealReadinessBarrierTests state.SpawnCellReady = true; Assert.True(barrier.IsReady(indoorCell)); - Assert.Equal(0, state.RenderRadius); + Assert.Equal(0, state.RenderNearRadius); + Assert.Equal(0, state.RenderFarRadius); } [Fact] @@ -119,7 +207,7 @@ public sealed class WorldRevealReadinessBarrierTests Assert.True(barrier.IsReady(0x113401FFu)); Assert.Equal(0, state.Preparations); - Assert.Equal(-1, state.RenderRadius); + Assert.Equal(-1, state.RenderFarRadius); } [Fact] @@ -128,6 +216,7 @@ public sealed class WorldRevealReadinessBarrierTests const uint outdoorCell = 0x11340021u; var state = new State { + Window = new StreamingRevealWindow(4, 12), RenderReady = true, CompositeReady = true, TerrainReady = true, @@ -137,7 +226,8 @@ public sealed class WorldRevealReadinessBarrierTests Assert.Equal(outdoorCell, snapshot.DestinationCell); Assert.False(snapshot.IsIndoor); - Assert.Equal(WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius, snapshot.RequiredRenderRadius); + Assert.Equal(state.Window.FarRadius, snapshot.RequiredRenderRadius); + Assert.Equal(state.Window.NearRadius, snapshot.RequiredNearRadius); Assert.True(snapshot.IsRenderNeighborhoodReady); Assert.True(snapshot.AreCompositeTexturesReady); Assert.True(snapshot.IsCollisionReady); @@ -163,4 +253,41 @@ public sealed class WorldRevealReadinessBarrierTests Assert.False(snapshot.IsReady); Assert.Equal(-1, state.TerrainRadius); } + + /// + /// The near arm demands Near-tier publication and can therefore never + /// exceed the outer arm. A misconfigured window must clamp rather than + /// hand IsRenderNeighborhoodResident an argument it rejects. + /// + [Fact] + public void RequiredWindow_ClampsANearRadiusThatExceedsTheFarRadius() + { + var state = new State { Window = new StreamingRevealWindow(9, 4) }; + var barrier = state.Build(); + + Assert.Equal( + new StreamingRevealWindow(4, 4), + barrier.RequiredWindow(0x11340021u)); + } + + /// + /// D5: the probe reproduces the pre-fix gate on the same binary so the + /// connected route's A/B pair is a real comparison. It is a measurement + /// override, never a user-facing prefetch knob (§3, T11). + /// + [Fact] + public void RevealRadiusOverride_ReplacesTheDerivedWindowAndClampsTheNearArm() + { + var window = new StreamingRevealWindow(4, 12); + + Assert.Equal( + window, + StreamingDiagnostics.ApplyRevealRadiusOverride(window, null)); + Assert.Equal( + new StreamingRevealWindow(1, 1), + StreamingDiagnostics.ApplyRevealRadiusOverride(window, 1)); + Assert.Equal( + new StreamingRevealWindow(4, 25), + StreamingDiagnostics.ApplyRevealRadiusOverride(window, 25)); + } } diff --git a/tests/AcDream.App.Tests/World/LiveEntityWorldOriginCoordinatorTests.cs b/tests/AcDream.App.Tests/World/LiveEntityWorldOriginCoordinatorTests.cs index d8dc607a..1bcce017 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityWorldOriginCoordinatorTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityWorldOriginCoordinatorTests.cs @@ -82,7 +82,8 @@ public sealed class LiveEntityWorldOriginCoordinatorTests private static WorldRevealCoordinator Reveal() => new( new RuntimeWorldTransitState(), - (_, _) => true, + () => new StreamingRevealWindow(1, 1), + (_, _, _) => true, _ => true, (_, _) => true, () => true, diff --git a/tests/AcDream.Core.Tests/Physics/NeighborhoodTerrainResidencyTests.cs b/tests/AcDream.Core.Tests/Physics/NeighborhoodTerrainResidencyTests.cs new file mode 100644 index 00000000..beaebedd --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/NeighborhoodTerrainResidencyTests.cs @@ -0,0 +1,95 @@ +using AcDream.Core.Physics; + +namespace AcDream.Core.Tests.Physics; + +/// +/// #280 (D6/P8). is +/// the collision half of the reveal gate and runs once per frame for the whole +/// duration of a hold. Before #280 that hold measured a 3x3 neighbourhood; it +/// now measures the streaming Far window (12 at the shipped High preset = +/// 625 members). Rebuilding a landblock-prefix HashSet per call would +/// allocate on every frame of every hold, against Slice I1's 0 B standard. +/// +public sealed class NeighborhoodTerrainResidencyTests +{ + private const int FarRadius = 12; + + [Fact] + public void WarmedNeighborhoodQuery_AtTheFarRadius_AllocatesNothing() + { + PhysicsEngine engine = EngineWithWindow(0x80, 0x80, FarRadius); + uint center = Canonical(0x80, 0x80); + + // Warm the JIT, the scratch set's buckets/entries arrays, and the + // dictionary enumerator before measuring. + for (int i = 0; i < 64; i++) + Assert.True(engine.IsNeighborhoodTerrainResident(center, FarRadius)); + + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 1_000; i++) + Assert.True(engine.IsNeighborhoodTerrainResident(center, FarRadius)); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True( + allocated == 0, + $"1,000 radius-{FarRadius} residency queries allocated " + + $"{allocated:N0} bytes"); + } + + [Fact] + public void NeighborhoodQuery_StillRejectsAMissingOuterRingMember() + { + PhysicsEngine engine = EngineWithWindow(0x80, 0x80, FarRadius); + uint center = Canonical(0x80, 0x80); + + Assert.True(engine.IsNeighborhoodTerrainResident(center, FarRadius)); + + engine.RemoveLandblock(Canonical(0x80 + FarRadius, 0x80)); + + Assert.False(engine.IsNeighborhoodTerrainResident(center, FarRadius)); + Assert.True(engine.IsNeighborhoodTerrainResident(center, FarRadius - 1)); + } + + /// + /// The gate has always compared on the high 16 bits, so a landblock + /// registered under a cell-resolved id satisfies the same ring member as + /// one registered under the canonical sentinel. D6 replaced the per-call + /// set construction, not that masking rule. + /// + [Fact] + public void NeighborhoodQuery_KeepsPrefixMaskedMembership() + { + var engine = new PhysicsEngine(); + AddTerrain(engine, (0x40u << 24) | (0x40u << 16) | 0x0021u); + + Assert.True( + engine.IsNeighborhoodTerrainResident(Canonical(0x40, 0x40), 0)); + Assert.False( + engine.IsNeighborhoodTerrainResident(Canonical(0x41, 0x40), 0)); + } + + private static PhysicsEngine EngineWithWindow(int cx, int cy, int radius) + { + var engine = new PhysicsEngine(); + for (int dx = -radius; dx <= radius; dx++) + for (int dy = -radius; dy <= radius; dy++) + AddTerrain(engine, Canonical(cx + dx, cy + dy)); + return engine; + } + + private static uint Canonical(int x, int y) => + ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu; + + private static void AddTerrain(PhysicsEngine engine, uint landblockId) + { + var heights = new byte[81]; + var heightTable = new float[256]; + engine.AddLandblock( + landblockId, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + } +} diff --git a/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs b/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs index 37561c95..59f2debe 100644 --- a/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs +++ b/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs @@ -225,6 +225,97 @@ public sealed class RuntimeWorldTransitStateTests Assert.Equal(1, state.Snapshot.InvariantFailureCount); } + /// + /// #280 (D7): the readiness invariant is a SHAPE check, not a value check. + /// Runtime must not know the graphical host's streaming configuration, so + /// it cannot re-derive the outdoor radius — that value is now the App's + /// live Far radius and legitimately varies with the quality preset and + /// with runtime Settings changes. Every radius the two non-graphical + /// producers emit today (indoor ? 0 : 1) stays legal. + /// + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(5)] + [InlineData(8)] + [InlineData(12)] + [InlineData(15)] + [InlineData(25)] + public void OutdoorReadinessShape_AcceptsAnyDerivedStreamingRadius( + int requiredRenderRadius) + { + var state = new RuntimeWorldTransitState(); + long generation = state.BeginLoginReveal(OutdoorCell); + RuntimeDestinationReadiness acknowledgement = + Ready(generation, OutdoorCell) with + { + RequiredRenderRadius = requiredRenderRadius, + }; + + Assert.True(state.AcknowledgeDestinationReadiness(acknowledgement)); + + Assert.Equal(0, state.Snapshot.InvariantFailureCount); + Assert.Equal( + requiredRenderRadius, + state.Snapshot.Readiness.RequiredRenderRadius); + } + + /// + /// The shape is still a real invariant in both directions: an outdoor + /// claim must require at least its own landblock's ring, and an indoor + /// claim takes retail's EnvCell arm and must require none. + /// + [Fact] + public void OutdoorReadinessShape_RejectsAZeroRadius() + { + var state = new RuntimeWorldTransitState(); + long generation = state.BeginLoginReveal(OutdoorCell); + RuntimeDestinationReadiness invalid = + Ready(generation, OutdoorCell) with { RequiredRenderRadius = 0 }; + + Assert.False(state.AcknowledgeDestinationReadiness(invalid)); + + Assert.False(state.Snapshot.IsReady); + Assert.Equal(1, state.Snapshot.InvariantFailureCount); + } + + [Fact] + public void IndoorReadinessShape_RejectsANonZeroRadius() + { + const uint indoorCell = 0x11340100u; + var state = new RuntimeWorldTransitState(); + long generation = state.BeginLoginReveal(indoorCell); + RuntimeDestinationReadiness invalid = + Ready(generation, indoorCell) with + { + IsIndoor = true, + RequiredRenderRadius = 1, + }; + + Assert.False(state.AcknowledgeDestinationReadiness(invalid)); + + Assert.False(state.Snapshot.IsReady); + Assert.Equal(1, state.Snapshot.InvariantFailureCount); + } + + [Fact] + public void IndoorReadinessShape_AcceptsTheZeroRadiusEnvCellArm() + { + const uint indoorCell = 0x11340100u; + var state = new RuntimeWorldTransitState(); + long generation = state.BeginLoginReveal(indoorCell); + RuntimeDestinationReadiness acknowledgement = + Ready(generation, indoorCell) with + { + IsIndoor = true, + RequiredRenderRadius = 0, + }; + + Assert.True(state.AcknowledgeDestinationReadiness(acknowledgement)); + + Assert.Equal(0, state.Snapshot.InvariantFailureCount); + } + [Fact] public void AcceptedReadiness_IsGenerationSticky() {