CultLib/tests/GameCult.Mesh.Tests/CultMeshRealtimeWireProtocolTests.cs
Meta Vens 6cbc73fb17 Encode a CultMesh realtime frame in TypeScript
One codec, `packages/cultmesh-ts/src/realtime-wire.ts`, rule for rule what
`CultMeshRealtimeWireProtocol` does: the 37-byte little-endian fixed header
with magic 0x31545343, the delivery byte as the C# enum ordinal, the i64
epoch and sequence, three u16 identity lengths, the i32 payload length, the
stamped header size and wire version, then UTF-8 identities and the payload.
It touches no socket; QUIC stream framing stays transport-owned. The blank
reading in `Validate` is `IsNullOrWhiteSpace`, whose whitespace set is not
`String.prototype.trim`'s, so it borrows `isNullOrWhiteSpaceCSharp` from
`cultnet-ts` rather than growing a second reading of the same rule.

Byte parity is proven in both directions and in two processes, which is what
the authority vectors cost us to learn. `contracts/cultmesh/realtime-frame-vectors.json`
is written by the new C# test under CULTMESH_WRITE_VECTORS=1 and decoded and
re-encoded here; `realtime-frame-vectors.ts-written.json` is written by
`scripts/write-cultmesh-realtime-frame-vectors.mjs` with this codec, over
different identities and epochs, and decoded by the same C# test with the
reference implementation alone. Both files share one field shape, so each
side reads both with one reader. The two cases that would put megabytes into
a committed contract file, identities at exactly 65,535 UTF-8 bytes and a
64 MiB payload, are recorded as a build rule plus the digest of the whole
encoding; both sides build them the same way.

Three things the C# reference gets from its type system and TypeScript does
not, each refused rather than left to surface as a stray runtime error: a
`bigint` outside the i64 range, a delivery string the union cannot spell, and
a leading U+FEFF, which `TextDecoder` deletes by default and
`Encoding.UTF8.GetString` keeps. The last one is a real divergence the test
caught; the decoder now sets `ignoreBOM`. U+FEFF is not whitespace to .NET,
so such an identity passes the transport contract and reaches the wire.

There was no C# test for this codec before; this is also its first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:20:41 +02:00

