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; /// /// #138: fired after a landblock's entity layer (re)loads — both the full /// path (initial / dungeon-exit /// expand) and the path /// (Far→Near). GameWindow wires it to re-hydrate server objects whose /// render entities were dropped by the collapse/demote but whose parsed /// spawns it still retains. Receives the canonical landblock id. Null in /// tests that don't exercise re-hydration. /// private readonly Action? _onLandblockLoaded; 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; /// /// #138: the teleport destination landblock id. When non-zero, /// applies this landblock's /// or /// completion immediately /// — even if it sits past the /// position in the outbox — so the player can materialise at the /// destination without waiting for every earlier-queued landblock to /// drain first. Non-priority completions drained past it are buffered /// in and applied over subsequent frames, /// so no completions are lost and there is no GPU spike. /// /// Set by GameWindow when a teleport starts; reset to 0 /// once the destination landblock has been applied (or when the /// teleport destination changes). /// public uint PriorityLandblockId { get; set; } /// /// 2026-06-22: the radius (in landblocks, Chebyshev) around /// that eager-applies /// ahead of the per-frame budget. 0 (default) = only the single center landblock — the /// original priority behaviour. A teleport sets this to the near ring so the player's /// IMMEDIATE SURROUNDINGS (terrain + collision + scenery) are resident on arrival, not /// just the one landblock they stand on. Without it, only the destination landblock /// applies immediately and everything around it drains at , /// so the player arrives to a near-empty world (the "Fort Tethana only one landblock /// loaded" symptom) and their cell-walk can't root into neighbour cells yet (the /// transient walk-through-walls). The far ring still drains at the budget. The eager /// apply runs during the teleport hold (behind the fade), so the GPU spike is hidden. /// public int PriorityRadius { get; set; } // [FRAME-DIAG] (read by GameWindow's ACDREAM_WB_DIAG rollup): the standing // deferred-LOAD backlog. A non-zero value during/after a teleport is the // GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface. public int DeferredApplyBacklog => _deferredApply.Count; // [FRAME-DIAG]: how many times ForceReloadWindow has fired (each one drops + // re-uploads the WHOLE window) and the landblock count it dropped last time. public int ForceReloadCount { get; private set; } public int LastForceReloadDropCount { get; private set; } // Completions that were drained past a priority item get buffered here // so they still apply over subsequent frames without loss. private readonly List _deferredApply = new(); public StreamingController( Action enqueueLoad, Action enqueueUnload, Func> drainCompletions, Action applyTerrain, GpuWorldState state, int nearRadius, int farRadius, Action? removeTerrain = null, Action? clearPendingLoads = null, Action? onLandblockLoaded = null) { _enqueueLoad = enqueueLoad; _enqueueUnload = enqueueUnload; _drainCompletions = drainCompletions; _applyTerrain = applyTerrain; _removeTerrain = removeTerrain; _clearPendingLoads = clearPendingLoads; _onLandblockLoaded = onLandblockLoaded; _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) { if (!_collapsed || _collapsedCenter != centerId) Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}"); _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)); } /// /// True when is the priority center or within /// landblocks of it (Chebyshev) — i.e. inside the teleport /// near ring that eager-applies. With the default radius 0 /// this reduces to an exact match on (the original /// single-landblock priority behaviour). /// private bool IsWithinPriorityRing(uint id) => PriorityLandblockId != 0u && ChebyshevLandblocks(id, PriorityLandblockId) <= PriorityRadius; /// /// 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) { Console.WriteLine( $"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " + $"(was collapsed on 0x{_collapsedCenter:X8})"); _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; } /// /// 2026-06-22: an OUTDOOR teleport moved the render origin (_liveCenter). Every /// resident terrain block was baked relative to the OLD origin, so any block that survives /// an INCREMENTAL recenter — which happens on a NEARBY jump where the old and new windows /// overlap, e.g. (170,168)→(169,180) — renders shifted by the jump distance: the confirmed /// "terrain in the sky" arcs (the stale slots were all offset by exactly deltaLB×192). /// SYNCHRONOUSLY drop every resident landblock (render slot + physics + state) so none /// survives into the next frame stale, and no async unload can race a re-bake, then null /// the region so the next re-bootstraps the WHOLE window fresh at /// the new origin (the near ring is priority-applied behind the fade; the rest streams in). /// A sealed-dungeon destination uses instead. /// public void ForceReloadWindow() { _collapsed = false; // Snapshot — RemoveLandblock mutates the loaded set we're iterating. var ids = new List(_state.LoadedLandblockIds); ForceReloadCount++; // [FRAME-DIAG] churn counter LastForceReloadDropCount = ids.Count; // = upcoming re-upload volume foreach (var id in ids) { _state.RemoveLandblock(id); _removeTerrain?.Invoke(id); // frees the render slot + physics + cell registries } _region = null; // NormalTick re-creates + bootstraps the full window next frame } /// /// Apply streamed completions for this frame. LOADS (terrain mesh GPU uploads) are the /// expensive part, so they are metered at to avoid a /// GPU-upload spike; the overflow buffers in and drains over /// subsequent frames. UNLOADS are cheap (they free GPU buffers — no upload) and are applied /// IMMEDIATELY, never throttled: a teleport produces a whole window of unloads (~600), and /// metering them at the load rate left the previous location's terrain resident for seconds /// (rendering at its old world position as "floating terrain at the horizon"), and rapid /// hops accumulated them faster than they cleared — a runaway resident count (951 observed /// vs a 625 window) that also dragged FPS. Loads inside the teleport near ring /// (, applied behind the fade) likewise bypass the budget so the /// player materialises in a loaded world. /// private void DrainAndApply() { // --- Step 1: drain the outbox in bounded chunks. Apply unloads + priority near-ring // loads immediately; defer every other (budget-metered) load. Draining the whole // outbox each frame (bounded by MaxDrainIterations) is what lets unloads flush // promptly regardless of the load backlog — the throttle is on GPU UPLOADS, not on // freeing them. The drain cap must NOT be gated behind the per-frame load budget // (the prior version returned once the budget hit 0, stranding the outbox). const int MaxDrainIterations = 64; // cap at 64 * MaxCompletionsPerFrame drained/frame int iter = 0; while (iter++ < MaxDrainIterations) { var chunk = _drainCompletions(MaxCompletionsPerFrame); if (chunk.Count == 0) break; foreach (var result in chunk) { if (result is LandblockStreamResult.Unloaded || IsWithinPriorityRing(ResultLandblockId(result))) ApplyResult(result); // free (unload) or behind-the-fade near ring else _deferredApply.Add(result); // a GPU-upload load — meter it in step 2 } } // --- Step 2: apply the deferred LOAD backlog at the per-frame budget (FIFO, so // earlier-queued landblocks win). Caps GPU upload per frame so a big diff doesn't // spike. _deferredApply now only ever holds loads — unloads were applied in step 1. int budget = MaxCompletionsPerFrame; int i = 0; while (i < budget && i < _deferredApply.Count) { ApplyResult(_deferredApply[i]); i++; } if (i > 0) _deferredApply.RemoveRange(0, i); } /// /// Apply a single with the full side- /// effects: terrain upload, GPU state, and the re-hydration callback. /// Extracted from the inline switch in the original DrainAndApply /// so both the priority-hunt path and the normal drain path share it. /// private void ApplyResult(LandblockStreamResult result) { switch (result) { case LandblockStreamResult.Loaded loaded: _applyTerrain(loaded.Landblock, loaded.MeshData); _state.AddLandblock(loaded.Landblock); // #138: after the landblock is in _loaded (so AppendLiveEntity // hot-paths), restore any retained server objects ACE won't // re-send. Fired AFTER AddLandblock, never before. _onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId); break; case LandblockStreamResult.Promoted promoted: _applyTerrain(promoted.Landblock, promoted.MeshData); _state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities); _onLandblockLoaded?.Invoke(promoted.LandblockId); 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; } } /// /// Returns the landblock id associated with . /// For this is 0 by /// convention (not tied to a specific landblock). /// private static uint ResultLandblockId(LandblockStreamResult result) => result.LandblockId; }