diff --git a/tests/GameCult.Networking.InteropPeer/GameCult.Networking.InteropPeer.csproj b/tests/GameCult.Networking.InteropPeer/GameCult.Networking.InteropPeer.csproj
new file mode 100644
index 00000000..728e20c5
--- /dev/null
+++ b/tests/GameCult.Networking.InteropPeer/GameCult.Networking.InteropPeer.csproj
@@ -0,0 +1,13 @@
+
+
+ Exe
+ net10.0
+ enable
+ GameCult.Networking.InteropPeer
+
+
+
+
+
+
+
diff --git a/tests/GameCult.Networking.InteropPeer/Program.cs b/tests/GameCult.Networking.InteropPeer/Program.cs
new file mode 100644
index 00000000..82b05d51
--- /dev/null
+++ b/tests/GameCult.Networking.InteropPeer/Program.cs
@@ -0,0 +1,724 @@
+using System;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using GameCult.Caching;
+using GameCult.Networking;
+using MessagePack;
+
+return await ProgramMainAsync(args);
+
+static async Task ProgramMainAsync(string[] args)
+{
+ try
+ {
+ if (args.Length == 0)
+ {
+ throw new InvalidOperationException("Expected mode: serve | probe | dial");
+ }
+
+ var mode = args[0];
+ var options = ParseArgs(args.Skip(1).ToArray());
+ switch (mode)
+ {
+ case "serve":
+ await ServeAsync(ParseServeConfig(options));
+ return 0;
+ case "probe":
+ await ProbeAsync(options);
+ return 0;
+ case "dial":
+ await DialAsync(ParseDialConfig(options));
+ return 0;
+ default:
+ throw new InvalidOperationException($"Unknown mode {mode}");
+ }
+ }
+ catch (Exception error)
+ {
+ WriteLog("fatal", new
+ {
+ error = error.ToString()
+ });
+ return 1;
+ }
+}
+
+static async Task ServeAsync(ServeConfig config)
+{
+ var schemaRegistration = LoadSchemaRegistration(config.SchemaPath);
+ var documentRegistry = new CultNetDocumentRegistry()
+ .Register(CultNetDocumentBinding.ForEntry(
+ InteropPeerShared.InteropDocumentType,
+ payloadSchemaVersion: InteropPeerShared.InteropSchemaVersion,
+ documentKeySelector: note => note.DocumentId,
+ payloadSerializer: SerializeInteropNotePayload,
+ payloadDeserializer: DeserializeInteropNotePayload));
+
+ var customSchemaRegistry = new CultNetSchemaRegistry().Register(schemaRegistration);
+ var cache = new CultCache();
+ await cache.AddAsync(BuildInteropNote(config.RuntimeId, config.DisplayName));
+
+ using var udpSocket = CreateDiscoverySocket(config.DiscoveryPort, config.DiscoveryGroup);
+ using var tcpListener = new TcpListener(IPAddress.Parse(config.BindHost), config.TcpPort);
+ tcpListener.Start();
+
+ var cancellationSource = new CancellationTokenSource();
+ Console.CancelKeyPress += (_, eventArgs) =>
+ {
+ eventArgs.Cancel = true;
+ cancellationSource.Cancel();
+ };
+ AppDomain.CurrentDomain.ProcessExit += (_, _) => cancellationSource.Cancel();
+
+ _ = RunDiscoveryServerAsync(udpSocket, config, cancellationSource.Token);
+ _ = RunTcpServerAsync(tcpListener, config, cache, documentRegistry, customSchemaRegistry, cancellationSource.Token);
+
+ WriteJsonLine(new
+ {
+ status = "ready",
+ mode = "serve",
+ runtimeId = config.RuntimeId,
+ runtimeKind = config.RuntimeKind,
+ tcpPort = config.TcpPort,
+ discoveryPort = config.DiscoveryPort,
+ discoveryGroup = config.DiscoveryGroup.ToString()
+ });
+
+ await WaitForeverAsync(cancellationSource.Token);
+}
+
+static async Task ProbeAsync(Dictionary options)
+{
+ var runtimeId = RequireArg(options, "runtime-id");
+ var discoveryPort = ParseIntArg(options, "discovery-port");
+ var discoveryGroup = IPAddress.Parse(RequireArg(options, "discovery-group"));
+ var timeoutMs = ParseOptionalIntArg(options, "timeout-ms", 1500);
+ var messageId = $"{runtimeId}-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
+
+ using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
+ socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
+ socket.Bind(new IPEndPoint(IPAddress.Any, 0));
+ socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastLoopback, true);
+ socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 1);
+ using var timeoutSource = new CancellationTokenSource(timeoutMs);
+
+ var probe = new DiscoveryProbeMessage
+ {
+ SchemaVersion = InteropPeerShared.DiscoveryProbeSchemaVersion,
+ MessageId = messageId,
+ RequesterRuntimeId = runtimeId
+ };
+ var probeBytes = MessagePackSerializer.Serialize(probe, CultNetSchemaMessageSerialization.Options);
+ await socket.SendToAsync(probeBytes, SocketFlags.None, new IPEndPoint(discoveryGroup, discoveryPort));
+
+ var found = new Dictionary(StringComparer.Ordinal);
+ var buffer = new byte[4096];
+ while (true)
+ {
+ try
+ {
+ var result = await socket.ReceiveFromAsync(
+ buffer,
+ SocketFlags.None,
+ new IPEndPoint(IPAddress.Any, 0),
+ timeoutSource.Token);
+ var announce = MessagePackSerializer.Deserialize(
+ new ReadOnlyMemory(buffer, 0, result.ReceivedBytes),
+ CultNetSchemaMessageSerialization.Options);
+ if (announce.MessageId == messageId)
+ {
+ found[announce.RuntimeId] = announce;
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ }
+
+ WriteJsonLine(new
+ {
+ mode = "probe",
+ runtimeId,
+ peers = found.Values
+ .OrderBy(peer => peer.RuntimeId, StringComparer.Ordinal)
+ .Select(peer => new
+ {
+ schemaVersion = peer.SchemaVersion,
+ messageId = peer.MessageId,
+ runtimeId = peer.RuntimeId,
+ runtimeKind = peer.RuntimeKind,
+ displayName = peer.DisplayName,
+ agentId = peer.AgentId,
+ tcpHost = peer.TcpHost,
+ tcpPort = peer.TcpPort,
+ wireContract = peer.WireContract,
+ supportedDocumentTypes = peer.SupportedDocumentTypes,
+ supportsSchemaCatalog = peer.SupportsSchemaCatalog
+ })
+ .ToArray()
+ });
+}
+
+static async Task DialAsync(DialConfig config)
+{
+ var schemaRegistration = LoadSchemaRegistration(config.SchemaPath);
+ var cache = new CultCache();
+ var documentRegistry = new CultNetDocumentRegistry()
+ .Register(CultNetDocumentBinding.ForEntry(
+ InteropPeerShared.InteropDocumentType,
+ payloadSchemaVersion: InteropPeerShared.InteropSchemaVersion,
+ documentKeySelector: note => note.DocumentId,
+ payloadSerializer: SerializeInteropNotePayload,
+ payloadDeserializer: DeserializeInteropNotePayload));
+
+ using var client = new TcpClient();
+ await client.ConnectAsync(config.TargetHost, config.TargetPort);
+ using var stream = client.GetStream();
+
+ await SendMessageAsync(stream, new CultNetHelloMessage
+ {
+ RuntimeId = config.RuntimeId,
+ RuntimeKind = config.RuntimeKind,
+ AgentId = config.AgentId,
+ Role = "peer",
+ DisplayName = config.DisplayName,
+ SupportedDocumentTypes = [InteropPeerShared.InteropDocumentType],
+ SupportedMessageVersions = [InteropPeerShared.InteropSchemaVersion],
+ SupportsSchemaCatalog = true
+ });
+ var remoteHello = await ExpectMessageAsync(stream, CultNetSchemaVersions.Hello);
+
+ await SendMessageAsync(stream, new CultNetSchemaCatalogRequestMessage
+ {
+ MessageId = $"{config.RuntimeId}-catalog",
+ IncludeSchemaJson = true,
+ SchemaIds = [schemaRegistration.SchemaId],
+ Kinds = ["document_payload"]
+ });
+ var catalog = await ExpectMessageAsync(stream, CultNetSchemaVersions.SchemaCatalogResponse);
+
+ await SendMessageAsync(stream, new CultNetSnapshotRequestMessage
+ {
+ MessageId = $"{config.RuntimeId}-snapshot",
+ DocumentTypes = [InteropPeerShared.InteropDocumentType],
+ DocumentKeys = [$"note:{remoteHello.RuntimeId}"]
+ });
+ var snapshot = await ExpectMessageAsync(stream, CultNetSchemaVersions.SnapshotResponseRaw);
+ await documentRegistry.ApplyRawSnapshotResponseAsync(cache, snapshot);
+
+ var note = cache.AllEntries
+ .OfType()
+ .FirstOrDefault(candidate => string.Equals(candidate.AuthorRuntimeId, remoteHello.RuntimeId, StringComparison.Ordinal))
+ ?? throw new InvalidOperationException($"Did not receive interop note from {remoteHello.RuntimeId}.");
+ var hasInteropSchema = catalog.Schemas.Any(schema =>
+ string.Equals(schema.SchemaId, schemaRegistration.SchemaId, StringComparison.Ordinal) &&
+ string.Equals(schema.DocumentType, InteropPeerShared.InteropDocumentType, StringComparison.Ordinal));
+
+ WriteJsonLine(new
+ {
+ mode = "dial",
+ runtimeId = config.RuntimeId,
+ targetHost = config.TargetHost,
+ targetPort = config.TargetPort,
+ remoteHello = new
+ {
+ schemaVersion = remoteHello.SchemaVersion,
+ runtimeId = remoteHello.RuntimeId,
+ runtimeKind = remoteHello.RuntimeKind,
+ agentId = remoteHello.AgentId,
+ displayName = remoteHello.DisplayName,
+ supportedDocumentTypes = remoteHello.SupportedDocumentTypes,
+ supportedMessageVersions = remoteHello.SupportedMessageVersions,
+ supportsSchemaCatalog = remoteHello.SupportsSchemaCatalog
+ },
+ hasInteropSchema,
+ retrievedNote = new
+ {
+ schemaVersion = note.SchemaVersion,
+ documentId = note.DocumentId,
+ authorRuntimeId = note.AuthorRuntimeId,
+ title = note.Title,
+ body = note.Body,
+ tags = note.Tags
+ }
+ });
+}
+
+static async Task RunDiscoveryServerAsync(Socket socket, ServeConfig config, CancellationToken cancellationToken)
+{
+ var buffer = new byte[4096];
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ var result = await socket.ReceiveFromAsync(buffer, SocketFlags.None, new IPEndPoint(IPAddress.Any, 0), cancellationToken);
+ DiscoveryProbeMessage? probe;
+ try
+ {
+ probe = MessagePackSerializer.Deserialize(
+ new ReadOnlyMemory(buffer, 0, result.ReceivedBytes),
+ CultNetSchemaMessageSerialization.Options);
+ }
+ catch
+ {
+ continue;
+ }
+
+ if (!string.Equals(probe.SchemaVersion, InteropPeerShared.DiscoveryProbeSchemaVersion, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var announce = new DiscoveryAnnounceMessage
+ {
+ SchemaVersion = InteropPeerShared.DiscoveryAnnounceSchemaVersion,
+ MessageId = probe.MessageId,
+ RuntimeId = config.RuntimeId,
+ RuntimeKind = config.RuntimeKind,
+ DisplayName = config.DisplayName,
+ AgentId = config.AgentId,
+ TcpHost = config.AdvertiseHost,
+ TcpPort = config.TcpPort,
+ WireContract = CultNetWireContracts.SchemaV0,
+ SupportedDocumentTypes = [InteropPeerShared.InteropDocumentType],
+ SupportsSchemaCatalog = true
+ };
+ var announceBytes = MessagePackSerializer.Serialize(announce, CultNetSchemaMessageSerialization.Options);
+ await socket.SendToAsync(announceBytes, SocketFlags.None, result.RemoteEndPoint, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (Exception error)
+ {
+ WriteLog("udpError", new { runtimeId = config.RuntimeId, error = error.Message });
+ }
+ }
+}
+
+static async Task RunTcpServerAsync(
+ TcpListener listener,
+ ServeConfig config,
+ CultCache cache,
+ CultNetDocumentRegistry documentRegistry,
+ CultNetSchemaRegistry customSchemaRegistry,
+ CancellationToken cancellationToken)
+{
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ TcpClient client;
+ try
+ {
+ client = await listener.AcceptTcpClientAsync(cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (ObjectDisposedException)
+ {
+ break;
+ }
+
+ _ = Task.Run(async () =>
+ {
+ using var ownedClient = client;
+ try
+ {
+ using var stream = ownedClient.GetStream();
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ ICultNetSchemaMessage message;
+ try
+ {
+ message = await ReadMessageAsync(stream, cancellationToken);
+ }
+ catch (EndOfStreamException)
+ {
+ break;
+ }
+
+ switch (message)
+ {
+ case CultNetHelloMessage:
+ await SendMessageAsync(stream, new CultNetHelloMessage
+ {
+ RuntimeId = config.RuntimeId,
+ RuntimeKind = config.RuntimeKind,
+ AgentId = config.AgentId,
+ Role = "peer",
+ DisplayName = config.DisplayName,
+ SupportedDocumentTypes = [InteropPeerShared.InteropDocumentType],
+ SupportedMessageVersions = [InteropPeerShared.InteropSchemaVersion],
+ SupportsSchemaCatalog = true
+ }, cancellationToken);
+ break;
+ case CultNetSchemaCatalogRequestMessage catalogRequest:
+ await SendMessageAsync(stream, CreateCatalogResponse(customSchemaRegistry, catalogRequest), cancellationToken);
+ break;
+ case CultNetSnapshotRequestMessage snapshotRequest:
+ await SendMessageAsync(
+ stream,
+ documentRegistry.CreateRawSnapshotResponse(
+ cache,
+ snapshotRequest.MessageId,
+ snapshotRequest,
+ new CultNetDocumentMessageOptions
+ {
+ SourceRuntimeId = config.RuntimeId,
+ SourceAgentId = config.AgentId,
+ SourceRole = "peer",
+ Tags = ["interop", config.RuntimeId]
+ }),
+ cancellationToken);
+ break;
+ }
+ }
+ }
+ catch (Exception error)
+ {
+ WriteLog("tcpServeError", new { runtimeId = config.RuntimeId, error = error.Message });
+ }
+ }, cancellationToken);
+ }
+}
+
+static CultNetSchemaCatalogResponseMessage CreateCatalogResponse(
+ CultNetSchemaRegistry customSchemaRegistry,
+ CultNetSchemaCatalogRequestMessage request)
+{
+ var builtIn = CultNetSchemaRegistry.BuiltIn.CreateCatalogResponse(request).Schemas;
+ var custom = customSchemaRegistry.List(
+ includeSchemaJson: request.IncludeSchemaJson,
+ schemaIds: request.SchemaIds,
+ kinds: request.Kinds);
+ var schemas = builtIn
+ .Concat(custom.Where(entry => builtIn.All(candidate => !string.Equals(candidate.SchemaId, entry.SchemaId, StringComparison.Ordinal))))
+ .ToArray();
+
+ return new CultNetSchemaCatalogResponseMessage
+ {
+ MessageId = request.MessageId,
+ Schemas = schemas
+ };
+}
+
+static async Task SendMessageAsync(NetworkStream stream, TMessage message, CancellationToken cancellationToken = default)
+ where TMessage : ICultNetSchemaMessage
+{
+ var payload = CultNetSchemaMessageSerialization.Serialize(message);
+ var header = new byte[4];
+ BinaryPrimitives.WriteUInt32BigEndian(header, checked((uint)payload.Length));
+ await stream.WriteAsync(header, cancellationToken);
+ await stream.WriteAsync(payload, cancellationToken);
+ await stream.FlushAsync(cancellationToken);
+}
+
+static async Task ExpectMessageAsync(NetworkStream stream, string expectedSchemaVersion)
+ where TMessage : class, ICultNetSchemaMessage
+{
+ var message = await ReadMessageAsync(stream, CancellationToken.None);
+ if (!string.Equals(message.SchemaVersion, expectedSchemaVersion, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException($"Expected {expectedSchemaVersion} but received {message.SchemaVersion}.");
+ }
+
+ return message as TMessage
+ ?? throw new InvalidOperationException($"Expected {typeof(TMessage).Name} but received {message.GetType().Name}.");
+}
+
+static async Task ReadMessageAsync(NetworkStream stream, CancellationToken cancellationToken)
+{
+ var header = new byte[4];
+ await ReadExactlyAsync(stream, header, cancellationToken);
+ var payloadLength = checked((int)BinaryPrimitives.ReadUInt32BigEndian(header));
+ var payload = new byte[payloadLength];
+ await ReadExactlyAsync(stream, payload, cancellationToken);
+ return CultNetSchemaMessageSerialization.Deserialize(payload);
+}
+
+static async Task ReadExactlyAsync(Stream stream, byte[] buffer, CancellationToken cancellationToken)
+{
+ var offset = 0;
+ while (offset < buffer.Length)
+ {
+ var read = await stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), cancellationToken);
+ if (read == 0)
+ {
+ throw new EndOfStreamException();
+ }
+
+ offset += read;
+ }
+}
+
+static Socket CreateDiscoverySocket(int port, IPAddress multicastGroup)
+{
+ var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp)
+ {
+ ExclusiveAddressUse = false
+ };
+ socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
+ socket.Bind(new IPEndPoint(IPAddress.Any, port));
+ socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.AddMembership, new MulticastOption(multicastGroup, IPAddress.Any));
+ socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastLoopback, true);
+ socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 1);
+ return socket;
+}
+
+static CultNetSchemaRegistration LoadSchemaRegistration(string schemaPath)
+{
+ var schemaJson = File.ReadAllText(schemaPath);
+ using var document = JsonDocument.Parse(schemaJson);
+ var root = document.RootElement;
+ var schemaId = root.GetProperty("$id").GetString()
+ ?? throw new InvalidOperationException($"Schema {schemaPath} is missing $id.");
+ var title = root.TryGetProperty("title", out var titleElement)
+ ? titleElement.GetString()
+ : null;
+
+ return new CultNetSchemaRegistration
+ {
+ SchemaId = schemaId,
+ Kind = "document_payload",
+ SchemaVersion = InteropPeerShared.InteropSchemaVersion,
+ DocumentType = InteropPeerShared.InteropDocumentType,
+ Title = title,
+ WireContracts = [CultNetWireContracts.SchemaV0],
+ SchemaJson = schemaJson
+ };
+}
+
+static CultNetInteropNote BuildInteropNote(string runtimeId, string displayName)
+{
+ var documentId = $"note:{runtimeId}";
+ return new CultNetInteropNote
+ {
+ ID = CreateDeterministicGuid(documentId),
+ SchemaVersion = InteropPeerShared.InteropSchemaVersion,
+ DocumentId = documentId,
+ AuthorRuntimeId = runtimeId,
+ Title = $"{displayName} keeps a little note",
+ Body = $"{runtimeId} can move CultNet state without begging the gods for translation.",
+ Tags = [runtimeId, "interop", "cultnet"]
+ };
+}
+
+static byte[] SerializeInteropNotePayload(CultNetInteropNote note)
+{
+ return MessagePackSerializer.Serialize(new CultNetInteropNotePayload
+ {
+ SchemaVersion = note.SchemaVersion,
+ DocumentId = note.DocumentId,
+ AuthorRuntimeId = note.AuthorRuntimeId,
+ Title = note.Title,
+ Body = note.Body,
+ Tags = note.Tags
+ }, CultNetSchemaMessageSerialization.Options);
+}
+
+static CultNetInteropNote DeserializeInteropNotePayload(byte[] payload)
+{
+ var decoded = MessagePackSerializer.Deserialize(payload, CultNetSchemaMessageSerialization.Options);
+ return new CultNetInteropNote
+ {
+ ID = CreateDeterministicGuid(decoded.DocumentId),
+ SchemaVersion = decoded.SchemaVersion,
+ DocumentId = decoded.DocumentId,
+ AuthorRuntimeId = decoded.AuthorRuntimeId,
+ Title = decoded.Title,
+ Body = decoded.Body,
+ Tags = decoded.Tags ?? Array.Empty()
+ };
+}
+
+static Guid CreateDeterministicGuid(string value)
+{
+ var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(value));
+ return new Guid(bytes);
+}
+
+static async Task WaitForeverAsync(CancellationToken cancellationToken)
+{
+ try
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ }
+}
+
+static Dictionary ParseArgs(string[] args)
+{
+ var parsed = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (var index = 0; index < args.Length; index += 2)
+ {
+ var token = args[index];
+ if (!token.StartsWith("--", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ if (index + 1 >= args.Length)
+ {
+ throw new InvalidOperationException($"Missing value for {token}.");
+ }
+
+ parsed[token[2..]] = args[index + 1];
+ }
+
+ return parsed;
+}
+
+static string RequireArg(Dictionary options, string name)
+{
+ if (!options.TryGetValue(name, out var value) || string.IsNullOrWhiteSpace(value))
+ {
+ throw new InvalidOperationException($"Missing required argument --{name}.");
+ }
+
+ return value;
+}
+
+static int ParseIntArg(Dictionary options, string name)
+{
+ return int.Parse(RequireArg(options, name), CultureInfo.InvariantCulture);
+}
+
+static int ParseOptionalIntArg(Dictionary options, string name, int fallback)
+{
+ return options.TryGetValue(name, out var value)
+ ? int.Parse(value, CultureInfo.InvariantCulture)
+ : fallback;
+}
+
+static ServeConfig ParseServeConfig(Dictionary options)
+{
+ return new ServeConfig(
+ RequireArg(options, "runtime-id"),
+ RequireArg(options, "runtime-kind"),
+ RequireArg(options, "display-name"),
+ RequireArg(options, "agent-id"),
+ options.TryGetValue("bind-host", out var bindHost) ? bindHost : "127.0.0.1",
+ RequireArg(options, "advertise-host"),
+ ParseIntArg(options, "tcp-port"),
+ ParseIntArg(options, "discovery-port"),
+ IPAddress.Parse(RequireArg(options, "discovery-group")),
+ RequireArg(options, "schema-path"));
+}
+
+static DialConfig ParseDialConfig(Dictionary options)
+{
+ return new DialConfig(
+ RequireArg(options, "runtime-id"),
+ RequireArg(options, "runtime-kind"),
+ RequireArg(options, "display-name"),
+ RequireArg(options, "agent-id"),
+ RequireArg(options, "target-host"),
+ ParseIntArg(options, "target-port"),
+ RequireArg(options, "schema-path"));
+}
+
+static void WriteJsonLine(object value)
+{
+ Console.Out.WriteLine(JsonSerializer.Serialize(value, InteropPeerShared.JsonOptions));
+}
+
+static void WriteLog(string @event, object payload)
+{
+ Console.Error.WriteLine(JsonSerializer.Serialize(new Dictionary
+ {
+ ["event"] = @event,
+ ["payload"] = payload
+ }, InteropPeerShared.JsonOptions));
+}
+
+sealed record ServeConfig(
+ string RuntimeId,
+ string RuntimeKind,
+ string DisplayName,
+ string AgentId,
+ string BindHost,
+ string AdvertiseHost,
+ int TcpPort,
+ int DiscoveryPort,
+ IPAddress DiscoveryGroup,
+ string SchemaPath);
+
+sealed record DialConfig(
+ string RuntimeId,
+ string RuntimeKind,
+ string DisplayName,
+ string AgentId,
+ string TargetHost,
+ int TargetPort,
+ string SchemaPath);
+
+static class InteropPeerShared
+{
+ public const string InteropDocumentType = "cultnet.interop-note";
+ public const string InteropSchemaVersion = "cultnet.interop_note.v0";
+ public const string DiscoveryProbeSchemaVersion = "cultnet.discovery_probe.v0";
+ public const string DiscoveryAnnounceSchemaVersion = "cultnet.discovery_announce.v0";
+
+ public static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+}
+
+public sealed class CultNetInteropNote : DatabaseEntry
+{
+ public string SchemaVersion { get; set; } = InteropPeerShared.InteropSchemaVersion;
+ public string DocumentId { get; set; } = string.Empty;
+ public string AuthorRuntimeId { get; set; } = string.Empty;
+ public string Title { get; set; } = string.Empty;
+ public string Body { get; set; } = string.Empty;
+ public string[] Tags { get; set; } = Array.Empty();
+}
+
+[MessagePackObject]
+public sealed class CultNetInteropNotePayload
+{
+ [Key(0)] public string SchemaVersion { get; set; } = InteropPeerShared.InteropSchemaVersion;
+ [Key(1)] public string DocumentId { get; set; } = string.Empty;
+ [Key(2)] public string AuthorRuntimeId { get; set; } = string.Empty;
+ [Key(3)] public string Title { get; set; } = string.Empty;
+ [Key(4)] public string Body { get; set; } = string.Empty;
+ [Key(5)] public string[] Tags { get; set; } = Array.Empty();
+}
+
+[MessagePackObject]
+public sealed class DiscoveryProbeMessage
+{
+ [Key("schemaVersion")] public string SchemaVersion { get; set; } = InteropPeerShared.DiscoveryProbeSchemaVersion;
+ [Key("messageId")] public string MessageId { get; set; } = string.Empty;
+ [Key("requesterRuntimeId")] public string RequesterRuntimeId { get; set; } = string.Empty;
+}
+
+[MessagePackObject]
+public sealed class DiscoveryAnnounceMessage
+{
+ [Key("schemaVersion")] public string SchemaVersion { get; set; } = InteropPeerShared.DiscoveryAnnounceSchemaVersion;
+ [Key("messageId")] public string MessageId { get; set; } = string.Empty;
+ [Key("runtimeId")] public string RuntimeId { get; set; } = string.Empty;
+ [Key("runtimeKind")] public string RuntimeKind { get; set; } = string.Empty;
+ [Key("displayName")] public string DisplayName { get; set; } = string.Empty;
+ [Key("agentId")] public string? AgentId { get; set; }
+ [Key("tcpHost")] public string TcpHost { get; set; } = string.Empty;
+ [Key("tcpPort")] public int TcpPort { get; set; }
+ [Key("wireContract")] public string WireContract { get; set; } = CultNetWireContracts.SchemaV0;
+ [Key("supportedDocumentTypes")] public string[] SupportedDocumentTypes { get; set; } = Array.Empty();
+ [Key("supportsSchemaCatalog")] public bool SupportsSchemaCatalog { get; set; }
+}