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, }; }