using System.Linq; 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 VoxelEngine.World.WorldGen; 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 } // Mesher that returns an empty mesh, simulating a pure-air chunk. internal sealed class EmptyMesher : IChunkMesher { public float[] Build(VoxelChunk chunk, BlockRegistry registry, Func? neighborOpaqueOutside = null) => Array.Empty(); } public class ChunkStreamerTests { private static (ChunkStreamer streamer, World.World world, List created) CreateStreamer( Configuration cfg, Func? surfaceHeightAt = null) { 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(); Func height = surfaceHeightAt ?? ((_, _) => 8); var streamer = new ChunkStreamer(cfg, world, generate, height, mesher, registry, executor, factory); return (streamer, world, created); } // Ticks at a fixed player position until the streamer stops producing new // renderers (queued loads + their one-shot in-column remesh cascade have all // drained). Capped to avoid an infinite loop if something never settles. private static void TickToQuiescence(ChunkStreamer streamer, List created, Vector3i player) { int last = -1; for (int i = 0; i < 200 && created.Count != last; i++) { last = created.Count; streamer.Tick(player); } } [Fact] public void Tick_FirstCall_LoadsAllChunksInLoadBox() { var cfg = new Configuration { RenderDistanceHorizontal = 1, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; var (streamer, world, created) = CreateStreamer(cfg); streamer.Tick(new Vector3i(0, 0, 0)); // H=1 → a 3×3 horizontal box of full columns. The default test surface // (8) is below sea level, so each column loads bedrock→waterline; assert // the box is covered at the player's chunk Y rather than a raw 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, 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, 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, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; // Surface far above the world ceiling -> top chunk clamps to // WorldHeightChunks-1 (31). Full column loads cy 0..31 and nothing above. var (streamer, world, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => 100_000); streamer.Tick(new Vector3i(0, 0, 0)); for (int cy = 0; cy < WorldConstants.WorldHeightChunks; cy++) { var pos = new Vector3i(0, cy, 0); Assert.NotNull(world.GetChunk(pos)); Assert.True(streamer.IsReady(pos)); } } [Fact] public void Tick_AppliesModificationDiffOnLoad() { var cfg = new Configuration { RenderDistanceHorizontal = 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, (_, _) => 8, 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, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; var (streamer, world, created) = CreateStreamer(cfg); streamer.Tick(new Vector3i(0, 0, 0)); // loads the (0,*,0) column Assert.NotNull(world.GetChunk(new Vector3i(0, 0, 0))); Assert.NotEmpty(created); streamer.Tick(new Vector3i(5, 0, 0)); // (0,*,0) now out of range → unloaded // The player chunk (priority 0) loaded first, so created[0] is its // renderer; moving away must dispose it and drop the chunk. 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, 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, (_, _) => 8, 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, SpawnReadyRadiusHorizontal = 1, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; var (streamer, _, _) = CreateStreamer(cfg); Assert.False(streamer.SpawnReady); // The spawn area is now full columns (bedrock→waterline) per (x,z); tick // until it finishes loading. Cap generously to avoid an infinite loop. for (int i = 0; i < 200 && !streamer.SpawnReady; i++) streamer.Tick(new Vector3i(0, 0, 0)); Assert.True(streamer.SpawnReady); } [Fact] public void MarkDirty_RemeshesChunkOnNextTick() { var cfg = new Configuration { RenderDistanceHorizontal = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; var (streamer, _, created) = CreateStreamer(cfg); // The default test surface (8) is below sea level, so (0,*,0) is a full // bedrock→waterline column. Loads mark in-column vertical neighbours // dirty, so tick to quiescence (loads + the one-shot remesh cascade) // before measuring. Remeshes do NOT re-dirty neighbours, so it settles. TickToQuiescence(streamer, created, new Vector3i(0, 0, 0)); int afterLoad = created.Count; int liveBefore = streamer.RendererCount; // Capture the live renderer currently installed for (0,0,0). IChunkRenderer? initialRenderer = null; streamer.DrawVisible( pos => pos == new Vector3i(0, 0, 0), (_, r) => initialRenderer = r); Assert.NotNull(initialRenderer); streamer.MarkDirty(new Vector3i(0, 0, 0)); streamer.Tick(new Vector3i(0, 0, 0)); // exactly one remesh queued (no cascade) // Exactly one extra renderer (the remesh) — old disposed, new live, live // count unchanged. Assert.Equal(afterLoad + 1, created.Count); Assert.Equal(liveBefore, streamer.RendererCount); Assert.True(((FakeRenderer)initialRenderer!).Disposed); Assert.False(created[^1].Disposed); } [Fact] public void Tick_LoadingChunk_MarksReadyCardinalNeighborsDirty() { var cfg = new Configuration { RenderDistanceHorizontal = 1, 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, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; var (streamer, _, created) = CreateStreamer(cfg); // Settle the full submerged column (loads + their one-shot remesh // cascade) so the renderer count is stable before the no-op check. TickToQuiescence(streamer, created, new Vector3i(0, 0, 0)); int afterLoad = created.Count; streamer.MarkDirty(new Vector3i(99, 0, 99)); // not tracked streamer.Tick(new Vector3i(0, 0, 0)); // no remesh should be queued Assert.Equal(afterLoad, created.Count); } [Fact] public void Tick_GeneratorThrows_LogsAndDoesNotRetry() { var cfg = new Configuration { RenderDistanceHorizontal = 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(); int generateCalls = 0; Func generate = _ => { generateCalls++; throw new InvalidOperationException("boom"); }; Func factory = m => new FakeRenderer(m); var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory); streamer.Tick(new Vector3i(0, 0, 0)); int callsAfterFirst = generateCalls; Assert.True(callsAfterFirst > 0); Assert.False(streamer.IsReady(new Vector3i(0, 0, 0))); // Tick again — same position. Failed chunks must not be retried. streamer.Tick(new Vector3i(0, 0, 0)); Assert.Equal(callsAfterFirst, generateCalls); } [Fact] public void InstallResult_EmptyMesh_SkipsRendererCreationButMarksReady() { var cfg = new Configuration { RenderDistanceHorizontal = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; var registry = new BlockRegistry(); var store = new InMemoryChunkModificationStore(); var world = new World.World(registry, store); var mesher = new EmptyMesher(); var executor = new SynchronousJobExecutor(); int rendererFactoryCalls = 0; Func factory = m => { rendererFactoryCalls++; return new FakeRenderer(m); }; Func generate = _ => new VoxelChunk(); var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory); var pos = new Vector3i(0, 0, 0); streamer.Tick(pos); Assert.True(streamer.IsReady(pos)); Assert.Equal(0, rendererFactoryCalls); Assert.Equal(0, streamer.RendererCount); } [Fact] public void Tick_VerticalPriorityWeight_PrefersHorizontalAtSameDistance() { var cfg = new Configuration { RenderDistanceHorizontal = 1, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 5, // player's chunk + 4 horizontal cardinals VerticalPriorityWeight = 4, }; // Surface at worldY 48 -> top chunk 3, so a full column is cy 0..3 and // the player at Y=2 has in-column vertical neighbours at Y=1 and Y=3. var (streamer, world, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => 48); var playerPos = new Vector3i(0, 2, 0); streamer.Tick(playerPos); // Drain budget = 5 = priority 0 (player) + 4 priority-1 (horizontal // cardinals). With VerticalPriorityWeight=4, vertical cardinals // (Y=1, Y=3) have priority 4 -- they must NOT be among the first 5. Assert.NotNull(world.GetChunk(playerPos)); Assert.NotNull(world.GetChunk(new Vector3i( 1, 2, 0))); Assert.NotNull(world.GetChunk(new Vector3i(-1, 2, 0))); Assert.NotNull(world.GetChunk(new Vector3i( 0, 2, 1))); Assert.NotNull(world.GetChunk(new Vector3i( 0, 2, -1))); Assert.Null(world.GetChunk(new Vector3i(0, 3, 0))); Assert.Null(world.GetChunk(new Vector3i(0, 1, 0))); } [Fact] public void Tick_LoadsFullColumn_FromBedrockToSurface() { var cfg = new Configuration { RenderDistanceHorizontal = 0, // single column at the player MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; // Land surface at worldY 296 -> top chunk floor(296/16) = 18 (above the // sea-level chunk 16, so no water raises it). Full column loads cy 0..18; // chunk Y 19+ is air above the surface and must NOT load. var (streamer, world, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => 296); streamer.Tick(new Vector3i(0, 0, 0)); Assert.NotNull(world.GetChunk(new Vector3i(0, 0, 0))); // bedrock Assert.NotNull(world.GetChunk(new Vector3i(0, 9, 0))); // mid Assert.NotNull(world.GetChunk(new Vector3i(0, 18, 0))); // surface Assert.Null(world.GetChunk(new Vector3i(0, 19, 0))); // air above surface Assert.Null(world.GetChunk(new Vector3i(0, 25, 0))); // air above surface } [Fact] public void Tick_LoadsWaterSurface_OverSubmergedColumn() { var cfg = new Configuration { RenderDistanceHorizontal = 0, // single column at the player MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; // Terrain surface at worldY 100 (chunk 6) but well below SeaLevel 256. // Opaque water fills worldY 101..256, so the column must load through the // sea-level chunk floor(256/16)=16, not stop at the terrain top (chunk 6). var (streamer, world, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => 100); streamer.Tick(new Vector3i(0, 0, 0)); Assert.NotNull(world.GetChunk(new Vector3i(0, 6, 0))); // terrain top Assert.NotNull(world.GetChunk(new Vector3i(0, 10, 0))); // submerged water column Assert.NotNull(world.GetChunk(new Vector3i(0, 16, 0))); // chunk holding the waterline (y=256) Assert.Null(world.GetChunk(new Vector3i(0, 17, 0))); // above the waterline -- not loaded } [Fact] public void Tick_LoadsEditedSectionAboveSurface() { var cfg = new Configuration { RenderDistanceHorizontal = 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(); // Land surface at worldY 296 -> top chunk 18 (above the sea-level chunk // 16, so no water raises it). A player edit sits in chunk Y 25, above the // loaded column; that edited section must still load. var edited = new Vector3i(0, 25, 0); store.RecordEdit(edited, 0, registry.Stone.Id); var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 296, mesher, registry, executor, factory); streamer.Tick(new Vector3i(0, 0, 0)); Assert.NotNull(world.GetChunk(new Vector3i(0, 18, 0))); // surface column top Assert.NotNull(world.GetChunk(edited)); // edited air section above surface Assert.Null(world.GetChunk(new Vector3i(0, 20, 0))); // unedited air -- not loaded } [Fact] public void RecomputeTargetSet_CachesSurfaceHeights_OnlySamplesNewColumns() { var cfg = new Configuration { RenderDistanceHorizontal = 1, SpawnReadyRadiusHorizontal = 1, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; int calls = 0; var (streamer, _, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => { calls++; return 8; }); streamer.Tick(new Vector3i(0, 0, 0)); int afterFirst = calls; // 9 columns × 5 samples = 45 (spawn-check reuses cache) streamer.Tick(new Vector3i(1, 0, 0)); // overlaps 6 columns; only the cx=2 strip is new int newCalls = calls - afterFirst; Assert.Equal(45, afterFirst); Assert.Equal(15, newCalls); // 3 new columns × 5 — cached columns are not re-sampled } [Fact] public void DrawVisible_DrawsOnlyChunksPassingPredicate_AndReturnsCount() { var cfg = new Configuration { RenderDistanceHorizontal = 1, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; var (streamer, _, created) = CreateStreamer(cfg); streamer.Tick(new Vector3i(0, 0, 0)); // 9 columns, all chunk Y 0, non-empty meshes int drawn = streamer.DrawVisible( pos => pos == new Vector3i(0, 0, 0), // only the centre chunk "visible" (_, r) => r.Draw()); Assert.Equal(1, drawn); Assert.Equal(1, created.Sum(r => r.DrawCount)); } }