From d9e7dd65e9844c00964eda522a8caa0aa794ff82 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 22:43:18 +0200 Subject: [PATCH] =?UTF-8?q?fix(G.3):=20hysteresis=20on=20the=20dungeon=20s?= =?UTF-8?q?treaming=20gate=20=E2=80=94=20stop=20collapse=E2=86=94expand=20?= =?UTF-8?q?thrash=20(#133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut of the dungeon gate keyed expand on the per-frame insideDungeon signal (CurrCell is a sealed EnvCell). Live, CurrCell momentarily resolves to null mid-frame while the player stays put in the dungeon landblock, so the gate flipped collapse→expand→collapse every few frames. Each expand re-streamed the full 25×25 window; the unloads couldn't keep up (MaxCompletionsPerFrame=4), so registered lights leaked to 212k and FPS spiked to single digits between the ~199 fps collapsed frames. Fix: once collapsed, key the gate on the STABLE observer landblock, not CurrCell. Stay collapsed while the player remains in the dungeon landblock (_collapsedCenter); expand only when the observer actually moves to a different landblock (portal/ teleport out). CurrCell flicker no longer thrashes. Regression test added (Collapsed_CurrCellFlickersToNull_SameLandblock_DoesNotExpand). Build green; 60 streaming tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Streaming/StreamingController.cs | 27 ++++++++++++++----- .../StreamingControllerDungeonGateTests.cs | 19 +++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) 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() {