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()