acdream/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs
Erik 712f17f0f2 fix(G.3): pre-collapse dungeon streaming at login/teleport — kill the login FPS ramp (#135)
On login (or teleport) into a dungeon, FPS started ~10 and climbed over ~30 s.
Root cause: the dungeon "collapse" (which shrinks the 25x25 streaming window to
the player's single dungeon landblock — AC dungeons have no neighbours) only
fires once the per-frame `insideDungeon` gate reads true, and that gate keys on
the physics CurrCell, which isn't set until the player is PLACED, which waits for
the dungeon landblock to hydrate. So during the whole hydration window NormalTick
bootstraps the full window — ~24 unrelated ocean-grid neighbour dungeons + their
~19k entities each — and the collapse only mops them up afterward. That mop-up is
the ramp.

Fix: trigger the SAME collapse early, the instant we recenter the streaming center
onto a sealed dungeon cell, before the first NormalTick.

- StreamingController.PreCollapseToDungeon(cx,cy): fires EnterDungeonCollapse
  early (idempotent). The expensive neighbour window is never enqueued.
- GameWindow.IsSealedDungeonCell(cellId): reads the EnvCell dat SeenOutside flag
  (CurrCell is null pre-placement) — the same flag ObjCell.SeenOutside and the
  per-frame gate use, so the early decision matches the eventual one. Distinguishes
  a real dungeon from a cottage/inn interior (SeenOutside → keeps its outdoor
  surround). Excludes the 0xFFFE/0xFFFF structural shell ids so an outdoor spawn id
  can't type-confuse a LandBlock record as an EnvCell.
- Hooks: OnLiveEntitySpawnedLocked (login) + OnLivePositionUpdated (teleport).
- Observer robustness: during a teleport PortalSpace hold the streaming observer
  follows the recentered destination, not the frozen pre-teleport position (which
  could drift >=2 landblocks off and trip ExitDungeonExpand). And
  _lastLivePlayerLandblockId is now filtered to the player guid (resolves the
  Phase A.1 TODO) so a stray NPC UpdatePosition can't drift the login-hold observer
  off the dungeon.

Faithful EARLY trigger of the existing AP-36 collapse mechanism, not a new
workaround — AP-36 amended in the same commit. Adversarially reviewed across
timing / threading / faithfulness lenses; 5 new tests including the real runtime
ordering (Tick bootstraps, then PreCollapse cancels). Core suite green (1463).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:46:56 +02:00

257 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 Collapsed_CurrCellFlickersToAdjacentOffByOne_DoesNotExpand()
{
// 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 onto the dungeon (0,7)
h.Loads.Clear();
h.Unloads.Clear();
h.Ctrl.Tick(0, 6, insideDungeon: false); // flicker → adjacent off-by-one
Assert.Empty(h.Loads); // NO full-window reload
Assert.Empty(h.Unloads); // dungeon (0,7) preserved; nothing else resident
}
[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 PreCollapse_BeforeAnyTick_LoadsOnlyDungeon_NeverBootstrapsWindow()
{
// #135: at a dungeon login/teleport we pre-collapse the instant we recenter,
// BEFORE the first Tick. The full 25×25 neighbor window must NEVER be enqueued
// — only the single dungeon landblock loads.
var h = Make(); // empty state — nothing resident, _region is null
h.Ctrl.PreCollapseToDungeon(0, 7);
Assert.Single(h.Loads); // exactly one load
Assert.Equal(Encode(0, 7), h.Loads[0].Id); // the dungeon landblock
Assert.Equal(LandblockStreamJobKind.LoadNear, h.Loads[0].Kind);
Assert.DoesNotContain(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar);
}
[Fact]
public void PreCollapse_AfterBootstrapTick_CancelsWindow_UnloadsResidentNeighbors_KeepsDungeon()
{
// The REAL runtime ordering at a dungeon login: the per-frame streaming Tick
// runs FIRST and bootstraps the full 25×25 window, THEN the spawn handler fires
// PreCollapseToDungeon. The pre-collapse must cancel the queued window loads
// (_clearPendingLoads) and unload any neighbor that already finished streaming.
var h = Make();
h.Ctrl.Tick(0, 7, insideDungeon: false); // frame 1: NormalTick bootstraps the window
Assert.True(h.Loads.Count > 1); // the full window was enqueued
// Simulate neighbor landblocks that finished loading during the bootstrap,
// before the collapse edge.
h.State.AddLandblock(MakeLb(0, 7)); // the dungeon landblock itself
h.State.AddLandblock(MakeLb(0, 8)); // a neighbor ocean dungeon that loaded
h.State.AddLandblock(MakeLb(1, 7)); // another neighbor
h.Loads.Clear();
h.Unloads.Clear();
h.Ctrl.PreCollapseToDungeon(0, 7);
Assert.Equal(1, h.ClearCalls()); // queued window loads cancelled
Assert.Contains(Encode(0, 8), h.Unloads); // resident neighbor unloaded
Assert.Contains(Encode(1, 7), h.Unloads);
Assert.DoesNotContain(Encode(0, 7), h.Unloads); // dungeon landblock kept
}
[Fact]
public void PreCollapse_ThenHoldTicksWithStaleObserver_StaysCollapsed()
{
// After pre-collapse the player is held (CurrCell still null → insideDungeon
// false) while the dungeon hydrates. A stale observer that is the SAME dungeon
// landblock must keep streaming collapsed — no full-window reload.
var h = Make();
h.Ctrl.PreCollapseToDungeon(0, 7);
h.Loads.Clear();
h.Unloads.Clear();
h.Ctrl.Tick(0, 7, insideDungeon: false); // hold frame: not placed yet
Assert.Empty(h.Loads); // no neighbor window
Assert.Empty(h.Unloads);
}
[Fact]
public void PreCollapse_IsIdempotent_OnSameLandblock()
{
// A re-sent player spawn / a same-frame double call must not re-clear or
// re-enqueue.
var h = Make();
h.Ctrl.PreCollapseToDungeon(0, 7);
h.Loads.Clear();
h.Ctrl.PreCollapseToDungeon(0, 7);
Assert.Equal(1, h.ClearCalls()); // clear fired only on the first collapse
Assert.Empty(h.Loads); // no second dungeon load
}
[Fact]
public void PreCollapse_ThenPlaced_InsideDungeonTick_StaysCollapsed()
{
// When placement finally fires, the per-frame Tick(insideDungeon: true) sees
// the same collapsed landblock and holds — no re-collapse churn.
var h = Make();
h.State.AddLandblock(MakeLb(0, 7)); // dungeon landblock finished loading
h.Ctrl.PreCollapseToDungeon(0, 7);
h.Loads.Clear();
h.Unloads.Clear();
h.Ctrl.Tick(0, 7, insideDungeon: true); // placed: gate now fires
Assert.Equal(1, h.ClearCalls()); // no second clear
Assert.Empty(h.Loads);
Assert.DoesNotContain(Encode(0, 7), h.Unloads);
}
[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);
}
}