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);
}
}