using System;
using System.Collections.Generic;
using AcDream.Core.Terrain;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
///
/// Called once per frame from GameWindow.OnUpdate. Owns the
/// and uses delegates into
/// so tests can inject fakes. All work
/// happens on the render thread; the streamer itself is background.
///
///
/// Threading: not thread-safe. All calls must happen on the render thread.
///
///
public sealed class StreamingController
{
private readonly Action _enqueueLoad;
private readonly Action _enqueueUnload;
private readonly Func> _drainCompletions;
private readonly Action _applyTerrain;
private readonly Action? _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;
///
/// Near-tier radius (LBs from observer that load full detail: terrain +
/// scenery + entities). Set at construction; readable thereafter.
///
///
/// Mutating after the first has no effect — the
/// internal snapshots both radii on its
/// constructor. Treat as init-only post-Tick.
///
public int NearRadius { get; }
///
/// Far-tier radius (LBs from observer that load terrain only). Set at
/// construction; readable thereafter.
///
///
/// Mutating after the first has no effect — see .
///
public int FarRadius { get; }
///
/// Cap on completions drained per 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).
///
///
/// 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
/// 's pending-spawn list, so spreading
/// completions doesn't lose any data.
///
///
public int MaxCompletionsPerFrame { get; set; } = 4;
public StreamingController(
Action enqueueLoad,
Action enqueueUnload,
Func> drainCompletions,
Action applyTerrain,
GpuWorldState state,
int nearRadius,
int farRadius,
Action? removeTerrain = null,
Action? clearPendingLoads = null)
{
_enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions;
_applyTerrain = applyTerrain;
_removeTerrain = removeTerrain;
_clearPendingLoads = clearPendingLoads;
_state = state;
NearRadius = nearRadius;
FarRadius = farRadius;
}
///
/// Advance one frame. /
/// are landblock coordinates (0..255) of the current viewer — the camera
/// in offline mode, the server-sent player position in live.
///
/// Two-tier model (Phase A.5 T13):
///
/// - → enqueue LoadFar (terrain only, no entities)
/// - → enqueue LoadNear (terrain + entities)
/// - → enqueue PromoteToNear (entity layer for already-loaded terrain)
/// - → drop entities on render thread immediately (terrain stays)
/// - → enqueue full unload
///
///
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();
}
///
/// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first
/// has a chance to bootstrap the full 25×25 window. Called
/// from the login / teleport spawn path the instant the streaming center is
/// recentered onto a SEALED dungeon landblock.
///
/// The per-frame insideDungeon gate keys on the physics
/// CurrCell, which is only set once the player is PLACED — and placement
/// waits for the dungeon landblock to hydrate. So for the whole hydration window
/// (tens of seconds for a ~200-cell dungeon) the gate reads false and
/// would enqueue the ~24 unrelated ocean-grid neighbor
/// dungeons (+ ~19k entities each); the collapse then only mops them up after
/// placement. That mop-up is the 10→high FPS ramp users see at a dungeon login.
///
/// Pre-collapsing means the EXPENSIVE dungeon-neighbour window is never
/// enqueued. On teleport nothing is enqueued at all (this fires before the next
/// Tick recenters). On login a brief Holtburg outdoor window may be enqueued by the
/// frame-1 NormalTick (before the player's spawn arrives) and is immediately
/// cancelled by _clearPendingLoads here — cheap outdoor terrain, not the
/// ocean-grid dungeons, and a handful of already-dequeued loads get swept next
/// frame. Idempotent: a no-op when already collapsed onto this same landblock, so a
/// re-sent spawn or a same-frame double call costs nothing. Render-thread only,
/// same as .
///
public void PreCollapseToDungeon(int cx, int cy)
{
uint centerId = StreamingRegion.EncodeLandblockId(cx, cy);
if (_collapsed && _collapsedCenter == centerId) return;
EnterDungeonCollapse(cx, cy, centerId);
}
///
/// Outdoor / building-interior streaming — the original two-tier model.
///
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);
}
}
///
/// 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 LandblockManager.GetAdjacentIDs 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.
///
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);
}
///
/// While collapsed, unload any landblock that finished loading after the
/// collapse edge — a Load the worker had already dequeued before the
/// control job took
/// effect. At steady state only the dungeon landblock is resident, so this
/// is a no-op.
///
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);
}
/// 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));
}
///
/// 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.
///
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;
}
///
/// Drain up to N completions per frame so a big diff doesn't spike GPU
/// upload time. Remaining completions wait for the next frame.
///
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;
}
}
}
}