From 73cdb95c7ba44f49d488d24abfe341c365d1ba2f Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 6 Aug 2026 07:27:28 +0200 Subject: [PATCH] fix(streaming): make a demoted landblock render-ready like a published one (#280 D-1) Both #280 review lenses returned FAIL on the same defect, and both were right. IsRenderNeighborhoodResident's widened outer arm requires IsRenderReady out to FarRadius, justified by "a Far-tier landblock registers with an empty mesh set and is therefore render-ready." That held only for a landblock that ARRIVED as Far. The second, equally first-class way to be Far tier is a Near->Far DEMOTE: DemoteLandblock -> EnqueueNearLayerRetirement -> LandblockRetirementStage.MeshReferences -> GpuWorldState.ReleaseLandblockMeshReferences -> LandblockSpawnAdapter.OnLandblockUnloaded => WantsLoaded = false while DetachNearLayer deliberately keeps the landblock loaded, terrain-mesh resident, terrain-collision resident and DRAWN. Nothing re-publishes an already-loaded landblock, so the demoted member satisfied NEITHER arm of the gate, permanently: wormhole tunnel plus centered "In Portal Space - Please Wait..." forever, no recovery short of relog. Reachable by ordinary play. Two consecutive recalls to the same landblock with walking in between makes ChangesStreamingCenter false, so there is no origin recenter and the region recentres through the ordinary demote diff. Also reachable via a mid-hold quality-preset drop -- ironically the exact scenario ReconcileDestinationReservationRadius was added to support. The pre-#280 radius-1 gate never touched that band, because nothing inside the Near ring can demote. FIX SHAPE. Make the two routes genuinely equivalent rather than teaching the predicate to tolerate the difference. ReleaseLandblockMeshReferences becomes "reconcile the registration to the post-retirement tier": after the release converges, if the landblock is still loaded AND still Far tier, re-assert the empty registration -- the identical OnLandblockLoaded(lb, empty) a PublicationKind.Far activation makes. It is empty by construction: DetachNearLayer retains only live server projections, which the adapter's atlas-tier filter skips. A full retirement is unaffected (DetachLandblock clears both _loaded and _tierByLandblock), and a throwing release still retries because the re-assert is only reached after the adapter converged. The alternative -- "|| (IsFarTier && IsLoaded)" at the gate -- was rejected: it fixes one caller while leaving IsRenderReady meaning two different things, which is precisely how this defect arose. After this change the predicate reads "drawable at its current tier" for every caller, with no knowledge of how the landblock got there. WHY THE TESTS MISSED IT, fixed here too: - Proof obligation P2 was discharged against RESIDENCY (the FarRadius+2 eviction threshold) rather than against IsRenderReady, the gate's actual atom. The contract now carries the correction and the restated obligation: no transition may REVOKE IsRenderReady from a landblock that stays inside FarRadius. - WorldRevealDerivedWindowIntegrationTests advertised itself as end-to-end against the real GpuWorldState but constructed it with no spawn adapter, so its IsRenderReady degenerated to IsLoaded via the "?? true". The single most load-bearing predicate in the change was stubbed out by a null in the test named after it -- the same shape as C5b's D3 and #276's three settler tests. Every fixture in that file now owns a real LandblockSpawnAdapter. - The P1 test's comment described its subject as "a Near-shaped completion the streaming window has since DEMOTED to Far". It is not; it is a fresh PublishAsFar, the case that does hold. Corrected, since a future reader would have taken it as demote coverage. Four new regression tests, all driving the real GpuWorldState + LandblockSpawnAdapter + LandblockPresentationPipeline through an actual demote, and all sabotage-verified in both directions (fail with the production change reverted, pass with it): NearToFarDemote_LeavesTheLandblockRenderReadyThroughTheRealPipeline NearToFarDemote_LeavesTheLandblockRenderReadyUnderBudgetedRetirement TieredWindow_StaysResidentAfterAnOuterRingDemote OutdoorReveal_SurvivesAnOuterRingDemoteDuringTheHold The budgeted variant exists because production composes LandblockRetirementCoordinator.CreateBudgeted, whose MeshReferences stage is a separate call site from the legacy pipeline's. SECONDARY, same commit: - R-1: ACDREAM_PROBE_REVEAL_RADIUS=0 was parser-accepted and Runtime-rejected -- it yields far = 0 for an outdoor destination, which fails invalid-readiness-shape on every acknowledgement, hanging the very A/B route the probe exists to measure. Parser floor raised to 1, with a 7-case table test. - R-2: the composite-warmup TRIGGER had silently moved onto the far window's critical path. Pre-#280 the gate and the composite domain were the same radius-1 square; #280 widened the gate without widening the domain, so every composite upload serialised behind the last outer-ring landblock for no readiness benefit. Warmup now starts once the NEAR sub-window is published -- trigger scope == domain scope, as before. The reveal gate is untouched: Evaluate still requires the full window AND composite readiness. - AP-150 filed: acdream's RetailWaitCueDelay = 5 s arming is NOT retail's trigger, and #280's commit message got this wrong on both clauses. Retail emits the notice unconditionally per tunnel rotation segment, in the else arm of the segment-expiry test at 0x004D6FCD; segment duration is RandDouble(0.6, 1.8) s, byte-decoded at 0x004D6FE6. The 5.0 constant at VA 0x007991B0 is CellManager::CheckPrefetchStatus's prefetch RETRY cadence and has nothing to do with the cue. acdream's own 0.6/1.8 segment constants already match retail exactly; only the arming is wrong. Adopting retail's unconditional emit is filed as #329 rather than folded in here -- it is a user-visible presentation change and wants the user's eyes. - AP-151 filed: the gate is materially STRICTER than retail on the mesh-build/GPU-upload axis. Retail's LScape::PreFetchCells blocks on DAT RESIDENCY only -- no geometry construction, no upload; that work is lazy at draw. acdream requires a DAT read, terrain mesh build, render-thread upload, spatial commit, collision admission and spawn-adapter activation per member of a 625-member window, metered at MaxCompletionsPerFrame. Nothing bounds the hold. This is the OPPOSITE asymmetry from AP-149; both are live at once, on different axes. - AD-2's amendment stated the false Far-tier readiness assumption verbatim; corrected, along with the same error in claude-memory/reference_two_tier_streaming.md, which now carries an explicit DO-NOT-RETRY on the special-case-the-predicate shape. - AP-115 scope-noted (it covers the cue's presentation, not its arming). - #326's SmartBox::set_mid_radius citation corrected: the entry is 0x00453180; 0x004531D0 is the mid-function re-arm branch. Blast radius: GpuWorldState, LandblockSpawnAdapter, WorldRevealReadinessBarrier and StreamingDiagnostics are all App-internal; AcDream.Headless and AcDream.Runtime reference none of them outside comments. Headless tests run green as part of the gate below, per C5b's lesson about surveys that skip the no-window host. Gates: Release build 0 errors, 18 pre-existing xUnit analyzer warnings. Complete suite "dotnet test AcDream.slnx -c Release -m:1" with ACDREAM_PAK_PATH set: 11,192 passed / 4 skipped / 0 failed, from a clean rebuild (a prior session's deleted probe file had been compiled into a stale test DLL). Baseline at fafc0b65 was 11,179 / 4 / 0; the +13 delta reconciles exactly to this commit's additions -- 3 readiness tests, 1 integration test, 7 parser table cases, 2 warmup-trigger tests. None of the known flakes #302/#308/#321 surfaced, and none is conflated with the finding above. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 3 + docs/ISSUES.md | 35 +- .../retail-divergence-register.md | 8 +- docs/research/2026-08-05-280-contract.md | 61 +- .../2026-08-06-280-review-architecture.md | 321 ++++++++++ docs/research/2026-08-06-280-review-retail.md | 577 ++++++++++++++++++ src/AcDream.App/Streaming/GpuWorldState.cs | 58 +- .../Streaming/StreamingController.cs | 12 +- .../Streaming/StreamingDiagnostics.cs | 12 +- .../Streaming/WorldRevealReadinessBarrier.cs | 33 +- .../StreamingControllerReadinessTests.cs | 182 +++++- ...orldRevealDerivedWindowIntegrationTests.cs | 113 +++- .../WorldRevealReadinessBarrierTests.cs | 79 ++- 13 files changed, 1456 insertions(+), 38 deletions(-) create mode 100644 docs/research/2026-08-06-280-review-architecture.md create mode 100644 docs/research/2026-08-06-280-review-retail.md diff --git a/CLAUDE.md b/CLAUDE.md index 9d796086..28efd094 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1439,6 +1439,9 @@ via `PlayerMovementController.ApplyServerRunRate`) or from 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. + Values below 1 are rejected by the parser: an outdoor acknowledgement with + `RequiredRenderRadius == 0` fails Runtime's `invalid-readiness-shape` + invariant, so `=0` would hang the route it is meant to measure. - `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 9a0e1e69..2dce41a7 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,39 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #329 — The portal wait cue arms five seconds late; retail emits it per tunnel rotation segment, unconditionally + +**Status:** OPEN +**Severity:** LOW (cosmetic, but it is a retail divergence on every single +portal, in both directions) +**Filed:** 2026-08-06, #280 retail-conformance review, finding F2. +**Register row:** AP-150. + +acdream suppresses `"In Portal Space - Please Wait..."` until the hold has run +five seconds (`RuntimeWorldTransitState.RetailWaitCueDelay`), then re-emits it +per rotation segment only while `_waitCueVisible` +(`PortalTunnelPresentation.TickRotation`). + +Retail has no threshold. `gmSmartBoxUI::UseTime` @0x004D6E30 emits +`ECM_UI::SendNotice_DisplayStringInfo(0x1a, …)` in the `else` arm of the +rotation-segment-expiry test at 0x004D6FCD — i.e. every time a segment expires, +whether or not `CellManager::blocking_for_cells` is set. Segment duration is +`RandDouble(0.6, 1.8)` s, byte-decoded at 0x004D6FE6. acdream's own +`RotationDurationMin`/`Max` already match retail exactly, so only the arming is +wrong. + +The unrelated 5.0 s constant at VA 0x007991B0 is +`CellManager::CheckPrefetchStatus`'s retry cadence, and was mis-attributed to +the cue by #280's commit message (retracted in the contract doc). + +**Consequence:** every portal shorter than 5 s shows a silent tunnel where +retail shows the notice; every portal longer than 5 s shows it 3.2-4.4 s late. + +**Fix shape:** delete the delay and emit on the same segment boundary the +rotation already computes. One-line arming change, but it is a user-visible +presentation change and wants the user's eyes before it lands — do not fold it +into an unrelated commit. + ## #325 — Gate A's teleport test is narrower than retail's: a ForcePosition carrying a NEWER teleport stamp is misrouted into a full Apply **Status:** OPEN @@ -1719,7 +1752,7 @@ it. Do #297 FIRST — #298 depends on it. (`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 + `SmartBox::set_mid_radius` @0x00453180. 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 diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index daf80d25..4e263688 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 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-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 Far-tier landblock satisfies through an empty spawn-adapter registration installed after its terrain upload crossed the render-thread barrier). **#280 review correction (2026-08-06):** as originally written this row asserted that property of a `PublicationKind.Far` *publication* only, which was true but not exhaustive — a landblock also reaches Far tier by Near→Far DEMOTE, and the demote's `LandblockRetirementStage.MeshReferences` left `LandblockSpawnAdapter.WantsLoaded == false` on a landblock that stays loaded and drawn, with no path that re-publishes it. Both review lenses found the same defect: one demoted member anywhere in the far ring made the gate unsatisfiable for the life of the streaming window (permanent portal-space hang, no recovery short of relog), reachable by two consecutive recalls to the same landblock with walking in between, or by a mid-hold quality-preset drop. The two routes are now genuinely equivalent — `GpuWorldState.ReleaseLandblockMeshReferences` re-asserts the empty Far registration after retiring the Near layer — rather than the predicate being taught to tolerate two meanings of "ready". Composite-texture warmup stays `NearRadius`-scoped because it is entity-scoped and Far builds carry no entities, and (same correction) its TRIGGER is scoped the same way: gating warmup on the whole widened gate serialised every composite upload behind the last outer-ring landblock, which is a hold longer than the streaming work requires. 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 (that trigger is an acdream divergence in its own right — AP-150, filed 2026-08-06); 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 — that five-second arming is acdream's own and is NOT retail's trigger; see AP-150. | `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) — 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) +## 3. Documented approximation (AP) — 105 active rows (AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; 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 @@ -176,6 +176,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | 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-150 | **Filed 2026-08-06 at the #280 retail-conformance review (finding F2).** acdream arms the `"In Portal Space - Please Wait..."` cue only after the hold has run five seconds (`RuntimeWorldTransitState.RetailWaitCueDelay = TimeSpan.FromSeconds(5)`, enforced at the readiness tick; `PortalTunnelPresentation.TickRotation` then re-emits per rotation segment only `if (_waitCueVisible)`). Retail has no such threshold. The emit site is inside `gmSmartBoxUI::UseTime`'s `TAS_TUNNEL*` branch, in the `else` arm of the rotation-segment-expiry test at 0x004D6FCD: when a segment expires retail picks a new random segment and calls `ECM_UI::SendNotice_DisplayStringInfo(0x1a, ...)` UNCONDITIONALLY, whether or not `CellManager::blocking_for_cells` is set — the notice is a property of being in the tunnel, not of being blocked. Byte-decoded at 0x004D6FE6-0x004D7049: `teleportRotationDuration = RandDouble(0.6, 1.8)` s (`0x3ffccccc/0xcccccccd` = 1.8, `0x3fe33333/0x33333333` = 0.6) and `teleportRotationEndAngle = RandDouble(0, 360)` (`0x40768000`). The unrelated 5.0 s constant at 0x007991B0 belongs to `CellManager::CheckPrefetchStatus` @0x00455BE0, the prefetch RETRY cadence, and was mis-attributed to the cue by #280's commit message. acdream's own segment constants (`RotationDurationMin = 0.6f`, `RotationDurationMax = 1.8f`) already match retail exactly, so the cadence is faithful and only the ARMING is not. Pre-dates #280; filed here because #280 reasoned from the wrong model and because the row did not exist. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs` (`RetailWaitCueDelay`); `src/AcDream.App/Rendering/PortalTunnelPresentation.cs` (`RotationDurationMin`/`Max`, `TickRotation`); `src/AcDream.App/UI/PortalWaitNoticeController.cs` | Deliberate at the time as a "don't flash a scary notice on a fast portal" softening, but it was never recorded as a divergence and AD-2/AP-115 described it as acdream behaviour without stating that retail has no threshold. Adopting retail's unconditional per-segment emit is a one-line arming change; it is not made here because it is a user-visible presentation change outside the defect this commit fixes, and it wants the user's eyes. Filed as issue #329. | Every acdream portal shorter than 5 s shows a silent tunnel where retail shows the notice; every portal longer than 5 s shows it 3.2-4.4 s late (retail's first segment expires at 0.6-1.8 s). #280 makes holds longer, which MASKS this rather than fixing it. | `gmSmartBoxUI::UseTime` 0x004D6E30 (emit at the 0x004D6FCD segment-expiry else-arm); `ECM_UI::SendNotice_DisplayStringInfo` call @0x004D7064; wait-cue string VA 0x007BD6A8; `CellManager::CheckPrefetchStatus` 0x00455BE0 (the 5.0 s constant, VA 0x007991B0) | +| AP-151 | **Filed 2026-08-06 at the #280 retail-conformance review (finding F3).** The reveal gate is materially STRICTER than retail's prefetch predicate on the mesh-build/GPU-upload axis, over an equally large square. Retail's `LScape::PreFetchCells` @0x00505660 requires, per member, only that the DAT records be resident in memory (`DBObj::PreFetch` -> `IN_MEMORY` or `IN_FILE` -> `DBObj::Get` non-null); no geometry construction, no vertex arrays and no GPU upload are part of the blocking predicate — that work happens lazily at draw. acdream's gate requires, for every member of the derived window (25x25 at the shipped High preset): a worker-thread DAT read, a terrain mesh build, a render-thread `TerrainModernRenderer.AddLandblock` upload, a spatial commit, a physics collision-generation admission, and a spawn-adapter activation, all metered at `MaxCompletionsPerFrame`. The hold is therefore systematically longer than retail's for identical content, and nothing currently bounds it. Note this is the OPPOSITE asymmetry from AP-149, which records where the outer ring is WEAKER than retail; both are live simultaneously, on different axes. | `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Streaming/StreamingWorkBudget.cs` | It is what makes "no visible assembly after reveal" true at all: acdream draws through a bindless/MDI pipeline whose landblock slots must exist before the viewport opens, where retail can begin drawing a landblock the frame its DAT record lands. Weakening the predicate to DAT residency would restore retail's hold duration and reintroduce the visible-assembly artifact #280 exists to remove. AD-2's blanket "async readiness gates replace retail's synchronous destination cell load" pre-dates the window being 625 members wide and does not name this axis. | Portal/recall holds of several seconds where retail (warm cache) is near-instant, on EVERY transit rather than only on cold DAT. No upper bound is enforced and no progress readout is shown (#327). A slow disk or a saturated upload budget lengthens the hold without limit. | `LScape::PreFetchCells` 0x00505660; `DBObj::PreFetch`/`DBObj::Get` call sites @0x0050575C, @0x0050579C; `CellManager::PreFetchCells` 0x00455820 | | ~~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 | @@ -272,7 +274,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` | | ~~AP-114~~ | **RETIRED 2026-07-14 (protection-effect corrective gate)** — the particle renderer no longer replaces every authored GfxObj with one bounding-box quad. Retail `Always2D` classification preserves mode-1/no-degrade full meshes through the modern shared mesh buffer and leaves only other degrade modes on the billboard path; stable emitter handles balance mesh ownership. | `src/AcDream.App/Rendering/ParticleRenderer.cs`; `RetailParticleGeometryClassifier.cs`; `particle_mesh.vert/.frag` | — | — | `CPhysicsPart::Draw @ 0x0050D7A0`; `CPhysicsPart::Always2D @ 0x0050D8A0`; `ParticleEmitter::SetInfo @ 0x0051CE90`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` | -| AP-115 | The DAT-authored portal-space viewport, animation `SoundTweakedHook`, and centered repeating `"In Portal Space - Please Wait..."` display string are live, but the separate `ClientUISystem` enter/exit sound enums are not yet presented. | `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/Streaming/LocalPlayerTeleportController.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs` | acdream has no ClientUISystem sound-table-enum resolver yet; inventing direct wave IDs would be less faithful. The notice uses the retained fullscreen UI rather than chat and remains tied to the portal presentation lifetime. | Portal travel has the correct animated wormhole, timing, direct viewport switch, view-plane transitions, animation-authored sound, and centered wait notice, but lacks retail's short UI enter/exit cue sounds. | `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300`; `gmSmartBoxUI::UseTime @ 0x004D6E30` | +| AP-115 | The DAT-authored portal-space viewport, animation `SoundTweakedHook`, and centered repeating `"In Portal Space - Please Wait..."` display string are live, but the separate `ClientUISystem` enter/exit sound enums are not yet presented. **Scope note (2026-08-06):** this row covers the cue's PRESENTATION only. Its five-second arming threshold is a separate, unregistered divergence now filed as AP-150 — retail emits the notice unconditionally per tunnel rotation segment (0.6-1.8 s) and has no such threshold. | `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/Streaming/LocalPlayerTeleportController.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs` | acdream has no ClientUISystem sound-table-enum resolver yet; inventing direct wave IDs would be less faithful. The notice uses the retained fullscreen UI rather than chat and remains tied to the portal presentation lifetime. | Portal travel has the correct animated wormhole, timing, direct viewport switch, view-plane transitions, animation-authored sound, and centered wait notice, but lacks retail's short UI enter/exit cue sounds. | `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300`; `gmSmartBoxUI::UseTime @ 0x004D6E30` | | AP-116 | Default `Particle Range = Extended` multiplies DAT-authored particle degradation distances by 2; the `Retail` option restores exact values | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs`; `src/AcDream.Core/Vfx/ParticleSystem.cs` | User explicitly requested doubled range as the normal non-dev-UI behavior; it changes no terrain, scenery, entity, fog, or streaming distance, and remains reversible through settings | The default roughly enlarges the active particle area and reduces the CPU gain from MP2; distant VFX remain visible beyond retail's authored cutoff | `CPhysicsPart::GetMaxDegradeDistance @ 0x0050D510`; `GfxObjDegradeInfo::get_max_degrade_distance @ 0x0051E2D0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` | | AP-117 | Outdoor particle `CLandCell::IsInView` state is reconstructed with the modern landscape renderer's per-cell frustum plus active doorway clip-plane/scissor-AABB tests; retail `LScape::landcell_check` uses `Render::get_clip_height` + `Render::block_check` on terrain-cell corner intervals | `src/AcDream.App/Rendering/TerrainModernRenderer.cs` (`CollectVisibleCells`) | The mandatory modern renderer batches terrain by landblock and has no retail `ViewIntervalType` product. Publishing cell visibility from the exact landscape draw slices preserves ownership/order and removes the former object-survivor dependency without adding a second view pipeline | At a terrain cell grazing a frustum or doorway boundary, the conservative AABB test may freeze or resume particles on a slightly different frame than retail; whole regions outside the active doorway slice are rejected, and authored distance, login/portal fail-closed behavior, and indoor PView cells remain exact | `LScape::landcell_check @ 0x005050A0`; `CLandCell::IsInView @ 0x00532CB0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` | | AP-118 | An AutoWield transaction begun in active combat preserves the ready mode implied by the requested weapon. After authoritative `WieldObject`, a mode that settled without a blocker transition clears immediately; local ACE's observed pre-wield transition plus `ready -> NonCombat`, or post-wield `NonCombat -> ready -> NonCombat`, causes one normal `ChangeCombatMode` request from the trailing notice. Explicit user combat input cancels settlement. Retail's client does not need this extra request against the retail server. | `src/AcDream.App/UI/AutoWieldController.cs`; production binding in `GameWindow.cs` | Local ACE queues a trailing NonCombat callback during primary-weapon replacement and rejects an earlier request while the shuffle is busy; responding to the authoritative notice that completes that exact sequence orders the ordinary request after it without suppressing any server state | A non-ACE server that emits a different intermediate sequence can retain the settlement until a later explicit combat request, replacement, or logout clears it; peace-mode equips send none | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0`; ACE `Player_Inventory.TryShuffleStance` / `TryDequipObjectWithNetworking` | diff --git a/docs/research/2026-08-05-280-contract.md b/docs/research/2026-08-05-280-contract.md index 6e99eb53..33ec8c20 100644 --- a/docs/research/2026-08-05-280-contract.md +++ b/docs/research/2026-08-05-280-contract.md @@ -236,7 +236,7 @@ Which callers block: | `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** | +| `SmartBox::set_mid_radius` @ `0x00453180` (re-arm branch at `0x004531D0`, `:92053`) | `ChangePosition(pos, 1)` | **yes, if already blocking** | `CellManager::ChangePosition` @ `0x004559B0` (`:94601`) additionally promotes any call to blocking while the latch is set @@ -778,14 +778,43 @@ It is graphical-host-only; headless has no `ObserveWait` caller anywhere `"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.** +**Longer holds ARE retail-convergent — but the argument below was wrong on +both of its clauses, and is corrected here (2026-08-06, #280 +retail-conformance review, finding F2).** + +The retail string is byte-identical (§1.6; VA `0x007BD6A8`, verified). Two +things this section originally asserted are not true of the binary: + +1. *"retail emits it for the whole duration of a blocked prefetch"* — the + emit site is inside `gmSmartBoxUI::UseTime`'s `TAS_TUNNEL*` branch, in the + `else` arm of the rotation-segment-expiry test at `0x004D6FCD`, and fires + **unconditionally per tunnel rotation segment** whether or not + `blocking_for_cells` is set. The cue is a property of being in the tunnel, + not of being blocked. Byte-decoded at `0x004D6FE6`-`0x004D7049`: + `teleportRotationDuration = RandDouble(0.6, 1.8)` s + (`0x3ffccccc/0xcccccccd` = 1.8, `0x3fe33333/0x33333333` = 0.6), + `teleportRotationEndAngle = RandDouble(0, 360)` (`0x40768000`). +2. *"whose retry is quantised to 5 s"* — the 5.0 constant at VA `0x007991B0` + belongs to `CellManager::CheckPrefetchStatus` @`0x00455BE0`, the prefetch + **retry cadence**. It has no connection to the UI notice. #280's commit + message repeated this mis-attribution; it is retracted. + +What survives, and is the real justification: retail genuinely **blocks**. +`SmartBox::UseTime` @`0x00455410` runs only `CheckPrefetchStatus` while +`blocking_for_cells` is latched — object maintenance, physics, the game +clock, landscape and ambient are all skipped. A retail client at +`LandscapeDrawDistance = 8` recalling into a cold cache sits in the tunnel +until all 289 landblocks are in. A longer acdream hold is therefore +convergent *in kind*. + +**Two register rows ARE required, and were filed 2026-08-06:** + +- **AP-150** — acdream's `RetailWaitCueDelay = 5 s` arming is not retail's + trigger (see the out-of-scope note immediately below, which was right; the + error was concluding no row was needed). +- **AP-151** — acdream's per-member predicate is much heavier than retail's + (mesh build + GPU upload vs. DAT residency), so the hold is not merely + "retail's, honestly measured", and nothing bounds it. Two adjacent facts, both **out of scope**, both worth writing down so a later session does not mistake them for #280 regressions: @@ -873,6 +902,20 @@ performance story. Report both; do not report only the post-fix number. 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.** + + > **CORRECTED 2026-08-06 (both #280 review lenses, FAIL).** As written below, + > P2 was discharged against **residency** — "cannot be evicted" — when the + > gate's actual atom is `IsRenderReady`. Those are different predicates, and + > the gap between them is exactly the shipped defect: a Near→Far **demote** + > leaves the landblock resident and drawn while revoking its spawn-adapter + > registration, so `IsRenderReady` went permanently false and the gate could + > never open. Discharging a proof obligation against a predicate the code does + > not use proves nothing. The restated obligation is: **no transition may + > revoke `IsRenderReady` from a landblock that stays inside `FarRadius`** — + > which now holds because `GpuWorldState.ReleaseLandblockMeshReferences` + > re-asserts the Far registration on demote. Every remaining sentence below + > (hysteresis, recenter, dungeon collapse) is correct as stated. + 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 diff --git a/docs/research/2026-08-06-280-review-architecture.md b/docs/research/2026-08-06-280-review-architecture.md new file mode 100644 index 00000000..94887968 --- /dev/null +++ b/docs/research/2026-08-06-280-review-architecture.md @@ -0,0 +1,321 @@ +# #280 architecture review — ownership, layering, blast radius, test quality + +- **Commit under review:** `3aab05b0` — *fix(streaming): derive the portal + reveal window from the live streaming radii (#280)* +- **Branch / HEAD:** `claude/acdream-physics-divergence-5aa784` @ `fafc0b65` +- **Worktree:** `.claude/worktrees/peaceful-visvesvaraya-e0a196` +- **Scope:** ownership, layering, blast radius, test quality. Retail fidelity + is a separate reviewer's. +- **Mode:** read-only. Production edits were made only as sabotage probes and + reverted; the tree is clean at `fafc0b65` with no working-tree changes. + +## Verdict: **FAIL** + +One confirmed defect, reproduced end-to-end through production code paths. +The predicate D2 widened from a 3×3 always-Near neighbourhood to the full Far +window now demands `IsRenderReady` from landblocks that the streaming system +**permanently** takes out of render-readiness while leaving them loaded. There +is no code path that restores them. Under two ordinary player flows — one of +which is the exact mid-hold radius change this commit added +`ReconcileDestinationReservationRadius` to support — the reveal gate becomes +unsatisfiable and the client stays in "In Portal Space - Please Wait…" +forever. + +Everything else checked out. Layering held, the no-window host is genuinely +untouched, the allocation fix is real, and the tests are unusually well +disciplined about not re-encoding the constant under test. The failure is a +single missed state transition, not sloppy work — but it is the failure mode +the change's own proof obligation P2 was written to exclude, discharged +against the wrong predicate. + +--- + +## Defects + +### D-1 — A demoted-but-loaded landblock is permanently not `IsRenderReady`, so the widened gate can never open (**CONFIRMED, reproduced**) + +**Severity: defect. Blocks the fix.** + +`src/AcDream.App/Streaming/StreamingController.cs:270-278` — the new outer arm +requires `_state.IsRenderReady(canonical)` of every in-bounds member out to +`farRadius`. + +`IsRenderReady` is not a property of "loaded": + +- `src/AcDream.App/Streaming/GpuWorldState.cs:179-181` + `IsRenderReady = _loaded.ContainsKey(id) && _wbSpawnAdapter.IsLandblockRenderReady(id)` +- `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs:135-139` + returns **false** when the landblock has no registration, or a registration + with `WantsLoaded == false`. + +A Near→Far **demote** destroys exactly that registration while keeping the +landblock loaded: + +- `src/AcDream.App/Streaming/StreamingController.cs:555-559` + `DemoteLandblock` → `LandblockPresentationPipeline.EnqueueNearLayerRetirement` +- `src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs:309-312` + a `NearLayer` ticket still runs `CoreStages`, which includes + `LandblockRetirementStage.MeshReferences` +- `src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs:738-740` + → `GpuWorldState.ReleaseLandblockMeshReferences` +- `src/AcDream.App/Streaming/GpuWorldState.cs:1559-1560` + → `LandblockSpawnAdapter.OnLandblockUnloaded` → `WantsLoaded = false`, + registration dropped +- `src/AcDream.App/Streaming/GpuWorldState.cs:1800-1835` + meanwhile `DetachNearLayer` **keeps** `_loaded[canonical]` and flips + `_tierByLandblock[canonical] = Far` + +Nothing re-publishes an already-loaded landblock. `RecenterTo` +(`StreamingRegion.cs:232-244`) emits `ToLoadFar` only for ids absent from +`_tierResidence`, and a demoted block is still present as `Far`. Only a +**promote** back into the Near ring (`StreamingRegion.cs:239-244` → +`PublicationKind.PromoteExisting` → `ActivateLandblockPresentation` → +`OnLandblockLoaded`) restores the registration. Members of the band +`NearRadius+1 … FarRadius` are never promoted, so they stay dead for the rest +of the window's life. + +**Evidence (temporary probes, run then removed):** + +| Probe | Result | +|---|---| +| `GpuWorldState.IsRenderReady` after `LandblockPresentationPipeline.BeginNearLayerRetirement` (the production demote entry point) | **false** | +| `GpuWorldState.IsRenderReady` after `GpuWorldState.RemoveEntitiesFromLandblock` (same `DetachNearLayer` + `ReleaseLandblockMeshReferences` pair) | **false** | +| Full Near 5×5 window; `controller.IsRenderNeighborhoodResident(dest, 1, 2)` before one outer-ring demote | **true** | +| …the same call after that single demote | **false**, and never recovers | + +All three probes used a real `GpuWorldState` + real `LandblockSpawnAdapter` + +real `LandblockPresentationPipeline`. + +**Reachable failure scenarios** + +1. **Mid-hold quality change (the one this commit explicitly added code for).** + `StreamingController.ReconfigureRadii` at `:484-488` emits + `DemoteLandblock` for every currently-Near landblock that the new preset + puts in the Far band. Presets are High `(4,12)` → Low `(2,5)` + (`src/AcDream.UI.Abstractions/Settings/QualityPreset.cs:31-34`), so a + High→Low switch demotes the Chebyshev 3–5 band, which is *inside* the new + Far window. The gate then requires `IsRenderReady` from all of them. + Result: the hold never ends, and + `WorldRevealCoordinator.ReconcileDestinationReservationRadius` + (`:521-543`) cheerfully re-opens a reservation on a square that can no + longer converge. Note also that `NearRadius`/`FarRadius` only publish at + transaction convergence (`StreamingController.cs:886-888`) while the + demote mutations run earlier (`:860-876`), so for several frames the + barrier measures the OLD wide window against blocks already demoted for + the NEW narrow one. + +2. **A reveal that does not recenter the origin.** + `LocalPlayerTeleportController.cs:934` only calls `_streaming.BeginRecenter` + when `transition.ChangesStreamingCenter`, i.e. when the destination + landblock differs from the **world-origin** landblock. Origin recenter is + the only thing that clears the window (`GpuWorldState.cs:1481` + `_loaded.Clear()`). Walking ≳ `NearRadius + 2` landblocks from the last + origin (`StreamingRegion.cs:263-270`) demotes blocks into the Far band; + a subsequent teleport back to the origin landblock (lifestone recall → + travel → lifestone recall) takes the no-recenter path and inherits them. + Blocks that land back inside the Near ring are promoted and recover; + blocks in `near+1 … far` do not. + +**Why the old gate was safe:** at `OutdoorNeighborhoodRadius = 1` the required +set was the destination's own 3×3, which during a hold is pinned to the +streaming centre (`StreamingFrameController.SelectObserver:194-200` freezes the +observer at `_origin` while `PlayerState.PortalSpace`) and therefore always +Near tier. Demotion inside the Near ring cannot happen. The defect is +introduced by this commit, not exposed by it. + +**Why P2 missed it.** `docs/research/2026-08-05-280-contract.md:876-884` states +P2 as "an outer-ring member cannot be **evicted** while it is inside +`FarRadius`" and cites the `FarRadius + 2` unload threshold. That is true and +irrelevant: the gate's atom is `IsRenderReady`, not residency. P2 was +discharged against the wrong predicate. Every remaining sentence of the +static proof (hysteresis, recenter, dungeon collapse) is correct. + +**Also note:** the P1 test's own comment +(`tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs:567-570`) +describes its subject as *"A Near-shaped completion that the streaming window +has since demoted to Far"*. It is not — it is a fresh `PublishAsFar` of a +landblock that was never loaded, which is the case that **does** hold. The +comment names the one case that is false. A future reader will take it as +coverage of the demote path. + +--- + +## Latent risks + +### R-1 — `ACDREAM_PROBE_REVEAL_RADIUS=0` is accepted by the parser and rejected by Runtime + +`src/AcDream.App/Streaming/StreamingDiagnostics.cs:54-55` accepts any +`value >= 0`. `ApplyRevealRadiusOverride` then yields +`StreamingRevealWindow(0, 0)`, so `RequiredWindow` returns far = 0 for an +**outdoor** destination, and +`src/AcDream.Runtime/World/RuntimeWorldTransitState.cs:585-597` fails +`invalid-readiness-shape` on every acknowledgement (outdoor ⇒ `>= 1`). The +A/B probe would hang the route it is meant to measure. The documented value is +`1`; the parser should refuse `0` rather than let the two halves of the same +commit disagree. One line. + +### R-2 — the composite-warmup **trigger** moved onto the far window's critical path, undocumented + +`WorldRevealReadinessBarrier.Prepare:120-133` starts composite preparation only +once `_isRenderNeighborhoodReady(dest, near, far)` is true. Before this commit +that predicate was the destination's 3×3; it is now the entire 25×25. The +commit message and D3 justify keeping the composite **radius** at `NearRadius` +(correct — Far builds carry no entities), but say nothing about the trigger. +Net effect: composite upload can no longer overlap far-ring streaming; the +hold is longer than the streaming work alone requires by the whole warmup +duration. This is a real serialisation the "expect longer holds" paragraph +does not account for, and it is separable — warming composites at Near-ready +would restore the overlap without weakening the gate. + +### R-3 — two different windows describe "destination publication incomplete" + +`StreamingController.cs:719-724` computes the destination-lane preference with +`(Math.Min(NearRadius, DestinationRadius), DestinationRadius)` — live +`NearRadius` against the *reservation's* radius — while the gate uses +`RequiredWindow`'s `(clamp(NearRadius, 0, FarRadius), FarRadius)`. Between a +preset change and reservation reconciliation these name different squares, so +the work-lane prioritisation can consider the destination complete on a +square the gate still rejects. Cosmetic today; it becomes a real starvation +question once R-1/D-1 are fixed and holds get long. + +### R-4 — Runtime lost its only cross-host consistency check + +The `indoor ? 0 : 1` equality became `indoor ⇒ 0`, `outdoor ⇒ ≥ 1` +(`RuntimeWorldTransitState.cs:585-597`). The layering argument is right — +Runtime cannot learn the graphical host's streaming configuration, and +plumbing App radii in would be exactly the C5b failure. The cost is that +Runtime can no longer detect an App regression to a hardcoded radius: a +future revert of D1 would emit `1` and pass. Accepted, but worth recording +that the invariant is now shape-only and the only remaining guard on the +value is the App-side tests. + +--- + +## What was checked and is sound + +**Layering / ownership (attack 6) — held.** +`WorldRevealCoordinator` and `WorldRevealReadinessBarrier` are `internal` to +`AcDream.App`. `src/AcDream.Headless/AcDream.Headless.csproj` references only +`AcDream.Runtime`. Both non-graphical producers +(`HeadlessSessionWorldProjection.cs:979`, +`RuntimeLiveEntitySessionController.cs:782`) keep emitting their own +centre-ring token and are legal under the loosened shape by construction, and +both were annotated in place explaining why they must **not** track the +graphical radius. No App type crossed into Runtime. `AcDream.Headless.Tests` +passes 89/89 including its dependency/loaded-assembly guards. C5b's lesson was +applied, not repeated. + +**P1 (attack 2) — independently verified TRUE, by reading rather than by +reading the test.** `PublishAsFar` (`LandblockPresentationPipeline.cs:432-522`) +constructs a `LoadedLandblock` with `Array.Empty()` and +`PhysicsDatBundle.Empty`; the spatial commit +(`LandblockPresentationPipeline.cs:900-916`) routes `PublicationKind.Far` +through `CommitLandblockSpatial`, which returns a publication with +`RequiresActivation` defaulted true (`GpuWorldState.cs:24`), so +`ActivateLandblockPresentation` (`:1009-1035`) calls `OnLandblockLoaded` with +an empty entity list and empty `AdditionalRenderIds`. The registration is +created with `WantsLoaded = true` and both reference dictionaries empty, so +`IsLandblockRenderReady` returns true with no `IWbMeshAdapter` upload. The one +early-out (`GpuWorldState.cs:913-923`, a stale Far completion over a live Near +tier) returns `RequiresActivation: false`, but that case is already +registered. **P1 holds. The fix shape does not collapse.** Its failure is D-1, +a different transition. + +Same check for the collision arm, which is equally load-bearing and was not +called out as a proof obligation: `LandblockPhysicsPublisher.AdvanceBeginOne` +(`:390-435`) builds the terrain surface from the heightmap with no dat bundle +and stages it, so a Far publication does register terrain collision; +`AdvanceDemotion` (`:675-683`) calls `DemoteCollisionToTerrain`, which +**retains** terrain. `IsNeighborhoodTerrainResident` therefore stays satisfiable +across a demote. The render arm is the only one that breaks. + +**Reveal-hang sweep (attack 1) — all other configurations converge.** + +| Configuration | Result | +|---|---| +| Map edge (coords clamped 0/254) | Safe. Gate skips `> 254` (`StreamingController.cs:265-266`); `StreamingRegion` loads up to `0xFF` (`:80`). The loaded set is a strict superset of the required set, and `PhysicsEngine.cs:157` uses the identical bound. | +| Dungeon → outdoor | Safe. `TryCommitOriginRecenterCore:1235-1251` clears `_collapsed` for a non-dungeon destination and nulls `_region` so the next tick bootstraps the full window. | +| Indoor destination | Safe. `RequiredWindow` returns `(0,0)` before touching the live window (`WorldRevealReadinessBarrier.cs:206-215`), so no streaming state can gate it. | +| Recenter in flight | Safe. `Tick` is blocked while `_originRecenterRetirement` is open, radii requests defer, and `_loaded.Clear()` (`GpuWorldState.cs:1481`) guarantees every member of the rebuilt window gets a fresh publication and registration. | +| Destination/observer landblock offset | Safe **during a hold**: `StreamingFrameController.SelectObserver:194-200` pins the observer to `_origin` while `PlayerState.PortalSpace`, so the gate's inner ring and the streaming Near ring are the same square. Worth noting the new inner arm has **zero** margin here where the old one had `NearRadius − 1`; the pin is now load-bearing. | +| `FarRadius == 0` on an outdoor destination | Not reachable from presets (min far = 5); reachable only via R-1. | + +**D6 allocation fix — real, and the sabotage number is honest.** +`PhysicsEngine.cs:49-52,146-149`. Single production call site +(`SessionPlayerComposition.cs:384`), main thread, leaf method — the +instance-owned scratch is safe, and it is cleared at entry so no stale state +can leak across sessions. + +**Test discipline — good, with two gaps.** No test re-encodes the constant +under test: every radius assertion references the fake window's own input +(`WorldRevealReadinessBarrierTests`, `WorldRevealDerivedWindowIntegrationTests`). +No new `Skip=`. No test deleted — one was renamed +(`OutdoorReveal_JoinsNearRenderTexturesAndTerrain` → +`RequiredWindow_IsRereadOnEveryEvaluationWithoutReconstruction`) with its +original assertions carried into a new +`OutdoorReveal_JoinsRenderTexturesAndTerrainOverTheDerivedWindow`; coverage is +preserved and the +36 arithmetic holds. + +Gaps: +- No test exercises a demote. That is D-1. +- `WorldRevealDerivedWindowIntegrationTests` advertises itself as end-to-end + against "the REAL `StreamingController`, `GpuWorldState`, and + `PhysicsEngine`", but constructs `new GpuWorldState()` with no spawn + adapter, so `IsRenderReady` degenerates to `IsLoaded` via the + `?? true` at `GpuWorldState.cs:181`. The single most load-bearing predicate + in the whole change is stubbed out by a null in the test named after it. + It also builds its own `revealWindow` lambda rather than the production one + in `SessionPlayerComposition.cs:373-379`, so the + `ApplyRevealRadiusOverride` wrapper is never exercised in composition. + +**Sabotage spot-checks — 4 of 9 families reproduced (asked for ≥ 3).** + +| Sabotage | Named test(s) | Result | +|---|---|---| +| `PhysicsEngine`: scratch → `new HashSet()` | `WarmedNeighborhoodQuery_AtTheFarRadius_AllocatesNothing` | FAIL, **"allocated 27,712,000 bytes"** — S5's claimed figure reproduces to the byte | +| `StreamingController`: revert tier split to `!IsNearTier \|\| !IsRenderReady` | `TieredWindow_OuterRingFarTierMemberIsResident`, `TieredWindow_AbsentOuterRingMemberIsNotResident`, `TieredWindow_MapCornerDestinationConvergesAtAWideRadius`, `OutdoorReveal_HoldsUntilTheWholeDerivedWindowIsPublished` ×2, `LoginReveal_UsesTheSameWidenedGateAsPortalArrival` | 6 FAIL | +| `RuntimeWorldTransitState`: shape → `== (isIndoor ? 0 : 1)` | `OutdoorReadinessShape_AcceptsAnyDerivedStreamingRadius` | 6 FAIL (all inline radii) | +| `WorldRevealCoordinator`: neuter `ReconcileDestinationReservationRadius` | `MidHoldRadiusChange_ReopensTheReservationOnTheSameGeneration` | 1 FAIL | + +All four reverted; `git status` clean afterwards. No sign of the C5b-D3 class +(a test that passes with its own change reverted) in the families checked. + +**Suite at HEAD `fafc0b65`, Release, verified by running it:** +`11,179 passed / 4 skipped / 0 failed` — exactly the expected +11,178 + 1 Core settler test. Per project: UI 546, Content 124, Runtime 1217, +Cli 4, Bake 15, App 4157/3 skips, Headless 89, Core.Net 764, Core 4263/1 skip. +None of #302/#308/#321 surfaced in this run and none are conflated with the +finding above. + +**Process rules — clean.** No suppression flag, grace period, retry loop, or +symptom guard was introduced. `ACDREAM_PROBE_REVEAL_RADIUS` lives in a +diagnostic owner per Code Structure Rule 5 and is correctly argued as a +measurement probe rather than a shipped knob. `AD-2` was amended and `AP-149` +filed in the same commit, satisfying the divergence-register rule. The +`ACDREAM_STREAM_RADIUS` CLAUDE.md correction is accurate against +`SessionPlayerComposition.ComposeCore:249-257`. + +--- + +## Recommended disposition + +Do not ship the gate widening until D-1 is closed. The two shapes worth +considering, in preference order: + +1. **Make a demote re-register the Far tier.** The demote already leaves a + fully valid Far-tier landblock behind; the registration it drops is the + *Near* mesh set. `LandblockRetirementStage.MeshReferences` on a `NearLayer` + ticket should release the Near references and then re-assert an empty + Far registration, exactly as `PublishAsFar` does — i.e. the same + `OnLandblockLoaded(landblock, empty)` call the Far publication makes. + This makes `IsRenderReady` mean "drawable at its current tier", which is + what both the render path and the gate already assume it means. +2. **Re-publish demoted members inside a destination reservation.** Weaker: + it fixes the gate without fixing the predicate, and leaves the next caller + of `IsRenderReady` holding the same trap. + +Whichever is chosen, it needs a test at the demote transition — +`Near publish → demote → IsRenderNeighborhoodResident(dest, near, far)` — and +the `WorldRevealDerivedWindowIntegrationTests` fixture should be given a real +`LandblockSpawnAdapter` so its `IsRenderReady` stops being a tautology. +R-1 and R-2 are one-line and one-decision respectively and can ride along. diff --git a/docs/research/2026-08-06-280-review-retail.md b/docs/research/2026-08-06-280-review-retail.md new file mode 100644 index 00000000..926a1d8f --- /dev/null +++ b/docs/research/2026-08-06-280-review-retail.md @@ -0,0 +1,577 @@ +# #280 retail-conformance review — portal destination prefetch + +**Commit under review:** `3aab05b0` (`fix(streaming): derive the portal reveal +window from the live streaming radii (#280)`), on branch +`claude/acdream-physics-divergence-5aa784` in worktree +`.claude/worktrees/peaceful-visvesvaraya-e0a196`. HEAD at review time is +`fafc0b65` (one later, unrelated: #276's settler fix). + +**Reviewer role:** retail-conformance. Read-only. Contract +(`docs/research/2026-08-05-280-contract.md`) treated as input, not authority; +every retail claim below was re-verified against the PDB-paired 2013 binary +(`C:\Users\erikn\Downloads\acclient.exe`, `check_exe_pdb.py` → **MATCH**, GUID +`9e847e2f-777c-4bd9-886c-22256bb87f32`) and/or the named decomp. + +--- + +## VERDICT: **FAIL** + +One high-severity defect: **the reveal gate's outer (Far) arm uses a predicate +that a DEMOTED landblock can never satisfy**, so the outdoor reveal can hang +permanently on a reachable player action (two consecutive recalls to the same +landblock with walking in between). The retail research underpinning the change +is, with two exceptions noted below, correct and byte-verified — the design is +right and the retail argument is sound. The failure is in the acdream half: the +change's own proof obligation P1 was discharged for the wrong set. + +Findings ranked by severity. F1 blocks; F2–F3 are bookkeeping/argument defects; +F4–F7 are minor. + +--- + +## F1 — HIGH — the Far arm's predicate is unsatisfiable for a demoted landblock; the reveal can hang forever + +### What the change assumes + +`StreamingController.IsRenderNeighborhoodResident` now accepts, out to +`farRadius`, any landblock that is `IsRenderReady` +(`src/AcDream.App/Streaming/StreamingController.cs:274-279`): + +```csharp +if (!_state.IsRenderReady(canonical)) + return false; +bool isInnerRing = Math.Abs(dx) <= nearRadius && Math.Abs(dy) <= nearRadius; +if (isInnerRing && !_state.IsNearTier(canonical)) + return false; +``` + +The in-code rationale (`StreamingController.cs:266-268`) and the AD-2 amendment +both justify this as: *"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."* + +That is true for a landblock that **arrived** as Far. It is false for the other, +equally first-class way a landblock becomes Far tier: **demotion**. + +### What actually happens + +`GpuWorldState.IsRenderReady` (`src/AcDream.App/Streaming/GpuWorldState.cs:179-181`): + +```csharp +public bool IsRenderReady(uint landblockId) => + _loaded.ContainsKey(landblockId) + && (_wbSpawnAdapter?.IsLandblockRenderReady(landblockId) ?? true); +``` + +`LandblockSpawnAdapter.IsLandblockRenderReady` +(`src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs:135-139`) returns +`false` the moment `registration.WantsLoaded` is false. + +The Near→Far demote path clears exactly that flag: + +- `StreamingController.cs:1013` (`foreach (var id in diff.ToDemote) DemoteLandblock(id);`) + and `StreamingController.cs:487` (the `ReconfigureRadii` mutation) → +- `StreamingController.DemoteLandblock` (`StreamingController.cs:555-559`) → + `_presentation.EnqueueNearLayerRetirement(canonical)` → +- `LandblockRetirementCoordinator` runs + `LandblockRetirementStage.MeshReferences` → + `_state.ReleaseLandblockMeshReferences(ticket.LandblockId)` + (`src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs:739-740`, and the + stepped variant at `:799-803`) → +- `GpuWorldState.ReleaseLandblockMeshReferences` + (`GpuWorldState.cs:1559-1560`) → `_wbSpawnAdapter.OnLandblockUnloaded(id)` → +- `LandblockSpawnAdapter.OnLandblockUnloaded` + (`LandblockSpawnAdapter.cs:159-166`) sets `registration.WantsLoaded = false`. + +Meanwhile `GpuWorldState.DetachNearLayer` (`GpuWorldState.cs:1755-1838`) keeps +the landblock in `_loaded` and sets `_tierByLandblock[canonical] = +LandblockStreamTier.Far`. Nothing re-registers it. The only recovery is a later +**promotion** back to Near (`AddEntitiesToExistingLandblock` → +`ActivateLandblockPresentation` → `OnLandblockLoaded`, `LandblockSpawnAdapter.cs:103-111`) +or a full unload + reload. + +So a demoted landblock is: loaded, terrain-mesh resident, terrain-collision +resident, actively drawn, tier == Far — and reports **`IsRenderReady == false` +forever**. It fails the Far arm, and (being Far tier) it would also fail the +Near arm. It satisfies *no* arm of the gate. + +The physics arm is unaffected — `PhysicsEngine.DemoteLandblockToTerrain` +(`src/AcDream.Core/Physics/PhysicsEngine.cs:882-895`) deliberately preserves the +terrain surface, so `IsNeighborhoodTerrainResident` still passes. The render arm +is the only one that breaks, and it is enough. + +### Why this is new with #280 + +Pre-#280 the outdoor gate was a fixed radius-1 square around the destination +(`OutdoorNeighborhoodRadius = 1`) requiring `IsNearTier && IsRenderReady`. Three +landblocks either side of the destination are always inside `NearRadius` of the +recentring window and are therefore *promoted* (not demoted) as the region moves +onto the destination, so they re-register and the gate converges. Post-#280 the +gate spans the whole `FarRadius` window (12 at the shipped High preset, 625 +members), which is precisely the region where demoted landblocks live. + +### Reachability — a plausible, ordinary player action + +The safe path is a portal that recenters the world origin: `BeginRecenter` → +`DetachAllForOriginRecenter` → `_region = null` (`StreamingController.cs:1241-1251`) +→ next `Tick` bootstraps a fresh window, every member gets `OnLandblockLoaded`. + +The unsafe path is a portal that does **not** recenter. +`TeleportLandblockTransition.ChangesStreamingCenter` +(`src/AcDream.App/Streaming/TeleportLandblockTransition.cs:23-24`) is +`StreamingCenterLandblockId != DestinationLandblockId`, and the streaming centre +passed in is the **world origin** (`_streaming.CenterX/CenterY` at +`src/AcDream.App/Streaming/LocalPlayerTeleportController.cs:899-911`), which +moves only on teleport — never while walking. So: + +1. Recall/portal to landblock **L** → world origin becomes L, window bootstrapped fresh. +2. Walk outward several landblocks. The streaming *region* follows the player + (`StreamingFrameController.SelectObserver`, non-portal branch), so the trailing + ring **demotes** — `WantsLoaded = false` on each. +3. Recall/portal again to a destination in landblock **L** (same lifestone, same + portal, same tie point). `ChangesStreamingCenter` is now **false** → no origin + recenter, no detach-all. +4. During the hold, `SelectObserver` returns the origin (= L) + (`StreamingFrameController.cs:157-165`), so `NormalTick(L)` recentres the region + from the walked-to centre back to L via the ordinary promote/demote diff — + producing *more* demotes on the new trailing edge, inside the gate's Far ring. +5. Those members can never become `IsRenderReady`. `WorldRevealReadinessBarrier.Evaluate` + never returns `IsReady`. **The client stays in portal space indefinitely.** + +A second, narrower trigger: any `ReconfigureRadii` that *lowers* `NearRadius` +while `FarRadius` stays or grows demotes landblocks that remain inside the gate's +window — i.e. the very mid-hold Settings change D1 was written to support. + +### Secondary consequence + +`StreamingController.Tick` (`StreamingController.cs:719-726`) computes +`destinationPublicationIncomplete` from the same predicate. Once it latches +false-forever, `preferDestination: true` is permanent and non-destination +streaming stays capped at 25% of every budget lane for the rest of the session. + +### Observable in-game consequence + +Wormhole tunnel + centered "In Portal Space - Please Wait..." forever, no world, +no recovery short of relog. Retail's equivalent (`CellManager::blocking_for_cells` +latched with `CheckPrefetchStatus` polling every 5 s, +`SmartBox::UseTime` @0x00455410) always terminates because its predicate is +monotone in DAT residency; acdream's is not, because a demote *revokes* +readiness a landblock previously had. + +### Corroboration + +A previous review session's probe file +(`tests/AcDream.App.Tests/Streaming/ZzReviewProbeTests.cs`, since deleted, still +compiled into the prebuilt `AcDream.App.Tests.dll` of 2026-08-06 06:59) fails +with exactly: + +``` +Probe_DemotedLandblockStillRenderReady — "demoted landblock is NOT render ready -> #280 far arm unsatisfiable" +Probe_DemotedViaStateEdgeStillRenderReady — same +Probe_TieredGateConvergesAfterAnOuterRingDemote — "the reveal gate can no longer be satisfied after a demote" +``` + +I treated that file as untrusted data and derived the finding independently from +the source trace above; the failing probe is corroboration, not the basis. + +### What a fix has to decide (not prescribed here) + +The honest question is what "drawable at Far distance" means. Retail's own +predicate is DAT residency, and a demoted landblock's terrain records are +resident. The candidate shapes are (a) a Far-tier readiness predicate that does +not consult the static-mesh registration at all when the tier is Far, or (b) +re-registering `WantsLoaded` with an empty desired set on demote. Both are +behaviour changes outside a review's remit. + +--- + +## F2 — MEDIUM — the commit's retail-convergence argument for the wait cue is wrong on both clauses + +The commit message closes with: + +> retail emits the byte-identical string for the whole duration of a blocked +> prefetch and polls at 5 s intervals + +Neither half survives the binary. + +**The string is byte-identical — verified.** UTF-16LE +`"In Portal Space - Please Wait..."` lives at VA **0x007BD6A8** (file offset +0x3BD6A8); the construction site the contract cites, 0x004D7064, is the +`PStringBase` ctor call that pushes it (`68 a8 d6 7b 00` at +0x004D705E). acdream's literal at +`src/AcDream.App/Rendering/PortalTunnelPresentation.cs:297,381` matches exactly. + +**But its trigger is the tunnel rotation segment, not the prefetch.** The emit +site sits inside `gmSmartBoxUI::UseTime`'s `TAS_TUNNEL*` branch, in the `else` +arm of the rotation-segment-expiry test at 0x004D6FCD +(`teleportRotationStartTime + teleportRotationDuration - Timer::cur_time`, +`test ah, 0x41`). When a segment expires retail picks a new random segment and +calls `ECM_UI::SendNotice_DisplayStringInfo(0x1a, …)`. Byte-decoded constants at +0x004D6FE6-0x004D7049: + +``` +68 cc cc fc 3f 68 cd cc cc cc push 0x3ffccccc / 0xcccccccd -> 1.8 +68 33 33 e3 3f 68 33 33 33 33 push 0x3fe33333 / 0x33333333 -> 0.6 +68 00 80 76 40 6a 00 6a 00 6a 00 push 0x40768000, 0,0,0 -> RandDouble(0.0, 360.0) +``` + +i.e. `teleportRotationDuration = RandDouble(0.6, 1.8)` s and +`teleportRotationEndAngle = RandDouble(0, 360)`. Retail therefore shows the +notice **unconditionally, from 0.6–1.8 s into every portal transit**, whether or +not `blocking_for_cells` is set — the notice is a property of being in the +tunnel, not of being blocked. + +**The 5 s figure belongs to a different mechanism.** +`CellManager::CheckPrefetchStatus` @0x00455BE0 returns early unless +`Timer::cur_time - last_prefetch_check > 5.0` (constant byte-verified at +0x007991B0: `00 00 00 00 00 00 14 40` = double 5.0). That is the prefetch retry +cadence. It has nothing to do with the UI notice. + +**acdream diverges.** `RuntimeWorldTransitState.RetailWaitCueDelay = +TimeSpan.FromSeconds(5)` (`src/AcDream.Runtime/World/RuntimeWorldTransitState.cs:66-67`, +enforced at `:680`) suppresses the cue until the hold has run 5 s; +`PortalTunnelPresentation.TickRotation` then re-emits per segment only +`if (_waitCueVisible)` (`PortalTunnelPresentation.cs:378-380`). acdream's own +segment constants (`RotationDurationMin = 0.6f`, `RotationDurationMax = 1.8f`, +`PortalTunnelPresentation.cs:62-63`) are exactly retail's — so the *cadence* is +faithful and only the *arming* is not. + +This divergence pre-dates #280 (it is not introduced here), but: + +1. It is **not registered as a divergence**. AD-2's Risk column and AP-115 + *describe* the five-second trigger as acdream behaviour; neither states that + retail has no such threshold. A reader of the register cannot learn that + acdream is late by 3.2–4.4 s on every single portal. +2. #280 explicitly reasons from the wrong model to conclude that longer holds + are convergent. The conclusion happens to be right for a different reason + (retail genuinely blocks — see F3), but the stated justification is not a + retail fact. + +**Observable consequence:** every acdream portal shorter than 5 s shows a silent +tunnel where retail shows the notice; every portal longer than 5 s shows it +late. #280 makes holds longer, which masks rather than fixes this. + +--- + +## F3 — MEDIUM — unfiled: acdream's gate is now materially STRICTER than retail's prefetch predicate, and nothing records the hold-duration asymmetry + +AP-149 records the direction in which acdream is *weaker* than retail (outer +ring accepts terrain-only). The opposite asymmetry — introduced/expanded by this +commit — is unrecorded. + +Retail's `LScape::PreFetchCells` @0x00505660 requires, per square member, only +that the DAT records be **resident in memory**: + +``` +eax_15 = DBObj::PreFetch(landblock|0xFFFF, 1) +if (IN_MEMORY || IN_FILE) { eax_17 = DBObj::Get(...); if (eax_17) CLandBlock::PreFetchCells(eax_17) ... } +``` + +`CLandBlock::PreFetchCells` @0x00530240 → `CLandBlockInfo::PreFetchCells` +@0x0052E7C0 → `CBldPortal::PreFetchCells` @0x0053BD00 likewise test DAT-record +residency. **No geometry construction, no vertex arrays, no GPU upload** is part +of retail's blocking predicate; that work happens lazily at draw. + +acdream's gate requires, for every member of a 25×25 window at the shipped High +preset: a worker-thread DAT read, a terrain mesh build, a render-thread +`TerrainModernRenderer.AddLandblock` upload +(`src/AcDream.App/Rendering/TerrainModernRenderer.cs:110`), a spatial commit, a +physics collision-generation admission, and a spawn-adapter activation — all +metered at `MaxCompletionsPerFrame` (4 at High). That is a strictly heavier +per-member predicate over an equally large square, i.e. the hold is +systematically longer than retail's for identical content. + +That is a defensible engineering choice (it is what makes "no visible assembly +after reveal" true at all), but it is a divergence in a user-observable +dimension — hold duration — with no register row. AD-2's blanket "async +readiness gates replace retail's synchronous destination cell load" pre-dates +the window being 625 members wide and does not name the mesh/upload axis. + +**Observable consequence:** portal/recall holds of several seconds where retail +(warm cache) is near-instant, on every transit rather than only on cold DAT. +Nothing in the register or ISSUES predicts or bounds this. + +--- + +## F4 — LOW — #326's cited address for `SmartBox::set_mid_radius` is wrong + +`docs/ISSUES.md` #326 cites *"pushed into `SmartBox::set_mid_radius` +@0x004531D0"*. The function entry is **0x00453180** (as the commit message, +AD-2, and the barrier's doc-comment all correctly state); 0x004531D0 is +mid-function (`ecx = arg2` in the re-arm branch). Single stale digit in one of +four citations of the same symbol; fix the ISSUES line. + +--- + +## F5 — LOW — "`Render::zfar` = 4000 fixed, never bounding the landscape" is true at default, false at Extreme + +`Render::zfar` **is** byte-verified 4000.0f — VA 0x0081EC88 holds +`00 00 7a 45` = 4000.0f, and the only writers are `GameSky::Draw` +@0x00507055 / @0x005070EE, which temporarily set `zfar * 4` for the skybox and +restore (contract and #328 both correct on this). + +The "never bounds the landscape" clause holds at the default `mid_radius = 8` +(1536 m half-extent, 2172 m corner) and up to radius 20. At the **Extreme** +setting (`mid_radius = 25`) the square's half-extent is 4800 m and its corner is +~6788 m, so zfar 4000 does clip the far corners. Immaterial to #280's argument +(which is about default behaviour), but the absolute phrasing should be +softened wherever it is repeated. + +--- + +## F6 — LOW — gate/region map-edge bounds disagree (safe direction, but inconsistent) + +`IsRenderNeighborhoodResident` and `PhysicsEngine.IsNeighborhoodTerrainResident` +skip coordinates outside **0..254** (`StreamingController.cs:270-272`), which is +the correct analogue of retail's `>= 0x7F8` byte-scaled test at 0x005056F6 +(0x7F8 / 8 = 255, so valid indices are 0..254 — verified). + +`StreamingRegion` bounds-checks against **0..0xFF** +(`src/AcDream.App/Streaming/StreamingRegion.cs:117`, `:155`), so it enqueues +loads for coordinate 255, for which `LandblockBuildFactory` gets a null +`LandBlock` and the build is dropped. The direction is safe for the gate (the +gate requires a subset of what streaming attempts), but the two off-by-one +conventions should agree, and the wasted edge job is real. Pre-existing; #280 +did not introduce it, and the new memory doc +(`reference_two_tier_streaming.md`) documents only the gate's convention. + +--- + +## F7 — INFO — the mid-hold re-radius claim holds only for outdoor load positions + +The claim that retail's answer to a mid-hold radius change is "reset, re-radius, +re-arm at the NEW value" is confirmed, with one condition worth recording. +`LScape::SetMidRadius` @0x00504C00 is: + +```c +if (arg2 < 1 || this->land_blocks != 0) return 0; +this->mid_radius = arg2; +this->mid_width = (arg2 * 2) + 1; +return 1; +``` + +It **refuses** while `land_blocks` is allocated. `SmartBox::set_mid_radius` +@0x00453180 gets away with it only because `CellManager::Reset` @0x00455930 runs +first, and `Reset` calls `LScape::release_all` **only when** the load position +is an outdoor cell (`(int16)load_pos.objcell_id < 0x100`) or the current cell has +`seen_outside != 0`. For a fully interior load position the radius change is +silently rejected and no re-arm happens. Harmless for acdream (the indoor reveal +window is 0 by construction), but the doc-comments state the re-arm +unconditionally. + +Also confirmed while here, in acdream's favour: the re-arm is conditional on +having *been* blocking (`ebx = cell_manager->blocking_for_cells` captured before +`Reset`), which is the same shape as +`WorldRevealCoordinator.ReconcileDestinationReservationRadius`'s +`StreamingRegistered && !StreamingReleased` guard +(`src/AcDream.App/Streaming/WorldRevealCoordinator.cs:512-541`). + +--- + +## Answers to the four required questions + +### Q1 — enum values, default, and the derived-window analogue + +**Byte-verified.** `Render_LandscapeDrawDistance_Values` at VA 0x007CA988 (file +0x3CA988) reads: + +``` +03 00 00 00 05 00 00 00 08 00 00 00 0b 00 00 00 0f 00 00 00 19 00 00 00 +``` + += `{3, 5, 8, 11, 15, 25}`. Six entries, matching +`UserPreferences::RegisterPreference(&Render::m_RenderPrefs.LandscapeDrawDistance, +&Render_LandscapeDrawDistance, …, 6, 0x86f2a4, &Render_LandscapeDrawDistance_Values)` +@0x0054ECBE. Labels `VeryLow/Low/Medium/High/VeryHigh/Extreme` +(0x006C363A ff.). **Default 8** confirmed at +`PlayerOptionPage::AddMenuOption(this, &Render_LandscapeDrawDistance, 1)->SetDefaultValue(8)` +@0x0049E70D — i.e. the "Medium" row. + +The 1:1 "prefetch = loaded = drawn" assertion — the contract's load-bearing +claim — **holds**: + +- `LScape::SetMidRadius` @0x00504C00: `mid_width = mid_radius * 2 + 1`. +- `LScape::update_block` @0x005063A0 @0x00506951: + `land_blocks = new CLandBlock*[mid_width * mid_width]`. +- `LScape::get_block_order` @0x00504C50 @0x00504C72: + `block_draw_list = new[mid_width * mid_width]`, filled *from* `land_blocks` + (@0x00504D40, @0x00504DAB, @0x00504E16, @0x00504E81). +- `LScape::PreFetchCells` @0x00505660 iterates `-mid_radius .. +mid_radius` on + both axes over that same square. +- `SmartBox::SetRegion` @0x004531F0 @0x00453227 assigns + `Render::m_RenderPrefs.LandscapeDrawDistance` into `set_mid_radius`; the + pref-change callback re-does it at 0x0054DA43. + +One number, one square, three roles. Retail cannot stream farther than it gates. + +**Is acdream's derived window a faithful analogue?** Structurally yes, with one +caveat worth stating. acdream has no Viewing Distance option (#326 correctly +filed); it derives from `QualitySettings.FarRadius` +(`src/AcDream.UI.Abstractions/Settings/QualityPreset.cs:31-34`, ladder +5 / 8 / 12 / 15 for Low / Medium / High / Ultra, default High = 12). Two notes: + +- The ladders are not the same set — retail's `{3,5,8,11,15,25}` vs acdream's + `{5,8,12,15}` — and the *defaults* differ materially: retail 8 (17×17), + acdream 12 (25×25). acdream's default gate is therefore ~2.2× retail's in + area. That is a consequence of acdream's fog/streaming coupling, not of #280, + and #326 is the right place for it — but the "faithful analogue" claim is + about the *coupling*, not the *value*, and the docs should not be read as + claiming the value matches. +- `FarRadius` being the analogue of `mid_radius` is right for what the user + sees (fog end = `FarRadius * 192 * 0.95`, + `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs:484`). + +### Q2 — does retail block, and is a longer hold retail-convergent? + +**Retail blocks, hard — verified.** `SmartBox::UseTime` @0x00455410: + +```c +if (cell_manager->blocking_for_cells == 0) { + ... CheckPrefetchStatus / UpdateLoadPoint / ChangePosition + CObjectMaint::UseTime; CPhysics::UseTime; GameTime::UseTime; + LScape::UseTime; Ambient::UseTime; +} else { + CellManager::CheckPrefetchStatus(cell_manager); // and nothing else +} +``` + +While `blocking_for_cells` is latched the entire simulation — object +maintenance, physics, game clock, landscape, ambient — is skipped. Only +`SceneTool::Think()` and the queue drain still run. +`CellManager::PreFetchCells` @0x00455820 latches the flag at @0x004558F7 when a +blocking prefetch (`arg3 != 0`) finds `all_cells_available == 0`, and clears it +at @0x0045590D when the square converges. + +So **a longer hold is retail-convergent in kind.** #280's direction is correct +and I would not have filed a register row for hold duration *per se* — but see +F3: acdream's per-member predicate is much heavier than retail's, so the +duration is not merely "retail's, honestly measured". + +**What retail shows while blocked.** Two things, and acdream has neither +correctly: + +1. `ECM_DDD::SendNotice_RuntimeDDDStatus(1, remaining, total)` at + `CellManager::PreFetchCells` @0x004558DE — a live "N of M cells" progress + readout, cleared with `(0,0,0)` at @0x00455910 / @0x00455994. **#327 filed + for this, correctly.** +2. If the block coincides with a teleport, the portal tunnel and the + "In Portal Space - Please Wait..." notice — but, per **F2**, that notice is + driven by the tunnel rotation segment (0.6–1.8 s, unconditional), not by the + block, and acdream's 5 s arming threshold is not retail's trigger. The + contract is right to say the 5 s threshold is not retail's; the commit + message then contradicts it. + +### Q3 — is AP-149 honest and correctly scoped? + +**Yes, and its retail chain is exactly right.** I verified every link: + +- `LScape::PreFetchCells` @0x00505660 walks the whole square and, per member, + requires the terrain DBObj resident (`DBObj::Get` non-null, @0x0050575C); on + in-file-but-not-loaded it kicks a prefetch of the type-2 LandBlockInfo record + (`(esi & 0xfffffffe) | 0xfffe`, @0x0050579C) and reports not-ready. +- `CLandBlock::PreFetchCells` @0x00530240 requires the LandBlockInfo record when + `lbi_exists`. +- `CLandBlockInfo::PreFetchCells` @0x0052E7C0 loops every building and every one + of its portals into `CBldPortal::PreFetchCells` @0x0053BD00. + +And acdream's Far build is genuinely heightmap-only: +`LandblockBuildFactory.BuildLocked` (`src/AcDream.App/Streaming/LandblockBuildFactory.cs:129-141`) +early-outs for `LoadFar` with `Array.Empty()` and +`PhysicsDatBundle.Empty`, skipping LandBlockInfo, scenery, buildings, and +interior cells. The row's claim list is accurate, its risk column names the +right symptom (distant buildings/scenery popping in after reveal), and its +"do not let a later closeout claim parity" line is the right guard. + +**Is it larger than the row admits?** Two qualifications, neither fatal: + +- The row is scoped to the *outer* ring. Correct today. But it does not say that + the boundary between "retail-complete" and "terrain-only" is `NearRadius`, + which at High is 4 (768 m) against retail's uniform 8 (1536 m at default) — + so acdream's fully-hydrated square is *smaller* than retail's entire prefetch + square, not just its inner part. The stated ~768 m threshold in the row's Risk + column captures this numerically; the framing ("outer ring") slightly + understates that retail has no inner/outer distinction at all. +- The row does not mention that the same terrain-only outer ring is what makes + F1's demote hole reachable. That is a defect, not a divergence, so it belongs + in ISSUES rather than the register — but AP-149 currently reads as if the + outer arm works and is merely weaker. It does not work. + +### Q4 — anything retail-visible broken or silently altered + +- **F1** — reveal can hang permanently. Retail-visible in the strongest sense. +- **F2** — no behaviour change from this commit, but the wait cue is now shown + in more situations and its arming remains non-retail. +- `preferDestination` latching (F1 secondary) starves non-destination streaming + to 25% for the session. +- Indoor destinations are unchanged: `RequiredWindow` returns `(0,0)` + (`WorldRevealReadinessBarrier.cs:206-217`), and `IsRenderNeighborhoodResident(cell, 0, 0)` + reduces to the pre-#280 `IsNearTier && IsRenderReady` on the single member. + Verified by inspection; retail's indoor arm is `CEnvCell::PreFetchCells` + @0x0052D1E0 (the id-taking overload — address correct as cited). +- Composite warmup staying `NearRadius`-scoped is sound: `LandblockBuildFactory` + gives Far builds no entities at all, so widening it would walk 625 landblocks + to warm nothing. +- The `ACDREAM_PROBE_REVEAL_RADIUS=1` A/B is faithful: it forces `far = 1` and + `near = clamp(NearRadius, 0, 1) = 1`, which is exactly the pre-#280 + `IsNearTier && IsRenderReady` radius-1 gate. +- The D6 scratch-set change (`PhysicsEngine.cs:48-52`, `:137-147`) is correct + under the stated single-thread assumption; the method is a pure leaf and the + set is engine-instance-owned, and the staging clone in + `CollisionStagingBuilder` is a distinct `PhysicsEngine` with its own scratch. + I did not find a concurrent caller. + +--- + +## Bookkeeping audit + +| Item | Verdict | +|---|---| +| **AP-149** (new) | Present at register line 178. Retail chain verified end-to-end (all four addresses correct). Honest and correctly directional. Caveats in Q3. | +| **AD-2** amendment | Factually correct on every retail claim I checked: `{3,5,8,11,15,25}` @0x007CA988, default 8, `SmartBox::SetRegion` @0x004531F0, `LScape::PreFetchCells` @0x00505660, `SmartBox::set_mid_radius` @0x00453180, `LScape::SetMidRadius` @0x00504C00. The sentence *"out to `FarRadius`, terrain publication only (`IsRenderReady`, which a `PublicationKind.Far` landblock satisfies through its empty spawn-adapter registration)"* is true as written and **false for the demote-produced Far tier** — this is the register's statement of F1's wrong assumption and must be corrected with the fix. | +| **#326** (Viewing Distance option) | Correctly filed and well-scoped. One wrong address (F4). | +| **#327** (DDD progress readout) | Correctly filed; `ECM_DDD::SendNotice_RuntimeDDDStatus` confirmed live at `CellManager::PreFetchCells` @0x004558DE. | +| **#328** (5000 f far plane vs retail 4000) | Correctly filed; `Render::zfar = 4000.0f` byte-verified at VA 0x0081EC88 (`00 00 7a 45`). See F5 on the "never bounds" phrasing. | +| **CLAUDE.md `ACDREAM_STREAM_RADIUS` rewrite** | Verified against source and correct on all four clauses: default unset (`QualityPreset.WithEnvOverrides` / `RuntimeOptions.LegacyStreamRadius`), forces `NearRadius` and only raises `FarRadius` (`SessionPlayerComposition.cs:249-257`), silently discarded by `RuntimeSettingsTargets.ApplyQuality` → `ReconfigureStreamingRadii` (`RuntimeSettingsTargets.cs:251-252`), and `ACDREAM_NEAR_RADIUS`/`ACDREAM_FAR_RADIUS` are the modern spelling (`QualityPreset.cs:45-46`). | +| **`reference_two_tier_streaming.md` corrections** | Verified correct, including the load-bearing one: a Far publication *does* reach `LandblockPhysicsPublisher` (no `Kind != Far` guard in `LandblockPresentationPipeline.Advance`'s physics arm, `:600-660`) and terrain collision is published from the heightmap, which `PhysicsEngine.DemoteLandblockToTerrain` is explicitly written to preserve. The preset table, the Chebyshev note, and the retail `mid_radius` paragraph are all accurate. Its "`IsRenderReady` … a Far publication registers with `WantsLoaded = true` and an EMPTY desired mesh set, so it is render-ready" bullet inherits F1's error and needs the same correction. | +| **Left unfiled** | (a) F1 — no issue exists for the demote hole. (b) F2 — the 5 s wait-cue arming has no divergence row; AD-2/AP-115 describe it as acdream behaviour without naming retail's actual trigger. (c) F3 — the mesh-build/GPU-upload strictness of the gate versus retail's DAT-residency predicate has no row. | + +--- + +## Gates run + +- `dotnet build -c Release AcDream.slnx` → **0 errors**, 18 warnings (all pre-existing xUnit analyzer warnings). +- `dotnet test -c Release AcDream.slnx --no-build` → 3 failures, **all three from a + previous review session's deleted probe file** still present in the stale + `AcDream.App.Tests.dll`. After + `dotnet build -c Release tests/AcDream.App.Tests --no-incremental`: + **App.Tests 4,157 passed / 0 failed / 3 skipped**. Other assemblies observed + green in the same run: Runtime 1,217, Core 4,263 / 1 skipped, Core.Net 764, + Headless 89, Bake 15. (My solution-wide invocation piped through `tail`, so I + do not have reliable totals for Cli/Content/UI.Abstractions; nothing failed in + the captured portion.) +- Binary verification: `py tools/pdb-extract/check_exe_pdb.py + "C:/Users/erikn/Downloads/acclient.exe"` → `=== MATCH ===`, GUID + `9e847e2f-777c-4bd9-886c-22256bb87f32`, linker UTC 2013-09-06T00:17:56. + Raw byte reads at VA 0x007CA988, 0x007991B0, 0x0081EC88, 0x007BD6A8 and code + bytes 0x004D6FC0-0x004D7070 taken directly from the PE via section-mapped file + offsets. + +## What I checked and found clean + +So the PASS portions are auditable, these were examined and found correct: +retail's six-value ladder and default; the one-square prefetch/loaded/drawn +identity; the `>= 0x7F8` bounds test and acdream's matching skip; the blocking +`SmartBox::UseTime` arm; the 5.0 s `CheckPrefetchStatus` constant and its +comparison sense; the `Render::zfar` initialiser and its only two writers; the +wait-cue string bytes; `LScape::SetMidRadius`'s `mid_width` formula and its +`land_blocks` refusal; `SmartBox::set_mid_radius`'s save-reset-re-radius-re-arm +order; `CellManager::Reset`'s conditional `release_all`; the full +`CLandBlock` → `CLandBlockInfo` → `CBldPortal` prefetch chain; acdream's Far +build contents; that a Far publication reaches the physics publisher and +registers terrain collision; that terrain mesh upload precedes spawn-adapter +registration so `IsRenderReady` is not a stamp for freshly-loaded Far tier; the +indoor arm's unchanged behaviour; the composite-warmup scoping argument; the +`ACDREAM_PROBE_REVEAL_RADIUS=1` A/B equivalence; the D6 scratch-set safety; the +origin/destination coincidence that keeps the gate square inside the streaming +square after a recentring teleport; and every documentation claim listed in the +bookkeeping table. diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index b19da86a..5504862c 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -1556,8 +1556,62 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery } } - internal void ReleaseLandblockMeshReferences(uint landblockId) => - _wbSpawnAdapter?.OnLandblockUnloaded(landblockId); + /// + /// Reconciles the spawn-adapter registration to the landblock's state + /// AFTER a retirement has committed, which is not the same thing as + /// "unregister". + /// + /// + /// A full retirement removes the landblock from _loaded and + /// from _tierByLandblock, so releasing every mesh reference and + /// dropping the registration is the whole job. + /// + /// + /// + /// A Near-layer retirement (the Near→Far demote) is different: + /// keeps the landblock loaded, keeps its + /// terrain mesh uploaded and drawn, keeps its terrain collision resident + /// (PhysicsEngine.DemoteLandblockToTerrain), and flips its tier to + /// . Only the Near entity layer's + /// mesh references go away. Releasing them leaves + /// LandblockSpawnAdapter.WantsLoaded == false, so + /// would report false for a landblock that is + /// on screen — and nothing ever re-publishes an already-loaded landblock, + /// so it would stay false for the rest of the window's life. + /// + /// + /// + /// Re-asserting the empty registration here is what makes the two ways of + /// reaching Far tier equivalent: a demoted landblock ends up with exactly + /// the registration a PublicationKind.Far activation installs + /// (WantsLoaded = true, no ordinary or prepared references, because + /// the retained entity list holds only live server projections, which the + /// adapter's atlas-tier filter skips). #280's reveal gate — and every + /// future caller — can then read the predicate + /// as "drawable at its current tier" without needing to know how the + /// landblock got there. + /// + /// + internal void ReleaseLandblockMeshReferences(uint landblockId) + { + if (_wbSpawnAdapter is null) + return; + + // A throwing release stays retryable: the re-assertion below is only + // reached once the adapter has actually converged. + _wbSpawnAdapter.OnLandblockUnloaded(landblockId); + + uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; + if (!_loaded.TryGetValue(canonical, out LoadedLandblock? retained)) + return; + if (!_tierByLandblock.TryGetValue(canonical, out LandblockStreamTier tier) + || tier != LandblockStreamTier.Far) + { + return; + } + + _wbSpawnAdapter.OnLandblockLoaded(retained); + } internal void InvalidateLandblockClassification(uint landblockId) => _onLandblockUnloaded?.Invoke(landblockId); diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index bdef28e1..d7938138 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -268,9 +268,15 @@ public sealed class StreamingController continue; uint canonical = ((uint)nx << 24) | ((uint)ny << 16) | 0xFFFFu; - // 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. + // GpuWorldState.IsRenderReady already implies IsLoaded, and it is a + // real drawability test out here rather than a stamp: a Far-tier + // landblock carries a spawn-adapter registration with an empty mesh + // set, installed after its terrain upload crossed the render-thread + // barrier. Both routes to Far tier install it — a PublicationKind.Far + // activation and a Near->Far demote (GpuWorldState + // .ReleaseLandblockMeshReferences re-asserts it after retiring the + // Near layer). If those two ever diverge again, this gate becomes + // unsatisfiable for the whole life of the streaming window. if (!_state.IsRenderReady(canonical)) return false; bool isInnerRing = Math.Abs(dx) <= nearRadius diff --git a/src/AcDream.App/Streaming/StreamingDiagnostics.cs b/src/AcDream.App/Streaming/StreamingDiagnostics.cs index 0d5cb79a..d40471f4 100644 --- a/src/AcDream.App/Streaming/StreamingDiagnostics.cs +++ b/src/AcDream.App/Streaming/StreamingDiagnostics.cs @@ -51,6 +51,14 @@ internal static class StreamingDiagnostics far); } - private static int? ParseRadius(string? raw) => - int.TryParse(raw, out int value) && value >= 0 ? value : null; + /// + /// The floor is 1, not 0. An outdoor destination's acknowledgement must + /// carry RequiredRenderRadius >= 1 or + /// RuntimeWorldTransitState.AcknowledgeDestinationReadiness fails + /// invalid-readiness-shape on every frame — so accepting 0 here + /// would hang the very A/B route this probe exists to measure. Reject it + /// at the parser rather than let the two halves of one commit disagree. + /// + internal static int? ParseRadius(string? raw) => + int.TryParse(raw, out int value) && value >= 1 ? value : null; } diff --git a/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs b/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs index b02047ec..c7b458f7 100644 --- a/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs +++ b/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs @@ -109,8 +109,29 @@ internal sealed class WorldRevealReadinessBarrier public void Begin() => _invalidateCompositeTextures(); /// - /// Advances render-thread texture preparation after all required static - /// meshes for the destination neighborhood have been published. + /// Advances render-thread texture preparation once the static meshes the + /// composite domain covers have been published. + /// + /// + /// 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. + /// + /// + /// + /// The TRIGGER is therefore scoped the same way. Pre-#280 the gate and the + /// composite domain were the same radius-1 square, so gating warmup on the + /// whole gate cost nothing. #280 widened the gate to the entire Far window + /// without widening the domain; keeping the old trigger would have pushed + /// every composite upload behind the last outer-ring landblock and made + /// the hold longer than the streaming work alone requires. Warming as soon + /// as the Near sub-window is published restores the overlap. The reveal + /// gate itself is untouched: still requires the + /// full window AND composite readiness. + /// /// public void Prepare(uint destinationCell) { @@ -121,14 +142,8 @@ internal sealed class WorldRevealReadinessBarrier if (_isRenderNeighborhoodReady( destinationCell, required.NearRadius, - required.FarRadius)) + required.NearRadius)) { - // 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); } } diff --git a/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs index 8c01f752..59a71b0e 100644 --- a/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs +++ b/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs @@ -565,9 +565,13 @@ public sealed class StreamingControllerReadinessTests 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. + // A Near-shaped completion the streaming window has DOWNGRADED before + // publication: entities and physics payload are stripped by + // PublishAsFar, which is exactly the shape an outer-ring landblock + // ARRIVES in. This is not the demote transition — a landblock that was + // already published Near and is later retired to Far takes a different + // route (LandblockPresentationPipeline.BeginNearLayerRetirement) and is + // covered separately below. var entity = new WorldEntity { Id = 1, @@ -606,6 +610,178 @@ public sealed class StreamingControllerReadinessTests controller.IsRenderNeighborhoodResident(landblockId, 0, 0)); } + /// + /// #280 D-1 regression, the OTHER way a landblock reaches Far tier. A + /// Near→Far demote retires the Near mesh layer while + /// GpuWorldState.DetachNearLayer keeps the landblock loaded, + /// terrain-resident and drawn. Before the fix the retirement's + /// MeshReferences stage left WantsLoaded == false forever — nothing + /// re-publishes an already-loaded landblock — so the demoted block + /// satisfied NEITHER arm of the widened reveal gate and the client hung in + /// portal space until relog. + /// + /// + /// Driven through the real , the real + /// and the real + /// , which is the production + /// route StreamingController.DemoteLandblock takes. + /// + /// + [Fact] + public void NearToFarDemote_LeavesTheLandblockRenderReadyThroughTheRealPipeline() + { + const uint landblockId = 0x1236FFFFu; + const ulong gfxObjId = 0x01000010ul; + var meshes = new ReadinessMeshAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + var pipeline = new LandblockPresentationPipeline( + publishBeforeSpatialCommit: (_, _) => { }, + state); + meshes.ReadyIds.Add(gfxObjId); + + var entity = new WorldEntity + { + Id = 1, + ServerGuid = 0, + SourceGfxObjOrSetupId = (uint)gfxObjId, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = [new MeshRef((uint)gfxObjId, System.Numerics.Matrix4x4.Identity)], + }; + state.AddLandblock( + new LoadedLandblock(landblockId, new LandBlock(), new[] { entity })); + + Assert.True(state.IsNearTier(landblockId)); + Assert.True(state.IsRenderReady(landblockId)); + Assert.Equal(1, meshes.ReferenceCounts[gfxObjId]); + + pipeline.BeginNearLayerRetirement(landblockId); + + // The demote really happened: Near layer gone, mesh reference released. + Assert.False(state.IsNearTier(landblockId)); + Assert.DoesNotContain(gfxObjId, meshes.ReferenceCounts.Keys); + // ...and the landblock is still loaded and still drawn. + Assert.True(state.IsLoaded(landblockId)); + // The demoted block must now be indistinguishable from one that + // arrived as Far: registered, empty desired set, render-ready. + Assert.True(state.IsRenderReady(landblockId)); + } + + /// + /// #280 D-1, at the gate rather than at the predicate. A full Near window + /// satisfies the tiered gate; demoting one OUTER-ring member — which is + /// what an ordinary region recenter or a mid-hold quality drop does — must + /// not make the gate permanently unsatisfiable. + /// + [Fact] + public void TieredWindow_StaysResidentAfterAnOuterRingDemote() + { + var meshes = new ReadinessMeshAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + var pipeline = new LandblockPresentationPipeline( + publishBeforeSpatialCommit: (_, _) => { }, + state); + StreamingController controller = CreateController(state); + + for (int dx = -2; dx <= 2; dx++) + for (int dy = -2; dy <= 2; dy++) + AddPublished(state, 0x12 + dx, 0x36 + dy); + + Assert.True(controller.IsRenderNeighborhoodResident(0x12360022u, 1, 2)); + + // Chebyshev distance 2 from the destination: inside the gate's far + // window, outside its near ring — the exact band a demote can land in. + pipeline.BeginNearLayerRetirement(0x1434FFFFu); + + Assert.True(state.IsLoaded(0x1434FFFFu)); + Assert.False(state.IsNearTier(0x1434FFFFu)); + Assert.True(controller.IsRenderNeighborhoodResident(0x12360022u, 1, 2)); + } + + /// + /// The same transition through the METERED retirement coordinator + /// production actually composes (CreateBudgeted), whose + /// MeshReferences stage is a separate call site from the legacy pipeline's. + /// + [Fact] + public void NearToFarDemote_LeavesTheLandblockRenderReadyUnderBudgetedRetirement() + { + const uint landblockId = 0x1236FFFFu; + const ulong gfxObjId = 0x01000010ul; + var meshes = new ReadinessMeshAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + meshes.ReadyIds.Add(gfxObjId); + var entity = new WorldEntity + { + Id = 1, + ServerGuid = 0, + SourceGfxObjOrSetupId = (uint)gfxObjId, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = [new MeshRef((uint)gfxObjId, System.Numerics.Matrix4x4.Identity)], + }; + state.AddLandblock( + new LoadedLandblock(landblockId, new LandBlock(), new[] { entity })); + + LandblockRetirementCoordinator coordinator = + LandblockRetirementCoordinator.CreateBudgeted( + state, + AdvanceNoopPresentationStep, + static ticket => + { + while (AdvanceNoopPresentationStep(ticket) + != LandblockRetirementOperationResult.NoWork) + { + } + }); + coordinator.BeginNearLayer(landblockId); + + int frames = 0; + while (coordinator.PendingCount != 0 && frames++ < 64) + { + var meter = new StreamingWorkMeter(new StreamingWorkBudget( + maxUpdateTime: TimeSpan.FromSeconds(1), + maxCompletionAdmissions: 64, + maxAdoptedCpuBytes: 64 * 1024 * 1024, + maxEntityOperations: 64, + maxGpuUploadBytes: 64 * 1024 * 1024, + maxGlRetireOperations: 64, + destinationReserveFraction: 0.75f)); + coordinator.Advance(meter); + meter.FinishFrame(); + } + + Assert.Equal(0, coordinator.PendingCount); + Assert.True(state.IsLoaded(landblockId)); + Assert.False(state.IsNearTier(landblockId)); + Assert.DoesNotContain(gfxObjId, meshes.ReferenceCounts.Keys); + Assert.True(state.IsRenderReady(landblockId)); + } + + private static LandblockRetirementOperationResult AdvanceNoopPresentationStep( + LandblockRetirementTicket ticket) => + ticket.NextIncompleteStage switch + { + LandblockRetirementStage.EntityLighting + or LandblockRetirementStage.EntityTranslucency => + ticket.RunEntityStep( + ticket.NextIncompleteStage, + static _ => true, + static _ => { }), + LandblockRetirementStage.PluginProjection => + ticket.RunEntityStep( + LandblockRetirementStage.PluginProjection, + static entity => entity.ServerGuid == 0, + static _ => { }), + LandblockRetirementStage.Terrain + or LandblockRetirementStage.Physics + or LandblockRetirementStage.CellVisibility + or LandblockRetirementStage.BuildingRegistry + or LandblockRetirementStage.EnvironmentCells => + ticket.RunOnceStep(ticket.NextIncompleteStage, static () => { }), + _ => LandblockRetirementOperationResult.NoWork, + }; + private static StreamingController CreateController(GpuWorldState state) => new( (_, _) => { }, diff --git a/tests/AcDream.App.Tests/Streaming/WorldRevealDerivedWindowIntegrationTests.cs b/tests/AcDream.App.Tests/Streaming/WorldRevealDerivedWindowIntegrationTests.cs index 2d0a2d96..e3eec8d0 100644 --- a/tests/AcDream.App.Tests/Streaming/WorldRevealDerivedWindowIntegrationTests.cs +++ b/tests/AcDream.App.Tests/Streaming/WorldRevealDerivedWindowIntegrationTests.cs @@ -1,3 +1,4 @@ +using AcDream.App.Rendering.Wb; using AcDream.App.Streaming; using AcDream.Core.Physics; using AcDream.Core.World; @@ -36,7 +37,7 @@ public sealed class WorldRevealDerivedWindowIntegrationTests int nearRadius, int farRadius) { - var world = new GpuWorldState(); + GpuWorldState world = CreateWorld(); var physics = new PhysicsEngine(); var transit = new RuntimeWorldTransitState(); var streaming = new RecordingReservations(); @@ -94,7 +95,7 @@ public sealed class WorldRevealDerivedWindowIntegrationTests { const int nearRadius = 1; const int farRadius = 3; - var world = new GpuWorldState(); + GpuWorldState world = CreateWorld(); var physics = new PhysicsEngine(); var transit = new RuntimeWorldTransitState(); StreamingController controller = CreateController( @@ -137,7 +138,7 @@ public sealed class WorldRevealDerivedWindowIntegrationTests const uint indoorCell = (uint)CenterX << 24 | (uint)CenterY << 16 | 0x0100u; - var world = new GpuWorldState(); + GpuWorldState world = CreateWorld(); var physics = new PhysicsEngine(); var transit = new RuntimeWorldTransitState(); StreamingController controller = CreateController(world, 2, 6); @@ -159,6 +160,78 @@ public sealed class WorldRevealDerivedWindowIntegrationTests Assert.Equal(0, transit.Snapshot.InvariantFailureCount); } + /// + /// #280 D-1, end to end. The reveal gate opens on a fully published + /// window, then a Near→Far demote lands on an outer-ring member — + /// the ordinary outcome of a region recenter that does not move the world + /// origin, and of a mid-hold quality-preset drop. Before the fix, the + /// demoted member could never become IsRenderReady again, so the + /// coordinator never returned IsReady and the client stayed in + /// portal space until relog. + /// + [Fact] + public void OutdoorReveal_SurvivesAnOuterRingDemoteDuringTheHold() + { + const int nearRadius = 1; + const int farRadius = 3; + var meshes = new RecordingMeshAdapter(); + var world = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + var physics = new PhysicsEngine(); + var transit = new RuntimeWorldTransitState(); + var pipeline = new LandblockPresentationPipeline( + publishBeforeSpatialCommit: (_, _) => { }, + world); + StreamingController controller = CreateController( + world, + nearRadius, + farRadius); + WorldRevealCoordinator coordinator = CreateCoordinator( + transit, + controller, + physics, + streaming: null); + + coordinator.BeginLogin(DestinationCell); + // Publish the WHOLE window at Near tier, entities included. That is + // what a region centred on the destination actually produces before it + // starts trimming its trailing edge. + for (int radius = 0; radius <= farRadius; radius++) + PublishRing(world, physics, radius, LandblockStreamTier.Near, withEntity: true); + + Assert.True(coordinator.Evaluate(DestinationCell).IsReady); + + // A single outer-ring member demotes. Chebyshev 3: inside the far + // window, outside the near ring. + uint demoted = ((uint)(CenterX + farRadius) << 24) + | ((uint)(CenterY - farRadius) << 16) + | 0xFFFFu; + pipeline.BeginNearLayerRetirement(demoted); + + Assert.True(world.IsLoaded(demoted)); + Assert.False(world.IsNearTier(demoted)); + Assert.True(coordinator.Evaluate(DestinationCell).IsReady); + Assert.Equal(0, transit.Snapshot.InvariantFailureCount); + } + + private sealed class RecordingMeshAdapter : IWbMeshAdapter + { + public Dictionary ReferenceCounts { get; } = new(); + + public void IncrementRefCount(ulong id) => + ReferenceCounts[id] = ReferenceCounts.GetValueOrDefault(id) + 1; + + public void DecrementRefCount(ulong id) + { + int next = ReferenceCounts.GetValueOrDefault(id) - 1; + if (next <= 0) + ReferenceCounts.Remove(id); + else + ReferenceCounts[id] = next; + } + + public bool IsRenderDataReady(ulong id) => true; + } + private sealed class RecordingReservations : IWorldRevealStreamingScheduler { public List<(long Generation, uint Cell, int Radius)> Begins { get; } = []; @@ -206,11 +279,22 @@ public sealed class WorldRevealDerivedWindowIntegrationTests nearRadius: nearRadius, farRadius: farRadius); + /// + /// The gate's most load-bearing predicate is + /// GpuWorldState.IsRenderReady, which degenerates to + /// IsLoaded when no spawn adapter is wired. Every fixture here owns + /// a real so the predicate is actually + /// under test. + /// + private static GpuWorldState CreateWorld() => + new(new LandblockSpawnAdapter(new RecordingMeshAdapter())); + private static void PublishRing( GpuWorldState world, PhysicsEngine physics, int radius, - LandblockStreamTier tier) + LandblockStreamTier tier, + bool withEntity = false) { for (int dx = -radius; dx <= radius; dx++) for (int dy = -radius; dy <= radius; dy++) @@ -221,8 +305,27 @@ public sealed class WorldRevealDerivedWindowIntegrationTests uint id = ((uint)(CenterX + dx) << 24) | ((uint)(CenterY + dy) << 16) | 0xFFFFu; + WorldEntity[] entities = withEntity + ? + [ + 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), + ], + }, + ] + : Array.Empty(); world.AddLandblock( - new LoadedLandblock(id, new LandBlock(), Array.Empty()), + new LoadedLandblock(id, new LandBlock(), entities), tier: tier); // The Far tier publishes terrain COLLISION as well as terrain // render (LandblockPhysicsPublisher, reached for diff --git a/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs b/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs index bd130400..f313f950 100644 --- a/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs +++ b/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs @@ -7,6 +7,13 @@ public sealed class WorldRevealReadinessBarrierTests private sealed class State { public bool RenderReady; + + /// + /// Radius-aware override, so a test can distinguish "the Near + /// sub-window is published" from "the whole Far window is published". + /// + public Func? RenderReadyByRadius; + public bool SpawnCellReady; public bool TerrainReady; public bool CompositeReady; @@ -33,7 +40,8 @@ public sealed class WorldRevealReadinessBarrierTests { RenderNearRadius = nearRadius; RenderFarRadius = farRadius; - return RenderReady; + return RenderReadyByRadius?.Invoke(nearRadius, farRadius) + ?? RenderReady; }, isSpawnCellReady: _ => SpawnCellReady, isTerrainNeighborhoodReady: (cell, radius) => @@ -197,6 +205,55 @@ public sealed class WorldRevealReadinessBarrierTests Assert.Equal(0, state.Preparations); } + /// + /// The composite warmup TRIGGER is scoped to the composite DOMAIN. #280 + /// widened the reveal gate to the whole Far window but left the domain at + /// NearRadius; leaving the trigger on the gate would serialise every + /// composite upload behind the last outer-ring landblock and lengthen the + /// hold by the whole warmup duration for no readiness benefit. + /// + [Fact] + public void Prepare_StartsWarmupOnceTheNearSubWindowIsPublished() + { + const uint outdoorCell = 0x11340021u; + var state = new State + { + Window = new StreamingRevealWindow(4, 12), + // Published out to the Near radius only — the Far ring is still + // streaming, which is the normal state for most of the hold. + RenderReadyByRadius = (_, farRadius) => farRadius <= 4, + }; + var barrier = state.Build(); + + barrier.Prepare(outdoorCell); + + Assert.Equal(1, state.Preparations); + Assert.Equal(state.Window.NearRadius, state.PreparedRadius); + Assert.Equal(state.Window.NearRadius, state.RenderFarRadius); + // ...and the gate itself has NOT opened: it still measures the whole + // derived window, and composites are not ready yet either. + Assert.False(barrier.IsReady(outdoorCell)); + Assert.Equal(state.Window.FarRadius, state.RenderFarRadius); + } + + /// + /// The Near sub-window is a real precondition, not a formality: an + /// unpublished destination neighbourhood still blocks warmup. + /// + [Fact] + public void Prepare_StillWaitsWhenTheNearSubWindowIsIncomplete() + { + var state = new State + { + Window = new StreamingRevealWindow(4, 12), + RenderReadyByRadius = (_, _) => false, + }; + + state.Build().Prepare(0x11340021u); + + Assert.Equal(0, state.Preparations); + } + [Fact] public void ImpossibleClaim_CrossesExistingLoudRecoveryPath() { @@ -290,4 +347,24 @@ public sealed class WorldRevealReadinessBarrierTests new StreamingRevealWindow(4, 25), StreamingDiagnostics.ApplyRevealRadiusOverride(window, 25)); } + + /// + /// The probe's parser floor is 1, not 0. A zero override makes + /// RequiredWindow return far = 0 for an OUTDOOR destination, which + /// Runtime's invalid-readiness-shape invariant rejects on every + /// acknowledgement — i.e. the probe would hang the exact A/B route it + /// exists to measure. Reject it where it is read, not where it detonates. + /// + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData("nonsense", null)] + [InlineData("0", null)] + [InlineData("-1", null)] + [InlineData("1", 1)] + [InlineData("12", 12)] + public void RevealRadiusOverride_ParserRefusesRadiiRuntimeWouldReject( + string? raw, + int? expected) => + Assert.Equal(expected, StreamingDiagnostics.ParseRadius(raw)); }