using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
namespace AcDream.App.Tests.Rendering.Gpu;
///
/// Campaign V slice V0 — the pinned RHI contract.
///
/// These are not behaviour tests; they are the tripwires that stop the contract
/// drifting out from under the shaders and the two backends. Every constant
/// asserted here also appears in a GLSL source file or in a backend's binding
/// setup, so a change that lands in only one place fails here rather than as a
/// corrupted frame.
///
public sealed class GpuContractTests
{
[Fact]
public void PushConstantBlockMatchesThePinnedLayout()
{
Assert.Equal(GpuBindingModel.PushConstantBytes, Unsafe.SizeOf());
Assert.True(GpuBindingModel.PushConstantBytes <= GpuBindingModel.MaxPushConstantBytes);
Assert.Equal(0, (int)Marshal.OffsetOf(nameof(GpuPushConstants.ViewProjection)));
Assert.Equal(64, (int)Marshal.OffsetOf(nameof(GpuPushConstants.DrawIdOffset)));
Assert.Equal(68, (int)Marshal.OffsetOf(nameof(GpuPushConstants.LightingMode)));
Assert.Equal(72, (int)Marshal.OffsetOf(nameof(GpuPushConstants.RenderPass)));
Assert.Equal(76, (int)Marshal.OffsetOf(nameof(GpuPushConstants.LightDebug)));
Assert.Equal(80, (int)Marshal.OffsetOf(nameof(GpuPushConstants.TextureIndexA)));
Assert.Equal(84, (int)Marshal.OffsetOf(nameof(GpuPushConstants.TextureIndexB)));
Assert.Equal(88, (int)Marshal.OffsetOf(nameof(GpuPushConstants.ParamA)));
Assert.Equal(92, (int)Marshal.OffsetOf(nameof(GpuPushConstants.ParamB)));
}
[Fact]
public void StorageBindingsMatchTheShaderSources()
{
// mesh_modern.vert declares std430 bindings 0..8 in exactly this order.
// Binding 9 was the GL-only texture handle table added by slice V2;
// Campaign V slice V11 deleted it (StorageTextureTable) along with the
// rest of the raw-GL arm, so 9 is now one past the highest binding.
Assert.Equal(0u, GpuBindingModel.StorageInstances);
Assert.Equal(1u, GpuBindingModel.StorageBatches);
Assert.Equal(2u, GpuBindingModel.StorageClipRegions);
Assert.Equal(3u, GpuBindingModel.StorageClipSlots);
Assert.Equal(4u, GpuBindingModel.StorageGlobalLights);
Assert.Equal(5u, GpuBindingModel.StorageInstanceLightSets);
Assert.Equal(6u, GpuBindingModel.StorageInstanceIndoor);
Assert.Equal(7u, GpuBindingModel.StorageInstanceAlpha);
Assert.Equal(8u, GpuBindingModel.StorageInstanceSelectionLighting);
Assert.Equal(9u, GpuBindingModel.StorageBindingCount);
}
[Fact]
public void UniformAndTextureTableLiveInSeparateSets()
{
// The SceneLighting UBO keeps binding=1 even though the BatchBuffer SSBO
// also uses binding=1. GL tolerates that because its SSBO and UBO binding
// tables are separate; Vulkan does not, so the set index disambiguates.
Assert.Equal(GpuBindingModel.StorageBatches, GpuBindingModel.UniformSceneLighting);
Assert.NotEqual(0u, GpuBindingModel.UniformSet);
Assert.NotEqual(GpuBindingModel.UniformSet, GpuBindingModel.TextureTableSet);
}
[Fact]
public void ClipRegionStrideMatchesTheUploadedLayout()
{
// ClipFrame lays these bytes out on the CPU; ClipFrameLayoutTests pins the
// producer side, this pins the contract side. 16 B header + 8 x vec4.
Assert.Equal(8, GpuBindingModel.ClipPlanesPerSlot);
Assert.Equal(144, GpuBindingModel.ClipRegionStrideBytes);
}
[Fact]
public void BlendModesCoverEveryRetailTranslucencyKind()
{
// WbDrawDispatcher.ApplyRetailBlend selects a blend function from each DAT
// surface's TranslucencyKind. Retail has three, and the V0 contract shipped
// with only two — slice V4c found the gap. Mapping InvAlpha onto
// StraightAlpha would silently change how every inverse-alpha surface
// composites, so the contract has to carry all three.
Assert.Equal(4, Enum.GetValues().Length);
Assert.Contains(GpuBlendMode.None, Enum.GetValues());
Assert.Contains(GpuBlendMode.StraightAlpha, Enum.GetValues());
Assert.Contains(GpuBlendMode.Additive, Enum.GetValues());
Assert.Contains(GpuBlendMode.InverseAlpha, Enum.GetValues());
}
[Fact]
public void IntegerVertexAttributesAreRepresentableDistinctlyFromNormalizedOnes()
{
// terrain_modern.vert declares locations 2-5 as uvec4 and the CPU feeds
// them with glVertexAttribIPointer. GL leaves an integer shader input
// undefined if it arrives through the float path, and Vulkan needs the
// format named as R8G8B8A8_UINT rather than _UNORM — so the two cannot be
// the same contract value. Those packed bytes carry terrain-type, road and
// split-direction codes, so normalising them would produce garbage, not an
// approximation.
Assert.NotEqual(GpuVertexFormat.UByte4Normalized, GpuVertexFormat.UByte4UInt);
Assert.Contains(GpuVertexFormat.UByte4UInt, Enum.GetValues());
}
[Fact]
public void AVertexLayoutCanDeclareAPerInstanceBinding()
{
// Campaign V slice V6l. Both particle pipelines draw with per-instance
// VERTEX attributes, and the V0 contract could express instanced DRAWING
// (Draw/DrawIndexed both take firstInstance) but not instanced vertex
// INPUT — one stride, no divisor, one buffer at vertex rate. Plan
// §5.5.16 recorded that as what blocked V4e and named three ways out;
// this is option (i), the second binding with a per-instance rate, which
// both backends carry natively (VK_VERTEX_INPUT_RATE_INSTANCE,
// glVertexAttribDivisor) and which needs no shader edit.
var layout = new GpuVertexLayout(
[
new GpuVertexBinding(0, 16, GpuVertexInputRate.Vertex),
new GpuVertexBinding(1, 68, GpuVertexInputRate.Instance),
],
[
new GpuVertexAttribute(0, GpuVertexFormat.Float2, 0),
new GpuVertexAttribute(2, GpuVertexFormat.Float4, 0, Binding: 1),
]);
Assert.Equal(16u, layout.StrideOf(0));
Assert.Equal(68u, layout.StrideOf(1));
Assert.Equal(GpuVertexInputRate.Vertex, layout.InputRateOf(0));
Assert.Equal(GpuVertexInputRate.Instance, layout.InputRateOf(1));
// An attribute that names no declared binding is a composition error,
// not a silent bind against binding 0.
Assert.Throws(() => layout.StrideOf(2));
}
[Fact]
public void ASingleBindingLayoutStillMeansOneInterleavedVertexRateBuffer()
{
// Every layout written before V6l has to keep its meaning exactly. The
// default attribute binding is 0 and the Interleaved factory's one
// binding is vertex-rate, so no existing declaration changed behaviour.
Assert.Equal(0u, new GpuVertexAttribute(3, GpuVertexFormat.Float3, 12).Binding);
GpuVertexLayout world = GpuVertexLayout.WorldMesh;
GpuVertexBinding only = Assert.Single(world.Bindings);
Assert.Equal(0u, only.Binding);
Assert.Equal(GpuVertexInputRate.Vertex, only.InputRate);
Assert.Equal(world.StrideBytes, only.StrideBytes);
// And a buffer-fed pipeline declares no binding at all.
Assert.Empty(GpuVertexLayout.None.Bindings);
Assert.Empty(GpuVertexLayout.None.Attributes);
Assert.Equal(0u, GpuVertexLayout.None.StrideBytes);
}
[Fact]
public void ScalarIntegerVertexAttributesAreRepresentable()
{
// particle.vert declares `layout(location = 6) in uint aTextureIndex` —
// the per-instance texture-table slot. The amendment's premise is that no
// shader is edited, so the contract has to be able to name a scalar uint:
// GL requires glVertexAttribIPointer for it and Vulkan requires R32_UINT,
// and the float path would reinterpret its bits rather than approximate
// its value. Same kind-distinction UByte4UInt was added for at V4d.
Assert.Contains(GpuVertexFormat.UInt1, Enum.GetValues());
Assert.NotEqual(GpuVertexFormat.Float1, GpuVertexFormat.UInt1);
}
[Fact]
public void APipelineNamesTheColorFormatItRendersInto()
{
// Vulkan's dynamic rendering bakes the colour-attachment format into the
// pipeline, so a pipeline that cannot name it either forces one format on
// every pass or is undefined against the ones it does not match. Slice V6c
// hit that wall and hard-coded the swapchain format for every pipeline,
// recording the gap in VulkanTextureFormatMapping rather than hiding it.
var description = new GpuPipelineDescription
{
Name = "contract-default",
Shaders = new GpuShaderSet("ui_text"),
VertexLayout = GpuVertexLayout.None,
};
// The default has to be the render-target format, because that is what
// the Vulkan backend already maps to the B8G8R8A8_UNORM swapchain — so
// every pipeline written before this field existed keeps its behaviour.
Assert.Equal(GpuTextureFormat.Rgba8UnormRenderTarget, description.ColorFormat);
// And it has to be settable, or naming it would be decoration.
GpuPipelineDescription single = description with { ColorFormat = GpuTextureFormat.R8Unorm };
Assert.Equal(GpuTextureFormat.R8Unorm, single.ColorFormat);
Assert.Equal(GpuTextureFormat.Rgba8UnormRenderTarget, description.ColorFormat);
}
[Fact]
public void APipelineCanDeclareThatItUsesTheStencilAspect()
{
// Campaign V slice V6l. Issue #117's portal punch is a two-pass
// stencil operation — mark where the aperture wins a depth test, then
// write the far-Z punch only on marked pixels and zero the stencil as it
// goes — and the V0 contract carried no stencil state at all, so
// PortalDepthMaskRenderer stayed raw GL and V4g's "stencil/depth-mask
// pipelines" row could not be written (plan §5.5.16 defect 2).
var description = new GpuPipelineDescription
{
Name = "contract-stencil",
Shaders = new GpuShaderSet("portal_depth"),
VertexLayout = GpuVertexLayout.None,
};
// Off by default, so no pipeline written before this slice changed.
Assert.False(description.StencilTest);
Assert.Equal(GpuStencilState.Default, description.Stencil);
Assert.Equal(GpuCompareOp.Always, GpuStencilState.Default.Compare);
Assert.Equal(GpuStencilOp.Keep, GpuStencilState.Default.Pass);
GpuPipelineDescription punch = description with
{
StencilTest = true,
Stencil = GpuStencilState.Default with
{
Compare = GpuCompareOp.Equal,
Pass = GpuStencilOp.Zero,
Reference = 1,
},
};
Assert.True(punch.StencilTest);
Assert.Equal(GpuCompareOp.Equal, punch.Stencil.Compare);
Assert.Equal(GpuStencilOp.Zero, punch.Stencil.Pass);
Assert.False(description.StencilTest);
}
[Fact]
public void EveryStencilOperationThePortalPunchNeedsIsRepresentable()
{
// Replace marks, Equal gates, Zero self-cleans. Nothing else in the tree
// touches stencil, and a fourth value would be a facility with no
// consumer rather than completeness.
Assert.Equal(3, Enum.GetValues().Length);
Assert.Contains(GpuStencilOp.Keep, Enum.GetValues());
Assert.Contains(GpuStencilOp.Zero, Enum.GetValues());
Assert.Contains(GpuStencilOp.Replace, Enum.GetValues());
}
[Fact]
public void TheDepthStencilAttachmentFormatCarriesAStencilAspect()
{
// The punch has nowhere to mark without one. The V5 capability gate
// prefers D32_SFLOAT_S8_UINT and falls back to D24_UNORM_S8_UINT rather
// than taking a depth-only format for exactly this reason, and the
// backbuffer pass clears both aspects through one ClearDepthStencil.
GpuPassDescription pass = GpuPassDescription.BackbufferClear(
"world",
Vector4.Zero,
sampleCount: 4);
Assert.Equal(0u, pass.Depth!.Value.ClearStencil);
Assert.Equal(GpuLoadOp.Clear, pass.Depth!.Value.Load);
Assert.Equal(GpuTextureFormat.Depth24Stencil8, GpuTextureFormat.Depth24Stencil8);
}
[Fact]
public void UniformBindingsDoNotCollide()
{
// Campaign V slice V6e added the sky block. Vulkan has ONE binding
// namespace per set, so two uniform buffers sharing a number is not a
// style problem — it is one of them silently reading the other's bytes.
uint[] uniformBindings =
[
GpuBindingModel.UniformSceneLighting,
GpuBindingModel.UniformTerrainTiling,
GpuBindingModel.UniformSkyParams,
];
Assert.Equal(uniformBindings.Length, uniformBindings.Distinct().Count());
// Binding 2 is the terrain clip block, which sky.vert also reads and
// which has no constant here because no CPU writer names it through the
// binding model. It is spelled as a literal on purpose: a new uniform
// buffer that took 2 would compile, link, and render the wrong thing.
Assert.DoesNotContain(2u, uniformBindings);
}
[Fact]
public void UnassignedTextureSlotIsNeverAValidIndex()
{
Assert.False(GpuTextureSlot.Unassigned.IsAssigned);
Assert.True(new GpuTextureSlot(0).IsAssigned);
Assert.Equal("slot#unassigned", GpuTextureSlot.Unassigned.ToString());
Assert.Equal("slot#7", new GpuTextureSlot(7).ToString());
}
[Fact]
public void WorldMeshVertexLayoutMatchesTheMeshShaderInputs()
{
GpuVertexLayout layout = GpuVertexLayout.WorldMesh;
Assert.Equal(32u, layout.StrideBytes);
Assert.Equal(3, layout.Attributes.Length);
Assert.Equal(new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0), layout.Attributes[0]);
Assert.Equal(new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12), layout.Attributes[1]);
Assert.Equal(new GpuVertexAttribute(2, GpuVertexFormat.Float2, 24), layout.Attributes[2]);
}
[Fact]
public void MultisampledBackbufferPassResolvesWhileDepthIsDiscarded()
{
GpuPassDescription multisampled = GpuPassDescription.BackbufferClear("world", Vector4.Zero, sampleCount: 4);
Assert.Equal(GpuStoreOp.Resolve, multisampled.Color.Store);
Assert.Null(multisampled.Color.Target);
Assert.Equal(GpuStoreOp.DontCare, multisampled.Depth!.Value.Store);
Assert.Equal(1f, multisampled.Depth!.Value.ClearDepth);
GpuPassDescription single = GpuPassDescription.BackbufferClear("world", Vector4.Zero, sampleCount: 1);
Assert.Equal(GpuStoreOp.Store, single.Color.Store);
}
[Fact]
public void CapabilityRecordAcceptsADeviceThatMeetsEveryRequirement()
{
GpuCapabilityRecord record = SupportedRecord();
Assert.Empty(record.SupportFailures);
Assert.True(record.IsSupported);
}
[Fact]
public void CapabilityRecordNamesEveryMissingRequirement()
{
GpuCapabilityRecord record = SupportedRecord() with
{
SupportsMultiDrawIndirect = false,
SupportsDrawParameters = false,
SupportsTextureCompressionBc = false,
MaxTextureTableSlots = 16,
MaxStorageBufferBindings = 4,
MaxPushConstantBytes = 32,
MaxClipDistances = 0,
};
Assert.False(record.IsSupported);
Assert.Equal(7, record.SupportFailures.Count);
Assert.Contains(record.SupportFailures, failure => failure.Contains("Multi-draw-indirect", StringComparison.Ordinal));
Assert.Contains(record.SupportFailures, failure => failure.Contains("gl_DrawID", StringComparison.Ordinal));
Assert.Contains(record.SupportFailures, failure => failure.Contains("BC (DXT)", StringComparison.Ordinal));
Assert.Contains(record.SupportFailures, failure => failure.Contains("clip distances", StringComparison.Ordinal));
}
[Fact]
public void TimestampSupportIsOptional()
{
// Losing GPU timing degrades profiling; it must never refuse to start.
GpuCapabilityRecord record = SupportedRecord() with { SupportsTimestampQueries = false };
Assert.True(record.IsSupported);
}
private static GpuCapabilityRecord SupportedRecord() => new()
{
Backend = GpuBackendKind.Vulkan,
DeviceName = "test-adapter",
DriverInfo = "test-driver",
ApiVersion = "Vulkan 1.3.0",
MaxTextureTableSlots = GpuBindingModel.TextureTableCapacity,
MaxStorageBufferBindings = GpuBindingModel.StorageBindingCount,
MaxPushConstantBytes = GpuBindingModel.MaxPushConstantBytes,
MinStorageBufferOffsetAlignment = 64,
MinUniformBufferOffsetAlignment = 256,
MaxClipDistances = GpuBindingModel.ClipPlanesPerSlot,
MaxSampleCount = 8,
SupportsMultiDrawIndirect = true,
SupportsDrawParameters = true,
SupportsTextureCompressionBc = true,
SupportsTimestampQueries = true,
SupportsPersistentlyMappedRings = true,
};
}