diff --git a/packages/cultmath/README.md b/packages/cultmath/README.md
index 61351da3..0111949c 100644
--- a/packages/cultmath/README.md
+++ b/packages/cultmath/README.md
@@ -167,6 +167,7 @@ CultMath keeps portable DXC outside git under `packages/cultmath/.tools/`:
```powershell
.\packages\cultmath\tools\get-dxc.ps1
+.\packages\cultmath\tools\compile-hlsl.ps1
.\packages\cultmath\tools\get-spirv-cross.ps1
.\packages\cultmath\tools\compile-hlsl-spirv.ps1 `
-ShaderPath E:\Projects\Odin\crates\muninn-move-tracker\shaders\MoveSphereCandidate.comp.hlsl `
@@ -177,6 +178,8 @@ CultMath keeps portable DXC outside git under `packages/cultmath/.tools/`:
-OutputPath E:\Projects\Odin\crates\muninn-move-tracker\artifacts\shader\MoveSphereCandidate.comp.metal
```
+`compile-hlsl.ps1` compiles both `CultMath.hlsl` copies as a `lib_6_3` library with the x64 DXC.
+
DXC emits Vulkan SPIR-V with `-spirv`. `-TargetEnv vulkan1.2` is exposed by the
script, but the current Windows DXC release compiled this shader successfully
with DXC's default Vulkan target while rejecting the dotted target-env value.
diff --git a/packages/cultmath/docs/design.md b/packages/cultmath/docs/design.md
index 103cfa5b..d11e1518 100644
--- a/packages/cultmath/docs/design.md
+++ b/packages/cultmath/docs/design.md
@@ -112,8 +112,9 @@ local) and writes through a swizzle component (`v.xy.x = s`, which C# rejects as
modifying a temporary).
The HLSL side is checked too: `tools/get-dxc.ps1` fetches Microsoft's
-DirectXShaderCompiler, and
-`dxc -T lib_6_3 -HV 2021 shaders/CultMath.hlsl` must compile cleanly.
+DirectXShaderCompiler (x64), and `tools/compile-hlsl.ps1` compiles both copies of
+`CultMath.hlsl` with it (`dxc -T lib_6_3 -HV 2021`); the script throws when dxc cannot
+launch or exits non-zero, so a stale exit code never reads as a pass.
The mirror test compares bit patterns (any NaN equals any NaN; -0 differs from
0). It proves the text of `CultMath.hlsl` computes the same float32 results as
diff --git a/packages/cultmath/shaders/CultMath.hlsl b/packages/cultmath/shaders/CultMath.hlsl
index c454773c..c480acc7 100644
--- a/packages/cultmath/shaders/CultMath.hlsl
+++ b/packages/cultmath/shaders/CultMath.hlsl
@@ -304,7 +304,7 @@ float cultmath_integral_clamp_linear(float a, float b, float lo, float hi, float
{
lo = min(lo, hi);
if (b == 0.0)
- return clamp(a, lo, hi) * (t1 - t0);
+ return t1 != t0 ? clamp(a, lo, hi) * (t1 - t0) : 0.0;
float direction = t1 < t0 ? -1.0 : 1.0;
float start = min(t0, t1);
diff --git a/packages/cultmath/src/CultMath/math.Span.cs b/packages/cultmath/src/CultMath/math.Span.cs
index 6dcdd180..068dfd0c 100644
--- a/packages/cultmath/src/CultMath/math.Span.cs
+++ b/packages/cultmath/src/CultMath/math.Span.cs
@@ -112,7 +112,7 @@ public static partial class math
{
lo = min(lo, hi);
if (b == 0.0f)
- return clamp(a, lo, hi) * (t1 - t0);
+ return t1 != t0 ? clamp(a, lo, hi) * (t1 - t0) : 0.0f;
var direction = t1 < t0 ? -1.0f : 1.0f;
var start = min(t0, t1);
diff --git a/packages/cultmath/tests/CultMath.Tests/GlslMirrorTests.cs b/packages/cultmath/tests/CultMath.Tests/GlslMirrorTests.cs
index 9fb45199..33665d75 100644
--- a/packages/cultmath/tests/CultMath.Tests/GlslMirrorTests.cs
+++ b/packages/cultmath/tests/CultMath.Tests/GlslMirrorTests.cs
@@ -45,24 +45,50 @@ public sealed class GlslMirrorTests
}
}
+ // Counting stand-ins for the mirror's clamp, one per overload, declared in the mirror's class so they
+ // hide math.clamp for every function in it. Each counts a call whose minimum exceeds its maximum in any
+ // component and forwards to math.clamp. (The int vector overloads only forward: no mirror function
+ // clamps integer vectors with swapped bounds, and counting them would be unreachable.)
+ private const string CountingClampShadows = @"
+public static int ClampMinAboveMax;
+static float clamp(float x, float a, float b) { if (a > b) ClampMinAboveMax++; return math.clamp(x, a, b); }
+static float2 clamp(float2 x, float2 a, float2 b) { if (a.x > b.x || a.y > b.y) ClampMinAboveMax++; return math.clamp(x, a, b); }
+static float3 clamp(float3 x, float3 a, float3 b) { if (a.x > b.x || a.y > b.y || a.z > b.z) ClampMinAboveMax++; return math.clamp(x, a, b); }
+static float4 clamp(float4 x, float4 a, float4 b) { if (a.x > b.x || a.y > b.y || a.z > b.z || a.w > b.w) ClampMinAboveMax++; return math.clamp(x, a, b); }
+static int clamp(int x, int a, int b) { if (a > b) ClampMinAboveMax++; return math.clamp(x, a, b); }
+static int2 clamp(int2 x, int2 a, int2 b) { if (a.x > b.x || a.y > b.y) ClampMinAboveMax++; return math.clamp(x, a, b); }
+static int3 clamp(int3 x, int3 a, int3 b) { if (a.x > b.x || a.y > b.y || a.z > b.z) ClampMinAboveMax++; return math.clamp(x, a, b); }
+static int4 clamp(int4 x, int4 a, int4 b) { if (a.x > b.x || a.y > b.y || a.z > b.z || a.w > b.w) ClampMinAboveMax++; return math.clamp(x, a, b); }
+";
+
///
- /// The lowering keeps HLSL's statement order, and GLSL leaves clamp(x, minVal, maxVal) undefined for
- /// minVal above maxVal, so integral_clamp_linear must normalise lo = min(lo, hi) before any clamp in
- /// its body can see it: in the committed HLSL and the committed GLSL alike, the normalisation precedes
- /// the first clamp.
+ /// The lowering keeps HLSL's statement order (CommittedGlslEqualsLowering pins the GLSL as that
+ /// lowering), and GLSL leaves clamp(x, minVal, maxVal) undefined for minVal above maxVal, so no clamp
+ /// reached by cultmath_integral_clamp_linear may see lo above hi, on any path and for any spelling of the
+ /// normalisation. The mirror is compiled with counting clamps and the function is run over the edge grid
+ /// (lo above hi, infinite bounds, zero-length spans, b = 0 and b != 0): the count of such clamp calls
+ /// stays 0 after every call.
///
[Fact]
- public void IntegralClampLinearNormalisesLoBeforeAnyClamp()
+ public void IntegralClampLinearNeverClampsWithMinAboveMax()
{
- foreach (var (file, text) in Lowered.Where(l => l.File == "CultMath.glsl").Concat(new[] { ("CultMath.hlsl", Hlsl) }))
+ var (assembly, errors) = HlslSourceCompatibilityTests.CompileShaderMirror(CountingClampShadows);
+ Assert.True(assembly is not null, string.Join(Environment.NewLine, errors));
+ var shaderType = assembly!.GetType("CultMathHlsl.HlslShader")!;
+ var shader = Activator.CreateInstance(shaderType);
+ var count = shaderType.GetField("ClampMinAboveMax")!;
+ var function = shaderType.GetMethod("cultmath_integral_clamp_linear", BindingFlags.Instance | BindingFlags.NonPublic)!;
+
+ var bad = new List();
+ foreach (var c in HlslSourceCompatibilityTests.IntegralClampLinearEdgeGrid)
{
- var start = text.IndexOf("cultmath_integral_clamp_linear(float a", StringComparison.Ordinal);
- Assert.True(start >= 0, $"{file} has no cultmath_integral_clamp_linear");
- var body = text[start..text.IndexOf("}", start, StringComparison.Ordinal)];
- var normalise = body.IndexOf("lo = min(lo, hi);", StringComparison.Ordinal);
- var firstClamp = body.IndexOf("clamp(", StringComparison.Ordinal);
- Assert.True(normalise >= 0 && normalise < firstClamp, $"{file}: lo = min(lo, hi) must come before the first clamp in cultmath_integral_clamp_linear");
+ count.SetValue(null, 0);
+ function.Invoke(shader, c.Cast