acdream/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanCapabilityGateTests.cs
Erik e8a4c1af3f feat(render): Campaign V slice V5 - Vulkan bring-up, dark
Instance, physical-device selection, logical device, queues, swapchain, and the
three-layer capability gate, behind ACDREAM_RENDER_BACKEND=vulkan. Nothing of
the game renders through it. OpenGL stays the default and the only live backend
until V10, and with the variable unset or set to gl the GL path executes not one
new statement.

The shape of the slice. Plan §4.11 asks the Vulkan gate to mirror the GL one
exactly - passive record, active probes, an Evaluate producing operator-facing
sentences, NotSupportedException into Program.cs's exit-code-4 contract, and an
atomic JSON report. The harder question was where to put the seam, because a
capability gate is precisely the code you cannot exercise on the machine that
already passes it: this box has one discrete GPU, so device ranking, the split-
queue path, an sRGB-only surface, a minimised window and a device missing
descriptorBindingVariableDescriptorCount are all unreachable by running the
client. So every decision the gate makes is a pure function over plain records,
and the Silk interop layer only has to be right about which Vulkan field feeds
which property. VulkanPhysicalDeviceSelection ranks candidates,
VulkanExtensionSelection does the required-versus-optional set arithmetic,
VulkanSwapchainConfigurationFactory chooses format, present mode, image count,
extent, usage, transform and composite alpha, VulkanSwapchainRecreationPolicy
classifies every acquire and present result, and
VulkanCapabilityRequirements.Evaluate turns a captured record into failure
sentences. All of it is unit-tested with no driver, no device and no window.

This commit is the integration of that work onto the post-revert tree. The V5
branch was written on b064668b, before V4c/V4d were reverted, so GameWindow.cs
had to be merged rather than taken: the file here is eb2ba4e5's GameWindow plus
V5's fifteen-line backend branch, and it keeps _terrainModernShader, which the
revert restored and which the V5 branch never had. Every other file is byte-
identical to the branch - git diff e1ef4313 over Rendering/Gpu/Vk,
tests/.../Gpu/Vk and RenderBackendKind.cs is empty, no BOM was introduced, and
CRLF is uniform across all seventeen files.

Gate results, recorded verbatim.

Release build: succeeded, 0 warnings, 0 errors.

App tests, Release: Failed 0, Passed 3981, Skipped 3, Total 3984 - the 3,866
baseline plus V5's 115 new tests, exactly.

Offline pixel gate against eb2ba4e5: PASS world-offline.png, differing fraction
1.06534090909091E-05, which is 6 differing pixels out of the 563,200 compared
after the top 280 sky rows are masked. §5.1's re-measured same-commit control
band is 15-23 pixels at fraction <= 4.1e-05, so this sits below the noise floor
rather than merely inside it - the expected result for a slice that adds no
statement to the GL path.

Vulkan check (a), ACDREAM_RENDER_BACKEND=vulkan on the RX 9070 XT with an
automation artifact directory:

  vulkan: capability gate passed (Windows, AMD Radeon RX 9070 XT, Vulkan
  1.4.349, vendor 0x1002, device 0x7550, driver 2.0.395 (raw 0x0080018B));
  swapchain B8G8R8A8Unorm/PresentModeImmediateKhr 1280x720 x3
  vulkan: device selection - automatic: 'AMD Radeon RX 9070 XT' (DiscreteGpu,
  15.92 GiB device-local) ranked first of 2 enumerated device(s).
  [world-gate] screenshot-complete name=vulkan-bringup path=...
  artifacts\vk-bringup\vulkan-bringup.png size=1280x720
  vulkan: presented 64609 clear-colour frame(s); shutting down.

Exit code 0 on CloseMainWindow. The PNG is 5,238 bytes, 1280x720, and uniformly
RGBA(11,19,39,255) - exactly ClearColor [0.043, 0.075, 0.153, 1] scaled to
UNORM. Orientation is right-side-up by construction rather than by inspection,
which a uniform clear could not show: VulkanBackbufferSwizzle.ToGlOriginRgba
writes source row y into destination row height-1-y precisely because
FrameScreenshotController flips again on the way to the PNG, so the two
cancel. That double-flip is unit-tested.

Vulkan check (b), ACDREAM_VULKAN_FORCE_UNSUPPORTED=timelineSemaphore:

  [ERR] acdream's Vulkan renderer is unsupported by the selected device.
  Platform: win-x64, Windows, AMD Radeon RX 9070 XT (DiscreteGpu), Vulkan
  1.4.349, vendor 0x1002, device 0x7550, driver 2.0.395 (raw 0x0080018B)
   - timelineSemaphore is required; the frame serial is the semaphore value.
  Full capability report: ...\diagnostics\graphical-capabilities-vulkan.json

Exit code 4. The report records ForcedUnsupportedFeature timelineSemaphore,
TimelineSemaphore false against an otherwise complete feature set, and the
matching SupportFailures sentence, so the injected rejection is distinguishable
from a genuinely absent feature. Both enumerated devices, all five surface
formats, all four present modes and a clean FunctionProbe with no failures are
recorded beside it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 07:11:50 +02:00

545 lines
21 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Every field on <see cref="VulkanDeviceFeatureSupport"/> 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.
/// </summary>
[Fact]
public void EveryRequiredFeatureIsIndividuallyEnforced()
{
IEnumerable<string> 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));
}
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <summary>
/// A headless capture (no window) is still evaluable — that is what lets the
/// probe be offscreen — and simply carries no surface requirements.
/// </summary>
[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));
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// A knob that names a nonexistent feature must fail loudly. A silent no-op
/// would report a pass the operator never actually exercised.
/// </summary>
[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<NotSupportedException>(
() => VulkanCapabilityGuard.ThrowIfUnsupported(rejected, "report.json"));
VulkanCapabilityGuard.ThrowIfUnsupported(SupportedRecord(), "report.json");
}
[Fact]
public void TheReportFileNameSitsBesideTheGlOne()
{
Assert.Equal(
"graphical-capabilities-vulkan.json",
VulkanCapabilityGuard.ReportFileName);
}
/// <summary>
/// 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.
/// </summary>
[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());
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// A device the Vulkan gate accepted must also satisfy the backend-neutral
/// contract's own <c>SupportFailures</c>. If the two ever disagree, one of
/// them is checking something the other is not.
/// </summary>
[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));
}
}