diff --git a/src/AcDream.Core/Terrain/TerrainSlotAllocator.cs b/src/AcDream.Core/Terrain/TerrainSlotAllocator.cs
new file mode 100644
index 0000000..1e86f21
--- /dev/null
+++ b/src/AcDream.Core/Terrain/TerrainSlotAllocator.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Collections.Generic;
+
+namespace AcDream.Core.Terrain;
+
+///
+/// Pure-CPU slot allocator for the terrain modern dispatcher's global VBO/EBO.
+/// One slot = one landblock's worth of mesh data (384 verts + 384 indices).
+/// Uses a FIFO free-list for slot recycling and a monotonic counter for
+/// first-time growth, mirroring WorldBuilder's TerrainRenderManager pattern.
+/// All bookkeeping is CPU-side; the GPU buffer growth itself is performed
+/// by TerrainModernRenderer when sets needsGrow=true.
+///
+public sealed class TerrainSlotAllocator
+{
+ private readonly Queue _freeSlots = new();
+ private readonly HashSet _liveSlots = new();
+ private int _nextFreeSlot;
+ private int _capacity;
+
+ public TerrainSlotAllocator(int initialCapacity = 64)
+ {
+ if (initialCapacity <= 0)
+ throw new ArgumentOutOfRangeException(nameof(initialCapacity), "must be > 0");
+ _capacity = initialCapacity;
+ }
+
+ /// Current capacity in slots. Growable via .
+ public int Capacity => _capacity;
+
+ /// Slots currently in use (allocated minus freed).
+ public int LoadedCount => _liveSlots.Count;
+
+ ///
+ /// Allocate a slot index. Reuses a freed slot via FIFO if available,
+ /// otherwise hands out the next monotonic index. Sets
+ /// to true when the returned slot index is
+ /// at or beyond current capacity — caller must
+ /// before using the slot.
+ ///
+ public int Allocate(out bool needsGrow)
+ {
+ int slot;
+ if (_freeSlots.TryDequeue(out var freed))
+ {
+ slot = freed;
+ }
+ else
+ {
+ slot = _nextFreeSlot++;
+ }
+ _liveSlots.Add(slot);
+ needsGrow = slot >= _capacity;
+ return slot;
+ }
+
+ ///
+ /// Return a slot to the free list. Throws if the slot wasn't currently
+ /// allocated (catches double-free bugs).
+ ///
+ public void Free(int slot)
+ {
+ if (!_liveSlots.Remove(slot))
+ throw new InvalidOperationException(
+ $"Slot {slot} was not allocated (double-free or unknown slot).");
+ _freeSlots.Enqueue(slot);
+ }
+
+ /// Update capacity counter after the caller has grown the GPU buffers.
+ public void GrowTo(int newCapacity)
+ {
+ if (newCapacity < _capacity)
+ throw new ArgumentException("Capacity can only grow", nameof(newCapacity));
+ _capacity = newCapacity;
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Terrain/TerrainSlotAllocatorTests.cs b/tests/AcDream.Core.Tests/Terrain/TerrainSlotAllocatorTests.cs
new file mode 100644
index 0000000..aaa894c
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Terrain/TerrainSlotAllocatorTests.cs
@@ -0,0 +1,88 @@
+using AcDream.Core.Terrain;
+using Xunit;
+
+namespace AcDream.Core.Tests.Terrain;
+
+public class TerrainSlotAllocatorTests
+{
+ [Fact]
+ public void Allocate_FromFreshAllocator_ReturnsZero()
+ {
+ var alloc = new TerrainSlotAllocator(initialCapacity: 8);
+ Assert.Equal(0, alloc.Allocate(out _));
+ }
+
+ [Fact]
+ public void Allocate_TwoTimes_ReturnsZeroThenOne()
+ {
+ var alloc = new TerrainSlotAllocator(initialCapacity: 8);
+ Assert.Equal(0, alloc.Allocate(out _));
+ Assert.Equal(1, alloc.Allocate(out _));
+ }
+
+ [Fact]
+ public void FreeThenAllocate_ReusesFreedSlot()
+ {
+ var alloc = new TerrainSlotAllocator(initialCapacity: 8);
+ var s0 = alloc.Allocate(out _);
+ var s1 = alloc.Allocate(out _);
+ alloc.Free(s0);
+ Assert.Equal(s0, alloc.Allocate(out _));
+ }
+
+ [Fact]
+ public void FreeOrderedFreshAllocs_ReturnsInFifoOrder()
+ {
+ var alloc = new TerrainSlotAllocator(initialCapacity: 8);
+ var s0 = alloc.Allocate(out _);
+ var s1 = alloc.Allocate(out _);
+ var s2 = alloc.Allocate(out _);
+ alloc.Free(s0);
+ alloc.Free(s2);
+ Assert.Equal(s0, alloc.Allocate(out _));
+ Assert.Equal(s2, alloc.Allocate(out _));
+ }
+
+ [Fact]
+ public void Allocate_BeyondInitialCapacity_SignalsNeedsGrow()
+ {
+ var alloc = new TerrainSlotAllocator(initialCapacity: 2);
+ alloc.Allocate(out var grow0);
+ alloc.Allocate(out var grow1);
+ alloc.Allocate(out var grow2);
+ Assert.False(grow0);
+ Assert.False(grow1);
+ Assert.True(grow2);
+ }
+
+ [Fact]
+ public void GrowTo_DoublesCapacityCorrectly()
+ {
+ var alloc = new TerrainSlotAllocator(initialCapacity: 4);
+ alloc.GrowTo(8);
+ Assert.Equal(8, alloc.Capacity);
+ alloc.GrowTo(64);
+ Assert.Equal(64, alloc.Capacity);
+ }
+
+ [Fact]
+ public void LoadedCount_TracksAllocAndFree()
+ {
+ var alloc = new TerrainSlotAllocator(initialCapacity: 8);
+ Assert.Equal(0, alloc.LoadedCount);
+ var s0 = alloc.Allocate(out _);
+ var s1 = alloc.Allocate(out _);
+ Assert.Equal(2, alloc.LoadedCount);
+ alloc.Free(s0);
+ Assert.Equal(1, alloc.LoadedCount);
+ }
+
+ [Fact]
+ public void Free_TwiceForSameSlot_Throws()
+ {
+ var alloc = new TerrainSlotAllocator(initialCapacity: 8);
+ var s0 = alloc.Allocate(out _);
+ alloc.Free(s0);
+ Assert.Throws(() => alloc.Free(s0));
+ }
+}