using System; using System.Collections.Generic; using System.Linq; 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; } public int NearRadius { get; } public int FarRadius { 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(); // Two-tier residence tracking: maps each resident LB to its current tier. private readonly Dictionary _tierResidence = new(); // Set true after MarkResidentFromBootstrap. The two-tier RecenterTo // requires this state to be seeded; calling RecenterTo before the // bootstrap silently emits the entire window as fresh loads (no demotes, // no unloads, since _tierResidence is empty), which is a correctness // hazard. The flag converts that into a loud InvalidOperationException. private bool _bootstrapped; /// /// 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 centerX, int centerY, int nearRadius, int farRadius) { NearRadius = nearRadius; FarRadius = farRadius; Radius = farRadius; // outer ring drives Resident bookkeeping Recenter(centerX, centerY); } public StreamingRegion(int cx, int cy, int radius) : this(cx, cy, radius, radius) { } 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; /// /// First-tick bootstrap: emit ToLoadNear for every LB in the inner ring, /// ToLoadFar for every LB in the outer ring (between near and far). Used /// by on the first call before any /// RecenterTo. /// public TwoTierDiff ComputeFirstTickDiff() { var near = new List(); var far = new List(); for (int dx = -FarRadius; dx <= FarRadius; dx++) { for (int dy = -FarRadius; dy <= FarRadius; dy++) { int nx = CenterX + dx; int ny = CenterY + dy; if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue; int absDx = System.Math.Abs(dx); int absDy = System.Math.Abs(dy); var id = EncodeLandblockId(nx, ny); if (absDx <= NearRadius && absDy <= NearRadius) near.Add(id); else far.Add(id); } } return new TwoTierDiff( ToLoadFar: far, ToLoadNear: near, ToPromote: System.Array.Empty(), ToDemote: System.Array.Empty(), ToUnload: System.Array.Empty()); } /// /// Call once after to seed /// _tierResidence with the initial window. Every LB in the inner /// ring (Chebyshev ≤ NearRadius) is marked Near; everything else Far. /// public void MarkResidentFromBootstrap() { if (_bootstrapped) throw new InvalidOperationException( "MarkResidentFromBootstrap was already called; calling it again would " + "reset accumulated tier-residence state and silently drop differential " + "data built up by interim RecenterTo calls."); _tierResidence.Clear(); for (int dx = -FarRadius; dx <= FarRadius; dx++) { for (int dy = -FarRadius; dy <= FarRadius; dy++) { int nx = CenterX + dx; int ny = CenterY + dy; if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue; int absDx = Math.Abs(dx); int absDy = Math.Abs(dy); var id = EncodeLandblockId(nx, ny); _tierResidence[id] = (absDx <= NearRadius && absDy <= NearRadius) ? TierResidence.Near : TierResidence.Far; } } _bootstrapped = true; } /// /// Test-visible wrapper around so test /// assemblies can build expected IDs without duplicating the encoding rule. /// internal static uint EncodeLandblockIdForTest(int lbX, int lbY) => EncodeLandblockId(lbX, lbY); /// /// Two-tier recenter: computes the 5-list diff per Phase A.5 spec §4.2. /// Hysteresis: NearRadius+2 for Near→Far demote; FarRadius+2 for Far→null /// unload. Requires (or a prior /// call to this method) to have seeded _tierResidence. /// public TwoTierDiff RecenterTo(int newCx, int newCy) { if (!_bootstrapped) throw new InvalidOperationException( "Two-tier RecenterTo called before MarkResidentFromBootstrap. " + "First call ComputeFirstTickDiff to enqueue the bootstrap loads, " + "then MarkResidentFromBootstrap to seed _tierResidence, then RecenterTo " + "for subsequent observer moves."); int nearUnloadThreshold = NearRadius + 2; int farUnloadThreshold = FarRadius + 2; var toLoadFar = new List(); var toLoadNear = new List(); var toPromote = new List(); var toDemote = new List(); var toUnload = new List(); // Pass 1: walk new far window — emit ToLoadFar / ToLoadNear / ToPromote. var newCenterIds = new HashSet(); for (int dx = -FarRadius; dx <= FarRadius; dx++) { for (int dy = -FarRadius; dy <= FarRadius; dy++) { int nx = newCx + dx; int ny = newCy + dy; if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue; int absDx = Math.Abs(dx); int absDy = Math.Abs(dy); bool inNear = absDx <= NearRadius && absDy <= NearRadius; var id = EncodeLandblockId(nx, ny); newCenterIds.Add(id); if (!_tierResidence.TryGetValue(id, out var current)) { // Not resident at all — fresh load. if (inNear) toLoadNear.Add(id); else toLoadFar.Add(id); _tierResidence[id] = inNear ? TierResidence.Near : TierResidence.Far; } else if (current == TierResidence.Far && inNear) { // Was Far, now inside Near ring — promote. toPromote.Add(id); _tierResidence[id] = TierResidence.Near; } // Near→Near and Far→Far are no-ops. } } // Pass 2: check previously-resident LBs for demote / unload. foreach (var kvp in _tierResidence.ToArray()) { var id = kvp.Key; var current = kvp.Value; int lbX = (int)((id >> 24) & 0xFFu); int lbY = (int)((id >> 16) & 0xFFu); int absDx = Math.Abs(lbX - newCx); int absDy = Math.Abs(lbY - newCy); int distance = Math.Max(absDx, absDy); // Chebyshev if (newCenterIds.Contains(id)) { // Still in the far window — only Near→Far demote possible here. if (current == TierResidence.Near && (absDx > NearRadius || absDy > NearRadius)) { if (distance > nearUnloadThreshold) { toDemote.Add(id); _tierResidence[id] = TierResidence.Far; } } continue; } // Outside the new window — demote / unload by threshold. if (current == TierResidence.Near) { if (distance > nearUnloadThreshold) { toDemote.Add(id); _tierResidence[id] = TierResidence.Far; if (distance > farUnloadThreshold) { toUnload.Add(id); _tierResidence.Remove(id); } } } else if (current == TierResidence.Far) { if (distance > farUnloadThreshold) { toUnload.Add(id); _tierResidence.Remove(id); } } } CenterX = newCx; CenterY = newCy; return new TwoTierDiff(toLoadFar, toLoadNear, toPromote, toDemote, toUnload); } /// /// 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 RecenterToSingleTier(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); /// /// Tracks which tier a landblock currently occupies in the two-tier streaming /// model. Absence from the dictionary encodes "not resident"; the enum has no /// None member to avoid suggesting a third runtime state. /// internal enum TierResidence { Far, Near }