diff --git a/packages/GameCult.Thing.Contract/Command.cs b/packages/GameCult.Thing.Contract/Command.cs
index dbeff22..193574c 100644
--- a/packages/GameCult.Thing.Contract/Command.cs
+++ b/packages/GameCult.Thing.Contract/Command.cs
@@ -104,16 +104,32 @@ public sealed class CommandDecl
}
}
+///
+/// A typed record as its MessagePack bytes. Every empty memory (ReadOnlyMemory<byte>.Empty, a default view,
+/// an empty array) is an empty payload, as Rust's empty Vec<u8> is. A nil on the wire is not an empty
+/// payload: it decodes to a default view the constructor cannot tell from an empty one, so the constructor notes it and
+/// the decode door refuses it once the value is read.
+///
[MessagePackObject]
-public sealed class RecordPayload
+public sealed class RecordPayload : IMessagePackSerializationCallbackReceiver
{
+ private readonly bool _viewWasDefault;
+
[Key(0)] public string Schema { get; }
[Key(1)] public ReadOnlyMemory Bytes { get; }
public RecordPayload(string schema, ReadOnlyMemory bytes)
{
Schema = ThingDomain.Present(schema, "Schema");
- Bytes = ThingDomain.Present(bytes, "Bytes").ToArray();
+ _viewWasDefault = bytes.Equals(default(ReadOnlyMemory));
+ Bytes = bytes.ToArray();
+ }
+
+ void IMessagePackSerializationCallbackReceiver.OnBeforeSerialize() { }
+
+ void IMessagePackSerializationCallbackReceiver.OnAfterDeserialize()
+ {
+ if (_viewWasDefault) throw new ThingDomainException("Bytes", "present");
}
}
diff --git a/packages/GameCult.Thing.Contract/Domain.cs b/packages/GameCult.Thing.Contract/Domain.cs
index 77c0700..ca23b16 100644
--- a/packages/GameCult.Thing.Contract/Domain.cs
+++ b/packages/GameCult.Thing.Contract/Domain.cs
@@ -49,15 +49,6 @@ public static class ThingDomain
public static T Present(T? value, string slot) where T : class =>
value ?? throw new ThingDomainException(slot, "present");
- ///
- /// A required byte slot. A null on the wire decodes to a default view with no backing store, so the default view is
- /// refused as nil; an empty array is a present, empty payload.
- ///
- public static ReadOnlyMemory Present(ReadOnlyMemory value, string slot) =>
- value.Equals(default(ReadOnlyMemory))
- ? throw new ThingDomainException(slot, "present")
- : value;
-
///
/// A required list slot: a read-only copy, so the caller's list cannot change the value afterwards. A null list or
/// a null item is refused naming the slot.
diff --git a/tests/GameCult.Thing.Contract.Tests/ReadOnlyListTests.cs b/tests/GameCult.Thing.Contract.Tests/ReadOnlyListTests.cs
index 0d8a69f..cce5758 100644
--- a/tests/GameCult.Thing.Contract.Tests/ReadOnlyListTests.cs
+++ b/tests/GameCult.Thing.Contract.Tests/ReadOnlyListTests.cs
@@ -1,3 +1,4 @@
+using MessagePack;
using System.Collections;
using System.Reflection;
@@ -165,11 +166,31 @@ public sealed class ReadOnlyListTests
}
[Test]
- public void ANilPayloadIsRefusedAndAnEmptyOneIsKeptThroughTheWire()
+ public void EveryEmptyMemoryIsAnEmptyPayloadThatWritesAsRustsEmptyVec()
{
- var error = Assert.Throws(() => new RecordPayload("s", default))!;
- Assert.That(error.Domain, Is.EqualTo("Bytes"));
- Assert.That(error.Bound, Is.EqualTo("present"));
+ // Rust's empty Vec is bin8 of length 0 (c4 00); a RecordPayload is [schema, bytes].
+ var rust = new byte[] { 0x92, 0xA1, (byte)'s', 0xC4, 0x00 };
+ var empties = new Dictionary>
+ {
+ ["ReadOnlyMemory.Empty"] = ReadOnlyMemory.Empty,
+ ["Memory.Empty"] = Memory.Empty,
+ ["default"] = default(ReadOnlyMemory),
+ ["Array.Empty"] = Array.Empty(),
+ ["new byte[0]"] = new byte[0],
+ };
+ foreach (var (name, memory) in empties)
+ {
+ var payload = new RecordPayload("s", memory);
+ Assert.That(payload.Bytes.Length, Is.EqualTo(0), name);
+ Assert.That(Wire.Write(payload), Is.EqualTo(rust), name);
+ Assert.That(Wire.Write(Wire.Read(typeof(RecordPayload), rust)), Is.EqualTo(rust), name);
+ }
+ }
+
+ [Test]
+ public void ANilPayloadOnTheWireIsRefusedAndAnEmptyOneIsKeptThroughTheWire()
+ {
+ Assert.Throws(() => Wire.Read(typeof(RecordPayload), new byte[] { 0x92, 0xA1, (byte)'s', 0xC0 }));
var empty = new CommandInvocation("i", "k", 1, "go", new RecordPayload("s", Array.Empty()), new List(), "c", "t", null);
var decoded = (CommandInvocation)Wire.Read(typeof(CommandInvocation), Wire.Write(empty));