diff --git a/src/AcDream.App/Streaming/StreamingRegion.cs b/src/AcDream.App/Streaming/StreamingRegion.cs
new file mode 100644
index 0000000..025d3af
--- /dev/null
+++ b/src/AcDream.App/Streaming/StreamingRegion.cs
@@ -0,0 +1,105 @@
+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; }
+
+ private readonly HashSet _visible = new();
+
+ ///
+ /// Landblock IDs (8.8 coordinate form: (lbX << 24) | (lbY << 16) | 0xFFFE)
+ /// in the current visible window.
+ ///
+ public IReadOnlyCollection Visible => _visible;
+
+ 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));
+ }
+ }
+ }
+
+ internal static uint EncodeLandblockId(int lbX, int lbY)
+ => ((uint)lbX << 24) | ((uint)lbY << 16) | 0xFFFEu;
+
+ ///
+ /// 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 + 1 from the new center,
+ /// so boundary crossings don't thrash.
+ ///
+ public RegionDiff RecenterTo(int newCx, int newCy)
+ {
+ // Snapshot the current visible set so we can diff against it.
+ var oldVisible = new HashSet(_visible);
+ Recenter(newCx, newCy);
+
+ // Loads = everything newly in visible but not previously.
+ var toLoad = new List();
+ foreach (var id in _visible)
+ if (!oldVisible.Contains(id))
+ toLoad.Add(id);
+
+ // Unloads = everything previously visible AND now outside the
+ // hysteresis threshold (|dx| > r+2 OR |dy| > r+2).
+ // The extra +1 beyond the visible radius (+1) gives a full buffer
+ // cell of hysteresis before eviction, matching the test expectations.
+ int unloadThreshold = Radius + 2;
+ var toUnload = new List();
+ foreach (var id in oldVisible)
+ {
+ if (_visible.Contains(id)) continue; // still visible, not unloading
+ int lbX = (int)((id >> 24) & 0xFFu);
+ int lbY = (int)((id >> 16) & 0xFFu);
+ int dx = System.Math.Abs(lbX - newCx);
+ int dy = System.Math.Abs(lbY - newCy);
+ if (dx > unloadThreshold || dy > unloadThreshold)
+ toUnload.Add(id);
+ }
+
+ // Any "still loaded but outside visible" landblocks not in toUnload
+ // need to rejoin the visible set so we don't lose track of them.
+ foreach (var id in oldVisible)
+ if (!_visible.Contains(id) && !toUnload.Contains(id))
+ _visible.Add(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 + 1).
+/// 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);
diff --git a/tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj b/tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj
index 2104378..1c83fc3 100644
--- a/tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj
+++ b/tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj
@@ -20,6 +20,7 @@
+
diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingRegionTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTests.cs
new file mode 100644
index 0000000..9e5aeb1
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTests.cs
@@ -0,0 +1,85 @@
+using AcDream.App.Streaming;
+using Xunit;
+
+namespace AcDream.Core.Tests.Streaming;
+
+public class StreamingRegionTests
+{
+ [Fact]
+ public void Constructor_Radius2_Produces25Landblocks()
+ {
+ var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
+
+ Assert.Equal(25, region.Visible.Count);
+ }
+
+ [Fact]
+ public void Constructor_NearOrigin_ClampsToWorldEdge()
+ {
+ // Center at (0, 0) with radius 2: only the +X / +Y quadrant is
+ // in-bounds. That's a 3×3 subset of the 5×5 window = 9 landblocks.
+ var region = new StreamingRegion(cx: 0, cy: 0, radius: 2);
+
+ Assert.Equal(9, region.Visible.Count);
+ }
+
+ [Fact]
+ public void Constructor_NearFarEdge_ClampsToWorldEdge()
+ {
+ var region = new StreamingRegion(cx: 0xFF, cy: 0xFF, radius: 2);
+
+ Assert.Equal(9, region.Visible.Count);
+ }
+
+ [Fact]
+ public void RecenterTo_SamePosition_EmptyDiff()
+ {
+ var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
+
+ var diff = region.RecenterTo(50, 50);
+
+ Assert.Empty(diff.ToLoad);
+ Assert.Empty(diff.ToUnload);
+ }
+
+ [Fact]
+ public void RecenterTo_SingleStepEast_LoadsColumn_NoUnloadsDueToHysteresis()
+ {
+ // Radius 2 → unload threshold is radius+1 = 3.
+ // Starting center (50,50) covers X in [48..52]. Step to (51,50):
+ // new coverage X in [49..53]. New column is x=53 (5 entries).
+ // Departing column would be x=48, but |48-51| = 3 which equals the
+ // threshold, so it stays loaded (hysteresis keeps radius+1).
+ var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
+
+ var diff = region.RecenterTo(51, 50);
+
+ Assert.Equal(5, diff.ToLoad.Count);
+ Assert.Empty(diff.ToUnload);
+ }
+
+ [Fact]
+ public void RecenterTo_ThreeStepEast_LoadsAndUnloadsColumns()
+ {
+ // Starting (50,50) covers X in [48..52]. Step to (53,50):
+ // new coverage X in [51..55]. New columns: x=53,54,55 (15 entries).
+ // x=48 is now 5 away → unload. x=49,50 still within radius+1 → keep.
+ var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
+
+ var diff = region.RecenterTo(53, 50);
+
+ Assert.Equal(15, diff.ToLoad.Count);
+ Assert.Equal(5, diff.ToUnload.Count);
+ }
+
+ [Fact]
+ public void RecenterTo_LongTeleport_UnloadsEverythingLoadsEverything()
+ {
+ var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
+
+ var diff = region.RecenterTo(200, 200);
+
+ Assert.Equal(25, diff.ToLoad.Count);
+ Assert.Equal(25, diff.ToUnload.Count);
+ }
+}