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 onb064668b, 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 againsteb2ba4e5: 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>
340 lines
12 KiB
C#
340 lines
12 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using AcDream.App.Rendering.Gpu.Vk;
|
|
using Silk.NET.Vulkan;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<VulkanPhysicalDeviceCandidate> 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<VulkanPhysicalDeviceCandidate> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Two identical GPUs must produce the same choice on every launch, or a
|
|
/// capability report from one run does not describe the next.
|
|
/// </summary>
|
|
[Fact]
|
|
public void IdenticalDevicesAreBrokenDeterministicallyByEnumerationIndex()
|
|
{
|
|
List<VulkanPhysicalDeviceCandidate> 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<VulkanPhysicalDeviceCandidate> 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<VulkanPhysicalDeviceCandidate> 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<VulkanPhysicalDeviceCandidate> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A stale environment variable must not stop the client starting; it must
|
|
/// say so in the report and start on the right GPU anyway.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AnOverrideThatMatchesNothingFallsBackAndSaysSo()
|
|
{
|
|
List<VulkanPhysicalDeviceCandidate> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AnOutOfRangeIndexOverrideFallsBackRatherThanMatchingADigitInADeviceName()
|
|
{
|
|
List<VulkanPhysicalDeviceCandidate> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The exact index/substring split must not break names that legitimately
|
|
/// contain digits: "RX 7900" is not digits alone, so it is a name.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ANameContainingDigitsStillMatchesAsASubstring()
|
|
{
|
|
List<VulkanPhysicalDeviceCandidate> 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<VulkanQueueFamilyCandidate> 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<VulkanQueueFamilyCandidate> 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<VulkanQueueFamilyCandidate> 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<VulkanQueueFamilyCandidate> 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);
|
|
}
|
|
}
|