using System; using System.Collections.Generic; namespace AcDream.App.Streaming; /// /// Pure data type describing the set of landblocks currently considered /// "visible" by the streaming system. Given a center landblock (x, y) and /// a radius, builds the set of landblock IDs in the (2r+1)×(2r+1) window. /// public sealed class StreamingRegion { public int CenterX { get; private set; } public int CenterY { get; private set; } public int Radius { get; } // Strictly the (2r+1)×(2r+1) window (clamped to world bounds). private readonly HashSet _visible = new(); // Everything currently loaded: window + hysteresis-retained landblocks. private readonly HashSet _resident = new(); /// /// Landblock IDs in the current visible window in the AC 8.8 coordinate /// form: (lbX << 24) | (lbY << 16) | 0xFFFF. The trailing /// 0xFFFF selects the LandBlock dat (terrain heightmap); the /// matching LandBlockInfo (static-object metadata) is at 0xFFFE /// on the same coordinates and is resolved internally by /// LandblockLoader. /// /// /// This set is strictly the (2r+1)×(2r+1) window; it does NOT include /// hysteresis-retained landblocks outside the window. Use /// to enumerate everything actually loaded. /// /// public IReadOnlyCollection Visible => _visible; /// /// Every landblock currently loaded: the current visible window plus any /// landblocks being held in memory by the hysteresis buffer (not yet past /// the Radius + 2 unload threshold). /// public IReadOnlyCollection Resident => _resident; public StreamingRegion(int cx, int cy, int radius) { Radius = radius; Recenter(cx, cy); } private void Recenter(int cx, int cy) { CenterX = cx; CenterY = cy; _visible.Clear(); for (int dx = -Radius; dx <= Radius; dx++) { for (int dy = -Radius; dy <= Radius; dy++) { int nx = cx + dx; int ny = cy + dy; if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue; _visible.Add(EncodeLandblockId(nx, ny)); } } // On first construction, resident == visible. _resident.UnionWith(_visible); } /// /// Encode a landblock at (lbX, lbY) into the AC dat id form. Always uses /// the 0xFFFF terminator (LandBlock = terrain). The earlier /// version of this method used 0xFFFE by mistake — that's the /// LandBlockInfo id, and asking LandblockLoader.Load to read a /// LandBlock at the LandBlockInfo coords corrupts the dat reader's /// buffer position, returning a half-populated LandBlock.Height[] /// array which renders as wildly distorted "ball with spikes" terrain. /// internal static uint EncodeLandblockId(int lbX, int lbY) => ((uint)lbX << 24) | ((uint)lbY << 16) | 0xFFFFu; /// /// Recompute the visible window around a new center and return the /// delta vs. the previous state. Hysteresis: landblocks aren't unloaded /// until they're further than Radius + 2 from the new center, /// so boundary crossings don't thrash. /// public RegionDiff RecenterTo(int newCx, int newCy) { // Snapshot the old resident set so we can diff against it. var oldResident = new HashSet(_resident); // Recompute _visible strictly as the new window. Recenter(newCx, newCy); // Loads = entries in the new window not yet in the resident set. var toLoad = new List(); foreach (var id in _visible) if (!oldResident.Contains(id)) toLoad.Add(id); // Unloads = resident entries outside the hysteresis threshold // (|dx| > Radius+2 OR |dy| > Radius+2). int unloadThreshold = Radius + 2; var toUnload = new List(); foreach (var id in oldResident) { if (_visible.Contains(id)) continue; // still in window, keep int lbX = (int)((id >> 24) & 0xFFu); int lbY = (int)((id >> 16) & 0xFFu); int dx = Math.Abs(lbX - newCx); int dy = Math.Abs(lbY - newCy); if (dx > unloadThreshold || dy > unloadThreshold) toUnload.Add(id); } // Update resident: (oldResident ∪ newVisible) ∖ toUnload. _resident.UnionWith(_visible); foreach (var id in toUnload) _resident.Remove(id); return new RegionDiff(toLoad, toUnload); } } /// /// Output of : the landblocks to /// start loading (newly entered the visible window) and the landblocks to /// unload (fell outside the unload threshold, which is Radius + 2). /// Both lists are disjoint from the current /// set; the caller hands them to LandblockStreamer as jobs. /// public readonly record struct RegionDiff( IReadOnlyList ToLoad, IReadOnlyList ToUnload);