From 28e35dc4481728ca355e6064652480412cb42963 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 19:25:20 +0200 Subject: [PATCH 1/8] CultMath tests: legacy CultPhasor JSON fixture written by the 86cd84a3 encoder Two encodings with keys cos/sin, produced by CultMathJson at base (Yggdrasil scratch run, sha256 2903179720e2a2c974a3c64efea4ee95dec823943dd67f50df01adf650823e1a). Co-Authored-By: Claude Opus 5.5 --- .../GameCult.Caching.Tests/Fixtures/cultphasor-json-legacy.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/GameCult.Caching.Tests/Fixtures/cultphasor-json-legacy.json diff --git a/tests/GameCult.Caching.Tests/Fixtures/cultphasor-json-legacy.json b/tests/GameCult.Caching.Tests/Fixtures/cultphasor-json-legacy.json new file mode 100644 index 00000000..c04540ff --- /dev/null +++ b/tests/GameCult.Caching.Tests/Fixtures/cultphasor-json-legacy.json @@ -0,0 +1 @@ +[{"cos":{"x":1,"y":2,"z":3,"w":4},"sin":{"x":5,"y":6,"z":7,"w":8}},{"cos":{"x":-5.849812,"y":0.051139403,"z":0.14261721,"w":-0.087419346},"sin":{"x":-2.0218675,"y":0.7666904,"z":-0.22879826,"w":0.9240916}}] From d605c70bbafe1741a4be60cd56f5279cc7b12d8e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 19:25:39 +0200 Subject: [PATCH 2/8] CultPhasor.cos/.sin become .cosine/.sine in C# and HLSL (GLSL regenerated next) SPIRV-Cross renames members matching GLSL builtins, so the source names change. Resolver and JSON writer accessors follow the members; the wire (positional MessagePack, JSON keys) is untouched in this commit. The committed GLSL is stale until its regeneration commit, so CommittedGlslEqualsLowering fails here. Co-Authored-By: Claude Opus 5.5 --- .../cultmath/shaders/CultMath.Phacelle.hlsl | 12 ++++---- packages/cultmath/src/CultMath/CultPhasor.cs | 10 +++---- .../tests/CultMath.Tests/PhacelleTests.cs | 30 +++++++++---------- .../Shaders/CultMath.Phacelle.hlsl | 12 ++++---- .../CultMathJson.cs | 4 +-- .../CultMathResolver.cs | 6 ++-- 6 files changed, 37 insertions(+), 37 deletions(-) diff --git a/packages/cultmath/shaders/CultMath.Phacelle.hlsl b/packages/cultmath/shaders/CultMath.Phacelle.hlsl index c95aafc6..deb47813 100644 --- a/packages/cultmath/shaders/CultMath.Phacelle.hlsl +++ b/packages/cultmath/shaders/CultMath.Phacelle.hlsl @@ -14,8 +14,8 @@ // against CultMath.CultPhasor, same field order. struct CultPhasor { - float4 cos; - float4 sin; + float4 cosine; + float4 sine; }; // Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave @@ -84,13 +84,13 @@ CultPhasor cultmath_phacelle(float3 p, float3 side, float offset, float normaliz float outCos = rawCos / rawLength; float outSin = rawSin / rawLength; float3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength; - result.cos = float4((gradRawCos - outCos * gradLength) / rawLength, outCos); - result.sin = float4((gradRawSin - outSin * gradLength) / rawLength, outSin); + result.cosine = float4((gradRawCos - outCos * gradLength) / rawLength, outCos); + result.sine = float4((gradRawSin - outSin * gradLength) / rawLength, outSin); } else { - result.cos = float4(gradRawCos / floorLength, rawCos / floorLength); - result.sin = float4(gradRawSin / floorLength, rawSin / floorLength); + result.cosine = float4(gradRawCos / floorLength, rawCos / floorLength); + result.sine = float4(gradRawSin / floorLength, rawSin / floorLength); } return result; diff --git a/packages/cultmath/src/CultMath/CultPhasor.cs b/packages/cultmath/src/CultMath/CultPhasor.cs index fbc1f9d7..4b5b6a96 100644 --- a/packages/cultmath/src/CultMath/CultPhasor.cs +++ b/packages/cultmath/src/CultMath/CultPhasor.cs @@ -14,14 +14,14 @@ namespace CultMath; public struct CultPhasor { /// (∇cos, cos): the normalized cosine wave and its gradient. - public float4 cos; + public float4 cosine; /// (∇sin, sin): the normalized sine wave and its gradient. - public float4 sin; + public float4 sine; - public CultPhasor(float4 cos, float4 sin) + public CultPhasor(float4 cosine, float4 sine) { - this.cos = cos; - this.sin = sin; + this.cosine = cosine; + this.sine = sine; } } diff --git a/packages/cultmath/tests/CultMath.Tests/PhacelleTests.cs b/packages/cultmath/tests/CultMath.Tests/PhacelleTests.cs index b64f70b1..71f5b452 100644 --- a/packages/cultmath/tests/CultMath.Tests/PhacelleTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/PhacelleTests.cs @@ -93,12 +93,12 @@ public sealed class PhacelleTests var actual = math.phacelle(p, side, offset, normalization); var expected = Evaluate(p, side, offset, normalization); - maxError = Math.Max(maxError, Math.Max(Math.Abs(actual.cos.w - expected.Cos), Math.Abs(actual.sin.w - expected.Sin))); + maxError = Math.Max(maxError, Math.Max(Math.Abs(actual.cosine.w - expected.Cos), Math.Abs(actual.sine.w - expected.Sin))); if (expected.RawLength >= 1.0 - normalization + ThresholdBand) { // The output magnitude is 1 wherever the raw magnitude reaches 1 - normalization. above++; - var magnitude = Math.Sqrt((double)actual.cos.w * actual.cos.w + (double)actual.sin.w * actual.sin.w); + var magnitude = Math.Sqrt((double)actual.cosine.w * actual.cosine.w + (double)actual.sine.w * actual.sine.w); Assert.True(Math.Abs(magnitude - 1.0) < ValueTolerance, $"magnitude {magnitude} above the threshold at {p}"); } else if (expected.RawLength <= 1.0 - normalization - ThresholdBand) @@ -130,10 +130,10 @@ public sealed class PhacelleTests var quarter = math.phacelle(p, side, offset + 0.25f, 0.5f); var whole = math.phacelle(p, side, offset + 1.0f, 0.5f); - Assert.True(MathF.Abs(quarter.cos.w + basePhasor.sin.w) < 2.0e-5f); - Assert.True(MathF.Abs(quarter.sin.w - basePhasor.cos.w) < 2.0e-5f); - Assert.True(MathF.Abs(whole.cos.w - basePhasor.cos.w) < 2.0e-5f); - Assert.True(MathF.Abs(whole.sin.w - basePhasor.sin.w) < 2.0e-5f); + Assert.True(MathF.Abs(quarter.cosine.w + basePhasor.sine.w) < 2.0e-5f); + Assert.True(MathF.Abs(quarter.sine.w - basePhasor.cosine.w) < 2.0e-5f); + Assert.True(MathF.Abs(whole.cosine.w - basePhasor.cosine.w) < 2.0e-5f); + Assert.True(MathF.Abs(whole.sine.w - basePhasor.sine.w) < 2.0e-5f); } } @@ -166,9 +166,9 @@ public sealed class PhacelleTests if (distance > 0.0) above++; else below++; var analytic = math.phacelle(p, side, offset, normalization); - var numericCos = CentralDifference(q => math.phacelle(q, side, offset, normalization).cos.w, p, GradientEps); - var numericSin = CentralDifference(q => math.phacelle(q, side, offset, normalization).sin.w, p, GradientEps); - maxError = MathF.Max(maxError, MathF.Max(MaxComponentError(numericCos, analytic.cos), MaxComponentError(numericSin, analytic.sin))); + var numericCos = CentralDifference(q => math.phacelle(q, side, offset, normalization).cosine.w, p, GradientEps); + var numericSin = CentralDifference(q => math.phacelle(q, side, offset, normalization).sine.w, p, GradientEps); + maxError = MathF.Max(maxError, MathF.Max(MaxComponentError(numericCos, analytic.cosine), MaxComponentError(numericSin, analytic.sine))); } Assert.True(above >= 150 && below >= 150, $"{above} points above and {below} below the threshold"); @@ -199,7 +199,7 @@ public sealed class PhacelleTests var flow = math.normalize(tangent); var sideDirection = math.cross(hat, flow); var phasor = math.phacelle(hat * radiusInCells, sideDirection * math.TAU, 0.25f, 0.5f); - var gradient = new float3(phasor.cos.x, phasor.cos.y, phasor.cos.z); + var gradient = new float3(phasor.cosine.x, phasor.cosine.y, phasor.cosine.z); alongFlow += Math.Abs(math.dot(gradient, flow)); acrossFlow += Math.Abs(math.dot(gradient, sideDirection)); used++; @@ -232,7 +232,7 @@ public sealed class PhacelleTests { var low = math.phacelle(SetAxis(p, axis, GetAxis(p, axis) - steps[k] * 0.5f), side, offset, 0.5f); var high = math.phacelle(SetAxis(p, axis, GetAxis(p, axis) + steps[k] * 0.5f), side, offset, 0.5f); - maxDifference[k] = MathF.Max(maxDifference[k], MathF.Max(MathF.Abs(low.cos.w - high.cos.w), MathF.Abs(low.sin.w - high.sin.w))); + maxDifference[k] = MathF.Max(maxDifference[k], MathF.Max(MathF.Abs(low.cosine.w - high.cosine.w), MathF.Abs(low.sine.w - high.sine.w))); } } @@ -260,8 +260,8 @@ public sealed class PhacelleTests var pruned = math.phacelle(p, side, offset, normalization); var unpruned = math.phacelle(p, side, offset, normalization, false); - AssertBitEqual(unpruned.cos, pruned.cos); - AssertBitEqual(unpruned.sin, pruned.sin); + AssertBitEqual(unpruned.cosine, pruned.cosine); + AssertBitEqual(unpruned.sine, pruned.sine); } } @@ -325,8 +325,8 @@ public sealed class PhacelleTests var pruned = math.phacelle(p, PruneCutSide, PruneCutOffset, PruneCutNormalization); var unpruned = math.phacelle(p, PruneCutSide, PruneCutOffset, PruneCutNormalization, false); - AssertBitEqual(unpruned.cos, pruned.cos); - AssertBitEqual(unpruned.sin, pruned.sin); + AssertBitEqual(unpruned.cosine, pruned.cosine); + AssertBitEqual(unpruned.sine, pruned.sine); } } diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.Phacelle.hlsl b/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.Phacelle.hlsl index c95aafc6..deb47813 100644 --- a/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.Phacelle.hlsl +++ b/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.Phacelle.hlsl @@ -14,8 +14,8 @@ // against CultMath.CultPhasor, same field order. struct CultPhasor { - float4 cos; - float4 sin; + float4 cosine; + float4 sine; }; // Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave @@ -84,13 +84,13 @@ CultPhasor cultmath_phacelle(float3 p, float3 side, float offset, float normaliz float outCos = rawCos / rawLength; float outSin = rawSin / rawLength; float3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength; - result.cos = float4((gradRawCos - outCos * gradLength) / rawLength, outCos); - result.sin = float4((gradRawSin - outSin * gradLength) / rawLength, outSin); + result.cosine = float4((gradRawCos - outCos * gradLength) / rawLength, outCos); + result.sine = float4((gradRawSin - outSin * gradLength) / rawLength, outSin); } else { - result.cos = float4(gradRawCos / floorLength, rawCos / floorLength); - result.sin = float4(gradRawSin / floorLength, rawSin / floorLength); + result.cosine = float4(gradRawCos / floorLength, rawCos / floorLength); + result.sine = float4(gradRawSin / floorLength, rawSin / floorLength); } return result; diff --git a/src/GameCult.Caching.MessagePack/CultMathJson.cs b/src/GameCult.Caching.MessagePack/CultMathJson.cs index ba83410d..12958f03 100644 --- a/src/GameCult.Caching.MessagePack/CultMathJson.cs +++ b/src/GameCult.Caching.MessagePack/CultMathJson.cs @@ -511,8 +511,8 @@ public static class CultMathJson a => new CultPhasor(new float4(a[0], a[1], a[2], a[3]), new float4(a[4], a[5], a[6], a[7])), (v, a) => { - a[0] = v.cos.x; a[1] = v.cos.y; a[2] = v.cos.z; a[3] = v.cos.w; - a[4] = v.sin.x; a[5] = v.sin.y; a[6] = v.sin.z; a[7] = v.sin.w; + a[0] = v.cosine.x; a[1] = v.cosine.y; a[2] = v.cosine.z; a[3] = v.cosine.w; + a[4] = v.sine.x; a[5] = v.sine.y; a[6] = v.sine.z; a[7] = v.sine.w; }), Add(f, new Fields(("field", Xyzw()), ("curvature", Val()), ("fold", Val())), a => new SpanNoise(new float4(a[0], a[1], a[2], a[3]), a[4], a[5]), diff --git a/src/GameCult.Caching.MessagePack/CultMathResolver.cs b/src/GameCult.Caching.MessagePack/CultMathResolver.cs index 95937282..02ee980c 100644 --- a/src/GameCult.Caching.MessagePack/CultMathResolver.cs +++ b/src/GameCult.Caching.MessagePack/CultMathResolver.cs @@ -221,7 +221,7 @@ public sealed class CultMathResolver : IFormatterResolver } return value; }), - Shape((ref MessagePackWriter w, CultPhasor v) => { w.WriteArrayHeader(2); PutFloat4(ref w, v.cos); PutFloat4(ref w, v.sin); }, + Shape((ref MessagePackWriter w, CultPhasor v) => { w.WriteArrayHeader(2); PutFloat4(ref w, v.cosine); PutFloat4(ref w, v.sine); }, (ref MessagePackReader r) => { var value = default(CultPhasor); @@ -229,8 +229,8 @@ public sealed class CultMathResolver : IFormatterResolver { switch (i) { - case 0: value.cos = GetFloat4(ref r); break; - case 1: value.sin = GetFloat4(ref r); break; + case 0: value.cosine = GetFloat4(ref r); break; + case 1: value.sine = GetFloat4(ref r); break; default: r.Skip(); break; } } From c027d3f88464594ebe862c3ca2bc2793406cb50f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 19:28:00 +0200 Subject: [PATCH 3/8] CultMath.Phacelle.glsl regenerated for cosine/sine Written by CULTMATH_WRITE_GLSL=1 on Yggdrasil (sdk:10.0) at d605c70b; sha256 21eef3c38cc609909444c906a1ef965f01b541f2301023a722e64ecc4c846387. glsl-parity.json and device-bounds-lavapipe.txt are untouched by the regeneration. Co-Authored-By: Claude Opus 5.5 --- packages/cultmath/shaders/CultMath.Phacelle.glsl | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cultmath/shaders/CultMath.Phacelle.glsl b/packages/cultmath/shaders/CultMath.Phacelle.glsl index 11405fa2..c22c67c4 100644 --- a/packages/cultmath/shaders/CultMath.Phacelle.glsl +++ b/packages/cultmath/shaders/CultMath.Phacelle.glsl @@ -14,8 +14,8 @@ // against CultMath.CultPhasor, same field order. struct CultPhasor { - vec4 cos; - vec4 sin; + vec4 cosine; + vec4 sine; }; // Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave @@ -84,13 +84,13 @@ CultPhasor cultmath_phacelle(vec3 p, vec3 side, float offset, float normalizatio float outCos = rawCos / rawLength; float outSin = rawSin / rawLength; vec3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength; - result.cos = vec4((gradRawCos - outCos * gradLength) / rawLength, outCos); - result.sin = vec4((gradRawSin - outSin * gradLength) / rawLength, outSin); + result.cosine = vec4((gradRawCos - outCos * gradLength) / rawLength, outCos); + result.sine = vec4((gradRawSin - outSin * gradLength) / rawLength, outSin); } else { - result.cos = vec4(gradRawCos / floorLength, rawCos / floorLength); - result.sin = vec4(gradRawSin / floorLength, rawSin / floorLength); + result.cosine = vec4(gradRawCos / floorLength, rawCos / floorLength); + result.sine = vec4(gradRawSin / floorLength, rawSin / floorLength); } return result; From 58baccce787d1a40c75eb81757b89b83d157bb8a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 19:28:09 +0200 Subject: [PATCH 4/8] CultMathJson writes CultPhasor as cosine/sine and still reads cos/sin Fields takes an optional legacy name per field, matched on read only and mapped to the same index as the current name, so a legacy and a current name in one object are the same property and the duplicate rules decide (refused when AllowDuplicateProperties is false, last wins otherwise). Writing never emits a legacy name. MessagePack bytes are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../CultMathJson.cs | 23 +++++++-- .../CultMathResolver.cs | 2 +- .../CultMathJsonTests.cs | 51 ++++++++++++++++++- 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/GameCult.Caching.MessagePack/CultMathJson.cs b/src/GameCult.Caching.MessagePack/CultMathJson.cs index 12958f03..65dea503 100644 --- a/src/GameCult.Caching.MessagePack/CultMathJson.cs +++ b/src/GameCult.Caching.MessagePack/CultMathJson.cs @@ -14,7 +14,7 @@ namespace GameCult.Caching.MessagePack; /// The canonical System.Text.Json shape of every public CultMath value type, the JSON twin of /// . Vectors, quaternions and colors are objects keyed by their /// field names ({"x":1,"y":2}), matrices are arrays of row objects, rect is -/// {"min":..,"max":..}, CultCellular is {"nearest":..,"edge":..,"id":..}, CultPhasor is {"cos":..,"sin":..}, SpanNoise is {"field":..,"curvature":..,"fold":..}, SpanPath is {"midpoint":..,"velocity":..,"speed":..,"acceleration":..,"halfLength":..}, SpanStage is {"frequency":..,"stretch":..,"axis":..,"shift":..,"offset":..,"octaveStep":..}, and CultSiteCells is {"nearest":..,"second":..,"nearestSite":..,"secondSite":..}. +/// {"min":..,"max":..}, CultCellular is {"nearest":..,"edge":..,"id":..}, CultPhasor is {"cosine":..,"sine":..} (reading also accepts the legacy cos/sin names), SpanNoise is {"field":..,"curvature":..,"fold":..}, SpanPath is {"midpoint":..,"velocity":..,"speed":..,"acceleration":..,"halfLength":..}, SpanStage is {"frequency":..,"stretch":..,"axis":..,"shift":..,"offset":..,"octaveStep":..}, and CultSiteCells is {"nearest":..,"second":..,"nearestSite":..,"secondSite":..}. /// Property names are fixed: PropertyNamingPolicy does not rename them, and /// PropertyNameCaseInsensitive makes reading case-insensitive. /// @@ -270,14 +270,24 @@ public static class CultMathJson { private readonly string[] names; private readonly byte[][] utf8Names; + private readonly string?[] legacyNames; + private readonly byte[]?[] legacyUtf8Names; private readonly JsonEncodedText[] encoded; private readonly Node[] children; private readonly int[] offsets; public Fields(params (string Name, Node Child)[] fields) + : this(Array.ConvertAll(fields, f => (f.Name, f.Child, (string?)null))) + { + } + + // A legacy name is matched on read only and maps to the same field as its current name. + public Fields(params (string Name, Node Child, string? Legacy)[] fields) { names = new string[fields.Length]; utf8Names = new byte[fields.Length][]; + legacyNames = new string?[fields.Length]; + legacyUtf8Names = new byte[]?[fields.Length]; encoded = new JsonEncodedText[fields.Length]; children = new Node[fields.Length]; offsets = new int[fields.Length]; @@ -285,6 +295,8 @@ public static class CultMathJson { names[i] = fields[i].Name; utf8Names[i] = Encoding.UTF8.GetBytes(fields[i].Name); + legacyNames[i] = fields[i].Legacy; + legacyUtf8Names[i] = fields[i].Legacy is { } legacy ? Encoding.UTF8.GetBytes(legacy) : null; encoded[i] = JsonEncodedText.Encode(fields[i].Name); children[i] = fields[i].Child; offsets[i] = Count; @@ -339,13 +351,14 @@ public static class CultMathJson if (!ignoreCase) { for (var i = 0; i < utf8Names.Length; i++) - if (r.ValueTextEquals(utf8Names[i])) return i; + if (r.ValueTextEquals(utf8Names[i]) || (legacyUtf8Names[i] is { } legacy && r.ValueTextEquals(legacy))) return i; return -1; } var name = r.GetString()!; for (var i = 0; i < names.Length; i++) - if (string.Equals(names[i], name, StringComparison.OrdinalIgnoreCase)) return i; + if (string.Equals(names[i], name, StringComparison.OrdinalIgnoreCase) + || (legacyNames[i] is { } legacy && string.Equals(legacy, name, StringComparison.OrdinalIgnoreCase))) return i; return -1; } } @@ -481,7 +494,7 @@ public static class CultMathJson Add(UIntScalar.Instance, new Fields(("state", Val())), a => new Random { state = a[0] }, (v, a) => a[0] = v.state), - // Compositions: matrices are arrays of row objects, rect is {min, max}, CultCellular is {nearest, edge, id}, CultPhasor is {cos, sin}, SpanNoise is {field, curvature, fold}, SpanPath is {midpoint, velocity, speed, acceleration, halfLength}, SpanStage is {frequency, stretch, axis, shift, offset, octaveStep}, CultSiteCells is {nearest, second, nearestSite, secondSite}. + // Compositions: matrices are arrays of row objects, rect is {min, max}, CultCellular is {nearest, edge, id}, CultPhasor is {cosine, sine}, SpanNoise is {field, curvature, fold}, SpanPath is {midpoint, velocity, speed, acceleration, halfLength}, SpanStage is {frequency, stretch, axis, shift, offset, octaveStep}, CultSiteCells is {nearest, second, nearestSite, secondSite}. Add(f, new Rows(2, Xy()), a => new float2x2(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v[0].x; a[1] = v[0].y; a[2] = v[1].x; a[3] = v[1].y; }), @@ -507,7 +520,7 @@ public static class CultMathJson a[4] = v.edge.x; a[5] = v.edge.y; a[6] = v.edge.z; a[7] = v.edge.w; a[8] = v.id; }), - Add(f, new Fields(("cos", Xyzw()), ("sin", Xyzw())), + Add(f, new Fields(("cosine", Xyzw(), "cos"), ("sine", Xyzw(), "sin")), a => new CultPhasor(new float4(a[0], a[1], a[2], a[3]), new float4(a[4], a[5], a[6], a[7])), (v, a) => { diff --git a/src/GameCult.Caching.MessagePack/CultMathResolver.cs b/src/GameCult.Caching.MessagePack/CultMathResolver.cs index 02ee980c..048b7f87 100644 --- a/src/GameCult.Caching.MessagePack/CultMathResolver.cs +++ b/src/GameCult.Caching.MessagePack/CultMathResolver.cs @@ -180,7 +180,7 @@ public sealed class CultMathResolver : IFormatterResolver return value; }), - // Compositions: matrices are rows, rect is [min, max], CultCellular is [nearest, edge, id], CultPhasor is [cos, sin], SpanNoise is [field, curvature, fold], SpanPath is [midpoint, velocity, speed, acceleration, halfLength], SpanStage is [frequency, stretch, axis, shift, offset, octaveStep], CultSiteCells is [nearest, second, nearestSite, secondSite]. + // Compositions: matrices are rows, rect is [min, max], CultCellular is [nearest, edge, id], CultPhasor is [cosine, sine], SpanNoise is [field, curvature, fold], SpanPath is [midpoint, velocity, speed, acceleration, halfLength], SpanStage is [frequency, stretch, axis, shift, offset, octaveStep], CultSiteCells is [nearest, second, nearestSite, secondSite]. Shape((ref MessagePackWriter w, float2x2 v) => { w.WriteArrayHeader(2); PutFloat2(ref w, v[0]); PutFloat2(ref w, v[1]); }, (ref MessagePackReader r) => { diff --git a/tests/GameCult.Caching.Tests/CultMathJsonTests.cs b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs index 4ebd552b..ef8ea3d6 100644 --- a/tests/GameCult.Caching.Tests/CultMathJsonTests.cs +++ b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs @@ -44,7 +44,7 @@ namespace GameCult.Caching.Tests Json(new CultCellular(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f), 0.25f)).Should().Be( """{"nearest":{"x":1,"y":2,"z":3,"w":4},"edge":{"x":5,"y":6,"z":7,"w":8},"id":0.25}"""); Json(new CultPhasor(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f))).Should().Be( - """{"cos":{"x":1,"y":2,"z":3,"w":4},"sin":{"x":5,"y":6,"z":7,"w":8}}"""); + """{"cosine":{"x":1,"y":2,"z":3,"w":4},"sine":{"x":5,"y":6,"z":7,"w":8}}"""); Json(new SpanNoise(new float4(1f, 2f, 3f, 4f), 0.25f, 0.5f)).Should().Be( """{"field":{"x":1,"y":2,"z":3,"w":4},"curvature":0.25,"fold":0.5}"""); Json(new SpanPath { midpoint = new float3(1f, 2f, 3f), velocity = new float3(4f, 5f, 6f), speed = 7f, acceleration = 8f, halfLength = 9f }).Should().Be( @@ -92,6 +92,55 @@ namespace GameCult.Caching.Tests Parse("null").Should().BeNull(); } + [Test] + public void Reading_AcceptsTheLegacyPhasorNames() + { + // The fixture is what the 0.4.1-era encoder wrote: keys cos/sin. + var path = System.IO.Path.Combine(TestContext.CurrentContext.TestDirectory, "Fixtures", "cultphasor-json-legacy.json"); + var legacy = Parse(System.IO.File.ReadAllText(path)); + var current = new[] + { + new CultPhasor(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f)), + math.phacelle(new float3(0.3f, 1.7f, -2.2f), new float3(6.2831853f, 0f, 0f), 0.25f, 0.5f), + }; + legacy.Should().HaveCount(2); + for (var i = 0; i < 2; i++) + { + BitsOf(legacy[i]).Should().Equal(BitsOf(current[i])); + BitsOf(Parse(Json(current[i]))).Should().Equal(BitsOf(current[i])); + } + + var caseInsensitive = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }.AddCultMathConverters(); + BitsOf(JsonSerializer.Deserialize("""{"COS":{"x":1,"y":2,"z":3,"w":4},"Sin":{"x":5,"y":6,"z":7,"w":8}}""", caseInsensitive)) + .Should().Equal(BitsOf(current[0])); + } + + [Test] + public void Reading_RefusesALegacyAndCurrentNameTogether() + { + var property = typeof(JsonSerializerOptions).GetProperty("AllowDuplicateProperties"); + Assert.That(property, Is.Not.Null, "the test runtime's System.Text.Json has AllowDuplicateProperties"); + var strict = new JsonSerializerOptions().AddCultMathConverters(); + property!.SetValue(strict, false); + Action both = () => JsonSerializer.Deserialize("""{"cos":{"x":1},"cosine":{"x":2}}""", strict); + both.Should().Throw().WithMessage("*appears more than once*"); + Action reversed = () => JsonSerializer.Deserialize("""{"sine":{"x":1},"sin":{"x":2}}""", strict); + reversed.Should().Throw().WithMessage("*appears more than once*"); + + // With duplicates allowed the later property replaces the earlier one, as for any repeated name. + Parse("""{"cos":{"x":1,"y":9},"cosine":{"x":2}}""").cosine.Should().Be(new float4(2f, 0f, 0f, 0f)); + } + + [Test] + public void Writing_NeverEmitsLegacyNames() + { + using var document = JsonDocument.Parse(Json(new CultPhasor(new float4(1f, 1f, 1f, 1f), new float4(2f, 2f, 2f, 2f)))); + document.RootElement.EnumerateObject().Select(p => p.Name).Should().Equal("cosine", "sine"); + } + + private static int[] BitsOf(CultPhasor v) => new[] { v.cosine.x, v.cosine.y, v.cosine.z, v.cosine.w, v.sine.x, v.sine.y, v.sine.z, v.sine.w } + .Select(BitConverter.SingleToInt32Bits).ToArray(); + [Test] public void Reading_RefusesShapesThatAreNotTheType() { From 1330362235cc47cc8286af1d3efd8192cdc8428f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 19:28:25 +0200 Subject: [PATCH 5/8] CultPhasor rename: README, design note and both changelogs cultmath-unity [Unreleased] and cultlib [Unreleased] carry the Breaking entries; no version bump, no tag. Co-Authored-By: Claude Opus 5.5 --- packages/cultmath/docs/design.md | 2 +- .../cultmath/unity/org.gamecult.cultmath/CHANGELOG.md | 9 +++++++++ src/GameCult.Caching.MessagePack/README.md | 2 +- unity/org.gamecult.cultlib/CHANGELOG.md | 4 ++++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/cultmath/docs/design.md b/packages/cultmath/docs/design.md index 62c6ed00..3b63f4d2 100644 --- a/packages/cultmath/docs/design.md +++ b/packages/cultmath/docs/design.md @@ -429,7 +429,7 @@ together, laid out as `float4(gradient.xyz, value.w)`, with no value-only twin - `phacelle(float3 p, float3 side, float offset, float normalization)` is Rune Skovbo Johansen's Phacelle noise ("phase + cell": a stripe pattern built by blending one cosine/sine wave per jittered cell), generalised to 3D cells. It - returns `CultPhasor { cos, sin }`, each `(∇, value)`. `side` is the stripe + returns `CultPhasor { cosine, sine }`, each `(∇, value)`. `side` is the stripe wave vector, whose length is 2π times the stripe frequency; in 3D the perpendicular to the flow is not unique, so the caller chooses it (on a unit sphere, `cross(p̂, flow)`). `offset` is the phase in cycles. It visits 4×4×4 diff --git a/packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md b/packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md index 21511bc8..d8b27dc3 100644 --- a/packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md +++ b/packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this package are documented in this file. Earlier releases (0.2.0-0.2.3) predate this package's changelog and are not backfilled here; see `docs/semver-policy.md` for why. +## [Unreleased] + +### Breaking + +- `CultPhasor.cos` and `.sin` are now `.cosine` and `.sine` in C#, HLSL and GLSL, + constructor parameters included, because SPIRV-Cross renames members that match + GLSL builtins. This is a minor bump on 0.y.z, so the next cultmath-unity release + is 0.5.0. + ## [0.4.1] ### Fixed diff --git a/src/GameCult.Caching.MessagePack/README.md b/src/GameCult.Caching.MessagePack/README.md index a03a67c4..dfc7bff6 100644 --- a/src/GameCult.Caching.MessagePack/README.md +++ b/src/GameCult.Caching.MessagePack/README.md @@ -101,7 +101,7 @@ broken graph whole (an `ExprGraphException` naming the rule), so a consumer | `float2x2`, `float3x3` | array of row vectors (row-major) | array of row objects | | `rect` | `[min, max]` of `float2` | `{"min":{..},"max":{..}}` | | `CultCellular` | `[nearest, edge, id]` | `{"nearest":{..},"edge":{..},"id":..}` | -| `CultPhasor` | `[cos, sin]` of `float4` | `{"cos":{..},"sin":{..}}` | +| `CultPhasor` | `[cosine, sine]` of `float4` | `{"cosine":{..},"sine":{..}}` (reads legacy `cos`/`sin`) | | `CultSiteCells` | `[nearest, second, nearestSite, secondSite]` | `{"nearest":{..},"second":{..},"nearestSite":..,"secondSite":..}` | Floats are MessagePack float32 (`0xCA`), doubles float64 (`0xCB`), integers the diff --git a/unity/org.gamecult.cultlib/CHANGELOG.md b/unity/org.gamecult.cultlib/CHANGELOG.md index 9d54e996..7b0093d2 100644 --- a/unity/org.gamecult.cultlib/CHANGELOG.md +++ b/unity/org.gamecult.cultlib/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to this package are documented in this file. ### Breaking +- `CultMathJson` writes `CultPhasor` as `{"cosine":..,"sine":..}` and still reads + the old `cos`/`sin` keys. A reader from before this change skips the new keys + and decodes zeros, so upgrade readers before writers. MessagePack bytes are + unchanged. - CultCache refuses an unknown union arm on read and an unnamed union subtype on write. A payload whose arm key the union does not declare used to decode to `null` (and the next save wrote the loss back); a value whose type the union From 841b11db948a31bff9171edafd020f11d68752a1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 19:44:10 +0200 Subject: [PATCH 6/8] CultMathJson tests: unknown names are skipped under case-insensitive reading Kills the surviving Stryker mutant in Fields.IndexOf's case-insensitive loop and covers its not-found return. Co-Authored-By: Claude Opus 5.5 --- tests/GameCult.Caching.Tests/CultMathJsonTests.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/GameCult.Caching.Tests/CultMathJsonTests.cs b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs index ef8ea3d6..54e01e24 100644 --- a/tests/GameCult.Caching.Tests/CultMathJsonTests.cs +++ b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs @@ -131,6 +131,14 @@ namespace GameCult.Caching.Tests Parse("""{"cos":{"x":1,"y":9},"cosine":{"x":2}}""").cosine.Should().Be(new float4(2f, 0f, 0f, 0f)); } + [Test] + public void Reading_SkipsUnknownNamesWhenCaseInsensitive() + { + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }.AddCultMathConverters(); + JsonSerializer.Deserialize("""{"extra":[1,2],"COSINE":{"x":3},"coss":{"x":4}}""", options).cosine.x.Should().Be(3f); + JsonSerializer.Deserialize("""{"X":1,"q":2,"Y":3}""", options).Should().Be(new float2(1f, 3f)); + } + [Test] public void Writing_NeverEmitsLegacyNames() { From 3bf9f7040a5e9f77382e529e575e29a5044ddb00 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 20:33:44 +0200 Subject: [PATCH 7/8] CultMathJson tests: legacy phasor names under Disallow, and a third occurrence is a duplicate Co-Authored-By: Claude Opus 5.5 --- .../CultMathJsonTests.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/GameCult.Caching.Tests/CultMathJsonTests.cs b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs index 54e01e24..33753074 100644 --- a/tests/GameCult.Caching.Tests/CultMathJsonTests.cs +++ b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs @@ -131,6 +131,30 @@ namespace GameCult.Caching.Tests Parse("""{"cos":{"x":1,"y":9},"cosine":{"x":2}}""").cosine.Should().Be(new float4(2f, 0f, 0f, 0f)); } + [Test] + public void Reading_AcceptsTheLegacyPhasorNamesUnderDisallow() + { + var options = new JsonSerializerOptions { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }.AddCultMathConverters(); + var phasor = JsonSerializer.Deserialize("""{"cos":{"x":1,"y":2,"z":3,"w":4},"sin":{"x":5,"y":6,"z":7,"w":8}}""", options); + BitsOf(phasor).Should().Equal(BitsOf(new CultPhasor(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f)))); + Action unknown = () => JsonSerializer.Deserialize("""{"cos":{"x":1},"coss":{"x":2}}""", options); + unknown.Should().Throw().WithMessage("*not a component*"); + } + + [Test] + public void Reading_TreatsAThirdOccurrenceOfAPhasorNameAsADuplicate() + { + var property = typeof(JsonSerializerOptions).GetProperty("AllowDuplicateProperties"); + Assert.That(property, Is.Not.Null, "the test runtime's System.Text.Json has AllowDuplicateProperties"); + var strict = new JsonSerializerOptions().AddCultMathConverters(); + property!.SetValue(strict, false); + Action thrice = () => JsonSerializer.Deserialize("""{"cos":{"x":1},"cosine":{"x":2},"cos":{"x":3}}""", strict); + thrice.Should().Throw().WithMessage("*appears more than once*"); + + // Allowed, the last occurrence replaces the whole value, including after a third. + Parse("""{"cos":{"x":1,"y":9},"cosine":{"x":2},"cos":{"z":3}}""").cosine.Should().Be(new float4(0f, 0f, 3f, 0f)); + } + [Test] public void Reading_SkipsUnknownNamesWhenCaseInsensitive() { From 4391ed23f994db8be743fc99b36a94fc361e6a93 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 20:33:45 +0200 Subject: [PATCH 8/8] CHANGELOG: the old CultPhasor reader decodes zeros by default and throws under Disallow Co-Authored-By: Claude Opus 5.5 --- unity/org.gamecult.cultlib/CHANGELOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/unity/org.gamecult.cultlib/CHANGELOG.md b/unity/org.gamecult.cultlib/CHANGELOG.md index 7b0093d2..097fcada 100644 --- a/unity/org.gamecult.cultlib/CHANGELOG.md +++ b/unity/org.gamecult.cultlib/CHANGELOG.md @@ -7,9 +7,10 @@ All notable changes to this package are documented in this file. ### Breaking - `CultMathJson` writes `CultPhasor` as `{"cosine":..,"sine":..}` and still reads - the old `cos`/`sin` keys. A reader from before this change skips the new keys - and decodes zeros, so upgrade readers before writers. MessagePack bytes are - unchanged. + the old `cos`/`sin` keys. A reader from before this change does not know the + new keys: with default options it skips them and decodes zeros, and with + `UnmappedMemberHandling.Disallow` it throws. Upgrade readers before writers. + MessagePack bytes are unchanged. - CultCache refuses an unknown union arm on read and an unnamed union subtype on write. A payload whose arm key the union does not declare used to decode to `null` (and the next save wrote the loss back); a value whose type the union