diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index ef1b4092..f591d764 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -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); } diff --git a/src/GameCult.Caching.MessagePack/CultMathResolver.cs b/src/GameCult.Caching.MessagePack/CultMathResolver.cs new file mode 100644 index 00000000..71bdec2b --- /dev/null +++ b/src/GameCult.Caching.MessagePack/CultMathResolver.cs @@ -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; + +/// +/// 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 rect is [min, max]. Decoding +/// skips trailing elements and leaves missing ones at zero; nil is refused. The resolver is +/// part of the default chain, so a consumer +/// registers nothing. +/// +public sealed class CultMathResolver : IFormatterResolver +{ + public static readonly CultMathResolver Instance = new(); + private CultMathResolver() { } + + /// The CultMath types this resolver formats, for coverage checks. + public static IReadOnlyCollection FormattedTypes => Formatters.Keys; + + public IMessagePackFormatter? GetFormatter() => Cache.Formatter; + + private static class Cache + { + public static readonly IMessagePackFormatter? Formatter = + Formatters.TryGetValue(typeof(T), out var formatter) ? (IMessagePackFormatter)formatter : null; + } + + private delegate void WriteFn(ref MessagePackWriter writer, T value); + private delegate T ReadFn(ref MessagePackReader reader); + + private sealed class ArrayShape : IMessagePackFormatter + { + private readonly WriteFn write; + private readonly ReadFn read; + + public ArrayShape(WriteFn write, ReadFn 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 Shape(WriteFn write, ReadFn read) => + new(typeof(T), new ArrayShape(write, read)); + + // Each reader takes the first dst.Length elements and skips the rest. + private static void Singles(ref MessagePackReader r, Span 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 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 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 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 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 v) + { + w.WriteArrayHeader(v.Length); + foreach (var e in v) w.Write(e); + } + + private static void Put(ref MessagePackWriter w, ReadOnlySpan v) + { + w.WriteArrayHeader(v.Length); + foreach (var e in v) w.Write(e); + } + + private static void Put(ref MessagePackWriter w, ReadOnlySpan v) + { + w.WriteArrayHeader(v.Length); + foreach (var e in v) w.Write(e); + } + + private static void Put(ref MessagePackWriter w, ReadOnlySpan v) + { + w.WriteArrayHeader(v.Length); + foreach (var e in v) w.Write(e); + } + + private static void Put(ref MessagePackWriter w, ReadOnlySpan 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 s = stackalloc float[2]; + Singles(ref r, s); + return new float2(s[0], s[1]); + } + + private static float3 GetFloat3(ref MessagePackReader r) + { + Span 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 s = stackalloc float[4]; + Singles(ref r, s); + return new float4(s[0], s[1], s[2], s[3]); + } + + private static readonly Dictionary Formatters = Build(); + + private static Dictionary Build() + { + var shapes = new[] + { + Shape(PutFloat2, GetFloat2), + Shape(PutFloat3, GetFloat3), + Shape(PutFloat4, GetFloat4), + Shape((ref MessagePackWriter w, double2 v) => Put(ref w, stackalloc double[] { v.x, v.y }), + (ref MessagePackReader r) => { Span s = stackalloc double[2]; Doubles(ref r, s); return new double2(s[0], s[1]); }), + Shape((ref MessagePackWriter w, double3 v) => Put(ref w, stackalloc double[] { v.x, v.y, v.z }), + (ref MessagePackReader r) => { Span s = stackalloc double[3]; Doubles(ref r, s); return new double3(s[0], s[1], s[2]); }), + Shape((ref MessagePackWriter w, int2 v) => Put(ref w, stackalloc int[] { v.x, v.y }), + (ref MessagePackReader r) => { Span s = stackalloc int[2]; Ints(ref r, s); return new int2(s[0], s[1]); }), + Shape((ref MessagePackWriter w, int3 v) => Put(ref w, stackalloc int[] { v.x, v.y, v.z }), + (ref MessagePackReader r) => { Span s = stackalloc int[3]; Ints(ref r, s); return new int3(s[0], s[1], s[2]); }), + Shape((ref MessagePackWriter w, int4 v) => Put(ref w, stackalloc int[] { v.x, v.y, v.z, v.w }), + (ref MessagePackReader r) => { Span s = stackalloc int[4]; Ints(ref r, s); return new int4(s[0], s[1], s[2], s[3]); }), + Shape((ref MessagePackWriter w, bool2 v) => Put(ref w, stackalloc bool[] { v.x, v.y }), + (ref MessagePackReader r) => { Span s = stackalloc bool[2]; Bools(ref r, s); return new bool2(s[0], s[1]); }), + Shape((ref MessagePackWriter w, bool3 v) => Put(ref w, stackalloc bool[] { v.x, v.y, v.z }), + (ref MessagePackReader r) => { Span s = stackalloc bool[3]; Bools(ref r, s); return new bool3(s[0], s[1], s[2]); }), + Shape((ref MessagePackWriter w, bool4 v) => Put(ref w, stackalloc bool[] { v.x, v.y, v.z, v.w }), + (ref MessagePackReader r) => { Span s = stackalloc bool[4]; Bools(ref r, s); return new bool4(s[0], s[1], s[2], s[3]); }), + Shape((ref MessagePackWriter w, quaternion v) => Put(ref w, stackalloc float[] { v.x, v.y, v.z, v.w }), + (ref MessagePackReader r) => { Span s = stackalloc float[4]; Singles(ref r, s); return new quaternion(s[0], s[1], s[2], s[3]); }), + Shape((ref MessagePackWriter w, Color32 v) => Put(ref w, stackalloc byte[] { v.r, v.g, v.b, v.a }), + (ref MessagePackReader r) => { Span s = stackalloc byte[4]; Bytes(ref r, s); return new Color32(s[0], s[1], s[2], s[3]); }), + Shape((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((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((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((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((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(); + foreach (var shape in shapes) map.Add(shape.Key, shape.Value); + return map; + } +} diff --git a/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs b/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs new file mode 100644 index 00000000..c6b6d161 --- /dev/null +++ b/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs @@ -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 value) => Convert.ToHexString(MessagePackSerializer.Serialize(value, Options)); + + private static T Decode(string hex) => MessagePackSerializer.Deserialize(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(); + [Key(3)] public List 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(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(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(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("93CA3F800000CA40000000CA40400000").Should().Be(new float2(1f, 2f)); + Decode("92CA3F800000CA40000000").Should().Be(new float3(1f, 2f, 0f)); + Decode("9392CABF800000CAC000000092CA40A00000CA40E0000001").Should().Be(new rect(-1f, -2f, 5f, 7f)); + } + + [Test] + public void Decode_RefusesNilAndNonArrays() + { + Action nil = () => Decode("C0"); + nil.Should().Throw(); + Action notArray = () => Decode("01"); + notArray.Should().Throw(); + } + + [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 { new int2(1, 2) }, + }; + + var decoded = MessagePackSerializer.Deserialize(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(MessagePackSerializer.Serialize(new Carrier(), Options), Options); + absent.Velocity.Should().BeNull(); + } + + private static void RoundTrip(T value) + { + var bytes = MessagePackSerializer.Serialize(value, Options); + MessagePackSerializer.Deserialize(bytes, Options).Should().Be(value); + } + } +}