diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 0e992fa2..71a6f558 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -6892,10 +6892,23 @@ public sealed class GameWindow : IDisposable
// 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
+ bool insideDungeon = false;
+ if (_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell pcEnv
&& _cellVisibility.TryGetCell(pcEnv.Id, out var pcReg)
- && pcReg is { SeenOutside: false };
+ && pcReg is { SeenOutside: false })
+ {
+ insideDungeon = true;
+ // Pin the collapse to the cell's OWN landblock (cell id high 16 bits),
+ // NOT the position-derived observer landblock. A dungeon's EnvCells sit
+ // at arbitrary world coords (the "ocean" placement) with negative local
+ // offsets, so floor(pp.Y/192) lands one landblock off — which collapses
+ // onto the WRONG landblock and unloads the real dungeon, nulling CurrCell
+ // and breaking the render (the Bug-A coordinate class). The cell id is the
+ // authoritative landblock.
+ uint cellLb = pcEnv.Id >> 16;
+ observerCx = (int)((cellLb >> 8) & 0xFFu);
+ observerCy = (int)(cellLb & 0xFFu);
+ }
_streamingController.Tick(observerCx, observerCy, insideDungeon);
// Re-inject persistent entities rescued from unloaded landblocks
diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs
index 2637e9e3..9a357cbb 100644
--- a/src/AcDream.App/Streaming/StreamingController.cs
+++ b/src/AcDream.App/Streaming/StreamingController.cs
@@ -120,13 +120,22 @@ public sealed class StreamingController
if (_collapsed)
{
- // Hysteresis: stay collapsed while the player remains in the dungeon
- // landblock, regardless of CurrCell flicker. Expand only on an actual
- // landblock change (the player left through a portal / was teleported).
- if (centerId != _collapsedCenter)
+ // Hysteresis. Cases:
+ // - Still in the SAME dungeon landblock → hold (sweep stragglers).
+ // - In a DIFFERENT dungeon cell (multi-landblock dungeon / new dungeon)
+ // → re-collapse onto it.
+ // - CurrCell flickered null but the player hasn't gone anywhere: the
+ // observer landblock reverts to the position-derived value, which for a
+ // dungeon is only ever the ADJACENT off-by-one landblock (negative cell-
+ // local Y). Hold — never expand on an adjacent flicker.
+ // - Genuinely left to a DISTANT landblock (portal/teleport out, always far
+ // from the ocean-grid dungeon block) → expand.
+ if (insideDungeon && centerId != _collapsedCenter)
+ EnterDungeonCollapse(observerCx, observerCy, centerId);
+ else if (!insideDungeon && ChebyshevLandblocks(centerId, _collapsedCenter) > 1)
ExitDungeonExpand(observerCx, observerCy);
else
- SweepCollapsed(centerId);
+ SweepCollapsed();
}
else if (insideDungeon)
{
@@ -200,10 +209,20 @@ public sealed class StreamingController
/// effect. At steady state only the dungeon landblock is resident, so this
/// is a no-op.
///
- private void SweepCollapsed(uint centerId)
+ private void SweepCollapsed()
{
+ // Always preserve the true dungeon landblock (_collapsedCenter), never the
+ // per-frame observer landblock — a CurrCell flicker must not unload the dungeon.
foreach (var id in _state.LoadedLandblockIds)
- if (id != centerId) _enqueueUnload(id);
+ if (id != _collapsedCenter) _enqueueUnload(id);
+ }
+
+ /// Chebyshev distance in landblock cells between two landblock ids.
+ private static int ChebyshevLandblocks(uint a, uint b)
+ {
+ int ax = (int)((a >> 24) & 0xFFu), ay = (int)((a >> 16) & 0xFFu);
+ int bx = (int)((b >> 24) & 0xFFu), by = (int)((b >> 16) & 0xFFu);
+ return Math.Max(Math.Abs(ax - bx), Math.Abs(ay - by));
}
///
diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs
index 78dfb57e..fd99fe30 100644
--- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs
+++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs
@@ -110,22 +110,24 @@ public class StreamingControllerDungeonGateTests
}
[Fact]
- public void Collapsed_CurrCellFlickersToNull_SameLandblock_DoesNotExpand()
+ public void Collapsed_CurrCellFlickersToAdjacentOffByOne_DoesNotExpand()
{
- // Regression: the live run thrashed collapse↔expand because CurrCell
- // momentarily resolved to null (insideDungeon=false) while the player
- // stayed in the dungeon landblock — leaking lights via reload storms.
- // The landblock-hysteresis must hold the collapse.
+ // Regression: the live run broke because a dungeon cell's negative local-Y
+ // makes the position-derived observer landblock land one row off (0,7→0,6).
+ // When CurrCell flickers null mid-frame, GameWindow stops overriding to the
+ // cell landblock and passes that adjacent (0,6). The Chebyshev>1 guard must
+ // treat that as a flicker and HOLD — never expand (which would unload the
+ // real dungeon and re-stream the 25×25 neighbor window).
var h = Make();
h.State.AddLandblock(MakeLb(0, 7));
- h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse
+ h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse onto the dungeon (0,7)
h.Loads.Clear();
h.Unloads.Clear();
- h.Ctrl.Tick(0, 7, insideDungeon: false); // CurrCell flicker, same landblock
+ h.Ctrl.Tick(0, 6, insideDungeon: false); // flicker → adjacent off-by-one
Assert.Empty(h.Loads); // NO full-window reload
- Assert.Empty(h.Unloads); // only the center is resident → nothing to sweep
+ Assert.Empty(h.Unloads); // dungeon (0,7) preserved; nothing else resident
}
[Fact]