diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index dfae63ef..2637e9e3 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -32,6 +32,14 @@ public sealed class StreamingController // are never visible, so we stop loading the 25×25 window entirely. private bool _collapsed; + // The dungeon landblock id we collapsed onto. Once collapsed we key the + // gate on this STABLE landblock, not the per-frame insideDungeon signal: + // CurrCell can momentarily resolve to null/outdoor mid-frame, and gating + // expand on that flicker thrashes collapse↔expand (reload storms + a light + // leak). We only expand when the observer actually moves to a different + // landblock (teleport/portal out). + private uint _collapsedCenter; + /// /// Near-tier radius (LBs from observer that load full detail: terrain + /// scenery + entities). Set at construction; readable thereafter. @@ -110,19 +118,23 @@ public sealed class StreamingController { uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy); - if (insideDungeon) + if (_collapsed) { - if (!_collapsed) - EnterDungeonCollapse(observerCx, observerCy, centerId); + // 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) + ExitDungeonExpand(observerCx, observerCy); else SweepCollapsed(centerId); } + else if (insideDungeon) + { + EnterDungeonCollapse(observerCx, observerCy, centerId); + } else { - if (_collapsed) - ExitDungeonExpand(observerCx, observerCy); - else - NormalTick(observerCx, observerCy); + NormalTick(observerCx, observerCy); } DrainAndApply(); @@ -164,6 +176,7 @@ public sealed class StreamingController private void EnterDungeonCollapse(int cx, int cy, uint centerId) { _collapsed = true; + _collapsedCenter = centerId; _clearPendingLoads?.Invoke(); foreach (var id in _state.LoadedLandblockIds) diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs index ab4a4d62..78dfb57e 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs @@ -109,6 +109,25 @@ public class StreamingControllerDungeonGateTests Assert.Empty(h.Loads); // no spurious center reloads } + [Fact] + public void Collapsed_CurrCellFlickersToNull_SameLandblock_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. + var h = Make(); + h.State.AddLandblock(MakeLb(0, 7)); + h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse + h.Loads.Clear(); + h.Unloads.Clear(); + + h.Ctrl.Tick(0, 7, insideDungeon: false); // CurrCell flicker, same landblock + + Assert.Empty(h.Loads); // NO full-window reload + Assert.Empty(h.Unloads); // only the center is resident → nothing to sweep + } + [Fact] public void ExitsDungeon_RebuildsFullWindow_UnloadsStaleDungeonLandblock() {