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)); + } +}