Add the canonical MessagePack encoding for every public CultMath value type
CultMathResolver formats float2/3/4, double2/3, int2/3/4, bool2/3/4, quaternion, Color32, Random, float2x2, float3x3, rect and CultCellular as component arrays, and sits in the default CultCache resolver chain so no consumer registers anything. It lives in GameCult.Caching.MessagePack, which already owns that chain; CultMath core stays dependency-free. Vector, rect and Aetheria-formatter byte shapes are pinned by golden tests, a reflection test fails when a public CultMath value type has no formatter, and decode skips extra elements and refuses nil. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
65311c9ad7
commit
139deb0e28
3 changed files with 381 additions and 1 deletions
|
|
@ -57,7 +57,7 @@ public static class CultDocumentMessagePackSerialization
|
|||
{
|
||||
return MessagePackSerializerOptions.Standard
|
||||
.WithResolver(CompositeResolver.Create(
|
||||
consumerResolvers.Append(CultDocumentResolver.Instance).Append(StandardResolver.Instance).ToArray()))
|
||||
consumerResolvers.Append(CultDocumentResolver.Instance).Append(CultMathResolver.Instance).Append(StandardResolver.Instance).ToArray()))
|
||||
.WithSecurity(CultMessagePackSecurity.Instance);
|
||||
}
|
||||
|
||||
|
|
|
|||
231
src/GameCult.Caching.MessagePack/CultMathResolver.cs
Normal file
231
src/GameCult.Caching.MessagePack/CultMathResolver.cs
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using CultMath;
|
||||
using MessagePack;
|
||||
using MessagePack.Formatters;
|
||||
using Random = CultMath.Random;
|
||||
|
||||
namespace GameCult.Caching.MessagePack;
|
||||
|
||||
/// <summary>
|
||||
/// The canonical MessagePack encoding of every public CultMath value type. Each type is an
|
||||
/// array of its components in declaration order: vectors and quaternions are flat scalar
|
||||
/// arrays, matrices are arrays of row vectors, and <c>rect</c> is <c>[min, max]</c>. Decoding
|
||||
/// skips trailing elements and leaves missing ones at zero; nil is refused. The resolver is
|
||||
/// part of the default <see cref="CultDocumentMessagePackSerialization"/> chain, so a consumer
|
||||
/// registers nothing.
|
||||
/// </summary>
|
||||
public sealed class CultMathResolver : IFormatterResolver
|
||||
{
|
||||
public static readonly CultMathResolver Instance = new();
|
||||
private CultMathResolver() { }
|
||||
|
||||
/// <summary>The CultMath types this resolver formats, for coverage checks.</summary>
|
||||
public static IReadOnlyCollection<Type> FormattedTypes => Formatters.Keys;
|
||||
|
||||
public IMessagePackFormatter<T>? GetFormatter<T>() => Cache<T>.Formatter;
|
||||
|
||||
private static class Cache<T>
|
||||
{
|
||||
public static readonly IMessagePackFormatter<T>? Formatter =
|
||||
Formatters.TryGetValue(typeof(T), out var formatter) ? (IMessagePackFormatter<T>)formatter : null;
|
||||
}
|
||||
|
||||
private delegate void WriteFn<T>(ref MessagePackWriter writer, T value);
|
||||
private delegate T ReadFn<T>(ref MessagePackReader reader);
|
||||
|
||||
private sealed class ArrayShape<T> : IMessagePackFormatter<T>
|
||||
{
|
||||
private readonly WriteFn<T> write;
|
||||
private readonly ReadFn<T> read;
|
||||
|
||||
public ArrayShape(WriteFn<T> write, ReadFn<T> read)
|
||||
{
|
||||
this.write = write;
|
||||
this.read = read;
|
||||
}
|
||||
|
||||
public void Serialize(ref MessagePackWriter writer, T value, MessagePackSerializerOptions options) => write(ref writer, value);
|
||||
|
||||
public T Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
|
||||
{
|
||||
if (reader.IsNil) throw new MessagePackSerializationException($"{typeof(T).Name} cannot be read from nil.");
|
||||
return read(ref reader);
|
||||
}
|
||||
}
|
||||
|
||||
private static KeyValuePair<Type, object> Shape<T>(WriteFn<T> write, ReadFn<T> read) =>
|
||||
new(typeof(T), new ArrayShape<T>(write, read));
|
||||
|
||||
// Each reader takes the first dst.Length elements and skips the rest.
|
||||
private static void Singles(ref MessagePackReader r, Span<float> dst)
|
||||
{
|
||||
for (int i = 0, n = r.ReadArrayHeader(); i < n; i++)
|
||||
if (i < dst.Length) dst[i] = r.ReadSingle(); else r.Skip();
|
||||
}
|
||||
|
||||
private static void Doubles(ref MessagePackReader r, Span<double> dst)
|
||||
{
|
||||
for (int i = 0, n = r.ReadArrayHeader(); i < n; i++)
|
||||
if (i < dst.Length) dst[i] = r.ReadDouble(); else r.Skip();
|
||||
}
|
||||
|
||||
private static void Ints(ref MessagePackReader r, Span<int> dst)
|
||||
{
|
||||
for (int i = 0, n = r.ReadArrayHeader(); i < n; i++)
|
||||
if (i < dst.Length) dst[i] = r.ReadInt32(); else r.Skip();
|
||||
}
|
||||
|
||||
private static void Bools(ref MessagePackReader r, Span<bool> dst)
|
||||
{
|
||||
for (int i = 0, n = r.ReadArrayHeader(); i < n; i++)
|
||||
if (i < dst.Length) dst[i] = r.ReadBoolean(); else r.Skip();
|
||||
}
|
||||
|
||||
private static void Bytes(ref MessagePackReader r, Span<byte> dst)
|
||||
{
|
||||
for (int i = 0, n = r.ReadArrayHeader(); i < n; i++)
|
||||
if (i < dst.Length) dst[i] = r.ReadByte(); else r.Skip();
|
||||
}
|
||||
|
||||
private static void Put(ref MessagePackWriter w, ReadOnlySpan<float> v)
|
||||
{
|
||||
w.WriteArrayHeader(v.Length);
|
||||
foreach (var e in v) w.Write(e);
|
||||
}
|
||||
|
||||
private static void Put(ref MessagePackWriter w, ReadOnlySpan<double> v)
|
||||
{
|
||||
w.WriteArrayHeader(v.Length);
|
||||
foreach (var e in v) w.Write(e);
|
||||
}
|
||||
|
||||
private static void Put(ref MessagePackWriter w, ReadOnlySpan<int> v)
|
||||
{
|
||||
w.WriteArrayHeader(v.Length);
|
||||
foreach (var e in v) w.Write(e);
|
||||
}
|
||||
|
||||
private static void Put(ref MessagePackWriter w, ReadOnlySpan<bool> v)
|
||||
{
|
||||
w.WriteArrayHeader(v.Length);
|
||||
foreach (var e in v) w.Write(e);
|
||||
}
|
||||
|
||||
private static void Put(ref MessagePackWriter w, ReadOnlySpan<byte> v)
|
||||
{
|
||||
w.WriteArrayHeader(v.Length);
|
||||
foreach (var e in v) w.Write(e);
|
||||
}
|
||||
|
||||
private static void PutFloat2(ref MessagePackWriter w, float2 v) => Put(ref w, stackalloc float[] { v.x, v.y });
|
||||
private static void PutFloat3(ref MessagePackWriter w, float3 v) => Put(ref w, stackalloc float[] { v.x, v.y, v.z });
|
||||
private static void PutFloat4(ref MessagePackWriter w, float4 v) => Put(ref w, stackalloc float[] { v.x, v.y, v.z, v.w });
|
||||
|
||||
private static float2 GetFloat2(ref MessagePackReader r)
|
||||
{
|
||||
Span<float> s = stackalloc float[2];
|
||||
Singles(ref r, s);
|
||||
return new float2(s[0], s[1]);
|
||||
}
|
||||
|
||||
private static float3 GetFloat3(ref MessagePackReader r)
|
||||
{
|
||||
Span<float> s = stackalloc float[3];
|
||||
Singles(ref r, s);
|
||||
return new float3(s[0], s[1], s[2]);
|
||||
}
|
||||
|
||||
private static float4 GetFloat4(ref MessagePackReader r)
|
||||
{
|
||||
Span<float> s = stackalloc float[4];
|
||||
Singles(ref r, s);
|
||||
return new float4(s[0], s[1], s[2], s[3]);
|
||||
}
|
||||
|
||||
private static readonly Dictionary<Type, object> Formatters = Build();
|
||||
|
||||
private static Dictionary<Type, object> Build()
|
||||
{
|
||||
var shapes = new[]
|
||||
{
|
||||
Shape<float2>(PutFloat2, GetFloat2),
|
||||
Shape<float3>(PutFloat3, GetFloat3),
|
||||
Shape<float4>(PutFloat4, GetFloat4),
|
||||
Shape<double2>((ref MessagePackWriter w, double2 v) => Put(ref w, stackalloc double[] { v.x, v.y }),
|
||||
(ref MessagePackReader r) => { Span<double> s = stackalloc double[2]; Doubles(ref r, s); return new double2(s[0], s[1]); }),
|
||||
Shape<double3>((ref MessagePackWriter w, double3 v) => Put(ref w, stackalloc double[] { v.x, v.y, v.z }),
|
||||
(ref MessagePackReader r) => { Span<double> s = stackalloc double[3]; Doubles(ref r, s); return new double3(s[0], s[1], s[2]); }),
|
||||
Shape<int2>((ref MessagePackWriter w, int2 v) => Put(ref w, stackalloc int[] { v.x, v.y }),
|
||||
(ref MessagePackReader r) => { Span<int> s = stackalloc int[2]; Ints(ref r, s); return new int2(s[0], s[1]); }),
|
||||
Shape<int3>((ref MessagePackWriter w, int3 v) => Put(ref w, stackalloc int[] { v.x, v.y, v.z }),
|
||||
(ref MessagePackReader r) => { Span<int> s = stackalloc int[3]; Ints(ref r, s); return new int3(s[0], s[1], s[2]); }),
|
||||
Shape<int4>((ref MessagePackWriter w, int4 v) => Put(ref w, stackalloc int[] { v.x, v.y, v.z, v.w }),
|
||||
(ref MessagePackReader r) => { Span<int> s = stackalloc int[4]; Ints(ref r, s); return new int4(s[0], s[1], s[2], s[3]); }),
|
||||
Shape<bool2>((ref MessagePackWriter w, bool2 v) => Put(ref w, stackalloc bool[] { v.x, v.y }),
|
||||
(ref MessagePackReader r) => { Span<bool> s = stackalloc bool[2]; Bools(ref r, s); return new bool2(s[0], s[1]); }),
|
||||
Shape<bool3>((ref MessagePackWriter w, bool3 v) => Put(ref w, stackalloc bool[] { v.x, v.y, v.z }),
|
||||
(ref MessagePackReader r) => { Span<bool> s = stackalloc bool[3]; Bools(ref r, s); return new bool3(s[0], s[1], s[2]); }),
|
||||
Shape<bool4>((ref MessagePackWriter w, bool4 v) => Put(ref w, stackalloc bool[] { v.x, v.y, v.z, v.w }),
|
||||
(ref MessagePackReader r) => { Span<bool> s = stackalloc bool[4]; Bools(ref r, s); return new bool4(s[0], s[1], s[2], s[3]); }),
|
||||
Shape<quaternion>((ref MessagePackWriter w, quaternion v) => Put(ref w, stackalloc float[] { v.x, v.y, v.z, v.w }),
|
||||
(ref MessagePackReader r) => { Span<float> s = stackalloc float[4]; Singles(ref r, s); return new quaternion(s[0], s[1], s[2], s[3]); }),
|
||||
Shape<Color32>((ref MessagePackWriter w, Color32 v) => Put(ref w, stackalloc byte[] { v.r, v.g, v.b, v.a }),
|
||||
(ref MessagePackReader r) => { Span<byte> s = stackalloc byte[4]; Bytes(ref r, s); return new Color32(s[0], s[1], s[2], s[3]); }),
|
||||
Shape<Random>((ref MessagePackWriter w, Random v) => { w.WriteArrayHeader(1); w.Write(v.state); },
|
||||
(ref MessagePackReader r) =>
|
||||
{
|
||||
var value = default(Random);
|
||||
for (int i = 0, n = r.ReadArrayHeader(); i < n; i++)
|
||||
if (i == 0) value.state = r.ReadUInt32(); else r.Skip();
|
||||
return value;
|
||||
}),
|
||||
|
||||
// Compositions: matrices are rows, rect is [min, max], CultCellular is [nearest, edge, id].
|
||||
Shape<float2x2>((ref MessagePackWriter w, float2x2 v) => { w.WriteArrayHeader(2); PutFloat2(ref w, v[0]); PutFloat2(ref w, v[1]); },
|
||||
(ref MessagePackReader r) =>
|
||||
{
|
||||
var rows = new float2[2];
|
||||
for (int i = 0, n = r.ReadArrayHeader(); i < n; i++)
|
||||
if (i < 2) rows[i] = GetFloat2(ref r); else r.Skip();
|
||||
return new float2x2(rows[0], rows[1]);
|
||||
}),
|
||||
Shape<float3x3>((ref MessagePackWriter w, float3x3 v) => { w.WriteArrayHeader(3); PutFloat3(ref w, v[0]); PutFloat3(ref w, v[1]); PutFloat3(ref w, v[2]); },
|
||||
(ref MessagePackReader r) =>
|
||||
{
|
||||
var rows = new float3[3];
|
||||
for (int i = 0, n = r.ReadArrayHeader(); i < n; i++)
|
||||
if (i < 3) rows[i] = GetFloat3(ref r); else r.Skip();
|
||||
return new float3x3(rows[0], rows[1], rows[2]);
|
||||
}),
|
||||
Shape<rect>((ref MessagePackWriter w, rect v) => { w.WriteArrayHeader(2); PutFloat2(ref w, v.min); PutFloat2(ref w, v.max); },
|
||||
(ref MessagePackReader r) =>
|
||||
{
|
||||
var corners = new float2[2];
|
||||
for (int i = 0, n = r.ReadArrayHeader(); i < n; i++)
|
||||
if (i < 2) corners[i] = GetFloat2(ref r); else r.Skip();
|
||||
return new rect(corners[0], corners[1]);
|
||||
}),
|
||||
Shape<CultCellular>((ref MessagePackWriter w, CultCellular v) => { w.WriteArrayHeader(3); PutFloat4(ref w, v.nearest); PutFloat4(ref w, v.edge); w.Write(v.id); },
|
||||
(ref MessagePackReader r) =>
|
||||
{
|
||||
var value = default(CultCellular);
|
||||
for (int i = 0, n = r.ReadArrayHeader(); i < n; i++)
|
||||
{
|
||||
switch (i)
|
||||
{
|
||||
case 0: value.nearest = GetFloat4(ref r); break;
|
||||
case 1: value.edge = GetFloat4(ref r); break;
|
||||
case 2: value.id = r.ReadSingle(); break;
|
||||
default: r.Skip(); break;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}),
|
||||
};
|
||||
|
||||
var map = new Dictionary<Type, object>();
|
||||
foreach (var shape in shapes) map.Add(shape.Key, shape.Value);
|
||||
return map;
|
||||
}
|
||||
}
|
||||
149
tests/GameCult.Caching.Tests/CultMathSerializationTests.cs
Normal file
149
tests/GameCult.Caching.Tests/CultMathSerializationTests.cs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using CultMath;
|
||||
using MessagePack;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Caching.Tests
|
||||
{
|
||||
public sealed class CultMathSerializationTests
|
||||
{
|
||||
private static readonly MessagePackSerializerOptions Options = CultDocumentMessagePackSerialization.Options;
|
||||
|
||||
private static string Hex<T>(T value) => Convert.ToHexString(MessagePackSerializer.Serialize(value, Options));
|
||||
|
||||
private static T Decode<T>(string hex) => MessagePackSerializer.Deserialize<T>(Convert.FromHexString(hex), Options);
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class Carrier
|
||||
{
|
||||
[Key(0)] public float2 Position;
|
||||
[Key(1)] public float3? Velocity;
|
||||
[Key(2)] public float2[] Path = Array.Empty<float2>();
|
||||
[Key(3)] public List<int2> Cells = new();
|
||||
}
|
||||
|
||||
// Bytes the pre-collapse CultVec2/CultVec3/CultRect encodings and Aetheria's Float2/Int2/Bool2/Float3/Float4
|
||||
// formatters produce: an array of components in declaration order, float32 as 0xCA.
|
||||
[TestCase("92CA3F800000CA40000000")]
|
||||
public void Float2_MatchesTheLegacyVectorBytes(string hex)
|
||||
{
|
||||
Hex(new float2(1f, 2f)).Should().Be(hex);
|
||||
Decode<float2>(hex).Should().Be(new float2(1f, 2f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Float3_MatchesTheLegacyVectorBytes()
|
||||
{
|
||||
const string hex = "93CA3F800000CA40000000CA40400000";
|
||||
Hex(new float3(1f, 2f, 3f)).Should().Be(hex);
|
||||
Decode<float3>(hex).Should().Be(new float3(1f, 2f, 3f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Rect_MatchesTheLegacyCultRectBytes()
|
||||
{
|
||||
const string hex = "9292CABF800000CAC000000092CA40A00000CA40E00000";
|
||||
Hex(new rect(5f, 7f, -1f, -2f)).Should().Be(hex);
|
||||
Decode<rect>(hex).Should().Be(new rect(-1f, -2f, 5f, 7f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ConsumerFormatterShapes_MatchByteForByte()
|
||||
{
|
||||
Hex(new float4(1f, 2f, 3f, 4f)).Should().Be("94CA3F800000CA40000000CA40400000CA40800000");
|
||||
Hex(new int2(3, -4)).Should().Be("9203FC");
|
||||
Hex(new bool2(true, false)).Should().Be("92C3C2");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Decode_SkipsExtraElements_AndLeavesMissingOnesZero()
|
||||
{
|
||||
Decode<float2>("93CA3F800000CA40000000CA40400000").Should().Be(new float2(1f, 2f));
|
||||
Decode<float3>("92CA3F800000CA40000000").Should().Be(new float3(1f, 2f, 0f));
|
||||
Decode<rect>("9392CABF800000CAC000000092CA40A00000CA40E0000001").Should().Be(new rect(-1f, -2f, 5f, 7f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Decode_RefusesNilAndNonArrays()
|
||||
{
|
||||
Action nil = () => Decode<float2>("C0");
|
||||
nil.Should().Throw<MessagePackSerializationException>();
|
||||
Action notArray = () => Decode<float3>("01");
|
||||
notArray.Should().Throw<MessagePackSerializationException>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryFormattedType_RoundTrips()
|
||||
{
|
||||
RoundTrip(new float2(1.5f, -2f));
|
||||
RoundTrip(new float3(1f, 2f, 3f));
|
||||
RoundTrip(new float4(1f, 2f, 3f, 4f));
|
||||
RoundTrip(new double2(1e300, -2.5));
|
||||
RoundTrip(new double3(1, 2, 3));
|
||||
RoundTrip(new int2(-7, 900000));
|
||||
RoundTrip(new int3(1, -2, 3));
|
||||
RoundTrip(new int4(1, 2, 3, int.MinValue));
|
||||
RoundTrip(new bool2(true, false));
|
||||
RoundTrip(new bool3(false, true, true));
|
||||
RoundTrip(new bool4(true, false, false, true));
|
||||
RoundTrip(new quaternion(0.1f, 0.2f, 0.3f, 0.9f));
|
||||
RoundTrip(new Color32(255, 128, 0, 7));
|
||||
RoundTrip(new CultMath.Random(12345u));
|
||||
RoundTrip(new float2x2(1f, 2f, 3f, 4f));
|
||||
RoundTrip(new float3x3(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f));
|
||||
RoundTrip(new rect(-1f, -2f, 5f, 7f));
|
||||
RoundTrip(new CultCellular(new float4(1, 2, 3, 4), new float4(5, 6, 7, 8), 0.25f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Matrices_AreArraysOfRows()
|
||||
{
|
||||
Hex(new float2x2(1f, 2f, 3f, 4f)).Should().Be("9292CA3F800000CA400000009 2CA40400000CA40800000".Replace(" ", ""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryPublicCultMathValueType_HasAFormatter()
|
||||
{
|
||||
var valueTypes = typeof(float2).Assembly.GetExportedTypes()
|
||||
.Where(t => t.IsValueType && !t.IsEnum)
|
||||
.ToHashSet();
|
||||
|
||||
valueTypes.Except(CultMathResolver.FormattedTypes).Should().BeEmpty(
|
||||
"every public CultMath value type serializes through CultMathResolver");
|
||||
CultMathResolver.FormattedTypes.Except(valueTypes).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheDefaultChain_ComposesMathIntoNullablesCollectionsAndDocuments()
|
||||
{
|
||||
var value = new Carrier
|
||||
{
|
||||
Position = new float2(1f, 2f),
|
||||
Velocity = new float3(3f, 4f, 5f),
|
||||
Path = new[] { new float2(0f, 1f), new float2(2f, 3f) },
|
||||
Cells = new List<int2> { new int2(1, 2) },
|
||||
};
|
||||
|
||||
var decoded = MessagePackSerializer.Deserialize<Carrier>(MessagePackSerializer.Serialize(value, Options), Options);
|
||||
|
||||
decoded.Position.Should().Be(value.Position);
|
||||
decoded.Velocity.Should().Be(value.Velocity);
|
||||
decoded.Path.Should().Equal(value.Path);
|
||||
decoded.Cells.Should().Equal(value.Cells);
|
||||
MessagePackSerializer.Serialize(new Carrier(), Options).Should().NotBeEmpty();
|
||||
|
||||
var absent = MessagePackSerializer.Deserialize<Carrier>(MessagePackSerializer.Serialize(new Carrier(), Options), Options);
|
||||
absent.Velocity.Should().BeNull();
|
||||
}
|
||||
|
||||
private static void RoundTrip<T>(T value)
|
||||
{
|
||||
var bytes = MessagePackSerializer.Serialize(value, Options);
|
||||
MessagePackSerializer.Deserialize<T>(bytes, Options).Should().Be(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue