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]