using OpenTK.Mathematics; using VoxelEngine.Core; using VoxelEngine.World; using VoxelEngine.World.Blocks; using VoxelEngine.World.Chunk; using VoxelEngine.World.Persistence; using VoxelEngine.World.Streaming; using Xunit; using VoxelChunk = VoxelEngine.World.Chunk.Chunk; namespace VoxelEngine.Tests.Streaming; // Synchronous executor: runs the submitted action on the calling thread. internal sealed class SynchronousJobExecutor : IJobExecutor { public void Submit(Action work) => work(); } // Fake renderer: never touches GL. Just records lifecycle. internal sealed class FakeRenderer : IChunkRenderer { public bool Disposed { get; private set; } public int DrawCount { get; private set; } public float[] Mesh { get; } public FakeRenderer(float[] mesh) { this.Mesh = mesh; } public void Draw() => this.DrawCount++; public void Dispose() => this.Disposed = true; } // Minimal mesher returning a fixed-length non-empty mesh. internal sealed class FakeMesher : IChunkMesher { public float[] Build(VoxelChunk chunk, BlockRegistry registry, Func? neighborOpaqueOutside = null) => new float[6]; // one vertex worth } public class ChunkStreamerTests { private static (ChunkStreamer streamer, World.World world, List created) CreateStreamer(Configuration cfg) { var registry = new BlockRegistry(); var store = new InMemoryChunkModificationStore(); var world = new World.World(registry, store); var mesher = new FakeMesher(); var executor = new SynchronousJobExecutor(); var created = new List(); Func factory = m => { var r = new FakeRenderer(m); created.Add(r); return r; }; Func generate = _ => new VoxelChunk(); var streamer = new ChunkStreamer(cfg, world, generate, mesher, registry, executor, factory); return (streamer, world, created); } [Fact] public void Tick_FirstCall_LoadsAllChunksInLoadBox() { var cfg = new Configuration { RenderDistanceHorizontal = 1, RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; var (streamer, world, created) = CreateStreamer(cfg); streamer.Tick(new Vector3i(0, 0, 0)); // Load box H=1, V=0 → 3 × 1 × 3 = 9 chunks Assert.Equal(9, created.Count); for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) { var pos = new Vector3i(x, 0, z); Assert.NotNull(world.GetChunk(pos)); Assert.True(streamer.IsReady(pos)); } } [Fact] public void Tick_PlayerMoves_LoadsNewChunks() { var cfg = new Configuration { RenderDistanceHorizontal = 1, RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; var (streamer, world, created) = CreateStreamer(cfg); streamer.Tick(new Vector3i(0, 0, 0)); // loads 9 // After the second tick the 3 new x=2 chunks must be ready. // Cardinal-neighbor dirty-marking may also produce remesh renderers, so // we assert on world state rather than an exact renderer count. streamer.Tick(new Vector3i(1, 0, 0)); for (int z = -1; z <= 1; z++) { var newPos = new Vector3i(2, 0, z); Assert.NotNull(world.GetChunk(newPos)); Assert.True(streamer.IsReady(newPos)); } // x=-1 column must be unloaded for (int z = -1; z <= 1; z++) { Assert.Null(world.GetChunk(new Vector3i(-1, 0, z))); Assert.False(streamer.IsReady(new Vector3i(-1, 0, z))); } } [Fact] public void Tick_RespectsMaxMeshUploadsPerFrame() { var cfg = new Configuration { RenderDistanceHorizontal = 1, RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 2, // cap drains per tick }; var (streamer, _, created) = CreateStreamer(cfg); // With sync executor every submitted job completes immediately and // posts into _completed. The cap throttles how many drain per Tick(). streamer.Tick(new Vector3i(0, 0, 0)); // dispatch fills the queue; drain ≤ 2 Assert.Equal(2, created.Count); streamer.Tick(new Vector3i(0, 0, 0)); // same player chunk, drains 2 more Assert.Equal(4, created.Count); } [Fact] public void Tick_ClampsYToWorldBounds() { var cfg = new Configuration { RenderDistanceHorizontal = 0, RenderDistanceVertical = 2, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; var (streamer, world, created) = CreateStreamer(cfg); // Player at y=0 (chunk Y = 0). V=2 wants Y ∈ [-2, 2], but valid Y is [0, 8). // So only Y ∈ {0, 1, 2} qualifies → 1 × 3 × 1 = 3 chunks. streamer.Tick(new Vector3i(0, 0, 0)); Assert.Equal(3, created.Count); Assert.NotNull(world.GetChunk(new Vector3i(0, 0, 0))); Assert.NotNull(world.GetChunk(new Vector3i(0, 1, 0))); Assert.NotNull(world.GetChunk(new Vector3i(0, 2, 0))); } [Fact] public void Tick_AppliesModificationDiffOnLoad() { var cfg = new Configuration { RenderDistanceHorizontal = 0, RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; var registry = new BlockRegistry(); var store = new InMemoryChunkModificationStore(); var world = new World.World(registry, store); var mesher = new FakeMesher(); var executor = new SynchronousJobExecutor(); Func factory = m => new FakeRenderer(m); Func generate = _ => new VoxelChunk(); // all air // Pre-record an edit before the chunk loads. var pos = new Vector3i(0, 0, 0); int localIndex = (5 * VoxelChunk.Size + 6) * VoxelChunk.Size + 7; store.RecordEdit(pos, localIndex, registry.Stone.Id); var streamer = new ChunkStreamer(cfg, world, generate, mesher, registry, executor, factory); streamer.Tick(pos); Assert.Equal(registry.Stone.Id, world.GetBlock(5, 6, 7).Id); } [Fact] public void Tick_PlayerMovesAway_UnloadsOldChunksAndDisposesRenderer() { var cfg = new Configuration { RenderDistanceHorizontal = 0, RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; var (streamer, world, created) = CreateStreamer(cfg); streamer.Tick(new Vector3i(0, 0, 0)); // loads (0,0,0) Assert.Single(created); Assert.NotNull(world.GetChunk(new Vector3i(0, 0, 0))); streamer.Tick(new Vector3i(5, 0, 0)); // (0,0,0) now out of range; (5,0,0) loaded Assert.True(created[0].Disposed); Assert.Null(world.GetChunk(new Vector3i(0, 0, 0))); Assert.False(streamer.IsReady(new Vector3i(0, 0, 0))); } [Fact] public void Tick_UnloadKeepsModificationStoreDiff() { var cfg = new Configuration { RenderDistanceHorizontal = 0, RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; var registry = new BlockRegistry(); var store = new InMemoryChunkModificationStore(); var world = new World.World(registry, store); var mesher = new FakeMesher(); var executor = new SynchronousJobExecutor(); Func factory = m => new FakeRenderer(m); Func generate = _ => new VoxelChunk(); var streamer = new ChunkStreamer(cfg, world, generate, mesher, registry, executor, factory); var pos = new Vector3i(0, 0, 0); streamer.Tick(pos); store.RecordEdit(pos, 0, registry.Stone.Id); streamer.Tick(new Vector3i(99, 0, 0)); // unloads (0,0,0) Assert.True(store.HasDiff(pos)); // diff survives unload } [Fact] public void SpawnReady_FlipsTrueOnceSpawnAreaLoaded() { var cfg = new Configuration { RenderDistanceHorizontal = 1, RenderDistanceVertical = 0, SpawnReadyRadiusHorizontal = 1, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 1, // throttle to expose progression }; var (streamer, _, _) = CreateStreamer(cfg); Assert.False(streamer.SpawnReady); // Tick once per chunk needed (3×3×1 = 9 chunks at this Y). Spawn area // restricted by Y bounds (only Y=0 valid here) → 9 ticks. for (int i = 0; i < 9; i++) streamer.Tick(new Vector3i(0, 0, 0)); Assert.True(streamer.SpawnReady); } [Fact] public void MarkDirty_RemeshesChunkOnNextTick() { var cfg = new Configuration { RenderDistanceHorizontal = 0, RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; var (streamer, _, created) = CreateStreamer(cfg); streamer.Tick(new Vector3i(0, 0, 0)); Assert.Single(created); var initialRenderer = created[0]; streamer.MarkDirty(new Vector3i(0, 0, 0)); streamer.Tick(new Vector3i(0, 0, 0)); // same chunk, but a remesh is queued // Two renderers exist over the lifetime — old disposed, new live. Assert.Equal(2, created.Count); Assert.True(initialRenderer.Disposed); Assert.False(created[1].Disposed); } [Fact] public void Tick_LoadingChunk_MarksReadyCardinalNeighborsDirty() { var cfg = new Configuration { RenderDistanceHorizontal = 1, RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; var (streamer, _, created) = CreateStreamer(cfg); streamer.Tick(new Vector3i(0, 0, 0)); // load initial 3×3 int firstWave = created.Count; // 9 chunks // Move so we load a new x=2 column. The new chunks become ready and // mark their cardinal neighbors (x=1 column) dirty during Drain. streamer.Tick(new Vector3i(1, 0, 0)); // dispatch + drain new loads → dirty marks set streamer.Tick(new Vector3i(1, 0, 0)); // dispatch the queued remeshes → drain them // Should have: 3 new loads + at least 3 remeshes of the x=1 column. Assert.True(created.Count > firstWave + 3, $"Expected remeshes beyond the 3 new loads; got {created.Count - firstWave}"); } [Fact] public void MarkDirty_OnUntrackedChunk_IsNoOp() { var cfg = new Configuration { RenderDistanceHorizontal = 0, RenderDistanceVertical = 0 }; var (streamer, _, created) = CreateStreamer(cfg); streamer.MarkDirty(new Vector3i(99, 0, 99)); streamer.Tick(new Vector3i(0, 0, 0)); // Only the chunk in the load box was created; no spurious remesh. Assert.Single(created); } }