acdream/docs/research/2026-08-05-280-contract.md
Erik 73cdb95c7b 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 <noreply@anthropic.com>
2026-08-06 07:27:28 +02:00

63 KiB
Raw Blame History

#280 — portal destination prefetch: pinned contract

Slice: Placement cutover campaign, plan item 3 (docs/plans/2026-08-02-placement-cutover.md:65). Base: worktree .claude/worktrees/peaceful-visvesvaraya-e0a196, branch claude/acdream-physics-divergence-5aa784, HEAD 9ee9c1a1. Status: contract only. No production or test code written; no commit made.

Retail binary used for byte verification: C:\Users\erikn\Downloads\acclient.exe, PE timestamp 0x52291f34 (2013-09-06T00:17:56Z), CodeView GUID 9e847e2f-777c-4bd9-886c-22256bb87f32, image base 0x00400000. py tools/pdb-extract/check_exe_pdb.py prints === MATCH: this exe pairs with our acclient.pdb ===. Every retail constant below that carries a "(byte-verified)" tag was read out of that image, not out of Binary Ninja's pseudo-C.


0. One-paragraph statement of the slice

Retail loads, draws, and blocks on exactly one square of landblocks whose half-width is the user's landscape draw-distance preference; there is no second, smaller "reveal" radius, because the loaded set and the drawable set are the same array. acdream splits that into a two-tier streaming window (Near/Far) plus a reveal barrier gated at a hardcoded radius of 1. The barrier therefore opens the viewport when one landblock ring is complete while the user can see roughly twelve — which is #280. The fix derives the reveal radius from the live streaming window instead of hardcoding it, and makes the render-completeness predicate tier-aware so the outer rings can actually satisfy it. It is both a radius change and a predicate change; either alone is broken (§4).


1. Retail ground truth

1.1 There is exactly one radius, and it is the user's draw-distance setting

LScape owns the loaded landscape as a flat mid_width × mid_width array of CLandBlock*:

  • LScape::SetMidRadius @ 0x00504C00 (pseudo-C :266528):
    if (arg2 < 1 || this->land_blocks != 0) return 0;
    this->mid_radius = arg2;
    this->mid_width  = arg2 * 2 + 1;
    return 1;
    
    Note the second guard: the radius cannot be changed while the block array is allocated. Callers must reset first (see §1.5).
  • LScape::LScape @ 0x00505370 (:267007) constructs with mid_radius = 5, mid_width = 0xB. (byte-verified — file offset for 0x00505390 reads c706 05000000 = mov dword [esi], 5, c74604 0b000000 = mov dword [esi+4], 0xB.) This is the pre-preferences default only.
  • LScape::update_block @ 0x005063A0 (:267954) allocates operator new[](mid_width * mid_width * 4) — the array is the loaded landscape.
  • LScape::update_viewpoint / LScape::set_viewer_block @ 0x00505C70 (:267520) index that same array by viewer_b_xoff/viewer_b_yoff = ((mid_radius << 3) - origin + coord) >> 3, and block_draw_list (allocated alongside land_blocks, freed together at 0x00504BCB) is the draw order over it.

The value assigned to mid_radius comes from one place:

  • SmartBox::SetRegion @ 0x004531F0 (:92064): SmartBox::set_mid_radius(this, Render::m_RenderPrefs.LandscapeDrawDistance);
  • Render::GRPCallback_OnRenderPreferenceChanged @ 0x0054D9A80x0054DA43 (:344372, :344422): when Render::m_RenderPrefs.LandscapeDrawDistance differs from the cached Current_Render_LandscapeDrawDistance, it re-issues SmartBox::set_mid_radius(SmartBox::smartbox, Render::m_RenderPrefs.LandscapeDrawDistance).

Render.LandscapeDrawDistance is a registered user preference — UserPreferences::RegisterPreference(&Render::m_RenderPrefs.LandscapeDrawDistance, &Render_LandscapeDrawDistance, …, 6, 0x86f2a4, &Render_LandscapeDrawDistance_Values) @ 0x0054ECBE (:345471), string "Render.LandscapeDrawDistance" @ 0x006C33CA, UI string id ID_Graphics_LandscapeDrawDistanc… @ 0x004041B7.

The full ladder, byte-verified. uint32_t const Render_LandscapeDrawDistance_Values[6] @ 0x007CA988 (:1021469) reads, from the image:

Choice label (@0x006C363A+) Value Loaded/drawn square Half-extent @192 m/LB
VeryLow 3 7×7 576 m
(idx 1, data_793f8c) 5 11×11 960 m
Medium 8 17×17 1536 m
High 11 23×23 2112 m
VeryHigh 15 31×31 2880 m
Extreme 25 51×51 4800 m

Default = 8 — two independent sites, both byte-verified: PlayerOptionPage::AddMenuOption(...)->SetDefaultValue(8) @ 0x0049E70D (:169449; image bytes at 0x0049E6F0 contain 6a 08 … ff 92 d8020000 = push 8; call [edx+0x2D8]), and Render::m_RenderPrefs.LandscapeDrawDistance = 8 @ 0x0054EF0B (:345535; image bytes c705 a4ef8100 08000000).

The overall-quality presets (Render::SetOverallGraphicsQuality @ 0x0054B020, :341947) map quality 1..5 onto the first five of those values: 3, 5, 8, 11, 15 (:341956, :341970, :341982, :341993, :342004).

Consequence, and this is the load-bearing retail fact for the whole slice: retail's prefetch window, its loaded window, and its drawable landscape window are the same square. There is no retail configuration in which the client streams farther than it gates, because there is only one number.

1.2 The far clip plane is not the landscape bound

float Render::zfar is statically initialised to 4000.0 (:1101868; byte-verified at 0x0081EC88 = 4000.0f). The only Render::set_zfar calls are GameSky::Draw @ 0x00507055 / 0x005070EE (:268724, :268765), which temporarily multiply it by 4 for the skybox and restore it. At the default mid_radius = 8 the landscape ends at 1536 m, well inside zfar. The landscape horizon is the prefetch square, not the frustum.

1.3 m_bUseViewDistance is NOT a view-distance setting

Because it was raised as a candidate: SmartBox::SetOverrideFovDistance @ 0x00451BC0 (:90783) sets m_bUseViewDistance / m_fViewDistFOV, and the two readers — CreatureMode-style camera setup @ 0x00452AFD (:91723) and SmartBox::DrawNoBlit @ 0x00453AE6 (:92655) — use it as a projection parameterisation switch, not a distance:

if (m_bUseViewDistance == 0)  Render::SetFOVRad(m_fGameFOV / (aspect - 0.1f));
else                          Render::set_vdst(m_fViewDistFOV);

and Render::set_vdst @ 0x0054B240 (:342121) is SetFOVInternal(2 * atan(x)) with znear = (x < 0.4) ? 0.1 : x * 0.25. It is "frame the camera to see an object this far away" — an FOV, used by the creature/portrait camera path. It has no relationship to mid_radius, to LandscapeDrawDistance, or to streaming. Do not build anything on it.

1.4 What "each required cell is in" actually tests

