From 139deb0e28b3764339e16a796f2b8ddea4ddb0be Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 16:13:22 +0200 Subject: [PATCH 01/12] Add the canonical MessagePack encoding for every public CultMath value type CultMathResolver formats float2/3/4, double2/3, int2/3/4, bool2/3/4, quaternion, Color32, Random, float2x2, float3x3, rect and CultCellular as component arrays, and sits in the default CultCache resolver chain so no consumer registers anything. It lives in GameCult.Caching.MessagePack, which already owns that chain; CultMath core stays dependency-free. Vector, rect and Aetheria-formatter byte shapes are pinned by golden tests, a reflection test fails when a public CultMath value type has no formatter, and decode skips extra elements and refuses nil. Co-Authored-By: Claude Sonnet 5 --- .../CultDocumentMessagePackSerialization.cs | 2 +- .../CultMathResolver.cs | 231 ++++++++++++++++++ .../CultMathSerializationTests.cs | 149 +++++++++++ 3 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 src/GameCult.Caching.MessagePack/CultMathResolver.cs create mode 100644 tests/GameCult.Caching.Tests/CultMathSerializationTests.cs diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index ef1b4092..f591d764 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -57,7 +57,7 @@ public static class CultDocumentMessagePackSerialization { return MessagePackSerializerOptions.Standard .WithResolver(CompositeResolver.Create( - consumerResolvers.Append(CultDocumentResolver.Instance).Append(StandardResolver.Instance).ToArray())) + consumerResolvers.Append(CultDocumentResolver.Instance).Append(CultMathResolver.Instance).Append(StandardResolver.Instance).ToArray())) .WithSecurity(CultMessagePackSecurity.Instance); } diff --git a/src/GameCult.Caching.MessagePack/CultMathResolver.cs b/src/GameCult.Caching.MessagePack/CultMathResolver.cs new file mode 100644 index 00000000..71bdec2b --- /dev/null +++ b/src/GameCult.Caching.MessagePack/CultMathResolver.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using CultMath; +using MessagePack; +using MessagePack.Formatters; +using Random = CultMath.Random; + +namespace GameCult.Caching.MessagePack; + +/// +/// The canonical MessagePack encoding of every public CultMath value type. Each type is an +/// array of its components in declaration order: vectors and quaternions are flat scalar +/// arrays, matrices are arrays of row vectors, and rect is [min, max]. Decoding +/// skips trailing elements and leaves missing ones at zero; nil is refused. The resolver is +/// part of the default chain, so a consumer +/// registers nothing. +/// +public sealed class CultMathResolver : IFormatterResolver +{ + public static readonly CultMathResolver Instance = new(); + private CultMathResolver() { } + + /// The CultMath types this resolver formats, for coverage checks. + public static IReadOnlyCollection FormattedTypes => Formatters.Keys; + + public IMessagePackFormatter? GetFormatter() => Cache.Formatter; + + private static class Cache + { + public static readonly IMessagePackFormatter? Formatter = + Formatters.TryGetValue(typeof(T), out var formatter) ? (IMessagePackFormatter)formatter : null; + } + + private delegate void WriteFn(ref MessagePackWriter writer, T value); + private delegate T ReadFn(ref MessagePackReader reader); + + private sealed class ArrayShape : IMessagePackFormatter + { + private readonly WriteFn write; + private readonly ReadFn read; + + public ArrayShape(WriteFn write, ReadFn read) + { + this.write = write; + this.read = read; + } + + public void Serialize(ref MessagePackWriter writer, T value, MessagePackSerializerOptions options) => write(ref writer, value); + + public T Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + if (reader.IsNil) throw new MessagePackSerializationException($"{typeof(T).Name} cannot be read from nil."); + return read(ref reader); + } + } + + private static KeyValuePair Shape(WriteFn write, ReadFn read) => + new(typeof(T), new ArrayShape(write, read)); + + // Each reader takes the first dst.Length elements and skips the rest. + private static void Singles(ref MessagePackReader r, Span dst) + { + for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) + if (i < dst.Length) dst[i] = r.ReadSingle(); else r.Skip(); + } + + private static void Doubles(ref MessagePackReader r, Span dst) + { + for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) + if (i < dst.Length) dst[i] = r.ReadDouble(); else r.Skip(); + } + + private static void Ints(ref MessagePackReader r, Span dst) + { + for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) + if (i < dst.Length) dst[i] = r.ReadInt32(); else r.Skip(); + } + + private static void Bools(ref MessagePackReader r, Span dst) + { + for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) + if (i < dst.Length) dst[i] = r.ReadBoolean(); else r.Skip(); + } + + private static void Bytes(ref MessagePackReader r, Span dst) + { + for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) + if (i < dst.Length) dst[i] = r.ReadByte(); else r.Skip(); + } + + private static void Put(ref MessagePackWriter w, ReadOnlySpan v) + { + w.WriteArrayHeader(v.Length); + foreach (var e in v) w.Write(e); + } + + private static void Put(ref MessagePackWriter w, ReadOnlySpan v) + { + w.WriteArrayHeader(v.Length); + foreach (var e in v) w.Write(e); + } + + private static void Put(ref MessagePackWriter w, ReadOnlySpan v) + { + w.WriteArrayHeader(v.Length); + foreach (var e in v) w.Write(e); + } + + private static void Put(ref MessagePackWriter w, ReadOnlySpan v) + { + w.WriteArrayHeader(v.Length); + foreach (var e in v) w.Write(e); + } + + private static void Put(ref MessagePackWriter w, ReadOnlySpan v) + { + w.WriteArrayHeader(v.Length); + foreach (var e in v) w.Write(e); + } + + private static void PutFloat2(ref MessagePackWriter w, float2 v) => Put(ref w, stackalloc float[] { v.x, v.y }); + private static void PutFloat3(ref MessagePackWriter w, float3 v) => Put(ref w, stackalloc float[] { v.x, v.y, v.z }); + private static void PutFloat4(ref MessagePackWriter w, float4 v) => Put(ref w, stackalloc float[] { v.x, v.y, v.z, v.w }); + + private static float2 GetFloat2(ref MessagePackReader r) + { + Span s = stackalloc float[2]; + Singles(ref r, s); + return new float2(s[0], s[1]); + } + + private static float3 GetFloat3(ref MessagePackReader r) + { + Span s = stackalloc float[3]; + Singles(ref r, s); + return new float3(s[0], s[1], s[2]); + } + + private static float4 GetFloat4(ref MessagePackReader r) + { + Span s = stackalloc float[4]; + Singles(ref r, s); + return new float4(s[0], s[1], s[2], s[3]); + } + + private static readonly Dictionary Formatters = Build(); + + private static Dictionary Build() + { + var shapes = new[] + { + Shape(PutFloat2, GetFloat2), + Shape(PutFloat3, GetFloat3), + Shape(PutFloat4, GetFloat4), + Shape((ref MessagePackWriter w, double2 v) => Put(ref w, stackalloc double[] { v.x, v.y }), + (ref MessagePackReader r) => { Span s = stackalloc double[2]; Doubles(ref r, s); return new double2(s[0], s[1]); }), + Shape((ref MessagePackWriter w, double3 v) => Put(ref w, stackalloc double[] { v.x, v.y, v.z }), + (ref MessagePackReader r) => { Span s = stackalloc double[3]; Doubles(ref r, s); return new double3(s[0], s[1], s[2]); }), + Shape((ref MessagePackWriter w, int2 v) => Put(ref w, stackalloc int[] { v.x, v.y }), + (ref MessagePackReader r) => { Span s = stackalloc int[2]; Ints(ref r, s); return new int2(s[0], s[1]); }), + Shape((ref MessagePackWriter w, int3 v) => Put(ref w, stackalloc int[] { v.x, v.y, v.z }), + (ref MessagePackReader r) => { Span s = stackalloc int[3]; Ints(ref r, s); return new int3(s[0], s[1], s[2]); }), + Shape((ref MessagePackWriter w, int4 v) => Put(ref w, stackalloc int[] { v.x, v.y, v.z, v.w }), + (ref MessagePackReader r) => { Span s = stackalloc int[4]; Ints(ref r, s); return new int4(s[0], s[1], s[2], s[3]); }), + Shape((ref MessagePackWriter w, bool2 v) => Put(ref w, stackalloc bool[] { v.x, v.y }), + (ref MessagePackReader r) => { Span s = stackalloc bool[2]; Bools(ref r, s); return new bool2(s[0], s[1]); }), + Shape((ref MessagePackWriter w, bool3 v) => Put(ref w, stackalloc bool[] { v.x, v.y, v.z }), + (ref MessagePackReader r) => { Span s = stackalloc bool[3]; Bools(ref r, s); return new bool3(s[0], s[1], s[2]); }), + Shape((ref MessagePackWriter w, bool4 v) => Put(ref w, stackalloc bool[] { v.x, v.y, v.z, v.w }), + (ref MessagePackReader r) => { Span s = stackalloc bool[4]; Bools(ref r, s); return new bool4(s[0], s[1], s[2], s[3]); }), + Shape((ref MessagePackWriter w, quaternion v) => Put(ref w, stackalloc float[] { v.x, v.y, v.z, v.w }), + (ref MessagePackReader r) => { Span s = stackalloc float[4]; Singles(ref r, s); return new quaternion(s[0], s[1], s[2], s[3]); }), + Shape((ref MessagePackWriter w, Color32 v) => Put(ref w, stackalloc byte[] { v.r, v.g, v.b, v.a }), + (ref MessagePackReader r) => { Span s = stackalloc byte[4]; Bytes(ref r, s); return new Color32(s[0], s[1], s[2], s[3]); }), + Shape((ref MessagePackWriter w, Random v) => { w.WriteArrayHeader(1); w.Write(v.state); }, + (ref MessagePackReader r) => + { + var value = default(Random); + for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) + if (i == 0) value.state = r.ReadUInt32(); else r.Skip(); + return value; + }), + + // Compositions: matrices are rows, rect is [min, max], CultCellular is [nearest, edge, id]. + Shape((ref MessagePackWriter w, float2x2 v) => { w.WriteArrayHeader(2); PutFloat2(ref w, v[0]); PutFloat2(ref w, v[1]); }, + (ref MessagePackReader r) => + { + var rows = new float2[2]; + for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) + if (i < 2) rows[i] = GetFloat2(ref r); else r.Skip(); + return new float2x2(rows[0], rows[1]); + }), + Shape((ref MessagePackWriter w, float3x3 v) => { w.WriteArrayHeader(3); PutFloat3(ref w, v[0]); PutFloat3(ref w, v[1]); PutFloat3(ref w, v[2]); }, + (ref MessagePackReader r) => + { + var rows = new float3[3]; + for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) + if (i < 3) rows[i] = GetFloat3(ref r); else r.Skip(); + return new float3x3(rows[0], rows[1], rows[2]); + }), + Shape((ref MessagePackWriter w, rect v) => { w.WriteArrayHeader(2); PutFloat2(ref w, v.min); PutFloat2(ref w, v.max); }, + (ref MessagePackReader r) => + { + var corners = new float2[2]; + for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) + if (i < 2) corners[i] = GetFloat2(ref r); else r.Skip(); + return new rect(corners[0], corners[1]); + }), + Shape((ref MessagePackWriter w, CultCellular v) => { w.WriteArrayHeader(3); PutFloat4(ref w, v.nearest); PutFloat4(ref w, v.edge); w.Write(v.id); }, + (ref MessagePackReader r) => + { + var value = default(CultCellular); + for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) + { + switch (i) + { + case 0: value.nearest = GetFloat4(ref r); break; + case 1: value.edge = GetFloat4(ref r); break; + case 2: value.id = r.ReadSingle(); break; + default: r.Skip(); break; + } + } + return value; + }), + }; + + var map = new Dictionary(); + foreach (var shape in shapes) map.Add(shape.Key, shape.Value); + return map; + } +} diff --git a/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs b/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs new file mode 100644 index 00000000..c6b6d161 --- /dev/null +++ b/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using GameCult.Caching.MessagePack; +using CultMath; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + public sealed class CultMathSerializationTests + { + private static readonly MessagePackSerializerOptions Options = CultDocumentMessagePackSerialization.Options; + + private static string Hex(T value) => Convert.ToHexString(MessagePackSerializer.Serialize(value, Options)); + + private static T Decode(string hex) => MessagePackSerializer.Deserialize(Convert.FromHexString(hex), Options); + + [MessagePackObject] + public sealed class Carrier + { + [Key(0)] public float2 Position; + [Key(1)] public float3? Velocity; + [Key(2)] public float2[] Path = Array.Empty(); + [Key(3)] public List Cells = new(); + } + + // Bytes the pre-collapse CultVec2/CultVec3/CultRect encodings and Aetheria's Float2/Int2/Bool2/Float3/Float4 + // formatters produce: an array of components in declaration order, float32 as 0xCA. + [TestCase("92CA3F800000CA40000000")] + public void Float2_MatchesTheLegacyVectorBytes(string hex) + { + Hex(new float2(1f, 2f)).Should().Be(hex); + Decode(hex).Should().Be(new float2(1f, 2f)); + } + + [Test] + public void Float3_MatchesTheLegacyVectorBytes() + { + const string hex = "93CA3F800000CA40000000CA40400000"; + Hex(new float3(1f, 2f, 3f)).Should().Be(hex); + Decode(hex).Should().Be(new float3(1f, 2f, 3f)); + } + + [Test] + public void Rect_MatchesTheLegacyCultRectBytes() + { + const string hex = "9292CABF800000CAC000000092CA40A00000CA40E00000"; + Hex(new rect(5f, 7f, -1f, -2f)).Should().Be(hex); + Decode(hex).Should().Be(new rect(-1f, -2f, 5f, 7f)); + } + + [Test] + public void ConsumerFormatterShapes_MatchByteForByte() + { + Hex(new float4(1f, 2f, 3f, 4f)).Should().Be("94CA3F800000CA40000000CA40400000CA40800000"); + Hex(new int2(3, -4)).Should().Be("9203FC"); + Hex(new bool2(true, false)).Should().Be("92C3C2"); + } + + [Test] + public void Decode_SkipsExtraElements_AndLeavesMissingOnesZero() + { + Decode("93CA3F800000CA40000000CA40400000").Should().Be(new float2(1f, 2f)); + Decode("92CA3F800000CA40000000").Should().Be(new float3(1f, 2f, 0f)); + Decode("9392CABF800000CAC000000092CA40A00000CA40E0000001").Should().Be(new rect(-1f, -2f, 5f, 7f)); + } + + [Test] + public void Decode_RefusesNilAndNonArrays() + { + Action nil = () => Decode("C0"); + nil.Should().Throw(); + Action notArray = () => Decode("01"); + notArray.Should().Throw(); + } + + [Test] + public void EveryFormattedType_RoundTrips() + { + RoundTrip(new float2(1.5f, -2f)); + RoundTrip(new float3(1f, 2f, 3f)); + RoundTrip(new float4(1f, 2f, 3f, 4f)); + RoundTrip(new double2(1e300, -2.5)); + RoundTrip(new double3(1, 2, 3)); + RoundTrip(new int2(-7, 900000)); + RoundTrip(new int3(1, -2, 3)); + RoundTrip(new int4(1, 2, 3, int.MinValue)); + RoundTrip(new bool2(true, false)); + RoundTrip(new bool3(false, true, true)); + RoundTrip(new bool4(true, false, false, true)); + RoundTrip(new quaternion(0.1f, 0.2f, 0.3f, 0.9f)); + RoundTrip(new Color32(255, 128, 0, 7)); + RoundTrip(new CultMath.Random(12345u)); + RoundTrip(new float2x2(1f, 2f, 3f, 4f)); + RoundTrip(new float3x3(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f)); + RoundTrip(new rect(-1f, -2f, 5f, 7f)); + RoundTrip(new CultCellular(new float4(1, 2, 3, 4), new float4(5, 6, 7, 8), 0.25f)); + } + + [Test] + public void Matrices_AreArraysOfRows() + { + Hex(new float2x2(1f, 2f, 3f, 4f)).Should().Be("9292CA3F800000CA400000009 2CA40400000CA40800000".Replace(" ", "")); + } + + [Test] + public void EveryPublicCultMathValueType_HasAFormatter() + { + var valueTypes = typeof(float2).Assembly.GetExportedTypes() + .Where(t => t.IsValueType && !t.IsEnum) + .ToHashSet(); + + valueTypes.Except(CultMathResolver.FormattedTypes).Should().BeEmpty( + "every public CultMath value type serializes through CultMathResolver"); + CultMathResolver.FormattedTypes.Except(valueTypes).Should().BeEmpty(); + } + + [Test] + public void TheDefaultChain_ComposesMathIntoNullablesCollectionsAndDocuments() + { + var value = new Carrier + { + Position = new float2(1f, 2f), + Velocity = new float3(3f, 4f, 5f), + Path = new[] { new float2(0f, 1f), new float2(2f, 3f) }, + Cells = new List { new int2(1, 2) }, + }; + + var decoded = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(value, Options), Options); + + decoded.Position.Should().Be(value.Position); + decoded.Velocity.Should().Be(value.Velocity); + decoded.Path.Should().Equal(value.Path); + decoded.Cells.Should().Equal(value.Cells); + MessagePackSerializer.Serialize(new Carrier(), Options).Should().NotBeEmpty(); + + var absent = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(new Carrier(), Options), Options); + absent.Velocity.Should().BeNull(); + } + + private static void RoundTrip(T value) + { + var bytes = MessagePackSerializer.Serialize(value, Options); + MessagePackSerializer.Deserialize(bytes, Options).Should().Be(value); + } + } +} From 14621b3893c6a5fd9c0e3903eeea474ec25e091f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 16:15:29 +0200 Subject: [PATCH 02/12] Reference CultMath from Caching.MessagePack and scope the span helpers The first commit's resolver needs the CultMath project reference and scoped span parameters to compile; the golden test file also had two literal slips. Co-Authored-By: Claude Sonnet 5 --- .../CultMathResolver.cs | 20 +++++++++---------- .../GameCult.Caching.MessagePack.csproj | 1 + .../CultMathSerializationTests.cs | 7 ++++--- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/GameCult.Caching.MessagePack/CultMathResolver.cs b/src/GameCult.Caching.MessagePack/CultMathResolver.cs index 71bdec2b..c854e12d 100644 --- a/src/GameCult.Caching.MessagePack/CultMathResolver.cs +++ b/src/GameCult.Caching.MessagePack/CultMathResolver.cs @@ -58,61 +58,61 @@ public sealed class CultMathResolver : IFormatterResolver new(typeof(T), new ArrayShape(write, read)); // Each reader takes the first dst.Length elements and skips the rest. - private static void Singles(ref MessagePackReader r, Span dst) + private static void Singles(ref MessagePackReader r, scoped Span dst) { for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) if (i < dst.Length) dst[i] = r.ReadSingle(); else r.Skip(); } - private static void Doubles(ref MessagePackReader r, Span dst) + private static void Doubles(ref MessagePackReader r, scoped Span dst) { for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) if (i < dst.Length) dst[i] = r.ReadDouble(); else r.Skip(); } - private static void Ints(ref MessagePackReader r, Span dst) + private static void Ints(ref MessagePackReader r, scoped Span dst) { for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) if (i < dst.Length) dst[i] = r.ReadInt32(); else r.Skip(); } - private static void Bools(ref MessagePackReader r, Span dst) + private static void Bools(ref MessagePackReader r, scoped Span dst) { for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) if (i < dst.Length) dst[i] = r.ReadBoolean(); else r.Skip(); } - private static void Bytes(ref MessagePackReader r, Span dst) + private static void Bytes(ref MessagePackReader r, scoped Span dst) { for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) if (i < dst.Length) dst[i] = r.ReadByte(); else r.Skip(); } - private static void Put(ref MessagePackWriter w, ReadOnlySpan v) + private static void Put(ref MessagePackWriter w, scoped ReadOnlySpan v) { w.WriteArrayHeader(v.Length); foreach (var e in v) w.Write(e); } - private static void Put(ref MessagePackWriter w, ReadOnlySpan v) + private static void Put(ref MessagePackWriter w, scoped ReadOnlySpan v) { w.WriteArrayHeader(v.Length); foreach (var e in v) w.Write(e); } - private static void Put(ref MessagePackWriter w, ReadOnlySpan v) + private static void Put(ref MessagePackWriter w, scoped ReadOnlySpan v) { w.WriteArrayHeader(v.Length); foreach (var e in v) w.Write(e); } - private static void Put(ref MessagePackWriter w, ReadOnlySpan v) + private static void Put(ref MessagePackWriter w, scoped ReadOnlySpan v) { w.WriteArrayHeader(v.Length); foreach (var e in v) w.Write(e); } - private static void Put(ref MessagePackWriter w, ReadOnlySpan v) + private static void Put(ref MessagePackWriter w, scoped ReadOnlySpan v) { w.WriteArrayHeader(v.Length); foreach (var e in v) w.Write(e); diff --git a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj index c2bd1c1b..a37232a9 100644 --- a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj +++ b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj @@ -6,6 +6,7 @@ + diff --git a/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs b/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs index c6b6d161..76f95d34 100644 --- a/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs +++ b/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs @@ -28,9 +28,10 @@ namespace GameCult.Caching.Tests // Bytes the pre-collapse CultVec2/CultVec3/CultRect encodings and Aetheria's Float2/Int2/Bool2/Float3/Float4 // formatters produce: an array of components in declaration order, float32 as 0xCA. - [TestCase("92CA3F800000CA40000000")] - public void Float2_MatchesTheLegacyVectorBytes(string hex) + [Test] + public void Float2_MatchesTheLegacyVectorBytes() { + const string hex = "92CA3F800000CA40000000"; Hex(new float2(1f, 2f)).Should().Be(hex); Decode(hex).Should().Be(new float2(1f, 2f)); } @@ -102,7 +103,7 @@ namespace GameCult.Caching.Tests [Test] public void Matrices_AreArraysOfRows() { - Hex(new float2x2(1f, 2f, 3f, 4f)).Should().Be("9292CA3F800000CA400000009 2CA40400000CA40800000".Replace(" ", "")); + Hex(new float2x2(1f, 2f, 3f, 4f)).Should().Be("9292CA3F800000CA4000000092CA40400000CA40800000"); } [Test] From 20ac3b0b6ca95b4dcab951bd4ecb102e94039e18 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 16:18:09 +0200 Subject: [PATCH 03/12] Collapse CultVec2, CultVec3 and CultRect into CultMath float2, float3 and rect GameCult.Geometry no longer carries vector or rectangle values. CultCircle and CultSphere hold float2/float3, the isosurface and surface-nets extractors take float3 origin, and the per-call conversion to float3 is gone. Their MessagePack bytes are unchanged (pinned against the pre-collapse encodings), now produced by CultMathResolver through the default CultCache options. This is a breaking change to the GameCult.Geometry public API. Co-Authored-By: Claude Sonnet 5 --- src/GameCult.Caching.MessagePack/README.md | 1 + .../CultGeometryIsoSurface.cs | 5 +- .../CultGeometryPrimitives.cs | 229 ++---------------- .../CultGeometrySurfaceNets.cs | 7 +- src/GameCult.Geometry/README.md | 11 +- .../GeometryDocumentTests.cs | 63 +++-- .../IsoSurfaceTests.cs | 5 +- .../SurfaceNetsTests.cs | 68 +++--- 8 files changed, 93 insertions(+), 296 deletions(-) diff --git a/src/GameCult.Caching.MessagePack/README.md b/src/GameCult.Caching.MessagePack/README.md index c69ab887..277d0cb2 100644 --- a/src/GameCult.Caching.MessagePack/README.md +++ b/src/GameCult.Caching.MessagePack/README.md @@ -9,6 +9,7 @@ format for the attribute-first `GameCult.Caching` stack. - `CultDocumentMessagePackSerialization` - `CultDocumentResolver` - `CultRecordRefFormatter` +- `CultMathResolver`: the canonical component-array encoding of every public CultMath value type, part of the default resolver chain Cult document payloads serialize through `MessagePackSerializer` with the options of the document's assembly (`CultDocumentMessagePackSerialization.OptionsFor`), diff --git a/src/GameCult.Geometry/CultGeometryIsoSurface.cs b/src/GameCult.Geometry/CultGeometryIsoSurface.cs index 583fdd87..b0967ece 100644 --- a/src/GameCult.Geometry/CultGeometryIsoSurface.cs +++ b/src/GameCult.Geometry/CultGeometryIsoSurface.cs @@ -39,7 +39,7 @@ namespace GameCult.Geometry public static CultGeometryTriangleMesh Extract( float[,,] samples, float isoValue = 0f, - CultVec3 origin = default, + float3 origin = default, float cellSize = 1f) { if (samples == null) throw new ArgumentNullException(nameof(samples)); @@ -56,7 +56,6 @@ namespace GameCult.Geometry var positions = new List(); var normals = new List(); var indices = new List(); - var originValue = new float3(origin.X, origin.Y, origin.Z); var cornerPositions = new float3[8]; var cornerValues = new float[8]; var tetraPositions = new float3[4]; @@ -66,7 +65,7 @@ namespace GameCult.Geometry for (var y = 0; y < samples.GetLength(1) - 1; y++) for (var z = 0; z < samples.GetLength(2) - 1; z++) { - var cubeOrigin = originValue + new float3(x, y, z) * cellSize; + var cubeOrigin = origin + new float3(x, y, z) * cellSize; for (var corner = 0; corner < CubeCorners.Length; corner++) { var offset = CubeCorners[corner]; diff --git a/src/GameCult.Geometry/CultGeometryPrimitives.cs b/src/GameCult.Geometry/CultGeometryPrimitives.cs index 99587b8e..dafcca68 100644 --- a/src/GameCult.Geometry/CultGeometryPrimitives.cs +++ b/src/GameCult.Geometry/CultGeometryPrimitives.cs @@ -1,197 +1,10 @@ using System; using System.Text.Json.Serialization; +using CultMath; using MessagePack; namespace GameCult.Geometry { - /// - /// Deterministic two-dimensional float vector for cross-runtime geometry and query contracts. - /// - [MessagePackObject] - public readonly struct CultVec2 : IEquatable - { - /// Creates a two-dimensional vector. - public CultVec2(float x, float y) - { - X = x; - Y = y; - } - - /// Gets the x component. - [Key(0)] - public float X { get; } - - /// Gets the y component. - [Key(1)] - public float Y { get; } - - /// Gets the zero vector. - [IgnoreMember] - [JsonIgnore] - public static CultVec2 Zero => new(0f, 0f); - - /// Gets the squared vector length. - [IgnoreMember] - [JsonIgnore] - public float LengthSquared => (X * X) + (Y * Y); - - /// Returns whether this vector is equal to another vector. - public bool Equals(CultVec2 other) => X.Equals(other.X) && Y.Equals(other.Y); - - /// - public override bool Equals(object? obj) => obj is CultVec2 other && Equals(other); - - /// - public override int GetHashCode() => HashCode.Combine(X, Y); - - /// - public override string ToString() => $"({X}, {Y})"; - - public static CultVec2 operator +(CultVec2 left, CultVec2 right) => new(left.X + right.X, left.Y + right.Y); - public static CultVec2 operator -(CultVec2 left, CultVec2 right) => new(left.X - right.X, left.Y - right.Y); - public static CultVec2 operator *(CultVec2 value, float scalar) => new(value.X * scalar, value.Y * scalar); - public static CultVec2 operator /(CultVec2 value, float scalar) => new(value.X / scalar, value.Y / scalar); - public static bool operator ==(CultVec2 left, CultVec2 right) => left.Equals(right); - public static bool operator !=(CultVec2 left, CultVec2 right) => !left.Equals(right); - } - - /// - /// Deterministic three-dimensional float vector for cross-runtime geometry and query contracts. - /// - [MessagePackObject] - public readonly struct CultVec3 : IEquatable - { - /// Creates a three-dimensional vector. - public CultVec3(float x, float y, float z) - { - X = x; - Y = y; - Z = z; - } - - /// Gets the x component. - [Key(0)] - public float X { get; } - - /// Gets the y component. - [Key(1)] - public float Y { get; } - - /// Gets the z component. - [Key(2)] - public float Z { get; } - - /// Gets the zero vector. - [IgnoreMember] - [JsonIgnore] - public static CultVec3 Zero => new(0f, 0f, 0f); - - /// Gets the XY plane projection. - [IgnoreMember] - [JsonIgnore] - public CultVec2 Xy => new(X, Y); - - /// Gets the XZ plane projection for Unity compatibility adapters. - [IgnoreMember] - [JsonIgnore] - public CultVec2 Xz => new(X, Z); - - /// Gets the squared vector length. - [IgnoreMember] - [JsonIgnore] - public float LengthSquared => (X * X) + (Y * Y) + (Z * Z); - - /// Returns whether this vector is equal to another vector. - public bool Equals(CultVec3 other) => X.Equals(other.X) && Y.Equals(other.Y) && Z.Equals(other.Z); - - /// - public override bool Equals(object? obj) => obj is CultVec3 other && Equals(other); - - /// - public override int GetHashCode() => HashCode.Combine(X, Y, Z); - - /// - public override string ToString() => $"({X}, {Y}, {Z})"; - - public static CultVec3 operator +(CultVec3 left, CultVec3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z); - public static CultVec3 operator -(CultVec3 left, CultVec3 right) => new(left.X - right.X, left.Y - right.Y, left.Z - right.Z); - public static CultVec3 operator *(CultVec3 value, float scalar) => new(value.X * scalar, value.Y * scalar, value.Z * scalar); - public static CultVec3 operator /(CultVec3 value, float scalar) => new(value.X / scalar, value.Y / scalar, value.Z / scalar); - public static bool operator ==(CultVec3 left, CultVec3 right) => left.Equals(right); - public static bool operator !=(CultVec3 left, CultVec3 right) => !left.Equals(right); - } - - /// - /// Axis-aligned two-dimensional rectangle represented by canonical min and max corners. - /// - [MessagePackObject] - public readonly struct CultRect : IEquatable - { - /// Creates a rectangle and canonicalizes min/max corners. - public CultRect(CultVec2 min, CultVec2 max) - { - Min = new CultVec2(Math.Min(min.X, max.X), Math.Min(min.Y, max.Y)); - Max = new CultVec2(Math.Max(min.X, max.X), Math.Max(min.Y, max.Y)); - } - - /// Creates a rectangle and canonicalizes min/max corners. - public CultRect(float minX, float minY, float maxX, float maxY) - : this(new CultVec2(minX, minY), new CultVec2(maxX, maxY)) - { - } - - /// Gets the canonical minimum corner. - [Key(0)] - public CultVec2 Min { get; } - - /// Gets the canonical maximum corner. - [Key(1)] - public CultVec2 Max { get; } - - /// Gets the rectangle size. - [IgnoreMember] - [JsonIgnore] - public CultVec2 Size => Max - Min; - - /// Gets the rectangle center. - [IgnoreMember] - [JsonIgnore] - public CultVec2 Center => (Min + Max) * 0.5f; - - /// Returns whether the point lies inside or on the rectangle edge. - public bool Contains(CultVec2 point) - { - return point.X >= Min.X && - point.X <= Max.X && - point.Y >= Min.Y && - point.Y <= Max.Y; - } - - /// Returns whether this rectangle intersects another rectangle. - public bool Intersects(CultRect other) - { - return Min.X <= other.Max.X && - Max.X >= other.Min.X && - Min.Y <= other.Max.Y && - Max.Y >= other.Min.Y; - } - - /// Returns whether this rectangle is equal to another rectangle. - public bool Equals(CultRect other) => Min == other.Min && Max == other.Max; - - /// - public override bool Equals(object? obj) => obj is CultRect other && Equals(other); - - /// - public override int GetHashCode() => HashCode.Combine(Min, Max); - - /// - public override string ToString() => $"[{Min}..{Max}]"; - - public static bool operator ==(CultRect left, CultRect right) => left.Equals(right); - public static bool operator !=(CultRect left, CultRect right) => !left.Equals(right); - } - /// /// Two-dimensional circle for spatial queries. /// @@ -199,7 +12,7 @@ namespace GameCult.Geometry public readonly struct CultCircle : IEquatable { /// Creates a circle. - public CultCircle(CultVec2 center, float radius) + public CultCircle(float2 center, float radius) { if (radius < 0f) { @@ -212,7 +25,7 @@ namespace GameCult.Geometry /// Gets the circle center. [Key(0)] - public CultVec2 Center { get; } + public float2 Center { get; } /// Gets the circle radius. [Key(1)] @@ -221,29 +34,29 @@ namespace GameCult.Geometry /// Gets the circle bounds. [IgnoreMember] [JsonIgnore] - public CultRect Bounds => new( - Center.X - Radius, - Center.Y - Radius, - Center.X + Radius, - Center.Y + Radius); + public rect Bounds => new( + Center.x - Radius, + Center.y - Radius, + Center.x + Radius, + Center.y + Radius); /// Returns whether the point lies inside or on the circle edge. - public bool Contains(CultVec2 point) + public bool Contains(float2 point) { var delta = point - Center; - return delta.LengthSquared <= Radius * Radius; + return math.lengthsq(delta) <= Radius * Radius; } /// Returns whether this circle intersects a rectangle. - public bool Intersects(CultRect rect) + public bool Intersects(rect rect) { - var clampedX = Math.Max(rect.Min.X, Math.Min(Center.X, rect.Max.X)); - var clampedY = Math.Max(rect.Min.Y, Math.Min(Center.Y, rect.Max.Y)); - return Contains(new CultVec2(clampedX, clampedY)); + var clampedX = Math.Max(rect.min.x, Math.Min(Center.x, rect.max.x)); + var clampedY = Math.Max(rect.min.y, Math.Min(Center.y, rect.max.y)); + return Contains(new float2(clampedX, clampedY)); } /// Returns whether this circle is equal to another circle. - public bool Equals(CultCircle other) => Center == other.Center && Radius.Equals(other.Radius); + public bool Equals(CultCircle other) => Center.Equals(other.Center) && Radius.Equals(other.Radius); /// public override bool Equals(object? obj) => obj is CultCircle other && Equals(other); @@ -262,7 +75,7 @@ namespace GameCult.Geometry public readonly struct CultSphere : IEquatable { /// Creates a sphere. - public CultSphere(CultVec3 center, float radius) + public CultSphere(float3 center, float radius) { if (radius < 0f) { @@ -275,7 +88,7 @@ namespace GameCult.Geometry /// Gets the sphere center. [Key(0)] - public CultVec3 Center { get; } + public float3 Center { get; } /// Gets the sphere radius. [Key(1)] @@ -284,17 +97,17 @@ namespace GameCult.Geometry /// Gets the XY circle projection. [IgnoreMember] [JsonIgnore] - public CultCircle XyCircle => new(Center.Xy, Radius); + public CultCircle XyCircle => new(Center.xy, Radius); /// Returns whether the point lies inside or on the sphere surface. - public bool Contains(CultVec3 point) + public bool Contains(float3 point) { var delta = point - Center; - return delta.LengthSquared <= Radius * Radius; + return math.lengthsq(delta) <= Radius * Radius; } /// Returns whether this sphere is equal to another sphere. - public bool Equals(CultSphere other) => Center == other.Center && Radius.Equals(other.Radius); + public bool Equals(CultSphere other) => Center.Equals(other.Center) && Radius.Equals(other.Radius); /// public override bool Equals(object? obj) => obj is CultSphere other && Equals(other); diff --git a/src/GameCult.Geometry/CultGeometrySurfaceNets.cs b/src/GameCult.Geometry/CultGeometrySurfaceNets.cs index 45c3f1ec..bd78e053 100644 --- a/src/GameCult.Geometry/CultGeometrySurfaceNets.cs +++ b/src/GameCult.Geometry/CultGeometrySurfaceNets.cs @@ -44,7 +44,7 @@ namespace GameCult.Geometry public static CultGeometryQuadMesh Extract( float[,,] samples, float isoValue = 0f, - CultVec3 origin = default, + float3 origin = default, float cellSize = 1f) { if (samples == null) throw new ArgumentNullException(nameof(samples)); @@ -66,7 +66,7 @@ namespace GameCult.Geometry throw new ArgumentException("The isovalue must be finite.", nameof(isoValue)); } - if (!float.IsFinite(origin.X) || !float.IsFinite(origin.Y) || !float.IsFinite(origin.Z)) + if (!float.IsFinite(origin.x) || !float.IsFinite(origin.y) || !float.IsFinite(origin.z)) { throw new ArgumentException("The origin must be finite.", nameof(origin)); } @@ -80,7 +80,6 @@ namespace GameCult.Geometry var sizeY = samples.GetLength(1); var sizeZ = samples.GetLength(2); var cellCounts = new[] { sizeX - 1, sizeY - 1, sizeZ - 1 }; - var originValue = new float3(origin.X, origin.Y, origin.Z); var vertexIndex = new Dictionary<(int, int, int), uint>(); var positions = new List(); @@ -90,7 +89,7 @@ namespace GameCult.Geometry for (var cz = 0; cz < cellCounts[2]; cz++) { var cellCoord = new[] { cx, cy, cz }; - var vertex = CellVertex(samples, cellCoord, isoValue, originValue, cellSize); + var vertex = CellVertex(samples, cellCoord, isoValue, origin, cellSize); if (vertex == null) continue; vertexIndex[(cx, cy, cz)] = (uint)(positions.Count / 3); diff --git a/src/GameCult.Geometry/README.md b/src/GameCult.Geometry/README.md index 2d12dc25..9929e387 100644 --- a/src/GameCult.Geometry/README.md +++ b/src/GameCult.Geometry/README.md @@ -31,12 +31,9 @@ The package is deliberately substrate-shaped: ## Value Primitives The small value layer is for cross-runtime query contracts, physics probes, -native slices, and UI surfaces: +native slices, and UI surfaces. Vectors and rectangles are CultMath's `float2`, +`float3` and `rect`, and `GameCult.Caching.MessagePack` serializes them. -- `CultVec2` and `CultVec3`: whole-vector values for positions, velocities, - accelerations, and query inputs. SoA layouts may store them efficiently, but - the semantic API does not force callers to split x/y/z into unrelated fields. -- `CultRect`: canonical XY viewport/query rectangle stored as min/max corners. - `CultCircle`: 2D influence/query brush with rect intersection helpers. - `CultSphere`: 3D query primitive with whole-vector center and XY projection. @@ -85,8 +82,8 @@ cache miss; that is split-brain geometry. ## Documents -- `CultVec2`, `CultVec3`, `CultRect`, `CultCircle`, `CultSphere`: shared value - primitives for query and view contracts. +- `CultCircle`, `CultSphere`: shared value primitives for query and view + contracts. - `CultGeometryDomainDocument`: one hierarchical domain tree, suitable for Ragnarok/Fensalir-style feature DSL output. - `CultGeometryBuildRequest`: one LOD/frustum/budget request for workers. diff --git a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs index 302f53f3..daae992a 100644 --- a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs +++ b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using CultMath; using GameCult.Caching; using GameCult.Caching.MessagePack; using GameCult.Mesh; @@ -19,54 +20,50 @@ namespace GameCult.Geometry.Tests [Test] public void GeometryPrimitives_CanonicalizeAndIntersect() { - var viewport = new CultRect(8f, 4f, -8f, -4f); + var viewport = new rect(8f, 4f, -8f, -4f); - viewport.Min.Should().Be(new CultVec2(-8f, -4f)); - viewport.Max.Should().Be(new CultVec2(8f, 4f)); - viewport.Center.Should().Be(CultVec2.Zero); - viewport.Contains(new CultVec2(2f, 3f)).Should().BeTrue(); - viewport.Intersects(new CultRect(7f, 3f, 10f, 5f)).Should().BeTrue(); - viewport.Intersects(new CultRect(9f, 5f, 10f, 6f)).Should().BeFalse(); + viewport.min.Should().Be(new float2(-8f, -4f)); + viewport.max.Should().Be(new float2(8f, 4f)); + viewport.center.Should().Be(float2.zero); + viewport.Contains(new float2(2f, 3f)).Should().BeTrue(); + viewport.Intersects(new rect(7f, 3f, 10f, 5f)).Should().BeTrue(); + viewport.Intersects(new rect(9f, 5f, 10f, 6f)).Should().BeFalse(); - var gravityBrush = new CultCircle(new CultVec2(10f, 0f), 3f); + var gravityBrush = new CultCircle(new float2(10f, 0f), 3f); gravityBrush.Intersects(viewport).Should().BeTrue(); - gravityBrush.Contains(new CultVec2(12f, 0f)).Should().BeTrue(); - gravityBrush.Contains(new CultVec2(14f, 0f)).Should().BeFalse(); + gravityBrush.Contains(new float2(12f, 0f)).Should().BeTrue(); + gravityBrush.Contains(new float2(14f, 0f)).Should().BeFalse(); } [Test] public void GeometryPrimitives_PreserveWholeVectorsForSoaAndPhysicsQueries() { - var sphere = new CultSphere(new CultVec3(1f, 2f, 3f), 5f); + var sphere = new CultSphere(new float3(1f, 2f, 3f), 5f); - sphere.Contains(new CultVec3(1f, 6f, 3f)).Should().BeTrue(); - sphere.Contains(new CultVec3(1f, 8f, 3f)).Should().BeFalse(); - sphere.Center.Xy.Should().Be(new CultVec2(1f, 2f)); - sphere.Center.Xz.Should().Be(new CultVec2(1f, 3f)); - sphere.XyCircle.Bounds.Should().Be(new CultRect(-4f, -3f, 6f, 7f)); + sphere.Contains(new float3(1f, 6f, 3f)).Should().BeTrue(); + sphere.Contains(new float3(1f, 8f, 3f)).Should().BeFalse(); + sphere.Center.xy.Should().Be(new float2(1f, 2f)); + sphere.Center.xz.Should().Be(new float2(1f, 3f)); + sphere.XyCircle.Bounds.Should().Be(new rect(-4f, -3f, 6f, 7f)); } + // The bytes CultCircle and CultSphere produced when they held CultVec2/CultVec3 (probed at 65311c9). [Test] - public void GeometryPrimitives_RoundTripThroughMessagePack() + public void GeometryPrimitives_KeepTheirPreCollapseBytes() { - var rect = new CultRect(new CultVec2(5f, -2f), new CultVec2(-1f, 7f)); - var payload = MessagePackSerializer.Serialize(rect); - var decoded = MessagePackSerializer.Deserialize(payload); + var options = CultDocumentMessagePackSerialization.Options; + var circle = new CultCircle(new float2(1f, 2f), 3f); + var sphere = new CultSphere(new float3(1f, 2f, 3f), 5f); - decoded.Should().Be(rect); - decoded.Min.Should().Be(new CultVec2(-1f, -2f)); - decoded.Max.Should().Be(new CultVec2(5f, 7f)); - } + Convert.ToHexString(MessagePackSerializer.Serialize(circle, options)) + .Should().Be("9292CA3F800000CA40000000CA40400000"); + Convert.ToHexString(MessagePackSerializer.Serialize(sphere, options)) + .Should().Be("9293CA3F800000CA40000000CA40400000CA40A00000"); - [Test] - public void GeometryPrimitives_JsonOmitsComputedConvenienceProperties() - { - var json = JsonSerializer.Serialize(new CultVec3(1f, 2f, 3f)); - - json.Should().Be("""{"X":1,"Y":2,"Z":3}"""); - json.Should().NotContain("LengthSquared"); - json.Should().NotContain("Xy"); - json.Should().NotContain("Xz"); + MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(circle, options), options) + .Should().Be(circle); + MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(sphere, options), options) + .Should().Be(sphere); } [Test] diff --git a/tests/GameCult.Geometry.Tests/IsoSurfaceTests.cs b/tests/GameCult.Geometry.Tests/IsoSurfaceTests.cs index 63b794b3..52f7219e 100644 --- a/tests/GameCult.Geometry.Tests/IsoSurfaceTests.cs +++ b/tests/GameCult.Geometry.Tests/IsoSurfaceTests.cs @@ -1,4 +1,5 @@ using System; +using CultMath; using FluentAssertions; using NUnit.Framework; @@ -77,8 +78,8 @@ namespace GameCult.Geometry.Tests samples[0, 1, 1] = 1f; samples[1, 1, 1] = 1f; - var first = CultGeometryIsoSurface.Extract(samples, origin: new CultVec3(10f, 20f, 30f), cellSize: 2f); - var second = CultGeometryIsoSurface.Extract(samples, origin: new CultVec3(10f, 20f, 30f), cellSize: 2f); + var first = CultGeometryIsoSurface.Extract(samples, origin: new float3(10f, 20f, 30f), cellSize: 2f); + var second = CultGeometryIsoSurface.Extract(samples, origin: new float3(10f, 20f, 30f), cellSize: 2f); second.Positions.Should().Equal(first.Positions); second.Normals.Should().Equal(first.Normals); diff --git a/tests/GameCult.Geometry.Tests/SurfaceNetsTests.cs b/tests/GameCult.Geometry.Tests/SurfaceNetsTests.cs index 3e3b3892..82c8fd43 100644 --- a/tests/GameCult.Geometry.Tests/SurfaceNetsTests.cs +++ b/tests/GameCult.Geometry.Tests/SurfaceNetsTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using CultMath; using FluentAssertions; using NUnit.Framework; @@ -73,28 +74,28 @@ namespace GameCult.Geometry.Tests for (var quad = 0; quad < mesh.QuadCount; quad++) { var edge = mesh.QuadEdges[quad]; - var insideCoord = new CultVec3(1f, 1f, 1f); - var edgeStart = new CultVec3(edge.X, edge.Y, edge.Z); + var insideCoord = new float3(1f, 1f, 1f); + var edgeStart = new float3(edge.X, edge.Y, edge.Z); var edgeEnd = OtherEndpoint(edge); // The single inside sample is (1,1,1); the edge's other endpoint is outside. var outsideCoord = edgeStart.Equals(insideCoord) ? edgeEnd : edgeStart; - var outward = Subtract(outsideCoord, insideCoord); + var outward = outsideCoord - insideCoord; var baseIndex = quad * 4; var v0 = Position(mesh.Positions, mesh.Quads[baseIndex]); var v1 = Position(mesh.Positions, mesh.Quads[baseIndex + 1]); var v2 = Position(mesh.Positions, mesh.Quads[baseIndex + 2]); var v3 = Position(mesh.Positions, mesh.Quads[baseIndex + 3]); - var cross = Cross(Subtract(v2, v0), Subtract(v3, v1)); + var cross = math.cross(v2 - v0, v3 - v1); // Restricted to non-degenerate (non-zero-area) quads: the spec's geometric // winding rule is only meaningful there. Degenerate/collapsed quads are covered // separately by AssertEdgeDirectionsBalance, since a zero cross product carries // no orientation information to check here. - if (cross.X == 0f && cross.Y == 0f && cross.Z == 0f) continue; + if (cross.Equals(float3.zero)) continue; - Dot(cross, outward).Should().BePositive($"quad for edge {edge} should wind outward"); + math.dot(cross, outward).Should().BePositive($"quad for edge {edge} should wind outward"); } } @@ -209,7 +210,7 @@ namespace GameCult.Geometry.Tests samples[1, 1, 1] = -1f; - var mesh = CultGeometrySurfaceNets.Extract(samples, origin: new CultVec3(10f, 20f, 30f), cellSize: 2f); + var mesh = CultGeometrySurfaceNets.Extract(samples, origin: new float3(10f, 20f, 30f), cellSize: 2f); mesh.VertexCount.Should().Be(1); mesh.Positions[0].Should().BeApproximately(10f + (2.9f / 3f * 2f), 1e-5f); @@ -387,10 +388,10 @@ namespace GameCult.Geometry.Tests // documented c0..c3 order is not swapped. var expected = new[] { - new CultVec3(1.5f, 0.5f, 0.5f), // c0 = (1, 0, 0) - new CultVec3(1.5f, 1.5f, 0.5f), // c1 = (1, 1, 0) - new CultVec3(1.5f, 1.5f, 1.5f), // c2 = (1, 1, 1) - new CultVec3(1.5f, 0.5f, 1.5f), // c3 = (1, 0, 1) + new float3(1.5f, 0.5f, 0.5f), // c0 = (1, 0, 0) + new float3(1.5f, 1.5f, 0.5f), // c1 = (1, 1, 0) + new float3(1.5f, 1.5f, 1.5f), // c2 = (1, 1, 1) + new float3(1.5f, 0.5f, 1.5f), // c3 = (1, 0, 1) }; AssertQuadCorners(mesh, edge, expected); @@ -413,10 +414,10 @@ namespace GameCult.Geometry.Tests // v3 trade places relative to the raw c0..c3 listing. var expected = new[] { - new CultVec3(0.5f, 1.5f, 0.5f), // c0 = (0, 1, 0) - new CultVec3(0.5f, 1.5f, 1.5f), // c3 = (0, 1, 1), swapped into v1 - new CultVec3(1.5f, 1.5f, 1.5f), // c2 = (1, 1, 1) - new CultVec3(1.5f, 1.5f, 0.5f), // c1 = (1, 1, 0), swapped into v3 + new float3(0.5f, 1.5f, 0.5f), // c0 = (0, 1, 0) + new float3(0.5f, 1.5f, 1.5f), // c3 = (0, 1, 1), swapped into v1 + new float3(1.5f, 1.5f, 1.5f), // c2 = (1, 1, 1) + new float3(1.5f, 1.5f, 0.5f), // c1 = (1, 1, 0), swapped into v3 }; AssertQuadCorners(mesh, edge, expected); @@ -438,10 +439,10 @@ namespace GameCult.Geometry.Tests // mirrored, so the documented c0..c3 order is not swapped. var expected = new[] { - new CultVec3(0.5f, 0.5f, 1.5f), // c0 = (0, 0, 1) - new CultVec3(1.5f, 0.5f, 1.5f), // c1 = (1, 0, 1) - new CultVec3(1.5f, 1.5f, 1.5f), // c2 = (1, 1, 1) - new CultVec3(0.5f, 1.5f, 1.5f), // c3 = (0, 1, 1) + new float3(0.5f, 0.5f, 1.5f), // c0 = (0, 0, 1) + new float3(1.5f, 0.5f, 1.5f), // c1 = (1, 0, 1) + new float3(1.5f, 1.5f, 1.5f), // c2 = (1, 1, 1) + new float3(0.5f, 1.5f, 1.5f), // c3 = (0, 1, 1) }; AssertQuadCorners(mesh, edge, expected); @@ -476,7 +477,7 @@ namespace GameCult.Geometry.Tests // balance regardless. var samples = Sphere(64); - var mesh = CultGeometrySurfaceNets.Extract(samples, origin: new CultVec3(1e4f, 1e4f, 1e4f), cellSize: 0.001f); + var mesh = CultGeometrySurfaceNets.Extract(samples, origin: new float3(1e4f, 1e4f, 1e4f), cellSize: 0.001f); mesh.QuadCount.Should().BeGreaterThan(0); AssertEdgeDirectionsBalance(mesh); @@ -651,7 +652,7 @@ namespace GameCult.Geometry.Tests { var samples = new float[2, 2, 2]; - Action act = () => CultGeometrySurfaceNets.Extract(samples, origin: new CultVec3(x, y, z)); + Action act = () => CultGeometrySurfaceNets.Extract(samples, origin: new float3(x, y, z)); act.Should().Throw(); } @@ -725,28 +726,17 @@ namespace GameCult.Geometry.Tests return usage; } - private static CultVec3 OtherEndpoint(CultGeometryGridEdge edge) => edge.Axis switch + private static float3 OtherEndpoint(CultGeometryGridEdge edge) => edge.Axis switch { - 0 => new CultVec3(edge.X + 1, edge.Y, edge.Z), - 1 => new CultVec3(edge.X, edge.Y + 1, edge.Z), - _ => new CultVec3(edge.X, edge.Y, edge.Z + 1), + 0 => new float3(edge.X + 1, edge.Y, edge.Z), + 1 => new float3(edge.X, edge.Y + 1, edge.Z), + _ => new float3(edge.X, edge.Y, edge.Z + 1), }; - private static CultVec3 Subtract(CultVec3 left, CultVec3 right) => - new(left.X - right.X, left.Y - right.Y, left.Z - right.Z); - - private static CultVec3 Cross(CultVec3 left, CultVec3 right) => new( - (left.Y * right.Z) - (left.Z * right.Y), - (left.Z * right.X) - (left.X * right.Z), - (left.X * right.Y) - (left.Y * right.X)); - - private static float Dot(CultVec3 left, CultVec3 right) => - (left.X * right.X) + (left.Y * right.Y) + (left.Z * right.Z); - - private static CultVec3 Position(float[] positions, uint index) + private static float3 Position(float[] positions, uint index) { var offset = (int)index * 3; - return new CultVec3(positions[offset], positions[offset + 1], positions[offset + 2]); + return new float3(positions[offset], positions[offset + 1], positions[offset + 2]); } // Hand-derived (double precision, independent of Interpolate) expected position for a @@ -760,7 +750,7 @@ namespace GameCult.Geometry.Tests return (float)(amount / 3d); } - private static void AssertQuadCorners(CultGeometryQuadMesh mesh, CultGeometryGridEdge edge, CultVec3[] expected) + private static void AssertQuadCorners(CultGeometryQuadMesh mesh, CultGeometryGridEdge edge, float3[] expected) { var quadIndex = Array.IndexOf(mesh.QuadEdges, edge); quadIndex.Should().BeGreaterThanOrEqualTo(0, $"a quad for edge {edge} should exist"); From 5687d8c3558495ac285126e7af90204c859fddae Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 16:52:44 +0200 Subject: [PATCH 04/12] Make the CultLib Unity package depend on org.gamecult.cultmath GameCult.Caching.MessagePack now references CultMath, so the Unity package declares org.gamecult.cultmath in package.json, references CultMath.dll from the asmdef, and the package build refuses to ship a second CultMath.dll and checks the asmdef against the shipped plus external assembly lists. Co-Authored-By: Claude Sonnet 5 --- scripts/build-unity-package.ps1 | 18 ++++++++++++++++++ unity/org.gamecult.cultlib/README.md | 9 +++++++++ .../Runtime/GameCult.CultLib.asmdef | 1 + unity/org.gamecult.cultlib/package.json | 3 +++ 4 files changed, 31 insertions(+) diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1 index bdd9fff6..3258155f 100644 --- a/scripts/build-unity-package.ps1 +++ b/scripts/build-unity-package.ps1 @@ -128,6 +128,19 @@ $expectedAssemblies = @( "System.Text.Json.dll", "System.Threading.Channels.dll" ) +# Assemblies the package references but never ships: org.gamecult.cultmath owns CultMath.dll, and the +# package.json dependency delivers it. A second copy in Runtime\Plugins would be a duplicate assembly. +$externalAssemblies = @("CultMath.dll") +$asmdef = Get-Content -LiteralPath (Join-Path $templateRoot "Runtime\GameCult.CultLib.asmdef") -Raw | ConvertFrom-Json +$referencedAssemblies = @($asmdef.precompiledReferences | Sort-Object) +$declaredAssemblies = @($expectedAssemblies + $externalAssemblies | Sort-Object) +if (($referencedAssemblies -join "|") -ne ($declaredAssemblies -join "|")) { + throw "GameCult.CultLib.asmdef precompiledReferences differ from the shipped plus external assembly lists." +} +$cultMathDependency = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).dependencies.'org.gamecult.cultmath' +if ([string]::IsNullOrWhiteSpace($cultMathDependency)) { + throw "package.json must declare org.gamecult.cultmath, which supplies CultMath.dll." +} $publishedByName = @{} foreach ($assembly in Get-ChildItem -LiteralPath $publishRoot -Filter "*.dll") { $publishedByName[$assembly.Name] = $assembly @@ -154,6 +167,11 @@ foreach ($assemblyName in $expectedAssemblies) { Copy-Item -LiteralPath $pdb -Destination $pluginRoot } } +foreach ($external in $externalAssemblies) { + if (Test-Path -LiteralPath (Join-Path $pluginRoot $external)) { + throw "CultLib Unity package must not ship $external; org.gamecult.cultmath owns it." + } +} $nativePluginRoot = Join-Path $pluginRoot "x86_64" New-Item -ItemType Directory -Force -Path $nativePluginRoot | Out-Null Copy-Item -LiteralPath (Join-Path $quicNativeRoot "gamecult_mesh_quic_native.dll") -Destination $nativePluginRoot diff --git a/unity/org.gamecult.cultlib/README.md b/unity/org.gamecult.cultlib/README.md index 88a083e1..8716d7e4 100644 --- a/unity/org.gamecult.cultlib/README.md +++ b/unity/org.gamecult.cultlib/README.md @@ -17,6 +17,15 @@ Install version `1.0.57` through Unity Package Manager with: https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.57 ``` +The package depends on `org.gamecult.cultmath` (0.3.0 or later) and does not +carry its own `CultMath.dll`. Unity Package Manager resolves git packages only +from the project manifest, so add CultMath beside CultLib in +`Packages/manifest.json`: + +```text +"org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.3.0" +``` + CultMesh chooses mapped memory for reachable same-machine bodies and advertised QUIC for remote realtime state. Applications register the packaged `CultMeshNativeQuicRealtimeTransportConnector`; they do not load or configure diff --git a/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef b/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef index bd13cb38..0ddbb9a6 100644 --- a/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef +++ b/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef @@ -8,6 +8,7 @@ "overrideReferences": true, "precompiledReferences": [ "ConcurrentCollections.dll", + "CultMath.dll", "GameCult.Caching.dll", "GameCult.Caching.MessagePack.dll", "GameCult.Logging.dll", diff --git a/unity/org.gamecult.cultlib/package.json b/unity/org.gamecult.cultlib/package.json index 722190cf..6545bed1 100644 --- a/unity/org.gamecult.cultlib/package.json +++ b/unity/org.gamecult.cultlib/package.json @@ -3,6 +3,9 @@ "displayName": "CultLib", "version": "1.0.60", "unity": "2021.3", + "dependencies": { + "org.gamecult.cultmath": "0.3.0" + }, "description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.", "license": "MIT", "author": { From 77602d5c0eb01b2e34bf1bb9820432e77c78c50a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 16:57:31 +0200 Subject: [PATCH 05/12] Pin every CultMath MessagePack shape and decode a missing Color32 alpha as opaque Golden bytes now cover double2, double3, int3, int4, bool3, bool4, quaternion, Color32, Random, float3x3 and CultCellular. A missing Color32 alpha decodes as 255, the constructor default. A test proves a consumer resolver registered for the assembly wins over CultMathResolver. The redundant nil check is deleted; ReadArrayHeader already refuses nil. Co-Authored-By: Claude Sonnet 5 --- .../CultMathResolver.cs | 5 +- .../CultMathSerializationTests.cs | 58 +++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/GameCult.Caching.MessagePack/CultMathResolver.cs b/src/GameCult.Caching.MessagePack/CultMathResolver.cs index c854e12d..803b3873 100644 --- a/src/GameCult.Caching.MessagePack/CultMathResolver.cs +++ b/src/GameCult.Caching.MessagePack/CultMathResolver.cs @@ -11,7 +11,7 @@ namespace GameCult.Caching.MessagePack; /// The canonical MessagePack encoding of every public CultMath value type. Each type is an /// array of its components in declaration order: vectors and quaternions are flat scalar /// arrays, matrices are arrays of row vectors, and rect is [min, max]. Decoding -/// skips trailing elements and leaves missing ones at zero; nil is refused. The resolver is +/// skips trailing elements and leaves missing ones at zero (a missing Color32 alpha is 255); nil is refused. The resolver is /// part of the default chain, so a consumer /// registers nothing. /// @@ -49,7 +49,6 @@ public sealed class CultMathResolver : IFormatterResolver public T Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { - if (reader.IsNil) throw new MessagePackSerializationException($"{typeof(T).Name} cannot be read from nil."); return read(ref reader); } } @@ -171,7 +170,7 @@ public sealed class CultMathResolver : IFormatterResolver Shape((ref MessagePackWriter w, quaternion v) => Put(ref w, stackalloc float[] { v.x, v.y, v.z, v.w }), (ref MessagePackReader r) => { Span s = stackalloc float[4]; Singles(ref r, s); return new quaternion(s[0], s[1], s[2], s[3]); }), Shape((ref MessagePackWriter w, Color32 v) => Put(ref w, stackalloc byte[] { v.r, v.g, v.b, v.a }), - (ref MessagePackReader r) => { Span s = stackalloc byte[4]; Bytes(ref r, s); return new Color32(s[0], s[1], s[2], s[3]); }), + (ref MessagePackReader r) => { Span s = stackalloc byte[4]; s[3] = 255; Bytes(ref r, s); return new Color32(s[0], s[1], s[2], s[3]); }), Shape((ref MessagePackWriter w, Random v) => { w.WriteArrayHeader(1); w.Write(v.state); }, (ref MessagePackReader r) => { diff --git a/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs b/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs index 76f95d34..fb6f1489 100644 --- a/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs +++ b/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs @@ -5,8 +5,11 @@ using FluentAssertions; using GameCult.Caching.MessagePack; using CultMath; using MessagePack; +using MessagePack.Formatters; using NUnit.Framework; +[assembly: CultCacheFormatterResolver(typeof(GameCult.Caching.Tests.CultMathSerializationTests.Float2Override))] + namespace GameCult.Caching.Tests { public sealed class CultMathSerializationTests @@ -106,6 +109,61 @@ namespace GameCult.Caching.Tests Hex(new float2x2(1f, 2f, 3f, 4f)).Should().Be("9292CA3F800000CA4000000092CA40400000CA40800000"); } + + [Test] + public void EveryShape_IsPinnedByteForByte() + { + Hex(new double2(1d, 2d)).Should().Be("92CB3FF0000000000000CB4000000000000000"); + Hex(new double3(1d, 2d, 3d)).Should().Be("93CB3FF0000000000000CB4000000000000000CB4008000000000000"); + Hex(new int3(1, -2, 3)).Should().Be("9301FE03"); + Hex(new int4(1, 2, 3, -1)).Should().Be("94010203FF"); + Hex(new bool3(true, false, true)).Should().Be("93C3C2C3"); + Hex(new bool4(false, true, true, false)).Should().Be("94C2C3C3C2"); + Hex(new quaternion(1f, 2f, 3f, 4f)).Should().Be("94CA3F800000CA40000000CA40400000CA40800000"); + Hex(new Color32(255, 128, 0, 7)).Should().Be("94CCFFCC800007"); + Hex(new CultMath.Random(12345u)).Should().Be("91CD3039"); + Hex(new float3x3(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f)).Should().Be( + "93" + + "93CA3F800000CA40000000CA40400000" + + "93CA40800000CA40A00000CA40C00000" + + "93CA40E00000CA41000000CA41100000"); + Hex(new CultCellular(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f), 0.25f)).Should().Be( + "93" + + "94CA3F800000CA40000000CA40400000CA40800000" + + "94CA40A00000CA40C00000CA40E00000CA41000000" + + "CA3E800000"); + } + + [Test] + public void Color32_MissingAlphaDecodesAsOpaque() + { + Decode("93CCFFCC8000").Should().Be(new Color32(255, 128, 0, 255)); + Decode("94CCFFCC800007").Should().Be(new Color32(255, 128, 0, 7)); + } + + [Test] + public void ConsumerResolvers_WinOverCultMathResolver() + { + var overridden = CultDocumentMessagePackSerialization.OptionsFor(typeof(Float2Override).Assembly); + + MessagePackSerializer.Serialize(new float2(1f, 2f), overridden).Should().Equal(0xC3); + Hex(new float2(1f, 2f)).Should().Be("92CA3F800000CA40000000"); + } + + public sealed class Float2Override : IFormatterResolver + { + public static readonly Float2Override Instance = new(); + + public IMessagePackFormatter? GetFormatter() => + typeof(T) == typeof(float2) ? (IMessagePackFormatter)(object)Formatter.Instance : null; + + private sealed class Formatter : IMessagePackFormatter + { + public static readonly Formatter Instance = new(); + public void Serialize(ref MessagePackWriter writer, float2 value, MessagePackSerializerOptions options) => writer.Write(true); + public float2 Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) => throw new NotSupportedException(); + } + } [Test] public void EveryPublicCultMathValueType_HasAFormatter() { From 20c30224fd70ff36cc9c46ff060ae6dcec68d884 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 16:57:31 +0200 Subject: [PATCH 06/12] Add canonical System.Text.Json converters for every CultMath value type CultMathJson is the JSON twin of CultMathResolver: vectors, quaternion and Color32 are objects keyed by field name, matrices are arrays of row objects, rect is {min,max}. Consumers add them with AddCultMathConverters(); CultLib owns no JSON options. CultCircle and CultSphere serialize and deserialize through them, with [JsonConstructor] so the struct constructor is used. The Caching README documents each MessagePack and JSON shape in one table. Co-Authored-By: Claude Sonnet 5 --- .../CultMathJson.cs | 143 ++++++++++++++++++ .../GameCult.Caching.MessagePack.csproj | 1 + src/GameCult.Caching.MessagePack/README.md | 24 +++ .../CultGeometryPrimitives.cs | 2 + .../CultMathJsonTests.cs | 104 +++++++++++++ .../GeometryDocumentTests.cs | 16 ++ 6 files changed, 290 insertions(+) create mode 100644 src/GameCult.Caching.MessagePack/CultMathJson.cs create mode 100644 tests/GameCult.Caching.Tests/CultMathJsonTests.cs diff --git a/src/GameCult.Caching.MessagePack/CultMathJson.cs b/src/GameCult.Caching.MessagePack/CultMathJson.cs new file mode 100644 index 00000000..7070d389 --- /dev/null +++ b/src/GameCult.Caching.MessagePack/CultMathJson.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using CultMath; +using Random = CultMath.Random; + +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":..}, and CultCellular is {"nearest":..,"edge":..,"id":..}. +/// Reading skips unknown properties and leaves missing ones at zero (a missing Color32.a +/// is 255); a value that is not an object, or a matrix that is not an array of exactly its row count, is refused. +/// Register with ; JSON options are the consumer's own. +/// +public static class CultMathJson +{ + /// The CultMath types that have a converter, for coverage checks. + public static IReadOnlyCollection ConvertedTypes => Converters.Keys; + + /// Adds the CultMath converters to and returns it. + public static JsonSerializerOptions AddCultMathConverters(this JsonSerializerOptions options) + { + if (options == null) throw new ArgumentNullException(nameof(options)); + foreach (var converter in Converters.Values) options.Converters.Add(converter); + return options; + } + + private sealed class ObjectShape : JsonConverter + { + private readonly Action write; + private readonly Func read; + + public ObjectShape(Action write, Func read) + { + this.write = write; + this.read = read; + } + + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + return read(document.RootElement); + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) => write(writer, value); + } + + private static KeyValuePair Shape(Action write, Func read) => + new(typeof(T), new ObjectShape(write, read)); + + private static JsonElement Obj(JsonElement e) + { + if (e.ValueKind != JsonValueKind.Object) throw new JsonException("Expected a JSON object."); + return e; + } + + private static JsonElement Rows(JsonElement e, int count) + { + if (e.ValueKind != JsonValueKind.Array || e.GetArrayLength() != count) + throw new JsonException($"Expected a JSON array of {count} rows."); + return e; + } + + private static bool Has(JsonElement e, string name, out JsonElement value) => Obj(e).TryGetProperty(name, out value); + private static float F(JsonElement e, string name) => Has(e, name, out var v) ? v.GetSingle() : 0f; + private static double D(JsonElement e, string name) => Has(e, name, out var v) ? v.GetDouble() : 0d; + private static int I(JsonElement e, string name) => Has(e, name, out var v) ? v.GetInt32() : 0; + private static bool B(JsonElement e, string name) => Has(e, name, out var v) && v.GetBoolean(); + private static JsonElement Member(JsonElement e, string name) => + Has(e, name, out var v) ? v : Empty; + private static readonly JsonElement Empty = JsonDocument.Parse("{}").RootElement; + + private static void PutFloat2(Utf8JsonWriter w, float2 v) { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteEndObject(); } + private static void PutFloat3(Utf8JsonWriter w, float3 v) { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteEndObject(); } + private static void PutFloat4(Utf8JsonWriter w, float4 v) { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteNumber("w", v.w); w.WriteEndObject(); } + private static float2 GetFloat2(JsonElement e) => new(F(e, "x"), F(e, "y")); + private static float3 GetFloat3(JsonElement e) => new(F(e, "x"), F(e, "y"), F(e, "z")); + private static float4 GetFloat4(JsonElement e) => new(F(e, "x"), F(e, "y"), F(e, "z"), F(e, "w")); + + private static readonly Dictionary Converters = Build(); + + private static Dictionary Build() + { + var shapes = new[] + { + Shape(PutFloat2, GetFloat2), + Shape(PutFloat3, GetFloat3), + Shape(PutFloat4, GetFloat4), + Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteEndObject(); }, + e => new double2(D(e, "x"), D(e, "y"))), + Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteEndObject(); }, + e => new double3(D(e, "x"), D(e, "y"), D(e, "z"))), + Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteEndObject(); }, + e => new int2(I(e, "x"), I(e, "y"))), + Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteEndObject(); }, + e => new int3(I(e, "x"), I(e, "y"), I(e, "z"))), + Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteNumber("w", v.w); w.WriteEndObject(); }, + e => new int4(I(e, "x"), I(e, "y"), I(e, "z"), I(e, "w"))), + Shape((w, v) => { w.WriteStartObject(); w.WriteBoolean("x", v.x); w.WriteBoolean("y", v.y); w.WriteEndObject(); }, + e => new bool2(B(e, "x"), B(e, "y"))), + Shape((w, v) => { w.WriteStartObject(); w.WriteBoolean("x", v.x); w.WriteBoolean("y", v.y); w.WriteBoolean("z", v.z); w.WriteEndObject(); }, + e => new bool3(B(e, "x"), B(e, "y"), B(e, "z"))), + Shape((w, v) => { w.WriteStartObject(); w.WriteBoolean("x", v.x); w.WriteBoolean("y", v.y); w.WriteBoolean("z", v.z); w.WriteBoolean("w", v.w); w.WriteEndObject(); }, + e => new bool4(B(e, "x"), B(e, "y"), B(e, "z"), B(e, "w"))), + Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteNumber("w", v.w); w.WriteEndObject(); }, + e => new quaternion(F(e, "x"), F(e, "y"), F(e, "z"), F(e, "w"))), + Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("r", v.r); w.WriteNumber("g", v.g); w.WriteNumber("b", v.b); w.WriteNumber("a", v.a); w.WriteEndObject(); }, + e => new Color32( + Has(e, "r", out var r) ? r.GetByte() : (byte)0, + Has(e, "g", out var g) ? g.GetByte() : (byte)0, + Has(e, "b", out var b) ? b.GetByte() : (byte)0, + Has(e, "a", out var a) ? a.GetByte() : (byte)255)), + Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("state", v.state); w.WriteEndObject(); }, + e => new Random { state = Has(e, "state", out var s) ? s.GetUInt32() : 0u }), + + // Compositions: matrices are arrays of row objects, rect is {min, max}, CultCellular is {nearest, edge, id}. + Shape((w, v) => { w.WriteStartArray(); PutFloat2(w, v[0]); PutFloat2(w, v[1]); w.WriteEndArray(); }, + e => + { + var rows = Rows(e, 2); + return new float2x2(GetFloat2(rows[0]), GetFloat2(rows[1])); + }), + Shape((w, v) => { w.WriteStartArray(); PutFloat3(w, v[0]); PutFloat3(w, v[1]); PutFloat3(w, v[2]); w.WriteEndArray(); }, + e => + { + var rows = Rows(e, 3); + return new float3x3(GetFloat3(rows[0]), GetFloat3(rows[1]), GetFloat3(rows[2])); + }), + Shape((w, v) => { w.WriteStartObject(); w.WritePropertyName("min"); PutFloat2(w, v.min); w.WritePropertyName("max"); PutFloat2(w, v.max); w.WriteEndObject(); }, + e => new rect(GetFloat2(Member(e, "min")), GetFloat2(Member(e, "max")))), + Shape((w, v) => { w.WriteStartObject(); w.WritePropertyName("nearest"); PutFloat4(w, v.nearest); w.WritePropertyName("edge"); PutFloat4(w, v.edge); w.WriteNumber("id", v.id); w.WriteEndObject(); }, + e => new CultCellular(GetFloat4(Member(e, "nearest")), GetFloat4(Member(e, "edge")), F(e, "id"))), + }; + + var map = new Dictionary(); + foreach (var shape in shapes) map.Add(shape.Key, shape.Value); + return map; + } +} diff --git a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj index a37232a9..c2588e24 100644 --- a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj +++ b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj @@ -8,6 +8,7 @@ + diff --git a/src/GameCult.Caching.MessagePack/README.md b/src/GameCult.Caching.MessagePack/README.md index 277d0cb2..ae4d08a4 100644 --- a/src/GameCult.Caching.MessagePack/README.md +++ b/src/GameCult.Caching.MessagePack/README.md @@ -57,3 +57,27 @@ await cache.PullAllBackingStoresAsync(); persisted through hand-written MessagePack array layouts in this package. - The raw CultNet document lane should treat these payload bytes as already blessed, not decode and re-encode them for sport. + +## CultMath Value Shapes + +Every public CultMath value type has one MessagePack shape (`CultMathResolver`, +in the default chain) and one JSON shape (`CultMathJson`, added to a consumer's +options with `new JsonSerializerOptions().AddCultMathConverters()`; CultLib owns +no JSON options of its own). MessagePack decoding skips extra elements, leaves +missing ones at zero (`Color32` alpha at 255) and refuses nil. JSON reading +skips unknown properties and leaves missing ones at zero. A consumer resolver or +converter registered ahead of these wins. + +| Type | MessagePack | JSON | +| --- | --- | --- | +| `float2`, `double2`, `int2`, `bool2` | `[x, y]` | `{"x":..,"y":..}` | +| `float3`, `double3`, `int3`, `bool3` | `[x, y, z]` | `{"x":..,"y":..,"z":..}` | +| `float4`, `int4`, `bool4`, `quaternion` | `[x, y, z, w]` | `{"x":..,"y":..,"z":..,"w":..}` | +| `Color32` | `[r, g, b, a]` as uint8 | `{"r":..,"g":..,"b":..,"a":..}` | +| `Random` | `[state]` | `{"state":..}` | +| `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":..}` | + +Floats are MessagePack float32 (`0xCA`), doubles float64 (`0xCB`), integers the +shortest MessagePack integer. diff --git a/src/GameCult.Geometry/CultGeometryPrimitives.cs b/src/GameCult.Geometry/CultGeometryPrimitives.cs index dafcca68..580f2b71 100644 --- a/src/GameCult.Geometry/CultGeometryPrimitives.cs +++ b/src/GameCult.Geometry/CultGeometryPrimitives.cs @@ -12,6 +12,7 @@ namespace GameCult.Geometry public readonly struct CultCircle : IEquatable { /// Creates a circle. + [JsonConstructor] public CultCircle(float2 center, float radius) { if (radius < 0f) @@ -75,6 +76,7 @@ namespace GameCult.Geometry public readonly struct CultSphere : IEquatable { /// Creates a sphere. + [JsonConstructor] public CultSphere(float3 center, float radius) { if (radius < 0f) diff --git a/tests/GameCult.Caching.Tests/CultMathJsonTests.cs b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs new file mode 100644 index 00000000..64702b74 --- /dev/null +++ b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Linq; +using System.Text.Json; +using CultMath; +using FluentAssertions; +using GameCult.Caching.MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + public sealed class CultMathJsonTests + { + private static readonly JsonSerializerOptions Options = new JsonSerializerOptions().AddCultMathConverters(); + + private static string Json(T value) => JsonSerializer.Serialize(value, Options); + + private static T Parse(string json) => JsonSerializer.Deserialize(json, Options)!; + + [Test] + public void EveryShape_IsPinned() + { + Json(new float2(1f, 2f)).Should().Be("""{"x":1,"y":2}"""); + Json(new float3(1f, 2f, 3f)).Should().Be("""{"x":1,"y":2,"z":3}"""); + Json(new float4(1f, 2f, 3f, 4f)).Should().Be("""{"x":1,"y":2,"z":3,"w":4}"""); + Json(new double2(1d, 2.5d)).Should().Be("""{"x":1,"y":2.5}"""); + Json(new double3(1d, 2d, 3d)).Should().Be("""{"x":1,"y":2,"z":3}"""); + Json(new int2(3, -4)).Should().Be("""{"x":3,"y":-4}"""); + Json(new int3(1, 2, 3)).Should().Be("""{"x":1,"y":2,"z":3}"""); + Json(new int4(1, 2, 3, 4)).Should().Be("""{"x":1,"y":2,"z":3,"w":4}"""); + Json(new bool2(true, false)).Should().Be("""{"x":true,"y":false}"""); + Json(new bool3(true, false, true)).Should().Be("""{"x":true,"y":false,"z":true}"""); + Json(new bool4(true, false, false, true)).Should().Be("""{"x":true,"y":false,"z":false,"w":true}"""); + Json(new quaternion(0f, 0f, 0f, 1f)).Should().Be("""{"x":0,"y":0,"z":0,"w":1}"""); + Json(new Color32(255, 128, 0, 7)).Should().Be("""{"r":255,"g":128,"b":0,"a":7}"""); + Json(new CultMath.Random(12345u)).Should().Be("""{"state":12345}"""); + Json(new float2x2(1f, 2f, 3f, 4f)).Should().Be("""[{"x":1,"y":2},{"x":3,"y":4}]"""); + Json(new float3x3(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f)).Should().Be( + """[{"x":1,"y":2,"z":3},{"x":4,"y":5,"z":6},{"x":7,"y":8,"z":9}]"""); + Json(new rect(5f, 7f, -1f, -2f)).Should().Be("""{"min":{"x":-1,"y":-2},"max":{"x":5,"y":7}}"""); + 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}"""); + } + + [Test] + public void EveryShape_RoundTrips() + { + RoundTrip(new float2(1.5f, -2f)); + RoundTrip(new float3(1f, 2f, 3f)); + RoundTrip(new float4(1f, 2f, 3f, 4f)); + RoundTrip(new double2(1e300, -2.5)); + RoundTrip(new double3(1, 2, 3)); + RoundTrip(new int2(-7, 900000)); + RoundTrip(new int3(1, -2, 3)); + RoundTrip(new int4(1, 2, 3, int.MinValue)); + RoundTrip(new bool2(true, false)); + RoundTrip(new bool3(false, true, true)); + RoundTrip(new bool4(true, false, false, true)); + RoundTrip(new quaternion(0.1f, 0.2f, 0.3f, 0.9f)); + RoundTrip(new Color32(255, 128, 0, 7)); + RoundTrip(new CultMath.Random(12345u)); + RoundTrip(new float2x2(1f, 2f, 3f, 4f)); + RoundTrip(new float3x3(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f)); + RoundTrip(new rect(-1f, -2f, 5f, 7f)); + RoundTrip(new CultCellular(new float4(1, 2, 3, 4), new float4(5, 6, 7, 8), 0.25f)); + } + + [Test] + public void Reading_SkipsUnknownPropertiesAndZeroesMissingOnes() + { + Parse("""{"z":3,"extra":[1,2],"x":1}""").Should().Be(new float3(1f, 0f, 3f)); + Parse("""{"r":1,"g":2,"b":3}""").Should().Be(new Color32(1, 2, 3, 255)); + Parse("null").Should().BeNull(); + } + + [Test] + public void Reading_RefusesShapesThatAreNotTheType() + { + Action array = () => Parse("[1,2]"); + array.Should().Throw(); + Action shortMatrix = () => Parse("""[{"x":1,"y":2}]"""); + shortMatrix.Should().Throw(); + Action nil = () => Parse("null"); + nil.Should().Throw(); + } + + [Test] + public void EveryPublicCultMathValueType_HasAConverter() + { + var valueTypes = typeof(float2).Assembly.GetExportedTypes() + .Where(t => t.IsValueType && !t.IsEnum) + .ToHashSet(); + + valueTypes.Except(CultMathJson.ConvertedTypes).Should().BeEmpty(); + CultMathJson.ConvertedTypes.Except(valueTypes).Should().BeEmpty(); + foreach (var type in valueTypes) + { + var json = JsonSerializer.Serialize(Activator.CreateInstance(type), type, Options); + JsonSerializer.Deserialize(json, type, Options).Should().Be(Activator.CreateInstance(type)); + } + } + + private static void RoundTrip(T value) => Parse(Json(value)).Should().Be(value); + } +} diff --git a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs index daae992a..8741f891 100644 --- a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs +++ b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs @@ -47,6 +47,22 @@ namespace GameCult.Geometry.Tests sphere.XyCircle.Bounds.Should().Be(new rect(-4f, -3f, 6f, 7f)); } + [Test] + public void GeometryPrimitives_JsonOmitsComputedConvenienceProperties() + { + var options = new JsonSerializerOptions().AddCultMathConverters(); + var sphere = new CultSphere(new float3(1f, 2f, 3f), 5f); + var circle = new CultCircle(new float2(1f, 2f), 3f); + + var sphereJson = JsonSerializer.Serialize(sphere, options); + var circleJson = JsonSerializer.Serialize(circle, options); + + sphereJson.Should().Be("""{"Center":{"x":1,"y":2,"z":3},"Radius":5}"""); + circleJson.Should().Be("""{"Center":{"x":1,"y":2},"Radius":3}"""); + JsonSerializer.Deserialize(sphereJson, options).Should().Be(sphere); + JsonSerializer.Deserialize(circleJson, options).Should().Be(circle); + } + // The bytes CultCircle and CultSphere produced when they held CultVec2/CultVec3 (probed at 65311c9). [Test] public void GeometryPrimitives_KeepTheirPreCollapseBytes() From 9dcce987fd0eba713d07084826b32f110defea28 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 16:57:31 +0200 Subject: [PATCH 07/12] Update Mesh docs that still named CultVec2 and CultRect Co-Authored-By: Claude Sonnet 5 --- .../docs/cross-runtime-primitives-roadmap.md | 8 ++++---- src/GameCult.Mesh/docs/public-api.md | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md b/src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md index 93fb3f3c..5b0048de 100644 --- a/src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md +++ b/src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md @@ -116,12 +116,12 @@ var aetheria = verse.Use(AetheriaRuntime.Schema); var viewport = await aetheria.Zone(zoneId) .Objects() .VisibleTo(controlledUnits) - .Within(CultRect.FromBounds(-400, -250, 400, 250)) + .Within(new rect(-400f, -250f, 400f, 250f)) .QueryAsync(); await aetheria.Entity(ravenId) .Pilot() - .MoveAsync(CultVec2.Right, claim: "pilot-control"); + .MoveAsync(new float2(1f, 0f), claim: "pilot-control"); ``` Current seed: @@ -134,7 +134,7 @@ var verse = await CultMesh.ConnectVerseAsync( new[] { new CultMeshAuthorityClaim("pilot-control", shardId: "zone:raven") }); var aetheria = verse.Use(context => new AetheriaGeneratedFacade(context)); -await aetheria.Entity(ravenId).Pilot.MoveAsync(CultVec2.Right); +await aetheria.Entity(ravenId).Pilot.MoveAsync(new float2(1f, 0f)); ``` ```ts @@ -177,7 +177,7 @@ let viewport = aetheria .zone(zone_id) .objects() .visible_to(&controlled_units) - .within(CultRect::from_bounds(-400.0, -250.0, 400.0, 250.0)) + .within(rect::new(-400.0, -250.0, 400.0, 250.0)) .query() .await?; ``` diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index df1735b6..e9f19ab7 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -407,8 +407,8 @@ var view = new CultMeshNativeSliceViewDescriptor( rowCount, new[] { - CultMeshNativeSliceColumn.For("position"), - CultMeshNativeSliceColumn.For("velocity") + CultMeshNativeSliceColumn.For("position"), + CultMeshNativeSliceColumn.For("velocity") }, new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory)); ``` From 9b5597e89c291ec42ac1f79ef6b0a01b6976c3bd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 17:20:17 +0200 Subject: [PATCH 08/12] Gate the CultLib Unity release on the CultMath package it depends on The package build now fails when the declared org.gamecult.cultmath version is ahead of the CultMath package, or when that package's CultMath.dll lacks a type CultMathResolver formats. It also drops the shipped-plugin guard that could not fire, since the output root is wiped first. Co-Authored-By: Claude Sonnet 5 --- scripts/build-unity-package.ps1 | 37 +++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1 index 3258155f..49bf391f 100644 --- a/scripts/build-unity-package.ps1 +++ b/scripts/build-unity-package.ps1 @@ -53,6 +53,34 @@ if ($LASTEXITCODE -ne 0) { throw "CultLib Unity package failed the semver policy check (see docs/semver-policy.md)." } + +# Release gate: cultlib's Unity package resolves CultMath through the org.gamecult.cultmath +# dependency, so a cultlib release must not run ahead of the CultMath package it names. +# (1) The declared dependency version must not exceed the CultMath package's own version. +# (2) The CultMath.dll that package tracks must define every type CultMathResolver formats. +# Type names come from the resolver source (its Shape entries) and are looked up as +# NUL-delimited names in the assembly's metadata string heap, which is a name check, not a +# signature check; Windows PowerShell cannot load a netstandard2.1 assembly to reflect on it. +$cultMathPackageRoot = Join-Path $repoRoot "packages\cultmath\unity\org.gamecult.cultmath" +$cultMathDeclared = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).dependencies.'org.gamecult.cultmath' +$cultMathAvailable = (Get-Content -LiteralPath (Join-Path $cultMathPackageRoot "package.json") -Raw | ConvertFrom-Json).version +if ([string]::IsNullOrWhiteSpace($cultMathDeclared)) { + throw "package.json must declare org.gamecult.cultmath, which supplies CultMath.dll." +} +if ([version]$cultMathDeclared -gt [version]$cultMathAvailable) { + throw "Release order: org.gamecult.cultlib depends on org.gamecult.cultmath $cultMathDeclared, but the CultMath package is at $cultMathAvailable. Release CultMath first." +} +$resolverSource = Get-Content -LiteralPath (Join-Path $repoRoot "src\GameCult.Caching.MessagePack\CultMathResolver.cs") -Raw +$resolverTypes = @([regex]::Matches($resolverSource, '\bShape<(?!T>)(\w+)>\(') | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique) +if ($resolverTypes.Count -eq 0) { + throw "Found no CultMath types in CultMathResolver.cs; the release gate cannot check the CultMath package." +} +$cultMathBytes = [IO.File]::ReadAllBytes((Join-Path $cultMathPackageRoot "Runtime\Plugins\CultMath.dll")) +$cultMathText = [Text.Encoding]::GetEncoding(28591).GetString($cultMathBytes) +$missingTypes = @($resolverTypes | Where-Object { -not $cultMathText.Contains("`0$_`0") }) +if ($missingTypes.Count -gt 0) { + throw "The CultMath.dll in org.gamecult.cultmath $cultMathAvailable lacks types CultMathResolver formats: $($missingTypes -join ', '). Release CultMath first." +} # The tracked DLLs and pdbs are committed beside their source, so none may name a commit or a worktree: # Source Link writes the commit SHA into the pdb, the informational version carries it too, and each DLL # carries its pdb's content id. ContinuousIntegrationBuild maps the local source path to /_/. @@ -137,10 +165,6 @@ $declaredAssemblies = @($expectedAssemblies + $externalAssemblies | Sort-Object) if (($referencedAssemblies -join "|") -ne ($declaredAssemblies -join "|")) { throw "GameCult.CultLib.asmdef precompiledReferences differ from the shipped plus external assembly lists." } -$cultMathDependency = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).dependencies.'org.gamecult.cultmath' -if ([string]::IsNullOrWhiteSpace($cultMathDependency)) { - throw "package.json must declare org.gamecult.cultmath, which supplies CultMath.dll." -} $publishedByName = @{} foreach ($assembly in Get-ChildItem -LiteralPath $publishRoot -Filter "*.dll") { $publishedByName[$assembly.Name] = $assembly @@ -167,11 +191,6 @@ foreach ($assemblyName in $expectedAssemblies) { Copy-Item -LiteralPath $pdb -Destination $pluginRoot } } -foreach ($external in $externalAssemblies) { - if (Test-Path -LiteralPath (Join-Path $pluginRoot $external)) { - throw "CultLib Unity package must not ship $external; org.gamecult.cultmath owns it." - } -} $nativePluginRoot = Join-Path $pluginRoot "x86_64" New-Item -ItemType Directory -Force -Path $nativePluginRoot | Out-Null Copy-Item -LiteralPath (Join-Path $quicNativeRoot "gamecult_mesh_quic_native.dll") -Destination $nativePluginRoot From 817c2627b8903fa44ec3d0996d4c54051402ed2f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 17:29:02 +0200 Subject: [PATCH 09/12] Rebuild the CultMath JSON converters on a streaming reader with STJ number semantics One Scalar plus Node description per type now drives reading, writing and the dictionary-key spelling, replacing the per-value JsonDocument parse. NaN and the infinities follow JsonNumberHandling (named literals or JsonException), floats beyond float.MaxValue are refused instead of becoming infinity, out-of-range components throw JsonException instead of FormatException, property matching honours PropertyNameCaseInsensitive, and every type works as a dictionary key spelled as comma-joined components. Co-Authored-By: Claude Sonnet 5 --- .../CultMathJson.cs | 513 +++++++++++++++--- src/GameCult.Caching.MessagePack/README.md | 11 +- .../CultMathJsonTests.cs | 126 +++++ 3 files changed, 567 insertions(+), 83 deletions(-) diff --git a/src/GameCult.Caching.MessagePack/CultMathJson.cs b/src/GameCult.Caching.MessagePack/CultMathJson.cs index 7070d389..def7ab9a 100644 --- a/src/GameCult.Caching.MessagePack/CultMathJson.cs +++ b/src/GameCult.Caching.MessagePack/CultMathJson.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; using CultMath; @@ -12,8 +13,26 @@ namespace GameCult.Caching.MessagePack; /// . 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":..}, and CultCellular is {"nearest":..,"edge":..,"id":..}. +/// Property names are fixed: PropertyNamingPolicy does not rename them, and +/// PropertyNameCaseInsensitive makes reading case-insensitive. +/// /// Reading skips unknown properties and leaves missing ones at zero (a missing Color32.a -/// is 255); a value that is not an object, or a matrix that is not an array of exactly its row count, is refused. +/// is 255). A wrong token, a number outside its component type (including a float beyond +/// , which is refused rather than becoming infinity), or a matrix +/// that is not an array of exactly its row count throws . +/// +/// +/// Numbers follow as System.Text.Json's own +/// float does: NaN and the infinities need AllowNamedFloatingPointLiterals (written and +/// read as "NaN", "Infinity", "-Infinity") and are otherwise refused with +/// ; AllowReadingFromString reads quoted numbers; +/// WriteAsString writes quoted numbers. +/// +/// +/// Every type is also a dictionary key: the flattened components joined by commas in +/// declaration order, invariant culture, shortest round-trip floats ("1,2", +/// "true,false", matrices row by row, rect as "minX,minY,maxX,maxY"). +/// /// Register with ; JSON options are the consumer's own. /// public static class CultMathJson @@ -29,111 +48,443 @@ public static class CultMathJson return options; } - private sealed class ObjectShape : JsonConverter - { - private readonly Action write; - private readonly Func read; + private static bool Named(JsonSerializerOptions o) => (o.NumberHandling & JsonNumberHandling.AllowNamedFloatingPointLiterals) != 0; + private static bool FromString(JsonSerializerOptions o) => (o.NumberHandling & JsonNumberHandling.AllowReadingFromString) != 0; + private static bool AsString(JsonSerializerOptions o) => (o.NumberHandling & JsonNumberHandling.WriteAsString) != 0; - public ObjectShape(Action write, Func read) + private static string Invalid(string what, string got) => $"{what} cannot be read from '{got}'."; + + // One component kind. Every CultMath type is a flat list of one kind, so a Scalar plus a Node tree describes it. + private abstract class Scalar + { + public virtual S[] Defaults(int count) => new S[count]; + public abstract void Write(Utf8JsonWriter w, S value, JsonSerializerOptions o); + public abstract S Read(ref Utf8JsonReader r, JsonSerializerOptions o); + public abstract string Text(S value, JsonSerializerOptions o); + public abstract S Parse(string text, JsonSerializerOptions o); + } + + private sealed class FloatScalar : Scalar + { + public static readonly FloatScalar Instance = new(); + + private static bool IsLiteral(string s) => s == "NaN" || s == "Infinity" || s == "-Infinity"; + + private static string Literal(float v) => float.IsNaN(v) ? "NaN" : v > 0f ? "Infinity" : "-Infinity"; + + public override void Write(Utf8JsonWriter w, float v, JsonSerializerOptions o) { - this.write = write; - this.read = read; + if (!float.IsFinite(v)) + { + if (!Named(o)) throw new JsonException("A non-finite float needs JsonNumberHandling.AllowNamedFloatingPointLiterals."); + w.WriteStringValue(Literal(v)); + } + else if (AsString(o)) w.WriteStringValue(v.ToString("R", CultureInfo.InvariantCulture)); + else w.WriteNumberValue(v); + } + + public override float Read(ref Utf8JsonReader r, JsonSerializerOptions o) + { + if (r.TokenType == JsonTokenType.Number) + { + if (!r.TryGetSingle(out var v) || float.IsInfinity(v)) throw new JsonException("The number is outside the float range."); + return v; + } + + if (r.TokenType == JsonTokenType.String) + { + var text = r.GetString()!; + if (!FromString(o) && !(Named(o) && IsLiteral(text))) throw new JsonException($"Expected a number, found the string '{text}'."); + return Parse(text, o); + } + + throw new JsonException($"Expected a number, found {r.TokenType}."); + } + + public override string Text(float v, JsonSerializerOptions o) + { + if (!float.IsFinite(v) && !Named(o)) throw new JsonException("A non-finite float needs JsonNumberHandling.AllowNamedFloatingPointLiterals."); + return v.ToString("R", CultureInfo.InvariantCulture); + } + + public override float Parse(string text, JsonSerializerOptions o) + { + if (Named(o)) + { + if (text == "NaN") return float.NaN; + if (text == "Infinity") return float.PositiveInfinity; + if (text == "-Infinity") return float.NegativeInfinity; + } + + if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) && float.IsFinite(v)) return v; + throw new JsonException(Invalid("A float", text)); + } + } + + private sealed class DoubleScalar : Scalar + { + public static readonly DoubleScalar Instance = new(); + + private static bool IsLiteral(string s) => s == "NaN" || s == "Infinity" || s == "-Infinity"; + + private static string Literal(double v) => double.IsNaN(v) ? "NaN" : v > 0d ? "Infinity" : "-Infinity"; + + public override void Write(Utf8JsonWriter w, double v, JsonSerializerOptions o) + { + if (!double.IsFinite(v)) + { + if (!Named(o)) throw new JsonException("A non-finite double needs JsonNumberHandling.AllowNamedFloatingPointLiterals."); + w.WriteStringValue(Literal(v)); + } + else if (AsString(o)) w.WriteStringValue(v.ToString("R", CultureInfo.InvariantCulture)); + else w.WriteNumberValue(v); + } + + public override double Read(ref Utf8JsonReader r, JsonSerializerOptions o) + { + if (r.TokenType == JsonTokenType.Number) + { + if (!r.TryGetDouble(out var v) || double.IsInfinity(v)) throw new JsonException("The number is outside the double range."); + return v; + } + + if (r.TokenType == JsonTokenType.String) + { + var text = r.GetString()!; + if (!FromString(o) && !(Named(o) && IsLiteral(text))) throw new JsonException($"Expected a number, found the string '{text}'."); + return Parse(text, o); + } + + throw new JsonException($"Expected a number, found {r.TokenType}."); + } + + public override string Text(double v, JsonSerializerOptions o) + { + if (!double.IsFinite(v) && !Named(o)) throw new JsonException("A non-finite double needs JsonNumberHandling.AllowNamedFloatingPointLiterals."); + return v.ToString("R", CultureInfo.InvariantCulture); + } + + public override double Parse(string text, JsonSerializerOptions o) + { + if (Named(o)) + { + if (text == "NaN") return double.NaN; + if (text == "Infinity") return double.PositiveInfinity; + if (text == "-Infinity") return double.NegativeInfinity; + } + + if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) && double.IsFinite(v)) return v; + throw new JsonException(Invalid("A double", text)); + } + } + + private sealed class IntScalar : Scalar + { + public static readonly IntScalar Instance = new(); + + public override void Write(Utf8JsonWriter w, int v, JsonSerializerOptions o) + { + if (AsString(o)) w.WriteStringValue(v.ToString(CultureInfo.InvariantCulture)); else w.WriteNumberValue(v); + } + + public override int Read(ref Utf8JsonReader r, JsonSerializerOptions o) + { + if (r.TokenType == JsonTokenType.Number) + { + if (!r.TryGetInt32(out var v)) throw new JsonException("The number is not an int."); + return v; + } + + if (r.TokenType == JsonTokenType.String && FromString(o)) return Parse(r.GetString()!, o); + throw new JsonException($"Expected an integer, found {r.TokenType}."); + } + + public override string Text(int v, JsonSerializerOptions o) => v.ToString(CultureInfo.InvariantCulture); + + public override int Parse(string text, JsonSerializerOptions o) => + int.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var v) ? v : throw new JsonException(Invalid("An int", text)); + } + + private sealed class UIntScalar : Scalar + { + public static readonly UIntScalar Instance = new(); + + public override void Write(Utf8JsonWriter w, uint v, JsonSerializerOptions o) + { + if (AsString(o)) w.WriteStringValue(v.ToString(CultureInfo.InvariantCulture)); else w.WriteNumberValue(v); + } + + public override uint Read(ref Utf8JsonReader r, JsonSerializerOptions o) + { + if (r.TokenType == JsonTokenType.Number) + { + if (!r.TryGetUInt32(out var v)) throw new JsonException("The number is not a uint."); + return v; + } + + if (r.TokenType == JsonTokenType.String && FromString(o)) return Parse(r.GetString()!, o); + throw new JsonException($"Expected an unsigned integer, found {r.TokenType}."); + } + + public override string Text(uint v, JsonSerializerOptions o) => v.ToString(CultureInfo.InvariantCulture); + + public override uint Parse(string text, JsonSerializerOptions o) => + uint.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var v) ? v : throw new JsonException(Invalid("A uint", text)); + } + + private sealed class ByteScalar : Scalar + { + public static readonly ByteScalar Instance = new(); + + public override byte[] Defaults(int count) + { + var defaults = new byte[count]; + if (count == 4) defaults[3] = 255; // Color32 alpha + return defaults; + } + + public override void Write(Utf8JsonWriter w, byte v, JsonSerializerOptions o) + { + if (AsString(o)) w.WriteStringValue(v.ToString(CultureInfo.InvariantCulture)); else w.WriteNumberValue(v); + } + + public override byte Read(ref Utf8JsonReader r, JsonSerializerOptions o) + { + if (r.TokenType == JsonTokenType.Number) + { + if (!r.TryGetByte(out var v)) throw new JsonException("The number is not a byte."); + return v; + } + + if (r.TokenType == JsonTokenType.String && FromString(o)) return Parse(r.GetString()!, o); + throw new JsonException($"Expected a byte, found {r.TokenType}."); + } + + public override string Text(byte v, JsonSerializerOptions o) => v.ToString(CultureInfo.InvariantCulture); + + public override byte Parse(string text, JsonSerializerOptions o) => + byte.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var v) ? v : throw new JsonException(Invalid("A byte", text)); + } + + private sealed class BoolScalar : Scalar + { + public static readonly BoolScalar Instance = new(); + + public override void Write(Utf8JsonWriter w, bool v, JsonSerializerOptions o) => w.WriteBooleanValue(v); + + public override bool Read(ref Utf8JsonReader r, JsonSerializerOptions o) => + r.TokenType == JsonTokenType.True ? true + : r.TokenType == JsonTokenType.False ? false + : throw new JsonException($"Expected true or false, found {r.TokenType}."); + + public override string Text(bool v, JsonSerializerOptions o) => v ? "true" : "false"; + + public override bool Parse(string text, JsonSerializerOptions o) => + text == "true" ? true : text == "false" ? false : throw new JsonException(Invalid("A bool", text)); + } + + // The JSON structure over a flat component list: leaves consume one component each, in order. + private abstract class Node + { + public int Count { get; protected set; } + public abstract void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o); + public abstract void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o); + } + + private sealed class Leaf : Node + { + public Leaf() { Count = 1; } + + public override void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o) => scalar.Write(w, flat[offset], o); + + public override void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o) => flat[offset] = scalar.Read(ref r, o); + } + + private sealed class Fields : Node + { + private readonly string[] names; + private readonly Node[] children; + private readonly int[] offsets; + + public Fields(params (string Name, Node Child)[] fields) + { + names = new string[fields.Length]; + children = new Node[fields.Length]; + offsets = new int[fields.Length]; + for (var i = 0; i < fields.Length; i++) + { + names[i] = fields[i].Name; + children[i] = fields[i].Child; + offsets[i] = Count; + Count += fields[i].Child.Count; + } + } + + public override void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o) + { + w.WriteStartObject(); + for (var i = 0; i < names.Length; i++) + { + w.WritePropertyName(names[i]); + children[i].Write(w, flat, offset + offsets[i], scalar, o); + } + + w.WriteEndObject(); + } + + public override void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o) + { + if (r.TokenType != JsonTokenType.StartObject) throw new JsonException($"Expected a JSON object, found {r.TokenType}."); + var comparison = o.PropertyNameCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + while (r.Read() && r.TokenType == JsonTokenType.PropertyName) + { + var name = r.GetString()!; + var index = Array.FindIndex(names, n => string.Equals(n, name, comparison)); + r.Read(); + if (index < 0) r.Skip(); else children[index].Read(ref r, flat, offset + offsets[index], scalar, o); + } + } + } + + private sealed class Rows : Node + { + private readonly Node row; + private readonly int rows; + + public Rows(int rows, Node row) + { + this.rows = rows; + this.row = row; + Count = rows * row.Count; + } + + public override void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o) + { + w.WriteStartArray(); + for (var i = 0; i < rows; i++) row.Write(w, flat, offset + (i * row.Count), scalar, o); + w.WriteEndArray(); + } + + public override void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o) + { + if (r.TokenType != JsonTokenType.StartArray) throw new JsonException($"Expected a JSON array of {rows} rows, found {r.TokenType}."); + for (var i = 0; i < rows; i++) + { + if (!r.Read() || r.TokenType == JsonTokenType.EndArray) throw new JsonException($"Expected {rows} rows."); + row.Read(ref r, flat, offset + (i * row.Count), scalar, o); + } + + if (!r.Read() || r.TokenType != JsonTokenType.EndArray) throw new JsonException($"Expected {rows} rows."); + } + } + + private sealed class Shape : JsonConverter + { + private readonly Scalar scalar; + private readonly Node root; + private readonly Func make; + private readonly Action flatten; + + public Shape(Scalar scalar, Node root, Func make, Action flatten) + { + this.scalar = scalar; + this.root = root; + this.make = make; + this.flatten = flatten; } public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - using var document = JsonDocument.ParseValue(ref reader); - return read(document.RootElement); + var flat = scalar.Defaults(root.Count); + root.Read(ref reader, flat, 0, scalar, options); + return make(flat); } - public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) => write(writer, value); + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + var flat = new S[root.Count]; + flatten(value, flat); + root.Write(writer, flat, 0, scalar, options); + } + + public override T ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var parts = (reader.GetString() ?? throw new JsonException("A dictionary key cannot be null.")).Split(','); + if (parts.Length != root.Count) throw new JsonException($"A {typeof(T).Name} key has {root.Count} comma-separated components."); + var flat = new S[root.Count]; + for (var i = 0; i < flat.Length; i++) flat[i] = scalar.Parse(parts[i], options); + return make(flat); + } + + public override void WriteAsPropertyName(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + var flat = new S[root.Count]; + flatten(value, flat); + writer.WritePropertyName(string.Join(",", Array.ConvertAll(flat, c => scalar.Text(c, options)))); + } } - private static KeyValuePair Shape(Action write, Func read) => - new(typeof(T), new ObjectShape(write, read)); + private static Node Val() => new Leaf(); - private static JsonElement Obj(JsonElement e) - { - if (e.ValueKind != JsonValueKind.Object) throw new JsonException("Expected a JSON object."); - return e; - } + private static Node Xy() => new Fields(("x", Val()), ("y", Val())); + private static Node Xyz() => new Fields(("x", Val()), ("y", Val()), ("z", Val())); + private static Node Xyzw() => new Fields(("x", Val()), ("y", Val()), ("z", Val()), ("w", Val())); - private static JsonElement Rows(JsonElement e, int count) - { - if (e.ValueKind != JsonValueKind.Array || e.GetArrayLength() != count) - throw new JsonException($"Expected a JSON array of {count} rows."); - return e; - } - - private static bool Has(JsonElement e, string name, out JsonElement value) => Obj(e).TryGetProperty(name, out value); - private static float F(JsonElement e, string name) => Has(e, name, out var v) ? v.GetSingle() : 0f; - private static double D(JsonElement e, string name) => Has(e, name, out var v) ? v.GetDouble() : 0d; - private static int I(JsonElement e, string name) => Has(e, name, out var v) ? v.GetInt32() : 0; - private static bool B(JsonElement e, string name) => Has(e, name, out var v) && v.GetBoolean(); - private static JsonElement Member(JsonElement e, string name) => - Has(e, name, out var v) ? v : Empty; - private static readonly JsonElement Empty = JsonDocument.Parse("{}").RootElement; - - private static void PutFloat2(Utf8JsonWriter w, float2 v) { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteEndObject(); } - private static void PutFloat3(Utf8JsonWriter w, float3 v) { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteEndObject(); } - private static void PutFloat4(Utf8JsonWriter w, float4 v) { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteNumber("w", v.w); w.WriteEndObject(); } - private static float2 GetFloat2(JsonElement e) => new(F(e, "x"), F(e, "y")); - private static float3 GetFloat3(JsonElement e) => new(F(e, "x"), F(e, "y"), F(e, "z")); - private static float4 GetFloat4(JsonElement e) => new(F(e, "x"), F(e, "y"), F(e, "z"), F(e, "w")); + private static KeyValuePair Add( + Scalar scalar, Node root, Func make, Action flatten) => + new(typeof(T), new Shape(scalar, root, make, flatten)); private static readonly Dictionary Converters = Build(); private static Dictionary Build() { + var f = FloatScalar.Instance; var shapes = new[] { - Shape(PutFloat2, GetFloat2), - Shape(PutFloat3, GetFloat3), - Shape(PutFloat4, GetFloat4), - Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteEndObject(); }, - e => new double2(D(e, "x"), D(e, "y"))), - Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteEndObject(); }, - e => new double3(D(e, "x"), D(e, "y"), D(e, "z"))), - Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteEndObject(); }, - e => new int2(I(e, "x"), I(e, "y"))), - Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteEndObject(); }, - e => new int3(I(e, "x"), I(e, "y"), I(e, "z"))), - Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteNumber("w", v.w); w.WriteEndObject(); }, - e => new int4(I(e, "x"), I(e, "y"), I(e, "z"), I(e, "w"))), - Shape((w, v) => { w.WriteStartObject(); w.WriteBoolean("x", v.x); w.WriteBoolean("y", v.y); w.WriteEndObject(); }, - e => new bool2(B(e, "x"), B(e, "y"))), - Shape((w, v) => { w.WriteStartObject(); w.WriteBoolean("x", v.x); w.WriteBoolean("y", v.y); w.WriteBoolean("z", v.z); w.WriteEndObject(); }, - e => new bool3(B(e, "x"), B(e, "y"), B(e, "z"))), - Shape((w, v) => { w.WriteStartObject(); w.WriteBoolean("x", v.x); w.WriteBoolean("y", v.y); w.WriteBoolean("z", v.z); w.WriteBoolean("w", v.w); w.WriteEndObject(); }, - e => new bool4(B(e, "x"), B(e, "y"), B(e, "z"), B(e, "w"))), - Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteNumber("w", v.w); w.WriteEndObject(); }, - e => new quaternion(F(e, "x"), F(e, "y"), F(e, "z"), F(e, "w"))), - Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("r", v.r); w.WriteNumber("g", v.g); w.WriteNumber("b", v.b); w.WriteNumber("a", v.a); w.WriteEndObject(); }, - e => new Color32( - Has(e, "r", out var r) ? r.GetByte() : (byte)0, - Has(e, "g", out var g) ? g.GetByte() : (byte)0, - Has(e, "b", out var b) ? b.GetByte() : (byte)0, - Has(e, "a", out var a) ? a.GetByte() : (byte)255)), - Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("state", v.state); w.WriteEndObject(); }, - e => new Random { state = Has(e, "state", out var s) ? s.GetUInt32() : 0u }), + Add(f, Xy(), a => new float2(a[0], a[1]), (v, a) => { a[0] = v.x; a[1] = v.y; }), + Add(f, Xyz(), a => new float3(a[0], a[1], a[2]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; }), + Add(f, Xyzw(), a => new float4(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; a[3] = v.w; }), + Add(f, Xyzw(), a => new quaternion(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; a[3] = v.w; }), + Add(DoubleScalar.Instance, Xy(), a => new double2(a[0], a[1]), (v, a) => { a[0] = v.x; a[1] = v.y; }), + Add(DoubleScalar.Instance, Xyz(), a => new double3(a[0], a[1], a[2]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; }), + Add(IntScalar.Instance, Xy(), a => new int2(a[0], a[1]), (v, a) => { a[0] = v.x; a[1] = v.y; }), + Add(IntScalar.Instance, Xyz(), a => new int3(a[0], a[1], a[2]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; }), + Add(IntScalar.Instance, Xyzw(), a => new int4(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; a[3] = v.w; }), + Add(BoolScalar.Instance, Xy(), a => new bool2(a[0], a[1]), (v, a) => { a[0] = v.x; a[1] = v.y; }), + Add(BoolScalar.Instance, Xyz(), a => new bool3(a[0], a[1], a[2]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; }), + Add(BoolScalar.Instance, Xyzw(), a => new bool4(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; a[3] = v.w; }), + Add(ByteScalar.Instance, + new Fields(("r", Val()), ("g", Val()), ("b", Val()), ("a", Val())), + a => new Color32(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.r; a[1] = v.g; a[2] = v.b; a[3] = v.a; }), + 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}. - Shape((w, v) => { w.WriteStartArray(); PutFloat2(w, v[0]); PutFloat2(w, v[1]); w.WriteEndArray(); }, - e => + 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; }), + Add(f, new Rows(3, Xyz()), + a => new float3x3(a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8]), + (v, a) => { - var rows = Rows(e, 2); - return new float2x2(GetFloat2(rows[0]), GetFloat2(rows[1])); + for (var row = 0; row < 3; row++) + { + a[row * 3] = v[row].x; + a[(row * 3) + 1] = v[row].y; + a[(row * 3) + 2] = v[row].z; + } }), - Shape((w, v) => { w.WriteStartArray(); PutFloat3(w, v[0]); PutFloat3(w, v[1]); PutFloat3(w, v[2]); w.WriteEndArray(); }, - e => + Add(f, new Fields(("min", Xy()), ("max", Xy())), + a => new rect(a[0], a[1], a[2], a[3]), + (v, a) => { a[0] = v.min.x; a[1] = v.min.y; a[2] = v.max.x; a[3] = v.max.y; }), + Add(f, new Fields(("nearest", Xyzw()), ("edge", Xyzw()), ("id", Val())), + a => new CultCellular(new float4(a[0], a[1], a[2], a[3]), new float4(a[4], a[5], a[6], a[7]), a[8]), + (v, a) => { - var rows = Rows(e, 3); - return new float3x3(GetFloat3(rows[0]), GetFloat3(rows[1]), GetFloat3(rows[2])); + a[0] = v.nearest.x; a[1] = v.nearest.y; a[2] = v.nearest.z; a[3] = v.nearest.w; + a[4] = v.edge.x; a[5] = v.edge.y; a[6] = v.edge.z; a[7] = v.edge.w; + a[8] = v.id; }), - Shape((w, v) => { w.WriteStartObject(); w.WritePropertyName("min"); PutFloat2(w, v.min); w.WritePropertyName("max"); PutFloat2(w, v.max); w.WriteEndObject(); }, - e => new rect(GetFloat2(Member(e, "min")), GetFloat2(Member(e, "max")))), - Shape((w, v) => { w.WriteStartObject(); w.WritePropertyName("nearest"); PutFloat4(w, v.nearest); w.WritePropertyName("edge"); PutFloat4(w, v.edge); w.WriteNumber("id", v.id); w.WriteEndObject(); }, - e => new CultCellular(GetFloat4(Member(e, "nearest")), GetFloat4(Member(e, "edge")), F(e, "id"))), }; var map = new Dictionary(); diff --git a/src/GameCult.Caching.MessagePack/README.md b/src/GameCult.Caching.MessagePack/README.md index ae4d08a4..27e3ea57 100644 --- a/src/GameCult.Caching.MessagePack/README.md +++ b/src/GameCult.Caching.MessagePack/README.md @@ -65,8 +65,15 @@ in the default chain) and one JSON shape (`CultMathJson`, added to a consumer's options with `new JsonSerializerOptions().AddCultMathConverters()`; CultLib owns no JSON options of its own). MessagePack decoding skips extra elements, leaves missing ones at zero (`Color32` alpha at 255) and refuses nil. JSON reading -skips unknown properties and leaves missing ones at zero. A consumer resolver or -converter registered ahead of these wins. +skips unknown properties, leaves missing ones at zero, honours +`PropertyNameCaseInsensitive`, and throws `JsonException` for a wrong token or an +out-of-range number (a float beyond `float.MaxValue` is refused, not read as +infinity). Numbers follow `JsonSerializerOptions.NumberHandling`: NaN and the +infinities need `AllowNamedFloatingPointLiterals` (`"NaN"`, `"Infinity"`, +`"-Infinity"`). Every type is also a dictionary key: its flattened components +joined by commas, invariant culture (`"1,2"`, `"true,false"`, matrices row by +row, `rect` as `"minX,minY,maxX,maxY"`). A consumer resolver or converter +registered ahead of these wins. | Type | MessagePack | JSON | | --- | --- | --- | diff --git a/tests/GameCult.Caching.Tests/CultMathJsonTests.cs b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs index 64702b74..bf8444f9 100644 --- a/tests/GameCult.Caching.Tests/CultMathJsonTests.cs +++ b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs @@ -1,6 +1,9 @@ using System; +using System.Collections; +using System.Collections.Generic; using System.Linq; using System.Text.Json; +using System.Text.Json.Serialization; using CultMath; using FluentAssertions; using GameCult.Caching.MessagePack; @@ -99,6 +102,129 @@ namespace GameCult.Caching.Tests } } + private static JsonSerializerOptions With(JsonNumberHandling handling = JsonNumberHandling.Strict, bool ignoreCase = false) => + new JsonSerializerOptions { NumberHandling = handling, PropertyNameCaseInsensitive = ignoreCase }.AddCultMathConverters(); + + private const JsonNumberHandling Named = JsonNumberHandling.AllowNamedFloatingPointLiterals; + + [Test] + public void NonFiniteFloats_NeedNamedLiterals_AsInSystemTextJson() + { + Action nan = () => JsonSerializer.Serialize(new float2(float.NaN, 1f), Options); + nan.Should().Throw(); + Action inf = () => JsonSerializer.Serialize(new double3(1d, double.PositiveInfinity, 0d), Options); + inf.Should().Throw(); + Action named = () => JsonSerializer.Deserialize("""{"x":"NaN","y":1}""", Options); + named.Should().Throw(); + + var options = With(Named); + JsonSerializer.Serialize(new float2(float.NaN, 1f), options).Should().Be("""{"x":"NaN","y":1}"""); + JsonSerializer.Serialize(new float3(float.PositiveInfinity, float.NegativeInfinity, 0f), options) + .Should().Be("""{"x":"Infinity","y":"-Infinity","z":0}"""); + var back = JsonSerializer.Deserialize("""{"x":"Infinity","y":"-Infinity","z":"NaN"}""", options); + back.x.Should().Be(float.PositiveInfinity); + back.y.Should().Be(float.NegativeInfinity); + float.IsNaN(back.z).Should().BeTrue(); + JsonSerializer.Deserialize("""{"x":"NaN","y":"-Infinity"}""", options).y.Should().Be(double.NegativeInfinity); + } + + [Test] + public void Numbers_OutsideTheComponentType_AreRefused() + { + foreach (var json in new[] { """{"x":1e40}""", """{"x":-1e40}""" }) + { + Action tooBig = () => Parse(json); + tooBig.Should().Throw(json); + } + + Action doubleTooBig = () => Parse("""{"x":1e400}"""); + doubleTooBig.Should().Throw(); + Action alpha = () => Parse("""{"r":300}"""); + alpha.Should().Throw(); + Action negativeByte = () => Parse("""{"g":-1}"""); + negativeByte.Should().Throw(); + Action fraction = () => Parse("""{"x":1.5}"""); + fraction.Should().Throw(); + Action wrongToken = () => Parse("""{"x":"a"}"""); + wrongToken.Should().Throw(); + Action boolean = () => Parse("""{"x":1}"""); + boolean.Should().Throw(); + Action negativeState = () => Parse("""{"state":-1}"""); + negativeState.Should().Throw(); + } + + [Test] + public void NumberHandling_QuotesAndUnquotesNumbers() + { + JsonSerializer.Serialize(new float2(1f, 2.5f), With(JsonNumberHandling.WriteAsString)).Should().Be("""{"x":"1","y":"2.5"}"""); + JsonSerializer.Serialize(new int2(1, 2), With(JsonNumberHandling.WriteAsString)).Should().Be("""{"x":"1","y":"2"}"""); + JsonSerializer.Deserialize("""{"x":"1.5","y":2}""", With(JsonNumberHandling.AllowReadingFromString)) + .Should().Be(new float2(1.5f, 2f)); + JsonSerializer.Deserialize("""{"x":"7","y":2}""", With(JsonNumberHandling.AllowReadingFromString)) + .Should().Be(new int2(7, 2)); + Action strict = () => Parse("""{"x":"1.5"}"""); + strict.Should().Throw(); + Action tooBig = () => JsonSerializer.Deserialize("""{"x":"1e40"}""", With(JsonNumberHandling.AllowReadingFromString)); + tooBig.Should().Throw(); + } + + [Test] + public void PropertyNames_FollowTheCaseSensitivityOption() + { + JsonSerializer.Deserialize("""{"X":1,"Y":2}""", With(ignoreCase: true)).Should().Be(new float2(1f, 2f)); + JsonSerializer.Deserialize("""{"MIN":{"X":1,"y":2},"Max":{"x":3,"Y":4}}""", With(ignoreCase: true)) + .Should().Be(new rect(1f, 2f, 3f, 4f)); + Parse("""{"X":1,"Y":2}""").Should().Be(float2.zero); + } + + [Test] + public void EveryShape_WorksAsADictionaryKey() + { + Json(new Dictionary { [new int2(1, -2)] = "a" }).Should().Be("""{"1,-2":"a"}"""); + Parse>("""{"1,-2":"a"}""")[new int2(1, -2)].Should().Be("a"); + + KeyOf(new float2(1.5f, -2f)).Should().Be("1.5,-2"); + KeyOf(new float3(0.1f, 2f, 3f)).Should().Be("0.1,2,3"); + KeyOf(new bool2(true, false)).Should().Be("true,false"); + KeyOf(new Color32(255, 128, 0, 7)).Should().Be("255,128,0,7"); + KeyOf(new CultMath.Random(12345u)).Should().Be("12345"); + KeyOf(new float2x2(1f, 2f, 3f, 4f)).Should().Be("1,2,3,4"); + KeyOf(new rect(5f, 7f, -1f, -2f)).Should().Be("-1,-2,5,7"); + KeyOf(new float2(float.NaN, float.NegativeInfinity), Named).Should().Be("NaN,-Infinity"); + + var nan = JsonSerializer.Deserialize>("""{"NaN,Infinity":1}""", With(Named))!; + nan.Keys.Single().y.Should().Be(float.PositiveInfinity); + + Action refuseNonFinite = () => KeyOf(new float2(float.NaN, 0f)); + refuseNonFinite.Should().Throw(); + Action wrongArity = () => Parse>("""{"1,2":1}"""); + wrongArity.Should().Throw(); + Action overflow = () => Parse>("""{"1e40,0":1}"""); + overflow.Should().Throw(); + } + + [Test] + public void EveryPublicCultMathValueType_RoundTripsAsADictionaryKey() + { + foreach (var type in typeof(float2).Assembly.GetExportedTypes().Where(t => t.IsValueType && !t.IsEnum)) + { + var dictionary = (IDictionary)Activator.CreateInstance(typeof(Dictionary<,>).MakeGenericType(type, typeof(int)))!; + dictionary.Add(Activator.CreateInstance(type)!, 7); + + var json = JsonSerializer.Serialize(dictionary, dictionary.GetType(), Options); + var back = (IDictionary)JsonSerializer.Deserialize(json, dictionary.GetType(), Options)!; + + back.Count.Should().Be(1, type.Name); + back[Activator.CreateInstance(type)!].Should().Be(7, type.Name); + } + } + + private static string KeyOf(T value, JsonNumberHandling handling = JsonNumberHandling.Strict) where T : notnull + { + var json = JsonSerializer.Serialize(new Dictionary { [value] = 1 }, With(handling)); + return JsonDocument.Parse(json).RootElement.EnumerateObject().Single().Name; + } + private static void RoundTrip(T value) => Parse(Json(value)).Should().Be(value); } } From 26a4f4240bd180ffd2ec082166e115fae6bb6b0e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 17:29:02 +0200 Subject: [PATCH 10/12] Pin that a zero or missing Random state decodes to the generator's default seed Co-Authored-By: Claude Sonnet 5 --- .../CultMathSerializationTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs b/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs index fb6f1489..32531679 100644 --- a/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs +++ b/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs @@ -141,6 +141,20 @@ namespace GameCult.Caching.Tests Decode("94CCFFCC800007").Should().Be(new Color32(255, 128, 0, 7)); } + + [Test] + public void Random_StateZero_IsTheGeneratorDefaultSeed() + { + // CultMath's generator maps state 0 to its default seed, so a zero or missing state is not degenerate. + var reference = new CultMath.Random(0u); + var expected = new[] { reference.NextUInt(), reference.NextUInt(), reference.NextUInt() }; + + foreach (var hex in new[] { "9100", "90" }) + { + var decoded = Decode(hex); + new[] { decoded.NextUInt(), decoded.NextUInt(), decoded.NextUInt() }.Should().Equal(expected); + } + } [Test] public void ConsumerResolvers_WinOverCultMathResolver() { From fee55e89c55a377f38248aabd09654b17d85bb87 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 18:31:16 +0200 Subject: [PATCH 11/12] Check the CultMath release gate against the type-definition table scripts/check-cultmath-types.cs reads the assembly with System.Reflection.Metadata and reports which named types are not public top-level CultMath types. The old NUL-delimited string search passed an appended string or a member named like the type, and failed a real type whose name is tail-shared in the string heap. Prerelease and range versions now get a clear message instead of a raw System.Version error. Co-Authored-By: Claude Sonnet 5 --- scripts/build-unity-package.ps1 | 22 ++++++++++++++-------- scripts/check-cultmath-types.cs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 scripts/check-cultmath-types.cs diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1 index 49bf391f..9de7c718 100644 --- a/scripts/build-unity-package.ps1 +++ b/scripts/build-unity-package.ps1 @@ -58,15 +58,20 @@ if ($LASTEXITCODE -ne 0) { # dependency, so a cultlib release must not run ahead of the CultMath package it names. # (1) The declared dependency version must not exceed the CultMath package's own version. # (2) The CultMath.dll that package tracks must define every type CultMathResolver formats. -# Type names come from the resolver source (its Shape entries) and are looked up as -# NUL-delimited names in the assembly's metadata string heap, which is a name check, not a -# signature check; Windows PowerShell cannot load a netstandard2.1 assembly to reflect on it. +# The names come from the resolver source (its Shape entries); scripts\check-cultmath-types.cs +# looks them up in the assembly's type-definition table, which Windows PowerShell cannot load +# the assembly to do. It checks type names, not signatures. $cultMathPackageRoot = Join-Path $repoRoot "packages\cultmath\unity\org.gamecult.cultmath" $cultMathDeclared = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).dependencies.'org.gamecult.cultmath' $cultMathAvailable = (Get-Content -LiteralPath (Join-Path $cultMathPackageRoot "package.json") -Raw | ConvertFrom-Json).version if ([string]::IsNullOrWhiteSpace($cultMathDeclared)) { throw "package.json must declare org.gamecult.cultmath, which supplies CultMath.dll." } +foreach ($declared in @(@("org.gamecult.cultlib's org.gamecult.cultmath dependency", $cultMathDeclared), @("the CultMath package version", $cultMathAvailable))) { + if ($declared[1] -notmatch '^\d+\.\d+\.\d+$') { + throw "$($declared[0]) is '$($declared[1])'. The release gate compares exact release versions (major.minor.patch); prerelease and range versions are not supported." + } +} if ([version]$cultMathDeclared -gt [version]$cultMathAvailable) { throw "Release order: org.gamecult.cultlib depends on org.gamecult.cultmath $cultMathDeclared, but the CultMath package is at $cultMathAvailable. Release CultMath first." } @@ -75,11 +80,12 @@ $resolverTypes = @([regex]::Matches($resolverSource, '\bShape<(?!T>)(\w+)>\(') | if ($resolverTypes.Count -eq 0) { throw "Found no CultMath types in CultMathResolver.cs; the release gate cannot check the CultMath package." } -$cultMathBytes = [IO.File]::ReadAllBytes((Join-Path $cultMathPackageRoot "Runtime\Plugins\CultMath.dll")) -$cultMathText = [Text.Encoding]::GetEncoding(28591).GetString($cultMathBytes) -$missingTypes = @($resolverTypes | Where-Object { -not $cultMathText.Contains("`0$_`0") }) -if ($missingTypes.Count -gt 0) { - throw "The CultMath.dll in org.gamecult.cultmath $cultMathAvailable lacks types CultMathResolver formats: $($missingTypes -join ', '). Release CultMath first." +$missingTypes = & dotnet run (Join-Path $PSScriptRoot "check-cultmath-types.cs") -- (Join-Path $cultMathPackageRoot "Runtime\Plugins\CultMath.dll") @resolverTypes +if ($LASTEXITCODE -eq 1) { + throw "The CultMath.dll in org.gamecult.cultmath $cultMathAvailable lacks types CultMathResolver formats: $missingTypes. Release CultMath first." +} +if ($LASTEXITCODE -ne 0) { + throw "scripts\check-cultmath-types.cs failed with exit code $LASTEXITCODE." } # The tracked DLLs and pdbs are committed beside their source, so none may name a commit or a worktree: # Source Link writes the commit SHA into the pdb, the informational version carries it too, and each DLL diff --git a/scripts/check-cultmath-types.cs b/scripts/check-cultmath-types.cs new file mode 100644 index 00000000..dab33397 --- /dev/null +++ b/scripts/check-cultmath-types.cs @@ -0,0 +1,30 @@ +// Release gate helper for build-unity-package.ps1: reads an assembly's type-definition table and +// reports which of the named CultMath types it does not define as a public top-level type. +// Usage: dotnet run scripts/check-cultmath-types.cs -- ... +// Exit 0 when every type is defined, 1 when any is missing, 2 on bad usage. +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +if (args.Length < 2) +{ + Console.Error.WriteLine("usage: check-cultmath-types ..."); + return 2; +} + +using var stream = File.OpenRead(args[0]); +using var pe = new PEReader(stream); +var metadata = pe.GetMetadataReader(); +var defined = new HashSet(); +foreach (var handle in metadata.TypeDefinitions) +{ + var type = metadata.GetTypeDefinition(handle); + if (!type.GetDeclaringType().IsNil) continue; + if ((type.Attributes & TypeAttributes.VisibilityMask) != TypeAttributes.Public) continue; + if (metadata.GetString(type.Namespace) == "CultMath") defined.Add(metadata.GetString(type.Name)); +} + +var missing = args.Skip(1).Where(name => !defined.Contains(name)).ToArray(); +if (missing.Length == 0) return 0; +Console.WriteLine(string.Join(", ", missing)); +return 1; From 4e5327c61c43779db7bfe08b8bd404b451a84fa7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 18:31:16 +0200 Subject: [PATCH 12/12] Bring the CultMath JSON converters to System.Text.Json parity and remove their allocations Named literals now follow AllowReadingFromString and WriteAsString as System.Text.Json's float does; quoted numbers refuse whitespace; UnmappedMemberHandling.Disallow and AllowDuplicateProperties=false are honoured and a repeated property replaces the whole value; dictionary keys read only the canonical spelling. Components live in stack spans, property names are pre-encoded, and there is no per-property closure, so a float3 read allocates nothing (424 bytes before). Tests pin unknown nested values, de-DE and sv-SE style cultures, and a distinct negative-zero key. Co-Authored-By: Claude Sonnet 5 --- .../CultMathJson.cs | 474 +++++++++--------- src/GameCult.Caching.MessagePack/README.md | 24 +- .../CultMathJsonTests.cs | 185 +++++++ 3 files changed, 449 insertions(+), 234 deletions(-) diff --git a/src/GameCult.Caching.MessagePack/CultMathJson.cs b/src/GameCult.Caching.MessagePack/CultMathJson.cs index def7ab9a..438c6150 100644 --- a/src/GameCult.Caching.MessagePack/CultMathJson.cs +++ b/src/GameCult.Caching.MessagePack/CultMathJson.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Reflection; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using CultMath; @@ -16,22 +18,30 @@ namespace GameCult.Caching.MessagePack; /// Property names are fixed: PropertyNamingPolicy does not rename them, and /// PropertyNameCaseInsensitive makes reading case-insensitive. /// -/// Reading skips unknown properties and leaves missing ones at zero (a missing Color32.a -/// is 255). A wrong token, a number outside its component type (including a float beyond -/// , which is refused rather than becoming infinity), or a matrix -/// that is not an array of exactly its row count throws . +/// Reading skips unknown properties (UnmappedMemberHandling.Disallow refuses them) and +/// leaves missing ones at zero (a missing Color32.a is 255). A repeated property replaces +/// the whole earlier value, never merging component-wise, and is refused when +/// AllowDuplicateProperties is false. A wrong token, a number outside its component type, +/// or a matrix that is not an array of exactly its row count throws +/// . /// /// /// Numbers follow as System.Text.Json's own -/// float does: NaN and the infinities need AllowNamedFloatingPointLiterals (written and -/// read as "NaN", "Infinity", "-Infinity") and are otherwise refused with -/// ; AllowReadingFromString reads quoted numbers; -/// WriteAsString writes quoted numbers. +/// float does. NaN and the infinities are the strings "NaN", "Infinity", +/// "-Infinity": written when AllowNamedFloatingPointLiterals or +/// WriteAsString is set (otherwise ), read when +/// AllowNamedFloatingPointLiterals or AllowReadingFromString is set. +/// AllowReadingFromString reads quoted numbers without surrounding whitespace, and +/// WriteAsString writes quoted numbers. Two intended divergences: a bare number beyond +/// the component's range (1e40 as a float) is refused where System.Text.Json reads +/// infinity, and refusals on write are where it throws +/// . /// /// /// Every type is also a dictionary key: the flattened components joined by commas in /// declaration order, invariant culture, shortest round-trip floats ("1,2", /// "true,false", matrices row by row, rect as "minX,minY,maxX,maxY"). +/// Only that exact spelling reads back, so two JSON keys never collapse into one dictionary key. /// /// Register with ; JSON options are the consumer's own. /// @@ -48,150 +58,141 @@ public static class CultMathJson return options; } - private static bool Named(JsonSerializerOptions o) => (o.NumberHandling & JsonNumberHandling.AllowNamedFloatingPointLiterals) != 0; + private static bool LiteralsWrite(JsonSerializerOptions o) => + (o.NumberHandling & (JsonNumberHandling.AllowNamedFloatingPointLiterals | JsonNumberHandling.WriteAsString)) != 0; + + private static bool LiteralsRead(JsonSerializerOptions o) => + (o.NumberHandling & (JsonNumberHandling.AllowNamedFloatingPointLiterals | JsonNumberHandling.AllowReadingFromString)) != 0; + private static bool FromString(JsonSerializerOptions o) => (o.NumberHandling & JsonNumberHandling.AllowReadingFromString) != 0; private static bool AsString(JsonSerializerOptions o) => (o.NumberHandling & JsonNumberHandling.WriteAsString) != 0; - private static string Invalid(string what, string got) => $"{what} cannot be read from '{got}'."; + // AllowDuplicateProperties arrived after the System.Text.Json this library compiles against. + private static readonly PropertyInfo? AllowDuplicateProperties = typeof(JsonSerializerOptions).GetProperty("AllowDuplicateProperties"); + + private static bool DuplicatesAllowed(JsonSerializerOptions o) => AllowDuplicateProperties == null || (bool)AllowDuplicateProperties.GetValue(o)!; + + private const NumberStyles FloatStyle = NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent; // One component kind. Every CultMath type is a flat list of one kind, so a Scalar plus a Node tree describes it. - private abstract class Scalar + private abstract class Scalar where S : unmanaged { - public virtual S[] Defaults(int count) => new S[count]; public abstract void Write(Utf8JsonWriter w, S value, JsonSerializerOptions o); public abstract S Read(ref Utf8JsonReader r, JsonSerializerOptions o); - public abstract string Text(S value, JsonSerializerOptions o); + + /// The canonical key spelling, whatever the options allow. + public abstract string Spelling(S value); + + /// The key spelling, refusing what the options would refuse to write. + public virtual string Text(S value, JsonSerializerOptions o) => Spelling(value); + public abstract S Parse(string text, JsonSerializerOptions o); + + /// Reads a key component; keys are always text, so AllowReadingFromString does not apply. + public virtual S ParseKey(string text, JsonSerializerOptions o) => Parse(text, o); } - private sealed class FloatScalar : Scalar + private abstract class FloatingScalar : Scalar where S : unmanaged + { + protected static bool IsLiteral(string s) => s == "NaN" || s == "Infinity" || s == "-Infinity"; + protected abstract bool IsFinite(S value); + protected abstract string Literal(S value); + protected abstract S FromLiteral(string literal); + protected abstract bool TryNumber(string text, out S value); + protected abstract bool TryToken(ref Utf8JsonReader r, out S value); + protected abstract void WriteNumber(Utf8JsonWriter w, S value); + protected abstract string Number(S value); + + public override void Write(Utf8JsonWriter w, S v, JsonSerializerOptions o) + { + if (!IsFinite(v)) + { + if (!LiteralsWrite(o)) throw new JsonException($"A non-finite {typeof(S).Name} needs JsonNumberHandling.AllowNamedFloatingPointLiterals or WriteAsString."); + w.WriteStringValue(Literal(v)); + } + else if (AsString(o)) w.WriteStringValue(Number(v)); + else WriteNumber(w, v); + } + + public override S Read(ref Utf8JsonReader r, JsonSerializerOptions o) + { + if (r.TokenType == JsonTokenType.Number) + { + if (!TryToken(ref r, out var v) || !IsFinite(v)) throw new JsonException($"The number is outside the {typeof(S).Name} range."); + return v; + } + + if (r.TokenType == JsonTokenType.String) return Parse(r.GetString()!, o); + throw new JsonException($"Expected a number, found {r.TokenType}."); + } + + public override string Spelling(S v) => Number(v); + + public override string Text(S v, JsonSerializerOptions o) + { + if (!IsFinite(v) && !LiteralsWrite(o)) throw new JsonException($"A non-finite {typeof(S).Name} needs JsonNumberHandling.AllowNamedFloatingPointLiterals or WriteAsString."); + return Spelling(v); + } + + public override S Parse(string text, JsonSerializerOptions o) + { + if (!IsLiteral(text) && !FromString(o)) throw new JsonException($"Expected a number, found the string '{text}'."); + return ParseKey(text, o); + } + + public override S ParseKey(string text, JsonSerializerOptions o) + { + if (IsLiteral(text)) + { + if (!LiteralsRead(o)) throw new JsonException($"'{text}' needs JsonNumberHandling.AllowNamedFloatingPointLiterals or AllowReadingFromString."); + return FromLiteral(text); + } + + if (TryNumber(text, out var v) && IsFinite(v)) return v; + throw new JsonException($"A {typeof(S).Name} cannot be read from '{text}'."); + } + } + + private sealed class FloatScalar : FloatingScalar { public static readonly FloatScalar Instance = new(); - - private static bool IsLiteral(string s) => s == "NaN" || s == "Infinity" || s == "-Infinity"; - - private static string Literal(float v) => float.IsNaN(v) ? "NaN" : v > 0f ? "Infinity" : "-Infinity"; - - public override void Write(Utf8JsonWriter w, float v, JsonSerializerOptions o) - { - if (!float.IsFinite(v)) - { - if (!Named(o)) throw new JsonException("A non-finite float needs JsonNumberHandling.AllowNamedFloatingPointLiterals."); - w.WriteStringValue(Literal(v)); - } - else if (AsString(o)) w.WriteStringValue(v.ToString("R", CultureInfo.InvariantCulture)); - else w.WriteNumberValue(v); - } - - public override float Read(ref Utf8JsonReader r, JsonSerializerOptions o) - { - if (r.TokenType == JsonTokenType.Number) - { - if (!r.TryGetSingle(out var v) || float.IsInfinity(v)) throw new JsonException("The number is outside the float range."); - return v; - } - - if (r.TokenType == JsonTokenType.String) - { - var text = r.GetString()!; - if (!FromString(o) && !(Named(o) && IsLiteral(text))) throw new JsonException($"Expected a number, found the string '{text}'."); - return Parse(text, o); - } - - throw new JsonException($"Expected a number, found {r.TokenType}."); - } - - public override string Text(float v, JsonSerializerOptions o) - { - if (!float.IsFinite(v) && !Named(o)) throw new JsonException("A non-finite float needs JsonNumberHandling.AllowNamedFloatingPointLiterals."); - return v.ToString("R", CultureInfo.InvariantCulture); - } - - public override float Parse(string text, JsonSerializerOptions o) - { - if (Named(o)) - { - if (text == "NaN") return float.NaN; - if (text == "Infinity") return float.PositiveInfinity; - if (text == "-Infinity") return float.NegativeInfinity; - } - - if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) && float.IsFinite(v)) return v; - throw new JsonException(Invalid("A float", text)); - } + protected override bool IsFinite(float v) => float.IsFinite(v); + protected override string Literal(float v) => float.IsNaN(v) ? "NaN" : v > 0f ? "Infinity" : "-Infinity"; + protected override float FromLiteral(string s) => s == "NaN" ? float.NaN : s == "Infinity" ? float.PositiveInfinity : float.NegativeInfinity; + protected override bool TryNumber(string s, out float v) => float.TryParse(s, FloatStyle, CultureInfo.InvariantCulture, out v); + protected override bool TryToken(ref Utf8JsonReader r, out float v) => r.TryGetSingle(out v); + protected override void WriteNumber(Utf8JsonWriter w, float v) => w.WriteNumberValue(v); + protected override string Number(float v) => v.ToString("R", CultureInfo.InvariantCulture); } - private sealed class DoubleScalar : Scalar + private sealed class DoubleScalar : FloatingScalar { public static readonly DoubleScalar Instance = new(); - - private static bool IsLiteral(string s) => s == "NaN" || s == "Infinity" || s == "-Infinity"; - - private static string Literal(double v) => double.IsNaN(v) ? "NaN" : v > 0d ? "Infinity" : "-Infinity"; - - public override void Write(Utf8JsonWriter w, double v, JsonSerializerOptions o) - { - if (!double.IsFinite(v)) - { - if (!Named(o)) throw new JsonException("A non-finite double needs JsonNumberHandling.AllowNamedFloatingPointLiterals."); - w.WriteStringValue(Literal(v)); - } - else if (AsString(o)) w.WriteStringValue(v.ToString("R", CultureInfo.InvariantCulture)); - else w.WriteNumberValue(v); - } - - public override double Read(ref Utf8JsonReader r, JsonSerializerOptions o) - { - if (r.TokenType == JsonTokenType.Number) - { - if (!r.TryGetDouble(out var v) || double.IsInfinity(v)) throw new JsonException("The number is outside the double range."); - return v; - } - - if (r.TokenType == JsonTokenType.String) - { - var text = r.GetString()!; - if (!FromString(o) && !(Named(o) && IsLiteral(text))) throw new JsonException($"Expected a number, found the string '{text}'."); - return Parse(text, o); - } - - throw new JsonException($"Expected a number, found {r.TokenType}."); - } - - public override string Text(double v, JsonSerializerOptions o) - { - if (!double.IsFinite(v) && !Named(o)) throw new JsonException("A non-finite double needs JsonNumberHandling.AllowNamedFloatingPointLiterals."); - return v.ToString("R", CultureInfo.InvariantCulture); - } - - public override double Parse(string text, JsonSerializerOptions o) - { - if (Named(o)) - { - if (text == "NaN") return double.NaN; - if (text == "Infinity") return double.PositiveInfinity; - if (text == "-Infinity") return double.NegativeInfinity; - } - - if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) && double.IsFinite(v)) return v; - throw new JsonException(Invalid("A double", text)); - } + protected override bool IsFinite(double v) => double.IsFinite(v); + protected override string Literal(double v) => double.IsNaN(v) ? "NaN" : v > 0d ? "Infinity" : "-Infinity"; + protected override double FromLiteral(string s) => s == "NaN" ? double.NaN : s == "Infinity" ? double.PositiveInfinity : double.NegativeInfinity; + protected override bool TryNumber(string s, out double v) => double.TryParse(s, FloatStyle, CultureInfo.InvariantCulture, out v); + protected override bool TryToken(ref Utf8JsonReader r, out double v) => r.TryGetDouble(out v); + protected override void WriteNumber(Utf8JsonWriter w, double v) => w.WriteNumberValue(v); + protected override string Number(double v) => v.ToString("R", CultureInfo.InvariantCulture); } - private sealed class IntScalar : Scalar + private abstract class IntegerScalar : Scalar where S : unmanaged { - public static readonly IntScalar Instance = new(); + protected abstract bool TryToken(ref Utf8JsonReader r, out S value); + protected abstract bool TryText(string text, out S value); + protected abstract void WriteNumber(Utf8JsonWriter w, S value); - public override void Write(Utf8JsonWriter w, int v, JsonSerializerOptions o) + public override void Write(Utf8JsonWriter w, S v, JsonSerializerOptions o) { - if (AsString(o)) w.WriteStringValue(v.ToString(CultureInfo.InvariantCulture)); else w.WriteNumberValue(v); + if (AsString(o)) w.WriteStringValue(Spelling(v)); else WriteNumber(w, v); } - public override int Read(ref Utf8JsonReader r, JsonSerializerOptions o) + public override S Read(ref Utf8JsonReader r, JsonSerializerOptions o) { if (r.TokenType == JsonTokenType.Number) { - if (!r.TryGetInt32(out var v)) throw new JsonException("The number is not an int."); + if (!TryToken(ref r, out var v)) throw new JsonException($"The number is not a {typeof(S).Name}."); return v; } @@ -199,71 +200,35 @@ public static class CultMathJson throw new JsonException($"Expected an integer, found {r.TokenType}."); } - public override string Text(int v, JsonSerializerOptions o) => v.ToString(CultureInfo.InvariantCulture); - - public override int Parse(string text, JsonSerializerOptions o) => - int.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var v) ? v : throw new JsonException(Invalid("An int", text)); + public override S Parse(string text, JsonSerializerOptions o) => + TryText(text, out var v) ? v : throw new JsonException($"A {typeof(S).Name} cannot be read from '{text}'."); } - private sealed class UIntScalar : Scalar + private sealed class IntScalar : IntegerScalar + { + public static readonly IntScalar Instance = new(); + protected override bool TryToken(ref Utf8JsonReader r, out int v) => r.TryGetInt32(out v); + protected override bool TryText(string s, out int v) => int.TryParse(s, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out v); + protected override void WriteNumber(Utf8JsonWriter w, int v) => w.WriteNumberValue(v); + public override string Spelling(int v) => v.ToString(CultureInfo.InvariantCulture); + } + + private sealed class UIntScalar : IntegerScalar { public static readonly UIntScalar Instance = new(); - - public override void Write(Utf8JsonWriter w, uint v, JsonSerializerOptions o) - { - if (AsString(o)) w.WriteStringValue(v.ToString(CultureInfo.InvariantCulture)); else w.WriteNumberValue(v); - } - - public override uint Read(ref Utf8JsonReader r, JsonSerializerOptions o) - { - if (r.TokenType == JsonTokenType.Number) - { - if (!r.TryGetUInt32(out var v)) throw new JsonException("The number is not a uint."); - return v; - } - - if (r.TokenType == JsonTokenType.String && FromString(o)) return Parse(r.GetString()!, o); - throw new JsonException($"Expected an unsigned integer, found {r.TokenType}."); - } - - public override string Text(uint v, JsonSerializerOptions o) => v.ToString(CultureInfo.InvariantCulture); - - public override uint Parse(string text, JsonSerializerOptions o) => - uint.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var v) ? v : throw new JsonException(Invalid("A uint", text)); + protected override bool TryToken(ref Utf8JsonReader r, out uint v) => r.TryGetUInt32(out v); + protected override bool TryText(string s, out uint v) => uint.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out v); + protected override void WriteNumber(Utf8JsonWriter w, uint v) => w.WriteNumberValue(v); + public override string Spelling(uint v) => v.ToString(CultureInfo.InvariantCulture); } - private sealed class ByteScalar : Scalar + private sealed class ByteScalar : IntegerScalar { public static readonly ByteScalar Instance = new(); - - public override byte[] Defaults(int count) - { - var defaults = new byte[count]; - if (count == 4) defaults[3] = 255; // Color32 alpha - return defaults; - } - - public override void Write(Utf8JsonWriter w, byte v, JsonSerializerOptions o) - { - if (AsString(o)) w.WriteStringValue(v.ToString(CultureInfo.InvariantCulture)); else w.WriteNumberValue(v); - } - - public override byte Read(ref Utf8JsonReader r, JsonSerializerOptions o) - { - if (r.TokenType == JsonTokenType.Number) - { - if (!r.TryGetByte(out var v)) throw new JsonException("The number is not a byte."); - return v; - } - - if (r.TokenType == JsonTokenType.String && FromString(o)) return Parse(r.GetString()!, o); - throw new JsonException($"Expected a byte, found {r.TokenType}."); - } - - public override string Text(byte v, JsonSerializerOptions o) => v.ToString(CultureInfo.InvariantCulture); - - public override byte Parse(string text, JsonSerializerOptions o) => - byte.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var v) ? v : throw new JsonException(Invalid("A byte", text)); + protected override bool TryToken(ref Utf8JsonReader r, out byte v) => r.TryGetByte(out v); + protected override bool TryText(string s, out byte v) => byte.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out v); + protected override void WriteNumber(Utf8JsonWriter w, byte v) => w.WriteNumberValue(v); + public override string Spelling(byte v) => v.ToString(CultureInfo.InvariantCulture); } private sealed class BoolScalar : Scalar @@ -277,76 +242,115 @@ public static class CultMathJson : r.TokenType == JsonTokenType.False ? false : throw new JsonException($"Expected true or false, found {r.TokenType}."); - public override string Text(bool v, JsonSerializerOptions o) => v ? "true" : "false"; + public override string Spelling(bool v) => v ? "true" : "false"; public override bool Parse(string text, JsonSerializerOptions o) => - text == "true" ? true : text == "false" ? false : throw new JsonException(Invalid("A bool", text)); + text == "true" ? true : text == "false" ? false : throw new JsonException($"A bool cannot be read from '{text}'."); } // The JSON structure over a flat component list: leaves consume one component each, in order. - private abstract class Node + // Read fills `flat` (pre-loaded with `defaults`), so a missing property keeps its default. + private abstract class Node where S : unmanaged { public int Count { get; protected set; } - public abstract void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o); - public abstract void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o); + public abstract void Write(Utf8JsonWriter w, ReadOnlySpan flat, Scalar scalar, JsonSerializerOptions o); + public abstract void Read(ref Utf8JsonReader r, scoped Span flat, scoped ReadOnlySpan defaults, Scalar scalar, JsonSerializerOptions o); } - private sealed class Leaf : Node + private sealed class Leaf : Node where S : unmanaged { public Leaf() { Count = 1; } - public override void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o) => scalar.Write(w, flat[offset], o); + public override void Write(Utf8JsonWriter w, ReadOnlySpan flat, Scalar scalar, JsonSerializerOptions o) => scalar.Write(w, flat[0], o); - public override void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o) => flat[offset] = scalar.Read(ref r, o); + public override void Read(ref Utf8JsonReader r, scoped Span flat, scoped ReadOnlySpan defaults, Scalar scalar, JsonSerializerOptions o) => flat[0] = scalar.Read(ref r, o); } - private sealed class Fields : Node + private sealed class Fields : Node where S : unmanaged { private readonly string[] names; + private readonly byte[][] utf8Names; + private readonly JsonEncodedText[] encoded; private readonly Node[] children; private readonly int[] offsets; public Fields(params (string Name, Node Child)[] fields) { names = new string[fields.Length]; + utf8Names = new byte[fields.Length][]; + encoded = new JsonEncodedText[fields.Length]; children = new Node[fields.Length]; offsets = new int[fields.Length]; for (var i = 0; i < fields.Length; i++) { names[i] = fields[i].Name; + utf8Names[i] = Encoding.UTF8.GetBytes(fields[i].Name); + encoded[i] = JsonEncodedText.Encode(fields[i].Name); children[i] = fields[i].Child; offsets[i] = Count; Count += fields[i].Child.Count; } } - public override void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o) + public override void Write(Utf8JsonWriter w, ReadOnlySpan flat, Scalar scalar, JsonSerializerOptions o) { w.WriteStartObject(); - for (var i = 0; i < names.Length; i++) + for (var i = 0; i < children.Length; i++) { - w.WritePropertyName(names[i]); - children[i].Write(w, flat, offset + offsets[i], scalar, o); + w.WritePropertyName(encoded[i]); + children[i].Write(w, flat.Slice(offsets[i], children[i].Count), scalar, o); } w.WriteEndObject(); } - public override void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o) + public override void Read(ref Utf8JsonReader r, scoped Span flat, scoped ReadOnlySpan defaults, Scalar scalar, JsonSerializerOptions o) { if (r.TokenType != JsonTokenType.StartObject) throw new JsonException($"Expected a JSON object, found {r.TokenType}."); - var comparison = o.PropertyNameCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var ignoreCase = o.PropertyNameCaseInsensitive; + var seen = 0; while (r.Read() && r.TokenType == JsonTokenType.PropertyName) { - var name = r.GetString()!; - var index = Array.FindIndex(names, n => string.Equals(n, name, comparison)); + var index = IndexOf(ref r, ignoreCase); r.Read(); - if (index < 0) r.Skip(); else children[index].Read(ref r, flat, offset + offsets[index], scalar, o); + if (index < 0) + { + if (o.UnmappedMemberHandling == JsonUnmappedMemberHandling.Disallow) + throw new JsonException("The JSON object has a property that is not a component."); + r.Skip(); + continue; + } + + var slice = flat.Slice(offsets[index], children[index].Count); + var bit = 1 << index; + if ((seen & bit) != 0) + { + if (!DuplicatesAllowed(o)) throw new JsonException($"The property '{names[index]}' appears more than once."); + defaults.Slice(offsets[index], slice.Length).CopyTo(slice); + } + + seen |= bit; + children[index].Read(ref r, slice, defaults.Slice(offsets[index], slice.Length), scalar, o); } } + + private int IndexOf(ref Utf8JsonReader r, bool ignoreCase) + { + if (!ignoreCase) + { + for (var i = 0; i < utf8Names.Length; i++) + if (r.ValueTextEquals(utf8Names[i])) 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; + return -1; + } } - private sealed class Rows : Node + private sealed class Rows : Node where S : unmanaged { private readonly Node row; private readonly int rows; @@ -358,81 +362,98 @@ public static class CultMathJson Count = rows * row.Count; } - public override void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o) + public override void Write(Utf8JsonWriter w, ReadOnlySpan flat, Scalar scalar, JsonSerializerOptions o) { w.WriteStartArray(); - for (var i = 0; i < rows; i++) row.Write(w, flat, offset + (i * row.Count), scalar, o); + for (var i = 0; i < rows; i++) row.Write(w, flat.Slice(i * row.Count, row.Count), scalar, o); w.WriteEndArray(); } - public override void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o) + public override void Read(ref Utf8JsonReader r, scoped Span flat, scoped ReadOnlySpan defaults, Scalar scalar, JsonSerializerOptions o) { if (r.TokenType != JsonTokenType.StartArray) throw new JsonException($"Expected a JSON array of {rows} rows, found {r.TokenType}."); for (var i = 0; i < rows; i++) { if (!r.Read() || r.TokenType == JsonTokenType.EndArray) throw new JsonException($"Expected {rows} rows."); - row.Read(ref r, flat, offset + (i * row.Count), scalar, o); + row.Read(ref r, flat.Slice(i * row.Count, row.Count), defaults.Slice(i * row.Count, row.Count), scalar, o); } if (!r.Read() || r.TokenType != JsonTokenType.EndArray) throw new JsonException($"Expected {rows} rows."); } } - private sealed class Shape : JsonConverter + private delegate T Make(ReadOnlySpan components) where S : unmanaged; + private delegate void Flatten(T value, Span components) where S : unmanaged; + + private sealed class Shape : JsonConverter where S : unmanaged { private readonly Scalar scalar; private readonly Node root; - private readonly Func make; - private readonly Action flatten; + private readonly S[] defaults; + private readonly Make make; + private readonly Flatten flatten; - public Shape(Scalar scalar, Node root, Func make, Action flatten) + public Shape(Scalar scalar, Node root, Make make, Flatten flatten, S[]? initial) { this.scalar = scalar; this.root = root; this.make = make; this.flatten = flatten; + defaults = initial ?? new S[root.Count]; } public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - var flat = scalar.Defaults(root.Count); - root.Read(ref reader, flat, 0, scalar, options); + Span flat = stackalloc S[root.Count]; + defaults.CopyTo(flat); + root.Read(ref reader, flat, defaults, scalar, options); return make(flat); } public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) { - var flat = new S[root.Count]; + Span flat = stackalloc S[root.Count]; flatten(value, flat); - root.Write(writer, flat, 0, scalar, options); + root.Write(writer, flat, scalar, options); } public override T ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - var parts = (reader.GetString() ?? throw new JsonException("A dictionary key cannot be null.")).Split(','); + var text = reader.GetString() ?? throw new JsonException("A dictionary key cannot be null."); + var parts = text.Split(','); if (parts.Length != root.Count) throw new JsonException($"A {typeof(T).Name} key has {root.Count} comma-separated components."); - var flat = new S[root.Count]; - for (var i = 0; i < flat.Length; i++) flat[i] = scalar.Parse(parts[i], options); - return make(flat); + Span flat = stackalloc S[root.Count]; + for (var i = 0; i < parts.Length; i++) flat[i] = scalar.ParseKey(parts[i], options); + var value = make(flat); + if (Spell(flat) != text) throw new JsonException($"'{text}' is not the canonical spelling of a {typeof(T).Name} key."); + return value; } public override void WriteAsPropertyName(Utf8JsonWriter writer, T value, JsonSerializerOptions options) { - var flat = new S[root.Count]; + Span flat = stackalloc S[root.Count]; flatten(value, flat); - writer.WritePropertyName(string.Join(",", Array.ConvertAll(flat, c => scalar.Text(c, options)))); + for (var i = 0; i < flat.Length; i++) scalar.Text(flat[i], options); + writer.WritePropertyName(Spell(flat)); + } + + private string Spell(ReadOnlySpan flat) + { + var parts = new string[flat.Length]; + for (var i = 0; i < parts.Length; i++) parts[i] = scalar.Spelling(flat[i]); + return string.Join(",", parts); } } - private static Node Val() => new Leaf(); + private static Node Val() where S : unmanaged => new Leaf(); - private static Node Xy() => new Fields(("x", Val()), ("y", Val())); - private static Node Xyz() => new Fields(("x", Val()), ("y", Val()), ("z", Val())); - private static Node Xyzw() => new Fields(("x", Val()), ("y", Val()), ("z", Val()), ("w", Val())); + private static Node Xy() where S : unmanaged => new Fields(("x", Val()), ("y", Val())); + private static Node Xyz() where S : unmanaged => new Fields(("x", Val()), ("y", Val()), ("z", Val())); + private static Node Xyzw() where S : unmanaged => new Fields(("x", Val()), ("y", Val()), ("z", Val()), ("w", Val())); private static KeyValuePair Add( - Scalar scalar, Node root, Func make, Action flatten) => - new(typeof(T), new Shape(scalar, root, make, flatten)); + Scalar scalar, Node root, Make make, Flatten flatten, S[]? initial = null) where S : unmanaged => + new(typeof(T), new Shape(scalar, root, make, flatten, initial)); private static readonly Dictionary Converters = Build(); @@ -455,7 +476,8 @@ public static class CultMathJson Add(BoolScalar.Instance, Xyzw(), a => new bool4(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; a[3] = v.w; }), Add(ByteScalar.Instance, new Fields(("r", Val()), ("g", Val()), ("b", Val()), ("a", Val())), - a => new Color32(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.r; a[1] = v.g; a[2] = v.b; a[3] = v.a; }), + a => new Color32(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.r; a[1] = v.g; a[2] = v.b; a[3] = v.a; }, + initial: new byte[] { 0, 0, 0, 255 }), Add(UIntScalar.Instance, new Fields(("state", Val())), a => new Random { state = a[0] }, (v, a) => a[0] = v.state), diff --git a/src/GameCult.Caching.MessagePack/README.md b/src/GameCult.Caching.MessagePack/README.md index 27e3ea57..fdf91ab2 100644 --- a/src/GameCult.Caching.MessagePack/README.md +++ b/src/GameCult.Caching.MessagePack/README.md @@ -65,14 +65,22 @@ in the default chain) and one JSON shape (`CultMathJson`, added to a consumer's options with `new JsonSerializerOptions().AddCultMathConverters()`; CultLib owns no JSON options of its own). MessagePack decoding skips extra elements, leaves missing ones at zero (`Color32` alpha at 255) and refuses nil. JSON reading -skips unknown properties, leaves missing ones at zero, honours -`PropertyNameCaseInsensitive`, and throws `JsonException` for a wrong token or an -out-of-range number (a float beyond `float.MaxValue` is refused, not read as -infinity). Numbers follow `JsonSerializerOptions.NumberHandling`: NaN and the -infinities need `AllowNamedFloatingPointLiterals` (`"NaN"`, `"Infinity"`, -`"-Infinity"`). Every type is also a dictionary key: its flattened components -joined by commas, invariant culture (`"1,2"`, `"true,false"`, matrices row by -row, `rect` as `"minX,minY,maxX,maxY"`). A consumer resolver or converter +skips unknown properties (`UnmappedMemberHandling.Disallow` refuses them), leaves +missing ones at zero, honours `PropertyNameCaseInsensitive`, lets a repeated +property replace the whole earlier value (refused when `AllowDuplicateProperties` +is false), and throws `JsonException` for a wrong token or an out-of-range number. +Numbers follow `JsonSerializerOptions.NumberHandling` as System.Text.Json's own +float does: `"NaN"`, `"Infinity"` and `"-Infinity"` are written under +`AllowNamedFloatingPointLiterals` or `WriteAsString` and read under +`AllowNamedFloatingPointLiterals` or `AllowReadingFromString`; quoted numbers read +under `AllowReadingFromString` with no surrounding whitespace. +Every type is also a dictionary key: its flattened components joined by commas, +invariant culture (`"1,2"`, `"true,false"`, matrices row by row, `rect` as +`"minX,minY,maxX,maxY"`), and only that exact spelling reads back. Two intended +divergences from System.Text.Json: a bare number beyond a component's range +(`1e40` as a float) is refused where System.Text.Json reads infinity, and a +refusal on write is a `JsonException` where System.Text.Json throws +`ArgumentException`. A consumer resolver or converter registered ahead of these wins. | Type | MessagePack | JSON | diff --git a/tests/GameCult.Caching.Tests/CultMathJsonTests.cs b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs index bf8444f9..87e5b742 100644 --- a/tests/GameCult.Caching.Tests/CultMathJsonTests.cs +++ b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; @@ -225,6 +226,190 @@ namespace GameCult.Caching.Tests return JsonDocument.Parse(json).RootElement.EnumerateObject().Single().Name; } + private const JsonNumberHandling ReadStrings = JsonNumberHandling.AllowReadingFromString; + private const JsonNumberHandling WriteStrings = JsonNumberHandling.WriteAsString; + + // Every row was measured against System.Text.Json's own float. + [Test] + public void NamedLiterals_FollowTheSameFlagsAsSystemTextJsonFloat() + { + foreach (var handling in new[] { Named, ReadStrings, ReadStrings | Named }) + { + JsonSerializer.Deserialize("""{"x":"NaN","y":"-Infinity"}""", With(handling))!.y.Should().Be(float.NegativeInfinity, handling.ToString()); + } + + foreach (var handling in new[] { JsonNumberHandling.Strict, WriteStrings }) + { + Action read = () => JsonSerializer.Deserialize("""{"x":"NaN"}""", With(handling)); + read.Should().Throw(handling.ToString()); + } + + foreach (var handling in new[] { Named, WriteStrings, WriteStrings | Named }) + { + JsonSerializer.Serialize(new float2(float.NaN, float.PositiveInfinity), With(handling)) + .Should().Be("""{"x":"NaN","y":"Infinity"}""", handling.ToString()); + } + + foreach (var handling in new[] { JsonNumberHandling.Strict, ReadStrings }) + { + Action write = () => JsonSerializer.Serialize(new float2(float.NaN, 0f), With(handling)); + write.Should().Throw(handling.ToString()); + } + + JsonSerializer.Serialize(new float2(1f, float.NaN), With(WriteStrings)).Should().Be("""{"x":"1","y":"NaN"}"""); + JsonSerializer.Deserialize("""{"z":"Infinity"}""", With(ReadStrings))!.z.Should().Be(double.PositiveInfinity); + JsonSerializer.Serialize(new double2(double.NegativeInfinity, 2d), With(WriteStrings)).Should().Be("""{"x":"-Infinity","y":"2"}"""); + } + + [Test] + public void QuotedNumbers_RefuseWhitespace() + { + var options = With(ReadStrings); + foreach (var json in new[] { """{"x":" 1.5"}""", """{"x":"1.5 "}""", """{"x":"\t1"}""" }) + { + Action floatRead = () => JsonSerializer.Deserialize(json, options); + floatRead.Should().Throw(json); + } + + Action doubleRead = () => JsonSerializer.Deserialize("""{"y":" 2"}""", options); + doubleRead.Should().Throw(); + Action intRead = () => JsonSerializer.Deserialize("""{"x":" 1"}""", options); + intRead.Should().Throw(); + Action padded = () => JsonSerializer.Deserialize("""{"x":" NaN"}""", With(ReadStrings | Named)); + padded.Should().Throw(); + } + + [Test] + public void UnknownProperties_AreSkippedWhateverTheyContain() + { + var expected = new float3(1f, 2f, 3f); + foreach (var json in new[] + { + """{"u":{"a":[1,{"b":2}]},"x":1,"y":2,"z":3}""", + """{"x":1,"u":[[1],[2,[3]]],"y":2,"v":{"a":{}},"z":3}""", + """{"x":1,"y":2,"z":3,"u":{"a":{"b":[]}}}""", + """{"x":1,"y":2,"z":3,"u":[{"a":[{}]}]}""", + }) + { + Parse(json).Should().Be(expected, json); + } + + Parse("""{"min":{"q":[{}],"x":1,"y":2},"skip":{"a":[1]},"max":{"x":3,"y":4,"q":{"a":[]}}}""") + .Should().Be(new rect(1f, 2f, 3f, 4f)); + Parse("""[{"x":1,"u":[1],"y":2},{"y":4,"u":{"a":1},"x":3}]""").Should().Be(new float2x2(1f, 2f, 3f, 4f)); + } + + [Test] + public void UnmappedMemberHandling_Disallow_RefusesUnknownProperties() + { + var options = new JsonSerializerOptions { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }.AddCultMathConverters(); + Action flat = () => JsonSerializer.Deserialize("""{"x":1,"z":3}""", options); + flat.Should().Throw(); + Action nested = () => JsonSerializer.Deserialize("""{"min":{"x":1,"q":1},"max":{}}""", options); + nested.Should().Throw(); + JsonSerializer.Deserialize("""{"x":1,"y":2}""", options).Should().Be(new float2(1f, 2f)); + } + + [Test] + public void DuplicateProperties_ReplaceTheWholeValue() + { + Parse("""{"x":1,"x":2,"y":5}""").Should().Be(new float2(2f, 5f)); + Parse("""{"min":{"x":1,"y":2},"min":{"x":3},"max":{"x":4,"y":5}}""").Should().Be(new rect(3f, 0f, 4f, 5f)); + Parse("""{"a":9,"a":1}""").Should().Be(new Color32(0, 0, 0, 1)); + Parse("""{"r":3,"r":4}""").Should().Be(new Color32(4, 0, 0, 255)); + Parse("""{"nearest":{"x":1,"y":2},"nearest":{"z":3},"id":1}""") + .Should().Be(new CultCellular(new float4(0f, 0f, 3f, 0f), float4.zero, 1f)); + } + + [Test] + public void AllowDuplicatePropertiesFalse_RefusesRepeats() + { + var property = typeof(JsonSerializerOptions).GetProperty("AllowDuplicateProperties"); + Assert.That(property, Is.Not.Null, "the test runtime's System.Text.Json has AllowDuplicateProperties"); + var options = new JsonSerializerOptions().AddCultMathConverters(); + property!.SetValue(options, false); + + Action flat = () => JsonSerializer.Deserialize("""{"x":1,"x":2}""", options); + flat.Should().Throw(); + Action nested = () => JsonSerializer.Deserialize("""{"min":{"x":1,"x":2}}""", options); + nested.Should().Throw(); + JsonSerializer.Deserialize("""{"x":1,"y":2}""", options).Should().Be(new float2(1f, 2f)); + } + + [Test] + public void DictionaryKeys_ReadOnlyTheCanonicalSpelling() + { + foreach (var key in new[] { "1.0,2", "+1,2", " 1,2", "1, 2", "01,2", "1,2 ", "1e0,2", "1,2,", ",1" }) + { + Action read = () => Parse>($$"""{"{{key}}":1}"""); + read.Should().Throw(key); + } + + foreach (var key in new[] { "01,2", "+1,2", "1,-0" }) + { + Action read = () => Parse>($$"""{"{{key}}":1}"""); + read.Should().Throw(key); + } + + Action bools = () => Parse>("""{"True,false":1}"""); + bools.Should().Throw(); + Parse>("""{"1,2":1}""").Should().ContainKey(new float2(1f, 2f)); + } + + [Test] + public void NegativeZero_IsADistinctKey() + { + var negative = new float2(-0f, 0f); + KeyOf(negative).Should().Be("-0,0"); + KeyOf(float2.zero).Should().Be("0,0"); + KeyOf(new double2(0d, -0d)).Should().Be("0,-0"); + + var parsed = Parse>("""{"-0,0":1}""").Keys.Single(); + float.IsNegative(parsed.x).Should().BeTrue(); + var positive = Parse>("""{"0,0":1}""").Keys.Single(); + float.IsNegative(positive.x).Should().BeFalse(); + } + + [Test] + public void Culture_DoesNotChangeKeysOrValues() + { + var german = (CultureInfo)CultureInfo.InvariantCulture.Clone(); + german.NumberFormat.NumberDecimalSeparator = ","; + german.NumberFormat.NumberGroupSeparator = "."; + german.NumberFormat.NegativeSign = "−"; + german.NumberFormat.PositiveSign = "➕"; + var swedish = (CultureInfo)CultureInfo.InvariantCulture.Clone(); + swedish.NumberFormat.NegativeSign = "−"; + swedish.NumberFormat.NumberDecimalSeparator = ","; + + var original = CultureInfo.CurrentCulture; + try + { + foreach (var culture in new[] { german, swedish }) + { + CultureInfo.CurrentCulture = culture; + var options = With(ReadStrings | WriteStrings | Named); + + KeyOf(new float2(-1.5f, 2.25f)).Should().Be("-1.5,2.25"); + KeyOf(new double3(-1e-7, 1234.5, -0d)).Should().Be("-1E-07,1234.5,-0"); + KeyOf(new int2(-3, 4)).Should().Be("-3,4"); + KeyOf(new float2(float.NaN, float.NegativeInfinity), Named).Should().Be("NaN,-Infinity"); + Parse>("""{"-1.5,2.25":1}""").Keys.Single().Should().Be(new float2(-1.5f, 2.25f)); + Parse>("""{"-3,4":1}""").Keys.Single().Should().Be(new int2(-3, 4)); + + JsonSerializer.Serialize(new float2(-1.5f, 2.25f), options).Should().Be("""{"x":"-1.5","y":"2.25"}"""); + JsonSerializer.Deserialize("""{"x":"-1.5","y":"2.25"}""", options).Should().Be(new float2(-1.5f, 2.25f)); + JsonSerializer.Serialize(new int2(-3, 4), options).Should().Be("""{"x":"-3","y":"4"}"""); + JsonSerializer.Deserialize("""{"x":"-3","y":"4"}""", options).Should().Be(new int2(-3, 4)); + Json(new float2(-1.5f, 2.25f)).Should().Be("""{"x":-1.5,"y":2.25}"""); + } + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + private static void RoundTrip(T value) => Parse(Json(value)).Should().Be(value); } }