acdream/tests/AcDream.App.Tests/Rendering/SkyParamsLayoutTests.cs
Erik 0dac024cdb feat(sky): the night sky wheels with the Dereth clock; uniform time fade with a scoped twilight band
Rotation (user-directed): the procedural starfield rotates once per
Dereth day (~2 real hours - constellations visibly wheel through a
night) about a celestial pole ~41 deg above the northern horizon, plus
dayOfYear/360 of seasonal drift so the 360-day year changes the night
sky. One SkyParams float (272-byte block, layout test re-pinned)
carries dayFraction + dayOfYear/360 from the world clock; sky.frag
applies a Rodrigues rotation to the sample direction so stars and
mottle turn together. Impossible with retail's static stretched layer.

Fade rework (the 2026-08-23 two-screenshot gate finding): the
per-vertex vTint signal carried the sun-facing product and blanked
stars across the entire twilight half of the sky. The fade now reads
the UNIFORM ambient term - identical star visibility in every compass
direction, same dusk-to-dawn schedule - with one deliberate exception:
a thin suppression band hugging the low sky toward the sun's azimuth
while the sun term is strong, so stars still wash out inside the
actual twilight glow.

Guards updated (rotation anchor, uniform-fade anchor, 272-byte layout);
both sky SPIR-V hashes re-pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 18:21:17 +02:00

129 lines
5.5 KiB
C#

using System;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// 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 272 bytes.
///
/// <para>It exists because the failure mode is silent. std140 gives a
/// <c>vec3</c> 16-byte alignment while only using 12, so the block deliberately
/// parks a <c>float</c> 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.</para>
/// </summary>
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)]
// IA-26: the enhanced night sky's rotation turns plus its three pad
// words, filling the seventeenth vec4 exactly.
[InlineData("NightSkyRotationTurns", 256)]
[InlineData("NsPadA", 260)]
[InlineData("NsPadB", 264)]
[InlineData("NsPadC", 268)]
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(272, 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);
}
/// <summary>
/// The block's declaration with comments and whitespace runs collapsed, so
/// the comparison is about members rather than formatting.
/// </summary>
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();
}
}