LScape::PreFetchCells @ 0x00505660 (:267154) walks for dy in -mid_radius..mid_radius: for dx in -mid_radius..mid_radius (:267172-267255), computes each landblock DID (((x & ~7) << 5) | (y >> 3)) << 16 | 0xFFFF (:267199), skips out-of-bounds (< 0 || >= 0x7F8, i.e. off-map), and for each in-bounds landblock:

  1. DBObj::PreFetch(qdid(did, type 1)). If the result is neither CACHE_OBJECT_IN_MEMORY nor CACHE_OBJECT_IN_FILE, result = 0; if it is CACHE_OBJECT_LOOKING, also *waitingCount += 1.
  2. Otherwise DBObj::Get(...). If Get returns null (present in the file but not yet resident), it additionally prefetches the LandBlockInfo record (did & ~1) | 0xFFFE as type 2, sets result = 0, and *waitingCount += 1.
  3. Otherwise CLandBlock::PreFetchCells(block) @ 0x00530240 (:314195), which prefetches the LandBlockInfo type-2 record and, via CLandBlockInfo::PreFetchCells @ 0x0052E7C0 (:312329), walks every building and calls CBldPortal::PreFetchCells @ 0x0053BD00 (:325061) for each, which prefetches every stab_list[i] as type 3 (the building's EnvCells). Any failure propagates result = 0.

So retail's completeness predicate is: for every in-bounds landblock in the mid_radius square — its terrain record, its LandBlockInfo, and every EnvCell of every building it contains are resident. It is a static-DAT residency test, not a render-upload test (retail uploads synchronously), and it does not include procedural scenery (derived from the terrain record it already required).

Indoor destinations take a different arm: CellManager::PreFetchCells @ 0x00455820 (:94471) dispatches on cellIndex >= 0x100 to CEnvCell::PreFetchCells @ 0x0052D1E0 (:310659), which walks the EnvCell's visible-cell graph recursively; and CEnvCell::PreFetchCells @ 0x0052C460 (:309754) additionally requires the whole mid_radius landscape square when seen_outside != 0 (:309759). Indoor cells that can see outside still require the full outdoor square.

1.5 What blocking_for_cells gates, and how it clears

CellManager::PreFetchCells @ 0x00455820:

  • Early-outs to "available" if DBCache::IsLoader() (:94477).
  • Outdoor arm only re-sweeps if blocking != 0 || all_cells_available == 0 || ((last_prefetch_cell_id ^ cellId) & 0xFFFF0000) != 0 — i.e. standing still in an already-complete landblock is free (:94496).
  • When called with blocking != 0 and cells are missing, it reports ECM_DDD::SendNotice_RuntimeDDDStatus(1, remaining, total) and latches this->blocking_for_cells = 1 (:94538-94541).
  • When everything is in, it clears the latch and reports SendNotice_RuntimeDDDStatus(0,0,0) (:94549-94552).
  • CellManager::Reset @ 0x00455930 (:94588) also clears it.

The latch gates the entire simulation. SmartBox::UseTime @ 0x00455410 (:94168):

if (cell_manager->blocking_for_cells == 0) {
    if (!all_cells_available && CheckPrefetchStatus()) UpdateLoadPoint();
    CellManager::ChangePosition(player->m_position, /*blocking*/ 0);
    ... position_update_complete / has_been_teleported latch ...
    CObjectMaint::UseTime(); CPhysics::UseTime();
    GameTime::UseTime(); LScape::UseTime(); Ambient::UseTime();
} else {
    CellManager::CheckPrefetchStatus(cell_manager);   // and nothing else
}
SceneTool::Think();
... drain the inbound NetBlob queue and dispatch ...
cmdinterp->UseTime(); Render::CalcDegLevel();

So while blocked: no object maintenance, no physics, no game clock, no landscape update, no ambient sound. Networking still drains and events still dispatch. This is the exact behaviour AD-2 already cites.

The retry is rate-limited. CellManager::CheckPrefetchStatus @ 0x00455BE0 (:94734) compares Timer::cur_time - last_prefetch_check against a qword constant at 0x007991B0; byte-verified as 5.0 (0000000000001440). The instruction sequence at 0x00455BE0 is fld qword [0x8369A8]; fsub qword [esi+0x10]; fcomp qword [0x7991B0]; fnstsw ax; test ah,0x41; jnzCF|ZF after fcomp means "elapsed < 5.0 or elapsed == 5.0", so the function returns 0 without re-sweeping. Retail's blocked hold is therefore quantised to 5-second poll intervals. (BN renders the tail -((eax_3 - eax_3)); the image is neg eax; sbb eax,eax; neg eax, an ordinary boolean normalise of the PreFetchCells result. This is one of the flag-test drops the C5b review warned about; it is benign here.)

Which callers block:

Site Call Blocking?
SmartBox::HandleCreateObject (initial player) @ 0x00455069 (:93814) ChangePosition(pos, 1) yes
SmartBox::PlayerPositionUpdated @ 0x00453903 (:92508), teleport arm ChangePosition(pos, arg2 != 0) yes on teleport
SmartBox::PlayerPositionUpdated, ordinary arm ChangePosition(pos, 0) no
SmartBox::UseTime @ 0x00455462 (:94180) ChangePosition(pos, 0) no
SmartBox::set_mid_radius @ 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 (if (blocking_for_cells == 0) edi = arg3; — i.e. once latched, always blocking until cleared).

SmartBox::set_mid_radius @ 0x00453180 (:92036) is the retail answer to "what if the radius changes mid-hold":

ebx = cell_manager->blocking_for_cells;
CellManager::Reset(cell_manager);            // clears the latch, releases lscape
ok = LScape::SetMidRadius(this->lscape, arg2) != 0;
if (ok && ebx != 0 && player && player->m_position.objcell_id != 0)
    CellManager::ChangePosition(this->cell_manager, &player->m_position, 1);

Reset, re-radius, and re-arm the blocking prefetch at the new radius. It does not finish the old hold at the old radius and it does not ignore the change.

1.6 What the user sees while blocked

Two things, both confirmed:

  1. The DDD progress readout. ECM_DDD::SendNotice_RuntimeDDDStatus(active, remaining, total) @ 0x00692870 reaches gmPowerbarUI::RecvNotice_RuntimeDDDStatus @ 0x004DA5C0 (:222574), which sets a text element to string id ID_Powerbar_DDDModeText with CURRENT/TOTAL integer variables and writes current/total into a float attribute 0x69 on element 0x10000034 — a progress bar with an "N of M" cell count on the powerbar. On active == 0 it restores normal state.
  2. The portal-space notice. gmSmartBoxUI::UseTime @ 0x004D6E30 (:219400): while teleportAnimState == TAS_TUNNEL, each time the current rotation segment expires (teleportRotationStartTime + teleportRotationDuration <= cur_time, :004D6FC7) it picks a fresh random segment and emits ECM_UI::SendNotice_DisplayStringInfo(0x1A, "In Portal Space - Please Wait...") (literal at 0x004D7064, :219516). It repeats every rotation segment for as long as the tunnel runs.

Also relevant: on the initial-entry path SmartBox::hidden = 1 is set (0x004553F7, :94139) so SmartBox::Draw @ 0x00455570 returns without drawing at all. On a mid-session teleport the portal tunnel (m_pPortalSpace) is made visible and SmartBox::Hide(m_pSmartBox) is called (0x004D6FA3/0x004D6FB6) — the world is not drawn behind the tunnel either.

So: retail blocks. It does not reveal progressively. It freezes simulation, hides the world, shows a tunnel plus a repeating wait string plus a cell-count progress bar, and only resumes once every landblock in the full draw-distance square is resident.


2. acdream ground truth at HEAD 9ee9c1a1

Every path and line below was read at this HEAD. Do not inherit line numbers from the issue, the campaign plan, the streaming memory doc, or this contract into a later session without re-verifying.

2.1 The reveal barrier

src/AcDream.App/Streaming/WorldRevealReadinessBarrier.csinternal sealed class, 147 lines, seven injected delegates.

  • :38 internal const int OutdoorNeighborhoodRadius = 1;
  • :143-146
    internal static int RequiredRenderRadius(uint destinationCell) =>
        IsIndoor(destinationCell) ? 0 : OutdoorNeighborhoodRadius;
    private static bool IsIndoor(uint cellId) => (cellId & 0xFFFFu) >= 0x0100u;
    
    Note it is static — that is the first structural obstacle to a derived radius.
  • :107-141 Evaluate short-circuits render → composites → collision and returns a WorldRevealReadinessSnapshot (:9) whose IsReady is HasDestination && (IsUnhydratable || (render && composites && collision)).
  • :84-92 Prepare calls _prepareCompositeTextures(destinationCell, radius) with the same radius.

Production wiring, src/AcDream.App/Composition/SessionPlayerComposition.cs:371-396:

Delegate Implementation
isRenderNeighborhoodReady StreamingController.IsRenderNeighborhoodResident (src/AcDream.App/Streaming/StreamingController.cs:227)
isTerrainNeighborhoodReady PhysicsEngine.IsNeighborhoodTerrainResident (src/AcDream.Core/Physics/PhysicsEngine.cs:129)
isSpawnCellReady PhysicsEngine.IsSpawnCellReady (:1797)
areCompositeTexturesReady WbDrawDispatcher.CompositeTexturesReady
prepareCompositeTextures CompositeWarmupEntitySource.Refresh + WbDrawDispatcher.PrepareCompositeTextures
invalidateCompositeTextures CompositeWarmupEntitySource.Reset + WbDrawDispatcher.InvalidateCompositeWarmupReadiness
isSpawnClaimUnhydratable DatSpawnClaimHydrationClassifier.IsUnhydratable

2.2 The radius 1 exists in FOUR places, three of them uncited

# Site Shape
1 src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs:38,144 the named constant
2 src/AcDream.Runtime/World/RuntimeWorldTransitState.cs:573 int requiredRenderRadius = isIndoor ? 0 : 1;a validating invariant
3 src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs:776 RequiredRenderRadius: indoor ? 0 : 1 (producer)
4 src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs:973 RequiredRenderRadius: indoor ? 0 : 1 (producer)

Site 2 is the sharp edge. RuntimeWorldTransitState.AcknowledgeDestinationReadiness re-derives the expected radius and calls FailInvariant("invalid-readiness-shape", …) on mismatch. Changing OutdoorNeighborhoodRadius alone makes every graphical readiness acknowledgement fail this invariant, and the reveal never opens. This is the C5b lesson in its purest form — a contract asserting a mechanism, with the mechanism's value copied rather than referenced.

2.3 The render-completeness predicate refuses Far-tier landblocks

StreamingController.IsRenderNeighborhoodResident (:227-254), the ring body:

uint canonical = ((uint)nx << 24) | ((uint)ny << 16) | 0xFFFFu;
if (!_state.IsNearTier(canonical) || !_state.IsRenderReady(canonical))
    return false;

GpuWorldState.IsNearTier (src/AcDream.App/Streaming/GpuWorldState.cs:176) is true only for LandblockStreamTier.Near. A Far-tier landblock can never satisfy this predicate. Since the Near window is NearRadius (4 at the default preset), any reveal radius above 4 would hang forever. That is why #280 cannot be fixed by editing the constant.

GpuWorldState.IsRenderReady (:180) is _loaded.ContainsKey(id) && (_wbSpawnAdapter?.IsLandblockRenderReady(id) ?? true). Verified that a Far-tier landblock does get a spawn-adapter registration and therefore is render-ready: PublicationKind.Far flows through LandblockPresentationPipeline.cs:900-923GpuWorldState.CommitLandblockSpatialActivateLandblockPresentation (GpuWorldState.cs:1009) → _wbSpawnAdapter.OnLandblockLoaded(...) (:1022), which creates a registration with WantsLoaded = true (src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs:98-101) and an empty Ordinary/Prepared set, so IsLandblockRenderReady (:135-152) returns true. IsNearTier is the sole blocker.

PhysicsEngine.IsNeighborhoodTerrainResident (:129-146) is already tier-agnostic: it keys off PhysicsEngine._landblocks, and Far-tier publication does construct the terrain surface (src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:390-403, reached for PublicationKind.Far via LandblockPresentationPipeline.cs:619). It also allocates a fresh HashSet<uint> over every resident landblock on every call (:131-133) — see §7.

2.4 The streaming window and what the user can actually see

src/AcDream.UI.Abstractions/Settings/QualityPreset.cs:29-36:

Preset NearRadius FarRadius Near ring Far window
Low 2 5 5×5 11×11
Medium 3 8 7×7 17×17
High (default) 4 12 9×9 25×25
Ultra 5 15 11×11 31×31

Default preset is High (src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs:52). Per-axis env overrides ACDREAM_NEAR_RADIUS / ACDREAM_FAR_RADIUS (QualityPreset.cs:45-46). Radii are runtime-mutable via StreamingController.ReconfigureRadii (StreamingController.cs:377), driven from the Settings panel through RuntimeSettingsTargets.ApplyQuality (src/AcDream.App/Settings/RuntimeSettingsTargets.cs:247-253).

Tier contents:

  • Far = the LandBlock heightmap record only (src/AcDream.App/Streaming/LandblockBuildFactory.cs:131-142), empty entity list, PhysicsDatBundle.Empty, and the prepared-collision closure is skipped (:97-101) — but the terrain render mesh and terrain collision surface are published.
  • Near = LandBlock + LandBlockInfo, static entities, procedural scenery, EnvCell shells, interior statics, prepared collision closure (LandblockBuildFactory.cs:144-202).

Visible extent:

  • Far plane: hardcoded 5000f in every camera (src/AcDream.App/Rendering/RetailChaseCamera.cs:56, and identically in ChaseCamera.cs:64, FlyCamera.cs:37, OrbitCamera.cs:28). No config path. Retail's is 4000.
  • Fog, derived from the streaming radii — src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs:481-491 with LandblockSize = 192f (:418): fogStart = NearRadius * 192 * FogStartMultiplier, fogEnd = FarRadius * 192 * FogEndMultiplier, defaults 0.7 / 0.95 (src/AcDream.App/RuntimeOptions.cs:145-146). At High: fogStart 537.6 m, fogEnd 2188.8 m.

2.5 The defect, quantified

At the shipped default the user can see terrain to roughly 2189 m (fog end, inside a 2304 m Far window). The reveal barrier opens once 192 m — the centre landblock plus one ring — is Near-tier complete. That is an 11.4:1 gap, and the far end of it is exactly where the user reported watching the world assemble. Retail's equivalent gap is 1:1 by construction (§1.1).

2.6 The reveal lifecycle owners (post-J6.2)

Canonical: src/AcDream.Runtime/World/RuntimeWorldTransitState.cs (public sealed class … : IRuntimePortalView).

Concern Owner Line
reveal generation BeginRevealCore (checked(++_nextGeneration)) :386 (entered :135, :159)
destination/readiness latch AcknowledgeDestinationReadiness :558-593
materialization/simulation edge AcknowledgePortalMaterialized :595-638
viewport AcknowledgeWorldViewportVisible :640-660
completion / cancellation Complete / Cancel :685, :722
wait-cue latch ObserveWait, RetailWaitCueDelay = 5 s :662-683, :66
host projection lifetime TryRegisterHostProjection :189, :282, :299, :309

Graphical adapter: WorldRevealCoordinator (src/AcDream.App/Streaming/WorldRevealCoordinator.cs:38); it owns the barrier (:58, :80-87), bridges App readiness into the Runtime latch in Evaluate (:183-202), and computes the reservation radius via the static WorldRevealReadinessBarrier.RequiredRenderRadius at :167, handing it to IWorldRevealStreamingScheduler.BeginDestinationReservation at :408-411.

Reveal decision (portal): LocalPlayerTeleportController.Tick (src/AcDream.App/Streaming/LocalPlayerTeleportController.cs:486, decision block :503-539):

bool dataReady = haveDestination && originReady
    && _worldReveal.Evaluate(_pendingCell).IsReady;
bool placementReady = dataReady && TryAdvancePortalCommit(sequence);
if (haveDestination && !placementReady) _holdSeconds += deltaSeconds;
_presentation.SetWaitCue(haveDestination && !placementReady
    && _worldReveal.ObserveWait(TimeSpan.FromSeconds(_holdSeconds)));

Reveal decision (login): LivePlayerModeAutoEntryContext.IsWorldReady (src/AcDream.App/Input/PlayerModeAutoEntry.cs:106-111).

Slice E's hold mechanism is correct and already in place. #280 is not a missing hold — it is the hold measuring the wrong domain. Say this plainly in the commit message; it is the difference between a two-file change and a redesign.

2.7 The destination reservation

StreamingController DestinationReservation(long RevealGeneration, uint LandblockId, int Radius) (:24-27), opened at :145-161, radius supplied by the coordinator = the reveal radius. Two effects: enqueue-order priority (EnqueueLoadsByRevealPriority :994-1012, membership IsDestinationWork :1079-1082, Chebyshev ≤ radius) and a budget lane reservation of DestinationReserveFraction = 0.75 (src/AcDream.App/Streaming/StreamingWorkBudgetOptions.cs:33; enforcement StreamingWorkBudget.cs:385-438) capping non-destination work at 25% of every dimension.


3. The scoping question: derive from what?

Retail's mid_radius is derived from nothing — it is the user's landscape-draw-distance preference, assigned directly (§1.1). It is not a function of m_bUseViewDistance (§1.3) and not a function of the frustum (§1.2). Retail exposes exactly one landscape-extent number and uses it for loading, drawing, and blocking.

acdream has no view-distance setting. ViewDistance / view_distance / LandscapeDrawDistance / DrawDistance return zero hits under src/. What acdream has is QualitySettings.FarRadius — a per-preset landblock radius that bounds the loaded and drawn landscape and drives the fog end. FarRadius is acdream's structural analogue of retail's LandscapeDrawDistance, and the two ladders are strikingly close (retail 3/5/8/11/15/25 vs acdream 5/8/12/15).

Therefore:

D0 — #280 derives its window from the live streaming radii (NearRadius, FarRadius) and does NOT depend on a Viewing Distance option existing.

Retail's Viewing Distance option — a dedicated, user-facing, six-position enum with retail's exact values, replacing or reparameterising the quality preset's radii — is a genuinely separate missing feature. It is not required for #280 and must not be invented inside it. File it as its own issue (suggested text in §14). When it lands, it lands by changing what feeds NearRadius/FarRadius; #280's derivation keeps working untouched, which is the point of deriving rather than duplicating.

No standalone prefetch knob. The defect is the decoupling of the reveal window from the visible window; a knob re-exposes it as a feature and a low setting reintroduces the bug. A diagnostic override is legitimate and belongs in a PhysicsDiagnostics-style owner per CLAUDE.md rule 5 — see D5.

Runtime mutability. The radii can change mid-session (ReconfigureRadii :377). Retail's answer is unambiguous (SmartBox::set_mid_radius @ 0x00453180, §1.5): reset, re-radius, and re-arm the blocking prefetch at the new value. So acdream must read the radii live, per evaluation, not capture them at barrier construction — which falls out for free from D1 and needs no extra machinery, because Evaluate already runs every frame. See D4 for the reservation half, which does need explicit handling.


4. The change — pinned

D1 — the barrier's radius becomes an instance value read live from the streaming window

WorldRevealReadinessBarrier gains an injected Func<StreamingRevealWindow> (or equivalent two-int accessor) supplying the current NearRadius and FarRadius, and RequiredRenderRadius(uint) stops being static:

indoor  -> 0                       (unchanged; retail's EnvCell arm, §1.4)
outdoor -> FarRadius               (retail's mid_radius, §1.1)

The snapshot record gains the near/far split so the predicate and the Runtime acknowledgement carry the same shape. Composite preparation (Prepare, :84-92) keeps using NearRadius, not FarRadius — see D3.

Rationale for FarRadius rather than NearRadius: retail gates on the whole drawn square, and acdream's drawn square is the Far window (fog end 2189 m < Far extent 2304 m at every preset, since FogEndMultiplier = 0.95 < 1, so FarRadius always covers everything the user can see). Gating at NearRadius would fix only the near-detail pop and leave the reported far-terrain symptom intact.

D2 — the render predicate becomes tier-aware

StreamingController.IsRenderNeighborhoodResident takes a near radius and a far radius, and per ring member at Chebyshev distance d:

d <= nearRadius : require IsNearTier(canonical) && IsRenderReady(canonical)
d <= farRadius  : require IsLoaded(canonical)   && IsRenderReady(canonical)

Off-map coordinates keep being skipped (nx/ny outside 0..254), matching retail's >= 0x7F8 bounds skip (§1.4) and the existing comment at StreamingController.cs:244-245.

This is the half without which D1 cannot work: today's IsNearTier requirement makes any radius above NearRadius unsatisfiable (§2.3). IsRenderReady is already true for a published Far landblock (verified, §2.3), so the outer arm is a real test of "this landblock's terrain is published and drawable", not a rubber stamp.

PhysicsEngine.IsNeighborhoodTerrainResident needs no semantic change — it is already tier-agnostic and Far publishes terrain collision — but it needs the allocation fix in D6.

D3 — composites stay at the near radius, and this is a fact about the data, not a shortcut

WbDrawDispatcher.IsCompositeWarmupCandidate (src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:335-360) filters entities by IsWithinLandblockRadius (:451-457) and admits only those with a PaletteOverride or per-mesh SurfaceOverrides. Far-tier landblocks carry zero entities (LandblockBuildFactory.cs:131-142), so widening the composite radius over Far rings warms nothing while walking 625 landblocks' worth of nothing. Passing NearRadius is the honest domain, not a scope cut. Pin it explicitly so a later reader does not "fix the inconsistency" by widening it.

D4 — the destination reservation uses the same radius as the gate

WorldRevealCoordinator (:167, :408-411) hands BeginDestinationReservation the new (Far) radius. Retail has one square for prefetch and for blocking; it has no concept of prioritising an inner ring differently, and splitting them here would invent a mechanism retail does not have.

Consequence to measure, not to assume (§8-P4): with the reservation covering the whole Far window, "non-destination work" during a hold shrinks to out-of-window traffic (retirements, unloads, live entities), which the 25% lane must still drain. If the measurement shows retirement/unload backlog growth across a hold, that is a real finding and gets its own issue — not a quiet radius split inside this slice.

Runtime mutability: if the radii change while a reservation is open, follow retail — end the reservation and reopen it at the new radius on the same reveal generation. EndDestinationReservation already refuses a generation mismatch (:145-183), so the reopen is generation-safe.

D5 — the diagnostic override

One diagnostic-owner property (CLAUDE.md rule 5), read once at startup, e.g. StreamingDiagnostics.RevealRadiusOverride from ACDREAM_PROBE_REVEAL_RADIUS. Default unset ⇒ D1's derivation. Its only purpose is A/B measurement of the stall (§7) and reproduction of the pre-fix behaviour during the connected gate (§10). It is not a user setting, is not surfaced in Settings, and is not persisted. Do not add it to RuntimeOptions as a general knob — it is a probe.

D6 — IsNeighborhoodTerrainResident stops allocating per call

src/AcDream.Core/Physics/PhysicsEngine.cs:129-146 builds a HashSet<uint> over every resident landblock on every call. It is called every frame during a hold (via RenderFrameResourceController.Prepare, src/AcDream.App/Rendering/RenderFrameResourceController.cs:256-265). Today that is 9 ring members and one set build; after D1 it is up to 625 ring members and the same set build, at 3060 Hz, against Slice I1's "0 B/resolve" standard. Replace the set build with direct per-member lookups against the existing landblock map. This is a direct cost of the change, not opportunistic cleanup — it ships in the same commit.

D7 — the Runtime invariant stops re-encoding the App's value

RuntimeWorldTransitState.cs:573 currently asserts RequiredRenderRadius == (isIndoor ? 0 : 1). Runtime must not know the App's streaming configuration — that is the J-slice ownership boundary. Replace with a shape invariant that is still a real invariant:

indoor  => RequiredRenderRadius == 0
outdoor => RequiredRenderRadius >= 1

plus the existing IsIndoor consistency check, which stays. The two producers (RuntimeLiveEntitySessionController.cs:776, HeadlessSessionWorldProjection.cs:973) keep emitting indoor ? 0 : 1 and remain legal: neither host has a streaming window, so "the centre ring" is the honest token there (§5, class C). Do not plumb the App's radii into Runtime to make the strict equality survive — that would be exactly the mechanism-that-does-not-exist assertion C5b was built to stop.

D8 — register bookkeeping, in the implementation commit

AD-2 in docs/architecture/retail-divergence-register.md is the row that covers this machinery and already cites blocking_for_cells and SmartBox::UseTime @0x00455410. It currently describes the outdoor gate as "terrain/collision residency for the required Near ring". Amend it in the same commit to state the derived Far-window gate, the two-tier completeness split (Near ring: full publication; Far ring: terrain publication), the NearRadius-scoped composite domain, and the anchor LScape::PreFetchCells @0x00505660 / Render_LandscapeDrawDistance_Values @0x007CA988.

A new row is required for the residual deviation the fix does not remove: acdream's outer reveal ring accepts terrain-only publication where retail requires the landblock's LandBlockInfo and every building EnvCell (§1.4). Risk column: a distant building or its interior shells can still pop in after reveal, at Far-ring distances, where retail would have blocked. This is a real, named, bounded residual — do not let the slice close claiming parity it does not have.


5. Blast radius, by consumer class, both hosts

C5b's enumeration leaked because it was performed over one host's call graph. This one is organised by host first.

Class A — graphical host, reveal-gating consumers. Verdict: INTENDED CHANGE.

  • WorldRevealReadinessBarrier.Evaluate / IsReady — the behaviour under change.
  • LocalPlayerTeleportController.Tick:503-539 (portal dataReady) and LivePlayerModeAutoEntryContext.IsWorldReady (PlayerModeAutoEntry.cs:106-111) (login). Both hold longer. Login already held behind the same barrier, so first-login also gets the wider gate — state this as intended, and gate it (§10), because the issue only reported recall.
  • RenderFrameResourceController.Prepare:256-265 — per-frame PrepareAndEvaluate. Frequency unchanged; per-call cost rises (D6).
  • WorldRevealCoordinator.Evaluate:183-202 — forwards the wider snapshot into the Runtime latch.

Class B — graphical host, streaming scheduling. Verdict: CHANGED BY DESIGN, measured.

  • StreamingController.BeginDestinationReservation:145-161, IsDestinationWork:1079-1082, EnqueueLoadsByRevealPriority:994-1012 — the destination set grows from 9 to up to 625 landblocks (D4).
  • StreamingWorkBudget destination/non-destination lanes (:385-438) — the 25% non-destination cap now applies to a much larger fraction of the frame's work during a hold. Measured, P4.

Class C — Runtime, canonical reveal state. Verdict: SHAPE LOOSENED, no behavioural change for existing producers.

  • RuntimeWorldTransitState.AcknowledgeDestinationReadiness:558-593 — invariant loosened (D7). Every value the two non-graphical producers emit today remains legal.
  • RuntimeDestinationReadiness (src/AcDream.Runtime/GameRuntimeViews.cs:114) — carries the radius as data; its IsReady join is unchanged.
  • RuntimeLiveEntitySessionController.TryAdvancePortalCompletion:757-799 — unchanged.

Class D — no-window host. Verdict: UNAFFECTED, and that is correct.

HeadlessSessionWorldProjection.PrepareDestination (src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs:832, readiness at :955-976) returns render-ready and composites-ready hardcoded true, with IsCollisionReady = "the placement committed". The headless host has no streaming window, no render publication, and no composites; there is nothing for a wider radius to mean. Its RequiredRenderRadius: indoor ? 0 : 1 at :973 is a token that satisfies the Runtime shape check, and D7 keeps it legal.

Explicitly: AcDream.App types must not appear in the headless path, and the headless radius must not be derived from anything. The only way #280 can break headless is by tightening the Runtime invariant instead of loosening it — hence D7, hence the headless assertion in §9.

Class E — presentation-only. Verdict: HARMLESS.

PortalTunnelPresentation (:290, :296-297, :381), PortalWaitNoticeController, LocalPlayerTeleportPresentation:353, RuntimeWorldFrameVisibilityPreparation.Begin (src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs:332-342). These observe a longer hold; none of them gate anything. The wait cue's frequency of appearance changes — §6.

Class F — tests. Verdict: MUST BE REWRITTEN, NOT RELAXED.

tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs (:63, :69, :76, :140) and tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs:65 assert the constant. §9 replaces them with derivation and tier assertions. Anything that "fixes" them by re-asserting a new literal is the trap in T3.


6. Interaction with the wait cue

acdream's cue: RuntimeWorldTransitState.ObserveWait:662-683, threshold RetailWaitCueDelay = TimeSpan.FromSeconds(5) (:66), driven only from LocalPlayerTeleportController.cs:536 via WorldRevealCoordinator.ObserveWait:245-250; rendered as a centered UiText "PortalSpaceWaitNotice" (src/AcDream.App/UI/PortalWaitNoticeController.cs:10-37) with the literal "In Portal Space - Please Wait..." (src/AcDream.App/Rendering/PortalTunnelPresentation.cs:296-297, :381). It is graphical-host-only; headless has no ObserveWait caller anywhere (its analogue is the parked-completion log at RuntimeLiveEntitySessionController.cs:792-799).

Pinned expectation: after the fix the user will see "In Portal Space - Please Wait..." on recalls more often, and the tunnel will run longer before the world appears.

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:

  1. acdream's 5-second threshold is not retail's trigger. Retail emits on each tunnel rotation-segment boundary (0x004D6FC70x004D70A1), not on a fixed elapsed threshold. The 5 s constant is an acdream approximation. Do not change it in this slice; if it is ever revisited, the anchor is gmSmartBoxUI::UseTime @ 0x004D6E30.
  2. acdream has no analogue of retail's DDD progress bar (gmPowerbarUI::RecvNotice_RuntimeDDDStatus @ 0x004DA5C0, string ID_Powerbar_DDDModeText, CURRENT/TOTAL). With longer holds this becomes more noticeable. File it; do not build it here.

7. Performance risk, stated honestly

The change does not add streaming work. The Far window is already 25×25 at the default preset; those landblocks are already queued and already built. #280 makes the reveal wait for a tail that is already being produced. What lengthens is the hold, not the workload — with three exceptions, each of which must be measured:

  1. Priority inversion at the reservation boundary (D4). Growing the destination set from 9 to 625 landblocks changes what the 75% reserved lane prioritises. Best case it strictly helps (the tail we now wait on is the tail we now prioritise). Worst case out-of-window retirement and unload starve inside the 25% remainder.
  2. IsNeighborhoodTerrainResident per-call cost (D6). Unfixed, this grows from ~9 to ~625 membership tests plus a full-map HashSet rebuild, every frame, during the hold. D6 removes the rebuild; the remaining 625 lookups are the honest cost.
  3. Near-ring composite warmup. D1 does not widen the composite radius (D3), but the reveal now waits on AreCompositeTexturesReady, which is already NearRadius-scoped — i.e. the composite domain is unchanged and is not a new risk. Confirm rather than assume (P3).

The budget this is measured against. The project already has an explicit acceptance edge, written into src/AcDream.App/Streaming/StreamingWorkBudgetOptions.cs:26-30: an earlier MaxEntityOperations: 256 was raised to 4096 precisely because it "stretched destination publication past retail's five-second wait-notice edge." So:

  • Primary budget: destination convergence. The hold must converge; the automated ceiling already exists in the harness as wait world-visible 30000 in tools/connected-world-lifecycle.route.txt. A hold that trips that timeout is a failure, not a slower pass.
  • Secondary budget: steady-state frame cost must not regress. The standing reference figures are the ordinary production profile at 519.7 FPS with CPU/GPU p50 1.869 / 1.096 ms and 652.1 / 928.3 MiB working/private (CLAUDE.md, Slice G5). The hold is a transient; the post-reveal steady state must land inside those figures.
  • Tertiary: allocation. Slice I1's 0 B/resolve standard is what D6 protects.

Measurement, not judgement. WorldLifecycleCheckpoint (src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs:101-110) already serialises RuntimePortalSnapshot Reveal, StreamingWorkDiagnostics StreamingWork, ResidencySnapshot Residency, RenderFrameOutcome Render, Fps, FrameMilliseconds. StreamingWorkDiagnostics (src/AcDream.App/Streaming/StreamingWorkBudget.cs:166-184) carries PendingPublications, PendingRetirements, DestinationBacklog, ControlBacklog, UnloadBacklog, NearBacklog, FarBacklog, WorkerCompletionBacklog, DeferredCompletions, LifetimeFrameOverrunCount, MaximumFrameMilliseconds. Everything §8 needs is already emitted; no new telemetry is required.

The A/B. Run the same route twice on the same binary — once with ACDREAM_PROBE_REVEAL_RADIUS=1 (pre-fix behaviour) and once without (D5). The delta in hold duration per stop, and the delta in NearBacklog/FarBacklog at the world-visible checkpoint, is the whole performance story. Report both; do not report only the post-fix number.


8. Proof obligations — must be proven, not assumed

  • P1 — A Far-tier landblock satisfies IsRenderReady. Traced statically (§2.3: PublicationKind.FarCommitLandblockSpatialActivateLandblockPresentationOnLandblockLoaded with an empty mesh set → IsLandblockRenderReady true). Prove it with a test, because the entire fix rests on it: if a Far landblock is not render-ready, D2's outer arm never satisfies and the reveal hangs.

  • P2 — The outer ring converges within the Far window's own lifetime.

    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 cannot deadlock against hysteresis, recenter (IsRecenterPending, already gated at LocalPlayerTeleportController.cs:505), or dungeon collapse (StreamingController.IsCollapsedToDungeon) — specifically: an outdoor destination while the window is collapsed to a dungeon.

  • P3 — Composite readiness is NearRadius-scoped and unchanged. Confirm IsCompositeWarmupCandidate sees no Far-ring entities (Far builds carry Array.Empty<WorldEntity>()), so D3 is a statement about the data and not a scope cut.

  • P4 — The 25% non-destination lane still drains during a hold. Measured from the checkpoint's PendingRetirements / UnloadBacklog / ControlBacklog across the hold. A monotonic rise is a finding.

  • P5 — Runtime and headless are untouched. Assert the loosened invariant accepts every radius both non-graphical producers emit, and that the headless dependency guard still passes with no App reference.

  • P6 — The radii are read live. Prove that changing the quality preset mid-hold changes the gate on the next evaluation, and that the reservation reopens at the new radius on the same generation (D4). Retail anchor: SmartBox::set_mid_radius @ 0x00453180.

  • P7 — Login uses the same widened gate. The barrier is shared; confirm first-login readiness moves with it and does not regress the capped_login checkpoint.

  • P8 — D6 restores 0 B/allocation on the terrain-neighborhood path at the new radius.


9. Test plan

Assert at layers that have broken historically. No source-text pins. No test that re-encodes the constant under test — a test asserting RequiredRenderRadius == 12 is the same defect in a different file.

  1. Derivation, not value (WorldRevealReadinessBarrierTests). Drive the barrier with a fake window reporting (near: 3, far: 8) and then (near: 5, far: 15); assert the outdoor required radius equals the fake's far radius in both cases and that indoor is 0 in both. The assertion references the fake's input, never a literal.
  2. Live re-read. Mutate the fake window between two Evaluate calls with no reconstruction; assert the second evaluation used the new radius (P6).
  3. Tier-aware ring (StreamingController / IsRenderNeighborhoodResident). Three cases: (a) inner-ring member at Far tier ⇒ not resident; (b) outer-ring member at Far tier and render-ready ⇒ resident; (c) outer-ring member absent ⇒ not resident. Case (a) is the discriminating one — it proves the near arm did not get loosened into the far arm.
  4. P1 as a test. A Far publication through the real pipeline, then GpuWorldState.IsRenderReady(farLandblock) == true. If this needs the real LandblockSpawnAdapter, use it; a fake here proves nothing.
  5. Off-map skip. A destination at a map corner still converges — the out-of-bounds members are skipped, not required (retail parity, §1.4).
  6. Runtime shape invariant (RuntimeWorldTransitStateTests). Assert AcknowledgeDestinationReadiness accepts (indoor: false, radius: 1), (false, 12), (false, 25); rejects (indoor: true, radius: 1) and (false, 0). This is the test that would have caught the four-site duplication (§2.2).
  7. Headless producer stays legal (HeadlessSessionWorldProjection / AcDream.Headless.Tests): its indoor ? 0 : 1 acknowledgement is accepted, and the existing dependency/loaded-assembly guards still pass.
  8. Login parity. LivePlayerModeAutoEntryContext.IsWorldReady returns false while an outer-ring member is missing and true once it lands.
  9. Allocation (D6/P8): a warmed loop over IsNeighborhoodTerrainResident at radius 12 measures 0 managed bytes, matching the Slice I1 pattern.
  10. Reservation radius follows the gate (D4): the tuple handed to BeginDestinationReservation carries the derived far radius, asserted against the fake window's input, not a literal.

Delete — do not adapt — the two existing assertions that pin 1 (WorldRevealReadinessBarrierTests.cs:63,69,76,140, LocalPlayerTeleportControllerTests.cs:65). Test 1 replaces them.


10. Gates

  • Focused tests above.
  • Release build, then the complete Release suite: $env:ACDREAM_PAK_PATH set, dotnet test AcDream.slnx -c Release -m:1. Re-measure the baseline at the implementation HEAD; do not inherit it. Known separately-filed flakes — #302 (PortalProjectionTests.ClipToRegion_FrameOwnedStore_…), #308 (NakEmissionTests.LossSoak_…), #321 (DatSoundCacheTests concurrent-decode-dedup). If one appears, re-run and name which; never fold, never mask, never retry-loop.
  • Automated route, twice, same binarytools/run-connected-world-lifecycle-gate.ps1 over tools/connected-world-lifecycle.route.txt, once with ACDREAM_PROBE_REVEAL_RADIUS=1 and once without (§7 A/B). The route already covers the cases that matter: capped_login, a dense outdoor island, a world-edge streaming transition, and an indoor Facility Hub cell. Its wait world-visible 30000 is the convergence ceiling.
  • Connected/visual gate: YES, and batched into C5c's visual matrix, which the campaign sequences after #280. Release, ACDREAM_RETAIL_UI=1, two-client.

The visual gate's positive evidence

The user-facing observable — "no visibly constructing far terrain after portal space exits" — is an absence, and C5b's rule (g) says an absence is not a pass criterion. So the gate produces three positive artifacts per stop, all from machinery that already exists:

  1. A checkpoint JSON captured at world-visible whose StreamingWork.NearBacklog, .FarBacklog, .DestinationBacklog and .PendingPublications are zero for the destination window at the moment the viewport opened. That is the positive form of "nothing was still building." A screenshot alone cannot say this; the backlog counts can.
  2. A hold-duration pair: _holdSeconds (equivalently Reveal.WaitCueShown plus the checkpoint timestamps) with the probe on and off. The expected, reportable result is that the post-fix hold is longer. A post-fix hold that is not longer means the gate did not actually widen and the run proves nothing.
  3. A paired screenshot at each stop from the two A/B runs. The pre-fix run is expected to show the defect; that is the run that makes the post-fix screenshot mean something.

Recall specifically (the reported repro) must be in the matrix, not only /teleloc: the user observed this "after some recalls". Add a lifestone / recall leg to the C5c session even though the automated route uses /teleloc.


11. Traps

  • T1 — the four-site radius. OutdoorNeighborhoodRadius is not the single source of truth (§2.2). Changing it alone trips FailInvariant("invalid-readiness-shape") at RuntimeWorldTransitState.cs:573 and the reveal never opens — which will look like "the fix hangs the client" and invite reverting the radius instead of fixing the invariant.
  • T2 — bumping the constant without D2. Any radius above NearRadius is unsatisfiable while IsRenderNeighborhoodResident demands IsNearTier. The symptom is an infinite hold with the wait cue up. The wrong conclusion available at that moment is "the world can't stream that far"; the right one is "the predicate refuses Far tier."
  • T3 — a test that re-encodes the new number. Replacing Assert(radius == 1) with Assert(radius == 12) reproduces the exact defect class this slice exists to remove. Assert against the window's reported value (§9-1).
  • T4 — plumbing App radii into Runtime to keep the strict equality at :573. That is the C5b mechanism-that-does-not-exist failure and it breaks the J-slice ownership boundary and the headless dependency guard. Loosen the invariant (D7).
  • T5 — widening the composite radius "for consistency." Far-tier landblocks have no entities; the composite domain is entity-scoped (D3/P3). Widening it walks 625 landblocks to warm nothing and pressures a 128 MiB physical budget for no benefit.
  • T6 — reading the radii once at construction. Radii are runtime mutable (ReconfigureRadii:377) and retail explicitly re-arms the blocking prefetch on a radius change (set_mid_radius @0x00453180). A captured radius is a latent bug that only appears when someone opens Settings mid-portal.
  • T7 — ACDREAM_STREAM_RADIUS during measurement. It forces NearRadius to its value and only raises FarRadius (SessionPlayerComposition.cs:249-257), and it is silently discarded by any later ApplyQuality (RuntimeSettingsTargets.cs:247-253). A gate run with it set is measuring a different window than production. Leave it unset.
  • T8 — treating the longer hold as a regression. It is the fix working, and it is retail (§1.5, §6). The failure condition is non-convergence, not duration.
  • T9 — "the far terrain is behind the fog, so don't gate it." It is not: fogEnd = FarRadius * 192 * 0.95 is inside the Far extent at every preset, so far-ring terrain is visible through fog, which is exactly what the user watched assemble. Do not introduce a fog-derived radius; keep the retail shape (one square).
  • T10 — declaring parity. The outer ring accepts terrain-only publication where retail requires LandBlockInfo and building EnvCells (§1.4). A distant building can still pop. That residual gets its own register row (D8) and must not be quietly dropped from the closeout.
  • T11 — scope creep into the Viewing Distance option. §3 files it as a separate feature. Adding a user-facing setting inside #280 makes the slice unreviewable and re-opens the decoupling the slice exists to close.

12. Size and split call

One slice. Do not split.

Estimated production change, by site:

Site Lines
WorldRevealReadinessBarrier.cs (instance radius, near/far split, snapshot shape) ~40
StreamingController.IsRenderNeighborhoodResident (tier-aware ring) ~20
WorldRevealCoordinator.cs (instance RequiredRenderRadius, reservation radius, radius-change reopen) ~15
SessionPlayerComposition.cs (wire the live window accessor) ~10
PhysicsEngine.IsNeighborhoodTerrainResident (D6, allocation) ~15
RuntimeWorldTransitState.cs:573 (D7, shape invariant) ~8
Diagnostic owner (D5) ~10
Total ~120

Plus ~10 focused tests and two register edits (D8).

Splitting is worse here, not better: D1 without D2 hangs the client (T2), and D2 without D1 is dead code. D6 and D7 are both forced by D1 — D7 because the acknowledgement fails otherwise, D6 because the per-frame cost otherwise regresses against a standing budget. There is no bisectable intermediate state, so a split produces commits that are individually broken, which is worse for the campaign's bisect discipline than one 120-line behaviour commit.

Sequence within the one commit: D7 (loosen) → D2 (predicate) → D1 (derive) → D4/D5/D6. Register edits in the same commit (D8).


13. Claims found false or stale at HEAD 9ee9c1a1

  1. The issue's "the normal configured view extends substantially farther" names a setting that does not exist. grep -rniE "viewdistance|view_distance|ViewDistance|LandscapeDrawDistance|DrawDistance" over src/ returns nothing. The conclusion is right — the visible extent is ~2189 m of fog inside a 2304 m Far window against a 192 m gate — but the premise should read "the configured streaming/fog window (QualitySettings.FarRadius)", not "the configured view distance."
  2. The issue's implied fix is incomplete. It frames #280 as "the gate is only radius 1." Raising that constant alone cannot work: any radius above NearRadius is unsatisfiable while IsRenderNeighborhoodResident requires IsNearTier (StreamingController.cs:250), and the change also fails the Runtime invariant at RuntimeWorldTransitState.cs:573. It is a radius change and a predicate change and an invariant loosening.
  3. OutdoorNeighborhoodRadius is not the single source of truth. Three uncited indoor ? 0 : 1 literals exist outside it, one of them validating (§2.2). Any doc or comment implying one owner is wrong.
  4. CLAUDE.md's ACDREAM_STREAM_RADIUS description is stale. It says "tune landblock visible-window radius (default 2 = 5×5)". At HEAD it is a legacy override (RuntimeOptions.cs:113-115) whose default is null/unset; when set it forces NearRadius and only raises FarRadius (SessionPlayerComposition.cs:249-257); and it is silently discarded by any runtime ApplyQuality (RuntimeSettingsTargets.cs:247-253). The shipped defaults are the QualityPreset.High row, 4/12.
  5. claude-memory/reference_two_tier_streaming.md is stale in four ways. (a) N₁=4 / N₂=12 are the High row of a four-row preset table (QualityPreset.cs:29-36), not standalone constants, and are runtime mutable. (b) The Far tier is not "terrain render only" — it also publishes terrain collision (LandblockPhysicsPublisher.cs:390-403, reached for PublicationKind.Far via LandblockPresentationPipeline.cs:619), which is precisely why D2's outer arm is viable. (c) MaxCompletionsPerFrame is no longer a per-frame completion drain; it is a whole-profile scalar over a seven-dimension typed budget (StreamingController.cs:117-134, StreamingWorkBudgetOptions.cs:79-96). (d) It documents none of IsRenderReady, WorldRevealReadinessBarrier, the destination reservation, or the 75% reserved lane — all of which are now the mechanism for "is this landblock done."
  6. The campaign plan's phrase "retail's configured destination-prefetch window" (2026-08-02-placement-cutover.md:65) is right in intent and slightly wrong in shape. Retail has no separate prefetch window; it has one landscape window (mid_radius) that is simultaneously loaded, drawn, and blocked on, and its configured value is Render.LandscapeDrawDistance.
  7. AD-2's outdoor-gate wording will be wrong after this slice. It describes the outdoor claim as requiring residency "for the required Near ring." Amend in the implementation commit (D8).
  8. The #280 issue text implies portal-only. The barrier is shared with login (PlayerModeAutoEntry.cs:106-111), so first-login reveal widens too. Intended, but it must be gated (P7) and stated at closeout.
  9. acdream's RetailWaitCueDelay = 5 s is not retail's trigger. The string is exact; the trigger is retail's tunnel rotation-segment boundary (gmSmartBoxUI::UseTime @ 0x004D6E30, 0x004D6FC70x004D70A1), not a fixed elapsed threshold. Out of #280's scope; recorded so it is not mistaken for a #280 regression.
  10. acdream's far plane (5000 m) differs from retail's Render::zfar (4000 m, byte-verified). Independent of #280 — in both clients the landscape horizon is the landblock window, not the frustum — but it is an uncited divergence sitting in four camera classes and should be filed.

14. What #280 does NOT do

  • It does not add a user-facing Viewing Distance option. That is a separate missing feature: a dedicated six-position enum matching retail's Render.LandscapeDrawDistance (labels VeryLow/Low/Medium/High/VeryHigh/ Extreme, values 3/5/8/11/15/25 @ 0x007CA988, default 8) feeding the streaming radii, replacing or reparameterising the quality preset's Near/Far pair. File it. #280's derivation keeps working when it lands.
  • It does not add a user-facing prefetch knob (§3).
  • It does not implement retail's DDD progress readout (§6).
  • It does not change the wait cue's threshold or trigger (§6).
  • It does not make the outer ring require LandBlockInfo or building EnvCells the way retail does; that residual gets a register row (D8, T10).
  • It does not touch the far plane (§13-10).
  • It does not change blocking_for_cells-equivalent semantics: acdream holds in the portal tunnel where retail freezes simulation behind a hidden world. That divergence is AD-2's and stays AD-2's.

15. Retail facts I could NOT establish

Flagged rather than guessed:

  • The index-1 choice label. Render_LandscapeDrawDistance_Choices[1] is initialised from &data_793f8c rather than an inline literal (:718533), so its text is not visible in the pseudo-C. By position between VeryLow and Medium it is almost certainly "Low", but I did not decode the string. The value (5) is byte-verified and is what matters here.
  • Whether retail draws anything of the destination during a blocked mid-session teleport. SmartBox::Hide is called when the portal space becomes visible (0x004D6FB6) and SmartBox::Draw @ 0x00455570 returns early on hidden, which strongly implies the world is not drawn behind the tunnel — but I did not trace every hidden transition (0x00451D30 / 0x00451D40 are its setters and I did not name their callers). Nothing in this contract depends on the answer; acdream's tunnel covers the viewport either way.
  • The exact retail tunnel rotation-segment duration. RandDouble at 0x004D701B / 0x004D7049 has its arguments mangled by BN's FPU handling; the nearby immediates (0x40768000, 0x3FFCCCCC, 0x3FE33333) look like the double halves but I did not decode them. Only relevant to §13-9, which is out of scope.
  • Whether Render::zfar is ever assigned outside GameSky::Draw. Grep found only the static initialiser (4000.0, byte-verified) and the two sky calls. I did not exhaustively search for indirect writes. Relevant only to §13-10.