From 817c2627b8903fa44ec3d0996d4c54051402ed2f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 17:29:02 +0200 Subject: [PATCH] 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); } }