using System; using System.IO; using System.Reflection; using System.Runtime.InteropServices; namespace AcDream.App.Tests.Rendering; /// /// Campaign V slice V6e: the sky's dozen loose uniforms became one std140 /// uniform block, and this is the test that keeps the CPU struct and the GLSL /// block describing the same 256 bytes. /// /// It exists because the failure mode is silent. std140 gives a /// vec3 16-byte alignment while only using 12, so the block deliberately /// parks a float in each of those pad words — which is why the lighting /// colours and the per-surface scalars interleave rather than being grouped by /// meaning. Get one member out of order and the shader reads the sun direction /// where a colour should be: no compile error, no link error, no GL error, just /// a wrong sky that only a human looking at the screen would catch. The offline /// pixel gate masks the sky band for determinism, so nothing automated is /// watching. This is the substitute. /// public sealed class SkyParamsLayoutTests { private static Type SkyParamsType => typeof(AcDream.App.Rendering.Sky.SkyRenderer) .GetNestedType("SkyParams", BindingFlags.NonPublic) ?? throw new InvalidOperationException("SkyRenderer.SkyParams is missing."); [Theory] // Three transforms first: mat4 is 4 vec4s in std140, so these need no thought. [InlineData("Model", 0)] [InlineData("SkyView", 64)] [InlineData("SkyProjection", 128)] // Then three (vec3, float) couples. Each float rides in the pad word the // vec3's 16-byte alignment would otherwise waste. [InlineData("AmbientColor", 192)] [InlineData("Emissive", 204)] [InlineData("SunColor", 208)] [InlineData("DiffuseFactor", 220)] [InlineData("SunDir", 224)] [InlineData("Transparency", 236)] // Finally a vec2 (8-byte aligned) and the last two scalars, filling the // sixteenth vec4 exactly. [InlineData("UvScroll", 240)] [InlineData("ApplyFog", 248)] [InlineData("SurfOpacity", 252)] public void EveryMemberSitsWhereStd140PutsIt(string member, int expectedOffset) { Assert.Equal( new IntPtr(expectedOffset), Marshal.OffsetOf(SkyParamsType, member)); } [Fact] public void TheBlockIsAWholeNumberOfVec4s() { int declared = (int)(SkyParamsType .GetField("SizeInBytes", BindingFlags.Public | BindingFlags.Static) ?.GetRawConstantValue() ?? throw new InvalidOperationException("SkyParams.SizeInBytes is missing.")); Assert.Equal(256, declared); Assert.Equal(declared, Marshal.SizeOf(SkyParamsType)); // std140 rounds a block up to its largest member's alignment (16). Assert.Equal(0, declared % 16); } [Fact] public void BothStagesDeclareTheBlockIdentically() { // A uniform block named in two stages of one program must be declared // the same way in both, or the link fails — but only if someone reads // it, and sky.frag reads three of the twelve members. Comparing the // declarations directly means a divergence is caught by a test rather // than by a driver's link log at startup. string shaders = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders"); string vertexBlock = ExtractSkyParamsBlock(File.ReadAllText(Path.Combine(shaders, "sky.vert"))); string fragmentBlock = ExtractSkyParamsBlock(File.ReadAllText(Path.Combine(shaders, "sky.frag"))); Assert.Equal(vertexBlock, fragmentBlock); // And the binding must be the one the CPU binds the buffer to. Assert.Equal(4u, AcDream.App.Rendering.Gpu.GpuBindingModel.UniformSkyParams); Assert.Contains("binding = 4) uniform SkyParams {", vertexBlock); } /// /// The block's declaration with comments and whitespace runs collapsed, so /// the comparison is about members rather than formatting. /// private static string ExtractSkyParamsBlock(string source) { int start = source.IndexOf("layout(std140", StringComparison.Ordinal); while (start >= 0) { int end = source.IndexOf("};", start, StringComparison.Ordinal); Assert.True(end > start, "A layout(std140 …) block was never closed."); string block = source[start..(end + 2)]; if (block.Contains("uniform SkyParams", StringComparison.Ordinal)) return Normalise(block); start = source.IndexOf("layout(std140", end, StringComparison.Ordinal); } throw new InvalidOperationException("No SkyParams block found in the shader source."); } private static string Normalise(string block) { var text = new System.Text.StringBuilder(); foreach (string rawLine in block.Replace("\r\n", "\n").Split('\n')) { int comment = rawLine.IndexOf("//", StringComparison.Ordinal); string line = comment >= 0 ? rawLine[..comment] : rawLine; string collapsed = string.Join(' ', line.Split( (char[]?)null, StringSplitOptions.RemoveEmptyEntries)); if (collapsed.Length > 0) text.Append(collapsed).Append('\n'); } return text.ToString(); } }