Bring the CultMath JSON converters to System.Text.Json parity and remove their allocations

Named literals now follow AllowReadingFromString and WriteAsString as
System.Text.Json's float does; quoted numbers refuse whitespace;
UnmappedMemberHandling.Disallow and AllowDuplicateProperties=false are honoured
and a repeated property replaces the whole value; dictionary keys read only the
canonical spelling. Components live in stack spans, property names are
pre-encoded, and there is no per-property closure, so a float3 read allocates
nothing (424 bytes before). Tests pin unknown nested values, de-DE and sv-SE
style cultures, and a distinct negative-zero key.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 18:31:16 +02:00
commit 4e5327c61c
3 changed files with 424 additions and 209 deletions

View file

@ -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: <c>PropertyNamingPolicy</c> does not rename them, and
/// <c>PropertyNameCaseInsensitive</c> makes reading case-insensitive.
/// <para>
/// Reading skips unknown properties and leaves missing ones at zero (a missing <c>Color32.a</c>
/// is 255). A wrong token, a number outside its component type (including a float beyond
/// <see cref="float.MaxValue"/>, which is refused rather than becoming infinity), or a matrix
/// that is not an array of exactly its row count throws <see cref="JsonException"/>.
/// Reading skips unknown properties (<c>UnmappedMemberHandling.Disallow</c> refuses them) and
/// leaves missing ones at zero (a missing <c>Color32.a</c> is 255). A repeated property replaces
/// the whole earlier value, never merging component-wise, and is refused when
/// <c>AllowDuplicateProperties</c> 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
/// <see cref="JsonException"/>.
/// </para>
/// <para>
/// Numbers follow <see cref="JsonSerializerOptions.NumberHandling"/> as System.Text.Json's own
/// float does: NaN and the infinities need <c>AllowNamedFloatingPointLiterals</c> (written and
/// read as <c>"NaN"</c>, <c>"Infinity"</c>, <c>"-Infinity"</c>) and are otherwise refused with
/// <see cref="JsonException"/>; <c>AllowReadingFromString</c> reads quoted numbers;
/// <c>WriteAsString</c> writes quoted numbers.
/// float does. NaN and the infinities are the strings <c>"NaN"</c>, <c>"Infinity"</c>,
/// <c>"-Infinity"</c>: written when <c>AllowNamedFloatingPointLiterals</c> or
/// <c>WriteAsString</c> is set (otherwise <see cref="JsonException"/>), read when
/// <c>AllowNamedFloatingPointLiterals</c> or <c>AllowReadingFromString</c> is set.
/// <c>AllowReadingFromString</c> reads quoted numbers without surrounding whitespace, and
/// <c>WriteAsString</c> writes quoted numbers. Two intended divergences: a bare number beyond
/// the component's range (<c>1e40</c> as a float) is refused where System.Text.Json reads
/// infinity, and refusals on write are <see cref="JsonException"/> where it throws
/// <see cref="ArgumentException"/>.
/// </para>
/// <para>
/// Every type is also a dictionary key: the flattened components joined by commas in
/// declaration order, invariant culture, shortest round-trip floats (<c>"1,2"</c>,
/// <c>"true,false"</c>, matrices row by row, <c>rect</c> as <c>"minX,minY,maxX,maxY"</c>).
/// Only that exact spelling reads back, so two JSON keys never collapse into one dictionary key.
/// </para>
/// Register with <see cref="AddCultMathConverters"/>; JSON options are the consumer's own.
/// </summary>
@ -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<S>
private abstract class Scalar<S> 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);
/// <summary>The canonical key spelling, whatever the options allow.</summary>
public abstract string Spelling(S value);
/// <summary>The key spelling, refusing what the options would refuse to write.</summary>
public virtual string Text(S value, JsonSerializerOptions o) => Spelling(value);
public abstract S Parse(string text, JsonSerializerOptions o);
/// <summary>Reads a key component; keys are always text, so AllowReadingFromString does not apply.</summary>
public virtual S ParseKey(string text, JsonSerializerOptions o) => Parse(text, o);
}
private sealed class FloatScalar : Scalar<float>
private abstract class FloatingScalar<S> : Scalar<S> 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<float>
{
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<double>
private sealed class DoubleScalar : FloatingScalar<double>
{
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<int>
private abstract class IntegerScalar<S> : Scalar<S> 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<uint>
private sealed class IntScalar : IntegerScalar<int>
{
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<uint>
{
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<byte>
private sealed class ByteScalar : IntegerScalar<byte>
{
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<bool>
@ -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<S>
// Read fills `flat` (pre-loaded with `defaults`), so a missing property keeps its default.
private abstract class Node<S> where S : unmanaged
{
public int Count { get; protected set; }
public abstract void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar<S> scalar, JsonSerializerOptions o);
public abstract void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar<S> scalar, JsonSerializerOptions o);
public abstract void Write(Utf8JsonWriter w, ReadOnlySpan<S> flat, Scalar<S> scalar, JsonSerializerOptions o);
public abstract void Read(ref Utf8JsonReader r, scoped Span<S> flat, scoped ReadOnlySpan<S> defaults, Scalar<S> scalar, JsonSerializerOptions o);
}
private sealed class Leaf<S> : Node<S>
private sealed class Leaf<S> : Node<S> where S : unmanaged
{
public Leaf() { Count = 1; }
public override void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar<S> scalar, JsonSerializerOptions o) => scalar.Write(w, flat[offset], o);
public override void Write(Utf8JsonWriter w, ReadOnlySpan<S> flat, Scalar<S> scalar, JsonSerializerOptions o) => scalar.Write(w, flat[0], o);
public override void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar<S> scalar, JsonSerializerOptions o) => flat[offset] = scalar.Read(ref r, o);
public override void Read(ref Utf8JsonReader r, scoped Span<S> flat, scoped ReadOnlySpan<S> defaults, Scalar<S> scalar, JsonSerializerOptions o) => flat[0] = scalar.Read(ref r, o);
}
private sealed class Fields<S> : Node<S>
private sealed class Fields<S> : Node<S> where S : unmanaged
{
private readonly string[] names;
private readonly byte[][] utf8Names;
private readonly JsonEncodedText[] encoded;
private readonly Node<S>[] children;
private readonly int[] offsets;
public Fields(params (string Name, Node<S> Child)[] fields)
{
names = new string[fields.Length];
utf8Names = new byte[fields.Length][];
encoded = new JsonEncodedText[fields.Length];
children = new Node<S>[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<S> scalar, JsonSerializerOptions o)
public override void Write(Utf8JsonWriter w, ReadOnlySpan<S> flat, Scalar<S> 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<S> scalar, JsonSerializerOptions o)
public override void Read(ref Utf8JsonReader r, scoped Span<S> flat, scoped ReadOnlySpan<S> defaults, Scalar<S> 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<S> : Node<S>
private sealed class Rows<S> : Node<S> where S : unmanaged
{
private readonly Node<S> 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<S> scalar, JsonSerializerOptions o)
public override void Write(Utf8JsonWriter w, ReadOnlySpan<S> flat, Scalar<S> 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<S> scalar, JsonSerializerOptions o)
public override void Read(ref Utf8JsonReader r, scoped Span<S> flat, scoped ReadOnlySpan<S> defaults, Scalar<S> 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<T, S> : JsonConverter<T>
private delegate T Make<T, S>(ReadOnlySpan<S> components) where S : unmanaged;
private delegate void Flatten<T, S>(T value, Span<S> components) where S : unmanaged;
private sealed class Shape<T, S> : JsonConverter<T> where S : unmanaged
{
private readonly Scalar<S> scalar;
private readonly Node<S> root;
private readonly Func<S[], T> make;
private readonly Action<T, S[]> flatten;
private readonly S[] defaults;
private readonly Make<T, S> make;
private readonly Flatten<T, S> flatten;
public Shape(Scalar<S> scalar, Node<S> root, Func<S[], T> make, Action<T, S[]> flatten)
public Shape(Scalar<S> scalar, Node<S> root, Make<T, S> make, Flatten<T, S> 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<S> 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<S> 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<S> 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<S> 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<S> 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<S> Val<S>() => new Leaf<S>();
private static Node<S> Val<S>() where S : unmanaged => new Leaf<S>();
private static Node<S> Xy<S>() => new Fields<S>(("x", Val<S>()), ("y", Val<S>()));
private static Node<S> Xyz<S>() => new Fields<S>(("x", Val<S>()), ("y", Val<S>()), ("z", Val<S>()));
private static Node<S> Xyzw<S>() => new Fields<S>(("x", Val<S>()), ("y", Val<S>()), ("z", Val<S>()), ("w", Val<S>()));
private static Node<S> Xy<S>() where S : unmanaged => new Fields<S>(("x", Val<S>()), ("y", Val<S>()));
private static Node<S> Xyz<S>() where S : unmanaged => new Fields<S>(("x", Val<S>()), ("y", Val<S>()), ("z", Val<S>()));
private static Node<S> Xyzw<S>() where S : unmanaged => new Fields<S>(("x", Val<S>()), ("y", Val<S>()), ("z", Val<S>()), ("w", Val<S>()));
private static KeyValuePair<Type, JsonConverter> Add<T, S>(
Scalar<S> scalar, Node<S> root, Func<S[], T> make, Action<T, S[]> flatten) =>
new(typeof(T), new Shape<T, S>(scalar, root, make, flatten));
Scalar<S> scalar, Node<S> root, Make<T, S> make, Flatten<T, S> flatten, S[]? initial = null) where S : unmanaged =>
new(typeof(T), new Shape<T, S>(scalar, root, make, flatten, initial));
private static readonly Dictionary<Type, JsonConverter> Converters = Build();
@ -455,7 +476,8 @@ public static class CultMathJson
Add<bool4, bool>(BoolScalar.Instance, Xyzw<bool>(), 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<Color32, byte>(ByteScalar.Instance,
new Fields<byte>(("r", Val<byte>()), ("g", Val<byte>()), ("b", Val<byte>()), ("a", Val<byte>())),
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<Random, uint>(UIntScalar.Instance, new Fields<uint>(("state", Val<uint>())),
a => new Random { state = a[0] }, (v, a) => a[0] = v.state),

View file

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

View file

@ -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<float2>("""{"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<float2>("""{"x":"NaN"}""", With(handling));
read.Should().Throw<JsonException>(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<JsonException>(handling.ToString());
}
JsonSerializer.Serialize(new float2(1f, float.NaN), With(WriteStrings)).Should().Be("""{"x":"1","y":"NaN"}""");
JsonSerializer.Deserialize<double3>("""{"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<float2>(json, options);
floatRead.Should().Throw<JsonException>(json);
}
Action doubleRead = () => JsonSerializer.Deserialize<double2>("""{"y":" 2"}""", options);
doubleRead.Should().Throw<JsonException>();
Action intRead = () => JsonSerializer.Deserialize<int2>("""{"x":" 1"}""", options);
intRead.Should().Throw<JsonException>();
Action padded = () => JsonSerializer.Deserialize<float2>("""{"x":" NaN"}""", With(ReadStrings | Named));
padded.Should().Throw<JsonException>();
}
[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<float3>(json).Should().Be(expected, json);
}
Parse<rect>("""{"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<float2x2>("""[{"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<float2>("""{"x":1,"z":3}""", options);
flat.Should().Throw<JsonException>();
Action nested = () => JsonSerializer.Deserialize<rect>("""{"min":{"x":1,"q":1},"max":{}}""", options);
nested.Should().Throw<JsonException>();
JsonSerializer.Deserialize<float2>("""{"x":1,"y":2}""", options).Should().Be(new float2(1f, 2f));
}
[Test]
public void DuplicateProperties_ReplaceTheWholeValue()
{
Parse<float2>("""{"x":1,"x":2,"y":5}""").Should().Be(new float2(2f, 5f));
Parse<rect>("""{"min":{"x":1,"y":2},"min":{"x":3},"max":{"x":4,"y":5}}""").Should().Be(new rect(3f, 0f, 4f, 5f));
Parse<Color32>("""{"a":9,"a":1}""").Should().Be(new Color32(0, 0, 0, 1));
Parse<Color32>("""{"r":3,"r":4}""").Should().Be(new Color32(4, 0, 0, 255));
Parse<CultCellular>("""{"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<float2>("""{"x":1,"x":2}""", options);
flat.Should().Throw<JsonException>();
Action nested = () => JsonSerializer.Deserialize<rect>("""{"min":{"x":1,"x":2}}""", options);
nested.Should().Throw<JsonException>();
JsonSerializer.Deserialize<float2>("""{"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<Dictionary<float2, int>>($$"""{"{{key}}":1}""");
read.Should().Throw<JsonException>(key);
}
foreach (var key in new[] { "01,2", "+1,2", "1,-0" })
{
Action read = () => Parse<Dictionary<int2, int>>($$"""{"{{key}}":1}""");
read.Should().Throw<JsonException>(key);
}
Action bools = () => Parse<Dictionary<bool2, int>>("""{"True,false":1}""");
bools.Should().Throw<JsonException>();
Parse<Dictionary<float2, int>>("""{"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<Dictionary<float2, int>>("""{"-0,0":1}""").Keys.Single();
float.IsNegative(parsed.x).Should().BeTrue();
var positive = Parse<Dictionary<float2, int>>("""{"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<Dictionary<float2, int>>("""{"-1.5,2.25":1}""").Keys.Single().Should().Be(new float2(-1.5f, 2.25f));
Parse<Dictionary<int2, int>>("""{"-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<float2>("""{"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<int2>("""{"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>(T value) => Parse<T>(Json(value)).Should().Be(value);
}
}