361 lines
19 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using NUnit.Framework;
namespace GameCult.Mesh.Tests
{
/// <summary>
/// The realtime frame codec, rule by rule, and the C# half of the
/// cross-runtime byte parity. <c>contracts/cultmesh/realtime-frame-vectors.json</c>
/// is written here under CULTMESH_WRITE_VECTORS=1 and decoded by
/// <c>packages/cultmesh-ts/test/realtime-wire.test.ts</c>;
/// <c>realtime-frame-vectors.ts-written.json</c> is written by
/// <c>scripts/write-cultmesh-realtime-frame-vectors.mjs</c> with the
/// TypeScript codec and judged here by the reference only. Both files carry
/// the same field shape: an identity is a string or a repeat rule, a payload
/// is base64 or a ramp rule, so one reader on each side serves both.
/// </summary>
[TestFixture]
public class CultMeshRealtimeWireProtocolTests
{
// The three identities that sit exactly on the u16 length prefix. Each
// repeat unit is five UTF-8 bytes over two characters, so 13,107 repeats
// are 65,535 bytes and 26,214 characters: byte length and character
// length differ, which is the point of the case. The vector records the
// rule and the digest of the whole encoding instead of 200 KB of literal
// identity text.
private const string WideChannelUnit = "\u00e4\u20ac";
private const string WideSchemaUnit = "\u20ac\u00e4";
private const string WideBodyUnit = "\u00f1\u20ac";
private const int WideIdentityBytes = 65535;
[Test]
public void RealtimeFrameVectorsAreSharedWithTypeScript()
{
var path = VectorPath("realtime-frame-vectors.json");
if (Environment.GetEnvironmentVariable("CULTMESH_WRITE_VECTORS") == "1")
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, JsonSerializer.Serialize(new
{
frames = Frames().Select(Recorded).ToArray(),
malformed = Malformed().Select(entry => new
{
label = entry.Label,
encoding = Convert.ToBase64String(entry.Bytes),
message = Refusal(entry.Bytes)
}).ToArray()
}, new JsonSerializerOptions
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
}) + "\n");
}
using var vectors = JsonDocument.Parse(File.ReadAllText(path));
var root = vectors.RootElement;
var expected = Frames().ToDictionary(entry => entry.Label);
root.GetProperty("frames").GetArrayLength().Should().Be(expected.Count);
foreach (var element in root.GetProperty("frames").EnumerateArray())
{
var label = element.GetProperty("label").GetString()!;
expected.Should().ContainKey(label);
var frame = expected[label].Frame;
var encoded = CultMeshRealtimeWireProtocol.EncodeFrame(frame);
element.GetProperty("encodedLength").GetInt32().Should().Be(encoded.Length, label);
element.GetProperty("sha256OfEncoding").GetString().Should().Be(Sha256(encoded), label);
if (element.TryGetProperty("encoding", out var encoding) && encoding.ValueKind == JsonValueKind.String)
Convert.FromBase64String(encoding.GetString()!).Should().Equal(encoded, label);
// The recorded fields rebuild the frame, and it round-trips.
Same(Read(element.GetProperty("fields")), frame, label);
var decoded = CultMeshRealtimeWireProtocol.DecodeFrame(encoded);
Same(decoded, frame, label);
CultMeshRealtimeWireProtocol.EncodeFrame(decoded).Should().Equal(encoded, label);
}
var malformed = Malformed().ToDictionary(entry => entry.Label);
root.GetProperty("malformed").GetArrayLength().Should().Be(malformed.Count);
foreach (var element in root.GetProperty("malformed").EnumerateArray())
{
var label = element.GetProperty("label").GetString()!;
malformed.Should().ContainKey(label);
var bytes = Convert.FromBase64String(element.GetProperty("encoding").GetString()!);
bytes.Should().Equal(malformed[label].Bytes, label);
Refusal(bytes).Should().Be(element.GetProperty("message").GetString(), label);
}
}
// The other direction, cross-process: the TypeScript codec wrote these
// bytes and only the reference implementation judges them here.
[Test]
public void RealtimeFrameVectorsWrittenByTypeScriptDecodeHere()
{
var path = VectorPath("realtime-frame-vectors.ts-written.json");
using var vectors = JsonDocument.Parse(File.ReadAllText(path));
var frames = vectors.RootElement.GetProperty("frames");
frames.GetArrayLength().Should().BeGreaterThan(0);
foreach (var element in frames.EnumerateArray())
{
var label = element.GetProperty("label").GetString()!;
var frame = Read(element.GetProperty("fields"));
var mine = CultMeshRealtimeWireProtocol.EncodeFrame(frame);
// What TypeScript wrote is what this side writes, byte for byte.
element.GetProperty("encodedLength").GetInt32().Should().Be(mine.Length, label);
element.GetProperty("sha256OfEncoding").GetString().Should().Be(Sha256(mine), label);
var theirs = element.TryGetProperty("encoding", out var encoding) && encoding.ValueKind == JsonValueKind.String
? Convert.FromBase64String(encoding.GetString()!)
: mine;
theirs.Should().Equal(mine, label);
// And it decodes here to the fields TypeScript claims it encoded.
Same(CultMeshRealtimeWireProtocol.DecodeFrame(theirs), frame, label);
}
foreach (var element in vectors.RootElement.GetProperty("malformed").EnumerateArray())
{
var label = element.GetProperty("label").GetString()!;
var bytes = Convert.FromBase64String(element.GetProperty("encoding").GetString()!);
Refusal(bytes).Should().Be(element.GetProperty("message").GetString(), label);
}
}
[Test]
public void EncodeRefusesWhatTheTransportContractForbids()
{
Spoiled(frame => frame.ChannelId = " ").Should().Be("Realtime channel identity is required.");
Spoiled(frame => frame.SchemaId = "\t").Should().Be("Realtime schema identity is required.");
Spoiled(frame => frame.BodyId = "").Should().Be("Realtime body identity is required.");
Spoiled(frame => frame.ProducerEpoch = -1).Should().Be("Realtime epoch and sequence must be non-negative.");
Spoiled(frame => frame.Sequence = -1).Should().Be("Realtime epoch and sequence must be non-negative.");
Spoiled(frame => frame.ChannelId = new string('a', ushort.MaxValue + 1))
.Should().Be("Realtime frame identity exceeds the QUIC wire limit.");
Spoiled(frame => frame.Payload = new byte[CultMeshRealtimeWireProtocol.MaximumFrameBytes + 1])
.Should().Be("Realtime frame payload exceeds the QUIC wire limit.");
}
[Test]
public void TheWireConstantsAreTheOnesTypeScriptMirrors()
{
CultMeshRealtimeWireProtocol.ApplicationProtocolName.Should().Be("cultmesh-state-v1");
CultMeshRealtimeWireProtocol.ConnectionCloseCode.Should().Be(0x43554c54);
CultMeshRealtimeWireProtocol.StreamAbortCode.Should().Be(0x53544154);
CultMeshRealtimeWireProtocol.ReliableStream.Should().Be(1);
CultMeshRealtimeWireProtocol.LatestOnlyStream.Should().Be(2);
CultMeshRealtimeWireProtocol.MaximumFrameBytes.Should().Be(64 * 1024 * 1024);
CultMeshRealtimeWireProtocol.MaximumEncodedFrameBytes.Should().Be(
CultMeshRealtimeWireProtocol.MaximumFrameBytes + 37 + 3 * ushort.MaxValue);
((int)CultMeshRealtimeDelivery.ReliableOrdered).Should().Be(0);
((int)CultMeshRealtimeDelivery.LatestOnly).Should().Be(1);
((int)CultMeshRealtimeDelivery.Unreliable).Should().Be(2);
}
private static IEnumerable<(string Label, CultMeshRealtimeFrame Frame, bool Wide)> Frames()
{
yield return ("reliable-ordered, small payload", Frame(
"aetheria.zone-1", "cultmesh.realtime.v1", "body-7", 1, 2,
CultMeshRealtimeDelivery.ReliableOrdered, new byte[] { 0x00, 0x7f, 0x80, 0xff, 0x2a }), false);
yield return ("latest-only, small payload", Frame(
"aetheria.zone-1", "cultmesh.realtime.v1", "body-7", 3, 4,
CultMeshRealtimeDelivery.LatestOnly, new byte[] { 0x01, 0x02, 0x03 }), false);
yield return ("unreliable, small payload", Frame(
"aetheria.zone-1", "cultmesh.realtime.v1", "body-7", 5, 6,
CultMeshRealtimeDelivery.Unreliable, new byte[] { 0xfe }), false);
yield return ("empty payload", Frame(
"aetheria.zone-1", "cultmesh.realtime.v1", "body-7", 0, 0,
CultMeshRealtimeDelivery.ReliableOrdered, Array.Empty<byte>()), false);
yield return ("multi-byte identities", Frame(
"aetheria.z\u00f6ne-\u221e", "cultmesh.realtime.\u20ac1", "b\u00f8dy-\ud83d\udd25", 11, 12,
CultMeshRealtimeDelivery.LatestOnly, Encoding.UTF8.GetBytes("payload-\u221e")), false);
yield return ("epoch and sequence at long.MaxValue", Frame(
"aetheria.zone-1", "cultmesh.realtime.v1", "body-7", long.MaxValue, long.MaxValue,
CultMeshRealtimeDelivery.Unreliable, new byte[] { 0x5a }), false);
yield return ("identities at exactly 65535 UTF-8 bytes", Frame(
Repeat(WideChannelUnit, WideIdentityBytes), Repeat(WideSchemaUnit, WideIdentityBytes),
Repeat(WideBodyUnit, WideIdentityBytes), 13, 14,
CultMeshRealtimeDelivery.ReliableOrdered, new byte[] { 0x11, 0x22 }), true);
yield return ("64 MiB payload", Frame(
"aetheria.zone-1", "cultmesh.realtime.v1", "body-7", 15, 16,
CultMeshRealtimeDelivery.ReliableOrdered, Ramp(CultMeshRealtimeWireProtocol.MaximumFrameBytes)), false);
}
private static IEnumerable<(string Label, byte[] Bytes)> Malformed()
{
var good = CultMeshRealtimeWireProtocol.EncodeFrame(Frame(
"aetheria.zone-1", "cultmesh.realtime.v1", "body-7", 1, 2,
CultMeshRealtimeDelivery.ReliableOrdered, new byte[] { 9, 9, 9 }));
yield return ("truncated header", good.Take(36).ToArray());
yield return ("wrong magic", Spoil(good, bytes => bytes[0] ^= 0xff));
yield return ("wire version 2", Spoil(good, bytes => bytes[35] = 2));
yield return ("header size 36", Spoil(good, bytes => bytes[31] = 36));
yield return ("delivery 3", Spoil(good, bytes => bytes[4] = 3));
yield return ("delivery 255", Spoil(good, bytes => bytes[4] = 255));
yield return ("payload length -1", Spoil(good, bytes => bytes[30] = 0x80));
yield return ("length sum off by one", good.Concat(new byte[] { 0 }).ToArray());
}
private static object Recorded((string Label, CultMeshRealtimeFrame Frame, bool Wide) entry)
{
var encoded = CultMeshRealtimeWireProtocol.EncodeFrame(entry.Frame);
var frame = entry.Frame;
return new
{
label = entry.Label,
fields = new
{
channelId = entry.Wide ? Rule(WideChannelUnit, WideIdentityBytes) : frame.ChannelId,
schemaId = entry.Wide ? Rule(WideSchemaUnit, WideIdentityBytes) : frame.SchemaId,
bodyId = entry.Wide ? Rule(WideBodyUnit, WideIdentityBytes) : frame.BodyId,
producerEpoch = frame.ProducerEpoch,
sequence = frame.Sequence,
delivery = Name(frame.Delivery),
payload = frame.Payload.Length > 4096
? (object)new { ramp = frame.Payload.Length }
: Convert.ToBase64String(frame.Payload.ToArray())
},
// The two oversized cases would put megabytes into a committed
// contract file; both sides build them from the rules above and
// compare the digest of the whole encoding.
encoding = encoded.Length > 4096 ? null : Convert.ToBase64String(encoded),
encodedLength = encoded.Length,
sha256OfEncoding = Sha256(encoded)
};
}
private static object Rule(string unit, int byteLength) => new { repeat = unit, byteLength };
private static void Same(CultMeshRealtimeFrame actual, CultMeshRealtimeFrame expected, string label)
{
actual.ChannelId.Should().Be(expected.ChannelId, label);
actual.SchemaId.Should().Be(expected.SchemaId, label);
actual.BodyId.Should().Be(expected.BodyId, label);
actual.ProducerEpoch.Should().Be(expected.ProducerEpoch, label);
actual.Sequence.Should().Be(expected.Sequence, label);
actual.Delivery.Should().Be(expected.Delivery, label);
actual.Payload.Length.Should().Be(expected.Payload.Length, label);
actual.Payload.Span.SequenceEqual(expected.Payload.Span).Should().BeTrue(label);
}
private static CultMeshRealtimeFrame Read(JsonElement fields) => new()
{
ChannelId = Identity(fields.GetProperty("channelId")),
SchemaId = Identity(fields.GetProperty("schemaId")),
BodyId = Identity(fields.GetProperty("bodyId")),
ProducerEpoch = fields.GetProperty("producerEpoch").GetInt64(),
Sequence = fields.GetProperty("sequence").GetInt64(),
Delivery = Delivery(fields.GetProperty("delivery").GetString()!),
Payload = Payload(fields.GetProperty("payload"))
};
private static string Identity(JsonElement element) =>
element.ValueKind == JsonValueKind.String
? element.GetString()!
: Repeat(element.GetProperty("repeat").GetString()!, element.GetProperty("byteLength").GetInt32());
private static byte[] Payload(JsonElement element) =>
element.ValueKind == JsonValueKind.String
? Convert.FromBase64String(element.GetString()!)
: Ramp(element.GetProperty("ramp").GetInt32());
/// <summary>The repeat unit written whole until the UTF-8 byte length is exactly <paramref name="byteLength"/>.</summary>
private static string Repeat(string unit, int byteLength)
{
var unitBytes = Encoding.UTF8.GetByteCount(unit);
if (byteLength % unitBytes != 0)
throw new ArgumentException($"'{unit}' is {unitBytes} UTF-8 bytes and does not divide {byteLength}.");
return string.Concat(Enumerable.Repeat(unit, byteLength / unitBytes));
}
/// <summary>A deterministic payload both runtimes build the same way.</summary>
private static byte[] Ramp(int length)
{
var bytes = new byte[length];
for (var index = 0; index < length; index++) bytes[index] = (byte)(index % 251);
return bytes;
}
private static string Spoiled(Action<CultMeshRealtimeFrame> spoil)
{
var frame = Frame("c", "s", "b", 1, 2, CultMeshRealtimeDelivery.ReliableOrdered, new byte[] { 1 });
spoil(frame);
try
{
CultMeshRealtimeWireProtocol.EncodeFrame(frame);
return "<no refusal>";
}
catch (Exception error)
{
return error.Message;
}
}
private static string Refusal(byte[] bytes)
{
try
{
CultMeshRealtimeWireProtocol.DecodeFrame(bytes);
return "<no refusal>";
}
catch (Exception error)
{
return error.Message;
}
}
private static byte[] Spoil(byte[] bytes, Action<byte[]> spoil)
{
var copy = bytes.ToArray();
spoil(copy);
return copy;
}
private static CultMeshRealtimeFrame Frame(
string channelId, string schemaId, string bodyId, long producerEpoch, long sequence,
CultMeshRealtimeDelivery delivery, byte[] payload) =>
new()
{
ChannelId = channelId,
SchemaId = schemaId,
BodyId = bodyId,
ProducerEpoch = producerEpoch,
Sequence = sequence,
Delivery = delivery,
Payload = payload
};
private static string Name(CultMeshRealtimeDelivery delivery) => delivery switch
{
CultMeshRealtimeDelivery.ReliableOrdered => "reliable-ordered",
CultMeshRealtimeDelivery.LatestOnly => "latest-only",
CultMeshRealtimeDelivery.Unreliable => "unreliable",
_ => throw new ArgumentOutOfRangeException(nameof(delivery))
};
private static CultMeshRealtimeDelivery Delivery(string name) => name switch
{
"reliable-ordered" => CultMeshRealtimeDelivery.ReliableOrdered,
"latest-only" => CultMeshRealtimeDelivery.LatestOnly,
"unreliable" => CultMeshRealtimeDelivery.Unreliable,
_ => throw new ArgumentOutOfRangeException(nameof(name), name, "unknown delivery")
};
private static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
private static string VectorPath(string name) => Path.Combine(RepoRoot(), "contracts", "cultmesh", name);
private static string RepoRoot()
{
var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln")))
directory = directory.Parent;
return directory?.FullName ?? throw new InvalidOperationException("CultLib.sln was not found above the test directory.");
}
}
}