fix(G.3): hysteresis on the dungeon streaming gate — stop collapse↔expand thrash (#133)

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-13 22:43:18 +02:00
parent 56860501b6
commit d9e7dd65e9
2 changed files with 39 additions and 7 deletions

View file

@ -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;
/// <summary>
/// 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)

View file

@ -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()
{