fix(G.3): collapse streaming to the single dungeon landblock indoors (#133 FPS)

Dungeon FPS sat at ~30 (frame ~33ms) because the 25x25 streaming window around
the dungeon landblock pulled in ~129 NEIGHBORING landblocks + their thousands of
torch/particle emitters, all drawn though never visible. In AC all dungeons are
packed adjacent in the unused "ocean" map grid, so those neighbors are unrelated
dungeons. The FPS timeline proved it: 247 fps at login (lb 0/0, ~10K entities) →
17 → 30 as landblocks streamed in (lb 0→129) — the cost tracked LANDBLOCK count,
not entities.

Retail-faithful: ACE LandblockManager.GetAdjacentIDs returns ZERO adjacents for a
dungeon (`if (landblock.IsDungeon) return adjacents;`, Landblock.cs:577-582) —
every dungeon is a self-contained landblock you never see out of.

Fix: when the player stands in a sealed indoor cell (CurrCell.IsEnv &&
!SeenOutside — the same predicate that kills the sun/sky), collapse streaming to
just the player's dungeon landblock and unload the neighbors. Building interiors
(cottage/inn) have SeenOutside cells, so they are NOT gated and keep their
surrounding terrain (the frozen building/cellar demo is unaffected). Unloading the
neighbors also tears down their lights (removeTerrain → UnregisterOwner), shrinking
LightManager._all from ~2227 toward retail's ≤40 — which directly helps the A7
lighting bake landing next.

Mechanics (StreamingController):
- Edge IN: ClearPendingLoads() cancels the in-flight 25x25 window (new streamer
  ClearLoads control job — worker drops queued Loads, keeps Unloads), unload every
  resident neighbor, pin a radius-0 StreamingRegion, (re)load the dungeon block if
  needed.
- Stay collapsed: sweep any straggler that finished loading after the edge (a Load
  the worker had already dequeued before ClearLoads).
- Edge OUT (portal/teleport to outdoors): rebuild the full two-tier window at the
  new center, unload anything stale.

AP-36 added to the divergence register (the gate uses the cheap SeenOutside cell
predicate as an approximation of ACE's full landblock IsDungeon classification).
GameWindow also carries a TEMP ACDREAM_LOG_FPS=1 headless FPS line (strip after
the A7 FPS+lighting verification).

Build green; 58 streaming tests green (6 new dungeon-gate tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-13 22:32:56 +02:00
parent 007e287309
commit 56860501b6
6 changed files with 324 additions and 6 deletions

View file

@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.App.Streaming;
using AcDream.Core.World;
using Xunit;
namespace AcDream.Core.Tests.Streaming;
/// <summary>
/// The dungeon streaming gate (#133 FPS). AC dungeons have no adjacent
/// landblocks (ACE <c>LandblockManager.GetAdjacentIDs</c> returns empty for a
/// dungeon); they sit packed in the ocean grid, so the normal 25×25 window
/// pulls in ~129 unrelated neighbor dungeons + their emitters. When the player
/// is inside a sealed dungeon cell, <c>Tick(insideDungeon: true)</c> collapses
/// streaming to the single dungeon landblock and unloads the neighbors.
/// </summary>
public class StreamingControllerDungeonGateTests
{
private static uint Encode(int x, int y) => ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu;
private static LoadedLandblock MakeLb(int x, int y) => new LoadedLandblock(
Encode(x, y),
Heightmap: null!,
Entities: Array.Empty<WorldEntity>());
private sealed record Harness(
StreamingController Ctrl,
List<(uint Id, LandblockStreamJobKind Kind)> Loads,
List<uint> Unloads,
Func<int> ClearCalls,
GpuWorldState State);
private static Harness Make()
{
var loads = new List<(uint, LandblockStreamJobKind)>();
var unloads = new List<uint>();
int clearCalls = 0;
var state = new GpuWorldState();
var ctrl = new StreamingController(
enqueueLoad: (id, kind) => loads.Add((id, kind)),
enqueueUnload: unloads.Add,
drainCompletions: _ => Array.Empty<LandblockStreamResult>(),
applyTerrain: (_, _) => { },
state: state,
nearRadius: 4,
farRadius: 12,
clearPendingLoads: () => clearCalls++);
return new Harness(ctrl, loads, unloads, () => clearCalls, state);
}
[Fact]
public void EntersDungeon_CancelsPending_UnloadsNeighbors_KeepsCenter()
{
var h = Make();
uint center = Encode(0, 7);
h.State.AddLandblock(MakeLb(0, 7)); // the dungeon landblock
h.State.AddLandblock(MakeLb(0, 8)); // a neighbor ocean dungeon
h.State.AddLandblock(MakeLb(1, 7)); // another neighbor
h.Ctrl.Tick(observerCx: 0, observerCy: 7, insideDungeon: true);
Assert.Equal(1, h.ClearCalls()); // in-flight window load cancelled
Assert.Contains(Encode(0, 8), h.Unloads); // neighbor unloaded
Assert.Contains(Encode(1, 7), h.Unloads); // neighbor unloaded
Assert.DoesNotContain(center, h.Unloads); // dungeon landblock kept
Assert.DoesNotContain(h.Loads, l => l.Id == center); // already loaded → no reload
}
[Fact]
public void EntersDungeon_CenterNotLoaded_EnqueuesCenterLoad()
{
var h = Make(); // empty state — the dungeon landblock isn't resident yet
h.Ctrl.Tick(observerCx: 0, observerCy: 7, insideDungeon: true);
Assert.Equal(1, h.ClearCalls());
Assert.Contains(h.Loads, l => l.Id == Encode(0, 7)
&& l.Kind == LandblockStreamJobKind.LoadNear);
}
[Fact]
public void StayingCollapsed_SweepsStragglerThatFinishedAfterTheEdge()
{
var h = Make();
h.State.AddLandblock(MakeLb(0, 7));
h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse edge
h.Unloads.Clear();
// A Load the worker had already dequeued before ClearLoads now completes.
h.State.AddLandblock(MakeLb(0, 8));
h.Ctrl.Tick(0, 7, insideDungeon: true); // sweep
Assert.Contains(Encode(0, 8), h.Unloads);
Assert.DoesNotContain(Encode(0, 7), h.Unloads);
}
[Fact]
public void StayingCollapsed_DoesNotReClearOrReloadCenter()
{
var h = Make();
h.State.AddLandblock(MakeLb(0, 7));
h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse (clear #1)
h.Loads.Clear();
h.Ctrl.Tick(0, 7, insideDungeon: true); // stay collapsed
Assert.Equal(1, h.ClearCalls()); // clear only fired on the edge
Assert.Empty(h.Loads); // no spurious center reloads
}
[Fact]
public void ExitsDungeon_RebuildsFullWindow_UnloadsStaleDungeonLandblock()
{
var h = Make();
h.State.AddLandblock(MakeLb(0, 7));
h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse
h.Loads.Clear();
h.Unloads.Clear();
// Exit through a portal to an outdoor location far from the dungeon block.
h.Ctrl.Tick(observerCx: 100, observerCy: 100, insideDungeon: false);
Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadNear);
Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar);
Assert.Contains(Encode(0, 7), h.Unloads); // stale dungeon block, outside new window
}
[Fact]
public void NormalOutdoorTick_Unchanged_NoCollapseNoClear()
{
var h = Make();
h.Ctrl.Tick(observerCx: 100, observerCy: 100); // default insideDungeon: false
Assert.Equal(0, h.ClearCalls());
Assert.Empty(h.Unloads);
// 9 near (9×9? no — nearRadius 4 → 9×9=81) + far ring loads enqueued.
Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadNear);
Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar);
}
}