V6f ran the bring-up host once under VK_LAYER_KHRONOS_validation and found
seven VUIDs, every one of them on the path any world frame takes (plan
§5.5.7). This closes all of them, plus a fourth defect in the same log that
§5.5.7 did not call out. The host now runs validation-clean: zero errors and
zero warnings over 39,855 frames.
Nothing outside Gpu/Vk/ is touched, so the GL backend executes not one changed
statement. The offline pixel gate says so too — 4.08e-05 differing fraction
against f8dbe2ee, which is exactly the value the campaign recorded as its own
same-commit control (§5.1's 15–23 pixel band).
The dynamic-descriptor limit was a decision, not a patch. V6b declared all ten
of set 0's bindings STORAGE_BUFFER_DYNAMIC on the reasoning that the contract
lets a renderer bind any range per draw. That is true and still cost nothing to
honour for four of them: a dynamic descriptor buys exactly one thing, the
ability to address the SAME buffer at a DIFFERENT offset without a descriptor
write, which is the shape of a ring allocation and of nothing else. So the
ring-fed bindings — instances, batches, clip slots, instance light sets — stay
dynamic, and the ones pointing at a long-lived buffer written whole and bound
once per pass carry their offset in the descriptor instead. Binding 9 is the
clearest of those: it is the GL-only uvec2 handle table, which the Vulkan
backend never binds at all.
That lands on four dynamic storage descriptors. The RX 9070 XT allows eight, so
eight would have worked here — but four is Vulkan's GUARANTEED minimum, which
means no conformant device can fail this layout, and V9's lavapipe row and the
deferred physical Linux row both depend on that. The count is asserted against
maxDescriptorSetStorageBuffersDynamic in the capability record, so a device that
cannot serve it is rejected at startup in the report under the same exit-code-4
contract as every other requirement, rather than failing silently at
vkCreatePipelineLayout the way this one did.
Depth-off pipelines were malformed in any pass that has depth. Dynamic rendering
bakes the depth/stencil attachment format into the pipeline and requires it to
equal the pass's; V6c set it only when the pipeline itself tested or wrote
depth. Debug lines, the retained UI and the sky are all depth-off and all
composite over the main pass, so this was not an edge case. The same
GpuPipelineDescription is legitimately used both ways — ui-text opens its own
depth-less pass — so the description cannot answer the question and the backend
builds both variants, binding whichever matches what vkCmdBeginRendering was
actually handed rather than what the pass asked for. Both are built at startup
against the persisted cache, so no frame compiles one. A slice entitled to
change the contract should add a depth-format field the way V6d added
ColorFormat; this is the honest expression of the gap until then.
vk-backbuffer-depth and vk-backbuffer-msaa-color were created UNDEFINED and
never moved. Both now barrier on every backbuffer pass — from UNDEFINED on the
first use after Configure, from attachment-optimal with a write-after-write
dependency thereafter. The dependency matters on its own account, not just the
layout: two passes in one frame write both images and so does the next frame,
and Vulkan orders nothing between render-pass instances.
The fourth defect is the one worth reading twice. CaptureBackbuffer transitioned
the LAST PRESENTED swapchain image to TRANSFER_SRC and copied out of it. After
vkQueuePresentKHR that image belongs to the presentation engine and its contents
are not ours to read — and the pixels were usually right, which is precisely the
problem. This campaign spent three sections of its own plan (§5.5.1–§5.5.3)
discovering how much a capture instrument that is "usually right" can cost, and
shipping that shape on the new backend would have made every Vulkan PNG, and the
V7 differential built on them, formally undefined. The frame now copies its own
output into a host-readable buffer while it still owns the image, and the
capture reads that. Retention is opt-in, armed when an artifact directory
exists: one full-resolution copy per frame is worth nothing to a player and is
the entire instrument to a gate. The old one-shot command pool, device-idle wait
and per-capture readback buffer go with it.
Two gaps found and recorded in §5.5.8 rather than fixed, both outside this
slice's brief. UniformSkyParams (set 1, binding 4) is not in the uniform set
layout, so whoever first draws sky on Vulkan must add it. And a binding pointed
at two different buffers within one frame silently corrupts the earlier draws,
on dynamic and plain descriptors alike, because descriptor contents are read at
execution time — no consumer does that today, but WbDrawDispatcher and
EnvCellRenderer each own their own instance and batch buffers and both bind
bindings 0, 1, 3, 4 and 5 in one frame, so the Vulkan world arm has to know
before it is written.
Gates: Release build; App tests 4,075 passed / 3 skipped (baseline 4,073 + the
two new capability cases); GL offline pixel gate PASS at 4.08e-05; one
validation-layer Vulkan run, clean, with the captured PNG inspected and correct
in orientation, colour and glyph coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
603 lines
24 KiB
C#
603 lines
24 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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6g, plan §5.5.7 defect 1. The V6b layout declared all
|
|
/// ten of set 0's bindings <c>STORAGE_BUFFER_DYNAMIC</c> 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.
|
|
/// </summary>
|
|
[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 is the GL-only uvec2 handle-table emulation. The Vulkan
|
|
// backend binds set 2 instead and never touches it, so spending a scarce
|
|
// dynamic descriptor on it would be spending one on a binding that is
|
|
// provably never bound.
|
|
Assert.False(VulkanPipelineLayouts.IsDynamicStorageBinding(GpuBindingModel.StorageTextureTable));
|
|
}
|
|
|
|
[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));
|
|
}
|
|
|
|
/// <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));
|
|
}
|
|
}
|