"The dungeon is broken" — the collapse was unloading the REAL dungeon. A dungeon's EnvCells sit at arbitrary "ocean" world coords with negative cell-local Y (snap showed pos=(58.9,-69.6) in cell 0x00070133), so the observer landblock _liveCenterY + floor(pp.Y/192) = 7 + floor(-69.6/192) = 7 + (-1) = 6 lands one row off. The collapse pinned to 0x0006 and unloaded 0x0007 — the real dungeon — which nulled CurrCell (the cell no longer existed) and left the player floating in outdoor-lit empty space (lb 1/1 @ ~1585 fps, but the wrong landblock). This is the Bug-A negative-local-coordinate class. Fix: when inside a dungeon, pin the collapse to the cell's OWN landblock (CurrCell.Id >> 16), never the position-derived observer landblock — the cell id is the authoritative landblock for ocean-placed dungeon geometry. Also hardened the hysteresis so a transient CurrCell flicker can't thrash: - Re-collapse when insideDungeon at a DIFFERENT landblock (multi-landblock dungeon). - Expand only on a DISTANT move (Chebyshev > 1) — a real exit teleports far from the ocean-grid block; the off-by-one flicker is always an ADJACENT (±1) landblock, so it now HOLDS the collapse instead of expanding. - SweepCollapsed always preserves _collapsedCenter (the true dungeon landblock), never the per-frame observer landblock. Build green; 59 streaming tests green (flicker regression test updated to the realistic adjacent off-by-one). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
284 lines
13 KiB
C#
284 lines
13 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using AcDream.Core.Terrain;
|
||
using AcDream.Core.World;
|
||
|
||
namespace AcDream.App.Streaming;
|
||
|
||
/// <summary>
|
||
/// Called once per frame from <c>GameWindow.OnUpdate</c>. Owns the
|
||
/// <see cref="StreamingRegion"/> and uses delegates into
|
||
/// <see cref="LandblockStreamer"/> so tests can inject fakes. All work
|
||
/// happens on the render thread; the streamer itself is background.
|
||
///
|
||
/// <remarks>
|
||
/// Threading: not thread-safe. All calls must happen on the render thread.
|
||
/// </remarks>
|
||
/// </summary>
|
||
public sealed class StreamingController
|
||
{
|
||
private readonly Action<uint, LandblockStreamJobKind> _enqueueLoad;
|
||
private readonly Action<uint> _enqueueUnload;
|
||
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
||
private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain;
|
||
private readonly Action<uint>? _removeTerrain;
|
||
private readonly Action? _clearPendingLoads;
|
||
private readonly GpuWorldState _state;
|
||
private StreamingRegion? _region;
|
||
|
||
// True while streaming is collapsed to the single dungeon landblock the
|
||
// player stands in (the dungeon gate, #133 FPS). AC dungeons have NO
|
||
// adjacent landblocks — neighbors are unrelated ocean-grid dungeons that
|
||
// 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.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Mutating after the first <see cref="Tick"/> has no effect — the
|
||
/// internal <see cref="StreamingRegion"/> snapshots both radii on its
|
||
/// constructor. Treat as init-only post-Tick.
|
||
/// </remarks>
|
||
public int NearRadius { get; }
|
||
|
||
/// <summary>
|
||
/// Far-tier radius (LBs from observer that load terrain only). Set at
|
||
/// construction; readable thereafter.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Mutating after the first <see cref="Tick"/> has no effect — see <see cref="NearRadius"/>.
|
||
/// </remarks>
|
||
public int FarRadius { get; }
|
||
|
||
/// <summary>
|
||
/// Cap on completions drained per <see cref="Tick"/> call. The cap is
|
||
/// the GPU upload budget for one frame: terrain mesh + per-entity GfxObj
|
||
/// sub-mesh uploads + texture uploads for one landblock take a few ms;
|
||
/// applying 25 of them in a single frame produces a memory spike
|
||
/// (observed: out-of-memory crash on the 5×5 first-frame load).
|
||
///
|
||
/// <para>
|
||
/// 4 is the original async-streamer value; it spreads a 5×5 first-frame
|
||
/// load over ~7 frames (~116ms at 60fps), which is below the human
|
||
/// perception threshold. Spawn races that previously dropped entities
|
||
/// while landblocks were in flight are now handled by
|
||
/// <see cref="GpuWorldState"/>'s pending-spawn list, so spreading
|
||
/// completions doesn't lose any data.
|
||
/// </para>
|
||
/// </summary>
|
||
public int MaxCompletionsPerFrame { get; set; } = 4;
|
||
|
||
public StreamingController(
|
||
Action<uint, LandblockStreamJobKind> enqueueLoad,
|
||
Action<uint> enqueueUnload,
|
||
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
|
||
Action<LoadedLandblock, LandblockMeshData> applyTerrain,
|
||
GpuWorldState state,
|
||
int nearRadius,
|
||
int farRadius,
|
||
Action<uint>? removeTerrain = null,
|
||
Action? clearPendingLoads = null)
|
||
{
|
||
_enqueueLoad = enqueueLoad;
|
||
_enqueueUnload = enqueueUnload;
|
||
_drainCompletions = drainCompletions;
|
||
_applyTerrain = applyTerrain;
|
||
_removeTerrain = removeTerrain;
|
||
_clearPendingLoads = clearPendingLoads;
|
||
_state = state;
|
||
NearRadius = nearRadius;
|
||
FarRadius = farRadius;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Advance one frame. <paramref name="observerCx"/>/<paramref name="observerCy"/>
|
||
/// are landblock coordinates (0..255) of the current viewer — the camera
|
||
/// in offline mode, the server-sent player position in live.
|
||
///
|
||
/// <para>Two-tier model (Phase A.5 T13):</para>
|
||
/// <list type="bullet">
|
||
/// <item><see cref="TwoTierDiff.ToLoadFar"/> → enqueue LoadFar (terrain only, no entities)</item>
|
||
/// <item><see cref="TwoTierDiff.ToLoadNear"/> → enqueue LoadNear (terrain + entities)</item>
|
||
/// <item><see cref="TwoTierDiff.ToPromote"/> → enqueue PromoteToNear (entity layer for already-loaded terrain)</item>
|
||
/// <item><see cref="TwoTierDiff.ToDemote"/> → drop entities on render thread immediately (terrain stays)</item>
|
||
/// <item><see cref="TwoTierDiff.ToUnload"/> → enqueue full unload</item>
|
||
/// </list>
|
||
/// </summary>
|
||
public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
|
||
{
|
||
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
|
||
|
||
if (_collapsed)
|
||
{
|
||
// 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();
|
||
}
|
||
else if (insideDungeon)
|
||
{
|
||
EnterDungeonCollapse(observerCx, observerCy, centerId);
|
||
}
|
||
else
|
||
{
|
||
NormalTick(observerCx, observerCy);
|
||
}
|
||
|
||
DrainAndApply();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Outdoor / building-interior streaming — the original two-tier model.
|
||
/// </summary>
|
||
private void NormalTick(int observerCx, int observerCy)
|
||
{
|
||
if (_region is null)
|
||
{
|
||
_region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
||
var bootstrap = _region.ComputeFirstTickDiff();
|
||
foreach (var id in bootstrap.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||
foreach (var id in bootstrap.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||
_region.MarkResidentFromBootstrap();
|
||
}
|
||
else if (_region.CenterX != observerCx || _region.CenterY != observerCy)
|
||
{
|
||
var diff = _region.RecenterTo(observerCx, observerCy);
|
||
foreach (var id in diff.ToPromote) _enqueueLoad(id, LandblockStreamJobKind.PromoteToNear);
|
||
foreach (var id in diff.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||
foreach (var id in diff.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||
foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
|
||
foreach (var id in diff.ToUnload) _enqueueUnload(id);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Dungeon-entry edge: cancel the in-flight window load, unload every
|
||
/// resident neighbor, and pin streaming to the player's single dungeon
|
||
/// landblock. Retail-faithful — AC dungeons have no adjacent landblocks
|
||
/// (ACE <c>LandblockManager.GetAdjacentIDs</c> returns empty for a dungeon);
|
||
/// the 25×25 window was pulling in ~129 unrelated ocean-grid dungeons and
|
||
/// their thousands of emitters (#133 FPS). Unloading them also tears down
|
||
/// their lights, shrinking the static-light set toward retail's ≤40.
|
||
/// </summary>
|
||
private void EnterDungeonCollapse(int cx, int cy, uint centerId)
|
||
{
|
||
_collapsed = true;
|
||
_collapsedCenter = centerId;
|
||
_clearPendingLoads?.Invoke();
|
||
|
||
foreach (var id in _state.LoadedLandblockIds)
|
||
if (id != centerId) _enqueueUnload(id);
|
||
|
||
// Pin a radius-0 region so RecenterTo never re-expands while inside,
|
||
// and so the post-exit rebuild starts from a clean, consistent state.
|
||
_region = new StreamingRegion(cx, cy, 0, 0);
|
||
_region.MarkResidentFromBootstrap();
|
||
|
||
// The dungeon landblock itself must be (or become) loaded. If a prior
|
||
// ClearPendingLoads cancelled its queued load, re-enqueue it.
|
||
if (!_state.IsLoaded(centerId))
|
||
_enqueueLoad(centerId, LandblockStreamJobKind.LoadNear);
|
||
}
|
||
|
||
/// <summary>
|
||
/// While collapsed, unload any landblock that finished loading after the
|
||
/// collapse edge — a Load the worker had already dequeued before the
|
||
/// <see cref="LandblockStreamer.ClearPendingLoads"/> control job took
|
||
/// effect. At steady state only the dungeon landblock is resident, so this
|
||
/// is a no-op.
|
||
/// </summary>
|
||
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 != _collapsedCenter) _enqueueUnload(id);
|
||
}
|
||
|
||
/// <summary>Chebyshev distance in landblock cells between two landblock ids.</summary>
|
||
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));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Dungeon-exit edge (portal to outdoors / teleport): rebuild the full
|
||
/// two-tier window at the new center and unload anything resident from the
|
||
/// collapsed state that falls outside it.
|
||
/// </summary>
|
||
private void ExitDungeonExpand(int observerCx, int observerCy)
|
||
{
|
||
_collapsed = false;
|
||
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
||
|
||
foreach (var id in _state.LoadedLandblockIds)
|
||
if (!rebuilt.Resident.Contains(id)) _enqueueUnload(id);
|
||
|
||
var boot = rebuilt.ComputeFirstTickDiff();
|
||
foreach (var id in boot.ToLoadNear)
|
||
if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||
foreach (var id in boot.ToLoadFar)
|
||
if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||
rebuilt.MarkResidentFromBootstrap();
|
||
_region = rebuilt;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Drain up to N completions per frame so a big diff doesn't spike GPU
|
||
/// upload time. Remaining completions wait for the next frame.
|
||
/// </summary>
|
||
private void DrainAndApply()
|
||
{
|
||
var drained = _drainCompletions(MaxCompletionsPerFrame);
|
||
foreach (var result in drained)
|
||
{
|
||
switch (result)
|
||
{
|
||
case LandblockStreamResult.Loaded loaded:
|
||
_applyTerrain(loaded.Landblock, loaded.MeshData);
|
||
_state.AddLandblock(loaded.Landblock);
|
||
break;
|
||
case LandblockStreamResult.Promoted promoted:
|
||
_applyTerrain(promoted.Landblock, promoted.MeshData);
|
||
_state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities);
|
||
break;
|
||
case LandblockStreamResult.Unloaded unloaded:
|
||
_state.RemoveLandblock(unloaded.LandblockId);
|
||
_removeTerrain?.Invoke(unloaded.LandblockId);
|
||
break;
|
||
case LandblockStreamResult.Failed failed:
|
||
Console.WriteLine(
|
||
$"streaming: load failed for 0x{failed.LandblockId:X8}: {failed.Error}");
|
||
break;
|
||
case LandblockStreamResult.WorkerCrashed crashed:
|
||
Console.WriteLine(
|
||
$"streaming: worker CRASHED: {crashed.Error}");
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|