From c13e7f787152a393f04072165e87f512ebf6c031 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 19:18:21 +0200 Subject: [PATCH] Extend the HLSL mirror comparison to one struct return shape HlslSourceCompatibilityTests previously flattened only bare vector returns to scalar leaves. Values() now recurses through struct fields of any type, so a struct return (invariant 8's one shape, e.g. CultCellular) is compared field by field down to bit-for-bit scalars, exactly like a vector return already was. Components() stays scoped to scalar-typed fields, since it only ever builds function arguments and must not recurse. TransformHlslToCSharp gains transformation 5: struct fields gain `public`, since HLSL has no field access-modifier concept and a C# struct's fields default to private, which would hide them from Values()' reflection. The old wrap-in-a-class transformation is renumbered 6. design.md's transformation list documents both, matching design.md:92's numbered list. StructReturnComparisonRejectsAMismatchedField is the extension's own test: two hand-built structs with the same shape (mirroring a real mirror-vs-C# comparison) prove the field-by-field, bit-for-bit walk actually rejects a struct whose leaf field differs, or whose nested vector's own component differs, not just a length check. EveryMirrorFunctionMatchesCSharpMath and HlslMirrorTests' required-symbol list now cover cultmath_smin_grad and cultmath_cellular; the mirrored-function count moves from 28 to 30. Co-Authored-By: Claude Sonnet 5 --- .../tests/CultMath.Tests/HlslMirrorTests.cs | 2 + .../HlslSourceCompatibilityTests.cs | 54 ++++++++++++++++--- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs b/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs index d73c6d7b..2b695fcb 100644 --- a/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs @@ -49,6 +49,8 @@ public sealed class HlslMirrorTests "cultmath_value_noise_bicubic", "cultmath_value_noise_texture", "cultmath_value_noise_texture_bicubic", + "cultmath_smin_grad", + "cultmath_cellular", }; foreach (var symbol in requiredSymbols) diff --git a/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs b/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs index 9426e4c8..4a28b1d7 100644 --- a/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs @@ -25,6 +25,8 @@ public sealed class HlslSourceCompatibilityTests 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))); } // HLSL functions carry no access modifier, so they compile as private instance methods. @@ -82,9 +84,7 @@ public sealed class HlslSourceCompatibilityTests : 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 (expected.Length != actual.Length || expected.Zip(actual).Any(p => p.First is float a && p.Second is float b - ? !(float.IsNaN(a) && float.IsNaN(b)) && BitConverter.SingleToInt32Bits(a) != BitConverter.SingleToInt32Bits(b) - : !p.First.Equals(p.Second))) + if (!BitwiseEqual(expected, actual)) { mismatches.Add($"{mirror.Name}({string.Join(", ", args.Select(a => string.Join(" ", Values(a))))}): C# {string.Join(" ", expected)}, mirror {string.Join(" ", actual)}"); } @@ -92,7 +92,30 @@ public sealed class HlslSourceCompatibilityTests } Assert.True(mismatches.Count == 0, $"{mismatches.Count} mismatches:{Environment.NewLine}{string.Join(Environment.NewLine, mismatches.Take(12))}"); - Assert.Equal(28, compared.Count); + Assert.Equal(30, 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))); } // Integer arguments take the bit pattern of the float input, so specials become 0, 0x80000000, NaN bits, ... @@ -101,10 +124,23 @@ public sealed class HlslSourceCompatibilityTests 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(); + /// + /// 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. + /// private static object[] Values(object value) => Scalars.Contains(value.GetType()) ? new[] { value } - : Components(value.GetType()).Select(field => field.GetValue(value)!).ToArray(); + : value.GetType().GetFields().Where(f => !f.IsStatic).SelectMany(f => Values(f.GetValue(value)!)).ToArray(); + + private static bool BitwiseEqual(object[] expected, object[] actual) => + expected.Length == actual.Length && expected.Zip(actual).All(p => p.First is float a && p.Second is float b + ? (float.IsNaN(a) && float.IsNaN(b)) || BitConverter.SingleToInt32Bits(a) == BitConverter.SingleToInt32Bits(b) + : p.First.Equals(p.Second)); /// The documented HLSL-to-C# transformations, and nothing else. internal static string TransformHlslToCSharp(string hlsl) @@ -122,7 +158,13 @@ public sealed class HlslSourceCompatibilityTests // 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"; }