acdream/docs/research/2026-08-06-280-review-retail.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

31 KiB
Raw Blame History

#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.pyMATCH, 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; F2F3 are bookkeeping/argument defects; F4F7 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):

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):

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 (AddEntitiesToExistingLandblockActivateLandblockPresentationOnLandblockLoaded, 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: BeginRecenterDetachAllForOriginRecenter_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 demotesWantsLoaded = 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<unsigned short> 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.61.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.24.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:

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:

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.61.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<WorldEntity>() 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.ApplyQualityReconfigureStreamingRadii (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.slnx0 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 CLandBlockCLandBlockInfoCBldPortal 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.