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)); } /// /// Campaign V slice V6g, plan §5.5.7 defect 1. The V6b layout declared all /// ten of set 0's bindings STORAGE_BUFFER_DYNAMIC and was rejected by /// the RX 9070 XT's limit of 8 — silently, and only visible under the /// validation layer. The split has to stay inside Vulkan's GUARANTEED /// minimum, not merely inside one device's: at four, no conformant /// implementation can fail the layout, which is what slice V9's lavapipe row /// and the deferred physical Linux row depend on. /// [Fact] public void TheDynamicStorageBindingSplitFitsVulkansGuaranteedMinimum() { const uint VulkanGuaranteedMinimum = 4; Assert.True( VulkanPipelineLayouts.DynamicStorageBindingCount <= VulkanGuaranteedMinimum, $"set 0 declares {VulkanPipelineLayouts.DynamicStorageBindingCount} dynamic storage " + $"bindings; Vulkan only guarantees {VulkanGuaranteedMinimum}."); // The bindings that stay dynamic are the ones a renderer feeds from the // per-frame ring; the rest point at long-lived buffers bound once per // pass and buy nothing from a dynamic offset. Assert.True(VulkanPipelineLayouts.IsDynamicStorageBinding(GpuBindingModel.StorageInstances)); Assert.True(VulkanPipelineLayouts.IsDynamicStorageBinding(GpuBindingModel.StorageBatches)); Assert.False(VulkanPipelineLayouts.IsDynamicStorageBinding(GpuBindingModel.StorageGlobalLights)); Assert.False(VulkanPipelineLayouts.IsDynamicStorageBinding(GpuBindingModel.StorageClipRegions)); // Binding 9 (the GL-only uvec2 handle-table emulation, StorageTextureTable) // is deleted as of Campaign V slice V11 — the Vulkan backend always bound // set 2 instead and never touched it, so there is no longer a ninth // binding to assert never spends a scarce dynamic descriptor. } [Fact] public void ADeviceWithTooFewDynamicBufferDescriptorsIsRejected() { VulkanCapabilityRecord storage = SupportedRecord( limits: VulkanDeviceLimitSupport.Complete with { MaxDescriptorSetStorageBuffersDynamic = VulkanPipelineLayouts.DynamicStorageBindingCount - 1, }); VulkanCapabilityRecord uniform = SupportedRecord( limits: VulkanDeviceLimitSupport.Complete with { MaxDescriptorSetUniformBuffersDynamic = VulkanFrameBindings.DynamicUniformBindingCount - 1, }); Assert.Contains( storage.SupportFailures, failure => failure.Contains("dynamic storage bindings", StringComparison.Ordinal)); Assert.Contains( uniform.SupportFailures, failure => failure.Contains("dynamic uniform bindings", StringComparison.Ordinal)); Assert.Empty(SupportedRecord().SupportFailures); } [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)); } }