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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 16:57:31 +02:00
commit 20c30224fd
6 changed files with 290 additions and 0 deletions

View file

@ -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;
/// <summary>
/// The canonical System.Text.Json shape of every public CultMath value type, the JSON twin of
/// <see cref="CultMathResolver"/>. Vectors, quaternions and colors are objects keyed by their
/// field names (<c>{"x":1,"y":2}</c>), matrices are arrays of row objects, <c>rect</c> is
/// <c>{"min":..,"max":..}</c>, and <c>CultCellular</c> is <c>{"nearest":..,"edge":..,"id":..}</c>.
/// Reading skips unknown properties and leaves missing ones at zero (a missing <c>Color32.a</c>
/// 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 <see cref="AddCultMathConverters"/>; JSON options are the consumer's own.
/// </summary>
public static class CultMathJson
{
/// <summary>The CultMath types that have a converter, for coverage checks.</summary>
public static IReadOnlyCollection<Type> ConvertedTypes => Converters.Keys;
/// <summary>Adds the CultMath converters to <paramref name="options"/> and returns it.</summary>
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<T> : JsonConverter<T>
{
private readonly Action<Utf8JsonWriter, T> write;
private readonly Func<JsonElement, T> read;
public ObjectShape(Action<Utf8JsonWriter, T> write, Func<JsonElement, T> 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<Type, JsonConverter> Shape<T>(Action<Utf8JsonWriter, T> write, Func<JsonElement, T> read) =>
new(typeof(T), new ObjectShape<T>(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<Type, JsonConverter> Converters = Build();
private static Dictionary<Type, JsonConverter> Build()
{
var shapes = new[]
{
Shape<float2>(PutFloat2, GetFloat2),
Shape<float3>(PutFloat3, GetFloat3),
Shape<float4>(PutFloat4, GetFloat4),
Shape<double2>((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<double3>((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<int2>((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<int3>((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<int4>((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<bool2>((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<bool3>((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<bool4>((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<quaternion>((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<Color32>((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<Random>((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<float2x2>((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<float3x3>((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<rect>((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<CultCellular>((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<Type, JsonConverter>();
foreach (var shape in shapes) map.Add(shape.Key, shape.Value);
return map;
}
}

View file

@ -8,6 +8,7 @@
<ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" />
<ProjectReference Include="..\..\packages\cultmath\src\CultMath\CultMath.csproj" />
<PackageReference Include="MessagePack" Version="3.1.7" />
<PackageReference Include="System.Text.Json" Version="9.0.10" />
<PackageReference Include="MessagePackAnalyzer" Version="3.1.7" PrivateAssets="all" />
<InternalsVisibleTo Include="GameCult.Caching.Tests" />
</ItemGroup>

View file

@ -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.

View file

@ -12,6 +12,7 @@ namespace GameCult.Geometry
public readonly struct CultCircle : IEquatable<CultCircle>
{
/// <summary>Creates a circle.</summary>
[JsonConstructor]
public CultCircle(float2 center, float radius)
{
if (radius < 0f)
@ -75,6 +76,7 @@ namespace GameCult.Geometry
public readonly struct CultSphere : IEquatable<CultSphere>
{
/// <summary>Creates a sphere.</summary>
[JsonConstructor]
public CultSphere(float3 center, float radius)
{
if (radius < 0f)

View file

@ -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>(T value) => JsonSerializer.Serialize(value, Options);
private static T Parse<T>(string json) => JsonSerializer.Deserialize<T>(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<float3>("""{"z":3,"extra":[1,2],"x":1}""").Should().Be(new float3(1f, 0f, 3f));
Parse<Color32>("""{"r":1,"g":2,"b":3}""").Should().Be(new Color32(1, 2, 3, 255));
Parse<float2?>("null").Should().BeNull();
}
[Test]
public void Reading_RefusesShapesThatAreNotTheType()
{
Action array = () => Parse<float2>("[1,2]");
array.Should().Throw<JsonException>();
Action shortMatrix = () => Parse<float2x2>("""[{"x":1,"y":2}]""");
shortMatrix.Should().Throw<JsonException>();
Action nil = () => Parse<rect>("null");
nil.Should().Throw<JsonException>();
}
[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>(T value) => Parse<T>(Json(value)).Should().Be(value);
}
}

View file

@ -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<CultSphere>(sphereJson, options).Should().Be(sphere);
JsonSerializer.Deserialize<CultCircle>(circleJson, options).Should().Be(circle);
}
// The bytes CultCircle and CultSphere produced when they held CultVec2/CultVec3 (probed at 65311c9).
[Test]
public void GeometryPrimitives_KeepTheirPreCollapseBytes()