using System; using System.Collections.Generic; using AcDream.App.Rendering.Gpu.Vk; namespace AcDream.App.Tests.Rendering.Gpu.Vk; /// /// Campaign V slice V6a — the frame timeline, the per-slot ring, and the /// staging ring (plan §4.3 and §4.8). /// /// These three carry the invariant that everything else in the Vulkan backend /// rests on: memory handed to the GPU is not reused, and memory freed by the CPU /// is not destroyed, until the frame that could still be reading it has /// completed. A timeline semaphore makes that one number, which is exactly what /// makes it testable — is the number. /// public sealed class VulkanFrameFlightTests { private sealed class FakeTimeline : IVulkanTimelineApi { public ulong Value { get; set; } public List Waits { get; } = []; public ulong CurrentValue => Value; public void Wait(ulong value) { Waits.Add(value); // A real wait blocks until the GPU gets there; the fake models a // GPU that is always just far enough ahead. Value = Math.Max(Value, value); } } // ── flight controller ──────────────────────────────────────────────────── [Fact] public void Flights_FirstFramesDoNotWaitBecauseNoSlotIsOccupiedYet() { var timeline = new FakeTimeline(); var flights = new VulkanFrameFlightController(timeline, framesInFlight: 2); Assert.Equal(1, flights.BeginFrame()); flights.EndFrame(); Assert.Equal(2, flights.BeginFrame()); flights.EndFrame(); Assert.Empty(timeline.Waits); } [Fact] public void Flights_ThirdFrameWaitsForTheFirstToComplete() { var timeline = new FakeTimeline(); var flights = new VulkanFrameFlightController(timeline, framesInFlight: 2); flights.BeginFrame(); flights.EndFrame(); flights.BeginFrame(); flights.EndFrame(); Assert.Equal(3, flights.BeginFrame()); Assert.Equal([1ul], timeline.Waits); } [Fact] public void Flights_SerialsMapOntoSlotsRoundRobin() { var flights = new VulkanFrameFlightController(new FakeTimeline(), framesInFlight: 2); Assert.Equal(0, flights.SlotIndexOf(1)); Assert.Equal(1, flights.SlotIndexOf(2)); Assert.Equal(0, flights.SlotIndexOf(3)); Assert.Equal(1, flights.SlotIndexOf(4)); } [Fact] public void Flights_RetirementIsKeyedToTheOpenFrameNotTheCompletedOne() { var timeline = new FakeTimeline(); var flights = new VulkanFrameFlightController(timeline, framesInFlight: 2); var released = new List(); flights.BeginFrame(); // serial 1 open flights.Retire(() => released.Add("during-1")); flights.EndFrame(); // Frame 1 has been submitted but the GPU has not finished it, so the // release must not run: commands recorded into frame 1 may still read // the resource. timeline.Value = 0; flights.BeginFrame(); // serial 2 open; retirements run here Assert.Empty(released); flights.EndFrame(); timeline.Value = 1; flights.RunRetirements(); Assert.Equal(["during-1"], released); } [Fact] public void Flights_ResourcesReleasedBetweenFramesBelongToTheNextFrame() { var timeline = new FakeTimeline(); var flights = new VulkanFrameFlightController(timeline, framesInFlight: 2); var released = new List(); flights.BeginFrame(); flights.EndFrame(); flights.Retire(() => released.Add("between")); // Filed against serial 2, which has not even opened. timeline.Value = 1; flights.RunRetirements(); Assert.Empty(released); timeline.Value = 2; flights.RunRetirements(); Assert.Equal(["between"], released); } [Fact] public void Flights_WaitForSubmittedWorkDrainsEverythingPending() { var timeline = new FakeTimeline(); var flights = new VulkanFrameFlightController(timeline, framesInFlight: 2); var released = new List(); flights.BeginFrame(); flights.Retire(() => released.Add("a")); flights.EndFrame(); flights.Retire(() => released.Add("b")); flights.WaitForSubmittedWork(); Assert.Equal(["a", "b"], released); Assert.Equal(0, flights.PendingRetirementCount); } [Fact] public void Flights_OpeningTwoFramesAtOnceIsRejected() { var flights = new VulkanFrameFlightController(new FakeTimeline(), framesInFlight: 2); flights.BeginFrame(); Assert.Throws(() => flights.BeginFrame()); } [Fact] public void Flights_DisposeRunsRemainingReleasesSoNothingLeaks() { var flights = new VulkanFrameFlightController(new FakeTimeline(), framesInFlight: 2); var released = new List(); flights.BeginFrame(); flights.Retire(() => released.Add("pending")); flights.Dispose(); Assert.Equal(["pending"], released); } // ── per-frame ring ─────────────────────────────────────────────────────── [Fact] public void Ring_AllocatesForwardWithAlignmentAndTracksThePeak() { var ring = new VulkanRingBufferState(1024); Assert.Equal(0ul, ring.Allocate(10, 1)); Assert.Equal(256ul, ring.Allocate(16, 256)); Assert.Equal(272ul, ring.AllocatedBytes); Assert.Equal(272ul, ring.PeakAllocatedBytes); ring.Reset(); Assert.Equal(0ul, ring.AllocatedBytes); // The peak survives the reset: it is the number that says whether the // per-slot capacity is right. Assert.Equal(272ul, ring.PeakAllocatedBytes); } [Fact] public void Ring_OverCapacityThrowsRatherThanTruncating() { var ring = new VulkanRingBufferState(64); InvalidOperationException error = Assert.Throws(() => ring.Allocate(65, 1)); // The message must name the capacity, because the fix is always // "raise it" and the person reading has no other source for the number. Assert.Contains("64 bytes", error.Message, StringComparison.Ordinal); } [Fact] public void Ring_ZeroSizedAllocationIsLegalAndMovesNothing() { var ring = new VulkanRingBufferState(64); Assert.Equal(0ul, ring.Allocate(0, 16)); Assert.Equal(0ul, ring.AllocatedBytes); } // ── staging ring ───────────────────────────────────────────────────────── [Fact] public void Staging_HandsOutBytesUntilTheRingIsFullOfUnretiredFrames() { var staging = new VulkanStagingRingState(1024); Assert.True(staging.TryAllocate(512, 4, serial: 1, out ulong first)); Assert.True(staging.TryAllocate(512, 4, serial: 1, out ulong second)); Assert.Equal(0ul, first); Assert.Equal(512ul, second); // Nothing has retired, so there is genuinely nowhere to put this. Assert.False(staging.TryAllocate(1, 4, serial: 1, out _)); } [Fact] public void Staging_ReclaimsSpaceWhenTheFrameThatTookItCompletes() { var staging = new VulkanStagingRingState(1024); Assert.True(staging.TryAllocate(1024, 4, serial: 1, out _)); Assert.False(staging.TryAllocate(16, 4, serial: 2, out _)); staging.Release(completedSerial: 1); Assert.Equal(0ul, staging.LiveBytes); Assert.True(staging.TryAllocate(16, 4, serial: 2, out _)); } [Fact] public void Staging_ReleasesOnlyFramesTheGpuHasActuallyFinished() { var staging = new VulkanStagingRingState(1024); Assert.True(staging.TryAllocate(256, 4, serial: 1, out _)); Assert.True(staging.TryAllocate(256, 4, serial: 2, out _)); staging.Release(completedSerial: 1); Assert.Equal(256ul, staging.LiveBytes); Assert.Equal(1, staging.PendingSegmentCount); } [Fact] public void Staging_WrapsRatherThanRefusingWhenTheTailIsFree() { var staging = new VulkanStagingRingState(1024); Assert.True(staging.TryAllocate(768, 4, serial: 1, out _)); staging.Release(completedSerial: 1); // 512 does not fit in the 256 bytes left at the end, so it wraps to 0 // and the wasted tail is charged to this segment. Assert.True(staging.TryAllocate(512, 4, serial: 2, out ulong wrapped)); Assert.Equal(0ul, wrapped); Assert.Equal(768ul, staging.LiveBytes); } [Fact] public void Staging_RefusesAPayloadLargerThanTheWholeRing() { var staging = new VulkanStagingRingState(1024); // The caller's answer to this is a temporary dedicated staging buffer, // not a bigger ring — see VulkanUploadQueue. Assert.False(staging.TryAllocate(2048, 4, serial: 1, out _)); } [Fact] public void Staging_MergesConsecutiveRequestsFromTheSameFrame() { var staging = new VulkanStagingRingState(1024); Assert.True(staging.TryAllocate(16, 4, serial: 1, out _)); Assert.True(staging.TryAllocate(16, 4, serial: 1, out _)); Assert.True(staging.TryAllocate(16, 4, serial: 2, out _)); Assert.Equal(2, staging.PendingSegmentCount); } }