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; } } } }