using System.Reflection; using System.Text.RegularExpressions; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Xunit; using static CultMath.math; namespace CultMath.Tests; /// /// The real shader mirror, shaders/CultMath.hlsl, compiles as C# against CultMath under /// using static CultMath.math; after exactly the source transformations listed in /// docs/design.md (HLSL Target: Source Transformations). is /// that list in code; if a body needs anything else, this test fails. /// public sealed class HlslSourceCompatibilityTests { [Fact] public void ShaderMirrorBodiesCompileAsCSharp() { var (assembly, errors) = CompileShaderMirror(); Assert.True(assembly is not null, "HLSL mirror did not compile as C#:" + Environment.NewLine + string.Join(Environment.NewLine, errors)); var shader = assembly!.GetType("CultMathHlsl.HlslShader")!; Assert.NotNull(ShaderFunction(shader, "cultmath_snoise", typeof(float3))); Assert.NotNull(ShaderFunction(shader, "cultmath_snoise", typeof(float2))); Assert.NotNull(ShaderFunction(shader, "cultmath_value_noise_bicubic", typeof(float2))); Assert.NotNull(ShaderFunction(shader, "cultmath_smin_grad", typeof(float4), typeof(float4), typeof(float))); Assert.NotNull(ShaderFunction(shader, "cultmath_cellular", typeof(float3))); Assert.NotNull(ShaderFunction(shader, "cultmath_snoise_grad", typeof(float3))); Assert.NotNull(ShaderFunction(shader, "cultmath_fbm_grad", typeof(float3), typeof(int), typeof(float), typeof(float))); Assert.NotNull(ShaderFunction(shader, "cultmath_ridged_grad", typeof(float3), typeof(int), typeof(float), typeof(float))); } // HLSL functions carry no access modifier, so they compile as private instance methods. private static MethodInfo? ShaderFunction(Type shader, string name, params Type[] parameters) => shader.GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic, null, parameters, null); // Mirror internals with no public C# math counterpart. Any other cultmath_* function must match one. private static readonly string[] MirrorOnly = { "cultmath_snoise_mod289", "cultmath_snoise_permute" }; private static readonly float[] Specials = { 0.0f, -0.0f, 1.0f, -1.0f, 0.5f, -2.75f, 3.0f, 1.0e-7f, 1.0e4f, -1.0e4f, 1.0e7f, float.NaN }; /// /// Every mirror function with a C# math counterpart returns the same bits (any NaN equals any NaN; /// -0 differs from 0) over random inputs, every special value in every argument (ties, zeros, /// negatives, NaN, large values, integer lattice points), special pairs, and vectors whose first two /// components tie (x0.x == x0.y in snoise). This proves the text of CultMath.hlsl computes the same /// float32 results as C# math on the CPU. It does not prove GPU agreement: driver sin precision alone /// makes cultmath_hash and value noise differ bit for bit on hardware. The mirror's intrinsics are C# /// math itself (it compiles under using static CultMath.math), so this proves the composition in the /// text, not the intrinsic rules; HlslSemanticsTests alone pins those. /// [Fact] public void EveryMirrorFunctionMatchesCSharpMath() { var (assembly, errors) = CompileShaderMirror(); Assert.True(assembly is not null, string.Join(Environment.NewLine, errors)); var shaderType = assembly!.GetType("CultMathHlsl.HlslShader")!; var shader = Activator.CreateInstance(shaderType); var random = new System.Random(0x5EED); float Next() => random.NextSingle() * 200.0f - 100.0f; var cases = new List>(); foreach (var s in Specials) cases.Add(_ => s); for (var k = 0; k < Specials.Length; k++) { var o = k; cases.Add(i => Specials[(i + o + 1) % Specials.Length]); } for (var k = 0; k < 64; k++) { var values = Enumerable.Range(0, 32).Select(_ => Next()).ToArray(); cases.Add(i => values[i]); } for (var k = 0; k < 16; k++) { var values = Enumerable.Range(0, 32).Select(_ => Next()).ToArray(); cases.Add(i => values[i & ~1]); } // Points aimed squarely at cellular's HLSL mirror (F6/F3 Soul findings): the generic random // and special-value cases above pass ~92 inputs through cultmath_cellular and never happen to // hit a point where a radius-2 cell wins (about 3.5e-5 of points, Soul measured) or an // integer point at or past 2^23, so an HLSL mutant of the search radius, the prune, or the // removed F2=0 guard can survive this test even though it changes cellular's real output. void AddPointCase(float3 point) { var values = new[] { point.x, point.y, point.z }; cases.Add(i => i < values.Length ? values[i] : 0.0f); } // The six constructed points from CellularAndSminGradTests.SearchReachesEveryAxisAlignedRadiusTwoSlice: // each one realizes a different axis-aligned radius-2 offset as F1 or F2, which is exactly // the case an `if (lowerBound >= f2)` prune written as `>= f1`, or a search radius narrowed on // one axis (`dz < 2`, `dx` starting at -1), changes. foreach (var offset in CellularAndSminGradTests.AxisAlignedRadiusTwoOffsets) AddPointCase(CellularAndSminGradTests.FindPointRealizingOffset(offset)); // Integer points at 2^24, several, both signs: past |p| ~= 2^23 distinct cells' jittered // features can round onto the same float32 value (design.md, "Precision domain"), making // F2 = 0 reachable and exercising the grad2 = f2 > 0 ? ... : zero guard this test would // otherwise never touch. const float twoTo24 = 16777216.0f; AddPointCase(new float3(twoTo24, twoTo24, twoTo24)); AddPointCase(new float3(-twoTo24, -twoTo24, -twoTo24)); AddPointCase(new float3(twoTo24 + 3.0f, -twoTo24 + 5.0f, twoTo24 - 7.0f)); AddPointCase(new float3(-twoTo24 + 11.0f, twoTo24 - 13.0f, -twoTo24 + 17.0f)); // One exact F1 = F2 tie point: the midpoint of two adjacent cells' feature points is // bit-exact-equidistant from both by construction (negating a vector does not change its // length, and p - a = -(p - b) here by symmetry), without needing to search for one. var tieMidpoint = (CellularAndSminGradTests.FeaturePoint(new float3(0.0f, 0.0f, 0.0f)) + CellularAndSminGradTests.FeaturePoint(new float3(1.0f, 0.0f, 0.0f))) * 0.5f; AddPointCase(tieMidpoint); var mismatches = new List(); var compared = new HashSet(); foreach (var mirror in shaderType.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic).Where(m => m.Name.StartsWith("cultmath_"))) { var types = mirror.GetParameters().Select(p => p.ParameterType).ToArray(); var counterpart = typeof(math).GetMethod(mirror.Name["cultmath_".Length..], BindingFlags.Public | BindingFlags.Static, null, types, null); if (counterpart is null) { Assert.Contains(mirror.Name, MirrorOnly); continue; } compared.Add(mirror.Name); foreach (var input in cases) { var slot = 0; var args = types.Select(t => Scalars.Contains(t) ? Scalar(t, input(slot++)) : Activator.CreateInstance(t, Components(t).Select(f => Scalar(f.FieldType, input(slot++))).ToArray())!).ToArray(); var expected = Values(counterpart.Invoke(null, args)!); var actual = Values(mirror.Invoke(shader, args)!); if (!BitwiseEqual(expected, actual)) { mismatches.Add($"{mirror.Name}({string.Join(", ", args.Select(a => string.Join(" ", Values(a).Select(v => v.Value))))}): C# {string.Join(" ", expected.Select(v => v.Value))}, mirror {string.Join(" ", actual.Select(v => v.Value))}"); } } } Assert.True(mismatches.Count == 0, $"{mismatches.Count} mismatches:{Environment.NewLine}{string.Join(Environment.NewLine, mismatches.Take(12))}"); Assert.Equal(33, compared.Count); } /// /// A hand-built pair of structs standing in for a mirror struct return and its C# counterpart /// (invariant 8's one struct return shape, e.g. ): same field shape, /// different concrete type, exactly like the real mirror comparison. Proves the field-by-field, /// bit-for-bit walk actually rejects a struct whose field differs; without this, the walk could /// have been comparing lengths only, or skipping the nested vector's own components. /// private struct StructMirrorProbeExpected { public float4 first; public float second; } private struct StructMirrorProbeActual { public float4 first; public float second; } [Fact] public void StructReturnComparisonRejectsAMismatchedField() { var expected = new StructMirrorProbeExpected { first = new float4(1.0f, 2.0f, 3.0f, 4.0f), second = 5.0f }; var matching = new StructMirrorProbeActual { first = new float4(1.0f, 2.0f, 3.0f, 4.0f), second = 5.0f }; var mismatchedLeaf = new StructMirrorProbeActual { first = new float4(1.0f, 2.0f, 3.0f, 4.0f), second = 5.5f }; var mismatchedNestedComponent = new StructMirrorProbeActual { first = new float4(1.0f, 2.0f, -3.0f, 4.0f), second = 5.0f }; Assert.True(BitwiseEqual(Values(expected), Values(matching))); Assert.False(BitwiseEqual(Values(expected), Values(mismatchedLeaf))); Assert.False(BitwiseEqual(Values(expected), Values(mismatchedNestedComponent))); } /// /// Struct shapes standing in for CultCellular's own field order (nearest; edge; id), used /// to falsify two named mutants (F2): H2 declares the mirror struct with its first two fields /// swapped (edge; nearest; id) and swaps the assignments to match, so every leaf VALUE /// still lands in the same POSITION as the correct struct; only the field NAME at each position /// differs. H3 renames and retypes every field (id becomes an int carrying the same bits) while /// keeping the original position order, so only NAME and TYPE differ, never position. /// private struct NearestEdgeIdExpected { public float4 nearest; public float4 edge; public float id; } private struct EdgeNearestIdSwappedNames { public float4 edge; public float4 nearest; public float id; } private struct RenamedAndRetypedFields { public float4 a; public float4 b; public int c; } [Fact] public void StructReturnComparisonRejectsFieldsSwappedByNameEvenWhenValuesAlign() { // H2: the mutant struct's field ORDER is (edge, nearest, id), and its assignments are // swapped to match, so declaration-position values are identical to the correct struct's. // Only checking names (not just position) can tell these apart. var expected = new NearestEdgeIdExpected { nearest = new float4(1.0f, 2.0f, 3.0f, 4.0f), edge = new float4(5.0f, 6.0f, 7.0f, 8.0f), id = 9.0f, }; var swapped = new EdgeNearestIdSwappedNames { edge = new float4(1.0f, 2.0f, 3.0f, 4.0f), // holds what should be "nearest"'s value. nearest = new float4(5.0f, 6.0f, 7.0f, 8.0f), // holds what should be "edge"'s value. id = 9.0f, }; Assert.False(BitwiseEqual(Values(expected), Values(swapped))); } [Fact] public void StructReturnComparisonRejectsRenamedAndRetypedFields() { // H3: field order matches the correct struct exactly, but every field is renamed, and id is // retyped from float to int carrying the same bit pattern the correct id would round to. var expected = new NearestEdgeIdExpected { nearest = new float4(1.0f, 2.0f, 3.0f, 4.0f), edge = new float4(5.0f, 6.0f, 7.0f, 8.0f), id = 9.0f, }; var renamed = new RenamedAndRetypedFields { a = new float4(1.0f, 2.0f, 3.0f, 4.0f), b = new float4(5.0f, 6.0f, 7.0f, 8.0f), c = BitConverter.SingleToInt32Bits(9.0f), }; Assert.False(BitwiseEqual(Values(expected), Values(renamed))); } // Integer arguments take the bit pattern of the float input, so specials become 0, 0x80000000, NaN bits, ... private static readonly Type[] Scalars = { typeof(float), typeof(int), typeof(uint) }; private static object Scalar(Type t, float f) => t == typeof(float) ? f : t == typeof(int) ? BitConverter.SingleToInt32Bits(f) : (object)(uint)BitConverter.SingleToInt32Bits(f); // Scoped to scalar-typed fields: this only ever builds function ARGUMENTS (float2/3/4, int2/3/4, ...), // never a struct-of-struct return, so it must not recurse. private static FieldInfo[] Components(Type t) => t.GetFields().Where(f => !f.IsStatic && Scalars.Contains(f.FieldType)).ToArray(); /// /// One flattened scalar leaf of a (possibly nested) return value: its declaration path (field /// names joined by '.'), its declared type, and its value. Carrying path and type, not just the /// value, is what lets the struct-return comparison reject a field that was renamed or retyped /// but landed in the same position (F2): a positional-only comparison cannot tell "edge" holding /// nearest's value apart from "nearest" holding it. /// private readonly record struct Leaf(string Path, Type Type, object Value); /// /// Flattens a return value to its scalar leaves, recursing through struct fields of any type /// (invariant 8's one struct return shape, e.g. 's float4/float4/float /// fields) so a struct return is compared field by field, down to bit-for-bit scalars, exactly /// like a bare vector return already is. Each leaf keeps the field name (and its ancestors') and /// declared type it was read from. /// private static Leaf[] Values(object value, string path = "") => Scalars.Contains(value.GetType()) ? new[] { new Leaf(path, value.GetType(), value) } : value.GetType().GetFields().Where(f => !f.IsStatic) .SelectMany(f => Values(f.GetValue(value)!, path.Length == 0 ? f.Name : path + "." + f.Name)) .ToArray(); // Compares leaves by NAME and TYPE as well as value, in declaration order, so a mirror struct // whose fields are reordered, renamed, or retyped relative to its C# counterpart fails here even // when the flattened values happen to line up positionally (F2's H2/H3 mutants). private static bool BitwiseEqual(Leaf[] expected, Leaf[] actual) => expected.Length == actual.Length && expected.Zip(actual).All(p => p.First.Path == p.Second.Path && p.First.Type == p.Second.Type && (p.First.Value is float a && p.Second.Value is float b ? (float.IsNaN(a) && float.IsNaN(b)) || BitConverter.SingleToInt32Bits(a) == BitConverter.SingleToInt32Bits(b) : p.First.Value.Equals(p.Second.Value))); /// The documented HLSL-to-C# transformations, and nothing else. internal static string TransformHlslToCSharp(string hlsl) { // 1. Include guards (#ifndef/#define/#endif) are dropped; C# has no textual include. var source = Regex.Replace(hlsl, @"(?m)^\s*#(ifndef|define|endif)\b.*$", string.Empty); // 2. Functions taking Texture2D/SamplerState resources are dropped; GPU resources have // no CultMath analog. source = Regex.Replace(source, @"(?ms)^\w+ \w+\([^)]*\b(Texture2D|SamplerState)\b[^)]*\)\s*\{.*?^\}[ \t]*\r?$", string.Empty); // 3. File-scope `static const` becomes `const` (C# consts are implicitly static). source = Regex.Replace(source, @"(?m)^static const ", "const "); // 4. Floating literals gain the `f` suffix; an unsuffixed C# literal is a double. source = Regex.Replace(source, @"(? m.Groups[1].Value + Regex.Replace(m.Groups[2].Value, @"(?m)^(\s*)(\w+\s+\w+;)", "$1public $2") + m.Groups[3].Value); // 6. The file body is wrapped in a class; C# has no free functions. return "using CultMath;\nusing static CultMath.math;\nnamespace CultMathHlsl;\npublic class HlslShader\n{\n" + source + "\n}\n"; } private static (Assembly? Assembly, IReadOnlyList Errors) CompileShaderMirror() { var hlsl = File.ReadAllText(Path.Combine(FindCultMathRoot(), "shaders", "CultMath.hlsl")); var tree = CSharpSyntaxTree.ParseText(TransformHlslToCSharp(hlsl)); var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) .Split(Path.PathSeparator) .Append(typeof(math).Assembly.Location) .Distinct(StringComparer.OrdinalIgnoreCase) .Select(path => MetadataReference.CreateFromFile(path)); var compilation = CSharpCompilation.Create( "CultMathHlslMirror", new[] { tree }, references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); using var stream = new MemoryStream(); var result = compilation.Emit(stream); var errors = result.Diagnostics .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) .Select(diagnostic => diagnostic.ToString()) .ToList(); return (result.Success ? Assembly.Load(stream.ToArray()) : null, errors); } private static string FindCultMathRoot() { var directory = new DirectoryInfo(AppContext.BaseDirectory); while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "packages", "cultmath", "shaders", "CultMath.hlsl"))) { directory = directory.Parent; } return Path.Combine(directory?.FullName ?? throw new InvalidOperationException("CultMath package root not found."), "packages", "cultmath"); } // ---- Hand-written HLSL-shaped host code: swizzles, mixed constructors, matrix rows. ---- static float3 shade(float3 position, float3 normal, float2 wind, float time) { float3 p = position; p.xz = p.xz + wind * time; p.y = saturate(p.y); float3x3 basis = float3x3(float3(1, 0, 0), float3(0, 1, 0), float3(0, 0, 1)); basis[1][1] = 1.0f; float3 n = normalize(mul(basis, normal)); float ndotl = saturate(dot(n, normalize(float3(0.3f, 1, 0.2f)))); float4 color = float4(lerp(float3(0.1f, 0.1f, 0.2f), float3(1, 0.9f, 0.7f), ndotl), 1); if (any(p < 0.0f)) color.rgb *= 0.5f; if (all(abs(n) <= 1.0f)) color.bgr = color.bgr * float3(1, 1, 1); color.rgb = select(p > 10.0f, float3(0), color.rgb); float2 uv = frac(p.xz * 0.25f); color.rg = lerp(color.rg, uv, 0.1f); return float4(color.xy, color.zw).xyz; } static float2 steer(float2 direction, float torque) { float2x2 turn = float2x2(cos(torque), -sin(torque), sin(torque), cos(torque)); float2 turned = mul(direction, turn); return normalize(turned) * pow(length(turned), 1.0f); } [Fact] public void HandWrittenShaderStyleMathEvaluates() { var color = shade(float3(1.0f, 0.5f, 2.0f), float3(0.0f, 1.0f, 0.0f), float2(1.0f, 0.0f), 0.0f); Assert.True(color.x > 0.1f && color.x <= 1.0f); Assert.True(float.IsFinite(color.y) && float.IsFinite(color.z)); var far = shade(float3(20.0f, 20.0f, 20.0f), float3(0.0f, 1.0f, 0.0f), float2(0.0f), 0.0f); Assert.Equal(0.0f, far.z); var steered = steer(float2(1.0f, 0.0f), 0.5f); var viaRotate = mul(float2(1.0f, 0.0f), CultMath.float2x2.Rotate(0.5f)); Assert.Equal(viaRotate.x, steered.x, precision: 5); Assert.Equal(viaRotate.y, steered.y, precision: 5); } }