diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 90c82257..79d30650 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -92,7 +92,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 3. Documented approximation (AP) — 35 rows
+## 3. Documented approximation (AP) — 36 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@@ -130,6 +130,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) |
+| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock | `src/AcDream.App/Rendering/GameWindow.cs:6895` (predicate) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 |
| AP-35 | Point/spot lights use a single PER-PIXEL accumulation that ports `calc_point_light`'s `(1 − dist/falloff_eff)` LINEAR ramp (falloff_eff = Falloff × static_light_factor 1.3) + standard Lambert `N·L`; retail's path is PER-VERTEX Gouraud and additionally applies a half-Lambert wrap (`0.5·dist + N·L_vec`, lights surfaces down to `N·L ≥ −0.5`) and an x87-obscured normalization factor, neither ported | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag:52` (+ `mesh.frag`; `LightInfoLoader.cs:81` folds 1.3 into Range) | The linear ramp is the user-visible fix (kills the hard-disc "spotlight" edge, #133 A7); the dropped wrap/normalization only re-shade the gradient slightly, and per-pixel vs per-vertex Gouraud chiefly differs on coarse geometry. Half-Lambert wrap + factor are an x87-decompile refinement (same artifact class as GetPowerBarLevel AP-24) | Surfaces facing slightly away from a torch (`−0.5 ≤ N·L < 0`) stay dark where retail's wrap lights them faintly; near-light gradient shading differs subtly from retail's per-vertex bake | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); static_light_factor 0x00820e24 |
---
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index b3e5efa0..0e992fa2 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -1914,6 +1914,7 @@ public sealed class GameWindow : IDisposable
state: _worldState,
nearRadius: _nearRadius,
farRadius: _farRadius,
+ clearPendingLoads: _streamer.ClearPendingLoads,
removeTerrain: id =>
{
// Phase G.2: release any LightSources attached to entities
@@ -6882,7 +6883,20 @@ public sealed class GameWindow : IDisposable
observerCy = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f);
}
- _streamingController.Tick(observerCx, observerCy);
+ // Dungeon gate (#133 FPS): when the player stands in a SEALED EnvCell
+ // (indoor cell that doesn't see outside — the same predicate that kills
+ // the sun/sky, playerInsideCell below), collapse streaming to the single
+ // dungeon landblock. AC dungeons have no adjacent landblocks; the 25×25
+ // window otherwise pulls in ~129 unrelated ocean-grid dungeons. Building
+ // interiors (cottage/inn) have SeenOutside cells, so they are NOT gated
+ // and keep their surrounding terrain.
+ // Mirrors the playerInsideCell computation below (CurrCell → registry
+ // LoadedCell.SeenOutside): true only for a sealed indoor cell.
+ bool insideDungeon =
+ _physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell pcEnv
+ && _cellVisibility.TryGetCell(pcEnv.Id, out var pcReg)
+ && pcReg is { SeenOutside: false };
+ _streamingController.Tick(observerCx, observerCy, insideDungeon);
// Re-inject persistent entities rescued from unloaded landblocks
// into the current center landblock (the one the observer is in).
@@ -8418,6 +8432,11 @@ public sealed class GameWindow : IDisposable
}
_lastFps = fps;
_lastFrameMs = avgFrameTime;
+ // TEMP (A7 FPS measurement, strip after): headless FPS/frame-time so the
+ // launch log can be correlated against the [WB-DIAG] draw stats.
+ if (Environment.GetEnvironmentVariable("ACDREAM_LOG_FPS") == "1")
+ Console.WriteLine(
+ $"[FPS] {fps:F1} fps | {avgFrameTime:F2} ms | lb {visibleLandblocks}/{totalLandblocks} | ent {entityCount} anim {animatedCount}");
_perfAccum = 0;
_perfFrameCount = 0;
}
@@ -10589,6 +10608,7 @@ public sealed class GameWindow : IDisposable
state: _worldState,
nearRadius: _nearRadius,
farRadius: _farRadius,
+ clearPendingLoads: _streamer.ClearPendingLoads,
removeTerrain: id =>
{
if (_lightingSink is not null &&
diff --git a/src/AcDream.App/Streaming/LandblockStreamJob.cs b/src/AcDream.App/Streaming/LandblockStreamJob.cs
index c5e36815..050c1265 100644
--- a/src/AcDream.App/Streaming/LandblockStreamJob.cs
+++ b/src/AcDream.App/Streaming/LandblockStreamJob.cs
@@ -14,6 +14,16 @@ public abstract record LandblockStreamJob(uint LandblockId)
{
public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId);
public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId);
+
+ ///
+ /// Control job: drop every queued (not-yet-started) Load from the worker's
+ /// priority queues, keeping Unloads. Posted by
+ /// when the player enters a
+ /// dungeon and the in-flight outdoor/neighbor window load must be cancelled
+ /// (#133 FPS — dungeons have no adjacent landblocks). LandblockId is 0 by
+ /// convention; readers pattern-match on the type.
+ ///
+ public sealed record ClearLoads() : LandblockStreamJob(0);
}
///
diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs
index 19b2a94b..ffaa6de7 100644
--- a/src/AcDream.App/Streaming/LandblockStreamer.cs
+++ b/src/AcDream.App/Streaming/LandblockStreamer.cs
@@ -141,6 +141,22 @@ public sealed class LandblockStreamer : IDisposable
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId));
}
+ ///
+ /// Cancel every queued-but-not-started Load. Posts a
+ /// control job which the worker
+ /// honours at read time, dropping all pending Loads from both priority
+ /// queues (Unloads survive). Used on the dungeon-entry edge to abort the
+ /// in-flight 25×25 neighbor window so the ~129 ocean-grid dungeons never
+ /// finish loading (#133 FPS). Loads the worker has ALREADY dequeued still
+ /// complete; the StreamingController's collapsed-sweep unloads those few.
+ ///
+ public void ClearPendingLoads()
+ {
+ if (System.Threading.Volatile.Read(ref _disposed) != 0)
+ throw new ObjectDisposedException(nameof(LandblockStreamer));
+ _inbox.Writer.TryWrite(new LandblockStreamJob.ClearLoads());
+ }
+
///
/// Drain up to completed results.
/// Non-blocking. Call from the render thread once per OnUpdate.
@@ -180,7 +196,18 @@ public sealed class LandblockStreamer : IDisposable
}
while (_inbox.Reader.TryRead(out var job))
+ {
+ if (job is LandblockStreamJob.ClearLoads)
+ {
+ // Dungeon-entry cancellation: drop every queued Load,
+ // keep Unloads. Handled at read time so it supersedes
+ // Loads sitting in the priority queues ahead of it.
+ DropLoadJobs(highPriority);
+ DropLoadJobs(lowPriority);
+ continue;
+ }
EnqueuePrioritized(job, highPriority, lowPriority);
+ }
if (highPriority.Count == 0 && lowPriority.Count == 0)
continue;
@@ -233,6 +260,22 @@ public sealed class LandblockStreamer : IDisposable
lowPriority.Enqueue(job);
}
+ ///
+ /// Drop every from a priority queue,
+ /// preserving Unloads (and any other control jobs). Rotates the queue once
+ /// in place. Used by the path.
+ ///
+ private static void DropLoadJobs(Queue queue)
+ {
+ int count = queue.Count;
+ for (int i = 0; i < count; i++)
+ {
+ var job = queue.Dequeue();
+ if (job is not LandblockStreamJob.Load)
+ queue.Enqueue(job);
+ }
+ }
+
private static void RemoveLowPriorityJobsForLandblock(
Queue queue,
uint landblockId,
diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs
index f0bc0955..dfae63ef 100644
--- a/src/AcDream.App/Streaming/StreamingController.cs
+++ b/src/AcDream.App/Streaming/StreamingController.cs
@@ -22,9 +22,16 @@ public sealed class StreamingController
private readonly Func> _drainCompletions;
private readonly Action _applyTerrain;
private readonly Action? _removeTerrain;
+ private readonly Action? _clearPendingLoads;
private readonly GpuWorldState _state;
private StreamingRegion? _region;
+ // True while streaming is collapsed to the single dungeon landblock the
+ // player stands in (the dungeon gate, #133 FPS). AC dungeons have NO
+ // adjacent landblocks — neighbors are unrelated ocean-grid dungeons that
+ // are never visible, so we stop loading the 25×25 window entirely.
+ private bool _collapsed;
+
///
/// Near-tier radius (LBs from observer that load full detail: terrain +
/// scenery + entities). Set at construction; readable thereafter.
@@ -71,13 +78,15 @@ public sealed class StreamingController
GpuWorldState state,
int nearRadius,
int farRadius,
- Action? removeTerrain = null)
+ Action? removeTerrain = null,
+ Action? clearPendingLoads = null)
{
_enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions;
_applyTerrain = applyTerrain;
_removeTerrain = removeTerrain;
+ _clearPendingLoads = clearPendingLoads;
_state = state;
NearRadius = nearRadius;
FarRadius = farRadius;
@@ -97,7 +106,32 @@ public sealed class StreamingController
/// - → enqueue full unload
///
///
- public void Tick(int observerCx, int observerCy)
+ public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
+ {
+ uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
+
+ if (insideDungeon)
+ {
+ if (!_collapsed)
+ EnterDungeonCollapse(observerCx, observerCy, centerId);
+ else
+ SweepCollapsed(centerId);
+ }
+ else
+ {
+ if (_collapsed)
+ ExitDungeonExpand(observerCx, observerCy);
+ else
+ NormalTick(observerCx, observerCy);
+ }
+
+ DrainAndApply();
+ }
+
+ ///
+ /// Outdoor / building-interior streaming — the original two-tier model.
+ ///
+ private void NormalTick(int observerCx, int observerCy)
{
if (_region is null)
{
@@ -116,9 +150,77 @@ public sealed class StreamingController
foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
foreach (var id in diff.ToUnload) _enqueueUnload(id);
}
+ }
- // Drain up to N completions per frame so a big diff doesn't spike
- // GPU upload time. Remaining completions wait for the next frame.
+ ///
+ /// Dungeon-entry edge: cancel the in-flight window load, unload every
+ /// resident neighbor, and pin streaming to the player's single dungeon
+ /// landblock. Retail-faithful — AC dungeons have no adjacent landblocks
+ /// (ACE LandblockManager.GetAdjacentIDs returns empty for a dungeon);
+ /// the 25×25 window was pulling in ~129 unrelated ocean-grid dungeons and
+ /// their thousands of emitters (#133 FPS). Unloading them also tears down
+ /// their lights, shrinking the static-light set toward retail's ≤40.
+ ///
+ private void EnterDungeonCollapse(int cx, int cy, uint centerId)
+ {
+ _collapsed = true;
+ _clearPendingLoads?.Invoke();
+
+ foreach (var id in _state.LoadedLandblockIds)
+ if (id != centerId) _enqueueUnload(id);
+
+ // Pin a radius-0 region so RecenterTo never re-expands while inside,
+ // and so the post-exit rebuild starts from a clean, consistent state.
+ _region = new StreamingRegion(cx, cy, 0, 0);
+ _region.MarkResidentFromBootstrap();
+
+ // The dungeon landblock itself must be (or become) loaded. If a prior
+ // ClearPendingLoads cancelled its queued load, re-enqueue it.
+ if (!_state.IsLoaded(centerId))
+ _enqueueLoad(centerId, LandblockStreamJobKind.LoadNear);
+ }
+
+ ///
+ /// While collapsed, unload any landblock that finished loading after the
+ /// collapse edge — a Load the worker had already dequeued before the
+ /// control job took
+ /// effect. At steady state only the dungeon landblock is resident, so this
+ /// is a no-op.
+ ///
+ private void SweepCollapsed(uint centerId)
+ {
+ foreach (var id in _state.LoadedLandblockIds)
+ if (id != centerId) _enqueueUnload(id);
+ }
+
+ ///
+ /// Dungeon-exit edge (portal to outdoors / teleport): rebuild the full
+ /// two-tier window at the new center and unload anything resident from the
+ /// collapsed state that falls outside it.
+ ///
+ private void ExitDungeonExpand(int observerCx, int observerCy)
+ {
+ _collapsed = false;
+ var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
+
+ foreach (var id in _state.LoadedLandblockIds)
+ if (!rebuilt.Resident.Contains(id)) _enqueueUnload(id);
+
+ var boot = rebuilt.ComputeFirstTickDiff();
+ foreach (var id in boot.ToLoadNear)
+ if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
+ foreach (var id in boot.ToLoadFar)
+ if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
+ rebuilt.MarkResidentFromBootstrap();
+ _region = rebuilt;
+ }
+
+ ///
+ /// Drain up to N completions per frame so a big diff doesn't spike GPU
+ /// upload time. Remaining completions wait for the next frame.
+ ///
+ private void DrainAndApply()
+ {
var drained = _drainCompletions(MaxCompletionsPerFrame);
foreach (var result in drained)
{
diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs
new file mode 100644
index 00000000..ab4a4d62
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs
@@ -0,0 +1,142 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AcDream.App.Streaming;
+using AcDream.Core.World;
+using Xunit;
+
+namespace AcDream.Core.Tests.Streaming;
+
+///
+/// The dungeon streaming gate (#133 FPS). AC dungeons have no adjacent
+/// landblocks (ACE LandblockManager.GetAdjacentIDs returns empty for a
+/// dungeon); they sit packed in the ocean grid, so the normal 25×25 window
+/// pulls in ~129 unrelated neighbor dungeons + their emitters. When the player
+/// is inside a sealed dungeon cell, Tick(insideDungeon: true) collapses
+/// streaming to the single dungeon landblock and unloads the neighbors.
+///
+public class StreamingControllerDungeonGateTests
+{
+ private static uint Encode(int x, int y) => ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu;
+
+ private static LoadedLandblock MakeLb(int x, int y) => new LoadedLandblock(
+ Encode(x, y),
+ Heightmap: null!,
+ Entities: Array.Empty());
+
+ private sealed record Harness(
+ StreamingController Ctrl,
+ List<(uint Id, LandblockStreamJobKind Kind)> Loads,
+ List Unloads,
+ Func ClearCalls,
+ GpuWorldState State);
+
+ private static Harness Make()
+ {
+ var loads = new List<(uint, LandblockStreamJobKind)>();
+ var unloads = new List();
+ int clearCalls = 0;
+ var state = new GpuWorldState();
+ var ctrl = new StreamingController(
+ enqueueLoad: (id, kind) => loads.Add((id, kind)),
+ enqueueUnload: unloads.Add,
+ drainCompletions: _ => Array.Empty(),
+ applyTerrain: (_, _) => { },
+ state: state,
+ nearRadius: 4,
+ farRadius: 12,
+ clearPendingLoads: () => clearCalls++);
+ return new Harness(ctrl, loads, unloads, () => clearCalls, state);
+ }
+
+ [Fact]
+ public void EntersDungeon_CancelsPending_UnloadsNeighbors_KeepsCenter()
+ {
+ var h = Make();
+ uint center = Encode(0, 7);
+ h.State.AddLandblock(MakeLb(0, 7)); // the dungeon landblock
+ h.State.AddLandblock(MakeLb(0, 8)); // a neighbor ocean dungeon
+ h.State.AddLandblock(MakeLb(1, 7)); // another neighbor
+
+ h.Ctrl.Tick(observerCx: 0, observerCy: 7, insideDungeon: true);
+
+ Assert.Equal(1, h.ClearCalls()); // in-flight window load cancelled
+ Assert.Contains(Encode(0, 8), h.Unloads); // neighbor unloaded
+ Assert.Contains(Encode(1, 7), h.Unloads); // neighbor unloaded
+ Assert.DoesNotContain(center, h.Unloads); // dungeon landblock kept
+ Assert.DoesNotContain(h.Loads, l => l.Id == center); // already loaded → no reload
+ }
+
+ [Fact]
+ public void EntersDungeon_CenterNotLoaded_EnqueuesCenterLoad()
+ {
+ var h = Make(); // empty state — the dungeon landblock isn't resident yet
+
+ h.Ctrl.Tick(observerCx: 0, observerCy: 7, insideDungeon: true);
+
+ Assert.Equal(1, h.ClearCalls());
+ Assert.Contains(h.Loads, l => l.Id == Encode(0, 7)
+ && l.Kind == LandblockStreamJobKind.LoadNear);
+ }
+
+ [Fact]
+ public void StayingCollapsed_SweepsStragglerThatFinishedAfterTheEdge()
+ {
+ var h = Make();
+ h.State.AddLandblock(MakeLb(0, 7));
+ h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse edge
+ h.Unloads.Clear();
+
+ // A Load the worker had already dequeued before ClearLoads now completes.
+ h.State.AddLandblock(MakeLb(0, 8));
+ h.Ctrl.Tick(0, 7, insideDungeon: true); // sweep
+
+ Assert.Contains(Encode(0, 8), h.Unloads);
+ Assert.DoesNotContain(Encode(0, 7), h.Unloads);
+ }
+
+ [Fact]
+ public void StayingCollapsed_DoesNotReClearOrReloadCenter()
+ {
+ var h = Make();
+ h.State.AddLandblock(MakeLb(0, 7));
+ h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse (clear #1)
+ h.Loads.Clear();
+
+ h.Ctrl.Tick(0, 7, insideDungeon: true); // stay collapsed
+
+ Assert.Equal(1, h.ClearCalls()); // clear only fired on the edge
+ Assert.Empty(h.Loads); // no spurious center reloads
+ }
+
+ [Fact]
+ public void ExitsDungeon_RebuildsFullWindow_UnloadsStaleDungeonLandblock()
+ {
+ var h = Make();
+ h.State.AddLandblock(MakeLb(0, 7));
+ h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse
+ h.Loads.Clear();
+ h.Unloads.Clear();
+
+ // Exit through a portal to an outdoor location far from the dungeon block.
+ h.Ctrl.Tick(observerCx: 100, observerCy: 100, insideDungeon: false);
+
+ Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadNear);
+ Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar);
+ Assert.Contains(Encode(0, 7), h.Unloads); // stale dungeon block, outside new window
+ }
+
+ [Fact]
+ public void NormalOutdoorTick_Unchanged_NoCollapseNoClear()
+ {
+ var h = Make();
+
+ h.Ctrl.Tick(observerCx: 100, observerCy: 100); // default insideDungeon: false
+
+ Assert.Equal(0, h.ClearCalls());
+ Assert.Empty(h.Unloads);
+ // 9 near (9×9? no — nearRadius 4 → 9×9=81) + far ring loads enqueued.
+ Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadNear);
+ Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar);
+ }
+}