diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj
index ffec2ecd..513360c5 100644
--- a/src/AcDream.App/AcDream.App.csproj
+++ b/src/AcDream.App/AcDream.App.csproj
@@ -28,6 +28,10 @@
+
+
+
diff --git a/src/AcDream.App/RenderBackendKind.cs b/src/AcDream.App/RenderBackendKind.cs
new file mode 100644
index 00000000..4765f914
--- /dev/null
+++ b/src/AcDream.App/RenderBackendKind.cs
@@ -0,0 +1,25 @@
+namespace AcDream.App;
+
+///
+/// Campaign V slice V5: which rendering backend the graphical host starts.
+///
+/// OpenGL is the default and, through slice V9, the only backend that renders
+/// the game. is dark bring-up — it creates an instance, a
+/// device, a swapchain and runs the capability gate, and nothing more, until the
+/// Vulkan RHI backend lands at V6.
+///
+/// This enum is public only because is public and
+/// exposes it as a property. The backend-neutral
+/// AcDream.App.Rendering.Gpu.GpuBackendKind stays internal and describes a
+/// live device rather than a startup request; the two are deliberately separate
+/// because a Vulkan-requested process can still fail its capability gate and
+/// never own a Vulkan device at all.
+///
+public enum RenderBackendKind
+{
+ /// OpenGL 4.3 core + bindless + MDI. The default, and the only live backend.
+ Gl,
+
+ /// Vulkan 1.3 core. Dark until Campaign V slice V10 flips the default.
+ Vulkan,
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index e16fa047..9cdee745 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -680,6 +680,21 @@ public sealed class GameWindow :
// attribute, so it must come from this same snapshot rather than a
// second settings load during OnLoad.
RuntimeSettingsSnapshot startup = _runtimeSettings.Startup;
+ if (_options.RenderBackend == RenderBackendKind.Vulkan)
+ {
+ // Campaign V slice V5 — dark Vulkan bring-up. Reached only when
+ // ACDREAM_RENDER_BACKEND=vulkan; the GL path below executes not one
+ // new statement. The host owns its own window, device and swapchain,
+ // and its capability gate throws NotSupportedException into the same
+ // exit-code-4 contract Program.cs already publishes for GL.
+ using var vulkan = new AcDream.App.Rendering.Gpu.Vk.VulkanBringUpHost(
+ _options,
+ _platformServices,
+ startup.Display.VSync);
+ vulkan.Run();
+ return;
+ }
+
FramePacingPolicy startupPacing =
_displayFramePacing.InitializeStartup(startup.Display.VSync);
var options = WindowOptions.Default with
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanActiveDeviceProbe.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanActiveDeviceProbe.cs
new file mode 100644
index 00000000..8770cacf
--- /dev/null
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanActiveDeviceProbe.cs
@@ -0,0 +1,768 @@
+using Silk.NET.Vulkan;
+using Semaphore = Silk.NET.Vulkan.Semaphore;
+
+namespace AcDream.App.Rendering.Gpu.Vk;
+
+///
+/// Campaign V slice V5, plan §4.11: the active half of the Vulkan
+/// capability gate.
+///
+/// Advertisement is not evidence. The GL gate learned that from Linux drivers
+/// that publish an extension string and then return a missing entry point, and
+/// the Vulkan probe is deliberately stronger: it really builds the production
+/// descriptor layouts, really allocates an offscreen colour target, really
+/// records a dynamic-rendering clear through a barrier2 pair, really
+/// submits it against a timeline semaphore, and really reads the pixels back.
+///
+/// Offscreen by construction. Nothing here touches a surface, a
+/// swapchain, or a window. That is what lets the gate run — and be reasoned
+/// about — without opening one, and it is why the probe is a separate type from
+/// VulkanBringUpHost rather than a phase inside it.
+///
+/// Deviation from §4.11, deliberate and scoped to V5: the plan also
+/// wants the probe to build "one real pipeline from the committed
+/// .spv" and render a triangle that samples a table slot. The
+/// .spv toolchain does not exist until slice V6c, so V5 proves everything
+/// the pipeline would have needed — the descriptor-indexing layout, the
+/// three-set pipeline layout, the 96-byte push-constant range, dynamic
+/// rendering, synchronization2, the timeline semaphore, host query reset and
+/// readback — and V6c adds the shader stage on top. Recorded in the slice V5
+/// commit message and in the divergence register.
+///
+internal static unsafe class VulkanActiveDeviceProbe
+{
+ /// Edge of the offscreen target. Small enough to be free, large enough to be a real image.
+ internal const uint ProbeExtent = 64;
+
+ ///
+ /// The clear colour the probe writes and then verifies. Chosen so all four
+ /// channels differ from each other and none is 0 or 255 — a readback that
+ /// silently returns zeroed or saturated memory cannot accidentally match.
+ ///
+ internal static ReadOnlySpan ExpectedClearRgba => [0x33, 0x77, 0xBB, 0xEE];
+
+ internal static VulkanFunctionProbeResult Run(
+ Silk.NET.Vulkan.Vk vk,
+ PhysicalDevice physicalDevice,
+ Device device,
+ Queue graphicsQueue,
+ uint graphicsFamily)
+ {
+ ArgumentNullException.ThrowIfNull(vk);
+ var failures = new List();
+
+ bool descriptorLayout = false;
+ bool pushConstantLayout = false;
+ bool dynamicRendering = false;
+ bool timelineWait = false;
+ bool hostQueryReset = false;
+ bool readback = false;
+
+ DescriptorSetLayout storageLayout = default;
+ DescriptorSetLayout uniformLayout = default;
+ DescriptorSetLayout tableLayout = default;
+ PipelineLayout pipelineLayout = default;
+
+ try
+ {
+ descriptorLayout = Attempt(
+ "descriptor-indexing set layouts",
+ () =>
+ {
+ storageLayout = CreateStorageSetLayout(vk, device);
+ uniformLayout = CreateUniformSetLayout(vk, device);
+ tableLayout = CreateTextureTableSetLayout(vk, device);
+ },
+ failures);
+
+ if (descriptorLayout)
+ {
+ pushConstantLayout = Attempt(
+ "three-set pipeline layout with the 96-byte push-constant block",
+ () => pipelineLayout = CreatePipelineLayout(
+ vk,
+ device,
+ storageLayout,
+ uniformLayout,
+ tableLayout),
+ failures);
+ }
+
+ hostQueryReset = Attempt(
+ "host timestamp query-pool reset",
+ () => ProbeHostQueryReset(vk, device),
+ failures);
+
+ byte[]? pixels = null;
+ dynamicRendering = Attempt(
+ "dynamic-rendering clear, synchronization2 barriers and timeline submit",
+ () => pixels = RenderAndReadBack(vk, physicalDevice, device, graphicsQueue, graphicsFamily),
+ failures);
+ timelineWait = dynamicRendering;
+
+ if (dynamicRendering && pixels is not null)
+ {
+ readback = Attempt(
+ "offscreen readback pixel comparison",
+ () => VerifyClearColour(pixels),
+ failures);
+ }
+ }
+ finally
+ {
+ if (pipelineLayout.Handle != 0)
+ vk.DestroyPipelineLayout(device, pipelineLayout, null);
+ if (tableLayout.Handle != 0)
+ vk.DestroyDescriptorSetLayout(device, tableLayout, null);
+ if (uniformLayout.Handle != 0)
+ vk.DestroyDescriptorSetLayout(device, uniformLayout, null);
+ if (storageLayout.Handle != 0)
+ vk.DestroyDescriptorSetLayout(device, storageLayout, null);
+ }
+
+ return new VulkanFunctionProbeResult(
+ DeviceCreation: true,
+ DescriptorIndexingLayout: descriptorLayout,
+ PushConstantLayout: pushConstantLayout,
+ DynamicRenderingClear: dynamicRendering,
+ TimelineSemaphoreWait: timelineWait,
+ HostQueryReset: hostQueryReset,
+ OffscreenReadback: readback,
+ Failures: failures);
+ }
+
+ private static bool Attempt(string name, Action action, List failures)
+ {
+ try
+ {
+ action();
+ return true;
+ }
+ catch (Exception error)
+ {
+ failures.Add($"{name}: {error.GetType().Name}: {error.Message}");
+ return false;
+ }
+ }
+
+ /// Set 0 — the ten storage bindings GpuBindingModel pins.
+ private static DescriptorSetLayout CreateStorageSetLayout(
+ Silk.NET.Vulkan.Vk vk,
+ Device device)
+ {
+ int count = (int)GpuBindingModel.StorageBindingCount;
+ DescriptorSetLayoutBinding* bindings = stackalloc DescriptorSetLayoutBinding[count];
+ for (int i = 0; i < count; i++)
+ {
+ bindings[i] = new DescriptorSetLayoutBinding
+ {
+ Binding = (uint)i,
+ DescriptorType = DescriptorType.StorageBuffer,
+ DescriptorCount = 1,
+ StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
+ };
+ }
+
+ var create = new DescriptorSetLayoutCreateInfo
+ {
+ SType = StructureType.DescriptorSetLayoutCreateInfo,
+ BindingCount = (uint)count,
+ PBindings = bindings,
+ };
+ VulkanInterop.Check(
+ vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout),
+ "vkCreateDescriptorSetLayout (set 0, storage)");
+ return layout;
+ }
+
+ /// Set 1 — the SceneLighting and terrain-tiling uniform blocks.
+ private static DescriptorSetLayout CreateUniformSetLayout(
+ Silk.NET.Vulkan.Vk vk,
+ Device device)
+ {
+ DescriptorSetLayoutBinding* bindings = stackalloc DescriptorSetLayoutBinding[2];
+ bindings[0] = new DescriptorSetLayoutBinding
+ {
+ Binding = GpuBindingModel.UniformSceneLighting,
+ DescriptorType = DescriptorType.UniformBuffer,
+ DescriptorCount = 1,
+ StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
+ };
+ bindings[1] = new DescriptorSetLayoutBinding
+ {
+ Binding = GpuBindingModel.UniformTerrainTiling,
+ DescriptorType = DescriptorType.UniformBuffer,
+ DescriptorCount = 1,
+ StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
+ };
+
+ var create = new DescriptorSetLayoutCreateInfo
+ {
+ SType = StructureType.DescriptorSetLayoutCreateInfo,
+ BindingCount = 2,
+ PBindings = bindings,
+ };
+ VulkanInterop.Check(
+ vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout),
+ "vkCreateDescriptorSetLayout (set 1, uniform)");
+ return layout;
+ }
+
+ ///
+ /// Set 2 — the production texture table exactly as §4.4 specifies it: one
+ /// combined-image-sampler binding of ,
+ /// partially bound, update-after-bind, update-unused-while-pending, variable
+ /// count. This is the layout the whole bindless replacement rests on, so the
+ /// probe builds the real thing rather than a token one.
+ ///
+ private static DescriptorSetLayout CreateTextureTableSetLayout(
+ Silk.NET.Vulkan.Vk vk,
+ Device device)
+ {
+ var binding = new DescriptorSetLayoutBinding
+ {
+ Binding = GpuBindingModel.TextureTableBinding,
+ DescriptorType = DescriptorType.CombinedImageSampler,
+ DescriptorCount = GpuBindingModel.TextureTableCapacity,
+ StageFlags = ShaderStageFlags.FragmentBit,
+ };
+ DescriptorBindingFlags flags =
+ DescriptorBindingFlags.PartiallyBoundBit
+ | DescriptorBindingFlags.UpdateAfterBindBit
+ | DescriptorBindingFlags.UpdateUnusedWhilePendingBit
+ | DescriptorBindingFlags.VariableDescriptorCountBit;
+
+ var bindingFlags = new DescriptorSetLayoutBindingFlagsCreateInfo
+ {
+ SType = StructureType.DescriptorSetLayoutBindingFlagsCreateInfo,
+ BindingCount = 1,
+ PBindingFlags = &flags,
+ };
+ var create = new DescriptorSetLayoutCreateInfo
+ {
+ SType = StructureType.DescriptorSetLayoutCreateInfo,
+ PNext = &bindingFlags,
+ Flags = DescriptorSetLayoutCreateFlags.UpdateAfterBindPoolBit,
+ BindingCount = 1,
+ PBindings = &binding,
+ };
+ VulkanInterop.Check(
+ vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout),
+ "vkCreateDescriptorSetLayout (set 2, texture table)");
+ return layout;
+ }
+
+ ///
+ /// One shared pipeline layout: three sets plus the single 96-byte push-constant
+ /// block. Creating it proves maxBoundDescriptorSets and
+ /// maxPushConstantsSize for real rather than by reading a limit.
+ ///
+ private static PipelineLayout CreatePipelineLayout(
+ Silk.NET.Vulkan.Vk vk,
+ Device device,
+ DescriptorSetLayout storage,
+ DescriptorSetLayout uniform,
+ DescriptorSetLayout table)
+ {
+ DescriptorSetLayout* sets = stackalloc DescriptorSetLayout[3];
+ sets[0] = storage;
+ sets[1] = uniform;
+ sets[2] = table;
+
+ var pushConstants = new PushConstantRange
+ {
+ StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
+ Offset = 0,
+ Size = GpuBindingModel.PushConstantBytes,
+ };
+ var create = new PipelineLayoutCreateInfo
+ {
+ SType = StructureType.PipelineLayoutCreateInfo,
+ SetLayoutCount = 3,
+ PSetLayouts = sets,
+ PushConstantRangeCount = 1,
+ PPushConstantRanges = &pushConstants,
+ };
+ VulkanInterop.Check(
+ vk.CreatePipelineLayout(device, &create, null, out PipelineLayout layout),
+ "vkCreatePipelineLayout");
+ return layout;
+ }
+
+ ///
+ /// Reset a timestamp pool from the host. This is the whole point of
+ /// hostQueryReset: without it the reset costs a command-buffer call
+ /// in every frame that measures anything.
+ ///
+ private static void ProbeHostQueryReset(Silk.NET.Vulkan.Vk vk, Device device)
+ {
+ var create = new QueryPoolCreateInfo
+ {
+ SType = StructureType.QueryPoolCreateInfo,
+ QueryType = QueryType.Timestamp,
+ QueryCount = 2,
+ };
+ VulkanInterop.Check(
+ vk.CreateQueryPool(device, &create, null, out QueryPool pool),
+ "vkCreateQueryPool");
+ try
+ {
+ vk.ResetQueryPool(device, pool, 0, 2);
+ }
+ finally
+ {
+ vk.DestroyQueryPool(device, pool, null);
+ }
+ }
+
+ ///
+ /// The real submission: clear a 64x64 image through dynamic rendering, move
+ /// it to TRANSFER_SRC with a barrier2, copy it into host-visible memory, and
+ /// wait on a timeline semaphore for the frame serial. Every mechanism the
+ /// production frame skeleton (§4.8) depends on, in miniature.
+ ///
+ private static byte[] RenderAndReadBack(
+ Silk.NET.Vulkan.Vk vk,
+ PhysicalDevice physicalDevice,
+ Device device,
+ Queue queue,
+ uint graphicsFamily)
+ {
+ Image image = default;
+ DeviceMemory imageMemory = default;
+ ImageView view = default;
+ Silk.NET.Vulkan.Buffer readback = default;
+ DeviceMemory readbackMemory = default;
+ CommandPool commandPool = default;
+ Semaphore timeline = default;
+
+ try
+ {
+ (image, imageMemory) = CreateColorTarget(vk, physicalDevice, device);
+ view = CreateImageView(vk, device, image);
+ uint byteCount = ProbeExtent * ProbeExtent * 4;
+ (readback, readbackMemory) = CreateReadbackBuffer(vk, physicalDevice, device, byteCount);
+
+ var poolCreate = new CommandPoolCreateInfo
+ {
+ SType = StructureType.CommandPoolCreateInfo,
+ QueueFamilyIndex = graphicsFamily,
+ Flags = CommandPoolCreateFlags.TransientBit,
+ };
+ VulkanInterop.Check(
+ vk.CreateCommandPool(device, &poolCreate, null, out commandPool),
+ "vkCreateCommandPool");
+
+ var allocate = new CommandBufferAllocateInfo
+ {
+ SType = StructureType.CommandBufferAllocateInfo,
+ CommandPool = commandPool,
+ Level = CommandBufferLevel.Primary,
+ CommandBufferCount = 1,
+ };
+ VulkanInterop.Check(
+ vk.AllocateCommandBuffers(device, &allocate, out CommandBuffer commands),
+ "vkAllocateCommandBuffers");
+
+ RecordClearAndCopy(vk, commands, image, view, readback);
+
+ var semaphoreType = new SemaphoreTypeCreateInfo
+ {
+ SType = StructureType.SemaphoreTypeCreateInfo,
+ SemaphoreType = SemaphoreType.Timeline,
+ InitialValue = 0,
+ };
+ var semaphoreCreate = new SemaphoreCreateInfo
+ {
+ SType = StructureType.SemaphoreCreateInfo,
+ PNext = &semaphoreType,
+ };
+ VulkanInterop.Check(
+ vk.CreateSemaphore(device, &semaphoreCreate, null, out timeline),
+ "vkCreateSemaphore (timeline)");
+
+ var commandSubmit = new CommandBufferSubmitInfo
+ {
+ SType = StructureType.CommandBufferSubmitInfo,
+ CommandBuffer = commands,
+ };
+ var signal = new SemaphoreSubmitInfo
+ {
+ SType = StructureType.SemaphoreSubmitInfo,
+ Semaphore = timeline,
+ Value = 1,
+ StageMask = PipelineStageFlags2.AllCommandsBit,
+ };
+ var submit = new SubmitInfo2
+ {
+ SType = StructureType.SubmitInfo2,
+ CommandBufferInfoCount = 1,
+ PCommandBufferInfos = &commandSubmit,
+ SignalSemaphoreInfoCount = 1,
+ PSignalSemaphoreInfos = &signal,
+ };
+ VulkanInterop.Check(
+ vk.QueueSubmit2(queue, 1, &submit, default),
+ "vkQueueSubmit2");
+
+ ulong waitValue = 1;
+ Semaphore waitSemaphore = timeline;
+ var wait = new SemaphoreWaitInfo
+ {
+ SType = StructureType.SemaphoreWaitInfo,
+ SemaphoreCount = 1,
+ PSemaphores = &waitSemaphore,
+ PValues = &waitValue,
+ };
+ VulkanInterop.Check(
+ vk.WaitSemaphores(device, &wait, 5_000_000_000ul),
+ "vkWaitSemaphores (timeline)");
+
+ void* mapped = null;
+ VulkanInterop.Check(
+ vk.MapMemory(device, readbackMemory, 0, byteCount, 0, &mapped),
+ "vkMapMemory (readback)");
+ try
+ {
+ var pixels = new byte[byteCount];
+ new ReadOnlySpan(mapped, (int)byteCount).CopyTo(pixels);
+ return pixels;
+ }
+ finally
+ {
+ vk.UnmapMemory(device, readbackMemory);
+ }
+ }
+ finally
+ {
+ if (timeline.Handle != 0)
+ vk.DestroySemaphore(device, timeline, null);
+ if (commandPool.Handle != 0)
+ vk.DestroyCommandPool(device, commandPool, null);
+ if (readback.Handle != 0)
+ vk.DestroyBuffer(device, readback, null);
+ if (readbackMemory.Handle != 0)
+ vk.FreeMemory(device, readbackMemory, null);
+ if (view.Handle != 0)
+ vk.DestroyImageView(device, view, null);
+ if (image.Handle != 0)
+ vk.DestroyImage(device, image, null);
+ if (imageMemory.Handle != 0)
+ vk.FreeMemory(device, imageMemory, null);
+ }
+ }
+
+ private static void RecordClearAndCopy(
+ Silk.NET.Vulkan.Vk vk,
+ CommandBuffer commands,
+ Image image,
+ ImageView view,
+ Silk.NET.Vulkan.Buffer readback)
+ {
+ var begin = new CommandBufferBeginInfo
+ {
+ SType = StructureType.CommandBufferBeginInfo,
+ Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
+ };
+ VulkanInterop.Check(vk.BeginCommandBuffer(commands, &begin), "vkBeginCommandBuffer");
+
+ var subresource = new ImageSubresourceRange
+ {
+ AspectMask = ImageAspectFlags.ColorBit,
+ BaseMipLevel = 0,
+ LevelCount = 1,
+ BaseArrayLayer = 0,
+ LayerCount = 1,
+ };
+
+ Barrier(
+ vk,
+ commands,
+ image,
+ subresource,
+ ImageLayout.Undefined,
+ ImageLayout.ColorAttachmentOptimal,
+ PipelineStageFlags2.TopOfPipeBit,
+ AccessFlags2.None,
+ PipelineStageFlags2.ColorAttachmentOutputBit,
+ AccessFlags2.ColorAttachmentWriteBit);
+
+ var clear = new ClearValue
+ {
+ Color = new ClearColorValue
+ {
+ Float32_0 = ExpectedClearRgba[0] / 255f,
+ Float32_1 = ExpectedClearRgba[1] / 255f,
+ Float32_2 = ExpectedClearRgba[2] / 255f,
+ Float32_3 = ExpectedClearRgba[3] / 255f,
+ },
+ };
+ var attachment = new RenderingAttachmentInfo
+ {
+ SType = StructureType.RenderingAttachmentInfo,
+ ImageView = view,
+ ImageLayout = ImageLayout.ColorAttachmentOptimal,
+ LoadOp = AttachmentLoadOp.Clear,
+ StoreOp = AttachmentStoreOp.Store,
+ ClearValue = clear,
+ };
+ var rendering = new RenderingInfo
+ {
+ SType = StructureType.RenderingInfo,
+ RenderArea = new Rect2D(new Offset2D(0, 0), new Extent2D(ProbeExtent, ProbeExtent)),
+ LayerCount = 1,
+ ColorAttachmentCount = 1,
+ PColorAttachments = &attachment,
+ };
+ vk.CmdBeginRendering(commands, &rendering);
+ vk.CmdEndRendering(commands);
+
+ Barrier(
+ vk,
+ commands,
+ image,
+ subresource,
+ ImageLayout.ColorAttachmentOptimal,
+ ImageLayout.TransferSrcOptimal,
+ PipelineStageFlags2.ColorAttachmentOutputBit,
+ AccessFlags2.ColorAttachmentWriteBit,
+ PipelineStageFlags2.CopyBit,
+ AccessFlags2.TransferReadBit);
+
+ var region = new BufferImageCopy
+ {
+ BufferOffset = 0,
+ BufferRowLength = 0,
+ BufferImageHeight = 0,
+ ImageSubresource = new ImageSubresourceLayers
+ {
+ AspectMask = ImageAspectFlags.ColorBit,
+ MipLevel = 0,
+ BaseArrayLayer = 0,
+ LayerCount = 1,
+ },
+ ImageOffset = new Offset3D(0, 0, 0),
+ ImageExtent = new Extent3D(ProbeExtent, ProbeExtent, 1),
+ };
+ vk.CmdCopyImageToBuffer(
+ commands,
+ image,
+ ImageLayout.TransferSrcOptimal,
+ readback,
+ 1,
+ ®ion);
+
+ VulkanInterop.Check(vk.EndCommandBuffer(commands), "vkEndCommandBuffer");
+ }
+
+ private static void Barrier(
+ Silk.NET.Vulkan.Vk vk,
+ CommandBuffer commands,
+ Image image,
+ ImageSubresourceRange subresource,
+ ImageLayout oldLayout,
+ ImageLayout newLayout,
+ PipelineStageFlags2 sourceStage,
+ AccessFlags2 sourceAccess,
+ PipelineStageFlags2 destinationStage,
+ AccessFlags2 destinationAccess)
+ {
+ var barrier = new ImageMemoryBarrier2
+ {
+ SType = StructureType.ImageMemoryBarrier2,
+ SrcStageMask = sourceStage,
+ SrcAccessMask = sourceAccess,
+ DstStageMask = destinationStage,
+ DstAccessMask = destinationAccess,
+ OldLayout = oldLayout,
+ NewLayout = newLayout,
+ SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
+ DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
+ Image = image,
+ SubresourceRange = subresource,
+ };
+ var dependency = new DependencyInfo
+ {
+ SType = StructureType.DependencyInfo,
+ ImageMemoryBarrierCount = 1,
+ PImageMemoryBarriers = &barrier,
+ };
+ vk.CmdPipelineBarrier2(commands, &dependency);
+ }
+
+ private static (Image Image, DeviceMemory Memory) CreateColorTarget(
+ Silk.NET.Vulkan.Vk vk,
+ PhysicalDevice physicalDevice,
+ Device device)
+ {
+ var create = new ImageCreateInfo
+ {
+ SType = StructureType.ImageCreateInfo,
+ ImageType = ImageType.Type2D,
+ Format = Format.R8G8B8A8Unorm,
+ Extent = new Extent3D(ProbeExtent, ProbeExtent, 1),
+ MipLevels = 1,
+ ArrayLayers = 1,
+ Samples = SampleCountFlags.Count1Bit,
+ Tiling = ImageTiling.Optimal,
+ Usage = ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferSrcBit,
+ SharingMode = SharingMode.Exclusive,
+ InitialLayout = ImageLayout.Undefined,
+ };
+ VulkanInterop.Check(
+ vk.CreateImage(device, &create, null, out Image image),
+ "vkCreateImage (probe colour target)");
+
+ vk.GetImageMemoryRequirements(device, image, out MemoryRequirements requirements);
+ DeviceMemory memory = Allocate(
+ vk,
+ physicalDevice,
+ device,
+ requirements,
+ MemoryPropertyFlags.DeviceLocalBit);
+ VulkanInterop.Check(
+ vk.BindImageMemory(device, image, memory, 0),
+ "vkBindImageMemory (probe colour target)");
+ return (image, memory);
+ }
+
+ private static ImageView CreateImageView(
+ Silk.NET.Vulkan.Vk vk,
+ Device device,
+ Image image)
+ {
+ var create = new ImageViewCreateInfo
+ {
+ SType = StructureType.ImageViewCreateInfo,
+ Image = image,
+ ViewType = ImageViewType.Type2D,
+ Format = Format.R8G8B8A8Unorm,
+ SubresourceRange = new ImageSubresourceRange
+ {
+ AspectMask = ImageAspectFlags.ColorBit,
+ BaseMipLevel = 0,
+ LevelCount = 1,
+ BaseArrayLayer = 0,
+ LayerCount = 1,
+ },
+ };
+ VulkanInterop.Check(
+ vk.CreateImageView(device, &create, null, out ImageView view),
+ "vkCreateImageView (probe colour target)");
+ return view;
+ }
+
+ private static (Silk.NET.Vulkan.Buffer Buffer, DeviceMemory Memory) CreateReadbackBuffer(
+ Silk.NET.Vulkan.Vk vk,
+ PhysicalDevice physicalDevice,
+ Device device,
+ uint byteCount)
+ {
+ var create = new BufferCreateInfo
+ {
+ SType = StructureType.BufferCreateInfo,
+ Size = byteCount,
+ Usage = BufferUsageFlags.TransferDstBit,
+ SharingMode = SharingMode.Exclusive,
+ };
+ VulkanInterop.Check(
+ vk.CreateBuffer(device, &create, null, out Silk.NET.Vulkan.Buffer buffer),
+ "vkCreateBuffer (probe readback)");
+
+ vk.GetBufferMemoryRequirements(device, buffer, out MemoryRequirements requirements);
+ DeviceMemory memory = Allocate(
+ vk,
+ physicalDevice,
+ device,
+ requirements,
+ MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit);
+ VulkanInterop.Check(
+ vk.BindBufferMemory(device, buffer, memory, 0),
+ "vkBindBufferMemory (probe readback)");
+ return (buffer, memory);
+ }
+
+ private static DeviceMemory Allocate(
+ Silk.NET.Vulkan.Vk vk,
+ PhysicalDevice physicalDevice,
+ Device device,
+ MemoryRequirements requirements,
+ MemoryPropertyFlags properties)
+ {
+ uint? typeIndex = FindMemoryType(
+ vk,
+ physicalDevice,
+ requirements.MemoryTypeBits,
+ properties);
+ if (typeIndex is not { } index)
+ {
+ throw new NotSupportedException(
+ $"No Vulkan memory type satisfies {properties} for the capability probe.");
+ }
+
+ var allocate = new MemoryAllocateInfo
+ {
+ SType = StructureType.MemoryAllocateInfo,
+ AllocationSize = requirements.Size,
+ MemoryTypeIndex = index,
+ };
+ VulkanInterop.Check(
+ vk.AllocateMemory(device, &allocate, null, out DeviceMemory memory),
+ "vkAllocateMemory (probe)");
+ return memory;
+ }
+
+ ///
+ /// First memory type that is both allowed by the resource and carries every
+ /// requested property. The mask is per-resource,
+ /// which is why this cannot be hoisted to a one-time lookup.
+ ///
+ internal static uint? FindMemoryType(
+ Silk.NET.Vulkan.Vk vk,
+ PhysicalDevice physicalDevice,
+ uint typeBits,
+ MemoryPropertyFlags properties)
+ {
+ vk.GetPhysicalDeviceMemoryProperties(physicalDevice, out PhysicalDeviceMemoryProperties memory);
+ for (uint i = 0; i < memory.MemoryTypeCount && i < 32; i++)
+ {
+ bool allowed = (typeBits & (1u << (int)i)) != 0;
+ if (allowed && memory.MemoryTypes[(int)i].PropertyFlags.HasFlag(properties))
+ return i;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Verify every pixel is the clear colour. Comparing all of them rather than
+ /// a sample is deliberate: a driver that clears only the first tile, or that
+ /// returns a row-padded copy, fails here rather than at V7.
+ ///
+ internal static void VerifyClearColour(ReadOnlySpan pixels)
+ {
+ if (pixels.Length != ProbeExtent * ProbeExtent * 4)
+ {
+ throw new InvalidOperationException(
+ $"the readback returned {pixels.Length} bytes; expected " +
+ $"{ProbeExtent * ProbeExtent * 4}.");
+ }
+
+ for (int i = 0; i < pixels.Length; i += 4)
+ {
+ for (int channel = 0; channel < 4; channel++)
+ {
+ byte actual = pixels[i + channel];
+ byte expected = ExpectedClearRgba[channel];
+ // One least-significant bit of slack: the clear colour is
+ // specified as a float and quantised by the implementation.
+ if (Math.Abs(actual - expected) > 1)
+ {
+ throw new InvalidOperationException(
+ $"pixel {i / 4} channel {channel} read 0x{actual:X2}, expected " +
+ $"0x{expected:X2}.");
+ }
+ }
+ }
+ }
+}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs
new file mode 100644
index 00000000..28cca247
--- /dev/null
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs
@@ -0,0 +1,718 @@
+using AcDream.App.Diagnostics;
+using AcDream.App.Platform;
+using AcDream.App.Rendering;
+using Silk.NET.Core.Native;
+using Silk.NET.Maths;
+using Silk.NET.Vulkan;
+using Silk.NET.Vulkan.Extensions.KHR;
+using Silk.NET.Windowing;
+using Semaphore = Silk.NET.Vulkan.Semaphore;
+
+namespace AcDream.App.Rendering.Gpu.Vk;
+
+///
+/// Campaign V slice V5 — Vulkan bring-up, dark.
+///
+/// Reached only when ACDREAM_RENDER_BACKEND=vulkan. It opens its own
+/// window, creates an instance, a surface, a device and a swapchain, runs the
+/// capability gate, and presents a flat clear colour until the window closes.
+/// Nothing of the game renders through it — the RHI backend lands at V6
+/// and the default flips at V10.
+///
+/// Deliberately a self-contained host rather than a branch woven into
+/// GameWindow's composition: the composition phases each own GL resources
+/// and would have to grow a backend switch apiece for a slice that draws one
+/// colour. GameWindow.Run delegates here in four lines and returns, so the
+/// GL path executes not one new statement.
+///
+/// Untested by the implementing slice. Every line below needs a
+/// window and a driver. The pure decisions it depends on — device ranking,
+/// present-mode mapping, extent and image-count clamping, the capability
+/// accept/reject matrix, the report shape, the BGRA swizzle — are unit-tested
+/// beside it. The remaining gate is manual: "Vulkan boots to a clear
+/// colour."
+///
+internal sealed unsafe class VulkanBringUpHost : IDisposable
+{
+ /// Two frames in flight, matching plan §4.8 and the GL flight controller.
+ internal const int FlightCount = 2;
+
+ ///
+ /// The bring-up clear colour, linear-encoded straight into a UNORM
+ /// swapchain. A deliberate deep blue: black would be indistinguishable from
+ /// an unpainted window, and magenta is reserved as the "unresolved texture
+ /// slot" sentinel everywhere else in this codebase.
+ ///
+ internal static readonly float[] ClearColor = [0.043f, 0.075f, 0.153f, 1f];
+
+ private const string ScreenshotName = "vulkan-bringup";
+ private const ulong AcquireTimeoutNanoseconds = 1_000_000_000ul;
+
+ private readonly RuntimeOptions _options;
+ private readonly GraphicalHostPlatformServices _platform;
+ private readonly FramePacingPolicy _pacing;
+ private readonly Action _log;
+
+ private IWindow? _window;
+ private Silk.NET.Vulkan.Vk? _vk;
+ private Instance _instance;
+ private KhrSurface? _surfaceApi;
+ private SurfaceKHR _surface;
+ private PhysicalDevice _physicalDevice;
+ private Device _device;
+ private KhrSwapchain? _swapchainApi;
+ private Queue _graphicsQueue;
+ private Queue _presentQueue;
+ private VulkanQueueFamilyChoice? _families;
+ private VulkanSwapchain? _swapchain;
+
+ private CommandPool[] _commandPools = [];
+ private CommandBuffer[] _commandBuffers = [];
+ private Semaphore[] _imageAcquired = [];
+ private Semaphore _timeline;
+ private ulong _frameSerial;
+ private bool _recreateAtFrameBoundary;
+ private bool _disposed;
+
+ internal VulkanBringUpHost(
+ RuntimeOptions options,
+ GraphicalHostPlatformServices platform,
+ bool requestedVSync,
+ Action? log = null)
+ {
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ _platform = platform ?? throw new ArgumentNullException(nameof(platform));
+ _log = log ?? Console.WriteLine;
+ // Resolved from the same pure policy the GL path uses, so "VSync on means
+ // FIFO" is one decision expressed once. The monitor refresh is left
+ // unknown because slice V5 consumes only UseVSync: there is no software
+ // pacer here to feed a limit to, and inventing one would be a number
+ // nothing reads. V6 wires FramePacingController and supplies it.
+ _pacing = FramePacingPolicy.Resolve(
+ requestedVSync,
+ _options.UncappedRendering,
+ monitorRefreshHz: null);
+ }
+
+ /// The record the gate evaluated, available after starts.
+ internal VulkanCapabilityRecord? Capabilities { get; private set; }
+
+ ///
+ /// Open the window, pass the capability gate, and present the clear colour
+ /// until the window closes. Throws when
+ /// the gate rejects the device, which Program.cs turns into exit code
+ /// 4 exactly as it does for the GL gate.
+ ///
+ internal void Run()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ CreateWindow();
+ CreateInstanceAndSurface();
+ SelectDeviceAndGate();
+ CreateFrameResources();
+ Present();
+ }
+
+ private void CreateWindow()
+ {
+ var options = WindowOptions.DefaultVulkan with
+ {
+ Size = new Vector2D(1280, 720),
+ Title = "acdream — Vulkan bring-up (Campaign V slice V5)",
+ VSync = _pacing.UseVSync,
+ };
+ _window = Window.Create(options);
+ _window.Initialize();
+ if (_window.VkSurface is null)
+ {
+ throw new NotSupportedException(
+ "The windowing backend did not expose a Vulkan surface. " +
+ "acdream requires GLFW 3.4 built with Vulkan support.");
+ }
+ }
+
+ private void CreateInstanceAndSurface()
+ {
+ IWindow window = _window!;
+ _vk = Silk.NET.Vulkan.Vk.GetApi();
+
+ byte** requiredNames = window.VkSurface!.GetRequiredExtensions(out uint requiredCount);
+ var required = new List((int)requiredCount);
+ for (uint i = 0; i < requiredCount; i++)
+ required.Add(VulkanInterop.ReadString(requiredNames[i]));
+
+ VulkanInstanceFactory.Created instance = VulkanInstanceFactory.Create(
+ _vk,
+ required,
+ enableOptionalExtensions: _options.DevTools);
+ _instance = instance.Instance;
+ InstanceExtensions = instance.EnabledExtensions;
+
+ if (!_vk.TryGetInstanceExtension(_instance, out KhrSurface surfaceApi))
+ {
+ throw new NotSupportedException(
+ "VK_KHR_surface is required but its entry points could not be loaded.");
+ }
+
+ _surfaceApi = surfaceApi;
+ _surface = window.VkSurface.Create(
+ _instance.ToHandle(),
+ null).ToSurface();
+ }
+
+ private IReadOnlyList InstanceExtensions { get; set; } = [];
+
+ private void SelectDeviceAndGate()
+ {
+ Silk.NET.Vulkan.Vk vk = _vk!;
+ IReadOnlyList candidates =
+ VulkanPhysicalDeviceInspector.Enumerate(vk, _instance, out PhysicalDevice[] handles);
+ VulkanPhysicalDeviceChoice? choice = VulkanPhysicalDeviceSelection.Choose(
+ candidates,
+ _options.VulkanDeviceOverride);
+ if (choice is null)
+ {
+ throw new NotSupportedException(
+ "No Vulkan physical device was enumerated. Install or update a " +
+ "Vulkan 1.3 driver for this GPU.");
+ }
+
+ _physicalDevice = handles[choice.Device.Index];
+
+ IReadOnlyList queueFamilies =
+ VulkanPhysicalDeviceInspector.ReadQueueFamilies(
+ vk,
+ _physicalDevice,
+ _surfaceApi,
+ _surface);
+ VulkanQueueFamilyChoice? families = VulkanQueueFamilySelection.Choose(queueFamilies);
+ if (families is null)
+ {
+ throw new NotSupportedException(
+ $"'{choice.Device.DeviceName}' exposes no queue family that can both " +
+ "render and present to the window surface.");
+ }
+
+ _families = families;
+ VulkanLogicalDeviceFactory.Created created = VulkanLogicalDeviceFactory.Create(
+ vk,
+ _physicalDevice,
+ families,
+ requireSwapchain: true);
+ _device = created.Device;
+ _graphicsQueue = created.GraphicsQueue;
+ _presentQueue = created.PresentQueue;
+
+ if (!vk.TryGetDeviceExtension(_instance, _device, out KhrSwapchain swapchainApi))
+ {
+ throw new NotSupportedException(
+ "VK_KHR_swapchain is required but its entry points could not be loaded.");
+ }
+
+ _swapchainApi = swapchainApi;
+ _swapchain = new VulkanSwapchain(
+ vk,
+ _surfaceApi!,
+ swapchainApi,
+ _physicalDevice,
+ _device,
+ _surface,
+ families);
+
+ (SurfaceCapabilitiesKHR surfaceCapabilities,
+ IReadOnlyList formats,
+ IReadOnlyList presentModes) = _swapchain.QuerySurface();
+
+ Vector2D framebuffer = _window!.FramebufferSize;
+ VulkanSwapchainConfiguration planned = VulkanSwapchainConfigurationFactory.Create(
+ surfaceCapabilities,
+ formats,
+ presentModes,
+ _pacing,
+ (uint)Math.Max(0, framebuffer.X),
+ (uint)Math.Max(0, framebuffer.Y));
+
+ var surfaceSupport = new VulkanSurfaceSupport(
+ PresentSupported: true,
+ SelectedFormat: planned.ImageFormat,
+ SelectedColorSpace: planned.ColorSpace,
+ SelectedPresentMode: planned.PresentMode,
+ SelectedImageCount: planned.ImageCount,
+ SelectedWidth: planned.Width,
+ SelectedHeight: planned.Height,
+ SupportsTransferSource:
+ VulkanSwapchainConfigurationFactory.SupportsTransferSource(surfaceCapabilities),
+ AvailableFormats: [.. formats.Select(format => format.Format).Distinct()],
+ AvailablePresentModes: [.. presentModes]);
+
+ VulkanFunctionProbeResult probe = VulkanActiveDeviceProbe.Run(
+ vk,
+ _physicalDevice,
+ _device,
+ _graphicsQueue,
+ families.GraphicsFamily);
+
+ var record = new VulkanCapabilityRecord(
+ DateTimeOffset.UtcNow,
+ _platform.RuntimeIdentifier,
+ _platform.OperatingSystem,
+ _platform.WindowBackend.RequestedProtocol,
+ GlfwNativePlatformProbe.GetActiveProtocol(_platform.OperatingSystem),
+ VulkanApiVersion.Describe(
+ VulkanApiVersion.Make(
+ VulkanCapabilityRequirements.RequiredApiMajor,
+ VulkanCapabilityRequirements.RequiredApiMinor,
+ 0)),
+ VulkanApiVersion.Describe(choice.Device.ApiVersion),
+ choice.Device.ApiVersion,
+ choice.Device.DeviceName,
+ VulkanPhysicalDeviceInspector.DescribeDriver(choice.Device),
+ choice.Device.DeviceType,
+ choice.Device.Index,
+ choice.Reason,
+ _options.VulkanDeviceOverride,
+ ForcedUnsupportedFeature: null,
+ candidates,
+ InstanceExtensions,
+ created.EnabledExtensions,
+ families.GraphicsFamily,
+ families.PresentFamily,
+ VulkanPhysicalDeviceInspector.ReadFeatures(vk, _physicalDevice),
+ VulkanPhysicalDeviceInspector.ReadLimits(vk, _physicalDevice),
+ VulkanPhysicalDeviceInspector.ReadFormats(
+ vk,
+ _physicalDevice,
+ VulkanSwapchainConfigurationFactory.OffersUnormFormat(formats)),
+ surfaceSupport,
+ probe,
+ SupportFailures: []);
+
+ record = VulkanCapabilityRequirements.Reevaluate(record);
+ record = VulkanCapabilityRequirements.ApplyForcedUnsupported(
+ record,
+ _options.VulkanForcedUnsupportedFeature);
+ Capabilities = record;
+
+ string reportPath = Path.Combine(
+ _platform.Paths.DiagnosticsDirectory,
+ VulkanCapabilityGuard.ReportFileName);
+ VulkanCapabilityReportWriter.Write(reportPath, record);
+ VulkanCapabilityGuard.ThrowIfUnsupported(record, reportPath);
+
+ _log(
+ "vulkan: capability gate passed " +
+ $"({record.ActiveDisplayProtocol}, {record.DeviceName}, " +
+ $"{record.DeviceApiVersion}, {record.DriverInfo}); " +
+ $"swapchain {planned.ImageFormat}/{planned.PresentMode} " +
+ $"{planned.Width}x{planned.Height} x{planned.ImageCount}; " +
+ $"report={reportPath}");
+ _log($"vulkan: device selection — {choice.Reason}");
+ }
+
+ private void CreateFrameResources()
+ {
+ Silk.NET.Vulkan.Vk vk = _vk!;
+ VulkanQueueFamilyChoice families = _families!;
+
+ _commandPools = new CommandPool[FlightCount];
+ _commandBuffers = new CommandBuffer[FlightCount];
+ _imageAcquired = new Semaphore[FlightCount];
+ for (int i = 0; i < FlightCount; i++)
+ {
+ var poolCreate = new CommandPoolCreateInfo
+ {
+ SType = StructureType.CommandPoolCreateInfo,
+ QueueFamilyIndex = families.GraphicsFamily,
+ };
+ VulkanInterop.Check(
+ vk.CreateCommandPool(_device, &poolCreate, null, out CommandPool pool),
+ "vkCreateCommandPool (flight slot)");
+ _commandPools[i] = pool;
+
+ var allocate = new CommandBufferAllocateInfo
+ {
+ SType = StructureType.CommandBufferAllocateInfo,
+ CommandPool = pool,
+ Level = CommandBufferLevel.Primary,
+ CommandBufferCount = 1,
+ };
+ VulkanInterop.Check(
+ vk.AllocateCommandBuffers(_device, &allocate, out CommandBuffer commands),
+ "vkAllocateCommandBuffers (flight slot)");
+ _commandBuffers[i] = commands;
+
+ var semaphoreCreate = new SemaphoreCreateInfo
+ {
+ SType = StructureType.SemaphoreCreateInfo,
+ };
+ VulkanInterop.Check(
+ vk.CreateSemaphore(_device, &semaphoreCreate, null, out Semaphore acquired),
+ "vkCreateSemaphore (image acquired)");
+ _imageAcquired[i] = acquired;
+ }
+
+ var timelineType = new SemaphoreTypeCreateInfo
+ {
+ SType = StructureType.SemaphoreTypeCreateInfo,
+ SemaphoreType = SemaphoreType.Timeline,
+ InitialValue = 0,
+ };
+ var timelineCreate = new SemaphoreCreateInfo
+ {
+ SType = StructureType.SemaphoreCreateInfo,
+ PNext = &timelineType,
+ };
+ VulkanInterop.Check(
+ vk.CreateSemaphore(_device, &timelineCreate, null, out _timeline),
+ "vkCreateSemaphore (frame timeline)");
+
+ RecreateSwapchain();
+ }
+
+ private bool RecreateSwapchain()
+ {
+ Vector2D framebuffer = _window!.FramebufferSize;
+ uint width = (uint)Math.Max(0, framebuffer.X);
+ uint height = (uint)Math.Max(0, framebuffer.Y);
+ if (VulkanSwapchainRecreationPolicy.OnFramebufferSize(width, height)
+ == VulkanSwapchainAction.Idle)
+ {
+ return false;
+ }
+
+ VulkanInterop.Check(_vk!.DeviceWaitIdle(_device), "vkDeviceWaitIdle (recreate)");
+ _recreateAtFrameBoundary = false;
+ return _swapchain!.Recreate(_pacing, width, height);
+ }
+
+ private void Present()
+ {
+ IWindow window = _window!;
+ FrameScreenshotController? screenshots = CreateScreenshotController();
+ bool screenshotRequested = false;
+
+ while (!window.IsClosing)
+ {
+ window.DoEvents();
+ if (window.IsClosing)
+ break;
+
+ if (_recreateAtFrameBoundary || !_swapchain!.IsCreated)
+ {
+ if (!RecreateSwapchain())
+ {
+ // Minimised: idle without burning a core, and without
+ // pretending a zero-area swapchain can be created.
+ Thread.Sleep(16);
+ continue;
+ }
+ }
+
+ if (!RenderClearFrame(out uint imageIndex))
+ continue;
+
+ if (screenshots is not null && !screenshotRequested)
+ {
+ screenshotRequested = true;
+ if (screenshots.TryRequest(ScreenshotName, out string error))
+ {
+ VulkanSwapchainConfiguration configuration = _swapchain!.Configuration!;
+ _lastPresentedImage = imageIndex;
+ screenshots.CapturePending(
+ (int)configuration.Width,
+ (int)configuration.Height);
+ }
+ else
+ {
+ _log($"vulkan: screenshot request rejected: {error}");
+ }
+ }
+ }
+
+ VulkanInterop.Check(_vk!.DeviceWaitIdle(_device), "vkDeviceWaitIdle (shutdown)");
+ _log($"vulkan: presented {_frameSerial} clear-colour frame(s); shutting down.");
+ }
+
+ private uint _lastPresentedImage;
+
+ private FrameScreenshotController? CreateScreenshotController()
+ {
+ if (string.IsNullOrWhiteSpace(_options.AutomationArtifactDirectory))
+ return null;
+
+ return new FrameScreenshotController(
+ (_, _) => _swapchain!.CaptureImage(
+ _graphicsQueue,
+ _families!.GraphicsFamily,
+ _lastPresentedImage),
+ _options.AutomationArtifactDirectory,
+ _log);
+ }
+
+ ///
+ /// One clear-colour frame: wait the timeline back to the flight window,
+ /// acquire, record two barriers around a dynamic-rendering clear, submit, and
+ /// present. This is the §4.8 frame skeleton with everything between the
+ /// barriers removed.
+ ///
+ private bool RenderClearFrame(out uint imageIndex)
+ {
+ imageIndex = 0;
+ Silk.NET.Vulkan.Vk vk = _vk!;
+ VulkanSwapchain swapchain = _swapchain!;
+
+ ulong signalValue = _frameSerial + 1;
+ if (signalValue > FlightCount)
+ {
+ ulong waitValue = signalValue - FlightCount;
+ Semaphore timeline = _timeline;
+ var wait = new SemaphoreWaitInfo
+ {
+ SType = StructureType.SemaphoreWaitInfo,
+ SemaphoreCount = 1,
+ PSemaphores = &timeline,
+ PValues = &waitValue,
+ };
+ VulkanInterop.Check(
+ vk.WaitSemaphores(_device, &wait, ulong.MaxValue),
+ "vkWaitSemaphores (frame flight)");
+ }
+
+ int slot = (int)((signalValue - 1) % FlightCount);
+ VulkanSwapchainAction acquired = swapchain.TryAcquire(
+ _imageAcquired[slot],
+ AcquireTimeoutNanoseconds,
+ out imageIndex);
+ switch (acquired)
+ {
+ case VulkanSwapchainAction.RecreateNow:
+ RecreateSwapchain();
+ return false;
+ case VulkanSwapchainAction.Idle:
+ return false;
+ case VulkanSwapchainAction.Fail:
+ throw new InvalidOperationException(
+ "vkAcquireNextImageKHR returned an unrecoverable result.");
+ case VulkanSwapchainAction.RecreateAtFrameBoundary:
+ _recreateAtFrameBoundary = true;
+ break;
+ }
+
+ VulkanInterop.Check(
+ vk.ResetCommandPool(_device, _commandPools[slot], 0),
+ "vkResetCommandPool");
+ RecordClear(_commandBuffers[slot], swapchain, imageIndex);
+
+ var commandSubmit = new CommandBufferSubmitInfo
+ {
+ SType = StructureType.CommandBufferSubmitInfo,
+ CommandBuffer = _commandBuffers[slot],
+ };
+ var waitSemaphore = new SemaphoreSubmitInfo
+ {
+ SType = StructureType.SemaphoreSubmitInfo,
+ Semaphore = _imageAcquired[slot],
+ StageMask = PipelineStageFlags2.ColorAttachmentOutputBit,
+ };
+ SemaphoreSubmitInfo* signals = stackalloc SemaphoreSubmitInfo[2];
+ signals[0] = new SemaphoreSubmitInfo
+ {
+ SType = StructureType.SemaphoreSubmitInfo,
+ Semaphore = swapchain.RenderCompleteAt(imageIndex),
+ StageMask = PipelineStageFlags2.AllCommandsBit,
+ };
+ signals[1] = new SemaphoreSubmitInfo
+ {
+ SType = StructureType.SemaphoreSubmitInfo,
+ Semaphore = _timeline,
+ Value = signalValue,
+ StageMask = PipelineStageFlags2.AllCommandsBit,
+ };
+ var submit = new SubmitInfo2
+ {
+ SType = StructureType.SubmitInfo2,
+ WaitSemaphoreInfoCount = 1,
+ PWaitSemaphoreInfos = &waitSemaphore,
+ CommandBufferInfoCount = 1,
+ PCommandBufferInfos = &commandSubmit,
+ SignalSemaphoreInfoCount = 2,
+ PSignalSemaphoreInfos = signals,
+ };
+ VulkanInterop.Check(
+ vk.QueueSubmit2(_graphicsQueue, 1, &submit, default),
+ "vkQueueSubmit2 (clear frame)");
+ _frameSerial = signalValue;
+
+ VulkanSwapchainAction presented = swapchain.Present(_presentQueue, imageIndex);
+ switch (presented)
+ {
+ case VulkanSwapchainAction.RecreateNow:
+ RecreateSwapchain();
+ return false;
+ case VulkanSwapchainAction.RecreateAtFrameBoundary:
+ _recreateAtFrameBoundary = true;
+ break;
+ case VulkanSwapchainAction.Fail:
+ throw new InvalidOperationException(
+ "vkQueuePresentKHR returned an unrecoverable result.");
+ }
+
+ return true;
+ }
+
+ private void RecordClear(
+ CommandBuffer commands,
+ VulkanSwapchain swapchain,
+ uint imageIndex)
+ {
+ Silk.NET.Vulkan.Vk vk = _vk!;
+ VulkanSwapchainConfiguration configuration = swapchain.Configuration!;
+
+ var begin = new CommandBufferBeginInfo
+ {
+ SType = StructureType.CommandBufferBeginInfo,
+ Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
+ };
+ VulkanInterop.Check(vk.BeginCommandBuffer(commands, &begin), "vkBeginCommandBuffer");
+
+ var subresource = new ImageSubresourceRange
+ {
+ AspectMask = ImageAspectFlags.ColorBit,
+ BaseMipLevel = 0,
+ LevelCount = 1,
+ BaseArrayLayer = 0,
+ LayerCount = 1,
+ };
+ swapchain.TransitionImage(
+ commands,
+ swapchain.ImageAt(imageIndex),
+ subresource,
+ ImageLayout.Undefined,
+ ImageLayout.ColorAttachmentOptimal,
+ PipelineStageFlags2.TopOfPipeBit,
+ AccessFlags2.None,
+ PipelineStageFlags2.ColorAttachmentOutputBit,
+ AccessFlags2.ColorAttachmentWriteBit);
+
+ var clear = new ClearValue
+ {
+ Color = new ClearColorValue
+ {
+ Float32_0 = ClearColor[0],
+ Float32_1 = ClearColor[1],
+ Float32_2 = ClearColor[2],
+ Float32_3 = ClearColor[3],
+ },
+ };
+ var attachment = new RenderingAttachmentInfo
+ {
+ SType = StructureType.RenderingAttachmentInfo,
+ ImageView = swapchain.ViewAt(imageIndex),
+ ImageLayout = ImageLayout.ColorAttachmentOptimal,
+ LoadOp = AttachmentLoadOp.Clear,
+ StoreOp = AttachmentStoreOp.Store,
+ ClearValue = clear,
+ };
+ var rendering = new RenderingInfo
+ {
+ SType = StructureType.RenderingInfo,
+ RenderArea = new Rect2D(
+ new Offset2D(0, 0),
+ new Extent2D(configuration.Width, configuration.Height)),
+ LayerCount = 1,
+ ColorAttachmentCount = 1,
+ PColorAttachments = &attachment,
+ };
+ vk.CmdBeginRendering(commands, &rendering);
+ vk.CmdEndRendering(commands);
+
+ swapchain.TransitionImage(
+ commands,
+ swapchain.ImageAt(imageIndex),
+ subresource,
+ ImageLayout.ColorAttachmentOptimal,
+ ImageLayout.PresentSrcKhr,
+ PipelineStageFlags2.ColorAttachmentOutputBit,
+ AccessFlags2.ColorAttachmentWriteBit,
+ PipelineStageFlags2.BottomOfPipeBit,
+ AccessFlags2.None);
+
+ VulkanInterop.Check(vk.EndCommandBuffer(commands), "vkEndCommandBuffer");
+ }
+
+ ///
+ /// Teardown in strict reverse-construction order. Every handle is checked
+ /// before destruction because can throw at any stage — a
+ /// rejected capability gate is a normal, expected exit, not a crash, and it
+ /// must still leave zero Vulkan objects behind.
+ ///
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+
+ Silk.NET.Vulkan.Vk? vk = _vk;
+ if (vk is not null && _device.Handle != 0)
+ {
+ vk.DeviceWaitIdle(_device);
+
+ _swapchain?.Dispose();
+ _swapchain = null;
+
+ if (_timeline.Handle != 0)
+ {
+ vk.DestroySemaphore(_device, _timeline, null);
+ _timeline = default;
+ }
+
+ foreach (Semaphore semaphore in _imageAcquired)
+ {
+ if (semaphore.Handle != 0)
+ vk.DestroySemaphore(_device, semaphore, null);
+ }
+
+ foreach (CommandPool pool in _commandPools)
+ {
+ if (pool.Handle != 0)
+ vk.DestroyCommandPool(_device, pool, null);
+ }
+
+ _imageAcquired = [];
+ _commandBuffers = [];
+ _commandPools = [];
+
+ vk.DestroyDevice(_device, null);
+ _device = default;
+ }
+ else
+ {
+ _swapchain?.Dispose();
+ _swapchain = null;
+ }
+
+ if (vk is not null && _surfaceApi is not null && _surface.Handle != 0)
+ {
+ _surfaceApi.DestroySurface(_instance, _surface, null);
+ _surface = default;
+ }
+
+ _swapchainApi?.Dispose();
+ _swapchainApi = null;
+ _surfaceApi?.Dispose();
+ _surfaceApi = null;
+
+ if (vk is not null && _instance.Handle != 0)
+ {
+ vk.DestroyInstance(_instance, null);
+ _instance = default;
+ }
+
+ vk?.Dispose();
+ _vk = null;
+
+ _window?.Dispose();
+ _window = null;
+ }
+}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs
new file mode 100644
index 00000000..6374f8a6
--- /dev/null
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs
@@ -0,0 +1,665 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using AcDream.App.Platform;
+using Silk.NET.Vulkan;
+
+namespace AcDream.App.Rendering.Gpu.Vk;
+
+///
+/// Every Vulkan device feature the acdream renderer requires, with the reason
+/// each one is mandatory recorded next to it (Campaign V plan §4.1).
+///
+/// This is a plain data record with no Silk handles in it, which is the point:
+/// the interop layer fills it from vkGetPhysicalDeviceFeatures2, the
+/// forced-unsupported gate knob clears one field of it, and
+/// turns it into operator-facing
+/// sentences — all three testable with no driver, no device, and no window.
+///
+internal sealed record VulkanDeviceFeatureSupport
+{
+ // ---- core 1.0 ----
+
+ /// The three MDI dispatch sites are the entire draw architecture.
+ public required bool MultiDrawIndirect { get; init; }
+
+ /// Indirect commands carry a non-zero firstInstance as the per-group instance base.
+ public required bool DrawIndirectFirstInstance { get; init; }
+
+ /// Phase U.3's per-cell screen-space clip gate writes gl_ClipDistance[8].
+ public required bool ShaderClipDistance { get; init; }
+
+ /// DXT1/3/5 DAT surfaces upload as BC1/2/3 with no transcode.
+ public required bool TextureCompressionBc { get; init; }
+
+ /// Sampler-quality parity with the GL path.
+ public required bool SamplerAnisotropy { get; init; }
+
+ // ---- 1.1 ----
+
+ /// gl_DrawID. Resets per indirect dispatch exactly as GL's does.
+ public required bool ShaderDrawParameters { get; init; }
+
+ // ---- 1.2 ----
+
+ /// One monotonic serial replaces the GL fence array; the retirement ledger keeps its keys.
+ public required bool TimelineSemaphore { get; init; }
+
+ /// Reset timestamp pools from the CPU instead of burning command-buffer calls.
+ public required bool HostQueryReset { get; init; }
+
+ /// The global texture table is a runtime-sized descriptor array.
+ public required bool RuntimeDescriptorArray { get; init; }
+
+ /// Unregistered table slots are legitimately absent rather than an error.
+ public required bool DescriptorBindingPartiallyBound { get; init; }
+
+ /// Texture registration appends a descriptor write without rebuilding the set.
+ public required bool DescriptorBindingSampledImageUpdateAfterBind { get; init; }
+
+ /// A slot may be rewritten while a command buffer that does not read it is pending.
+ public required bool DescriptorBindingUpdateUnusedWhilePending { get; init; }
+
+ /// The table's 16384-slot capacity is a variable descriptor count.
+ public required bool DescriptorBindingVariableDescriptorCount { get; init; }
+
+ ///
+ /// nonuniformEXT in the fragment shaders. Required, not optional:
+ /// within one MDI dispatch different draws read different Batches[]
+ /// entries, and "dynamically uniform" is defined over the whole dispatch on
+ /// some implementations (plan §4.6).
+ ///
+ public required bool ShaderSampledImageArrayNonUniformIndexing { get; init; }
+
+ // ---- 1.3 ----
+
+ /// No render-pass or framebuffer objects anywhere in the frame.
+ public required bool DynamicRendering { get; init; }
+
+ /// Every barrier in the frame skeleton is a vkCmdPipelineBarrier2.
+ public required bool Synchronization2 { get; init; }
+
+ /// Relaxed shader interface rules for the dual-legal GLSL sources.
+ public required bool Maintenance4 { get; init; }
+
+ ///
+ /// Every feature present. The starting point for the forced-unsupported gate
+ /// knob and for tests that assert one specific absence at a time.
+ ///
+ internal static VulkanDeviceFeatureSupport Complete { get; } = new()
+ {
+ MultiDrawIndirect = true,
+ DrawIndirectFirstInstance = true,
+ ShaderClipDistance = true,
+ TextureCompressionBc = true,
+ SamplerAnisotropy = true,
+ ShaderDrawParameters = true,
+ TimelineSemaphore = true,
+ HostQueryReset = true,
+ RuntimeDescriptorArray = true,
+ DescriptorBindingPartiallyBound = true,
+ DescriptorBindingSampledImageUpdateAfterBind = true,
+ DescriptorBindingUpdateUnusedWhilePending = true,
+ DescriptorBindingVariableDescriptorCount = true,
+ ShaderSampledImageArrayNonUniformIndexing = true,
+ DynamicRendering = true,
+ Synchronization2 = true,
+ Maintenance4 = true,
+ };
+
+ ///
+ /// Returns this record with the named feature cleared, or null when
+ /// the name matches no required feature. Drives
+ /// ACDREAM_VULKAN_FORCE_UNSUPPORTED; matching is case-insensitive on
+ /// the property name.
+ ///
+ internal VulkanDeviceFeatureSupport? Without(string featureName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(featureName);
+ return featureName.Trim() switch
+ {
+ var n when Is(n, nameof(MultiDrawIndirect)) => this with { MultiDrawIndirect = false },
+ var n when Is(n, nameof(DrawIndirectFirstInstance)) => this with { DrawIndirectFirstInstance = false },
+ var n when Is(n, nameof(ShaderClipDistance)) => this with { ShaderClipDistance = false },
+ var n when Is(n, nameof(TextureCompressionBc)) => this with { TextureCompressionBc = false },
+ var n when Is(n, nameof(SamplerAnisotropy)) => this with { SamplerAnisotropy = false },
+ var n when Is(n, nameof(ShaderDrawParameters)) => this with { ShaderDrawParameters = false },
+ var n when Is(n, nameof(TimelineSemaphore)) => this with { TimelineSemaphore = false },
+ var n when Is(n, nameof(HostQueryReset)) => this with { HostQueryReset = false },
+ var n when Is(n, nameof(RuntimeDescriptorArray)) => this with { RuntimeDescriptorArray = false },
+ var n when Is(n, nameof(DescriptorBindingPartiallyBound)) => this with { DescriptorBindingPartiallyBound = false },
+ var n when Is(n, nameof(DescriptorBindingSampledImageUpdateAfterBind)) => this with { DescriptorBindingSampledImageUpdateAfterBind = false },
+ var n when Is(n, nameof(DescriptorBindingUpdateUnusedWhilePending)) => this with { DescriptorBindingUpdateUnusedWhilePending = false },
+ var n when Is(n, nameof(DescriptorBindingVariableDescriptorCount)) => this with { DescriptorBindingVariableDescriptorCount = false },
+ var n when Is(n, nameof(ShaderSampledImageArrayNonUniformIndexing)) => this with { ShaderSampledImageArrayNonUniformIndexing = false },
+ var n when Is(n, nameof(DynamicRendering)) => this with { DynamicRendering = false },
+ var n when Is(n, nameof(Synchronization2)) => this with { Synchronization2 = false },
+ var n when Is(n, nameof(Maintenance4)) => this with { Maintenance4 = false },
+ _ => null,
+ };
+
+ static bool Is(string candidate, string name)
+ => string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase);
+ }
+}
+
+///
+/// The device limits the plan asserts up front rather than discovering at draw
+/// time (plan §4.1, §3.4).
+///
+internal sealed record VulkanDeviceLimitSupport
+{
+ /// Must reach ; Vulkan guarantees 128.
+ public required uint MaxPushConstantsSize { get; init; }
+
+ /// Must reach .
+ public required uint MaxClipDistances { get; init; }
+
+ /// Sets 0, 1 and 2 are all bound simultaneously, so at least 3.
+ public required uint MaxBoundDescriptorSets { get; init; }
+
+ /// Must reach .
+ public required uint MaxDescriptorSetUpdateAfterBindSampledImages { get; init; }
+
+ /// Must reach for the fragment stage.
+ public required uint MaxPerStageDescriptorUpdateAfterBindSampledImages { get; init; }
+
+ /// The frame profiler's GPU timings need graphics-queue timestamps.
+ public required bool TimestampComputeAndGraphics { get; init; }
+
+ /// Ring allocations must satisfy this; getting it wrong is a driver error on Vulkan.
+ public required uint MinStorageBufferOffsetAlignment { get; init; }
+
+ /// As above, for the SceneLighting uniform block.
+ public required uint MinUniformBufferOffsetAlignment { get; init; }
+
+ /// Largest 2D image edge; the terrain atlas and composite arrays are sized against it.
+ public required uint MaxImageDimension2D { get; init; }
+
+ /// Highest colour sample count the framebuffer supports, as a plain count (1/2/4/8...).
+ public required uint MaxColorSampleCount { get; init; }
+
+ ///
+ /// A profile that satisfies every requirement, used as the base for tests
+ /// and for the forced-unsupported knob. The numbers are the Vulkan 1.3
+ /// guaranteed minimums where a guarantee exists, and the acdream requirement
+ /// where it does not.
+ ///
+ internal static VulkanDeviceLimitSupport Complete { get; } = new()
+ {
+ MaxPushConstantsSize = GpuBindingModel.MaxPushConstantBytes,
+ MaxClipDistances = GpuBindingModel.ClipPlanesPerSlot,
+ MaxBoundDescriptorSets = 4,
+ MaxDescriptorSetUpdateAfterBindSampledImages = GpuBindingModel.TextureTableCapacity,
+ MaxPerStageDescriptorUpdateAfterBindSampledImages = GpuBindingModel.TextureTableCapacity,
+ TimestampComputeAndGraphics = true,
+ MinStorageBufferOffsetAlignment = 256,
+ MinUniformBufferOffsetAlignment = 256,
+ MaxImageDimension2D = 16384,
+ MaxColorSampleCount = 8,
+ };
+}
+
+///
+/// Format-level support that features alone do not prove: the swapchain colour
+/// format, a depth+stencil format for #117's portal punch, and the three BC
+/// blocks the DAT surfaces upload as.
+///
+internal sealed record VulkanFormatSupport
+{
+ ///
+ /// B8G8R8A8_UNORM is offered by the surface. Corrected at slice V3:
+ /// the renderer is plain UNORM end to end and an _SRGB swapchain
+ /// would apply an unwanted encode to already-display-space values.
+ ///
+ public required bool SwapchainUnormFormat { get; init; }
+
+ /// The chosen depth+stencil format, or when none is usable.
+ public required Format DepthStencilFormat { get; init; }
+
+ /// BC1 (DXT1) sampled-image support with optimal tiling.
+ public required bool Bc1Sampled { get; init; }
+
+ /// BC2 (DXT3) sampled-image support with optimal tiling.
+ public required bool Bc2Sampled { get; init; }
+
+ /// BC3 (DXT5) sampled-image support with optimal tiling.
+ public required bool Bc3Sampled { get; init; }
+
+ internal static VulkanFormatSupport Complete { get; } = new()
+ {
+ SwapchainUnormFormat = true,
+ DepthStencilFormat = Format.D32SfloatS8Uint,
+ Bc1Sampled = true,
+ Bc2Sampled = true,
+ Bc3Sampled = true,
+ };
+}
+
+///
+/// What the presentation surface offers, and what slice V5 selected from it.
+/// Null on the record when the device was probed headlessly (no window), which
+/// is how the capability logic stays testable without opening one.
+///
+internal sealed record VulkanSurfaceSupport(
+ bool PresentSupported,
+ Format SelectedFormat,
+ ColorSpaceKHR SelectedColorSpace,
+ PresentModeKHR SelectedPresentMode,
+ uint SelectedImageCount,
+ uint SelectedWidth,
+ uint SelectedHeight,
+ bool SupportsTransferSource,
+ IReadOnlyList AvailableFormats,
+ IReadOnlyList AvailablePresentModes);
+
+///
+/// One physical device as the selector sees it. Ordering and tie-breaking are
+/// computed from these fields alone (plan §4.11), so the ranking is unit-tested
+/// without enumerating a real instance.
+///
+internal sealed record VulkanPhysicalDeviceCandidate(
+ int Index,
+ string DeviceName,
+ PhysicalDeviceType DeviceType,
+ uint ApiVersion,
+ uint DriverVersion,
+ uint VendorId,
+ uint DeviceId,
+ ulong DeviceLocalHeapBytes);
+
+///
+/// Result of the active Vulkan probe. Mirrors
+/// GraphicalFunctionProbeResult: advertisement is not evidence, so the
+/// probe really creates the device, the descriptor layouts, an offscreen target,
+/// and a submitted command buffer, then reads pixels back.
+///
+internal sealed record VulkanFunctionProbeResult(
+ bool DeviceCreation,
+ bool DescriptorIndexingLayout,
+ bool PushConstantLayout,
+ bool DynamicRenderingClear,
+ bool TimelineSemaphoreWait,
+ bool HostQueryReset,
+ bool OffscreenReadback,
+ IReadOnlyList Failures)
+{
+ internal static VulkanFunctionProbeResult NotRun { get; } = new(
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ ["active Vulkan device probe did not run"]);
+}
+
+///
+/// Campaign V slice V5 — the Vulkan sibling of
+/// GraphicalCapabilityRecord. Passive capture plus the active probe
+/// result plus the derived failure list, written atomically to
+/// graphical-capabilities-vulkan.json and turned into the same
+/// → exit-code-4 contract.
+///
+internal sealed record VulkanCapabilityRecord(
+ DateTimeOffset CapturedAtUtc,
+ string RuntimeIdentifier,
+ GraphicalHostOperatingSystem OperatingSystem,
+ GraphicalDisplayProtocol RequestedDisplayProtocol,
+ GraphicalDisplayProtocol ActiveDisplayProtocol,
+ string InstanceApiVersion,
+ string DeviceApiVersion,
+ uint DeviceApiVersionPacked,
+ string DeviceName,
+ string DriverInfo,
+ PhysicalDeviceType DeviceType,
+ int SelectedDeviceIndex,
+ string DeviceSelectionReason,
+ string? RequestedDeviceOverride,
+ string? ForcedUnsupportedFeature,
+ IReadOnlyList AvailableDevices,
+ IReadOnlyList InstanceExtensions,
+ IReadOnlyList DeviceExtensions,
+ uint GraphicsQueueFamily,
+ uint PresentQueueFamily,
+ VulkanDeviceFeatureSupport Features,
+ VulkanDeviceLimitSupport Limits,
+ VulkanFormatSupport Formats,
+ VulkanSurfaceSupport? Surface,
+ VulkanFunctionProbeResult FunctionProbe,
+ IReadOnlyList SupportFailures)
+{
+ internal bool IsSupported => SupportFailures.Count == 0;
+
+ ///
+ /// Project onto the backend-neutral contract the renderers consult. The
+ /// alignment fields carry across verbatim because ring allocations are
+ /// validated against them, and SupportsPersistentlyMappedRings is
+ /// unconditionally true: writing per-frame data straight into mapped memory
+ /// is the mechanism behind Campaign V's CPU-cost target (plan §4.3).
+ ///
+ internal GpuCapabilityRecord ToGpuCapabilityRecord() => new()
+ {
+ Backend = GpuBackendKind.Vulkan,
+ DeviceName = DeviceName,
+ DriverInfo = DriverInfo,
+ ApiVersion = DeviceApiVersion,
+ MaxTextureTableSlots =
+ Math.Min(
+ Limits.MaxDescriptorSetUpdateAfterBindSampledImages,
+ Limits.MaxPerStageDescriptorUpdateAfterBindSampledImages),
+ // Sets 0..2 give each binding its own namespace, so the ten storage
+ // bindings the model declares are always all available once the set
+ // count requirement passes. There is no per-set binding-count limit in
+ // Vulkan below maxPerStageDescriptorStorageBuffers, which is far higher.
+ MaxStorageBufferBindings = GpuBindingModel.StorageBindingCount,
+ MaxPushConstantBytes = Limits.MaxPushConstantsSize,
+ MinStorageBufferOffsetAlignment = Limits.MinStorageBufferOffsetAlignment,
+ MinUniformBufferOffsetAlignment = Limits.MinUniformBufferOffsetAlignment,
+ MaxClipDistances = Limits.MaxClipDistances,
+ MaxSampleCount = Limits.MaxColorSampleCount,
+ SupportsMultiDrawIndirect = Features.MultiDrawIndirect,
+ SupportsDrawParameters = Features.ShaderDrawParameters,
+ SupportsTextureCompressionBc = Features.TextureCompressionBc,
+ SupportsTimestampQueries = Limits.TimestampComputeAndGraphics,
+ SupportsPersistentlyMappedRings = true,
+ };
+}
+
+///
+/// Turns a captured into operator-facing
+/// failure sentences. Deliberately the exact shape of
+/// GraphicalCapabilityRequirements.Evaluate, and deliberately NOT
+/// carrying its stale sRGB-framebuffer requirement forward: slice V3 established
+/// that the renderer never enables sRGB encoding anywhere (plan §4.10).
+///
+internal static class VulkanCapabilityRequirements
+{
+ /// Vulkan 1.3 is the floor; nothing below it is considered.
+ internal const uint RequiredApiMajor = 1;
+
+ /// Vulkan 1.3 is the floor; nothing below it is considered.
+ internal const uint RequiredApiMinor = 3;
+
+ internal static IReadOnlyList Evaluate(VulkanCapabilityRecord capabilities)
+ {
+ ArgumentNullException.ThrowIfNull(capabilities);
+ var failures = new List();
+
+ uint major = VulkanApiVersion.Major(capabilities.DeviceApiVersionPacked);
+ uint minor = VulkanApiVersion.Minor(capabilities.DeviceApiVersionPacked);
+ if (major < RequiredApiMajor || (major == RequiredApiMajor && minor < RequiredApiMinor))
+ {
+ failures.Add(
+ $"Vulkan {RequiredApiMajor}.{RequiredApiMinor} is required; " +
+ $"the selected device reports {major}.{minor}.");
+ }
+
+ VulkanDeviceFeatureSupport features = capabilities.Features;
+ if (!features.MultiDrawIndirect)
+ failures.Add("multiDrawIndirect is required to submit world geometry.");
+ if (!features.DrawIndirectFirstInstance)
+ failures.Add("drawIndirectFirstInstance is required; indirect commands carry a per-group instance base.");
+ if (!features.ShaderDrawParameters)
+ failures.Add("shaderDrawParameters (gl_DrawID) is required to select per-draw batch data.");
+ if (!features.ShaderClipDistance)
+ failures.Add("shaderClipDistance is required by the per-cell clip gate.");
+ if (!features.TextureCompressionBc)
+ failures.Add("textureCompressionBC is required to upload DAT surfaces without transcoding.");
+ if (!features.SamplerAnisotropy)
+ failures.Add("samplerAnisotropy is required for sampler-quality parity.");
+ if (!features.TimelineSemaphore)
+ failures.Add("timelineSemaphore is required; the frame serial is the semaphore value.");
+ if (!features.HostQueryReset)
+ failures.Add("hostQueryReset is required to reset timestamp pools from the CPU.");
+ if (!features.RuntimeDescriptorArray)
+ failures.Add("runtimeDescriptorArray is required by the global texture table.");
+ if (!features.DescriptorBindingPartiallyBound)
+ failures.Add("descriptorBindingPartiallyBound is required; unregistered texture slots are legitimately absent.");
+ if (!features.DescriptorBindingSampledImageUpdateAfterBind)
+ failures.Add("descriptorBindingSampledImageUpdateAfterBind is required to register textures without rebuilding the set.");
+ if (!features.DescriptorBindingUpdateUnusedWhilePending)
+ failures.Add("descriptorBindingUpdateUnusedWhilePending is required to recycle texture slots while frames are in flight.");
+ if (!features.DescriptorBindingVariableDescriptorCount)
+ failures.Add("descriptorBindingVariableDescriptorCount is required to size the texture table.");
+ if (!features.ShaderSampledImageArrayNonUniformIndexing)
+ failures.Add("shaderSampledImageArrayNonUniformIndexing is required; one indirect dispatch reads different texture slots per draw.");
+ if (!features.DynamicRendering)
+ failures.Add("dynamicRendering is required; the frame uses no render-pass or framebuffer objects.");
+ if (!features.Synchronization2)
+ failures.Add("synchronization2 is required; every barrier in the frame is a barrier2.");
+ if (!features.Maintenance4)
+ failures.Add("maintenance4 is required for the relaxed shader interface rules the shared GLSL relies on.");
+
+ VulkanDeviceLimitSupport limits = capabilities.Limits;
+ if (limits.MaxPushConstantsSize < GpuBindingModel.PushConstantBytes)
+ {
+ failures.Add(
+ $"{GpuBindingModel.PushConstantBytes} push-constant bytes are required; " +
+ $"this device provides {limits.MaxPushConstantsSize}.");
+ }
+ if (limits.MaxClipDistances < GpuBindingModel.ClipPlanesPerSlot)
+ {
+ failures.Add(
+ $"{GpuBindingModel.ClipPlanesPerSlot} clip distances are required by the per-cell clip gate; " +
+ $"this device provides {limits.MaxClipDistances}.");
+ }
+ if (limits.MaxBoundDescriptorSets < VulkanDescriptorSetCount)
+ {
+ failures.Add(
+ $"{VulkanDescriptorSetCount} simultaneously bound descriptor sets are required " +
+ $"(storage, uniform, texture table); this device provides {limits.MaxBoundDescriptorSets}.");
+ }
+ if (limits.MaxDescriptorSetUpdateAfterBindSampledImages < GpuBindingModel.TextureTableCapacity)
+ {
+ failures.Add(
+ $"the texture table needs {GpuBindingModel.TextureTableCapacity} update-after-bind sampled images; " +
+ $"this device provides {limits.MaxDescriptorSetUpdateAfterBindSampledImages} per set.");
+ }
+ if (limits.MaxPerStageDescriptorUpdateAfterBindSampledImages < GpuBindingModel.TextureTableCapacity)
+ {
+ failures.Add(
+ $"the texture table needs {GpuBindingModel.TextureTableCapacity} update-after-bind sampled images " +
+ $"in the fragment stage; this device provides {limits.MaxPerStageDescriptorUpdateAfterBindSampledImages}.");
+ }
+ if (!limits.TimestampComputeAndGraphics)
+ failures.Add("graphics-queue timestamps are required by the frame profiler.");
+
+ VulkanFormatSupport formats = capabilities.Formats;
+ if (!formats.SwapchainUnormFormat)
+ {
+ failures.Add(
+ "the presentation surface must offer B8G8R8A8_UNORM; the renderer is " +
+ "plain UNORM end to end and an sRGB swapchain would re-encode every frame.");
+ }
+ if (formats.DepthStencilFormat == Format.Undefined)
+ failures.Add("a combined depth+stencil format is required by the portal aperture punch.");
+ if (!formats.Bc1Sampled || !formats.Bc2Sampled || !formats.Bc3Sampled)
+ failures.Add("BC1, BC2 and BC3 sampled-image support is required to upload DAT surfaces.");
+
+ if (capabilities.Surface is { } surface)
+ {
+ if (!surface.PresentSupported)
+ failures.Add("the selected device cannot present to the window surface.");
+ if (!surface.SupportsTransferSource)
+ failures.Add("the swapchain must support TRANSFER_SRC usage for screenshot capture.");
+ }
+
+ if (capabilities.FunctionProbe.Failures.Count != 0)
+ {
+ failures.AddRange(
+ capabilities.FunctionProbe.Failures.Select(
+ failure => $"Vulkan device probe: {failure}"));
+ }
+ else
+ {
+ if (!capabilities.FunctionProbe.DeviceCreation)
+ failures.Add("the Vulkan device-creation probe did not pass.");
+ if (!capabilities.FunctionProbe.DescriptorIndexingLayout)
+ failures.Add("the descriptor-indexing layout probe did not pass.");
+ if (!capabilities.FunctionProbe.PushConstantLayout)
+ failures.Add("the push-constant pipeline-layout probe did not pass.");
+ if (!capabilities.FunctionProbe.DynamicRenderingClear)
+ failures.Add("the dynamic-rendering clear probe did not pass.");
+ if (!capabilities.FunctionProbe.TimelineSemaphoreWait)
+ failures.Add("the timeline-semaphore wait probe did not pass.");
+ if (!capabilities.FunctionProbe.HostQueryReset)
+ failures.Add("the host query-reset probe did not pass.");
+ if (!capabilities.FunctionProbe.OffscreenReadback)
+ failures.Add("the offscreen readback probe did not return the expected pixels.");
+ }
+
+ return failures;
+ }
+
+ ///
+ /// Sets 0 (storage), 1 (uniform) and 2 (texture table) are bound at once,
+ /// so maxBoundDescriptorSets must reach 3. Vulkan guarantees 4.
+ ///
+ internal const uint VulkanDescriptorSetCount =
+ GpuBindingModel.TextureTableSet + 1;
+
+ ///
+ /// Re-derive after any
+ /// mutation. Mirrors GraphicalCapabilityProbe.WithFunctionProbe: the
+ /// record is never trusted to carry a stale failure list.
+ ///
+ internal static VulkanCapabilityRecord Reevaluate(VulkanCapabilityRecord capabilities)
+ {
+ ArgumentNullException.ThrowIfNull(capabilities);
+ VulkanCapabilityRecord cleared = capabilities with { SupportFailures = [] };
+ return cleared with { SupportFailures = Evaluate(cleared) };
+ }
+
+ ///
+ /// Apply ACDREAM_VULKAN_FORCE_UNSUPPORTED. An unrecognised name is a
+ /// hard failure rather than a silent no-op: a gate knob that quietly does
+ /// nothing would report a pass the operator did not actually get.
+ ///
+ internal static VulkanCapabilityRecord ApplyForcedUnsupported(
+ VulkanCapabilityRecord capabilities,
+ string? featureName)
+ {
+ ArgumentNullException.ThrowIfNull(capabilities);
+ if (string.IsNullOrWhiteSpace(featureName))
+ return capabilities;
+
+ VulkanDeviceFeatureSupport? forced = capabilities.Features.Without(featureName);
+ if (forced is null)
+ {
+ return Reevaluate(
+ capabilities with
+ {
+ ForcedUnsupportedFeature = featureName,
+ FunctionProbe = capabilities.FunctionProbe with
+ {
+ Failures =
+ [
+ .. capabilities.FunctionProbe.Failures,
+ $"ACDREAM_VULKAN_FORCE_UNSUPPORTED named '{featureName}', " +
+ "which is not a required Vulkan feature.",
+ ],
+ },
+ });
+ }
+
+ return Reevaluate(
+ capabilities with
+ {
+ Features = forced,
+ ForcedUnsupportedFeature = featureName,
+ });
+ }
+}
+
+/// Packed VK_MAKE_API_VERSION arithmetic, kept out of the interop layer so it is testable.
+internal static class VulkanApiVersion
+{
+ internal static uint Major(uint packed) => (packed >> 22) & 0x7Fu;
+
+ internal static uint Minor(uint packed) => (packed >> 12) & 0x3FFu;
+
+ internal static uint Patch(uint packed) => packed & 0xFFFu;
+
+ internal static uint Make(uint major, uint minor, uint patch)
+ => (major << 22) | (minor << 12) | patch;
+
+ internal static string Describe(uint packed)
+ => $"Vulkan {Major(packed)}.{Minor(packed)}.{Patch(packed)}";
+}
+
+///
+/// The same throw/format/report contract the GL gate publishes: an unsupported
+/// device raises , which Program.cs
+/// turns into exit code 4 next to the written report.
+///
+internal static class VulkanCapabilityGuard
+{
+ /// File name of the Vulkan report, beside the GL one in the diagnostics directory.
+ internal const string ReportFileName = "graphical-capabilities-vulkan.json";
+
+ internal static void ThrowIfUnsupported(
+ VulkanCapabilityRecord capabilities,
+ string reportPath)
+ {
+ ArgumentNullException.ThrowIfNull(capabilities);
+ if (!capabilities.IsSupported)
+ throw new NotSupportedException(FormatUnsupportedMessage(capabilities, reportPath));
+ }
+
+ internal static string FormatUnsupportedMessage(
+ VulkanCapabilityRecord capabilities,
+ string reportPath)
+ {
+ ArgumentNullException.ThrowIfNull(capabilities);
+ ArgumentException.ThrowIfNullOrWhiteSpace(reportPath);
+ return
+ "acdream's Vulkan renderer is unsupported by the selected device.\n" +
+ $"Platform: {capabilities.RuntimeIdentifier}, " +
+ $"{capabilities.ActiveDisplayProtocol}, " +
+ $"{capabilities.DeviceName} ({capabilities.DeviceType}), " +
+ $"{capabilities.DeviceApiVersion}, {capabilities.DriverInfo}\n" +
+ string.Join(
+ "\n",
+ capabilities.SupportFailures.Select(failure => $" - {failure}")) +
+ $"\nFull capability report: {Path.GetFullPath(reportPath)}";
+ }
+}
+
+///
+/// Atomic JSON writer, byte-for-byte the same temp-then-move contract as
+/// GraphicalCapabilityReportWriter so a crashed launch never leaves a
+/// half-written report behind.
+///
+internal static class VulkanCapabilityReportWriter
+{
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ WriteIndented = true,
+ Converters =
+ {
+ new JsonStringEnumConverter(),
+ },
+ };
+
+ internal static void Write(string path, VulkanCapabilityRecord capabilities)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ ArgumentNullException.ThrowIfNull(capabilities);
+ string fullPath = Path.GetFullPath(path);
+ string? directory = Path.GetDirectoryName(fullPath);
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+
+ string temporaryPath = fullPath + ".tmp";
+ File.WriteAllText(temporaryPath, Serialize(capabilities));
+ File.Move(temporaryPath, fullPath, overwrite: true);
+ }
+
+ /// Exposed so the report's shape can be asserted without touching the file system.
+ internal static string Serialize(VulkanCapabilityRecord capabilities)
+ {
+ ArgumentNullException.ThrowIfNull(capabilities);
+ return JsonSerializer.Serialize(capabilities, Options);
+ }
+}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanExtensionSelection.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanExtensionSelection.cs
new file mode 100644
index 00000000..69242e0d
--- /dev/null
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanExtensionSelection.cs
@@ -0,0 +1,94 @@
+namespace AcDream.App.Rendering.Gpu.Vk;
+
+/// The extensions actually asked for, plus the optional ones that were not available.
+internal sealed record VulkanExtensionPlan(
+ IReadOnlyList Enabled,
+ IReadOnlyList MissingRequired,
+ IReadOnlyList UnavailableOptional)
+{
+ internal bool IsSatisfied => MissingRequired.Count == 0;
+}
+
+///
+/// Campaign V slice V5, plan §4.1: "Vulkan 1.3 core plus VK_KHR_swapchain
+/// (and the platform surface extensions). Optional and never required:
+/// VK_EXT_memory_budget (telemetry), VK_EXT_debug_utils (object
+/// naming in dev builds), VK_KHR_present_wait (an issue #235
+/// experiment)."
+///
+/// Pure set arithmetic over extension name lists, so "an optional extension that
+/// is missing must not stop startup, and a required one must" is a unit test
+/// rather than a driver-dependent discovery.
+///
+internal static class VulkanExtensionSelection
+{
+ /// Object naming for RenderDoc and validation output. Never required.
+ internal const string DebugUtilsExtension = "VK_EXT_debug_utils";
+
+ /// Real allocator headroom for GpuMemoryTracker. Never required.
+ internal const string MemoryBudgetExtension = "VK_EXT_memory_budget";
+
+ /// An issue #235 present-pacing experiment. Never required.
+ internal const string PresentWaitExtension = "VK_KHR_present_wait";
+
+ /// Presenting to a surface. Required on the real device; absent in the headless probe.
+ internal const string SwapchainExtension = "VK_KHR_swapchain";
+
+ ///
+ /// Resolve a plan. comes from Silk's
+ /// IVkSurface.GetRequiredExtensions for the instance and is a fixed
+ /// list for the device; is enabled only where
+ /// advertised. Comparison is ordinal — Vulkan extension names are ASCII and
+ /// case-sensitive.
+ ///
+ internal static VulkanExtensionPlan Resolve(
+ IReadOnlyList available,
+ IReadOnlyList required,
+ IReadOnlyList optional)
+ {
+ ArgumentNullException.ThrowIfNull(available);
+ ArgumentNullException.ThrowIfNull(required);
+ ArgumentNullException.ThrowIfNull(optional);
+
+ var advertised = new HashSet(available, StringComparer.Ordinal);
+ var enabled = new List();
+ var missing = new List();
+ var unavailable = new List();
+
+ foreach (string name in required)
+ {
+ if (advertised.Contains(name))
+ {
+ if (!enabled.Contains(name, StringComparer.Ordinal))
+ enabled.Add(name);
+ }
+ else if (!missing.Contains(name, StringComparer.Ordinal))
+ {
+ missing.Add(name);
+ }
+ }
+
+ foreach (string name in optional)
+ {
+ if (advertised.Contains(name))
+ {
+ if (!enabled.Contains(name, StringComparer.Ordinal))
+ enabled.Add(name);
+ }
+ else if (!unavailable.Contains(name, StringComparer.Ordinal))
+ {
+ unavailable.Add(name);
+ }
+ }
+
+ return new VulkanExtensionPlan(enabled, missing, unavailable);
+ }
+
+ /// The optional instance extensions slice V5 will take if offered.
+ internal static IReadOnlyList OptionalInstanceExtensions { get; } =
+ [DebugUtilsExtension];
+
+ /// The optional device extensions slice V5 will take if offered.
+ internal static IReadOnlyList OptionalDeviceExtensions { get; } =
+ [MemoryBudgetExtension, PresentWaitExtension];
+}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanInterop.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanInterop.cs
new file mode 100644
index 00000000..df359f5d
--- /dev/null
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanInterop.cs
@@ -0,0 +1,635 @@
+using Silk.NET.Core;
+using Silk.NET.Core.Native;
+using Silk.NET.Vulkan;
+
+namespace AcDream.App.Rendering.Gpu.Vk;
+
+/// Raised when a Vulkan entry point returns a failure code.
+internal sealed class VulkanCallException : InvalidOperationException
+{
+ internal VulkanCallException(string operation, Result result)
+ : base($"Vulkan call failed: {operation} returned {result}.")
+ {
+ Operation = operation;
+ Result = result;
+ }
+
+ internal string Operation { get; }
+
+ internal Result Result { get; }
+}
+
+///
+/// Small marshalling and error-checking helpers shared by the slice V5 interop
+/// files. Deliberately tiny: the interesting decisions all live in the pure
+/// selection types beside this one, which is what lets them be unit-tested with
+/// no driver present.
+///
+internal static unsafe class VulkanInterop
+{
+ /// Throw with the operation named when a call did not succeed.
+ internal static void Check(Result result, string operation)
+ {
+ if (result != Result.Success)
+ throw new VulkanCallException(operation, result);
+ }
+
+ /// Read a NUL-terminated ASCII field out of a Vulkan struct.
+ internal static string ReadString(byte* value)
+ => value is null ? string.Empty : SilkMarshal.PtrToString((nint)value) ?? string.Empty;
+
+ ///
+ /// Encode a managed string list as the const char* const* Vulkan
+ /// expects. The returned handle owns the native memory and must be freed.
+ ///
+ internal static nint AllocateStringArray(IReadOnlyList values)
+ => SilkMarshal.StringArrayToPtr(values.ToArray());
+
+ internal static void FreeStringArray(nint handle)
+ {
+ if (handle != 0)
+ SilkMarshal.Free(handle);
+ }
+
+ /// Enumerate the instance extension names the loader advertises.
+ internal static IReadOnlyList EnumerateInstanceExtensions(Silk.NET.Vulkan.Vk vk)
+ {
+ ArgumentNullException.ThrowIfNull(vk);
+ uint count = 0;
+ Check(
+ vk.EnumerateInstanceExtensionProperties((byte*)null, ref count, null),
+ "vkEnumerateInstanceExtensionProperties (count)");
+ if (count == 0)
+ return [];
+
+ var properties = new ExtensionProperties[count];
+ fixed (ExtensionProperties* first = properties)
+ {
+ Check(
+ vk.EnumerateInstanceExtensionProperties((byte*)null, ref count, first),
+ "vkEnumerateInstanceExtensionProperties");
+ }
+
+ return ReadNames(properties, count);
+ }
+
+ /// Enumerate the device extension names one physical device advertises.
+ internal static IReadOnlyList EnumerateDeviceExtensions(
+ Silk.NET.Vulkan.Vk vk,
+ PhysicalDevice device)
+ {
+ ArgumentNullException.ThrowIfNull(vk);
+ uint count = 0;
+ Check(
+ vk.EnumerateDeviceExtensionProperties(device, (byte*)null, ref count, null),
+ "vkEnumerateDeviceExtensionProperties (count)");
+ if (count == 0)
+ return [];
+
+ var properties = new ExtensionProperties[count];
+ fixed (ExtensionProperties* first = properties)
+ {
+ Check(
+ vk.EnumerateDeviceExtensionProperties(device, (byte*)null, ref count, first),
+ "vkEnumerateDeviceExtensionProperties");
+ }
+
+ return ReadNames(properties, count);
+ }
+
+ private static IReadOnlyList ReadNames(ExtensionProperties[] properties, uint count)
+ {
+ var names = new List((int)count);
+ for (uint i = 0; i < count && i < properties.Length; i++)
+ {
+ ExtensionProperties entry = properties[i];
+ names.Add(ReadString(entry.ExtensionName));
+ }
+
+ names.Sort(StringComparer.Ordinal);
+ return names;
+ }
+}
+
+///
+/// Slice V5 interop: create the Vulkan instance.
+///
+/// The instance is created at API version 1.3 because the whole feature floor is
+/// core-1.3; a loader that cannot honour that fails here with a clear message
+/// rather than producing a device that silently lacks
+/// dynamicRendering.
+///
+internal sealed unsafe class VulkanInstanceFactory
+{
+ internal sealed record Created(
+ Instance Instance,
+ IReadOnlyList EnabledExtensions,
+ IReadOnlyList UnavailableOptionalExtensions,
+ uint ApiVersion);
+
+ ///
+ /// Create the instance. comes from
+ /// Silk's surface (VK_KHR_surface plus the platform one) and is empty
+ /// for the headless capability probe.
+ ///
+ internal static Created Create(
+ Silk.NET.Vulkan.Vk vk,
+ IReadOnlyList requiredExtensions,
+ bool enableOptionalExtensions)
+ {
+ ArgumentNullException.ThrowIfNull(vk);
+ ArgumentNullException.ThrowIfNull(requiredExtensions);
+
+ IReadOnlyList available = VulkanInterop.EnumerateInstanceExtensions(vk);
+ VulkanExtensionPlan plan = VulkanExtensionSelection.Resolve(
+ available,
+ requiredExtensions,
+ enableOptionalExtensions
+ ? VulkanExtensionSelection.OptionalInstanceExtensions
+ : []);
+ if (!plan.IsSatisfied)
+ {
+ throw new NotSupportedException(
+ "The Vulkan loader does not advertise the required instance " +
+ $"extension(s): {string.Join(", ", plan.MissingRequired)}.");
+ }
+
+ uint apiVersion = VulkanApiVersion.Make(
+ VulkanCapabilityRequirements.RequiredApiMajor,
+ VulkanCapabilityRequirements.RequiredApiMinor,
+ 0);
+
+ nint applicationName = SilkMarshal.StringToPtr("acdream");
+ nint engineName = SilkMarshal.StringToPtr("acdream");
+ nint extensionNames = VulkanInterop.AllocateStringArray(plan.Enabled);
+ try
+ {
+ var application = new ApplicationInfo
+ {
+ SType = StructureType.ApplicationInfo,
+ PApplicationName = (byte*)applicationName,
+ ApplicationVersion = VulkanApiVersion.Make(0, 1, 0),
+ PEngineName = (byte*)engineName,
+ EngineVersion = VulkanApiVersion.Make(0, 1, 0),
+ ApiVersion = apiVersion,
+ };
+ var create = new InstanceCreateInfo
+ {
+ SType = StructureType.InstanceCreateInfo,
+ PApplicationInfo = &application,
+ EnabledExtensionCount = (uint)plan.Enabled.Count,
+ PpEnabledExtensionNames = (byte**)extensionNames,
+ EnabledLayerCount = 0,
+ PpEnabledLayerNames = null,
+ };
+
+ VulkanInterop.Check(
+ vk.CreateInstance(&create, null, out Instance instance),
+ "vkCreateInstance");
+ return new Created(
+ instance,
+ plan.Enabled,
+ plan.UnavailableOptional,
+ apiVersion);
+ }
+ finally
+ {
+ VulkanInterop.FreeStringArray(extensionNames);
+ SilkMarshal.Free(engineName);
+ SilkMarshal.Free(applicationName);
+ }
+ }
+}
+
+///
+/// Slice V5 interop: read one physical device's identity, features, limits and
+/// formats into the pure records the capability gate evaluates.
+///
+/// Everything this class produces is plain data. That is the seam: the gate's
+/// accept/reject matrix is exercised in unit tests against synthesised records,
+/// and this class only has to be right about which Vulkan field feeds which
+/// property.
+///
+internal static unsafe class VulkanPhysicalDeviceInspector
+{
+ /// Enumerate every physical device as a rankable candidate.
+ internal static IReadOnlyList Enumerate(
+ Silk.NET.Vulkan.Vk vk,
+ Instance instance,
+ out PhysicalDevice[] handles)
+ {
+ ArgumentNullException.ThrowIfNull(vk);
+
+ uint count = 0;
+ VulkanInterop.Check(
+ vk.EnumeratePhysicalDevices(instance, ref count, null),
+ "vkEnumeratePhysicalDevices (count)");
+ handles = new PhysicalDevice[count];
+ if (count == 0)
+ return [];
+
+ fixed (PhysicalDevice* first = handles)
+ {
+ VulkanInterop.Check(
+ vk.EnumeratePhysicalDevices(instance, ref count, first),
+ "vkEnumeratePhysicalDevices");
+ }
+
+ var candidates = new List((int)count);
+ for (int i = 0; i < handles.Length; i++)
+ {
+ PhysicalDeviceProperties properties;
+ vk.GetPhysicalDeviceProperties(handles[i], &properties);
+ candidates.Add(
+ new VulkanPhysicalDeviceCandidate(
+ i,
+ VulkanInterop.ReadString(properties.DeviceName),
+ properties.DeviceType,
+ properties.ApiVersion,
+ properties.DriverVersion,
+ properties.VendorID,
+ properties.DeviceID,
+ LargestDeviceLocalHeap(vk, handles[i])));
+ }
+
+ return candidates;
+ }
+
+ ///
+ /// Sum of the device-local heaps, which is the tie-break the plan specifies.
+ /// Summed rather than maximised because a device that splits its VRAM across
+ /// heaps is not smaller than one that does not.
+ ///
+ internal static ulong LargestDeviceLocalHeap(
+ Silk.NET.Vulkan.Vk vk,
+ PhysicalDevice device)
+ {
+ PhysicalDeviceMemoryProperties memory;
+ vk.GetPhysicalDeviceMemoryProperties(device, &memory);
+ ulong total = 0;
+ for (uint i = 0; i < memory.MemoryHeapCount && i < 16; i++)
+ {
+ MemoryHeap heap = memory.MemoryHeaps[(int)i];
+ if (heap.Flags.HasFlag(MemoryHeapFlags.DeviceLocalBit))
+ total += heap.Size;
+ }
+
+ return total;
+ }
+
+ /// Read the §4.1 feature chain through vkGetPhysicalDeviceFeatures2.
+ internal static VulkanDeviceFeatureSupport ReadFeatures(
+ Silk.NET.Vulkan.Vk vk,
+ PhysicalDevice device)
+ {
+ ArgumentNullException.ThrowIfNull(vk);
+
+ var vulkan13 = new PhysicalDeviceVulkan13Features
+ {
+ SType = StructureType.PhysicalDeviceVulkan13Features,
+ };
+ var vulkan12 = new PhysicalDeviceVulkan12Features
+ {
+ SType = StructureType.PhysicalDeviceVulkan12Features,
+ PNext = &vulkan13,
+ };
+ var vulkan11 = new PhysicalDeviceVulkan11Features
+ {
+ SType = StructureType.PhysicalDeviceVulkan11Features,
+ PNext = &vulkan12,
+ };
+ var features2 = new PhysicalDeviceFeatures2
+ {
+ SType = StructureType.PhysicalDeviceFeatures2,
+ PNext = &vulkan11,
+ };
+ vk.GetPhysicalDeviceFeatures2(device, &features2);
+
+ PhysicalDeviceFeatures core = features2.Features;
+ return new VulkanDeviceFeatureSupport
+ {
+ MultiDrawIndirect = core.MultiDrawIndirect,
+ DrawIndirectFirstInstance = core.DrawIndirectFirstInstance,
+ ShaderClipDistance = core.ShaderClipDistance,
+ TextureCompressionBc = core.TextureCompressionBC,
+ SamplerAnisotropy = core.SamplerAnisotropy,
+ ShaderDrawParameters = vulkan11.ShaderDrawParameters,
+ TimelineSemaphore = vulkan12.TimelineSemaphore,
+ HostQueryReset = vulkan12.HostQueryReset,
+ RuntimeDescriptorArray = vulkan12.RuntimeDescriptorArray,
+ DescriptorBindingPartiallyBound = vulkan12.DescriptorBindingPartiallyBound,
+ DescriptorBindingSampledImageUpdateAfterBind =
+ vulkan12.DescriptorBindingSampledImageUpdateAfterBind,
+ DescriptorBindingUpdateUnusedWhilePending =
+ vulkan12.DescriptorBindingUpdateUnusedWhilePending,
+ DescriptorBindingVariableDescriptorCount =
+ vulkan12.DescriptorBindingVariableDescriptorCount,
+ ShaderSampledImageArrayNonUniformIndexing =
+ vulkan12.ShaderSampledImageArrayNonUniformIndexing,
+ DynamicRendering = vulkan13.DynamicRendering,
+ Synchronization2 = vulkan13.Synchronization2,
+ Maintenance4 = vulkan13.Maintenance4,
+ };
+ }
+
+ ///
+ /// Read the limits the plan asserts up front. The update-after-bind sampled
+ /// image counts come from the descriptor-indexing properties chain, not from
+ /// PhysicalDeviceLimits — reading the ordinary
+ /// maxDescriptorSetSampledImages instead is a real and easy mistake,
+ /// because it is usually large enough to look like a pass.
+ ///
+ internal static VulkanDeviceLimitSupport ReadLimits(
+ Silk.NET.Vulkan.Vk vk,
+ PhysicalDevice device)
+ {
+ ArgumentNullException.ThrowIfNull(vk);
+
+ var indexing = new PhysicalDeviceDescriptorIndexingProperties
+ {
+ SType = StructureType.PhysicalDeviceDescriptorIndexingProperties,
+ };
+ var properties2 = new PhysicalDeviceProperties2
+ {
+ SType = StructureType.PhysicalDeviceProperties2,
+ PNext = &indexing,
+ };
+ vk.GetPhysicalDeviceProperties2(device, &properties2);
+
+ PhysicalDeviceLimits limits = properties2.Properties.Limits;
+ return new VulkanDeviceLimitSupport
+ {
+ MaxPushConstantsSize = limits.MaxPushConstantsSize,
+ MaxClipDistances = limits.MaxClipDistances,
+ MaxBoundDescriptorSets = limits.MaxBoundDescriptorSets,
+ MaxDescriptorSetUpdateAfterBindSampledImages =
+ indexing.MaxDescriptorSetUpdateAfterBindSampledImages,
+ MaxPerStageDescriptorUpdateAfterBindSampledImages =
+ indexing.MaxPerStageDescriptorUpdateAfterBindSampledImages,
+ TimestampComputeAndGraphics = limits.TimestampComputeAndGraphics,
+ MinStorageBufferOffsetAlignment = (uint)limits.MinStorageBufferOffsetAlignment,
+ MinUniformBufferOffsetAlignment = (uint)limits.MinUniformBufferOffsetAlignment,
+ MaxImageDimension2D = limits.MaxImageDimension2D,
+ MaxColorSampleCount = HighestSampleCount(
+ limits.FramebufferColorSampleCounts & limits.FramebufferDepthSampleCounts),
+ };
+ }
+
+ /// Highest set bit of a sample-count mask, as a plain count.
+ internal static uint HighestSampleCount(SampleCountFlags counts)
+ {
+ if (counts.HasFlag(SampleCountFlags.Count8Bit)) return 8;
+ if (counts.HasFlag(SampleCountFlags.Count4Bit)) return 4;
+ if (counts.HasFlag(SampleCountFlags.Count2Bit)) return 2;
+ return 1;
+ }
+
+ ///
+ /// Probe the formats features alone do not prove. Depth prefers
+ /// D32_SFLOAT_S8_UINT and falls back to D24_UNORM_S8_UINT; the
+ /// stencil aspect is required by #117's portal punch (plan §4.5).
+ ///
+ internal static VulkanFormatSupport ReadFormats(
+ Silk.NET.Vulkan.Vk vk,
+ PhysicalDevice device,
+ bool surfaceOffersUnorm)
+ {
+ ArgumentNullException.ThrowIfNull(vk);
+
+ return new VulkanFormatSupport
+ {
+ SwapchainUnormFormat = surfaceOffersUnorm,
+ DepthStencilFormat = ChooseDepthStencilFormat(vk, device),
+ Bc1Sampled = SupportsOptimalSampling(vk, device, Format.BC1RgbaUnormBlock),
+ Bc2Sampled = SupportsOptimalSampling(vk, device, Format.BC2UnormBlock),
+ Bc3Sampled = SupportsOptimalSampling(vk, device, Format.BC3UnormBlock),
+ };
+ }
+
+ internal static Format ChooseDepthStencilFormat(
+ Silk.NET.Vulkan.Vk vk,
+ PhysicalDevice device)
+ {
+ foreach (Format candidate in new[] { Format.D32SfloatS8Uint, Format.D24UnormS8Uint })
+ {
+ FormatProperties properties;
+ vk.GetPhysicalDeviceFormatProperties(device, candidate, &properties);
+ if (properties.OptimalTilingFeatures.HasFlag(
+ FormatFeatureFlags.DepthStencilAttachmentBit))
+ {
+ return candidate;
+ }
+ }
+
+ return Format.Undefined;
+ }
+
+ internal static bool SupportsOptimalSampling(
+ Silk.NET.Vulkan.Vk vk,
+ PhysicalDevice device,
+ Format format)
+ {
+ FormatProperties properties;
+ vk.GetPhysicalDeviceFormatProperties(device, format, &properties);
+ return properties.OptimalTilingFeatures.HasFlag(FormatFeatureFlags.SampledImageBit);
+ }
+
+ ///
+ /// Enumerate queue families, reporting present support only when a surface
+ /// is supplied. The headless probe passes null and takes the
+ /// graphics-only path.
+ ///
+ internal static IReadOnlyList ReadQueueFamilies(
+ Silk.NET.Vulkan.Vk vk,
+ PhysicalDevice device,
+ Silk.NET.Vulkan.Extensions.KHR.KhrSurface? surfaceApi,
+ SurfaceKHR surface)
+ {
+ ArgumentNullException.ThrowIfNull(vk);
+
+ uint count = 0;
+ vk.GetPhysicalDeviceQueueFamilyProperties(device, ref count, null);
+ if (count == 0)
+ return [];
+
+ var properties = new QueueFamilyProperties[count];
+ fixed (QueueFamilyProperties* first = properties)
+ vk.GetPhysicalDeviceQueueFamilyProperties(device, ref count, first);
+
+ var families = new List((int)count);
+ for (uint i = 0; i < count; i++)
+ {
+ bool graphics = properties[i].QueueFlags.HasFlag(QueueFlags.GraphicsBit);
+ bool present = false;
+ if (surfaceApi is not null)
+ {
+ VulkanInterop.Check(
+ surfaceApi.GetPhysicalDeviceSurfaceSupport(
+ device,
+ i,
+ surface,
+ out Bool32 supported),
+ "vkGetPhysicalDeviceSurfaceSupportKHR");
+ present = supported;
+ }
+
+ families.Add(new VulkanQueueFamilyCandidate(i, graphics, present));
+ }
+
+ return families;
+ }
+
+ /// A short, stable driver identification string for bug reports.
+ internal static string DescribeDriver(VulkanPhysicalDeviceCandidate device)
+ {
+ ArgumentNullException.ThrowIfNull(device);
+ return
+ $"vendor 0x{device.VendorId:X4}, device 0x{device.DeviceId:X4}, " +
+ $"driver {VulkanApiVersion.Major(device.DriverVersion)}." +
+ $"{VulkanApiVersion.Minor(device.DriverVersion)}." +
+ $"{VulkanApiVersion.Patch(device.DriverVersion)} " +
+ $"(raw 0x{device.DriverVersion:X8})";
+ }
+}
+
+///
+/// Slice V5 interop: create the logical device with the exact §4.1 feature
+/// chain enabled.
+///
+/// Enabling precisely the features the capability gate verified — no more —
+/// keeps the two in lockstep. A device created with a feature the gate never
+/// checked is how a V6 renderer ends up depending on something a second GPU does
+/// not have.
+///
+internal sealed unsafe class VulkanLogicalDeviceFactory
+{
+ internal sealed record Created(
+ Device Device,
+ Queue GraphicsQueue,
+ Queue PresentQueue,
+ VulkanQueueFamilyChoice Families,
+ IReadOnlyList EnabledExtensions,
+ IReadOnlyList UnavailableOptionalExtensions);
+
+ internal static Created Create(
+ Silk.NET.Vulkan.Vk vk,
+ PhysicalDevice physicalDevice,
+ VulkanQueueFamilyChoice families,
+ bool requireSwapchain)
+ {
+ ArgumentNullException.ThrowIfNull(vk);
+ ArgumentNullException.ThrowIfNull(families);
+
+ IReadOnlyList available =
+ VulkanInterop.EnumerateDeviceExtensions(vk, physicalDevice);
+ VulkanExtensionPlan plan = VulkanExtensionSelection.Resolve(
+ available,
+ requireSwapchain ? [VulkanExtensionSelection.SwapchainExtension] : [],
+ VulkanExtensionSelection.OptionalDeviceExtensions);
+ if (!plan.IsSatisfied)
+ {
+ throw new NotSupportedException(
+ "The selected Vulkan device does not advertise the required " +
+ $"extension(s): {string.Join(", ", plan.MissingRequired)}.");
+ }
+
+ var priority = 1f;
+ uint[] uniqueFamilies = families.IsUnified
+ ? [families.GraphicsFamily]
+ : [families.GraphicsFamily, families.PresentFamily];
+ var queueCreates = new DeviceQueueCreateInfo[uniqueFamilies.Length];
+ for (int i = 0; i < uniqueFamilies.Length; i++)
+ {
+ queueCreates[i] = new DeviceQueueCreateInfo
+ {
+ SType = StructureType.DeviceQueueCreateInfo,
+ QueueFamilyIndex = uniqueFamilies[i],
+ QueueCount = 1,
+ PQueuePriorities = &priority,
+ };
+ }
+
+ var vulkan13 = new PhysicalDeviceVulkan13Features
+ {
+ SType = StructureType.PhysicalDeviceVulkan13Features,
+ DynamicRendering = true,
+ Synchronization2 = true,
+ Maintenance4 = true,
+ };
+ var vulkan12 = new PhysicalDeviceVulkan12Features
+ {
+ SType = StructureType.PhysicalDeviceVulkan12Features,
+ PNext = &vulkan13,
+ DescriptorIndexing = true,
+ RuntimeDescriptorArray = true,
+ DescriptorBindingPartiallyBound = true,
+ DescriptorBindingSampledImageUpdateAfterBind = true,
+ DescriptorBindingUpdateUnusedWhilePending = true,
+ DescriptorBindingVariableDescriptorCount = true,
+ ShaderSampledImageArrayNonUniformIndexing = true,
+ TimelineSemaphore = true,
+ HostQueryReset = true,
+ };
+ var vulkan11 = new PhysicalDeviceVulkan11Features
+ {
+ SType = StructureType.PhysicalDeviceVulkan11Features,
+ PNext = &vulkan12,
+ ShaderDrawParameters = true,
+ };
+ var core = new PhysicalDeviceFeatures
+ {
+ MultiDrawIndirect = true,
+ DrawIndirectFirstInstance = true,
+ ShaderClipDistance = true,
+ TextureCompressionBC = true,
+ SamplerAnisotropy = true,
+ };
+ var features2 = new PhysicalDeviceFeatures2
+ {
+ SType = StructureType.PhysicalDeviceFeatures2,
+ PNext = &vulkan11,
+ Features = core,
+ };
+
+ nint extensionNames = VulkanInterop.AllocateStringArray(plan.Enabled);
+ try
+ {
+ fixed (DeviceQueueCreateInfo* queues = queueCreates)
+ {
+ var create = new DeviceCreateInfo
+ {
+ SType = StructureType.DeviceCreateInfo,
+ PNext = &features2,
+ QueueCreateInfoCount = (uint)queueCreates.Length,
+ PQueueCreateInfos = queues,
+ EnabledExtensionCount = (uint)plan.Enabled.Count,
+ PpEnabledExtensionNames = (byte**)extensionNames,
+ // PEnabledFeatures must stay null when the feature chain is
+ // supplied through pNext; passing both is invalid usage.
+ PEnabledFeatures = null,
+ };
+
+ VulkanInterop.Check(
+ vk.CreateDevice(physicalDevice, &create, null, out Device device),
+ "vkCreateDevice");
+
+ vk.GetDeviceQueue(device, families.GraphicsFamily, 0, out Queue graphics);
+ Queue present = graphics;
+ if (!families.IsUnified)
+ vk.GetDeviceQueue(device, families.PresentFamily, 0, out present);
+
+ return new Created(
+ device,
+ graphics,
+ present,
+ families,
+ plan.Enabled,
+ plan.UnavailableOptional);
+ }
+ }
+ finally
+ {
+ VulkanInterop.FreeStringArray(extensionNames);
+ }
+ }
+}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanPhysicalDeviceSelection.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanPhysicalDeviceSelection.cs
new file mode 100644
index 00000000..0da7b411
--- /dev/null
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanPhysicalDeviceSelection.cs
@@ -0,0 +1,232 @@
+using System.Globalization;
+using Silk.NET.Vulkan;
+
+namespace AcDream.App.Rendering.Gpu.Vk;
+
+/// The chosen device plus the sentence explaining why, which the report records verbatim.
+internal sealed record VulkanPhysicalDeviceChoice(
+ VulkanPhysicalDeviceCandidate Device,
+ string Reason);
+
+///
+/// Campaign V slice V5, plan §4.11: "discrete > integrated > virtual >
+/// CPU, tie-broken by largest device-local heap, with an
+/// ACDREAM_VULKAN_DEVICE override recorded in the report."
+///
+/// Pure ranking over values so the
+/// policy is unit-tested without enumerating a real instance — which matters
+/// because this machine has exactly one GPU and the ordering it exercises is
+/// therefore never the interesting case.
+///
+internal static class VulkanPhysicalDeviceSelection
+{
+ ///
+ /// Preference rank; lower is better. Vulkan's
+ /// numbering is Other(0) < Integrated(1) < Discrete(2) < Virtual(3)
+ /// < Cpu(4), which is neither our order nor a monotone one, so it is
+ /// mapped explicitly rather than compared numerically.
+ ///
+ internal static int PreferenceRank(PhysicalDeviceType type) => type switch
+ {
+ PhysicalDeviceType.DiscreteGpu => 0,
+ PhysicalDeviceType.IntegratedGpu => 1,
+ PhysicalDeviceType.VirtualGpu => 2,
+ PhysicalDeviceType.Cpu => 3,
+ _ => 4,
+ };
+
+ ///
+ /// Choose a device.
+ ///
+ /// is an enumeration index when it is
+ /// entirely decimal digits, and a case-insensitive device-name substring
+ /// otherwise. The split is exact rather than "try index, then fall back to
+ /// substring" because a bare digit is a substring of most real device names —
+ /// 7 occurs in "AMD Radeon RX 9070 XT" — so a fall-through would make
+ /// an out-of-range index quietly select a device by coincidence. A name that
+ /// genuinely contains digits ("RX 7900 XTX") still matches, because it is not
+ /// digits alone.
+ ///
+ /// An override that matches nothing falls back to the automatic choice
+ /// and says so in the reason: refusing to start over a stale environment
+ /// variable is worse than starting on the right GPU anyway.
+ ///
+ internal static VulkanPhysicalDeviceChoice? Choose(
+ IReadOnlyList candidates,
+ string? deviceOverride)
+ {
+ ArgumentNullException.ThrowIfNull(candidates);
+ if (candidates.Count == 0)
+ return null;
+
+ if (!string.IsNullOrWhiteSpace(deviceOverride))
+ {
+ string trimmed = deviceOverride.Trim();
+ if (IsDecimalIndex(trimmed))
+ {
+ int index = int.Parse(trimmed, NumberStyles.None, CultureInfo.InvariantCulture);
+ VulkanPhysicalDeviceCandidate? byIndex =
+ candidates.FirstOrDefault(candidate => candidate.Index == index);
+ if (byIndex is not null)
+ {
+ return new VulkanPhysicalDeviceChoice(
+ byIndex,
+ $"ACDREAM_VULKAN_DEVICE={trimmed} selected device index {index}.");
+ }
+ }
+ else
+ {
+ VulkanPhysicalDeviceCandidate? byName = candidates.FirstOrDefault(
+ candidate => candidate.DeviceName.Contains(
+ trimmed,
+ StringComparison.OrdinalIgnoreCase));
+ if (byName is not null)
+ {
+ return new VulkanPhysicalDeviceChoice(
+ byName,
+ $"ACDREAM_VULKAN_DEVICE={trimmed} matched device name '{byName.DeviceName}'.");
+ }
+ }
+
+ VulkanPhysicalDeviceCandidate automatic = Rank(candidates);
+ return new VulkanPhysicalDeviceChoice(
+ automatic,
+ $"ACDREAM_VULKAN_DEVICE={trimmed} matched no enumerated device; " +
+ $"fell back to the automatic choice '{automatic.DeviceName}' " +
+ $"({automatic.DeviceType}, {Gib(automatic.DeviceLocalHeapBytes)} device-local).");
+ }
+
+ VulkanPhysicalDeviceCandidate chosen = Rank(candidates);
+ return new VulkanPhysicalDeviceChoice(
+ chosen,
+ $"automatic: '{chosen.DeviceName}' ({chosen.DeviceType}, " +
+ $"{Gib(chosen.DeviceLocalHeapBytes)} device-local) ranked first of " +
+ $"{candidates.Count} enumerated device(s).");
+ }
+
+ ///
+ /// Deterministic ordering: type preference, then largest device-local heap,
+ /// then enumeration index. The final index tie-break exists so two identical
+ /// GPUs always produce the same choice across launches.
+ ///
+ internal static VulkanPhysicalDeviceCandidate Rank(
+ IReadOnlyList candidates)
+ {
+ ArgumentNullException.ThrowIfNull(candidates);
+ if (candidates.Count == 0)
+ throw new ArgumentException("At least one candidate is required.", nameof(candidates));
+
+ VulkanPhysicalDeviceCandidate best = candidates[0];
+ for (int i = 1; i < candidates.Count; i++)
+ {
+ if (Compare(candidates[i], best) < 0)
+ best = candidates[i];
+ }
+
+ return best;
+ }
+
+ /// Negative when is the better device.
+ internal static int Compare(
+ VulkanPhysicalDeviceCandidate left,
+ VulkanPhysicalDeviceCandidate right)
+ {
+ ArgumentNullException.ThrowIfNull(left);
+ ArgumentNullException.ThrowIfNull(right);
+
+ int byType = PreferenceRank(left.DeviceType).CompareTo(PreferenceRank(right.DeviceType));
+ if (byType != 0)
+ return byType;
+
+ int byHeap = right.DeviceLocalHeapBytes.CompareTo(left.DeviceLocalHeapBytes);
+ return byHeap != 0 ? byHeap : left.Index.CompareTo(right.Index);
+ }
+
+ /// An index override is decimal digits and nothing else.
+ internal static bool IsDecimalIndex(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ return false;
+ foreach (char character in value)
+ {
+ if (character is < '0' or > '9')
+ return false;
+ }
+
+ return true;
+ }
+
+ private static string Gib(ulong bytes)
+ => (bytes / (1024d * 1024d * 1024d)).ToString("0.##", CultureInfo.InvariantCulture) + " GiB";
+}
+
+///
+/// Which queue family carries graphics and which carries present. Slice V5 uses
+/// one graphics+present queue with transfers riding it (plan §4.8); a device
+/// whose present-capable family is separate is still supported, and the
+/// swapchain then declares concurrent sharing.
+///
+internal sealed record VulkanQueueFamilyChoice(
+ uint GraphicsFamily,
+ uint PresentFamily)
+{
+ /// True when one queue serves both, which is the case on every GPU we target.
+ internal bool IsUnified => GraphicsFamily == PresentFamily;
+}
+
+/// One enumerated queue family, reduced to the two facts the selector needs.
+internal readonly record struct VulkanQueueFamilyCandidate(
+ uint Index,
+ bool SupportsGraphics,
+ bool SupportsPresent);
+
+///
+/// Pure queue-family selection. Prefers a single family that does both, because
+/// that removes the concurrent-sharing declaration and the ownership transfers
+/// that would otherwise be needed on every swapchain image.
+///
+internal static class VulkanQueueFamilySelection
+{
+ internal static VulkanQueueFamilyChoice? Choose(
+ IReadOnlyList families)
+ {
+ ArgumentNullException.ThrowIfNull(families);
+
+ foreach (VulkanQueueFamilyCandidate family in families)
+ {
+ if (family.SupportsGraphics && family.SupportsPresent)
+ return new VulkanQueueFamilyChoice(family.Index, family.Index);
+ }
+
+ uint? graphics = null;
+ uint? present = null;
+ foreach (VulkanQueueFamilyCandidate family in families)
+ {
+ if (graphics is null && family.SupportsGraphics)
+ graphics = family.Index;
+ if (present is null && family.SupportsPresent)
+ present = family.Index;
+ }
+
+ return graphics is { } g && present is { } p
+ ? new VulkanQueueFamilyChoice(g, p)
+ : null;
+ }
+
+ ///
+ /// The headless variant used by the offscreen capability probe, which has no
+ /// surface and therefore no present requirement.
+ ///
+ internal static uint? ChooseGraphicsOnly(
+ IReadOnlyList families)
+ {
+ ArgumentNullException.ThrowIfNull(families);
+ foreach (VulkanQueueFamilyCandidate family in families)
+ {
+ if (family.SupportsGraphics)
+ return family.Index;
+ }
+
+ return null;
+ }
+}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanSwapchain.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanSwapchain.cs
new file mode 100644
index 00000000..af3d0238
--- /dev/null
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanSwapchain.cs
@@ -0,0 +1,565 @@
+using AcDream.App.Rendering;
+using Silk.NET.Vulkan;
+using Silk.NET.Vulkan.Extensions.KHR;
+using Semaphore = Silk.NET.Vulkan.Semaphore;
+
+namespace AcDream.App.Rendering.Gpu.Vk;
+
+///
+/// Campaign V slice V5, plan §4.9: the presentation swapchain.
+///
+/// Every decision this class makes — colour format, present mode, image
+/// count, extent, usage, transform, composite alpha, and what to do about
+/// OUT_OF_DATE / SUBOPTIMAL / a minimised window — is delegated to
+/// and
+/// , which are pure and unit-tested.
+/// What is left here is the calls themselves.
+///
+/// Untested by the implementing slice. Nothing below can run
+/// without a window and a driver, so it carries no automated coverage at V5. The
+/// manual "Vulkan boots to a clear colour" check is the gate.
+///
+internal sealed unsafe class VulkanSwapchain : IDisposable
+{
+ private readonly Silk.NET.Vulkan.Vk _vk;
+ private readonly KhrSurface _surfaceApi;
+ private readonly KhrSwapchain _swapchainApi;
+ private readonly PhysicalDevice _physicalDevice;
+ private readonly Device _device;
+ private readonly SurfaceKHR _surface;
+ private readonly VulkanQueueFamilyChoice _families;
+
+ private SwapchainKHR _swapchain;
+ private Image[] _images = [];
+ private ImageView[] _views = [];
+ private Semaphore[] _renderComplete = [];
+ private bool _disposed;
+
+ internal VulkanSwapchain(
+ Silk.NET.Vulkan.Vk vk,
+ KhrSurface surfaceApi,
+ KhrSwapchain swapchainApi,
+ PhysicalDevice physicalDevice,
+ Device device,
+ SurfaceKHR surface,
+ VulkanQueueFamilyChoice families)
+ {
+ _vk = vk ?? throw new ArgumentNullException(nameof(vk));
+ _surfaceApi = surfaceApi ?? throw new ArgumentNullException(nameof(surfaceApi));
+ _swapchainApi = swapchainApi ?? throw new ArgumentNullException(nameof(swapchainApi));
+ _physicalDevice = physicalDevice;
+ _device = device;
+ _surface = surface;
+ _families = families ?? throw new ArgumentNullException(nameof(families));
+ }
+
+ /// The configuration the live swapchain was created from, or null before the first create.
+ internal VulkanSwapchainConfiguration? Configuration { get; private set; }
+
+ /// True when a swapchain object currently exists.
+ internal bool IsCreated => _swapchain.Handle != 0;
+
+ internal int ImageCount => _images.Length;
+
+ internal ImageView ViewAt(uint index) => _views[index];
+
+ internal Image ImageAt(uint index) => _images[index];
+
+ internal Semaphore RenderCompleteAt(uint index) => _renderComplete[index];
+
+ /// Read the surface's current capabilities, formats and present modes.
+ internal (SurfaceCapabilitiesKHR Capabilities,
+ IReadOnlyList Formats,
+ IReadOnlyList PresentModes) QuerySurface()
+ {
+ VulkanInterop.Check(
+ _surfaceApi.GetPhysicalDeviceSurfaceCapabilities(
+ _physicalDevice,
+ _surface,
+ out SurfaceCapabilitiesKHR capabilities),
+ "vkGetPhysicalDeviceSurfaceCapabilitiesKHR");
+
+ uint formatCount = 0;
+ VulkanInterop.Check(
+ _surfaceApi.GetPhysicalDeviceSurfaceFormats(
+ _physicalDevice,
+ _surface,
+ ref formatCount,
+ null),
+ "vkGetPhysicalDeviceSurfaceFormatsKHR (count)");
+ var formats = new SurfaceFormatKHR[formatCount];
+ if (formatCount != 0)
+ {
+ fixed (SurfaceFormatKHR* first = formats)
+ {
+ VulkanInterop.Check(
+ _surfaceApi.GetPhysicalDeviceSurfaceFormats(
+ _physicalDevice,
+ _surface,
+ ref formatCount,
+ first),
+ "vkGetPhysicalDeviceSurfaceFormatsKHR");
+ }
+ }
+
+ uint modeCount = 0;
+ VulkanInterop.Check(
+ _surfaceApi.GetPhysicalDeviceSurfacePresentModes(
+ _physicalDevice,
+ _surface,
+ ref modeCount,
+ null),
+ "vkGetPhysicalDeviceSurfacePresentModesKHR (count)");
+ var modes = new PresentModeKHR[modeCount];
+ if (modeCount != 0)
+ {
+ fixed (PresentModeKHR* first = modes)
+ {
+ VulkanInterop.Check(
+ _surfaceApi.GetPhysicalDeviceSurfacePresentModes(
+ _physicalDevice,
+ _surface,
+ ref modeCount,
+ first),
+ "vkGetPhysicalDeviceSurfacePresentModesKHR");
+ }
+ }
+
+ return (capabilities, formats, modes);
+ }
+
+ ///
+ /// Create or rebuild the swapchain for the current framebuffer size. Returns
+ /// false when the surface reports zero area — a minimised window — in which
+ /// case the caller idles rather than spinning on a failing create.
+ ///
+ internal bool Recreate(FramePacingPolicy pacing, uint framebufferWidth, uint framebufferHeight)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ (SurfaceCapabilitiesKHR capabilities,
+ IReadOnlyList formats,
+ IReadOnlyList modes) = QuerySurface();
+
+ VulkanSwapchainConfiguration configuration =
+ VulkanSwapchainConfigurationFactory.Create(
+ capabilities,
+ formats,
+ modes,
+ pacing,
+ framebufferWidth,
+ framebufferHeight);
+ if (!configuration.IsPresentable)
+ return false;
+
+ SwapchainKHR old = _swapchain;
+ uint* families = stackalloc uint[2]
+ {
+ _families.GraphicsFamily,
+ _families.PresentFamily,
+ };
+ var create = new SwapchainCreateInfoKHR
+ {
+ SType = StructureType.SwapchainCreateInfoKhr,
+ Surface = _surface,
+ MinImageCount = configuration.ImageCount,
+ ImageFormat = configuration.ImageFormat,
+ ImageColorSpace = configuration.ColorSpace,
+ ImageExtent = new Extent2D(configuration.Width, configuration.Height),
+ ImageArrayLayers = 1,
+ ImageUsage = configuration.Usage,
+ // One queue does both on every GPU we target; the concurrent path
+ // exists so a split-family device still works without per-image
+ // ownership transfers.
+ ImageSharingMode = _families.IsUnified ? SharingMode.Exclusive : SharingMode.Concurrent,
+ QueueFamilyIndexCount = _families.IsUnified ? 0u : 2u,
+ PQueueFamilyIndices = _families.IsUnified ? null : families,
+ PreTransform = configuration.PreTransform,
+ CompositeAlpha = configuration.CompositeAlpha,
+ PresentMode = configuration.PresentMode,
+ Clipped = true,
+ OldSwapchain = old,
+ };
+
+ VulkanInterop.Check(
+ _swapchainApi.CreateSwapchain(_device, &create, null, out SwapchainKHR created),
+ "vkCreateSwapchainKHR");
+
+ DestroyImageResources();
+ if (old.Handle != 0)
+ _swapchainApi.DestroySwapchain(_device, old, null);
+
+ _swapchain = created;
+ Configuration = configuration;
+ AcquireImages(configuration);
+ return true;
+ }
+
+ private void AcquireImages(VulkanSwapchainConfiguration configuration)
+ {
+ uint count = 0;
+ VulkanInterop.Check(
+ _swapchainApi.GetSwapchainImages(_device, _swapchain, ref count, null),
+ "vkGetSwapchainImagesKHR (count)");
+ _images = new Image[count];
+ fixed (Image* first = _images)
+ {
+ VulkanInterop.Check(
+ _swapchainApi.GetSwapchainImages(_device, _swapchain, ref count, first),
+ "vkGetSwapchainImagesKHR");
+ }
+
+ _views = new ImageView[count];
+ _renderComplete = new Semaphore[count];
+ for (uint i = 0; i < count; i++)
+ {
+ var viewCreate = new ImageViewCreateInfo
+ {
+ SType = StructureType.ImageViewCreateInfo,
+ Image = _images[i],
+ ViewType = ImageViewType.Type2D,
+ Format = configuration.ImageFormat,
+ SubresourceRange = new ImageSubresourceRange
+ {
+ AspectMask = ImageAspectFlags.ColorBit,
+ BaseMipLevel = 0,
+ LevelCount = 1,
+ BaseArrayLayer = 0,
+ LayerCount = 1,
+ },
+ };
+ VulkanInterop.Check(
+ _vk.CreateImageView(_device, &viewCreate, null, out ImageView view),
+ "vkCreateImageView (swapchain image)");
+ _views[i] = view;
+
+ // One render-complete semaphore per image, not per flight slot: a
+ // present waits on the semaphore the submit for THAT image signalled,
+ // and reusing a slot-indexed semaphore is the classic way to signal a
+ // semaphore that is still pending.
+ var semaphoreCreate = new SemaphoreCreateInfo
+ {
+ SType = StructureType.SemaphoreCreateInfo,
+ };
+ VulkanInterop.Check(
+ _vk.CreateSemaphore(_device, &semaphoreCreate, null, out Semaphore semaphore),
+ "vkCreateSemaphore (render complete)");
+ _renderComplete[i] = semaphore;
+ }
+ }
+
+ /// Acquire the next image. The action tells the caller what to do about the result.
+ internal VulkanSwapchainAction TryAcquire(
+ Semaphore acquired,
+ ulong timeoutNanoseconds,
+ out uint imageIndex)
+ {
+ imageIndex = 0;
+ if (!IsCreated)
+ return VulkanSwapchainAction.RecreateNow;
+
+ Result result = _swapchainApi.AcquireNextImage(
+ _device,
+ _swapchain,
+ timeoutNanoseconds,
+ acquired,
+ default,
+ ref imageIndex);
+ return VulkanSwapchainRecreationPolicy.OnAcquire(result);
+ }
+
+ /// Present the acquired image, waiting on that image's render-complete semaphore.
+ internal VulkanSwapchainAction Present(Queue presentQueue, uint imageIndex)
+ {
+ SwapchainKHR swapchain = _swapchain;
+ Semaphore wait = _renderComplete[imageIndex];
+ uint index = imageIndex;
+ var present = new PresentInfoKHR
+ {
+ SType = StructureType.PresentInfoKhr,
+ WaitSemaphoreCount = 1,
+ PWaitSemaphores = &wait,
+ SwapchainCount = 1,
+ PSwapchains = &swapchain,
+ PImageIndices = &index,
+ };
+ Result result = _swapchainApi.QueuePresent(presentQueue, &present);
+ return VulkanSwapchainRecreationPolicy.OnPresent(result);
+ }
+
+ ///
+ /// Copy a presented image back to the CPU as tightly-packed RGBA, matching
+ /// FrameScreenshotController's Func<int, int, byte[]>
+ /// contract. Diagnostic-only, so it takes the simple synchronous route: idle
+ /// the device, run one throwaway command buffer, swizzle, done.
+ ///
+ internal byte[] CaptureImage(
+ Queue graphicsQueue,
+ uint graphicsFamily,
+ uint imageIndex)
+ {
+ if (Configuration is not { } configuration)
+ throw new InvalidOperationException("The swapchain has not been created.");
+
+ uint width = configuration.Width;
+ uint height = configuration.Height;
+ uint byteCount = width * height * 4;
+
+ VulkanInterop.Check(_vk.DeviceWaitIdle(_device), "vkDeviceWaitIdle (screenshot)");
+
+ Silk.NET.Vulkan.Buffer buffer = default;
+ DeviceMemory memory = default;
+ CommandPool pool = default;
+ try
+ {
+ var bufferCreate = new BufferCreateInfo
+ {
+ SType = StructureType.BufferCreateInfo,
+ Size = byteCount,
+ Usage = BufferUsageFlags.TransferDstBit,
+ SharingMode = SharingMode.Exclusive,
+ };
+ VulkanInterop.Check(
+ _vk.CreateBuffer(_device, &bufferCreate, null, out buffer),
+ "vkCreateBuffer (screenshot)");
+ _vk.GetBufferMemoryRequirements(_device, buffer, out MemoryRequirements requirements);
+ uint? typeIndex = VulkanActiveDeviceProbe.FindMemoryType(
+ _vk,
+ _physicalDevice,
+ requirements.MemoryTypeBits,
+ MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit);
+ if (typeIndex is not { } index)
+ {
+ throw new NotSupportedException(
+ "No host-visible Vulkan memory type is available for screenshot readback.");
+ }
+
+ var allocate = new MemoryAllocateInfo
+ {
+ SType = StructureType.MemoryAllocateInfo,
+ AllocationSize = requirements.Size,
+ MemoryTypeIndex = index,
+ };
+ VulkanInterop.Check(
+ _vk.AllocateMemory(_device, &allocate, null, out memory),
+ "vkAllocateMemory (screenshot)");
+ VulkanInterop.Check(
+ _vk.BindBufferMemory(_device, buffer, memory, 0),
+ "vkBindBufferMemory (screenshot)");
+
+ var poolCreate = new CommandPoolCreateInfo
+ {
+ SType = StructureType.CommandPoolCreateInfo,
+ QueueFamilyIndex = graphicsFamily,
+ Flags = CommandPoolCreateFlags.TransientBit,
+ };
+ VulkanInterop.Check(
+ _vk.CreateCommandPool(_device, &poolCreate, null, out pool),
+ "vkCreateCommandPool (screenshot)");
+ var allocateCommands = new CommandBufferAllocateInfo
+ {
+ SType = StructureType.CommandBufferAllocateInfo,
+ CommandPool = pool,
+ Level = CommandBufferLevel.Primary,
+ CommandBufferCount = 1,
+ };
+ VulkanInterop.Check(
+ _vk.AllocateCommandBuffers(_device, &allocateCommands, out CommandBuffer commands),
+ "vkAllocateCommandBuffers (screenshot)");
+
+ RecordCapture(commands, _images[imageIndex], buffer, width, height);
+
+ var commandSubmit = new CommandBufferSubmitInfo
+ {
+ SType = StructureType.CommandBufferSubmitInfo,
+ CommandBuffer = commands,
+ };
+ var submit = new SubmitInfo2
+ {
+ SType = StructureType.SubmitInfo2,
+ CommandBufferInfoCount = 1,
+ PCommandBufferInfos = &commandSubmit,
+ };
+ VulkanInterop.Check(
+ _vk.QueueSubmit2(graphicsQueue, 1, &submit, default),
+ "vkQueueSubmit2 (screenshot)");
+ VulkanInterop.Check(
+ _vk.QueueWaitIdle(graphicsQueue),
+ "vkQueueWaitIdle (screenshot)");
+
+ void* mapped = null;
+ VulkanInterop.Check(
+ _vk.MapMemory(_device, memory, 0, byteCount, 0, &mapped),
+ "vkMapMemory (screenshot)");
+ try
+ {
+ // vkCmdCopyImageToBuffer with bufferRowLength = 0 packs rows
+ // tightly, so the source pitch is exactly width * 4. The result
+ // is handed back in GL's bottom-left-origin convention because
+ // FrameScreenshotController flips what it receives.
+ return VulkanBackbufferSwizzle.ToGlOriginRgba(
+ new ReadOnlySpan(mapped, (int)byteCount),
+ (int)width,
+ (int)height,
+ (int)width * 4);
+ }
+ finally
+ {
+ _vk.UnmapMemory(_device, memory);
+ }
+ }
+ finally
+ {
+ if (pool.Handle != 0)
+ _vk.DestroyCommandPool(_device, pool, null);
+ if (buffer.Handle != 0)
+ _vk.DestroyBuffer(_device, buffer, null);
+ if (memory.Handle != 0)
+ _vk.FreeMemory(_device, memory, null);
+ }
+ }
+
+ private void RecordCapture(
+ CommandBuffer commands,
+ Image image,
+ Silk.NET.Vulkan.Buffer destination,
+ uint width,
+ uint height)
+ {
+ var begin = new CommandBufferBeginInfo
+ {
+ SType = StructureType.CommandBufferBeginInfo,
+ Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
+ };
+ VulkanInterop.Check(
+ _vk.BeginCommandBuffer(commands, &begin),
+ "vkBeginCommandBuffer (screenshot)");
+
+ var subresource = new ImageSubresourceRange
+ {
+ AspectMask = ImageAspectFlags.ColorBit,
+ BaseMipLevel = 0,
+ LevelCount = 1,
+ BaseArrayLayer = 0,
+ LayerCount = 1,
+ };
+ TransitionImage(
+ commands,
+ image,
+ subresource,
+ ImageLayout.PresentSrcKhr,
+ ImageLayout.TransferSrcOptimal,
+ PipelineStageFlags2.AllCommandsBit,
+ AccessFlags2.None,
+ PipelineStageFlags2.CopyBit,
+ AccessFlags2.TransferReadBit);
+
+ var region = new BufferImageCopy
+ {
+ BufferOffset = 0,
+ BufferRowLength = 0,
+ BufferImageHeight = 0,
+ ImageSubresource = new ImageSubresourceLayers
+ {
+ AspectMask = ImageAspectFlags.ColorBit,
+ MipLevel = 0,
+ BaseArrayLayer = 0,
+ LayerCount = 1,
+ },
+ ImageOffset = new Offset3D(0, 0, 0),
+ ImageExtent = new Extent3D(width, height, 1),
+ };
+ _vk.CmdCopyImageToBuffer(
+ commands,
+ image,
+ ImageLayout.TransferSrcOptimal,
+ destination,
+ 1,
+ ®ion);
+
+ TransitionImage(
+ commands,
+ image,
+ subresource,
+ ImageLayout.TransferSrcOptimal,
+ ImageLayout.PresentSrcKhr,
+ PipelineStageFlags2.CopyBit,
+ AccessFlags2.TransferReadBit,
+ PipelineStageFlags2.AllCommandsBit,
+ AccessFlags2.None);
+
+ VulkanInterop.Check(
+ _vk.EndCommandBuffer(commands),
+ "vkEndCommandBuffer (screenshot)");
+ }
+
+ /// One batched vkCmdPipelineBarrier2 image transition.
+ internal void TransitionImage(
+ CommandBuffer commands,
+ Image image,
+ ImageSubresourceRange subresource,
+ ImageLayout oldLayout,
+ ImageLayout newLayout,
+ PipelineStageFlags2 sourceStage,
+ AccessFlags2 sourceAccess,
+ PipelineStageFlags2 destinationStage,
+ AccessFlags2 destinationAccess)
+ {
+ var barrier = new ImageMemoryBarrier2
+ {
+ SType = StructureType.ImageMemoryBarrier2,
+ SrcStageMask = sourceStage,
+ SrcAccessMask = sourceAccess,
+ DstStageMask = destinationStage,
+ DstAccessMask = destinationAccess,
+ OldLayout = oldLayout,
+ NewLayout = newLayout,
+ SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
+ DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
+ Image = image,
+ SubresourceRange = subresource,
+ };
+ var dependency = new DependencyInfo
+ {
+ SType = StructureType.DependencyInfo,
+ ImageMemoryBarrierCount = 1,
+ PImageMemoryBarriers = &barrier,
+ };
+ _vk.CmdPipelineBarrier2(commands, &dependency);
+ }
+
+ private void DestroyImageResources()
+ {
+ foreach (Semaphore semaphore in _renderComplete)
+ {
+ if (semaphore.Handle != 0)
+ _vk.DestroySemaphore(_device, semaphore, null);
+ }
+
+ foreach (ImageView view in _views)
+ {
+ if (view.Handle != 0)
+ _vk.DestroyImageView(_device, view, null);
+ }
+
+ _renderComplete = [];
+ _views = [];
+ _images = [];
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+
+ DestroyImageResources();
+ if (_swapchain.Handle != 0)
+ {
+ _swapchainApi.DestroySwapchain(_device, _swapchain, null);
+ _swapchain = default;
+ }
+
+ Configuration = null;
+ }
+}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanSwapchainConfiguration.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanSwapchainConfiguration.cs
new file mode 100644
index 00000000..59cff702
--- /dev/null
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanSwapchainConfiguration.cs
@@ -0,0 +1,389 @@
+using AcDream.App.Rendering;
+using Silk.NET.Vulkan;
+
+namespace AcDream.App.Rendering.Gpu.Vk;
+
+///
+/// Everything vkCreateSwapchainKHR needs, decided by pure code from the
+/// surface's advertised capabilities plus the app's pacing policy. Keeping the
+/// decision separate from the call is what lets slice V5 unit-test format,
+/// present-mode, image-count and extent selection without a window.
+///
+internal sealed record VulkanSwapchainConfiguration(
+ Format ImageFormat,
+ ColorSpaceKHR ColorSpace,
+ PresentModeKHR PresentMode,
+ uint ImageCount,
+ uint Width,
+ uint Height,
+ ImageUsageFlags Usage,
+ SurfaceTransformFlagsKHR PreTransform,
+ CompositeAlphaFlagsKHR CompositeAlpha)
+{
+ /// A zero-area swapchain is not creatable; the frame loop idles instead.
+ internal bool IsPresentable => Width > 0 && Height > 0;
+}
+
+///
+/// Campaign V slice V5, plan §4.9. Three decisions live here and each has a
+/// reason that is easy to get silently wrong:
+///
+///
+/// - Format is B8G8R8A8_UNORM, never _SRGB.
+/// The V3 audit established that acdream never enables sRGB encoding anywhere —
+/// not on upload, not in a shader, not at the framebuffer. An _SRGB
+/// swapchain would encode already-display-space values and brighten every frame
+/// globally, and it would pass every automated gate until V7.
+/// - Present mode follows .
+/// FIFO when VSync is on; IMMEDIATE preferred then MAILBOX when it is off, with
+/// the software pacer continuing to drive the cap. FIFO is the only mode Vulkan
+/// guarantees, so it is always the final fallback.
+/// - Usage includes TRANSFER_SRC. Screenshots copy
+/// the presented image; without the usage bit the copy is invalid.
+///
+///
+internal static class VulkanSwapchainConfigurationFactory
+{
+ /// The swapchain colour format. Corrected at slice V3 — see the class remarks.
+ internal const Format PreferredFormat = Format.B8G8R8A8Unorm;
+
+ /// Paired with ; the only colour space core Vulkan guarantees.
+ internal const ColorSpaceKHR PreferredColorSpace = ColorSpaceKHR.SpaceSrgbNonlinearKhr;
+
+ ///
+ /// Two frames in flight (plan §4.8), so three images is the working target:
+ /// one presenting, one queued, one being recorded.
+ ///
+ internal const uint PreferredImageCount = 3;
+
+ /// Colour attachment for rendering; transfer source for screenshot capture.
+ internal const ImageUsageFlags RequiredUsage =
+ ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferSrcBit;
+
+ ///
+ /// Pick the surface format. Falls back to whatever the surface offers first
+ /// only when the preferred pair is genuinely absent; the capability gate has
+ /// already refused to start in that case, so the fallback exists purely so
+ /// this function is total.
+ ///
+ internal static SurfaceFormatKHR ChooseSurfaceFormat(
+ IReadOnlyList available)
+ {
+ ArgumentNullException.ThrowIfNull(available);
+ if (available.Count == 0)
+ return new SurfaceFormatKHR(PreferredFormat, PreferredColorSpace);
+
+ foreach (SurfaceFormatKHR format in available)
+ {
+ if (format.Format == PreferredFormat && format.ColorSpace == PreferredColorSpace)
+ return format;
+ }
+
+ foreach (SurfaceFormatKHR format in available)
+ {
+ if (format.Format == PreferredFormat)
+ return format;
+ }
+
+ return available[0];
+ }
+
+ /// True when the surface offers the UNORM format the renderer requires.
+ internal static bool OffersUnormFormat(IReadOnlyList available)
+ {
+ ArgumentNullException.ThrowIfNull(available);
+ foreach (SurfaceFormatKHR format in available)
+ {
+ if (format.Format == PreferredFormat)
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Map the resolved pacing policy onto a present mode.
+ ///
+ /// VSync on means FIFO — the driver blocks and there is no tearing. VSync off
+ /// prefers IMMEDIATE over MAILBOX because
+ /// and its platform waiters already own the software ceiling; MAILBOX would
+ /// add a hidden queue between our pacing decision and the display, which is
+ /// the opposite of what issue #235 needs. MAILBOX is still preferred over
+ /// falling back to FIFO, because FIFO would re-impose the very block the user
+ /// turned off.
+ ///
+ internal static PresentModeKHR ChoosePresentMode(
+ FramePacingPolicy pacing,
+ IReadOnlyList available)
+ {
+ ArgumentNullException.ThrowIfNull(available);
+
+ if (pacing.UseVSync)
+ return PresentModeKHR.FifoKhr;
+
+ if (available.Contains(PresentModeKHR.ImmediateKhr))
+ return PresentModeKHR.ImmediateKhr;
+ if (available.Contains(PresentModeKHR.MailboxKhr))
+ return PresentModeKHR.MailboxKhr;
+
+ // FIFO is the only mode Vulkan guarantees is present.
+ return PresentModeKHR.FifoKhr;
+ }
+
+ ///
+ /// Clamp the requested image count into the surface's window.
+ /// MaxImageCount == 0 means "no upper limit" in Vulkan, which is the
+ /// easiest clamp in the API to get backwards.
+ ///
+ internal static uint ChooseImageCount(in SurfaceCapabilitiesKHR capabilities)
+ {
+ uint count = Math.Max(PreferredImageCount, capabilities.MinImageCount);
+ if (capabilities.MaxImageCount != 0 && count > capabilities.MaxImageCount)
+ count = capabilities.MaxImageCount;
+ return count;
+ }
+
+ ///
+ /// Resolve the swapchain extent.
+ ///
+ /// CurrentExtent.Width == uint.MaxValue is the surface saying "you
+ /// choose", so the framebuffer size is clamped into the min/max window.
+ /// Otherwise the surface's own extent wins verbatim — including
+ /// 0 x 0, which is what a minimised window reports and which the
+ /// caller must treat as "do not create, do not present, idle".
+ ///
+ internal static (uint Width, uint Height) ChooseExtent(
+ in SurfaceCapabilitiesKHR capabilities,
+ uint framebufferWidth,
+ uint framebufferHeight)
+ {
+ if (capabilities.CurrentExtent.Width != uint.MaxValue)
+ return (capabilities.CurrentExtent.Width, capabilities.CurrentExtent.Height);
+
+ uint width = Math.Clamp(
+ framebufferWidth,
+ capabilities.MinImageExtent.Width,
+ capabilities.MaxImageExtent.Width);
+ uint height = Math.Clamp(
+ framebufferHeight,
+ capabilities.MinImageExtent.Height,
+ capabilities.MaxImageExtent.Height);
+ return (width, height);
+ }
+
+ ///
+ /// Prefer an identity pre-transform when the surface supports it; otherwise
+ /// pass its current transform straight through so the presentation engine
+ /// does not have to rotate.
+ ///
+ internal static SurfaceTransformFlagsKHR ChoosePreTransform(
+ in SurfaceCapabilitiesKHR capabilities)
+ => capabilities.SupportedTransforms.HasFlag(SurfaceTransformFlagsKHR.IdentityBitKhr)
+ ? SurfaceTransformFlagsKHR.IdentityBitKhr
+ : capabilities.CurrentTransform;
+
+ ///
+ /// The window is opaque. OPAQUE is what every desktop surface supports;
+ /// INHERIT is the only sane fallback if a compositor ever refuses it.
+ ///
+ internal static CompositeAlphaFlagsKHR ChooseCompositeAlpha(
+ in SurfaceCapabilitiesKHR capabilities)
+ {
+ if (capabilities.SupportedCompositeAlpha.HasFlag(CompositeAlphaFlagsKHR.OpaqueBitKhr))
+ return CompositeAlphaFlagsKHR.OpaqueBitKhr;
+ if (capabilities.SupportedCompositeAlpha.HasFlag(CompositeAlphaFlagsKHR.InheritBitKhr))
+ return CompositeAlphaFlagsKHR.InheritBitKhr;
+ return CompositeAlphaFlagsKHR.OpaqueBitKhr;
+ }
+
+ /// True when the surface permits the TRANSFER_SRC usage screenshots need.
+ internal static bool SupportsTransferSource(in SurfaceCapabilitiesKHR capabilities)
+ => capabilities.SupportedUsageFlags.HasFlag(ImageUsageFlags.TransferSrcBit);
+
+ /// Assemble the complete configuration from surface capabilities and app policy.
+ internal static VulkanSwapchainConfiguration Create(
+ in SurfaceCapabilitiesKHR capabilities,
+ IReadOnlyList formats,
+ IReadOnlyList presentModes,
+ FramePacingPolicy pacing,
+ uint framebufferWidth,
+ uint framebufferHeight)
+ {
+ SurfaceFormatKHR format = ChooseSurfaceFormat(formats);
+ (uint width, uint height) = ChooseExtent(
+ capabilities,
+ framebufferWidth,
+ framebufferHeight);
+
+ // TRANSFER_SRC is dropped rather than forced when a surface refuses it:
+ // the capability gate has already turned that into a startup failure, so
+ // forcing it here would only turn a clear message into a driver error.
+ ImageUsageFlags usage = SupportsTransferSource(capabilities)
+ ? RequiredUsage
+ : ImageUsageFlags.ColorAttachmentBit;
+
+ return new VulkanSwapchainConfiguration(
+ format.Format,
+ format.ColorSpace,
+ ChoosePresentMode(pacing, presentModes),
+ ChooseImageCount(capabilities),
+ width,
+ height,
+ usage,
+ ChoosePreTransform(capabilities),
+ ChooseCompositeAlpha(capabilities));
+ }
+}
+
+/// What the frame loop must do after an acquire or a present returned.
+internal enum VulkanSwapchainAction
+{
+ /// The image is usable; carry on.
+ Continue,
+
+ /// Rebuild the swapchain before doing anything else with it.
+ RecreateNow,
+
+ /// Usable this frame, but rebuild at the frame boundary.
+ RecreateAtFrameBoundary,
+
+ /// Nothing to present to — a minimised window. Idle without spinning.
+ Idle,
+
+ /// An error the loop cannot recover from; surface it rather than looping on it.
+ Fail,
+}
+
+///
+/// Plan §4.9: "OUT_OF_DATE recreates immediately, SUBOPTIMAL at the
+/// next frame boundary, both through FramebufferResizeController."
+///
+/// Pure, so the swapchain lifecycle that the risk register calls out (resize,
+/// minimise, RDP) is covered by unit tests rather than only by a connected gate.
+///
+internal static class VulkanSwapchainRecreationPolicy
+{
+ /// Classify the result of vkAcquireNextImageKHR.
+ internal static VulkanSwapchainAction OnAcquire(Result result) => result switch
+ {
+ Result.Success => VulkanSwapchainAction.Continue,
+ // A suboptimal acquire is still a usable image, so the frame renders and
+ // the rebuild happens at the boundary rather than mid-frame.
+ Result.SuboptimalKhr => VulkanSwapchainAction.RecreateAtFrameBoundary,
+ Result.ErrorOutOfDateKhr => VulkanSwapchainAction.RecreateNow,
+ Result.Timeout or Result.NotReady => VulkanSwapchainAction.Idle,
+ _ => VulkanSwapchainAction.Fail,
+ };
+
+ /// Classify the result of vkQueuePresentKHR.
+ internal static VulkanSwapchainAction OnPresent(Result result) => result switch
+ {
+ Result.Success => VulkanSwapchainAction.Continue,
+ Result.SuboptimalKhr => VulkanSwapchainAction.RecreateAtFrameBoundary,
+ Result.ErrorOutOfDateKhr => VulkanSwapchainAction.RecreateNow,
+ _ => VulkanSwapchainAction.Fail,
+ };
+
+ ///
+ /// A zero-area framebuffer is a minimised window. Creating a swapchain for it
+ /// is invalid, so the loop idles until the surface reports area again — this
+ /// is the check that must run before any acquire, not after a
+ /// failure.
+ ///
+ internal static VulkanSwapchainAction OnFramebufferSize(uint width, uint height)
+ => width == 0 || height == 0
+ ? VulkanSwapchainAction.Idle
+ : VulkanSwapchainAction.Continue;
+}
+
+///
+/// The swapchain is BGRA and FrameScreenshotController takes RGBA
+/// (Func<int, int, byte[]>). Plan §4.9: "Screenshots swizzle
+/// BGRA→RGBA on the CPU to preserve FrameScreenshotController's RGBA byte
+/// contract."
+///
+internal static class VulkanBackbufferSwizzle
+{
+ ///
+ /// Swap the red and blue bytes of every 4-byte pixel in place. Alpha and
+ /// green are untouched, so the transform is its own inverse.
+ ///
+ internal static void SwapRedAndBlueInPlace(Span pixels)
+ {
+ if (pixels.Length % 4 != 0)
+ {
+ throw new ArgumentException(
+ "A BGRA/RGBA pixel span must be a whole number of 4-byte pixels.",
+ nameof(pixels));
+ }
+
+ for (int i = 0; i + 3 < pixels.Length; i += 4)
+ (pixels[i], pixels[i + 2]) = (pixels[i + 2], pixels[i]);
+ }
+
+ ///
+ /// Copy a tightly-packed or row-padded BGRA image into a tightly-packed RGBA
+ /// buffer. is the copy's row pitch,
+ /// which Vulkan reports per image layout and which is frequently larger than
+ /// width * 4 — reading it as if it were tight is the classic way to
+ /// get a diagonally sheared screenshot.
+ ///
+ internal static byte[] ToRgba(
+ ReadOnlySpan source,
+ int width,
+ int height,
+ int sourceRowPitchBytes)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height);
+ ArgumentOutOfRangeException.ThrowIfLessThan(sourceRowPitchBytes, width * 4);
+
+ var destination = new byte[width * height * 4];
+ for (int y = 0; y < height; y++)
+ {
+ ReadOnlySpan sourceRow =
+ source.Slice(y * sourceRowPitchBytes, width * 4);
+ Span destinationRow =
+ destination.AsSpan(y * width * 4, width * 4);
+ sourceRow.CopyTo(destinationRow);
+ SwapRedAndBlueInPlace(destinationRow);
+ }
+
+ return destination;
+ }
+
+ ///
+ /// As , but also flips the image vertically so the result
+ /// is in OpenGL's bottom-left-origin convention.
+ ///
+ /// This is the same rule §3.3 states for winding and viewport origin —
+ /// "renderers always speak GL conventions ... the Vulkan backend inverts in
+ /// exactly one mapping function." A Vulkan image is top-left-origin, and
+ /// FrameScreenshotController flips what it is given because
+ /// glReadPixels hands back bottom-up rows. Converting here, once,
+ /// keeps that consumer and its PNG orientation untouched.
+ ///
+ internal static byte[] ToGlOriginRgba(
+ ReadOnlySpan source,
+ int width,
+ int height,
+ int sourceRowPitchBytes)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height);
+ ArgumentOutOfRangeException.ThrowIfLessThan(sourceRowPitchBytes, width * 4);
+
+ var destination = new byte[width * height * 4];
+ for (int y = 0; y < height; y++)
+ {
+ ReadOnlySpan sourceRow =
+ source.Slice(y * sourceRowPitchBytes, width * 4);
+ Span destinationRow =
+ destination.AsSpan((height - 1 - y) * width * 4, width * 4);
+ sourceRow.CopyTo(destinationRow);
+ SwapRedAndBlueInPlace(destinationRow);
+ }
+
+ return destination;
+ }
+}
diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs
index 8324c504..7b37ae2e 100644
--- a/src/AcDream.App/RuntimeOptions.cs
+++ b/src/AcDream.App/RuntimeOptions.cs
@@ -56,7 +56,10 @@ public sealed record RuntimeOptions(
float FogStartMultiplier,
float FogEndMultiplier,
ResidencyBudgetOptions ResidencyBudgets,
- StreamingWorkBudgetOptions StreamingWorkBudgets)
+ StreamingWorkBudgetOptions StreamingWorkBudgets,
+ RenderBackendKind RenderBackend,
+ string? VulkanDeviceOverride,
+ string? VulkanForcedUnsupportedFeature)
{
///
/// Build options from the process environment. Used by
@@ -118,9 +121,32 @@ public sealed record RuntimeOptions(
FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f,
FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f,
ResidencyBudgets: ResidencyBudgetOptions.Parse(env),
- StreamingWorkBudgets: StreamingWorkBudgetOptions.Parse(env));
+ StreamingWorkBudgets: StreamingWorkBudgetOptions.Parse(env),
+ // Campaign V slice V5. Unset, empty, or any unrecognised value means
+ // OpenGL: a typo must never silently start the dark Vulkan host.
+ RenderBackend: ParseRenderBackend(env("ACDREAM_RENDER_BACKEND")),
+ // Physical-device override, matched as a decimal index first and then
+ // as a case-insensitive device-name substring. Recorded verbatim in
+ // graphical-capabilities-vulkan.json whether or not it matched.
+ VulkanDeviceOverride:
+ NullIfEmpty(env("ACDREAM_VULKAN_DEVICE")),
+ // Slice V5 gate knob: names one required capability to report as
+ // absent so the NotSupportedException -> exit-code-4 -> report path
+ // can be exercised on hardware that actually supports everything.
+ VulkanForcedUnsupportedFeature:
+ NullIfEmpty(env("ACDREAM_VULKAN_FORCE_UNSUPPORTED")));
}
+ ///
+ /// Startup backend request. Only the exact lower-case token vulkan
+ /// selects Vulkan; everything else — unset, gl, or a typo — is
+ /// OpenGL, which is the shipping backend until Campaign V slice V10.
+ ///
+ private static RenderBackendKind ParseRenderBackend(string? value)
+ => string.Equals(value, "vulkan", StringComparison.OrdinalIgnoreCase)
+ ? RenderBackendKind.Vulkan
+ : RenderBackendKind.Gl;
+
/// True iff live-mode credentials are present and valid for connecting.
public bool HasLiveCredentials =>
LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass);
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanActiveDeviceProbeTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanActiveDeviceProbeTests.cs
new file mode 100644
index 00000000..3dfd52c4
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanActiveDeviceProbeTests.cs
@@ -0,0 +1,109 @@
+using System;
+using AcDream.App.Rendering.Gpu.Vk;
+
+namespace AcDream.App.Tests.Rendering.Gpu.Vk;
+
+///
+/// Campaign V slice V5 — the parts of the active Vulkan probe that can be
+/// checked without a device.
+///
+/// The probe's submission path needs a driver and is covered by the manual
+/// "Vulkan boots to a clear colour" gate. Its verdict — did the
+/// readback really contain the colour we cleared to? — is pure, and it is the
+/// step most worth pinning: a probe that accepts zeroed memory would report a
+/// pass on a device that rendered nothing at all.
+///
+public sealed class VulkanActiveDeviceProbeTests
+{
+ private static byte[] Filled(byte r, byte g, byte b, byte a)
+ {
+ int pixels = (int)(VulkanActiveDeviceProbe.ProbeExtent * VulkanActiveDeviceProbe.ProbeExtent);
+ var buffer = new byte[pixels * 4];
+ for (int i = 0; i < buffer.Length; i += 4)
+ {
+ buffer[i] = r;
+ buffer[i + 1] = g;
+ buffer[i + 2] = b;
+ buffer[i + 3] = a;
+ }
+
+ return buffer;
+ }
+
+ private static byte[] Expected() => Filled(
+ VulkanActiveDeviceProbe.ExpectedClearRgba[0],
+ VulkanActiveDeviceProbe.ExpectedClearRgba[1],
+ VulkanActiveDeviceProbe.ExpectedClearRgba[2],
+ VulkanActiveDeviceProbe.ExpectedClearRgba[3]);
+
+ ///
+ /// Every channel of the probe colour differs from the others and none is 0
+ /// or 255, so zeroed or saturated memory cannot accidentally match.
+ ///
+ [Fact]
+ public void TheProbeColourCannotBeMatchedByZeroedOrSaturatedMemory()
+ {
+ ReadOnlySpan colour = VulkanActiveDeviceProbe.ExpectedClearRgba;
+
+ Assert.Equal(4, colour.Length);
+ foreach (byte channel in colour)
+ {
+ Assert.NotEqual(0, channel);
+ Assert.NotEqual(255, channel);
+ }
+
+ Assert.Throws(
+ () => VulkanActiveDeviceProbe.VerifyClearColour(Filled(0, 0, 0, 0)));
+ Assert.Throws(
+ () => VulkanActiveDeviceProbe.VerifyClearColour(Filled(255, 255, 255, 255)));
+ }
+
+ [Fact]
+ public void TheExpectedClearColourIsAccepted()
+ {
+ VulkanActiveDeviceProbe.VerifyClearColour(Expected());
+ }
+
+ ///
+ /// The clear colour is specified as a float and quantised by the
+ /// implementation, so one least-significant bit of slack is allowed — and
+ /// exactly one.
+ ///
+ [Fact]
+ public void OneBitOfQuantisationSlackIsAllowedButTwoIsNot()
+ {
+ byte[] colour = VulkanActiveDeviceProbe.ExpectedClearRgba.ToArray();
+
+ VulkanActiveDeviceProbe.VerifyClearColour(
+ Filled((byte)(colour[0] + 1), colour[1], colour[2], colour[3]));
+ VulkanActiveDeviceProbe.VerifyClearColour(
+ Filled((byte)(colour[0] - 1), colour[1], colour[2], colour[3]));
+
+ Assert.Throws(
+ () => VulkanActiveDeviceProbe.VerifyClearColour(
+ Filled((byte)(colour[0] + 2), colour[1], colour[2], colour[3])));
+ }
+
+ ///
+ /// A driver that clears only the first tile, or that returns a row-padded
+ /// copy, must fail here rather than at the V7 differential — which is why
+ /// every pixel is compared and not a sample.
+ ///
+ [Fact]
+ public void ASinglyWrongPixelAnywhereIsRejected()
+ {
+ byte[] pixels = Expected();
+ int lastPixel = pixels.Length - 4;
+ pixels[lastPixel + 1] ^= 0xFF;
+
+ Assert.Throws(
+ () => VulkanActiveDeviceProbe.VerifyClearColour(pixels));
+ }
+
+ [Fact]
+ public void AShortReadbackIsRejectedRatherThanPartiallyChecked()
+ {
+ Assert.Throws(
+ () => VulkanActiveDeviceProbe.VerifyClearColour(new byte[64]));
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanCapabilityGateTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanCapabilityGateTests.cs
new file mode 100644
index 00000000..e209563c
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanCapabilityGateTests.cs
@@ -0,0 +1,545 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using AcDream.App.Platform;
+using AcDream.App.Rendering.Gpu;
+using AcDream.App.Rendering.Gpu.Vk;
+using Silk.NET.Vulkan;
+
+namespace AcDream.App.Tests.Rendering.Gpu.Vk;
+
+///
+/// Campaign V slice V5 — the Vulkan capability gate's accept/reject matrix.
+///
+/// These run with no driver, no device and no window: the gate is a pure
+/// function from a captured record to a list of operator-facing sentences, and
+/// that separation is the whole reason the interop layer stays thin. Each
+/// mandatory feature gets its own "absent means rejected" case, because a gate
+/// that silently stops checking one requirement is exactly the failure the GL
+/// probe was written to prevent.
+///
+public sealed class VulkanCapabilityGateTests
+{
+ private static VulkanCapabilityRecord SupportedRecord(
+ VulkanDeviceFeatureSupport? features = null,
+ VulkanDeviceLimitSupport? limits = null,
+ VulkanFormatSupport? formats = null,
+ VulkanSurfaceSupport? surface = null,
+ VulkanFunctionProbeResult? probe = null,
+ uint? apiVersion = null)
+ {
+ var record = new VulkanCapabilityRecord(
+ DateTimeOffset.UnixEpoch,
+ "win-x64",
+ GraphicalHostOperatingSystem.Windows,
+ GraphicalDisplayProtocol.Windows,
+ GraphicalDisplayProtocol.Windows,
+ "Vulkan 1.3.0",
+ "Vulkan 1.3.280",
+ apiVersion ?? VulkanApiVersion.Make(1, 3, 280),
+ "AMD Radeon RX 9070 XT",
+ "vendor 0x1002, device 0x7550, driver 2.0.0 (raw 0x00800000)",
+ PhysicalDeviceType.DiscreteGpu,
+ 0,
+ "automatic",
+ RequestedDeviceOverride: null,
+ ForcedUnsupportedFeature: null,
+ AvailableDevices: [],
+ InstanceExtensions: ["VK_KHR_surface", "VK_KHR_win32_surface"],
+ DeviceExtensions: ["VK_KHR_swapchain"],
+ GraphicsQueueFamily: 0,
+ PresentQueueFamily: 0,
+ features ?? VulkanDeviceFeatureSupport.Complete,
+ limits ?? VulkanDeviceLimitSupport.Complete,
+ formats ?? VulkanFormatSupport.Complete,
+ surface ?? SupportedSurface(),
+ probe ?? PassingProbe(),
+ SupportFailures: []);
+ return VulkanCapabilityRequirements.Reevaluate(record);
+ }
+
+ private static VulkanSurfaceSupport SupportedSurface() => new(
+ PresentSupported: true,
+ SelectedFormat: Format.B8G8R8A8Unorm,
+ SelectedColorSpace: ColorSpaceKHR.SpaceSrgbNonlinearKhr,
+ SelectedPresentMode: PresentModeKHR.FifoKhr,
+ SelectedImageCount: 3,
+ SelectedWidth: 1280,
+ SelectedHeight: 720,
+ SupportsTransferSource: true,
+ AvailableFormats: [Format.B8G8R8A8Unorm],
+ AvailablePresentModes: [PresentModeKHR.FifoKhr, PresentModeKHR.ImmediateKhr]);
+
+ private static VulkanFunctionProbeResult PassingProbe() => new(
+ DeviceCreation: true,
+ DescriptorIndexingLayout: true,
+ PushConstantLayout: true,
+ DynamicRenderingClear: true,
+ TimelineSemaphoreWait: true,
+ HostQueryReset: true,
+ OffscreenReadback: true,
+ Failures: []);
+
+ [Fact]
+ public void ACompleteDeviceIsAccepted()
+ {
+ VulkanCapabilityRecord record = SupportedRecord();
+
+ Assert.Empty(record.SupportFailures);
+ Assert.True(record.IsSupported);
+ }
+
+ ///
+ /// Every field on is mandatory, so
+ /// clearing any one of them must produce exactly one new failure. Driving
+ /// this by reflection rather than by hand means a feature added to the record
+ /// without a matching Evaluate clause fails here instead of shipping
+ /// unchecked.
+ ///
+ [Fact]
+ public void EveryRequiredFeatureIsIndividuallyEnforced()
+ {
+ IEnumerable featureNames = typeof(VulkanDeviceFeatureSupport)
+ .GetProperties()
+ .Where(property => property.PropertyType == typeof(bool))
+ .Select(property => property.Name);
+
+ foreach (string name in featureNames)
+ {
+ VulkanDeviceFeatureSupport? reduced =
+ VulkanDeviceFeatureSupport.Complete.Without(name);
+ Assert.NotNull(reduced);
+
+ VulkanCapabilityRecord record = SupportedRecord(features: reduced);
+ Assert.False(
+ record.IsSupported,
+ $"clearing {name} must reject the device.");
+ Assert.Single(record.SupportFailures);
+ }
+ }
+
+ [Fact]
+ public void AnUnknownFeatureNameIsNotSilentlyIgnored()
+ {
+ Assert.Null(VulkanDeviceFeatureSupport.Complete.Without("NotAVulkanFeature"));
+ }
+
+ [Theory]
+ [InlineData(1, 2)]
+ [InlineData(1, 0)]
+ [InlineData(0, 9)]
+ public void ADeviceBelowVulkan13IsRejected(uint major, uint minor)
+ {
+ VulkanCapabilityRecord record = SupportedRecord(
+ apiVersion: VulkanApiVersion.Make(major, minor, 0));
+
+ Assert.Contains(
+ record.SupportFailures,
+ failure => failure.Contains("Vulkan 1.3 is required", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void Vulkan14IsAccepted()
+ {
+ VulkanCapabilityRecord record = SupportedRecord(
+ apiVersion: VulkanApiVersion.Make(1, 4, 0));
+
+ Assert.True(record.IsSupported);
+ }
+
+ [Fact]
+ public void PushConstantsBelowThePinnedBlockAreRejected()
+ {
+ VulkanCapabilityRecord record = SupportedRecord(
+ limits: VulkanDeviceLimitSupport.Complete with
+ {
+ MaxPushConstantsSize = GpuBindingModel.PushConstantBytes - 1,
+ });
+
+ Assert.Contains(
+ record.SupportFailures,
+ failure => failure.Contains("push-constant bytes are required", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void FewerThanEightClipDistancesAreRejected()
+ {
+ VulkanCapabilityRecord record = SupportedRecord(
+ limits: VulkanDeviceLimitSupport.Complete with { MaxClipDistances = 6 });
+
+ Assert.Contains(
+ record.SupportFailures,
+ failure => failure.Contains("clip distances are required", StringComparison.Ordinal));
+ }
+
+ ///
+ /// Sets 0 (storage), 1 (uniform) and 2 (texture table) are bound at once, so
+ /// two bound sets is not enough. This is the limit the §3.4 binding model
+ /// silently assumes.
+ ///
+ [Fact]
+ public void FewerThanThreeBoundDescriptorSetsAreRejected()
+ {
+ Assert.Equal(3u, VulkanCapabilityRequirements.VulkanDescriptorSetCount);
+
+ VulkanCapabilityRecord record = SupportedRecord(
+ limits: VulkanDeviceLimitSupport.Complete with { MaxBoundDescriptorSets = 2 });
+
+ Assert.Contains(
+ record.SupportFailures,
+ failure => failure.Contains(
+ "simultaneously bound descriptor sets are required",
+ StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void ATextureTableSmallerThanTheCapacityIsRejectedPerSetAndPerStage()
+ {
+ VulkanCapabilityRecord perSet = SupportedRecord(
+ limits: VulkanDeviceLimitSupport.Complete with
+ {
+ MaxDescriptorSetUpdateAfterBindSampledImages =
+ GpuBindingModel.TextureTableCapacity - 1,
+ });
+ VulkanCapabilityRecord perStage = SupportedRecord(
+ limits: VulkanDeviceLimitSupport.Complete with
+ {
+ MaxPerStageDescriptorUpdateAfterBindSampledImages =
+ GpuBindingModel.TextureTableCapacity - 1,
+ });
+
+ Assert.Contains(
+ perSet.SupportFailures,
+ failure => failure.Contains("update-after-bind sampled images", StringComparison.Ordinal));
+ Assert.Contains(
+ perStage.SupportFailures,
+ failure => failure.Contains("fragment stage", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void MissingGraphicsTimestampsAreRejected()
+ {
+ VulkanCapabilityRecord record = SupportedRecord(
+ limits: VulkanDeviceLimitSupport.Complete with { TimestampComputeAndGraphics = false });
+
+ Assert.Contains(
+ record.SupportFailures,
+ failure => failure.Contains("timestamps are required", StringComparison.Ordinal));
+ }
+
+ ///
+ /// The single highest-severity finding of the V3 audit: the swapchain is
+ /// UNORM, not sRGB. A surface that cannot offer UNORM must be a startup
+ /// failure, because silently taking an _SRGB format would brighten every
+ /// frame and pass every automated gate until V7.
+ ///
+ [Fact]
+ public void ASurfaceWithoutTheUnormFormatIsRejected()
+ {
+ VulkanCapabilityRecord record = SupportedRecord(
+ formats: VulkanFormatSupport.Complete with { SwapchainUnormFormat = false });
+
+ Assert.Contains(
+ record.SupportFailures,
+ failure => failure.Contains("B8G8R8A8_UNORM", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void AMissingDepthStencilFormatIsRejected()
+ {
+ VulkanCapabilityRecord record = SupportedRecord(
+ formats: VulkanFormatSupport.Complete with { DepthStencilFormat = Format.Undefined });
+
+ Assert.Contains(
+ record.SupportFailures,
+ failure => failure.Contains("depth+stencil format", StringComparison.Ordinal));
+ }
+
+ [Theory]
+ [InlineData(false, true, true)]
+ [InlineData(true, false, true)]
+ [InlineData(true, true, false)]
+ public void MissingAnyBcBlockIsRejected(bool bc1, bool bc2, bool bc3)
+ {
+ VulkanCapabilityRecord record = SupportedRecord(
+ formats: VulkanFormatSupport.Complete with
+ {
+ Bc1Sampled = bc1,
+ Bc2Sampled = bc2,
+ Bc3Sampled = bc3,
+ });
+
+ Assert.Contains(
+ record.SupportFailures,
+ failure => failure.Contains("BC1, BC2 and BC3", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void ASurfaceWithoutTransferSourceUsageIsRejected()
+ {
+ VulkanCapabilityRecord record = SupportedRecord(
+ surface: SupportedSurface() with { SupportsTransferSource = false });
+
+ Assert.Contains(
+ record.SupportFailures,
+ failure => failure.Contains("TRANSFER_SRC", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void ADeviceThatCannotPresentIsRejected()
+ {
+ VulkanCapabilityRecord record = SupportedRecord(
+ surface: SupportedSurface() with { PresentSupported = false });
+
+ Assert.Contains(
+ record.SupportFailures,
+ failure => failure.Contains("cannot present", StringComparison.Ordinal));
+ }
+
+ ///
+ /// A headless capture (no window) is still evaluable — that is what lets the
+ /// probe be offscreen — and simply carries no surface requirements.
+ ///
+ [Fact]
+ public void AHeadlessCaptureWithNoSurfaceIsAccepted()
+ {
+ VulkanCapabilityRecord record = SupportedRecord(surface: null);
+
+ Assert.True(record.IsSupported);
+ }
+
+ [Fact]
+ public void ProbeFailuresArePrefixedAndReplaceTheIndividualChecks()
+ {
+ VulkanCapabilityRecord record = SupportedRecord(
+ probe: VulkanFunctionProbeResult.NotRun);
+
+ Assert.Contains(
+ record.SupportFailures,
+ failure => failure.StartsWith("Vulkan device probe:", StringComparison.Ordinal));
+ // The per-step sentences are suppressed when the probe reported its own
+ // failure, so the operator gets the cause rather than seven symptoms.
+ Assert.DoesNotContain(
+ record.SupportFailures,
+ failure => failure.Contains("probe did not pass", StringComparison.Ordinal));
+ }
+
+ [Theory]
+ [InlineData("DescriptorIndexingLayout", "descriptor-indexing layout probe")]
+ [InlineData("PushConstantLayout", "push-constant pipeline-layout probe")]
+ [InlineData("DynamicRenderingClear", "dynamic-rendering clear probe")]
+ [InlineData("TimelineSemaphoreWait", "timeline-semaphore wait probe")]
+ [InlineData("HostQueryReset", "host query-reset probe")]
+ [InlineData("OffscreenReadback", "offscreen readback probe")]
+ public void EachActiveProbeStepIsIndividuallyEnforced(string step, string expected)
+ {
+ VulkanFunctionProbeResult probe = step switch
+ {
+ "DescriptorIndexingLayout" => PassingProbe() with { DescriptorIndexingLayout = false },
+ "PushConstantLayout" => PassingProbe() with { PushConstantLayout = false },
+ "DynamicRenderingClear" => PassingProbe() with { DynamicRenderingClear = false },
+ "TimelineSemaphoreWait" => PassingProbe() with { TimelineSemaphoreWait = false },
+ "HostQueryReset" => PassingProbe() with { HostQueryReset = false },
+ _ => PassingProbe() with { OffscreenReadback = false },
+ };
+
+ VulkanCapabilityRecord record = SupportedRecord(probe: probe);
+
+ Assert.Contains(
+ record.SupportFailures,
+ failure => failure.Contains(expected, StringComparison.Ordinal));
+ }
+
+ ///
+ /// The V3 audit noted the GL gate requires sRGB-framebuffer support the
+ /// renderer never uses, and that the Vulkan gate "must not carry the stale
+ /// requirement forward". This is that tripwire.
+ ///
+ [Fact]
+ public void TheGateDoesNotRequireSrgbAnything()
+ {
+ VulkanCapabilityRecord record = SupportedRecord(
+ features: VulkanDeviceFeatureSupport.Complete.Without("MultiDrawIndirect")!);
+
+ Assert.DoesNotContain(
+ record.SupportFailures,
+ failure => failure.Contains("sRGB", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void TheForcedUnsupportedKnobRejectsTheNamedFeature()
+ {
+ VulkanCapabilityRecord forced = VulkanCapabilityRequirements.ApplyForcedUnsupported(
+ SupportedRecord(),
+ "timelineSemaphore");
+
+ Assert.False(forced.IsSupported);
+ Assert.Equal("timelineSemaphore", forced.ForcedUnsupportedFeature);
+ Assert.Contains(
+ forced.SupportFailures,
+ failure => failure.Contains("timelineSemaphore is required", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void TheForcedUnsupportedKnobIgnoresAnUnsetValue()
+ {
+ VulkanCapabilityRecord unchanged = VulkanCapabilityRequirements.ApplyForcedUnsupported(
+ SupportedRecord(),
+ featureName: null);
+
+ Assert.True(unchanged.IsSupported);
+ Assert.Null(unchanged.ForcedUnsupportedFeature);
+ }
+
+ ///
+ /// A knob that names a nonexistent feature must fail loudly. A silent no-op
+ /// would report a pass the operator never actually exercised.
+ ///
+ [Fact]
+ public void TheForcedUnsupportedKnobFailsOnAnUnknownName()
+ {
+ VulkanCapabilityRecord forced = VulkanCapabilityRequirements.ApplyForcedUnsupported(
+ SupportedRecord(),
+ "TeapotShading");
+
+ Assert.False(forced.IsSupported);
+ Assert.Contains(
+ forced.SupportFailures,
+ failure => failure.Contains(
+ "which is not a required Vulkan feature",
+ StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void TheUnsupportedMessageNamesThePlatformDeviceFailuresAndReport()
+ {
+ VulkanCapabilityRecord record = VulkanCapabilityRequirements.ApplyForcedUnsupported(
+ SupportedRecord(),
+ "DynamicRendering");
+
+ string message = VulkanCapabilityGuard.FormatUnsupportedMessage(
+ record,
+ "artifacts/graphical-capabilities-vulkan.json");
+
+ Assert.Contains("win-x64", message, StringComparison.Ordinal);
+ Assert.Contains("AMD Radeon RX 9070 XT", message, StringComparison.Ordinal);
+ Assert.Contains("dynamicRendering is required", message, StringComparison.Ordinal);
+ Assert.Contains("graphical-capabilities-vulkan.json", message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ThrowIfUnsupportedRaisesNotSupportedExceptionForTheExitFourContract()
+ {
+ VulkanCapabilityRecord rejected = VulkanCapabilityRequirements.ApplyForcedUnsupported(
+ SupportedRecord(),
+ "Synchronization2");
+
+ // Program.cs maps NotSupportedException out of window.Run() to exit 4.
+ Assert.Throws(
+ () => VulkanCapabilityGuard.ThrowIfUnsupported(rejected, "report.json"));
+ VulkanCapabilityGuard.ThrowIfUnsupported(SupportedRecord(), "report.json");
+ }
+
+ [Fact]
+ public void TheReportFileNameSitsBesideTheGlOne()
+ {
+ Assert.Equal(
+ "graphical-capabilities-vulkan.json",
+ VulkanCapabilityGuard.ReportFileName);
+ }
+
+ ///
+ /// The JSON report is the artifact an operator sends with a bug report, so
+ /// the fields that identify the machine and explain the refusal must be
+ /// present and readable — enums as names, not integers.
+ ///
+ [Fact]
+ public void TheJsonReportCarriesTheIdentifyingFieldsAsReadableNames()
+ {
+ VulkanCapabilityRecord record = VulkanCapabilityRequirements.ApplyForcedUnsupported(
+ SupportedRecord(),
+ "Maintenance4");
+
+ string json = VulkanCapabilityReportWriter.Serialize(record);
+ using JsonDocument document = JsonDocument.Parse(json);
+ JsonElement root = document.RootElement;
+
+ Assert.Equal("win-x64", root.GetProperty("RuntimeIdentifier").GetString());
+ Assert.Equal("AMD Radeon RX 9070 XT", root.GetProperty("DeviceName").GetString());
+ Assert.Equal("DiscreteGpu", root.GetProperty("DeviceType").GetString());
+ Assert.Equal("Windows", root.GetProperty("OperatingSystem").GetString());
+ Assert.Equal("Maintenance4", root.GetProperty("ForcedUnsupportedFeature").GetString());
+ Assert.False(root.GetProperty("Features").GetProperty("Maintenance4").GetBoolean());
+ Assert.Equal(
+ "B8G8R8A8Unorm",
+ root.GetProperty("Surface").GetProperty("SelectedFormat").GetString());
+ Assert.NotEmpty(root.GetProperty("SupportFailures").EnumerateArray().ToArray());
+ }
+
+ ///
+ /// The record projects onto the backend-neutral contract the renderers
+ /// consult. The alignment fields are load-bearing rather than informational —
+ /// a ring allocation that violates one is a driver error on Vulkan.
+ ///
+ [Fact]
+ public void TheRecordProjectsOntoTheBackendNeutralContract()
+ {
+ VulkanCapabilityRecord record = SupportedRecord(
+ limits: VulkanDeviceLimitSupport.Complete with
+ {
+ MinStorageBufferOffsetAlignment = 16,
+ MinUniformBufferOffsetAlignment = 64,
+ MaxColorSampleCount = 4,
+ MaxDescriptorSetUpdateAfterBindSampledImages = 500_000,
+ MaxPerStageDescriptorUpdateAfterBindSampledImages = 16_384,
+ });
+
+ GpuCapabilityRecord projected = record.ToGpuCapabilityRecord();
+
+ Assert.Equal(GpuBackendKind.Vulkan, projected.Backend);
+ Assert.Equal("AMD Radeon RX 9070 XT", projected.DeviceName);
+ Assert.Equal("Vulkan 1.3.280", projected.ApiVersion);
+ Assert.Equal(16u, projected.MinStorageBufferOffsetAlignment);
+ Assert.Equal(64u, projected.MinUniformBufferOffsetAlignment);
+ Assert.Equal(4u, projected.MaxSampleCount);
+ // The table is limited by whichever of the two counts is smaller.
+ Assert.Equal(16_384u, projected.MaxTextureTableSlots);
+ Assert.Equal(GpuBindingModel.StorageBindingCount, projected.MaxStorageBufferBindings);
+ Assert.True(projected.SupportsMultiDrawIndirect);
+ Assert.True(projected.SupportsDrawParameters);
+ Assert.True(projected.SupportsTextureCompressionBc);
+ Assert.True(projected.SupportsTimestampQueries);
+ // The whole point of the campaign's CPU target: per-frame data written
+ // straight into mapped memory rather than copied through BufferSubData.
+ Assert.True(projected.SupportsPersistentlyMappedRings);
+ Assert.Empty(projected.SupportFailures);
+ }
+
+ ///
+ /// A device the Vulkan gate accepted must also satisfy the backend-neutral
+ /// contract's own SupportFailures. If the two ever disagree, one of
+ /// them is checking something the other is not.
+ ///
+ [Fact]
+ public void AnAcceptedDeviceAlsoSatisfiesTheNeutralContract()
+ {
+ GpuCapabilityRecord projected = SupportedRecord().ToGpuCapabilityRecord();
+
+ Assert.True(projected.IsSupported);
+ }
+
+ [Theory]
+ [InlineData(1u, 3u, 280u)]
+ [InlineData(1u, 4u, 0u)]
+ [InlineData(0u, 0u, 1u)]
+ public void ApiVersionPackingRoundTrips(uint major, uint minor, uint patch)
+ {
+ uint packed = VulkanApiVersion.Make(major, minor, patch);
+
+ Assert.Equal(major, VulkanApiVersion.Major(packed));
+ Assert.Equal(minor, VulkanApiVersion.Minor(packed));
+ Assert.Equal(patch, VulkanApiVersion.Patch(packed));
+ Assert.Equal($"Vulkan {major}.{minor}.{patch}", VulkanApiVersion.Describe(packed));
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanDeviceSelectionTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanDeviceSelectionTests.cs
new file mode 100644
index 00000000..a511445a
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanDeviceSelectionTests.cs
@@ -0,0 +1,340 @@
+using System.Collections.Generic;
+using System.Linq;
+using AcDream.App.Rendering.Gpu.Vk;
+using Silk.NET.Vulkan;
+
+namespace AcDream.App.Tests.Rendering.Gpu.Vk;
+
+///
+/// Campaign V slice V5 — physical-device ranking and queue-family selection.
+///
+/// The development machine has exactly one GPU, so the ordering that actually
+/// matters on a user's machine is never exercised by running the client. These
+/// tests are the only coverage the policy gets, which is precisely why the
+/// ranking is pure data rather than something the enumeration loop decides
+/// inline.
+///
+public sealed class VulkanDeviceSelectionTests
+{
+ private const ulong Gib = 1024ul * 1024ul * 1024ul;
+
+ private static VulkanPhysicalDeviceCandidate Device(
+ int index,
+ string name,
+ PhysicalDeviceType type,
+ ulong heapBytes) => new(
+ index,
+ name,
+ type,
+ ApiVersion: VulkanApiVersion.Make(1, 3, 280),
+ DriverVersion: 1,
+ VendorId: 0x1002,
+ DeviceId: 0x7550,
+ DeviceLocalHeapBytes: heapBytes);
+
+ [Fact]
+ public void DiscreteBeatsIntegratedBeatsVirtualBeatsCpu()
+ {
+ // Vulkan's own enum ordering is Other < Integrated < Discrete < Virtual
+ // < Cpu, which is neither our order nor monotone. Pin the mapping.
+ Assert.True(
+ VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.DiscreteGpu)
+ < VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.IntegratedGpu));
+ Assert.True(
+ VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.IntegratedGpu)
+ < VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.VirtualGpu));
+ Assert.True(
+ VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.VirtualGpu)
+ < VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.Cpu));
+ Assert.True(
+ VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.Cpu)
+ < VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.Other));
+ }
+
+ [Fact]
+ public void TheDiscreteGpuWinsOverALargerIntegratedHeap()
+ {
+ // An integrated GPU can report the whole of system RAM as device-local.
+ // Type preference must dominate the heap tie-break, not the other way up.
+ List candidates =
+ [
+ Device(0, "Intel Iris Xe", PhysicalDeviceType.IntegratedGpu, 32 * Gib),
+ Device(1, "AMD Radeon RX 9070 XT", PhysicalDeviceType.DiscreteGpu, 16 * Gib),
+ ];
+
+ VulkanPhysicalDeviceChoice? choice =
+ VulkanPhysicalDeviceSelection.Choose(candidates, deviceOverride: null);
+
+ Assert.NotNull(choice);
+ Assert.Equal("AMD Radeon RX 9070 XT", choice.Device.DeviceName);
+ Assert.Contains("automatic", choice.Reason, System.StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TwoDiscreteGpusAreBrokenByTheLargerDeviceLocalHeap()
+ {
+ List candidates =
+ [
+ Device(0, "Small Discrete", PhysicalDeviceType.DiscreteGpu, 8 * Gib),
+ Device(1, "Big Discrete", PhysicalDeviceType.DiscreteGpu, 24 * Gib),
+ ];
+
+ VulkanPhysicalDeviceChoice? choice =
+ VulkanPhysicalDeviceSelection.Choose(candidates, deviceOverride: null);
+
+ Assert.Equal("Big Discrete", choice!.Device.DeviceName);
+ }
+
+ ///
+ /// Two identical GPUs must produce the same choice on every launch, or a
+ /// capability report from one run does not describe the next.
+ ///
+ [Fact]
+ public void IdenticalDevicesAreBrokenDeterministicallyByEnumerationIndex()
+ {
+ List candidates =
+ [
+ Device(0, "Twin", PhysicalDeviceType.DiscreteGpu, 16 * Gib),
+ Device(1, "Twin", PhysicalDeviceType.DiscreteGpu, 16 * Gib),
+ ];
+
+ Assert.Equal(
+ 0,
+ VulkanPhysicalDeviceSelection.Choose(candidates, null)!.Device.Index);
+ Assert.Equal(
+ 0,
+ VulkanPhysicalDeviceSelection.Choose([.. candidates.AsEnumerable().Reverse()], null)!
+ .Device.Index);
+ }
+
+ [Fact]
+ public void CpuAndSoftwareDevicesAreLastButStillSelectable()
+ {
+ // lavapipe is the CI second implementation (plan §5, slice V9), so a
+ // CPU device must still be chosen when it is the only one present.
+ List candidates =
+ [
+ Device(0, "llvmpipe", PhysicalDeviceType.Cpu, 0),
+ ];
+
+ VulkanPhysicalDeviceChoice? choice =
+ VulkanPhysicalDeviceSelection.Choose(candidates, deviceOverride: null);
+
+ Assert.Equal("llvmpipe", choice!.Device.DeviceName);
+ }
+
+ [Fact]
+ public void NoEnumeratedDeviceYieldsNoChoice()
+ {
+ Assert.Null(VulkanPhysicalDeviceSelection.Choose([], deviceOverride: null));
+ }
+
+ [Fact]
+ public void TheOverrideMatchesADecimalEnumerationIndex()
+ {
+ List candidates =
+ [
+ Device(0, "AMD Radeon RX 9070 XT", PhysicalDeviceType.DiscreteGpu, 16 * Gib),
+ Device(1, "Intel Iris Xe", PhysicalDeviceType.IntegratedGpu, 32 * Gib),
+ ];
+
+ VulkanPhysicalDeviceChoice? choice =
+ VulkanPhysicalDeviceSelection.Choose(candidates, "1");
+
+ Assert.Equal("Intel Iris Xe", choice!.Device.DeviceName);
+ Assert.Contains("ACDREAM_VULKAN_DEVICE=1", choice.Reason, System.StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData("iris")]
+ [InlineData("IRIS")]
+ [InlineData("Intel Iris Xe")]
+ public void TheOverrideMatchesACaseInsensitiveNameSubstring(string value)
+ {
+ List candidates =
+ [
+ Device(0, "AMD Radeon RX 9070 XT", PhysicalDeviceType.DiscreteGpu, 16 * Gib),
+ Device(1, "Intel Iris Xe", PhysicalDeviceType.IntegratedGpu, 32 * Gib),
+ ];
+
+ VulkanPhysicalDeviceChoice? choice =
+ VulkanPhysicalDeviceSelection.Choose(candidates, value);
+
+ Assert.Equal("Intel Iris Xe", choice!.Device.DeviceName);
+ Assert.Contains("matched device name", choice.Reason, System.StringComparison.Ordinal);
+ }
+
+ ///
+ /// A stale environment variable must not stop the client starting; it must
+ /// say so in the report and start on the right GPU anyway.
+ ///
+ [Fact]
+ public void AnOverrideThatMatchesNothingFallsBackAndSaysSo()
+ {
+ List candidates =
+ [
+ Device(0, "AMD Radeon RX 9070 XT", PhysicalDeviceType.DiscreteGpu, 16 * Gib),
+ ];
+
+ VulkanPhysicalDeviceChoice? choice =
+ VulkanPhysicalDeviceSelection.Choose(candidates, "GeForce");
+
+ Assert.Equal("AMD Radeon RX 9070 XT", choice!.Device.DeviceName);
+ Assert.Contains("matched no enumerated device", choice.Reason, System.StringComparison.Ordinal);
+ }
+
+ ///
+ /// A bare digit is a substring of most real device names — "7" occurs in
+ /// "AMD Radeon RX 9070 XT" — so an all-digits override must be an index and
+ /// nothing else. Falling through to substring matching would make an
+ /// out-of-range index quietly select a device by coincidence.
+ ///
+ [Fact]
+ public void AnOutOfRangeIndexOverrideFallsBackRatherThanMatchingADigitInADeviceName()
+ {
+ List candidates =
+ [
+ Device(0, "AMD Radeon RX 9070 XT", PhysicalDeviceType.DiscreteGpu, 16 * Gib),
+ ];
+
+ VulkanPhysicalDeviceChoice? choice =
+ VulkanPhysicalDeviceSelection.Choose(candidates, "7");
+
+ Assert.Equal(0, choice!.Device.Index);
+ Assert.Contains("matched no enumerated device", choice.Reason, System.StringComparison.Ordinal);
+ }
+
+ ///
+ /// The exact index/substring split must not break names that legitimately
+ /// contain digits: "RX 7900" is not digits alone, so it is a name.
+ ///
+ [Fact]
+ public void ANameContainingDigitsStillMatchesAsASubstring()
+ {
+ List candidates =
+ [
+ Device(0, "AMD Radeon RX 9070 XT", PhysicalDeviceType.DiscreteGpu, 24 * Gib),
+ Device(1, "AMD Radeon RX 7900 XTX", PhysicalDeviceType.DiscreteGpu, 16 * Gib),
+ ];
+
+ VulkanPhysicalDeviceChoice? choice =
+ VulkanPhysicalDeviceSelection.Choose(candidates, "RX 7900");
+
+ Assert.Equal("AMD Radeon RX 7900 XTX", choice!.Device.DeviceName);
+ }
+
+ [Theory]
+ [InlineData("0", true)]
+ [InlineData("12", true)]
+ [InlineData("", false)]
+ [InlineData("-1", false)]
+ [InlineData("7900 XTX", false)]
+ [InlineData("Radeon", false)]
+ public void OnlyAnAllDigitsOverrideIsTreatedAsAnIndex(string value, bool expected)
+ {
+ Assert.Equal(expected, VulkanPhysicalDeviceSelection.IsDecimalIndex(value));
+ }
+
+ [Fact]
+ public void OneFamilyThatDoesBothIsPreferredOverASplitPair()
+ {
+ List families =
+ [
+ new(0, SupportsGraphics: true, SupportsPresent: false),
+ new(1, SupportsGraphics: false, SupportsPresent: true),
+ new(2, SupportsGraphics: true, SupportsPresent: true),
+ ];
+
+ VulkanQueueFamilyChoice? choice = VulkanQueueFamilySelection.Choose(families);
+
+ Assert.NotNull(choice);
+ Assert.True(choice.IsUnified);
+ Assert.Equal(2u, choice.GraphicsFamily);
+ Assert.Equal(2u, choice.PresentFamily);
+ }
+
+ [Fact]
+ public void ASplitDeviceStillProducesAUsablePair()
+ {
+ List families =
+ [
+ new(0, SupportsGraphics: true, SupportsPresent: false),
+ new(1, SupportsGraphics: false, SupportsPresent: true),
+ ];
+
+ VulkanQueueFamilyChoice? choice = VulkanQueueFamilySelection.Choose(families);
+
+ Assert.NotNull(choice);
+ Assert.False(choice.IsUnified);
+ Assert.Equal(0u, choice.GraphicsFamily);
+ Assert.Equal(1u, choice.PresentFamily);
+ }
+
+ [Fact]
+ public void ADeviceWithNoPresentCapableFamilyYieldsNoChoice()
+ {
+ List families =
+ [
+ new(0, SupportsGraphics: true, SupportsPresent: false),
+ ];
+
+ Assert.Null(VulkanQueueFamilySelection.Choose(families));
+ // The headless probe has no surface and so no present requirement.
+ Assert.Equal(0u, VulkanQueueFamilySelection.ChooseGraphicsOnly(families));
+ }
+
+ [Fact]
+ public void AComputeOnlyDeviceHasNoGraphicsFamily()
+ {
+ List families =
+ [
+ new(0, SupportsGraphics: false, SupportsPresent: false),
+ ];
+
+ Assert.Null(VulkanQueueFamilySelection.ChooseGraphicsOnly(families));
+ }
+
+ [Fact]
+ public void RequiredExtensionsMustBeAdvertisedButOptionalOnesMayBeAbsent()
+ {
+ VulkanExtensionPlan plan = VulkanExtensionSelection.Resolve(
+ available: ["VK_KHR_surface", "VK_KHR_win32_surface", "VK_EXT_debug_utils"],
+ required: ["VK_KHR_surface", "VK_KHR_win32_surface"],
+ optional:
+ [
+ VulkanExtensionSelection.DebugUtilsExtension,
+ VulkanExtensionSelection.MemoryBudgetExtension,
+ ]);
+
+ Assert.True(plan.IsSatisfied);
+ Assert.Contains("VK_KHR_surface", plan.Enabled);
+ Assert.Contains(VulkanExtensionSelection.DebugUtilsExtension, plan.Enabled);
+ Assert.Contains(VulkanExtensionSelection.MemoryBudgetExtension, plan.UnavailableOptional);
+ Assert.Empty(plan.MissingRequired);
+ }
+
+ [Fact]
+ public void AMissingRequiredExtensionIsReportedByName()
+ {
+ VulkanExtensionPlan plan = VulkanExtensionSelection.Resolve(
+ available: ["VK_KHR_surface"],
+ required: ["VK_KHR_surface", VulkanExtensionSelection.SwapchainExtension],
+ optional: []);
+
+ Assert.False(plan.IsSatisfied);
+ Assert.Equal(
+ [VulkanExtensionSelection.SwapchainExtension],
+ plan.MissingRequired);
+ }
+
+ [Fact]
+ public void AnExtensionNamedBothRequiredAndOptionalIsEnabledOnce()
+ {
+ VulkanExtensionPlan plan = VulkanExtensionSelection.Resolve(
+ available: [VulkanExtensionSelection.DebugUtilsExtension],
+ required: [VulkanExtensionSelection.DebugUtilsExtension],
+ optional: [VulkanExtensionSelection.DebugUtilsExtension]);
+
+ Assert.Single(plan.Enabled);
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanSwapchainConfigurationTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanSwapchainConfigurationTests.cs
new file mode 100644
index 00000000..bf5956db
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanSwapchainConfigurationTests.cs
@@ -0,0 +1,493 @@
+using System;
+using System.Collections.Generic;
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Gpu.Vk;
+using Silk.NET.Vulkan;
+
+namespace AcDream.App.Tests.Rendering.Gpu.Vk;
+
+///
+/// Campaign V slice V5 — swapchain configuration and lifecycle policy.
+///
+/// Everything here is a decision the swapchain makes before it calls anything,
+/// which is what lets the risk register's "swapchain lifecycle (resize,
+/// minimize, RDP)" row be covered by unit tests rather than only by a connected
+/// gate on one machine.
+///
+public sealed class VulkanSwapchainConfigurationTests
+{
+ private static SurfaceCapabilitiesKHR Capabilities(
+ uint minImages = 2,
+ uint maxImages = 8,
+ uint currentWidth = 1280,
+ uint currentHeight = 720,
+ uint minWidth = 1,
+ uint minHeight = 1,
+ uint maxWidth = 16384,
+ uint maxHeight = 16384,
+ ImageUsageFlags usage =
+ ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferSrcBit,
+ SurfaceTransformFlagsKHR supportedTransforms = SurfaceTransformFlagsKHR.IdentityBitKhr,
+ SurfaceTransformFlagsKHR currentTransform = SurfaceTransformFlagsKHR.IdentityBitKhr,
+ CompositeAlphaFlagsKHR compositeAlpha = CompositeAlphaFlagsKHR.OpaqueBitKhr)
+ => new()
+ {
+ MinImageCount = minImages,
+ MaxImageCount = maxImages,
+ CurrentExtent = new Extent2D(currentWidth, currentHeight),
+ MinImageExtent = new Extent2D(minWidth, minHeight),
+ MaxImageExtent = new Extent2D(maxWidth, maxHeight),
+ MaxImageArrayLayers = 1,
+ SupportedTransforms = supportedTransforms,
+ CurrentTransform = currentTransform,
+ SupportedCompositeAlpha = compositeAlpha,
+ SupportedUsageFlags = usage,
+ };
+
+ ///
+ /// The single highest-severity finding of the V3 audit. acdream is plain
+ /// UNORM end to end; an _SRGB swapchain would encode already
+ /// display-space values and brighten every frame globally, and it would have
+ /// passed silently right up to the V7 differential.
+ ///
+ [Fact]
+ public void TheSwapchainFormatIsUnormAndNeverSrgb()
+ {
+ Assert.Equal(
+ Format.B8G8R8A8Unorm,
+ VulkanSwapchainConfigurationFactory.PreferredFormat);
+
+ SurfaceFormatKHR chosen = VulkanSwapchainConfigurationFactory.ChooseSurfaceFormat(
+ [
+ new SurfaceFormatKHR(Format.B8G8R8A8Srgb, ColorSpaceKHR.SpaceSrgbNonlinearKhr),
+ new SurfaceFormatKHR(Format.B8G8R8A8Unorm, ColorSpaceKHR.SpaceSrgbNonlinearKhr),
+ ]);
+
+ Assert.Equal(Format.B8G8R8A8Unorm, chosen.Format);
+ Assert.Equal(ColorSpaceKHR.SpaceSrgbNonlinearKhr, chosen.ColorSpace);
+ }
+
+ [Fact]
+ public void ASurfaceOfferingOnlySrgbIsDetectedAsMissingUnorm()
+ {
+ IReadOnlyList srgbOnly =
+ [
+ new SurfaceFormatKHR(Format.B8G8R8A8Srgb, ColorSpaceKHR.SpaceSrgbNonlinearKhr),
+ ];
+
+ Assert.False(VulkanSwapchainConfigurationFactory.OffersUnormFormat(srgbOnly));
+ Assert.True(
+ VulkanSwapchainConfigurationFactory.OffersUnormFormat(
+ [
+ new SurfaceFormatKHR(Format.B8G8R8A8Unorm, ColorSpaceKHR.SpaceSrgbNonlinearKhr),
+ ]));
+ }
+
+ [Fact]
+ public void VSyncOnAlwaysSelectsFifo()
+ {
+ FramePacingPolicy pacing = FramePacingPolicy.Resolve(
+ requestedVSync: true,
+ uncappedRendering: false,
+ monitorRefreshHz: 144);
+
+ Assert.True(pacing.UseVSync);
+ Assert.Equal(
+ PresentModeKHR.FifoKhr,
+ VulkanSwapchainConfigurationFactory.ChoosePresentMode(
+ pacing,
+ [PresentModeKHR.FifoKhr, PresentModeKHR.ImmediateKhr, PresentModeKHR.MailboxKhr]));
+ }
+
+ ///
+ /// With VSync off the software pacer owns the ceiling, so IMMEDIATE is
+ /// preferred: MAILBOX inserts a hidden queue between our pacing decision and
+ /// the display, which is the opposite of what issue #235 needs.
+ ///
+ [Fact]
+ public void VSyncOffPrefersImmediateThenMailbox()
+ {
+ FramePacingPolicy softwareCapped = FramePacingPolicy.Resolve(
+ requestedVSync: false,
+ uncappedRendering: false,
+ monitorRefreshHz: 144);
+ Assert.False(softwareCapped.UseVSync);
+ Assert.Equal(144d, softwareCapped.SoftwareLimitHz);
+
+ Assert.Equal(
+ PresentModeKHR.ImmediateKhr,
+ VulkanSwapchainConfigurationFactory.ChoosePresentMode(
+ softwareCapped,
+ [PresentModeKHR.FifoKhr, PresentModeKHR.MailboxKhr, PresentModeKHR.ImmediateKhr]));
+
+ Assert.Equal(
+ PresentModeKHR.MailboxKhr,
+ VulkanSwapchainConfigurationFactory.ChoosePresentMode(
+ softwareCapped,
+ [PresentModeKHR.FifoKhr, PresentModeKHR.MailboxKhr]));
+ }
+
+ [Fact]
+ public void UncappedRenderingAlsoTakesImmediate()
+ {
+ FramePacingPolicy uncapped = FramePacingPolicy.Resolve(
+ requestedVSync: true,
+ uncappedRendering: true,
+ monitorRefreshHz: 144);
+
+ Assert.False(uncapped.UseVSync);
+ Assert.Null(uncapped.SoftwareLimitHz);
+ Assert.Equal(
+ PresentModeKHR.ImmediateKhr,
+ VulkanSwapchainConfigurationFactory.ChoosePresentMode(
+ uncapped,
+ [PresentModeKHR.FifoKhr, PresentModeKHR.ImmediateKhr]));
+ }
+
+ /// FIFO is the only present mode Vulkan guarantees exists.
+ [Fact]
+ public void FifoIsTheFallbackWhenNothingElseIsOffered()
+ {
+ FramePacingPolicy pacing = FramePacingPolicy.Resolve(false, false, 60);
+
+ Assert.Equal(
+ PresentModeKHR.FifoKhr,
+ VulkanSwapchainConfigurationFactory.ChoosePresentMode(
+ pacing,
+ [PresentModeKHR.FifoKhr]));
+ }
+
+ [Fact]
+ public void ThreeImagesAreRequestedForTwoFramesInFlight()
+ {
+ Assert.Equal(3u, VulkanSwapchainConfigurationFactory.PreferredImageCount);
+ Assert.Equal(
+ 3u,
+ VulkanSwapchainConfigurationFactory.ChooseImageCount(Capabilities()));
+ }
+
+ [Fact]
+ public void TheImageCountIsRaisedToTheSurfaceMinimum()
+ {
+ Assert.Equal(
+ 5u,
+ VulkanSwapchainConfigurationFactory.ChooseImageCount(
+ Capabilities(minImages: 5, maxImages: 8)));
+ }
+
+ ///
+ /// maxImageCount == 0 means "no upper limit" in Vulkan. Reading it as
+ /// a literal ceiling would clamp every swapchain to zero images.
+ ///
+ [Fact]
+ public void AZeroMaximumImageCountMeansNoUpperLimit()
+ {
+ Assert.Equal(
+ 3u,
+ VulkanSwapchainConfigurationFactory.ChooseImageCount(
+ Capabilities(minImages: 2, maxImages: 0)));
+ }
+
+ [Fact]
+ public void TheImageCountIsClampedToTheSurfaceMaximum()
+ {
+ Assert.Equal(
+ 2u,
+ VulkanSwapchainConfigurationFactory.ChooseImageCount(
+ Capabilities(minImages: 1, maxImages: 2)));
+ }
+
+ [Fact]
+ public void TheSurfacesOwnExtentWinsWhenItReportsOne()
+ {
+ (uint width, uint height) = VulkanSwapchainConfigurationFactory.ChooseExtent(
+ Capabilities(currentWidth: 2560, currentHeight: 1440),
+ framebufferWidth: 1280,
+ framebufferHeight: 720);
+
+ Assert.Equal(2560u, width);
+ Assert.Equal(1440u, height);
+ }
+
+ ///
+ /// currentExtent.width == uint.MaxValue is the surface saying "you
+ /// choose" — Wayland does this — so the framebuffer size is used, clamped.
+ ///
+ [Fact]
+ public void TheFramebufferSizeIsUsedAndClampedWhenTheSurfaceDefers()
+ {
+ (uint width, uint height) = VulkanSwapchainConfigurationFactory.ChooseExtent(
+ Capabilities(
+ currentWidth: uint.MaxValue,
+ currentHeight: uint.MaxValue,
+ minWidth: 64,
+ minHeight: 64,
+ maxWidth: 1024,
+ maxHeight: 1024),
+ framebufferWidth: 4000,
+ framebufferHeight: 32);
+
+ Assert.Equal(1024u, width);
+ Assert.Equal(64u, height);
+ }
+
+ [Fact]
+ public void AMinimisedWindowProducesAZeroExtentThatIsNotPresentable()
+ {
+ VulkanSwapchainConfiguration configuration =
+ VulkanSwapchainConfigurationFactory.Create(
+ Capabilities(currentWidth: 0, currentHeight: 0),
+ [new SurfaceFormatKHR(Format.B8G8R8A8Unorm, ColorSpaceKHR.SpaceSrgbNonlinearKhr)],
+ [PresentModeKHR.FifoKhr],
+ FramePacingPolicy.Resolve(true, false, 60),
+ framebufferWidth: 0,
+ framebufferHeight: 0);
+
+ Assert.False(configuration.IsPresentable);
+ }
+
+ /// Screenshots copy the presented image, so TRANSFER_SRC is not optional.
+ [Fact]
+ public void TheUsageIncludesColourAttachmentAndTransferSource()
+ {
+ Assert.Equal(
+ ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferSrcBit,
+ VulkanSwapchainConfigurationFactory.RequiredUsage);
+
+ VulkanSwapchainConfiguration configuration =
+ VulkanSwapchainConfigurationFactory.Create(
+ Capabilities(),
+ [new SurfaceFormatKHR(Format.B8G8R8A8Unorm, ColorSpaceKHR.SpaceSrgbNonlinearKhr)],
+ [PresentModeKHR.FifoKhr],
+ FramePacingPolicy.Resolve(true, false, 60),
+ 1280,
+ 720);
+
+ Assert.True(configuration.Usage.HasFlag(ImageUsageFlags.TransferSrcBit));
+ Assert.True(configuration.Usage.HasFlag(ImageUsageFlags.ColorAttachmentBit));
+ Assert.True(configuration.IsPresentable);
+ }
+
+ ///
+ /// A surface that refuses TRANSFER_SRC has already failed the capability
+ /// gate, so the configuration drops the bit rather than forcing it — turning
+ /// a clear startup message into a driver error would be a regression.
+ ///
+ [Fact]
+ public void AnUnsupportedTransferSourceUsageIsDroppedRatherThanForced()
+ {
+ SurfaceCapabilitiesKHR capabilities =
+ Capabilities(usage: ImageUsageFlags.ColorAttachmentBit);
+
+ Assert.False(VulkanSwapchainConfigurationFactory.SupportsTransferSource(capabilities));
+
+ VulkanSwapchainConfiguration configuration =
+ VulkanSwapchainConfigurationFactory.Create(
+ capabilities,
+ [new SurfaceFormatKHR(Format.B8G8R8A8Unorm, ColorSpaceKHR.SpaceSrgbNonlinearKhr)],
+ [PresentModeKHR.FifoKhr],
+ FramePacingPolicy.Resolve(true, false, 60),
+ 1280,
+ 720);
+
+ Assert.False(configuration.Usage.HasFlag(ImageUsageFlags.TransferSrcBit));
+ }
+
+ [Fact]
+ public void IdentityPreTransformIsPreferredAndTheCurrentOneIsTheFallback()
+ {
+ Assert.Equal(
+ SurfaceTransformFlagsKHR.IdentityBitKhr,
+ VulkanSwapchainConfigurationFactory.ChoosePreTransform(Capabilities()));
+
+ Assert.Equal(
+ SurfaceTransformFlagsKHR.Rotate90BitKhr,
+ VulkanSwapchainConfigurationFactory.ChoosePreTransform(
+ Capabilities(
+ supportedTransforms: SurfaceTransformFlagsKHR.Rotate90BitKhr,
+ currentTransform: SurfaceTransformFlagsKHR.Rotate90BitKhr)));
+ }
+
+ [Fact]
+ public void CompositeAlphaPrefersOpaqueAndFallsBackToInherit()
+ {
+ Assert.Equal(
+ CompositeAlphaFlagsKHR.OpaqueBitKhr,
+ VulkanSwapchainConfigurationFactory.ChooseCompositeAlpha(Capabilities()));
+
+ Assert.Equal(
+ CompositeAlphaFlagsKHR.InheritBitKhr,
+ VulkanSwapchainConfigurationFactory.ChooseCompositeAlpha(
+ Capabilities(compositeAlpha: CompositeAlphaFlagsKHR.InheritBitKhr)));
+ }
+
+ ///
+ /// Plan §4.9: "OUT_OF_DATE recreates immediately, SUBOPTIMAL at the next
+ /// frame boundary." A suboptimal image is still renderable, so rebuilding
+ /// mid-frame would throw away work for nothing.
+ ///
+ [Fact]
+ public void AcquireResultsMapToTheDocumentedRecreationTiming()
+ {
+ Assert.Equal(
+ VulkanSwapchainAction.Continue,
+ VulkanSwapchainRecreationPolicy.OnAcquire(Result.Success));
+ Assert.Equal(
+ VulkanSwapchainAction.RecreateNow,
+ VulkanSwapchainRecreationPolicy.OnAcquire(Result.ErrorOutOfDateKhr));
+ Assert.Equal(
+ VulkanSwapchainAction.RecreateAtFrameBoundary,
+ VulkanSwapchainRecreationPolicy.OnAcquire(Result.SuboptimalKhr));
+ Assert.Equal(
+ VulkanSwapchainAction.Idle,
+ VulkanSwapchainRecreationPolicy.OnAcquire(Result.Timeout));
+ Assert.Equal(
+ VulkanSwapchainAction.Idle,
+ VulkanSwapchainRecreationPolicy.OnAcquire(Result.NotReady));
+ Assert.Equal(
+ VulkanSwapchainAction.Fail,
+ VulkanSwapchainRecreationPolicy.OnAcquire(Result.ErrorDeviceLost));
+ }
+
+ [Fact]
+ public void PresentResultsMapToTheDocumentedRecreationTiming()
+ {
+ Assert.Equal(
+ VulkanSwapchainAction.Continue,
+ VulkanSwapchainRecreationPolicy.OnPresent(Result.Success));
+ Assert.Equal(
+ VulkanSwapchainAction.RecreateNow,
+ VulkanSwapchainRecreationPolicy.OnPresent(Result.ErrorOutOfDateKhr));
+ Assert.Equal(
+ VulkanSwapchainAction.RecreateAtFrameBoundary,
+ VulkanSwapchainRecreationPolicy.OnPresent(Result.SuboptimalKhr));
+ Assert.Equal(
+ VulkanSwapchainAction.Fail,
+ VulkanSwapchainRecreationPolicy.OnPresent(Result.ErrorSurfaceLostKhr));
+ }
+
+ [Theory]
+ [InlineData(0u, 0u)]
+ [InlineData(0u, 720u)]
+ [InlineData(1280u, 0u)]
+ public void AZeroAreaFramebufferIsIdleRatherThanAFailedCreate(uint width, uint height)
+ {
+ Assert.Equal(
+ VulkanSwapchainAction.Idle,
+ VulkanSwapchainRecreationPolicy.OnFramebufferSize(width, height));
+ }
+
+ [Fact]
+ public void ANonZeroFramebufferContinues()
+ {
+ Assert.Equal(
+ VulkanSwapchainAction.Continue,
+ VulkanSwapchainRecreationPolicy.OnFramebufferSize(1280, 720));
+ }
+
+ [Fact]
+ public void TheBgraToRgbaSwizzleSwapsOnlyRedAndBlue()
+ {
+ byte[] bgra = [0x10, 0x20, 0x30, 0x40, 0x01, 0x02, 0x03, 0x04];
+
+ VulkanBackbufferSwizzle.SwapRedAndBlueInPlace(bgra);
+
+ Assert.Equal([0x30, 0x20, 0x10, 0x40, 0x03, 0x02, 0x01, 0x04], bgra);
+ }
+
+ [Fact]
+ public void TheSwizzleIsItsOwnInverse()
+ {
+ byte[] pixels = [0x10, 0x20, 0x30, 0x40];
+ byte[] original = [.. pixels];
+
+ VulkanBackbufferSwizzle.SwapRedAndBlueInPlace(pixels);
+ VulkanBackbufferSwizzle.SwapRedAndBlueInPlace(pixels);
+
+ Assert.Equal(original, pixels);
+ }
+
+ [Fact]
+ public void APartialPixelSpanIsRejected()
+ {
+ Assert.Throws(
+ () => VulkanBackbufferSwizzle.SwapRedAndBlueInPlace(new byte[6]));
+ }
+
+ ///
+ /// Vulkan reports a row pitch per image layout that is frequently larger than
+ /// width * 4; reading it as tight produces a diagonally sheared image.
+ ///
+ [Fact]
+ public void RowPaddingIsHonouredWhenConvertingToRgba()
+ {
+ // 2x2 BGRA with 4 bytes of padding per row.
+ byte[] source =
+ [
+ 0x01, 0x02, 0x03, 0xFF, 0x11, 0x12, 0x13, 0xFF, 0xAA, 0xAA, 0xAA, 0xAA,
+ 0x21, 0x22, 0x23, 0xFF, 0x31, 0x32, 0x33, 0xFF, 0xAA, 0xAA, 0xAA, 0xAA,
+ ];
+
+ byte[] rgba = VulkanBackbufferSwizzle.ToRgba(source, 2, 2, sourceRowPitchBytes: 12);
+
+ Assert.Equal(
+ [
+ 0x03, 0x02, 0x01, 0xFF, 0x13, 0x12, 0x11, 0xFF,
+ 0x23, 0x22, 0x21, 0xFF, 0x33, 0x32, 0x31, 0xFF,
+ ],
+ rgba);
+ }
+
+ ///
+ /// A Vulkan image is top-left-origin; FrameScreenshotController flips
+ /// what it is given because glReadPixels hands back bottom-up rows.
+ /// Converting once at the readback boundary keeps that consumer — and the
+ /// PNG's orientation — untouched.
+ ///
+ [Fact]
+ public void TheGlOriginConversionFlipsRowsAsWellAsSwizzling()
+ {
+ byte[] source =
+ [
+ 0x01, 0x02, 0x03, 0xFF,
+ 0x21, 0x22, 0x23, 0xFF,
+ ];
+
+ byte[] rgba = VulkanBackbufferSwizzle.ToGlOriginRgba(
+ source,
+ width: 1,
+ height: 2,
+ sourceRowPitchBytes: 4);
+
+ Assert.Equal(
+ [
+ 0x23, 0x22, 0x21, 0xFF,
+ 0x03, 0x02, 0x01, 0xFF,
+ ],
+ rgba);
+ }
+
+ [Fact]
+ public void ARowPitchNarrowerThanTheImageIsRejected()
+ {
+ Assert.Throws(
+ () => VulkanBackbufferSwizzle.ToRgba(new byte[16], 2, 2, sourceRowPitchBytes: 4));
+ }
+
+ ///
+ /// The bring-up clear colour must be visibly deliberate: black reads as an
+ /// unpainted window, and magenta is this codebase's "unresolved texture slot"
+ /// sentinel.
+ ///
+ [Fact]
+ public void TheBringUpClearColourIsNeitherBlackNorTheMagentaSentinel()
+ {
+ float[] colour = VulkanBringUpHost.ClearColor;
+
+ Assert.Equal(4, colour.Length);
+ Assert.Equal(1f, colour[3]);
+ Assert.True(colour[0] + colour[1] + colour[2] > 0f);
+ Assert.False(colour[0] == 1f && colour[1] == 0f && colour[2] == 1f);
+ Assert.Equal(2, VulkanBringUpHost.FlightCount);
+ }
+}
diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
index 9d9d5f51..8b53ff8c 100644
--- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
+++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
@@ -408,4 +408,86 @@ public sealed class RuntimeOptionsTests
Assert.Throws(() => RuntimeOptions.Parse(null!, EmptyEnv()));
Assert.Throws(() => RuntimeOptions.Parse(AnyDatDir, null!));
}
+
+ ///
+ /// Campaign V slice V5. OpenGL is the shipping backend until slice V10, so
+ /// the default must be GL and a typo must never silently start the dark
+ /// Vulkan host.
+ ///
+ [Fact]
+ public void RenderBackend_DefaultsToGl()
+ {
+ Assert.Equal(
+ RenderBackendKind.Gl,
+ RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).RenderBackend);
+ }
+
+ [Theory]
+ [InlineData("vulkan")]
+ [InlineData("Vulkan")]
+ [InlineData("VULKAN")]
+ public void RenderBackend_SelectsVulkanCaseInsensitively(string value)
+ {
+ Assert.Equal(
+ RenderBackendKind.Vulkan,
+ RuntimeOptions.Parse(
+ AnyDatDir,
+ Env(new() { ["ACDREAM_RENDER_BACKEND"] = value })).RenderBackend);
+ }
+
+ [Theory]
+ [InlineData("gl")]
+ [InlineData("opengl")]
+ [InlineData("")]
+ [InlineData("vulcan")]
+ [InlineData("vk")]
+ [InlineData(" vulkan")]
+ public void RenderBackend_AnythingElseStaysOnGl(string value)
+ {
+ Assert.Equal(
+ RenderBackendKind.Gl,
+ RuntimeOptions.Parse(
+ AnyDatDir,
+ Env(new() { ["ACDREAM_RENDER_BACKEND"] = value })).RenderBackend);
+ }
+
+ [Fact]
+ public void VulkanDeviceOverride_IsNullWhenUnsetOrEmpty()
+ {
+ Assert.Null(RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).VulkanDeviceOverride);
+ Assert.Null(
+ RuntimeOptions.Parse(
+ AnyDatDir,
+ Env(new() { ["ACDREAM_VULKAN_DEVICE"] = "" })).VulkanDeviceOverride);
+ }
+
+ ///
+ /// The override is carried verbatim — index or name substring — because the
+ /// capability report records exactly what the operator asked for, matched or
+ /// not.
+ ///
+ [Theory]
+ [InlineData("1")]
+ [InlineData("Radeon")]
+ public void VulkanDeviceOverride_IsCarriedVerbatim(string value)
+ {
+ Assert.Equal(
+ value,
+ RuntimeOptions.Parse(
+ AnyDatDir,
+ Env(new() { ["ACDREAM_VULKAN_DEVICE"] = value })).VulkanDeviceOverride);
+ }
+
+ [Fact]
+ public void VulkanForcedUnsupportedFeature_DrivesTheExitFourGateKnob()
+ {
+ Assert.Null(
+ RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).VulkanForcedUnsupportedFeature);
+ Assert.Equal(
+ "timelineSemaphore",
+ RuntimeOptions.Parse(
+ AnyDatDir,
+ Env(new() { ["ACDREAM_VULKAN_FORCE_UNSUPPORTED"] = "timelineSemaphore" }))
+ .VulkanForcedUnsupportedFeature);
+ }
}