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;
///
/// 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.
///
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,
};
///
/// The single highest-severity finding of the V3 audit. acdream is plain
/// UNORM end to end; an _SRGB swapchain would encode already
/// display-space values and brighten every frame globally, and it would have
/// passed silently right up to the V7 differential.
///
[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 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]));
}
///
/// 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.
///
[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]));
}
/// FIFO is the only present mode Vulkan guarantees exists.
[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)));
}
///
/// maxImageCount == 0 means "no upper limit" in Vulkan. Reading it as
/// a literal ceiling would clamp every swapchain to zero images.
///
[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);
}
///
/// currentExtent.width == uint.MaxValue is the surface saying "you
/// choose" — Wayland does this — so the framebuffer size is used, clamped.
///
[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);
}
/// Screenshots copy the presented image, so TRANSFER_SRC is not optional.
[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);
}
///
/// 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.
///
[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)));
}
///
/// 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.
///
[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(
() => VulkanBackbufferSwizzle.SwapRedAndBlueInPlace(new byte[6]));
}
///
/// Vulkan reports a row pitch per image layout that is frequently larger than
/// width * 4; reading it as tight produces a diagonally sheared image.
///
[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);
}
///
/// A Vulkan image is top-left-origin; FrameScreenshotController flips
/// what it is given because glReadPixels hands back bottom-up rows.
/// Converting once at the readback boundary keeps that consumer — and the
/// PNG's orientation — untouched.
///
[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(
() => VulkanBackbufferSwizzle.ToRgba(new byte[16], 2, 2, sourceRowPitchBytes: 4));
}
///
/// 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.
///
[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);
}
}