acdream/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs
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

242 lines
11 KiB
C#

namespace AcDream.App.Streaming;
/// <summary>
/// The live landblock window the reveal gate measures. Retail has exactly one
/// landscape square — <c>LScape::mid_radius</c>, assigned directly from the
/// user's <c>Render.LandscapeDrawDistance</c> preference
/// (<c>SmartBox::SetRegion</c> @0x004531F0;
/// <c>Render_LandscapeDrawDistance_Values</c> @0x007CA988 = {3,5,8,11,15,25},
/// default 8) — and that same square is simultaneously the loaded set, the
/// drawn set, and the set <c>LScape::PreFetchCells</c> @0x00505660 blocks on.
/// acdream splits the loaded landscape into a Near tier (full publication) and
/// a Far tier (terrain render + terrain collision), so the reveal window is a
/// pair rather than a single number; <see cref="FarRadius"/> is the structural
/// analogue of retail's <c>mid_radius</c> because it bounds everything the
/// user can see (fog end is <c>FarRadius * 192 m * 0.95</c>).
/// </summary>
internal readonly record struct StreamingRevealWindow(
int NearRadius,
int FarRadius);
/// <summary>
/// One evaluation of the destination domains guarded by
/// <see cref="WorldRevealReadinessBarrier"/>. Keeping the individual facts in
/// the canonical owner lets diagnostics and automation observe readiness
/// without duplicating (and eventually drifting from) the reveal predicate.
/// </summary>
internal readonly record struct WorldRevealReadinessSnapshot(
uint DestinationCell,
bool IsIndoor,
bool IsUnhydratable,
int RequiredRenderRadius,
int RequiredNearRadius,
bool IsRenderNeighborhoodReady,
bool AreCompositeTexturesReady,
bool IsCollisionReady)
{
public bool HasDestination => DestinationCell != 0u;
public bool IsReady => HasDestination
&& (IsUnhydratable
|| (IsRenderNeighborhoodReady
&& AreCompositeTexturesReady
&& IsCollisionReady));
}
/// <summary>
/// Joins the render-publication, texture, and collision domains that must be
/// complete before the normal world viewport is revealed. Retail keeps
/// <c>SmartBox::position_update_complete</c> false while
/// <c>CellManager::blocking_for_cells</c> is set
/// (<c>SmartBox::UseTime</c>, 0x00455410). acdream loads those domains
/// asynchronously, so login and portal arrival must share this explicit
/// equivalent of retail's single blocking-cell edge.
///
/// <para>
/// #280: the outdoor gate is DERIVED from the live streaming window, never
/// hardcoded. Retail cannot stream farther than it gates because it has only
/// one number (<c>LScape::SetMidRadius</c> @0x00504C00 sets
/// <c>mid_width = radius * 2 + 1</c> and <c>LScape::update_block</c>
/// @0x005063A0 allocates exactly that array). A fixed reveal radius under a
/// configurable streaming window reintroduces the decoupling the moment the
/// preset changes, which is the whole defect.
/// </para>
/// </summary>
internal sealed class WorldRevealReadinessBarrier
{
private readonly Func<StreamingRevealWindow> _revealWindow;
private readonly Func<uint, int, int, bool> _isRenderNeighborhoodReady;
private readonly Func<uint, bool> _isSpawnCellReady;
private readonly Func<uint, int, bool> _isTerrainNeighborhoodReady;
private readonly Func<bool> _areCompositeTexturesReady;
private readonly Action<uint, int> _prepareCompositeTextures;
private readonly Action _invalidateCompositeTextures;
private readonly Func<uint, bool> _isSpawnClaimUnhydratable;
public WorldRevealReadinessBarrier(
Func<StreamingRevealWindow> revealWindow,
Func<uint, int, int, bool> isRenderNeighborhoodReady,
Func<uint, bool> isSpawnCellReady,
Func<uint, int, bool> isTerrainNeighborhoodReady,
Func<bool> areCompositeTexturesReady,
Action<uint, int> prepareCompositeTextures,
Action invalidateCompositeTextures,
Func<uint, bool> isSpawnClaimUnhydratable)
{
_revealWindow = revealWindow
?? throw new ArgumentNullException(nameof(revealWindow));
_isRenderNeighborhoodReady = isRenderNeighborhoodReady
?? throw new ArgumentNullException(nameof(isRenderNeighborhoodReady));
_isSpawnCellReady = isSpawnCellReady
?? throw new ArgumentNullException(nameof(isSpawnCellReady));
_isTerrainNeighborhoodReady = isTerrainNeighborhoodReady
?? throw new ArgumentNullException(nameof(isTerrainNeighborhoodReady));
_areCompositeTexturesReady = areCompositeTexturesReady
?? throw new ArgumentNullException(nameof(areCompositeTexturesReady));
_prepareCompositeTextures = prepareCompositeTextures
?? throw new ArgumentNullException(nameof(prepareCompositeTextures));
_invalidateCompositeTextures = invalidateCompositeTextures
?? throw new ArgumentNullException(nameof(invalidateCompositeTextures));
_isSpawnClaimUnhydratable = isSpawnClaimUnhydratable
?? throw new ArgumentNullException(nameof(isSpawnClaimUnhydratable));
}
/// <summary>
/// Starts a new reveal lifetime. Texture readiness is destination-scoped;
/// carrying the prior login/portal result across a new destination would
/// open the barrier before that destination's composites were uploaded.
/// </summary>
public void Begin() => _invalidateCompositeTextures();
/// <summary>
/// Advances render-thread texture preparation once the static meshes the
/// composite domain covers have been published.
///
/// <para>
/// D3: the composite domain is entity-scoped
/// (<c>WbDrawDispatcher.IsCompositeWarmupCandidate</c> filters entities by
/// Chebyshev landblock radius) and Far-tier builds carry no entities at all
/// (<c>LandblockBuildFactory</c>), so the honest composite domain is the
/// NEAR radius. Widening it over Far rings would walk the whole outer
/// window to warm nothing.
/// </para>
///
/// <para>
/// The TRIGGER is therefore scoped the same way. Pre-#280 the gate and the
/// composite domain were the same radius-1 square, so gating warmup on the
/// whole gate cost nothing. #280 widened the gate to the entire Far window
/// without widening the domain; keeping the old trigger would have pushed
/// every composite upload behind the last outer-ring landblock and made
/// the hold longer than the streaming work alone requires. Warming as soon
/// as the Near sub-window is published restores the overlap. The reveal
/// gate itself is untouched: <see cref="Evaluate"/> still requires the
/// full window AND composite readiness.
/// </para>
/// </summary>
public void Prepare(uint destinationCell)
{
if (destinationCell == 0 || _isSpawnClaimUnhydratable(destinationCell))
return;
StreamingRevealWindow required = RequiredWindow(destinationCell);
if (_isRenderNeighborhoodReady(
destinationCell,
required.NearRadius,
required.NearRadius))
{
_prepareCompositeTextures(destinationCell, required.NearRadius);
}
}
/// <summary>
/// True only when the same destination is drawable and collidable. An
/// impossible indoor claim intentionally crosses the barrier so the
/// existing loud unhydratable-placement path can expose invalid server
/// data. A merely slow hydratable claim never crosses early.
/// </summary>
public bool IsReady(uint destinationCell)
=> Evaluate(destinationCell).IsReady;
/// <summary>
/// Evaluates each readiness domain once and returns the complete decision.
/// Consumers must use this snapshot rather than reimplementing the join.
/// </summary>
public WorldRevealReadinessSnapshot Evaluate(uint destinationCell)
{
if (destinationCell == 0)
return default;
bool isIndoor = IsIndoor(destinationCell);
StreamingRevealWindow required = RequiredWindow(destinationCell);
if (_isSpawnClaimUnhydratable(destinationCell))
{
return new WorldRevealReadinessSnapshot(
destinationCell,
isIndoor,
IsUnhydratable: true,
required.FarRadius,
required.NearRadius,
IsRenderNeighborhoodReady: false,
AreCompositeTexturesReady: false,
IsCollisionReady: false);
}
bool renderReady = _isRenderNeighborhoodReady(
destinationCell,
required.NearRadius,
required.FarRadius);
bool compositesReady = renderReady && _areCompositeTexturesReady();
bool collisionReady = renderReady && compositesReady
&& (isIndoor
? _isSpawnCellReady(destinationCell)
: _isTerrainNeighborhoodReady(
destinationCell,
required.FarRadius));
return new WorldRevealReadinessSnapshot(
destinationCell,
isIndoor,
IsUnhydratable: false,
required.FarRadius,
required.NearRadius,
renderReady,
compositesReady,
collisionReady);
}
/// <summary>
/// The window this destination must satisfy, read LIVE from the streaming
/// configuration on every call. Retail's answer to a mid-hold radius
/// change is <c>SmartBox::set_mid_radius</c> @0x00453180: reset the cell
/// manager, re-radius the landscape, and re-arm the blocking prefetch at
/// the NEW value — never finish the old hold at the old radius.
///
/// <para>
/// Indoor destinations take retail's EnvCell arm
/// (<c>CEnvCell::PreFetchCells</c> @0x0052D1E0), which walks the cell's own
/// visible-cell graph rather than the landscape square, so the outdoor
/// ring is not required and the radius is zero.
/// </para>
/// </summary>
internal StreamingRevealWindow RequiredWindow(uint destinationCell)
{
if (IsIndoor(destinationCell))
return new StreamingRevealWindow(0, 0);
StreamingRevealWindow window = _revealWindow();
int far = Math.Max(0, window.FarRadius);
int near = Math.Clamp(window.NearRadius, 0, far);
return new StreamingRevealWindow(near, far);
}
/// <summary>
/// The outdoor gate's outer radius — retail's <c>mid_radius</c>. Callers
/// that reserve destination streaming capacity must use exactly this value
/// so the prefetch square and the blocked square stay identical, as they
/// are in retail by construction.
/// </summary>
internal int RequiredRenderRadius(uint destinationCell) =>
RequiredWindow(destinationCell).FarRadius;
private static bool IsIndoor(uint cellId) => (cellId & 0xFFFFu) >= 0x0100u;
}