acdream/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanSwapchainConfigurationTests.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

493 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu.Vk;
using Silk.NET.Vulkan;
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
/// <summary>
/// Campaign V slice V5 — swapchain configuration and lifecycle policy.
///
/// Everything here is a decision the swapchain makes before it calls anything,
/// which is what lets the risk register's "swapchain lifecycle (resize,
/// minimize, RDP)" row be covered by unit tests rather than only by a connected
/// gate on one machine.
/// </summary>
public sealed class VulkanSwapchainConfigurationTests
{
private static SurfaceCapabilitiesKHR Capabilities(
uint minImages = 2,
uint maxImages = 8,
uint currentWidth = 1280,
uint currentHeight = 720,
uint minWidth = 1,
uint minHeight = 1,
uint maxWidth = 16384,
uint maxHeight = 16384,
ImageUsageFlags usage =
ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferSrcBit,
SurfaceTransformFlagsKHR supportedTransforms = SurfaceTransformFlagsKHR.IdentityBitKhr,
SurfaceTransformFlagsKHR currentTransform = SurfaceTransformFlagsKHR.IdentityBitKhr,
CompositeAlphaFlagsKHR compositeAlpha = CompositeAlphaFlagsKHR.OpaqueBitKhr)
=> new()
{
MinImageCount = minImages,
MaxImageCount = maxImages,
CurrentExtent = new Extent2D(currentWidth, currentHeight),
MinImageExtent = new Extent2D(minWidth, minHeight),
MaxImageExtent = new Extent2D(maxWidth, maxHeight),
MaxImageArrayLayers = 1,
SupportedTransforms = supportedTransforms,
CurrentTransform = currentTransform,
SupportedCompositeAlpha = compositeAlpha,
SupportedUsageFlags = usage,
};
/// <summary>
/// The single highest-severity finding of the V3 audit. acdream is plain
/// UNORM end to end; an <c>_SRGB</c> swapchain would encode already
/// display-space values and brighten every frame globally, and it would have
/// passed silently right up to the V7 differential.
/// </summary>
[Fact]
public void TheSwapchainFormatIsUnormAndNeverSrgb()
{
Assert.Equal(
Format.B8G8R8A8Unorm,
VulkanSwapchainConfigurationFactory.PreferredFormat);
SurfaceFormatKHR chosen = VulkanSwapchainConfigurationFactory.ChooseSurfaceFormat(
[
new SurfaceFormatKHR(Format.B8G8R8A8Srgb, ColorSpaceKHR.SpaceSrgbNonlinearKhr),
new SurfaceFormatKHR(Format.B8G8R8A8Unorm, ColorSpaceKHR.SpaceSrgbNonlinearKhr),
]);
Assert.Equal(Format.B8G8R8A8Unorm, chosen.Format);
Assert.Equal(ColorSpaceKHR.SpaceSrgbNonlinearKhr, chosen.ColorSpace);
}
[Fact]
public void ASurfaceOfferingOnlySrgbIsDetectedAsMissingUnorm()
{
IReadOnlyList<SurfaceFormatKHR> srgbOnly =
[
new SurfaceFormatKHR(Format.B8G8R8A8Srgb, ColorSpaceKHR.SpaceSrgbNonlinearKhr),
];
Assert.False(VulkanSwapchainConfigurationFactory.OffersUnormFormat(srgbOnly));
Assert.True(
VulkanSwapchainConfigurationFactory.OffersUnormFormat(
[
new SurfaceFormatKHR(Format.B8G8R8A8Unorm, ColorSpaceKHR.SpaceSrgbNonlinearKhr),
]));
}
[Fact]
public void VSyncOnAlwaysSelectsFifo()
{
FramePacingPolicy pacing = FramePacingPolicy.Resolve(
requestedVSync: true,
uncappedRendering: false,
monitorRefreshHz: 144);
Assert.True(pacing.UseVSync);
Assert.Equal(
PresentModeKHR.FifoKhr,
VulkanSwapchainConfigurationFactory.ChoosePresentMode(
pacing,
[PresentModeKHR.FifoKhr, PresentModeKHR.ImmediateKhr, PresentModeKHR.MailboxKhr]));
}
/// <summary>
/// With VSync off the software pacer owns the ceiling, so IMMEDIATE is
/// preferred: MAILBOX inserts a hidden queue between our pacing decision and
/// the display, which is the opposite of what issue #235 needs.
/// </summary>
[Fact]
public void VSyncOffPrefersImmediateThenMailbox()
{
FramePacingPolicy softwareCapped = FramePacingPolicy.Resolve(
requestedVSync: false,
uncappedRendering: false,
monitorRefreshHz: 144);
Assert.False(softwareCapped.UseVSync);
Assert.Equal(144d, softwareCapped.SoftwareLimitHz);
Assert.Equal(
PresentModeKHR.ImmediateKhr,
VulkanSwapchainConfigurationFactory.ChoosePresentMode(
softwareCapped,
[PresentModeKHR.FifoKhr, PresentModeKHR.MailboxKhr, PresentModeKHR.ImmediateKhr]));
Assert.Equal(
PresentModeKHR.MailboxKhr,
VulkanSwapchainConfigurationFactory.ChoosePresentMode(
softwareCapped,
[PresentModeKHR.FifoKhr, PresentModeKHR.MailboxKhr]));
}
[Fact]
public void UncappedRenderingAlsoTakesImmediate()
{
FramePacingPolicy uncapped = FramePacingPolicy.Resolve(
requestedVSync: true,
uncappedRendering: true,
monitorRefreshHz: 144);
Assert.False(uncapped.UseVSync);
Assert.Null(uncapped.SoftwareLimitHz);
Assert.Equal(
PresentModeKHR.ImmediateKhr,
VulkanSwapchainConfigurationFactory.ChoosePresentMode(
uncapped,
[PresentModeKHR.FifoKhr, PresentModeKHR.ImmediateKhr]));
}
/// <summary>FIFO is the only present mode Vulkan guarantees exists.</summary>
[Fact]
public void FifoIsTheFallbackWhenNothingElseIsOffered()
{
FramePacingPolicy pacing = FramePacingPolicy.Resolve(false, false, 60);
Assert.Equal(
PresentModeKHR.FifoKhr,
VulkanSwapchainConfigurationFactory.ChoosePresentMode(
pacing,
[PresentModeKHR.FifoKhr]));
}
[Fact]
public void ThreeImagesAreRequestedForTwoFramesInFlight()
{
Assert.Equal(3u, VulkanSwapchainConfigurationFactory.PreferredImageCount);
Assert.Equal(
3u,
VulkanSwapchainConfigurationFactory.ChooseImageCount(Capabilities()));
}
[Fact]
public void TheImageCountIsRaisedToTheSurfaceMinimum()
{
Assert.Equal(
5u,
VulkanSwapchainConfigurationFactory.ChooseImageCount(
Capabilities(minImages: 5, maxImages: 8)));
}
/// <summary>
/// <c>maxImageCount == 0</c> means "no upper limit" in Vulkan. Reading it as
/// a literal ceiling would clamp every swapchain to zero images.
/// </summary>
[Fact]
public void AZeroMaximumImageCountMeansNoUpperLimit()
{
Assert.Equal(
3u,
VulkanSwapchainConfigurationFactory.ChooseImageCount(
Capabilities(minImages: 2, maxImages: 0)));
}
[Fact]
public void TheImageCountIsClampedToTheSurfaceMaximum()
{
Assert.Equal(
2u,
VulkanSwapchainConfigurationFactory.ChooseImageCount(
Capabilities(minImages: 1, maxImages: 2)));
}
[Fact]
public void TheSurfacesOwnExtentWinsWhenItReportsOne()
{
(uint width, uint height) = VulkanSwapchainConfigurationFactory.ChooseExtent(
Capabilities(currentWidth: 2560, currentHeight: 1440),
framebufferWidth: 1280,
framebufferHeight: 720);
Assert.Equal(2560u, width);
Assert.Equal(1440u, height);
}
/// <summary>
/// <c>currentExtent.width == uint.MaxValue</c> is the surface saying "you
/// choose" — Wayland does this — so the framebuffer size is used, clamped.
/// </summary>
[Fact]
public void TheFramebufferSizeIsUsedAndClampedWhenTheSurfaceDefers()
{
(uint width, uint height) = VulkanSwapchainConfigurationFactory.ChooseExtent(
Capabilities(
currentWidth: uint.MaxValue,
currentHeight: uint.MaxValue,
minWidth: 64,
minHeight: 64,
maxWidth: 1024,
maxHeight: 1024),
framebufferWidth: 4000,
framebufferHeight: 32);
Assert.Equal(1024u, width);
Assert.Equal(64u, height);
}
[Fact]
public void AMinimisedWindowProducesAZeroExtentThatIsNotPresentable()
{
VulkanSwapchainConfiguration configuration =
VulkanSwapchainConfigurationFactory.Create(
Capabilities(currentWidth: 0, currentHeight: 0),
[new SurfaceFormatKHR(Format.B8G8R8A8Unorm, ColorSpaceKHR.SpaceSrgbNonlinearKhr)],
[PresentModeKHR.FifoKhr],
FramePacingPolicy.Resolve(true, false, 60),
framebufferWidth: 0,
framebufferHeight: 0);
Assert.False(configuration.IsPresentable);
}
/// <summary>Screenshots copy the presented image, so TRANSFER_SRC is not optional.</summary>
[Fact]
public void TheUsageIncludesColourAttachmentAndTransferSource()
{
Assert.Equal(
ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferSrcBit,
VulkanSwapchainConfigurationFactory.RequiredUsage);
VulkanSwapchainConfiguration configuration =
VulkanSwapchainConfigurationFactory.Create(
Capabilities(),
[new SurfaceFormatKHR(Format.B8G8R8A8Unorm, ColorSpaceKHR.SpaceSrgbNonlinearKhr)],
[PresentModeKHR.FifoKhr],
FramePacingPolicy.Resolve(true, false, 60),
1280,
720);
Assert.True(configuration.Usage.HasFlag(ImageUsageFlags.TransferSrcBit));
Assert.True(configuration.Usage.HasFlag(ImageUsageFlags.ColorAttachmentBit));
Assert.True(configuration.IsPresentable);
}
/// <summary>
/// A surface that refuses TRANSFER_SRC has already failed the capability
/// gate, so the configuration drops the bit rather than forcing it — turning
/// a clear startup message into a driver error would be a regression.
/// </summary>
[Fact]
public void AnUnsupportedTransferSourceUsageIsDroppedRatherThanForced()
{
SurfaceCapabilitiesKHR capabilities =
Capabilities(usage: ImageUsageFlags.ColorAttachmentBit);
Assert.False(VulkanSwapchainConfigurationFactory.SupportsTransferSource(capabilities));
VulkanSwapchainConfiguration configuration =
VulkanSwapchainConfigurationFactory.Create(
capabilities,
[new SurfaceFormatKHR(Format.B8G8R8A8Unorm, ColorSpaceKHR.SpaceSrgbNonlinearKhr)],
[PresentModeKHR.FifoKhr],
FramePacingPolicy.Resolve(true, false, 60),
1280,
720);
Assert.False(configuration.Usage.HasFlag(ImageUsageFlags.TransferSrcBit));
}
[Fact]
public void IdentityPreTransformIsPreferredAndTheCurrentOneIsTheFallback()
{
Assert.Equal(
SurfaceTransformFlagsKHR.IdentityBitKhr,
VulkanSwapchainConfigurationFactory.ChoosePreTransform(Capabilities()));
Assert.Equal(
SurfaceTransformFlagsKHR.Rotate90BitKhr,
VulkanSwapchainConfigurationFactory.ChoosePreTransform(
Capabilities(
supportedTransforms: SurfaceTransformFlagsKHR.Rotate90BitKhr,
currentTransform: SurfaceTransformFlagsKHR.Rotate90BitKhr)));
}
[Fact]
public void CompositeAlphaPrefersOpaqueAndFallsBackToInherit()
{
Assert.Equal(
CompositeAlphaFlagsKHR.OpaqueBitKhr,
VulkanSwapchainConfigurationFactory.ChooseCompositeAlpha(Capabilities()));
Assert.Equal(
CompositeAlphaFlagsKHR.InheritBitKhr,
VulkanSwapchainConfigurationFactory.ChooseCompositeAlpha(
Capabilities(compositeAlpha: CompositeAlphaFlagsKHR.InheritBitKhr)));
}
/// <summary>
/// Plan §4.9: "OUT_OF_DATE recreates immediately, SUBOPTIMAL at the next
/// frame boundary." A suboptimal image is still renderable, so rebuilding
/// mid-frame would throw away work for nothing.
/// </summary>
[Fact]
public void AcquireResultsMapToTheDocumentedRecreationTiming()
{
Assert.Equal(
VulkanSwapchainAction.Continue,
VulkanSwapchainRecreationPolicy.OnAcquire(Result.Success));
Assert.Equal(
VulkanSwapchainAction.RecreateNow,
VulkanSwapchainRecreationPolicy.OnAcquire(Result.ErrorOutOfDateKhr));
Assert.Equal(
VulkanSwapchainAction.RecreateAtFrameBoundary,
VulkanSwapchainRecreationPolicy.OnAcquire(Result.SuboptimalKhr));
Assert.Equal(
VulkanSwapchainAction.Idle,
VulkanSwapchainRecreationPolicy.OnAcquire(Result.Timeout));
Assert.Equal(
VulkanSwapchainAction.Idle,
VulkanSwapchainRecreationPolicy.OnAcquire(Result.NotReady));
Assert.Equal(
VulkanSwapchainAction.Fail,
VulkanSwapchainRecreationPolicy.OnAcquire(Result.ErrorDeviceLost));
}
[Fact]
public void PresentResultsMapToTheDocumentedRecreationTiming()
{
Assert.Equal(
VulkanSwapchainAction.Continue,
VulkanSwapchainRecreationPolicy.OnPresent(Result.Success));
Assert.Equal(
VulkanSwapchainAction.RecreateNow,
VulkanSwapchainRecreationPolicy.OnPresent(Result.ErrorOutOfDateKhr));
Assert.Equal(
VulkanSwapchainAction.RecreateAtFrameBoundary,
VulkanSwapchainRecreationPolicy.OnPresent(Result.SuboptimalKhr));
Assert.Equal(
VulkanSwapchainAction.Fail,
VulkanSwapchainRecreationPolicy.OnPresent(Result.ErrorSurfaceLostKhr));
}
[Theory]
[InlineData(0u, 0u)]
[InlineData(0u, 720u)]
[InlineData(1280u, 0u)]
public void AZeroAreaFramebufferIsIdleRatherThanAFailedCreate(uint width, uint height)
{
Assert.Equal(
VulkanSwapchainAction.Idle,
VulkanSwapchainRecreationPolicy.OnFramebufferSize(width, height));
}
[Fact]
public void ANonZeroFramebufferContinues()
{
Assert.Equal(
VulkanSwapchainAction.Continue,
VulkanSwapchainRecreationPolicy.OnFramebufferSize(1280, 720));
}
[Fact]
public void TheBgraToRgbaSwizzleSwapsOnlyRedAndBlue()
{
byte[] bgra = [0x10, 0x20, 0x30, 0x40, 0x01, 0x02, 0x03, 0x04];
VulkanBackbufferSwizzle.SwapRedAndBlueInPlace(bgra);
Assert.Equal([0x30, 0x20, 0x10, 0x40, 0x03, 0x02, 0x01, 0x04], bgra);
}
[Fact]
public void TheSwizzleIsItsOwnInverse()
{
byte[] pixels = [0x10, 0x20, 0x30, 0x40];
byte[] original = [.. pixels];
VulkanBackbufferSwizzle.SwapRedAndBlueInPlace(pixels);
VulkanBackbufferSwizzle.SwapRedAndBlueInPlace(pixels);
Assert.Equal(original, pixels);
}
[Fact]
public void APartialPixelSpanIsRejected()
{
Assert.Throws<ArgumentException>(
() => VulkanBackbufferSwizzle.SwapRedAndBlueInPlace(new byte[6]));
}
/// <summary>
/// Vulkan reports a row pitch per image layout that is frequently larger than
/// <c>width * 4</c>; reading it as tight produces a diagonally sheared image.
/// </summary>
[Fact]
public void RowPaddingIsHonouredWhenConvertingToRgba()
{
// 2x2 BGRA with 4 bytes of padding per row.
byte[] source =
[
0x01, 0x02, 0x03, 0xFF, 0x11, 0x12, 0x13, 0xFF, 0xAA, 0xAA, 0xAA, 0xAA,
0x21, 0x22, 0x23, 0xFF, 0x31, 0x32, 0x33, 0xFF, 0xAA, 0xAA, 0xAA, 0xAA,
];
byte[] rgba = VulkanBackbufferSwizzle.ToRgba(source, 2, 2, sourceRowPitchBytes: 12);
Assert.Equal(
[
0x03, 0x02, 0x01, 0xFF, 0x13, 0x12, 0x11, 0xFF,
0x23, 0x22, 0x21, 0xFF, 0x33, 0x32, 0x31, 0xFF,
],
rgba);
}
/// <summary>
/// A Vulkan image is top-left-origin; <c>FrameScreenshotController</c> flips
/// what it is given because <c>glReadPixels</c> hands back bottom-up rows.
/// Converting once at the readback boundary keeps that consumer — and the
/// PNG's orientation — untouched.
/// </summary>
[Fact]
public void TheGlOriginConversionFlipsRowsAsWellAsSwizzling()
{
byte[] source =
[
0x01, 0x02, 0x03, 0xFF,
0x21, 0x22, 0x23, 0xFF,
];
byte[] rgba = VulkanBackbufferSwizzle.ToGlOriginRgba(
source,
width: 1,
height: 2,
sourceRowPitchBytes: 4);
Assert.Equal(
[
0x23, 0x22, 0x21, 0xFF,
0x03, 0x02, 0x01, 0xFF,
],
rgba);
}
[Fact]
public void ARowPitchNarrowerThanTheImageIsRejected()
{
Assert.Throws<ArgumentOutOfRangeException>(
() => VulkanBackbufferSwizzle.ToRgba(new byte[16], 2, 2, sourceRowPitchBytes: 4));
}
/// <summary>
/// The bring-up clear colour must be visibly deliberate: black reads as an
/// unpainted window, and magenta is this codebase's "unresolved texture slot"
/// sentinel.
/// </summary>
[Fact]
public void TheBringUpClearColourIsNeitherBlackNorTheMagentaSentinel()
{
float[] colour = VulkanBringUpHost.ClearColor;
Assert.Equal(4, colour.Length);
Assert.Equal(1f, colour[3]);
Assert.True(colour[0] + colour[1] + colour[2] > 0f);
Assert.False(colour[0] == 1f && colour[1] == 0f && colour[2] == 1f);
Assert.Equal(2, VulkanBringUpHost.FlightCount);
}
}