diff --git a/src/GameCult.Caching.MessagePack/CultMathJson.cs b/src/GameCult.Caching.MessagePack/CultMathJson.cs
index def7ab9a..438c6150 100644
--- a/src/GameCult.Caching.MessagePack/CultMathJson.cs
+++ b/src/GameCult.Caching.MessagePack/CultMathJson.cs
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Globalization;
+using System.Reflection;
+using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using CultMath;
@@ -16,22 +18,30 @@ namespace GameCult.Caching.MessagePack;
/// Property names are fixed: PropertyNamingPolicy does not rename them, and
/// PropertyNameCaseInsensitive makes reading case-insensitive.
///
-/// Reading skips unknown properties and leaves missing ones at zero (a missing Color32.a
-/// is 255). A wrong token, a number outside its component type (including a float beyond
-/// , which is refused rather than becoming infinity), or a matrix
-/// that is not an array of exactly its row count throws .
+/// Reading skips unknown properties (UnmappedMemberHandling.Disallow refuses them) and
+/// leaves missing ones at zero (a missing Color32.a is 255). A repeated property replaces
+/// the whole earlier value, never merging component-wise, and is refused when
+/// AllowDuplicateProperties is false. A wrong token, a number outside its component type,
+/// or a matrix that is not an array of exactly its row count throws
+/// .
///
///
/// Numbers follow as System.Text.Json's own
-/// float does: NaN and the infinities need AllowNamedFloatingPointLiterals (written and
-/// read as "NaN", "Infinity", "-Infinity") and are otherwise refused with
-/// ; AllowReadingFromString reads quoted numbers;
-/// WriteAsString writes quoted numbers.
+/// float does. NaN and the infinities are the strings "NaN", "Infinity",
+/// "-Infinity": written when AllowNamedFloatingPointLiterals or
+/// WriteAsString is set (otherwise ), read when
+/// AllowNamedFloatingPointLiterals or AllowReadingFromString is set.
+/// AllowReadingFromString reads quoted numbers without surrounding whitespace, and
+/// WriteAsString writes quoted numbers. Two intended divergences: a bare number beyond
+/// the component's range (1e40 as a float) is refused where System.Text.Json reads
+/// infinity, and refusals on write are where it throws
+/// .
///
///
/// Every type is also a dictionary key: the flattened components joined by commas in
/// declaration order, invariant culture, shortest round-trip floats ("1,2",
/// "true,false", matrices row by row, rect as "minX,minY,maxX,maxY").
+/// Only that exact spelling reads back, so two JSON keys never collapse into one dictionary key.
///
/// Register with ; JSON options are the consumer's own.
///
@@ -48,150 +58,141 @@ public static class CultMathJson
return options;
}
- private static bool Named(JsonSerializerOptions o) => (o.NumberHandling & JsonNumberHandling.AllowNamedFloatingPointLiterals) != 0;
+ private static bool LiteralsWrite(JsonSerializerOptions o) =>
+ (o.NumberHandling & (JsonNumberHandling.AllowNamedFloatingPointLiterals | JsonNumberHandling.WriteAsString)) != 0;
+
+ private static bool LiteralsRead(JsonSerializerOptions o) =>
+ (o.NumberHandling & (JsonNumberHandling.AllowNamedFloatingPointLiterals | JsonNumberHandling.AllowReadingFromString)) != 0;
+
private static bool FromString(JsonSerializerOptions o) => (o.NumberHandling & JsonNumberHandling.AllowReadingFromString) != 0;
private static bool AsString(JsonSerializerOptions o) => (o.NumberHandling & JsonNumberHandling.WriteAsString) != 0;
- private static string Invalid(string what, string got) => $"{what} cannot be read from '{got}'.";
+ // AllowDuplicateProperties arrived after the System.Text.Json this library compiles against.
+ private static readonly PropertyInfo? AllowDuplicateProperties = typeof(JsonSerializerOptions).GetProperty("AllowDuplicateProperties");
+
+ private static bool DuplicatesAllowed(JsonSerializerOptions o) => AllowDuplicateProperties == null || (bool)AllowDuplicateProperties.GetValue(o)!;
+
+ private const NumberStyles FloatStyle = NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent;
// One component kind. Every CultMath type is a flat list of one kind, so a Scalar plus a Node tree describes it.
- private abstract class Scalar
+ private abstract class Scalar where S : unmanaged
{
- public virtual S[] Defaults(int count) => new S[count];
public abstract void Write(Utf8JsonWriter w, S value, JsonSerializerOptions o);
public abstract S Read(ref Utf8JsonReader r, JsonSerializerOptions o);
- public abstract string Text(S value, JsonSerializerOptions o);
+
+ /// The canonical key spelling, whatever the options allow.
+ public abstract string Spelling(S value);
+
+ /// The key spelling, refusing what the options would refuse to write.
+ public virtual string Text(S value, JsonSerializerOptions o) => Spelling(value);
+
public abstract S Parse(string text, JsonSerializerOptions o);
+
+ /// Reads a key component; keys are always text, so AllowReadingFromString does not apply.
+ public virtual S ParseKey(string text, JsonSerializerOptions o) => Parse(text, o);
}
- private sealed class FloatScalar : Scalar
+ private abstract class FloatingScalar : Scalar where S : unmanaged
+ {
+ protected static bool IsLiteral(string s) => s == "NaN" || s == "Infinity" || s == "-Infinity";
+ protected abstract bool IsFinite(S value);
+ protected abstract string Literal(S value);
+ protected abstract S FromLiteral(string literal);
+ protected abstract bool TryNumber(string text, out S value);
+ protected abstract bool TryToken(ref Utf8JsonReader r, out S value);
+ protected abstract void WriteNumber(Utf8JsonWriter w, S value);
+ protected abstract string Number(S value);
+
+ public override void Write(Utf8JsonWriter w, S v, JsonSerializerOptions o)
+ {
+ if (!IsFinite(v))
+ {
+ if (!LiteralsWrite(o)) throw new JsonException($"A non-finite {typeof(S).Name} needs JsonNumberHandling.AllowNamedFloatingPointLiterals or WriteAsString.");
+ w.WriteStringValue(Literal(v));
+ }
+ else if (AsString(o)) w.WriteStringValue(Number(v));
+ else WriteNumber(w, v);
+ }
+
+ public override S Read(ref Utf8JsonReader r, JsonSerializerOptions o)
+ {
+ if (r.TokenType == JsonTokenType.Number)
+ {
+ if (!TryToken(ref r, out var v) || !IsFinite(v)) throw new JsonException($"The number is outside the {typeof(S).Name} range.");
+ return v;
+ }
+
+ if (r.TokenType == JsonTokenType.String) return Parse(r.GetString()!, o);
+ throw new JsonException($"Expected a number, found {r.TokenType}.");
+ }
+
+ public override string Spelling(S v) => Number(v);
+
+ public override string Text(S v, JsonSerializerOptions o)
+ {
+ if (!IsFinite(v) && !LiteralsWrite(o)) throw new JsonException($"A non-finite {typeof(S).Name} needs JsonNumberHandling.AllowNamedFloatingPointLiterals or WriteAsString.");
+ return Spelling(v);
+ }
+
+ public override S Parse(string text, JsonSerializerOptions o)
+ {
+ if (!IsLiteral(text) && !FromString(o)) throw new JsonException($"Expected a number, found the string '{text}'.");
+ return ParseKey(text, o);
+ }
+
+ public override S ParseKey(string text, JsonSerializerOptions o)
+ {
+ if (IsLiteral(text))
+ {
+ if (!LiteralsRead(o)) throw new JsonException($"'{text}' needs JsonNumberHandling.AllowNamedFloatingPointLiterals or AllowReadingFromString.");
+ return FromLiteral(text);
+ }
+
+ if (TryNumber(text, out var v) && IsFinite(v)) return v;
+ throw new JsonException($"A {typeof(S).Name} cannot be read from '{text}'.");
+ }
+ }
+
+ private sealed class FloatScalar : FloatingScalar
{
public static readonly FloatScalar Instance = new();
-
- private static bool IsLiteral(string s) => s == "NaN" || s == "Infinity" || s == "-Infinity";
-
- private static string Literal(float v) => float.IsNaN(v) ? "NaN" : v > 0f ? "Infinity" : "-Infinity";
-
- public override void Write(Utf8JsonWriter w, float v, JsonSerializerOptions o)
- {
- if (!float.IsFinite(v))
- {
- if (!Named(o)) throw new JsonException("A non-finite float needs JsonNumberHandling.AllowNamedFloatingPointLiterals.");
- w.WriteStringValue(Literal(v));
- }
- else if (AsString(o)) w.WriteStringValue(v.ToString("R", CultureInfo.InvariantCulture));
- else w.WriteNumberValue(v);
- }
-
- public override float Read(ref Utf8JsonReader r, JsonSerializerOptions o)
- {
- if (r.TokenType == JsonTokenType.Number)
- {
- if (!r.TryGetSingle(out var v) || float.IsInfinity(v)) throw new JsonException("The number is outside the float range.");
- return v;
- }
-
- if (r.TokenType == JsonTokenType.String)
- {
- var text = r.GetString()!;
- if (!FromString(o) && !(Named(o) && IsLiteral(text))) throw new JsonException($"Expected a number, found the string '{text}'.");
- return Parse(text, o);
- }
-
- throw new JsonException($"Expected a number, found {r.TokenType}.");
- }
-
- public override string Text(float v, JsonSerializerOptions o)
- {
- if (!float.IsFinite(v) && !Named(o)) throw new JsonException("A non-finite float needs JsonNumberHandling.AllowNamedFloatingPointLiterals.");
- return v.ToString("R", CultureInfo.InvariantCulture);
- }
-
- public override float Parse(string text, JsonSerializerOptions o)
- {
- if (Named(o))
- {
- if (text == "NaN") return float.NaN;
- if (text == "Infinity") return float.PositiveInfinity;
- if (text == "-Infinity") return float.NegativeInfinity;
- }
-
- if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) && float.IsFinite(v)) return v;
- throw new JsonException(Invalid("A float", text));
- }
+ protected override bool IsFinite(float v) => float.IsFinite(v);
+ protected override string Literal(float v) => float.IsNaN(v) ? "NaN" : v > 0f ? "Infinity" : "-Infinity";
+ protected override float FromLiteral(string s) => s == "NaN" ? float.NaN : s == "Infinity" ? float.PositiveInfinity : float.NegativeInfinity;
+ protected override bool TryNumber(string s, out float v) => float.TryParse(s, FloatStyle, CultureInfo.InvariantCulture, out v);
+ protected override bool TryToken(ref Utf8JsonReader r, out float v) => r.TryGetSingle(out v);
+ protected override void WriteNumber(Utf8JsonWriter w, float v) => w.WriteNumberValue(v);
+ protected override string Number(float v) => v.ToString("R", CultureInfo.InvariantCulture);
}
- private sealed class DoubleScalar : Scalar
+ private sealed class DoubleScalar : FloatingScalar
{
public static readonly DoubleScalar Instance = new();
-
- private static bool IsLiteral(string s) => s == "NaN" || s == "Infinity" || s == "-Infinity";
-
- private static string Literal(double v) => double.IsNaN(v) ? "NaN" : v > 0d ? "Infinity" : "-Infinity";
-
- public override void Write(Utf8JsonWriter w, double v, JsonSerializerOptions o)
- {
- if (!double.IsFinite(v))
- {
- if (!Named(o)) throw new JsonException("A non-finite double needs JsonNumberHandling.AllowNamedFloatingPointLiterals.");
- w.WriteStringValue(Literal(v));
- }
- else if (AsString(o)) w.WriteStringValue(v.ToString("R", CultureInfo.InvariantCulture));
- else w.WriteNumberValue(v);
- }
-
- public override double Read(ref Utf8JsonReader r, JsonSerializerOptions o)
- {
- if (r.TokenType == JsonTokenType.Number)
- {
- if (!r.TryGetDouble(out var v) || double.IsInfinity(v)) throw new JsonException("The number is outside the double range.");
- return v;
- }
-
- if (r.TokenType == JsonTokenType.String)
- {
- var text = r.GetString()!;
- if (!FromString(o) && !(Named(o) && IsLiteral(text))) throw new JsonException($"Expected a number, found the string '{text}'.");
- return Parse(text, o);
- }
-
- throw new JsonException($"Expected a number, found {r.TokenType}.");
- }
-
- public override string Text(double v, JsonSerializerOptions o)
- {
- if (!double.IsFinite(v) && !Named(o)) throw new JsonException("A non-finite double needs JsonNumberHandling.AllowNamedFloatingPointLiterals.");
- return v.ToString("R", CultureInfo.InvariantCulture);
- }
-
- public override double Parse(string text, JsonSerializerOptions o)
- {
- if (Named(o))
- {
- if (text == "NaN") return double.NaN;
- if (text == "Infinity") return double.PositiveInfinity;
- if (text == "-Infinity") return double.NegativeInfinity;
- }
-
- if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) && double.IsFinite(v)) return v;
- throw new JsonException(Invalid("A double", text));
- }
+ protected override bool IsFinite(double v) => double.IsFinite(v);
+ protected override string Literal(double v) => double.IsNaN(v) ? "NaN" : v > 0d ? "Infinity" : "-Infinity";
+ protected override double FromLiteral(string s) => s == "NaN" ? double.NaN : s == "Infinity" ? double.PositiveInfinity : double.NegativeInfinity;
+ protected override bool TryNumber(string s, out double v) => double.TryParse(s, FloatStyle, CultureInfo.InvariantCulture, out v);
+ protected override bool TryToken(ref Utf8JsonReader r, out double v) => r.TryGetDouble(out v);
+ protected override void WriteNumber(Utf8JsonWriter w, double v) => w.WriteNumberValue(v);
+ protected override string Number(double v) => v.ToString("R", CultureInfo.InvariantCulture);
}
- private sealed class IntScalar : Scalar
+ private abstract class IntegerScalar : Scalar where S : unmanaged
{
- public static readonly IntScalar Instance = new();
+ protected abstract bool TryToken(ref Utf8JsonReader r, out S value);
+ protected abstract bool TryText(string text, out S value);
+ protected abstract void WriteNumber(Utf8JsonWriter w, S value);
- public override void Write(Utf8JsonWriter w, int v, JsonSerializerOptions o)
+ public override void Write(Utf8JsonWriter w, S v, JsonSerializerOptions o)
{
- if (AsString(o)) w.WriteStringValue(v.ToString(CultureInfo.InvariantCulture)); else w.WriteNumberValue(v);
+ if (AsString(o)) w.WriteStringValue(Spelling(v)); else WriteNumber(w, v);
}
- public override int Read(ref Utf8JsonReader r, JsonSerializerOptions o)
+ public override S Read(ref Utf8JsonReader r, JsonSerializerOptions o)
{
if (r.TokenType == JsonTokenType.Number)
{
- if (!r.TryGetInt32(out var v)) throw new JsonException("The number is not an int.");
+ if (!TryToken(ref r, out var v)) throw new JsonException($"The number is not a {typeof(S).Name}.");
return v;
}
@@ -199,71 +200,35 @@ public static class CultMathJson
throw new JsonException($"Expected an integer, found {r.TokenType}.");
}
- public override string Text(int v, JsonSerializerOptions o) => v.ToString(CultureInfo.InvariantCulture);
-
- public override int Parse(string text, JsonSerializerOptions o) =>
- int.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var v) ? v : throw new JsonException(Invalid("An int", text));
+ public override S Parse(string text, JsonSerializerOptions o) =>
+ TryText(text, out var v) ? v : throw new JsonException($"A {typeof(S).Name} cannot be read from '{text}'.");
}
- private sealed class UIntScalar : Scalar
+ private sealed class IntScalar : IntegerScalar
+ {
+ public static readonly IntScalar Instance = new();
+ protected override bool TryToken(ref Utf8JsonReader r, out int v) => r.TryGetInt32(out v);
+ protected override bool TryText(string s, out int v) => int.TryParse(s, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out v);
+ protected override void WriteNumber(Utf8JsonWriter w, int v) => w.WriteNumberValue(v);
+ public override string Spelling(int v) => v.ToString(CultureInfo.InvariantCulture);
+ }
+
+ private sealed class UIntScalar : IntegerScalar
{
public static readonly UIntScalar Instance = new();
-
- public override void Write(Utf8JsonWriter w, uint v, JsonSerializerOptions o)
- {
- if (AsString(o)) w.WriteStringValue(v.ToString(CultureInfo.InvariantCulture)); else w.WriteNumberValue(v);
- }
-
- public override uint Read(ref Utf8JsonReader r, JsonSerializerOptions o)
- {
- if (r.TokenType == JsonTokenType.Number)
- {
- if (!r.TryGetUInt32(out var v)) throw new JsonException("The number is not a uint.");
- return v;
- }
-
- if (r.TokenType == JsonTokenType.String && FromString(o)) return Parse(r.GetString()!, o);
- throw new JsonException($"Expected an unsigned integer, found {r.TokenType}.");
- }
-
- public override string Text(uint v, JsonSerializerOptions o) => v.ToString(CultureInfo.InvariantCulture);
-
- public override uint Parse(string text, JsonSerializerOptions o) =>
- uint.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var v) ? v : throw new JsonException(Invalid("A uint", text));
+ protected override bool TryToken(ref Utf8JsonReader r, out uint v) => r.TryGetUInt32(out v);
+ protected override bool TryText(string s, out uint v) => uint.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out v);
+ protected override void WriteNumber(Utf8JsonWriter w, uint v) => w.WriteNumberValue(v);
+ public override string Spelling(uint v) => v.ToString(CultureInfo.InvariantCulture);
}
- private sealed class ByteScalar : Scalar
+ private sealed class ByteScalar : IntegerScalar
{
public static readonly ByteScalar Instance = new();
-
- public override byte[] Defaults(int count)
- {
- var defaults = new byte[count];
- if (count == 4) defaults[3] = 255; // Color32 alpha
- return defaults;
- }
-
- public override void Write(Utf8JsonWriter w, byte v, JsonSerializerOptions o)
- {
- if (AsString(o)) w.WriteStringValue(v.ToString(CultureInfo.InvariantCulture)); else w.WriteNumberValue(v);
- }
-
- public override byte Read(ref Utf8JsonReader r, JsonSerializerOptions o)
- {
- if (r.TokenType == JsonTokenType.Number)
- {
- if (!r.TryGetByte(out var v)) throw new JsonException("The number is not a byte.");
- return v;
- }
-
- if (r.TokenType == JsonTokenType.String && FromString(o)) return Parse(r.GetString()!, o);
- throw new JsonException($"Expected a byte, found {r.TokenType}.");
- }
-
- public override string Text(byte v, JsonSerializerOptions o) => v.ToString(CultureInfo.InvariantCulture);
-
- public override byte Parse(string text, JsonSerializerOptions o) =>
- byte.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var v) ? v : throw new JsonException(Invalid("A byte", text));
+ protected override bool TryToken(ref Utf8JsonReader r, out byte v) => r.TryGetByte(out v);
+ protected override bool TryText(string s, out byte v) => byte.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out v);
+ protected override void WriteNumber(Utf8JsonWriter w, byte v) => w.WriteNumberValue(v);
+ public override string Spelling(byte v) => v.ToString(CultureInfo.InvariantCulture);
}
private sealed class BoolScalar : Scalar
@@ -277,76 +242,115 @@ public static class CultMathJson
: r.TokenType == JsonTokenType.False ? false
: throw new JsonException($"Expected true or false, found {r.TokenType}.");
- public override string Text(bool v, JsonSerializerOptions o) => v ? "true" : "false";
+ public override string Spelling(bool v) => v ? "true" : "false";
public override bool Parse(string text, JsonSerializerOptions o) =>
- text == "true" ? true : text == "false" ? false : throw new JsonException(Invalid("A bool", text));
+ text == "true" ? true : text == "false" ? false : throw new JsonException($"A bool cannot be read from '{text}'.");
}
// The JSON structure over a flat component list: leaves consume one component each, in order.
- private abstract class Node
+ // Read fills `flat` (pre-loaded with `defaults`), so a missing property keeps its default.
+ private abstract class Node where S : unmanaged
{
public int Count { get; protected set; }
- public abstract void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o);
- public abstract void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o);
+ public abstract void Write(Utf8JsonWriter w, ReadOnlySpan flat, Scalar scalar, JsonSerializerOptions o);
+ public abstract void Read(ref Utf8JsonReader r, scoped Span flat, scoped ReadOnlySpan defaults, Scalar scalar, JsonSerializerOptions o);
}
- private sealed class Leaf : Node
+ private sealed class Leaf : Node where S : unmanaged
{
public Leaf() { Count = 1; }
- public override void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o) => scalar.Write(w, flat[offset], o);
+ public override void Write(Utf8JsonWriter w, ReadOnlySpan flat, Scalar scalar, JsonSerializerOptions o) => scalar.Write(w, flat[0], o);
- public override void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o) => flat[offset] = scalar.Read(ref r, o);
+ public override void Read(ref Utf8JsonReader r, scoped Span flat, scoped ReadOnlySpan defaults, Scalar scalar, JsonSerializerOptions o) => flat[0] = scalar.Read(ref r, o);
}
- private sealed class Fields : Node
+ private sealed class Fields : Node where S : unmanaged
{
private readonly string[] names;
+ private readonly byte[][] utf8Names;
+ private readonly JsonEncodedText[] encoded;
private readonly Node[] children;
private readonly int[] offsets;
public Fields(params (string Name, Node Child)[] fields)
{
names = new string[fields.Length];
+ utf8Names = new byte[fields.Length][];
+ encoded = new JsonEncodedText[fields.Length];
children = new Node[fields.Length];
offsets = new int[fields.Length];
for (var i = 0; i < fields.Length; i++)
{
names[i] = fields[i].Name;
+ utf8Names[i] = Encoding.UTF8.GetBytes(fields[i].Name);
+ encoded[i] = JsonEncodedText.Encode(fields[i].Name);
children[i] = fields[i].Child;
offsets[i] = Count;
Count += fields[i].Child.Count;
}
}
- public override void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o)
+ public override void Write(Utf8JsonWriter w, ReadOnlySpan flat, Scalar scalar, JsonSerializerOptions o)
{
w.WriteStartObject();
- for (var i = 0; i < names.Length; i++)
+ for (var i = 0; i < children.Length; i++)
{
- w.WritePropertyName(names[i]);
- children[i].Write(w, flat, offset + offsets[i], scalar, o);
+ w.WritePropertyName(encoded[i]);
+ children[i].Write(w, flat.Slice(offsets[i], children[i].Count), scalar, o);
}
w.WriteEndObject();
}
- public override void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o)
+ public override void Read(ref Utf8JsonReader r, scoped Span flat, scoped ReadOnlySpan defaults, Scalar scalar, JsonSerializerOptions o)
{
if (r.TokenType != JsonTokenType.StartObject) throw new JsonException($"Expected a JSON object, found {r.TokenType}.");
- var comparison = o.PropertyNameCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+ var ignoreCase = o.PropertyNameCaseInsensitive;
+ var seen = 0;
while (r.Read() && r.TokenType == JsonTokenType.PropertyName)
{
- var name = r.GetString()!;
- var index = Array.FindIndex(names, n => string.Equals(n, name, comparison));
+ var index = IndexOf(ref r, ignoreCase);
r.Read();
- if (index < 0) r.Skip(); else children[index].Read(ref r, flat, offset + offsets[index], scalar, o);
+ if (index < 0)
+ {
+ if (o.UnmappedMemberHandling == JsonUnmappedMemberHandling.Disallow)
+ throw new JsonException("The JSON object has a property that is not a component.");
+ r.Skip();
+ continue;
+ }
+
+ var slice = flat.Slice(offsets[index], children[index].Count);
+ var bit = 1 << index;
+ if ((seen & bit) != 0)
+ {
+ if (!DuplicatesAllowed(o)) throw new JsonException($"The property '{names[index]}' appears more than once.");
+ defaults.Slice(offsets[index], slice.Length).CopyTo(slice);
+ }
+
+ seen |= bit;
+ children[index].Read(ref r, slice, defaults.Slice(offsets[index], slice.Length), scalar, o);
}
}
+
+ private int IndexOf(ref Utf8JsonReader r, bool ignoreCase)
+ {
+ if (!ignoreCase)
+ {
+ for (var i = 0; i < utf8Names.Length; i++)
+ if (r.ValueTextEquals(utf8Names[i])) return i;
+ return -1;
+ }
+
+ var name = r.GetString()!;
+ for (var i = 0; i < names.Length; i++)
+ if (string.Equals(names[i], name, StringComparison.OrdinalIgnoreCase)) return i;
+ return -1;
+ }
}
- private sealed class Rows : Node
+ private sealed class Rows : Node where S : unmanaged
{
private readonly Node row;
private readonly int rows;
@@ -358,81 +362,98 @@ public static class CultMathJson
Count = rows * row.Count;
}
- public override void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o)
+ public override void Write(Utf8JsonWriter w, ReadOnlySpan flat, Scalar scalar, JsonSerializerOptions o)
{
w.WriteStartArray();
- for (var i = 0; i < rows; i++) row.Write(w, flat, offset + (i * row.Count), scalar, o);
+ for (var i = 0; i < rows; i++) row.Write(w, flat.Slice(i * row.Count, row.Count), scalar, o);
w.WriteEndArray();
}
- public override void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o)
+ public override void Read(ref Utf8JsonReader r, scoped Span flat, scoped ReadOnlySpan defaults, Scalar scalar, JsonSerializerOptions o)
{
if (r.TokenType != JsonTokenType.StartArray) throw new JsonException($"Expected a JSON array of {rows} rows, found {r.TokenType}.");
for (var i = 0; i < rows; i++)
{
if (!r.Read() || r.TokenType == JsonTokenType.EndArray) throw new JsonException($"Expected {rows} rows.");
- row.Read(ref r, flat, offset + (i * row.Count), scalar, o);
+ row.Read(ref r, flat.Slice(i * row.Count, row.Count), defaults.Slice(i * row.Count, row.Count), scalar, o);
}
if (!r.Read() || r.TokenType != JsonTokenType.EndArray) throw new JsonException($"Expected {rows} rows.");
}
}
- private sealed class Shape : JsonConverter
+ private delegate T Make(ReadOnlySpan components) where S : unmanaged;
+ private delegate void Flatten(T value, Span components) where S : unmanaged;
+
+ private sealed class Shape : JsonConverter where S : unmanaged
{
private readonly Scalar scalar;
private readonly Node root;
- private readonly Func make;
- private readonly Action flatten;
+ private readonly S[] defaults;
+ private readonly Make make;
+ private readonly Flatten flatten;
- public Shape(Scalar scalar, Node root, Func make, Action flatten)
+ public Shape(Scalar scalar, Node root, Make make, Flatten flatten, S[]? initial)
{
this.scalar = scalar;
this.root = root;
this.make = make;
this.flatten = flatten;
+ defaults = initial ?? new S[root.Count];
}
public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
- var flat = scalar.Defaults(root.Count);
- root.Read(ref reader, flat, 0, scalar, options);
+ Span flat = stackalloc S[root.Count];
+ defaults.CopyTo(flat);
+ root.Read(ref reader, flat, defaults, scalar, options);
return make(flat);
}
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
{
- var flat = new S[root.Count];
+ Span flat = stackalloc S[root.Count];
flatten(value, flat);
- root.Write(writer, flat, 0, scalar, options);
+ root.Write(writer, flat, scalar, options);
}
public override T ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
- var parts = (reader.GetString() ?? throw new JsonException("A dictionary key cannot be null.")).Split(',');
+ var text = reader.GetString() ?? throw new JsonException("A dictionary key cannot be null.");
+ var parts = text.Split(',');
if (parts.Length != root.Count) throw new JsonException($"A {typeof(T).Name} key has {root.Count} comma-separated components.");
- var flat = new S[root.Count];
- for (var i = 0; i < flat.Length; i++) flat[i] = scalar.Parse(parts[i], options);
- return make(flat);
+ Span flat = stackalloc S[root.Count];
+ for (var i = 0; i < parts.Length; i++) flat[i] = scalar.ParseKey(parts[i], options);
+ var value = make(flat);
+ if (Spell(flat) != text) throw new JsonException($"'{text}' is not the canonical spelling of a {typeof(T).Name} key.");
+ return value;
}
public override void WriteAsPropertyName(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
{
- var flat = new S[root.Count];
+ Span flat = stackalloc S[root.Count];
flatten(value, flat);
- writer.WritePropertyName(string.Join(",", Array.ConvertAll(flat, c => scalar.Text(c, options))));
+ for (var i = 0; i < flat.Length; i++) scalar.Text(flat[i], options);
+ writer.WritePropertyName(Spell(flat));
+ }
+
+ private string Spell(ReadOnlySpan flat)
+ {
+ var parts = new string[flat.Length];
+ for (var i = 0; i < parts.Length; i++) parts[i] = scalar.Spelling(flat[i]);
+ return string.Join(",", parts);
}
}
- private static Node Val() => new Leaf();
+ private static Node Val() where S : unmanaged => new Leaf();
- private static Node Xy() => new Fields(("x", Val()), ("y", Val()));
- private static Node Xyz() => new Fields(("x", Val()), ("y", Val()), ("z", Val()));
- private static Node Xyzw() => new Fields(("x", Val()), ("y", Val()), ("z", Val()), ("w", Val()));
+ private static Node Xy() where S : unmanaged => new Fields(("x", Val()), ("y", Val()));
+ private static Node Xyz() where S : unmanaged => new Fields(("x", Val()), ("y", Val()), ("z", Val()));
+ private static Node Xyzw() where S : unmanaged => new Fields(("x", Val()), ("y", Val()), ("z", Val()), ("w", Val()));
private static KeyValuePair Add(
- Scalar scalar, Node root, Func make, Action flatten) =>
- new(typeof(T), new Shape(scalar, root, make, flatten));
+ Scalar scalar, Node root, Make make, Flatten flatten, S[]? initial = null) where S : unmanaged =>
+ new(typeof(T), new Shape(scalar, root, make, flatten, initial));
private static readonly Dictionary Converters = Build();
@@ -455,7 +476,8 @@ public static class CultMathJson
Add(BoolScalar.Instance, Xyzw(), a => new bool4(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; a[3] = v.w; }),
Add(ByteScalar.Instance,
new Fields(("r", Val()), ("g", Val()), ("b", Val()), ("a", Val())),
- a => new Color32(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.r; a[1] = v.g; a[2] = v.b; a[3] = v.a; }),
+ a => new Color32(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.r; a[1] = v.g; a[2] = v.b; a[3] = v.a; },
+ initial: new byte[] { 0, 0, 0, 255 }),
Add(UIntScalar.Instance, new Fields(("state", Val())),
a => new Random { state = a[0] }, (v, a) => a[0] = v.state),
diff --git a/src/GameCult.Caching.MessagePack/README.md b/src/GameCult.Caching.MessagePack/README.md
index 27e3ea57..fdf91ab2 100644
--- a/src/GameCult.Caching.MessagePack/README.md
+++ b/src/GameCult.Caching.MessagePack/README.md
@@ -65,14 +65,22 @@ in the default chain) and one JSON shape (`CultMathJson`, added to a consumer's
options with `new JsonSerializerOptions().AddCultMathConverters()`; CultLib owns
no JSON options of its own). MessagePack decoding skips extra elements, leaves
missing ones at zero (`Color32` alpha at 255) and refuses nil. JSON reading
-skips unknown properties, leaves missing ones at zero, honours
-`PropertyNameCaseInsensitive`, and throws `JsonException` for a wrong token or an
-out-of-range number (a float beyond `float.MaxValue` is refused, not read as
-infinity). Numbers follow `JsonSerializerOptions.NumberHandling`: NaN and the
-infinities need `AllowNamedFloatingPointLiterals` (`"NaN"`, `"Infinity"`,
-`"-Infinity"`). Every type is also a dictionary key: its flattened components
-joined by commas, invariant culture (`"1,2"`, `"true,false"`, matrices row by
-row, `rect` as `"minX,minY,maxX,maxY"`). A consumer resolver or converter
+skips unknown properties (`UnmappedMemberHandling.Disallow` refuses them), leaves
+missing ones at zero, honours `PropertyNameCaseInsensitive`, lets a repeated
+property replace the whole earlier value (refused when `AllowDuplicateProperties`
+is false), and throws `JsonException` for a wrong token or an out-of-range number.
+Numbers follow `JsonSerializerOptions.NumberHandling` as System.Text.Json's own
+float does: `"NaN"`, `"Infinity"` and `"-Infinity"` are written under
+`AllowNamedFloatingPointLiterals` or `WriteAsString` and read under
+`AllowNamedFloatingPointLiterals` or `AllowReadingFromString`; quoted numbers read
+under `AllowReadingFromString` with no surrounding whitespace.
+Every type is also a dictionary key: its flattened components joined by commas,
+invariant culture (`"1,2"`, `"true,false"`, matrices row by row, `rect` as
+`"minX,minY,maxX,maxY"`), and only that exact spelling reads back. Two intended
+divergences from System.Text.Json: a bare number beyond a component's range
+(`1e40` as a float) is refused where System.Text.Json reads infinity, and a
+refusal on write is a `JsonException` where System.Text.Json throws
+`ArgumentException`. A consumer resolver or converter
registered ahead of these wins.
| Type | MessagePack | JSON |
diff --git a/tests/GameCult.Caching.Tests/CultMathJsonTests.cs b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs
index bf8444f9..87e5b742 100644
--- a/tests/GameCult.Caching.Tests/CultMathJsonTests.cs
+++ b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -225,6 +226,190 @@ namespace GameCult.Caching.Tests
return JsonDocument.Parse(json).RootElement.EnumerateObject().Single().Name;
}
+ private const JsonNumberHandling ReadStrings = JsonNumberHandling.AllowReadingFromString;
+ private const JsonNumberHandling WriteStrings = JsonNumberHandling.WriteAsString;
+
+ // Every row was measured against System.Text.Json's own float.
+ [Test]
+ public void NamedLiterals_FollowTheSameFlagsAsSystemTextJsonFloat()
+ {
+ foreach (var handling in new[] { Named, ReadStrings, ReadStrings | Named })
+ {
+ JsonSerializer.Deserialize("""{"x":"NaN","y":"-Infinity"}""", With(handling))!.y.Should().Be(float.NegativeInfinity, handling.ToString());
+ }
+
+ foreach (var handling in new[] { JsonNumberHandling.Strict, WriteStrings })
+ {
+ Action read = () => JsonSerializer.Deserialize("""{"x":"NaN"}""", With(handling));
+ read.Should().Throw(handling.ToString());
+ }
+
+ foreach (var handling in new[] { Named, WriteStrings, WriteStrings | Named })
+ {
+ JsonSerializer.Serialize(new float2(float.NaN, float.PositiveInfinity), With(handling))
+ .Should().Be("""{"x":"NaN","y":"Infinity"}""", handling.ToString());
+ }
+
+ foreach (var handling in new[] { JsonNumberHandling.Strict, ReadStrings })
+ {
+ Action write = () => JsonSerializer.Serialize(new float2(float.NaN, 0f), With(handling));
+ write.Should().Throw(handling.ToString());
+ }
+
+ JsonSerializer.Serialize(new float2(1f, float.NaN), With(WriteStrings)).Should().Be("""{"x":"1","y":"NaN"}""");
+ JsonSerializer.Deserialize("""{"z":"Infinity"}""", With(ReadStrings))!.z.Should().Be(double.PositiveInfinity);
+ JsonSerializer.Serialize(new double2(double.NegativeInfinity, 2d), With(WriteStrings)).Should().Be("""{"x":"-Infinity","y":"2"}""");
+ }
+
+ [Test]
+ public void QuotedNumbers_RefuseWhitespace()
+ {
+ var options = With(ReadStrings);
+ foreach (var json in new[] { """{"x":" 1.5"}""", """{"x":"1.5 "}""", """{"x":"\t1"}""" })
+ {
+ Action floatRead = () => JsonSerializer.Deserialize(json, options);
+ floatRead.Should().Throw(json);
+ }
+
+ Action doubleRead = () => JsonSerializer.Deserialize("""{"y":" 2"}""", options);
+ doubleRead.Should().Throw();
+ Action intRead = () => JsonSerializer.Deserialize("""{"x":" 1"}""", options);
+ intRead.Should().Throw();
+ Action padded = () => JsonSerializer.Deserialize("""{"x":" NaN"}""", With(ReadStrings | Named));
+ padded.Should().Throw();
+ }
+
+ [Test]
+ public void UnknownProperties_AreSkippedWhateverTheyContain()
+ {
+ var expected = new float3(1f, 2f, 3f);
+ foreach (var json in new[]
+ {
+ """{"u":{"a":[1,{"b":2}]},"x":1,"y":2,"z":3}""",
+ """{"x":1,"u":[[1],[2,[3]]],"y":2,"v":{"a":{}},"z":3}""",
+ """{"x":1,"y":2,"z":3,"u":{"a":{"b":[]}}}""",
+ """{"x":1,"y":2,"z":3,"u":[{"a":[{}]}]}""",
+ })
+ {
+ Parse(json).Should().Be(expected, json);
+ }
+
+ Parse("""{"min":{"q":[{}],"x":1,"y":2},"skip":{"a":[1]},"max":{"x":3,"y":4,"q":{"a":[]}}}""")
+ .Should().Be(new rect(1f, 2f, 3f, 4f));
+ Parse("""[{"x":1,"u":[1],"y":2},{"y":4,"u":{"a":1},"x":3}]""").Should().Be(new float2x2(1f, 2f, 3f, 4f));
+ }
+
+ [Test]
+ public void UnmappedMemberHandling_Disallow_RefusesUnknownProperties()
+ {
+ var options = new JsonSerializerOptions { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }.AddCultMathConverters();
+ Action flat = () => JsonSerializer.Deserialize("""{"x":1,"z":3}""", options);
+ flat.Should().Throw();
+ Action nested = () => JsonSerializer.Deserialize("""{"min":{"x":1,"q":1},"max":{}}""", options);
+ nested.Should().Throw();
+ JsonSerializer.Deserialize("""{"x":1,"y":2}""", options).Should().Be(new float2(1f, 2f));
+ }
+
+ [Test]
+ public void DuplicateProperties_ReplaceTheWholeValue()
+ {
+ Parse("""{"x":1,"x":2,"y":5}""").Should().Be(new float2(2f, 5f));
+ Parse("""{"min":{"x":1,"y":2},"min":{"x":3},"max":{"x":4,"y":5}}""").Should().Be(new rect(3f, 0f, 4f, 5f));
+ Parse("""{"a":9,"a":1}""").Should().Be(new Color32(0, 0, 0, 1));
+ Parse("""{"r":3,"r":4}""").Should().Be(new Color32(4, 0, 0, 255));
+ Parse("""{"nearest":{"x":1,"y":2},"nearest":{"z":3},"id":1}""")
+ .Should().Be(new CultCellular(new float4(0f, 0f, 3f, 0f), float4.zero, 1f));
+ }
+
+ [Test]
+ public void AllowDuplicatePropertiesFalse_RefusesRepeats()
+ {
+ var property = typeof(JsonSerializerOptions).GetProperty("AllowDuplicateProperties");
+ Assert.That(property, Is.Not.Null, "the test runtime's System.Text.Json has AllowDuplicateProperties");
+ var options = new JsonSerializerOptions().AddCultMathConverters();
+ property!.SetValue(options, false);
+
+ Action flat = () => JsonSerializer.Deserialize("""{"x":1,"x":2}""", options);
+ flat.Should().Throw();
+ Action nested = () => JsonSerializer.Deserialize("""{"min":{"x":1,"x":2}}""", options);
+ nested.Should().Throw();
+ JsonSerializer.Deserialize("""{"x":1,"y":2}""", options).Should().Be(new float2(1f, 2f));
+ }
+
+ [Test]
+ public void DictionaryKeys_ReadOnlyTheCanonicalSpelling()
+ {
+ foreach (var key in new[] { "1.0,2", "+1,2", " 1,2", "1, 2", "01,2", "1,2 ", "1e0,2", "1,2,", ",1" })
+ {
+ Action read = () => Parse>($$"""{"{{key}}":1}""");
+ read.Should().Throw(key);
+ }
+
+ foreach (var key in new[] { "01,2", "+1,2", "1,-0" })
+ {
+ Action read = () => Parse>($$"""{"{{key}}":1}""");
+ read.Should().Throw(key);
+ }
+
+ Action bools = () => Parse>("""{"True,false":1}""");
+ bools.Should().Throw();
+ Parse>("""{"1,2":1}""").Should().ContainKey(new float2(1f, 2f));
+ }
+
+ [Test]
+ public void NegativeZero_IsADistinctKey()
+ {
+ var negative = new float2(-0f, 0f);
+ KeyOf(negative).Should().Be("-0,0");
+ KeyOf(float2.zero).Should().Be("0,0");
+ KeyOf(new double2(0d, -0d)).Should().Be("0,-0");
+
+ var parsed = Parse>("""{"-0,0":1}""").Keys.Single();
+ float.IsNegative(parsed.x).Should().BeTrue();
+ var positive = Parse>("""{"0,0":1}""").Keys.Single();
+ float.IsNegative(positive.x).Should().BeFalse();
+ }
+
+ [Test]
+ public void Culture_DoesNotChangeKeysOrValues()
+ {
+ var german = (CultureInfo)CultureInfo.InvariantCulture.Clone();
+ german.NumberFormat.NumberDecimalSeparator = ",";
+ german.NumberFormat.NumberGroupSeparator = ".";
+ german.NumberFormat.NegativeSign = "−";
+ german.NumberFormat.PositiveSign = "➕";
+ var swedish = (CultureInfo)CultureInfo.InvariantCulture.Clone();
+ swedish.NumberFormat.NegativeSign = "−";
+ swedish.NumberFormat.NumberDecimalSeparator = ",";
+
+ var original = CultureInfo.CurrentCulture;
+ try
+ {
+ foreach (var culture in new[] { german, swedish })
+ {
+ CultureInfo.CurrentCulture = culture;
+ var options = With(ReadStrings | WriteStrings | Named);
+
+ KeyOf(new float2(-1.5f, 2.25f)).Should().Be("-1.5,2.25");
+ KeyOf(new double3(-1e-7, 1234.5, -0d)).Should().Be("-1E-07,1234.5,-0");
+ KeyOf(new int2(-3, 4)).Should().Be("-3,4");
+ KeyOf(new float2(float.NaN, float.NegativeInfinity), Named).Should().Be("NaN,-Infinity");
+ Parse>("""{"-1.5,2.25":1}""").Keys.Single().Should().Be(new float2(-1.5f, 2.25f));
+ Parse>("""{"-3,4":1}""").Keys.Single().Should().Be(new int2(-3, 4));
+
+ JsonSerializer.Serialize(new float2(-1.5f, 2.25f), options).Should().Be("""{"x":"-1.5","y":"2.25"}""");
+ JsonSerializer.Deserialize("""{"x":"-1.5","y":"2.25"}""", options).Should().Be(new float2(-1.5f, 2.25f));
+ JsonSerializer.Serialize(new int2(-3, 4), options).Should().Be("""{"x":"-3","y":"4"}""");
+ JsonSerializer.Deserialize("""{"x":"-3","y":"4"}""", options).Should().Be(new int2(-3, 4));
+ Json(new float2(-1.5f, 2.25f)).Should().Be("""{"x":-1.5,"y":2.25}""");
+ }
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = original;
+ }
+ }
+
private static void RoundTrip(T value) => Parse(Json(value)).Should().Be(value);
}
}