Pin every CultMath MessagePack shape and decode a missing Color32 alpha as opaque

Golden bytes now cover double2, double3, int3, int4, bool3, bool4, quaternion,
Color32, Random, float3x3 and CultCellular. A missing Color32 alpha decodes as
255, the constructor default. A test proves a consumer resolver registered for
the assembly wins over CultMathResolver. The redundant nil check is deleted;
ReadArrayHeader already refuses nil.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 16:57:31 +02:00
commit 77602d5c0e
2 changed files with 60 additions and 3 deletions

View file

@ -11,7 +11,7 @@ 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 <c>rect</c> is <c>[min, max]</c>. Decoding
/// skips trailing elements and leaves missing ones at zero; nil is refused. The resolver is
/// skips trailing elements and leaves missing ones at zero (a missing Color32 alpha is 255); nil is refused. The resolver is
/// part of the default <see cref="CultDocumentMessagePackSerialization"/> chain, so a consumer
/// registers nothing.
/// </summary>
@ -49,7 +49,6 @@ public sealed class CultMathResolver : IFormatterResolver
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);
}
}
@ -171,7 +170,7 @@ public sealed class CultMathResolver : IFormatterResolver
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]); }),
(ref MessagePackReader r) => { Span<byte> s = stackalloc byte[4]; s[3] = 255; 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) =>
{

View file

@ -5,8 +5,11 @@ using FluentAssertions;
using GameCult.Caching.MessagePack;
using CultMath;
using MessagePack;
using MessagePack.Formatters;
using NUnit.Framework;
[assembly: CultCacheFormatterResolver(typeof(GameCult.Caching.Tests.CultMathSerializationTests.Float2Override))]
namespace GameCult.Caching.Tests
{
public sealed class CultMathSerializationTests
@ -106,6 +109,61 @@ namespace GameCult.Caching.Tests
Hex(new float2x2(1f, 2f, 3f, 4f)).Should().Be("9292CA3F800000CA4000000092CA40400000CA40800000");
}
[Test]
public void EveryShape_IsPinnedByteForByte()
{
Hex(new double2(1d, 2d)).Should().Be("92CB3FF0000000000000CB4000000000000000");
Hex(new double3(1d, 2d, 3d)).Should().Be("93CB3FF0000000000000CB4000000000000000CB4008000000000000");
Hex(new int3(1, -2, 3)).Should().Be("9301FE03");
Hex(new int4(1, 2, 3, -1)).Should().Be("94010203FF");
Hex(new bool3(true, false, true)).Should().Be("93C3C2C3");
Hex(new bool4(false, true, true, false)).Should().Be("94C2C3C3C2");
Hex(new quaternion(1f, 2f, 3f, 4f)).Should().Be("94CA3F800000CA40000000CA40400000CA40800000");
Hex(new Color32(255, 128, 0, 7)).Should().Be("94CCFFCC800007");
Hex(new CultMath.Random(12345u)).Should().Be("91CD3039");
Hex(new float3x3(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f)).Should().Be(
"93" +
"93CA3F800000CA40000000CA40400000" +
"93CA40800000CA40A00000CA40C00000" +
"93CA40E00000CA41000000CA41100000");
Hex(new CultCellular(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f), 0.25f)).Should().Be(
"93" +
"94CA3F800000CA40000000CA40400000CA40800000" +
"94CA40A00000CA40C00000CA40E00000CA41000000" +
"CA3E800000");
}
[Test]
public void Color32_MissingAlphaDecodesAsOpaque()
{
Decode<Color32>("93CCFFCC8000").Should().Be(new Color32(255, 128, 0, 255));
Decode<Color32>("94CCFFCC800007").Should().Be(new Color32(255, 128, 0, 7));
}
[Test]
public void ConsumerResolvers_WinOverCultMathResolver()
{
var overridden = CultDocumentMessagePackSerialization.OptionsFor(typeof(Float2Override).Assembly);
MessagePackSerializer.Serialize(new float2(1f, 2f), overridden).Should().Equal(0xC3);
Hex(new float2(1f, 2f)).Should().Be("92CA3F800000CA40000000");
}
public sealed class Float2Override : IFormatterResolver
{
public static readonly Float2Override Instance = new();
public IMessagePackFormatter<T>? GetFormatter<T>() =>
typeof(T) == typeof(float2) ? (IMessagePackFormatter<T>)(object)Formatter.Instance : null;
private sealed class Formatter : IMessagePackFormatter<float2>
{
public static readonly Formatter Instance = new();
public void Serialize(ref MessagePackWriter writer, float2 value, MessagePackSerializerOptions options) => writer.Write(true);
public float2 Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) => throw new NotSupportedException();
}
}
[Test]
public void EveryPublicCultMathValueType_HasAFormatter()
{