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));
}
}