CultLib/tests/GameCult.Networking.InteropPeer/Program.cs

1691 lines
66 KiB
C#
Raw Normal View History

2026-05-06 13:22:01 +01:00
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
Interop: C# managed QUIC provider serving a TypeScript native consumer tests/GameCult.Networking.InteropPeer gains mode quic-realtime-serve (--port, --frames, --delivery, --interval-ms): creates a self-signed certificate the same way CultMeshQuicRealtimeTransportTests.cs does, starts CultMeshQuicRealtimeServer, and prints one ready line carrying the advertised cultmesh-state+quic:// endpoint with its cert-sha256 pin, then broadcasts N frames with increasing sequence, exiting after the loop or on stdin close. GameCult.Networking.InteropPeer.csproj references GameCult.Mesh.Quic. Discrepancy, kept: the cut map's worked line for the ready signal is `{ endpoint: "..." }` alone; this repo's spawnServeProcess harness (cultnet-interop.test.ts:2491) waits for a line whose parsed `status` is "ready", the same convention every other serve mode here already uses. The printed line is `{ status: "ready", endpoint: "..." }` — the map's shape plus the field its own named mechanism requires — rather than inventing a second readiness convention for one peer. packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts: the TypeScript half, `dial --endpoint --expect N`, connecting with CultMeshQuicRealtimeConnector and printing the received frames as one JSON line (fields plus payload hex). It reaches the connector through this package's own src/, never importing cultmesh-ts itself, keeping the dependency direction the map states (cultmesh-ts depends on cultnet-ts, never the reverse). packages/cultnet-ts/test/interop/cultnet-interop.test.ts gains "CultMesh QUIC realtime: C# managed provider and TypeScript consumer", spawning the C# peer with spawnServeProcess and the TypeScript peer with runJsonCommand, both established patterns in this file. Skips when CULTMESH_QUIC_NATIVE_DIR is unset, the same convention the unit tests use. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:33:02 +02:00
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
2026-05-06 13:22:01 +01:00
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Caching;
Interop: C# managed QUIC provider serving a TypeScript native consumer tests/GameCult.Networking.InteropPeer gains mode quic-realtime-serve (--port, --frames, --delivery, --interval-ms): creates a self-signed certificate the same way CultMeshQuicRealtimeTransportTests.cs does, starts CultMeshQuicRealtimeServer, and prints one ready line carrying the advertised cultmesh-state+quic:// endpoint with its cert-sha256 pin, then broadcasts N frames with increasing sequence, exiting after the loop or on stdin close. GameCult.Networking.InteropPeer.csproj references GameCult.Mesh.Quic. Discrepancy, kept: the cut map's worked line for the ready signal is `{ endpoint: "..." }` alone; this repo's spawnServeProcess harness (cultnet-interop.test.ts:2491) waits for a line whose parsed `status` is "ready", the same convention every other serve mode here already uses. The printed line is `{ status: "ready", endpoint: "..." }` — the map's shape plus the field its own named mechanism requires — rather than inventing a second readiness convention for one peer. packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts: the TypeScript half, `dial --endpoint --expect N`, connecting with CultMeshQuicRealtimeConnector and printing the received frames as one JSON line (fields plus payload hex). It reaches the connector through this package's own src/, never importing cultmesh-ts itself, keeping the dependency direction the map states (cultmesh-ts depends on cultnet-ts, never the reverse). packages/cultnet-ts/test/interop/cultnet-interop.test.ts gains "CultMesh QUIC realtime: C# managed provider and TypeScript consumer", spawning the C# peer with spawnServeProcess and the TypeScript peer with runJsonCommand, both established patterns in this file. Skips when CULTMESH_QUIC_NATIVE_DIR is unset, the same convention the unit tests use. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:33:02 +02:00
using GameCult.Mesh;
using GameCult.Mesh.Quic;
2026-05-06 13:22:01 +01:00
using GameCult.Networking;
using MessagePack;
return await ProgramMainAsync(args);
static async Task<int> ProgramMainAsync(string[] args)
{
try
{
if (args.Length == 0)
{
Interop: C# managed QUIC provider serving a TypeScript native consumer tests/GameCult.Networking.InteropPeer gains mode quic-realtime-serve (--port, --frames, --delivery, --interval-ms): creates a self-signed certificate the same way CultMeshQuicRealtimeTransportTests.cs does, starts CultMeshQuicRealtimeServer, and prints one ready line carrying the advertised cultmesh-state+quic:// endpoint with its cert-sha256 pin, then broadcasts N frames with increasing sequence, exiting after the loop or on stdin close. GameCult.Networking.InteropPeer.csproj references GameCult.Mesh.Quic. Discrepancy, kept: the cut map's worked line for the ready signal is `{ endpoint: "..." }` alone; this repo's spawnServeProcess harness (cultnet-interop.test.ts:2491) waits for a line whose parsed `status` is "ready", the same convention every other serve mode here already uses. The printed line is `{ status: "ready", endpoint: "..." }` — the map's shape plus the field its own named mechanism requires — rather than inventing a second readiness convention for one peer. packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts: the TypeScript half, `dial --endpoint --expect N`, connecting with CultMeshQuicRealtimeConnector and printing the received frames as one JSON line (fields plus payload hex). It reaches the connector through this package's own src/, never importing cultmesh-ts itself, keeping the dependency direction the map states (cultmesh-ts depends on cultnet-ts, never the reverse). packages/cultnet-ts/test/interop/cultnet-interop.test.ts gains "CultMesh QUIC realtime: C# managed provider and TypeScript consumer", spawning the C# peer with spawnServeProcess and the TypeScript peer with runJsonCommand, both established patterns in this file. Skips when CULTMESH_QUIC_NATIVE_DIR is unset, the same convention the unit tests use. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:33:02 +02:00
throw new InvalidOperationException("Expected mode: serve | probe | dial | rudp-serve-once | rudp-dial-once | rudp-serve-message-once | rudp-dial-message-once | quic-realtime-serve");
2026-05-06 13:22:01 +01:00
}
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;
case "rudp-serve-once":
RudpServeOnce(options);
return 0;
case "rudp-dial-once":
RudpDialOnce(options);
return 0;
2026-06-15 23:38:42 +01:00
case "rudp-serve-message-once":
RudpServeMessageOnce(options);
return 0;
case "rudp-dial-message-once":
RudpDialMessageOnce(options);
return 0;
Interop: C# managed QUIC provider serving a TypeScript native consumer tests/GameCult.Networking.InteropPeer gains mode quic-realtime-serve (--port, --frames, --delivery, --interval-ms): creates a self-signed certificate the same way CultMeshQuicRealtimeTransportTests.cs does, starts CultMeshQuicRealtimeServer, and prints one ready line carrying the advertised cultmesh-state+quic:// endpoint with its cert-sha256 pin, then broadcasts N frames with increasing sequence, exiting after the loop or on stdin close. GameCult.Networking.InteropPeer.csproj references GameCult.Mesh.Quic. Discrepancy, kept: the cut map's worked line for the ready signal is `{ endpoint: "..." }` alone; this repo's spawnServeProcess harness (cultnet-interop.test.ts:2491) waits for a line whose parsed `status` is "ready", the same convention every other serve mode here already uses. The printed line is `{ status: "ready", endpoint: "..." }` — the map's shape plus the field its own named mechanism requires — rather than inventing a second readiness convention for one peer. packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts: the TypeScript half, `dial --endpoint --expect N`, connecting with CultMeshQuicRealtimeConnector and printing the received frames as one JSON line (fields plus payload hex). It reaches the connector through this package's own src/, never importing cultmesh-ts itself, keeping the dependency direction the map states (cultmesh-ts depends on cultnet-ts, never the reverse). packages/cultnet-ts/test/interop/cultnet-interop.test.ts gains "CultMesh QUIC realtime: C# managed provider and TypeScript consumer", spawning the C# peer with spawnServeProcess and the TypeScript peer with runJsonCommand, both established patterns in this file. Skips when CULTMESH_QUIC_NATIVE_DIR is unset, the same convention the unit tests use. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:33:02 +02:00
case "quic-realtime-serve":
await QuicRealtimeServeAsync(ParseQuicRealtimeServeConfig(options));
return 0;
2026-05-06 13:22:01 +01:00
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.ForDocument<CultNetInteropNote>(
schemaId: schemaRegistration.SchemaId,
2026-05-06 13:22:01 +01:00
payloadSerializer: SerializeInteropNotePayload,
payloadDeserializer: DeserializeInteropNotePayload));
2026-05-16 10:20:08 +01:00
RegisterCapabilityBindings(documentRegistry);
2026-05-06 13:22:01 +01:00
var customSchemaRegistry = new CultNetSchemaRegistry().Register(schemaRegistration);
var cache = new CultCache();
var localNote = BuildInteropNote(config.RuntimeId, config.DisplayName);
await cache.AddAsync(localNote, new CultRecordHandle<CultNetInteropNote>(new CultRecordKey(localNote.DocumentId)));
2026-05-06 13:22:01 +01:00
using var udpSocket = CreateDiscoverySocket(config.DiscoveryPort, config.DiscoveryGroup);
using var tcpListener = new TcpListener(IPAddress.Parse(config.BindHost), config.TcpPort);
2026-06-17 11:22:17 +01:00
using var rudpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
rudpSocket.Bind(new IPEndPoint(IPAddress.Parse(config.BindHost), config.RudpPort));
rudpSocket.ReceiveTimeout = 20;
2026-05-06 13:22:01 +01:00
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);
2026-06-17 11:22:17 +01:00
_ = RunRudpServerAsync(rudpSocket, config, cache, documentRegistry, customSchemaRegistry, cancellationSource.Token);
2026-05-06 13:22:01 +01:00
WriteJsonLine(new
{
status = "ready",
mode = "serve",
runtimeId = config.RuntimeId,
runtimeKind = config.RuntimeKind,
tcpPort = config.TcpPort,
2026-06-17 11:22:17 +01:00
rudpPort = config.RudpPort,
2026-05-06 13:22:01 +01:00
discoveryPort = config.DiscoveryPort,
discoveryGroup = config.DiscoveryGroup.ToString()
});
await WaitForeverAsync(cancellationSource.Token);
}
static async Task ProbeAsync(Dictionary<string, string> 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<string, DiscoveryAnnounceMessage>(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<DiscoveryAnnounceMessage>(
new ReadOnlyMemory<byte>(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,
2026-06-14 21:20:52 +01:00
transportProfiles = peer.TransportProfiles,
2026-05-06 13:22:01 +01:00
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.ForDocument<CultNetInteropNote>(
schemaId: schemaRegistration.SchemaId,
2026-05-06 13:22:01 +01:00
payloadSerializer: SerializeInteropNotePayload,
payloadDeserializer: DeserializeInteropNotePayload));
2026-05-16 10:20:08 +01:00
RegisterCapabilityBindings(documentRegistry);
2026-05-06 13:22:01 +01:00
2026-06-17 11:22:17 +01:00
using var transport = await OpenDialTransportAsync(config);
var transportName = config.TargetRudpPort.HasValue ? "rudp" : "tcp_framed";
2026-05-06 13:22:01 +01:00
2026-06-14 21:59:09 +01:00
await SendMessageAsync(transport, new CultNetHelloMessage
2026-05-06 13:22:01 +01:00
{
RuntimeId = config.RuntimeId,
RuntimeKind = config.RuntimeKind,
AgentId = config.AgentId,
Role = "peer",
DisplayName = config.DisplayName,
SupportedDocumentTypes = [InteropPeerShared.InteropDocumentType],
2026-05-16 10:20:08 +01:00
SupportedMutationContracts = [InteropPeerShared.InteractionContract()],
2026-05-06 13:22:01 +01:00
SupportedMessageVersions = [InteropPeerShared.InteropSchemaVersion],
2026-06-17 11:22:17 +01:00
TransportProfiles = CreateDialTransportProfiles(config),
2026-05-06 13:22:01 +01:00
SupportsSchemaCatalog = true
});
2026-06-14 21:59:09 +01:00
var remoteHello = await ExpectMessageAsync<CultNetHelloMessage>(transport, CultNetSchemaVersions.Hello);
2026-05-06 13:22:01 +01:00
2026-06-14 21:59:09 +01:00
await SendMessageAsync(transport, new CultNetSchemaCatalogRequestMessage
2026-05-06 13:22:01 +01:00
{
MessageId = $"{config.RuntimeId}-catalog",
IncludeSchemaJson = true,
SchemaIds = [schemaRegistration.SchemaId],
Kinds = ["document_payload"]
});
2026-06-14 21:59:09 +01:00
var catalog = await ExpectMessageAsync<CultNetSchemaCatalogResponseMessage>(transport, CultNetSchemaVersions.SchemaCatalogResponse);
2026-05-06 13:22:01 +01:00
2026-06-14 21:59:09 +01:00
await SendMessageAsync(transport, new CultNetSnapshotRequestMessage
2026-05-06 13:22:01 +01:00
{
MessageId = $"{config.RuntimeId}-snapshot",
SchemaIds = [schemaRegistration.SchemaId],
RecordKeys = [$"note:{remoteHello.RuntimeId}"]
2026-05-06 13:22:01 +01:00
});
2026-06-14 21:59:09 +01:00
var snapshot = await ExpectMessageAsync<CultNetSnapshotResponseRawMessage>(transport, CultNetSchemaVersions.SnapshotResponseRaw);
2026-05-06 13:22:01 +01:00
await documentRegistry.ApplyRawSnapshotResponseAsync(cache, snapshot);
var note = cache.AllEntries
.OfType<CultNetInteropNote>()
.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));
2026-06-14 21:59:09 +01:00
var mutation = await MutateRemoteNoteAsync(transport, cache, documentRegistry, config.RuntimeId, note);
var fireReceipt = await FireRemoteWeaponAsync(transport, cache, documentRegistry, config.RuntimeId, remoteHello.RuntimeId);
2026-05-16 10:20:08 +01:00
2026-05-06 13:22:01 +01:00
WriteJsonLine(new
{
mode = "dial",
2026-06-17 11:22:17 +01:00
transport = transportName,
2026-05-06 13:22:01 +01:00
runtimeId = config.RuntimeId,
targetHost = config.TargetHost,
2026-06-17 11:22:17 +01:00
targetPort = config.TargetRudpPort ?? config.TargetPort,
2026-05-06 13:22:01 +01:00
remoteHello = new
{
schemaVersion = remoteHello.SchemaVersion,
runtimeId = remoteHello.RuntimeId,
runtimeKind = remoteHello.RuntimeKind,
agentId = remoteHello.AgentId,
displayName = remoteHello.DisplayName,
supportedDocumentTypes = remoteHello.SupportedDocumentTypes,
supportedMessageVersions = remoteHello.SupportedMessageVersions,
2026-06-14 21:20:52 +01:00
transportProfiles = remoteHello.TransportProfiles,
2026-05-06 13:22:01 +01:00
supportsSchemaCatalog = remoteHello.SupportsSchemaCatalog
},
hasInteropSchema,
retrievedNote = new
{
schemaVersion = note.SchemaVersion,
documentId = note.DocumentId,
authorRuntimeId = note.AuthorRuntimeId,
title = note.Title,
body = note.Body,
tags = note.Tags
2026-05-16 10:20:08 +01:00
},
mutatedNote = mutation,
fireReceipt
2026-05-06 13:22:01 +01:00
});
}
2026-05-16 10:20:08 +01:00
static async Task<object> MutateRemoteNoteAsync(
2026-06-17 11:22:17 +01:00
ISchemaMessageTransport transport,
2026-05-16 10:20:08 +01:00
CultCache cache,
CultNetDocumentRegistry documentRegistry,
string runtimeId,
CultNetInteropNote note)
{
var intent = new CultNetInteropMutationIntent
{
IntentId = $"{runtimeId}-decorate",
TargetDocumentId = note.DocumentId,
AppendBody = $" Decorated by {runtimeId}.",
AppendTag = $"decorated:{runtimeId}"
};
2026-06-14 21:59:09 +01:00
await SendMessageAsync(transport, documentRegistry.CreateRawDocumentPutMessage(
2026-05-16 10:20:08 +01:00
$"{runtimeId}-decorate-put",
new CultRecordHandle<CultNetInteropMutationIntent>(new CultRecordKey(intent.IntentId)),
intent));
await documentRegistry.ApplyRawDocumentPutMessageAsync<CultNetInteropMutationReceipt>(
cache,
2026-06-14 21:59:09 +01:00
await ExpectMessageAsync<CultNetDocumentPutRawMessage>(transport, CultNetSchemaVersions.DocumentPutRaw));
2026-05-16 10:20:08 +01:00
var mutated = await documentRegistry.ApplyRawDocumentPutMessageAsync<CultNetInteropNote>(
cache,
2026-06-14 21:59:09 +01:00
await ExpectMessageAsync<CultNetDocumentPutRawMessage>(transport, CultNetSchemaVersions.DocumentPutRaw));
2026-05-16 10:20:08 +01:00
return new
{
schemaVersion = mutated.SchemaVersion,
documentId = mutated.DocumentId,
authorRuntimeId = mutated.AuthorRuntimeId,
title = mutated.Title,
body = mutated.Body,
tags = mutated.Tags
};
}
static async Task<object> FireRemoteWeaponAsync(
2026-06-17 11:22:17 +01:00
ISchemaMessageTransport transport,
2026-05-16 10:20:08 +01:00
CultCache cache,
CultNetDocumentRegistry documentRegistry,
string runtimeId,
string remoteRuntimeId)
{
var command = new CultNetInteropFireCommand
{
CommandId = $"{runtimeId}-fire",
CharacterId = remoteRuntimeId,
WeaponId = "interop-rifle"
};
2026-06-14 21:59:09 +01:00
await SendMessageAsync(transport, documentRegistry.CreateRawDocumentPutMessage(
2026-05-16 10:20:08 +01:00
$"{runtimeId}-fire-put",
new CultRecordHandle<CultNetInteropFireCommand>(new CultRecordKey(command.CommandId)),
command));
var receipt = await documentRegistry.ApplyRawDocumentPutMessageAsync<CultNetInteropFireReceipt>(
cache,
2026-06-14 21:59:09 +01:00
await ExpectMessageAsync<CultNetDocumentPutRawMessage>(transport, CultNetSchemaVersions.DocumentPutRaw));
2026-05-16 10:20:08 +01:00
return new
{
schemaVersion = receipt.SchemaVersion,
commandId = receipt.CommandId,
accepted = receipt.Accepted,
characterId = receipt.CharacterId,
weaponId = receipt.WeaponId,
shotsFired = receipt.ShotsFired,
ammoRemaining = receipt.AmmoRemaining
};
}
2026-05-06 13:22:01 +01:00
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<DiscoveryProbeMessage>(
new ReadOnlyMemory<byte>(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],
2026-06-17 11:22:17 +01:00
TransportProfiles = CreateInteropTransportProfiles(config.RuntimeId, config.AdvertiseHost, config.TcpPort, config.RudpPort),
2026-05-06 13:22:01 +01:00
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();
2026-06-17 11:22:17 +01:00
using var transport = new TcpSchemaMessageTransport(new TcpFramedTransportConnection(
2026-06-14 21:59:09 +01:00
stream,
2026-06-17 11:22:17 +01:00
CreateTcpFramedTransportProfile(config.RuntimeId, config.AdvertiseHost, config.TcpPort)));
2026-05-06 13:22:01 +01:00
while (!cancellationToken.IsCancellationRequested)
{
ICultNetSchemaMessage message;
try
{
2026-06-17 11:22:17 +01:00
message = await transport.ReadMessageAsync(cancellationToken);
2026-05-06 13:22:01 +01:00
}
catch (EndOfStreamException)
{
break;
}
2026-06-17 11:22:17 +01:00
await HandleServerMessageAsync(transport, message, config, cache, documentRegistry, customSchemaRegistry, cancellationToken);
2026-05-06 13:22:01 +01:00
}
}
catch (Exception error)
{
WriteLog("tcpServeError", new { runtimeId = config.RuntimeId, error = error.Message });
}
}, cancellationToken);
}
}
2026-06-17 11:22:17 +01:00
static Task RunRudpServerAsync(
Socket socket,
ServeConfig config,
CultCache cache,
CultNetDocumentRegistry documentRegistry,
CultNetSchemaRegistry customSchemaRegistry,
CancellationToken cancellationToken)
{
return Task.Run(async () =>
{
var peers = new Dictionary<string, RudpPeerSession>(StringComparer.Ordinal);
var buffer = new byte[65535];
while (!cancellationToken.IsCancellationRequested)
{
try
{
EndPoint remote = new IPEndPoint(IPAddress.Any, 0);
var received = socket.ReceiveFrom(buffer, ref remote);
var remoteKey = remote.ToString() ?? string.Empty;
var packetBytes = new byte[received];
Array.Copy(buffer, packetBytes, received);
CultNetRudpPacket packet;
try
{
packet = CultNetRudpPacketCodec.Decode(packetBytes);
}
catch
{
continue;
}
if (packet.ConnectionId != InteropPeerShared.RudpInteropConnectionId)
{
continue;
}
if (!peers.TryGetValue(remoteKey, out var peer))
{
peer = new RudpPeerSession(remote, new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = InteropPeerShared.RudpInteropConnectionId,
InitialSequence = 100,
ResendDelayMs = InteropPeerShared.RudpInteropResendDelayMs
}));
peers[remoteKey] = peer;
}
if (packet.PacketType == CultNetRudpPacketType.Connect)
{
InteropRudpRuntime.SendPacket(socket, peer.RemoteEndPoint, peer.Session.AcceptConnect(
packet,
InteropRudpRuntime.NowMs(),
Encoding.UTF8.GetBytes("cultnet-interop-rudp")));
continue;
}
var result = peer.Session.Receive(packet, InteropRudpRuntime.NowMs());
if (result.Reply != null)
{
InteropRudpRuntime.SendPacket(socket, peer.RemoteEndPoint, result.Reply);
}
if (packet.PacketType == CultNetRudpPacketType.Data)
{
InteropRudpRuntime.SendPacket(socket, peer.RemoteEndPoint, peer.Session.CreateAck());
}
foreach (var frame in result.Delivered)
{
if (!string.Equals(frame.ChannelId, "schema", StringComparison.Ordinal))
{
continue;
}
var message = CultNetSchemaMessageSerialization.Deserialize(frame.Payload);
var transport = new RudpSessionSchemaSender(socket, peer.RemoteEndPoint, peer.Session);
await HandleServerMessageAsync(transport, message, config, cache, documentRegistry, customSchemaRegistry, cancellationToken);
}
}
catch (SocketException error) when (
error.SocketErrorCode == SocketError.TimedOut ||
error.SocketErrorCode == SocketError.WouldBlock ||
error.SocketErrorCode == SocketError.ConnectionReset)
{
var now = InteropRudpRuntime.NowMs();
foreach (var peer in peers.Values)
{
foreach (var packet in peer.Session.DueResends(now))
{
InteropRudpRuntime.SendPacket(socket, peer.RemoteEndPoint, packet);
}
}
}
catch (ObjectDisposedException)
{
break;
}
catch (Exception error)
{
WriteLog("rudpServeError", new { runtimeId = config.RuntimeId, error = error.Message });
}
}
}, cancellationToken);
}
static async Task HandleServerMessageAsync(
ISchemaMessageSender transport,
ICultNetSchemaMessage message,
ServeConfig config,
CultCache cache,
CultNetDocumentRegistry documentRegistry,
CultNetSchemaRegistry customSchemaRegistry,
CancellationToken cancellationToken)
{
switch (message)
{
case CultNetHelloMessage:
await SendMessageAsync(transport, new CultNetHelloMessage
{
RuntimeId = config.RuntimeId,
RuntimeKind = config.RuntimeKind,
AgentId = config.AgentId,
Role = "peer",
DisplayName = config.DisplayName,
SupportedDocumentTypes = [InteropPeerShared.InteropDocumentType],
SupportedMutationContracts = [InteropPeerShared.InteractionContract()],
SupportedMessageVersions = [InteropPeerShared.InteropSchemaVersion],
TransportProfiles = CreateInteropTransportProfiles(config.RuntimeId, config.AdvertiseHost, config.TcpPort, config.RudpPort),
SupportsSchemaCatalog = true
}, cancellationToken);
break;
case CultNetSchemaCatalogRequestMessage catalogRequest:
await SendMessageAsync(transport, CreateCatalogResponse(customSchemaRegistry, catalogRequest), cancellationToken);
break;
case CultNetSnapshotRequestMessage snapshotRequest:
await SendMessageAsync(
transport,
documentRegistry.CreateRawSnapshotResponse(
cache,
snapshotRequest.MessageId,
snapshotRequest,
new CultNetDocumentMessageOptions
{
SourceRuntimeId = config.RuntimeId,
SourceAgentId = config.AgentId,
SourceRole = "peer",
Tags = ["interop", config.RuntimeId]
}),
cancellationToken);
break;
case CultNetDocumentPutRawMessage rawPut:
await HandleRawPutAsync(transport, config, cache, documentRegistry, rawPut, cancellationToken);
break;
}
}
2026-05-16 10:20:08 +01:00
static async Task HandleRawPutAsync(
2026-06-17 11:22:17 +01:00
ISchemaMessageSender transport,
2026-05-16 10:20:08 +01:00
ServeConfig config,
CultCache cache,
CultNetDocumentRegistry documentRegistry,
CultNetDocumentPutRawMessage rawPut,
CancellationToken cancellationToken)
{
switch (rawPut.Document.SchemaId)
{
case InteropPeerShared.MutationIntentSchemaId:
{
var intent = await documentRegistry.ApplyRawDocumentPutMessageAsync<CultNetInteropMutationIntent>(cache, rawPut);
var note = cache.AllEntries
.OfType<CultNetInteropNote>()
.First(candidate => string.Equals(candidate.DocumentId, intent.TargetDocumentId, StringComparison.Ordinal));
note.Body += intent.AppendBody;
note.Tags = [..note.Tags, intent.AppendTag];
await cache.AddAsync(note, new CultRecordHandle<CultNetInteropNote>(new CultRecordKey(note.DocumentId)));
var receipt = new CultNetInteropMutationReceipt
{
IntentId = intent.IntentId,
Accepted = true,
DocumentId = note.DocumentId,
Body = note.Body,
Tags = note.Tags
};
var options = ResponseOptions(config, "mutation");
2026-06-14 21:59:09 +01:00
await SendMessageAsync(transport, documentRegistry.CreateRawDocumentPutMessage(
2026-05-16 10:20:08 +01:00
$"{config.RuntimeId}-mutation-receipt",
new CultRecordHandle<CultNetInteropMutationReceipt>(new CultRecordKey(receipt.IntentId)),
receipt,
options), cancellationToken);
2026-06-14 21:59:09 +01:00
await SendMessageAsync(transport, documentRegistry.CreateRawDocumentPutMessage(
2026-05-16 10:20:08 +01:00
$"{config.RuntimeId}-mutated-note",
new CultRecordHandle<CultNetInteropNote>(new CultRecordKey(note.DocumentId)),
note,
options), cancellationToken);
break;
}
case InteropPeerShared.FireCommandSchemaId:
{
var command = await documentRegistry.ApplyRawDocumentPutMessageAsync<CultNetInteropFireCommand>(cache, rawPut);
var receipt = new CultNetInteropFireReceipt
{
CommandId = command.CommandId,
Accepted = true,
CharacterId = command.CharacterId,
WeaponId = command.WeaponId,
ShotsFired = 1,
AmmoRemaining = 29
};
2026-06-14 21:59:09 +01:00
await SendMessageAsync(transport, documentRegistry.CreateRawDocumentPutMessage(
2026-05-16 10:20:08 +01:00
$"{config.RuntimeId}-fire-receipt",
new CultRecordHandle<CultNetInteropFireReceipt>(new CultRecordKey(receipt.CommandId)),
receipt,
ResponseOptions(config, "side-effect")), cancellationToken);
break;
}
}
}
2026-05-06 13:22:01 +01:00
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();
2026-06-17 11:22:17 +01:00
foreach (var schema in schemas)
{
schema.SchemaVersion = string.IsNullOrWhiteSpace(schema.SchemaVersion)
? schema.SchemaId
: schema.SchemaVersion;
schema.DocumentType = string.IsNullOrWhiteSpace(schema.DocumentType)
? schema.Kind
: schema.DocumentType;
schema.Title ??= string.Empty;
schema.SchemaJson ??= string.Empty;
}
2026-05-06 13:22:01 +01:00
return new CultNetSchemaCatalogResponseMessage
{
MessageId = request.MessageId,
Schemas = schemas
};
}
2026-06-14 21:59:09 +01:00
static CultNetTransportProfile CreateTcpFramedTransportProfile(string runtimeId, string host, int port)
{
2026-06-17 11:22:17 +01:00
return NormalizeInteropTransportProfile(CultNetTransportProfiles.CreateTcpFramed(runtimeId, new TcpFramedTransportProfileOptions
2026-06-14 21:59:09 +01:00
{
TransportId = "interop-tcp",
Host = host,
Port = port
2026-06-17 11:22:17 +01:00
}));
}
static CultNetTransportProfile CreateRudpTransportProfile(string runtimeId, string host, int port)
{
return NormalizeInteropTransportProfile(CultNetTransportProfiles.CreateRudp(runtimeId, new RudpTransportProfileOptions
{
TransportId = "interop-rudp",
Host = host,
Port = port,
MaxFragmentBytes = InteropPeerShared.RudpInteropMaxFragmentBytes
}));
}
static CultNetTransportProfile NormalizeInteropTransportProfile(CultNetTransportProfile profile)
{
foreach (var transport in profile.Transports)
{
transport.Host ??= string.Empty;
transport.Path ??= "/";
transport.DiscoveryGroup ??= "none";
transport.ReconnectPolicy ??= CultNetReconnectPolicies.CreateDefault();
transport.ReconnectPolicy.MaxAttempts ??= 1;
foreach (var channel in transport.Channels)
{
channel.MaxPayloadBytes ??= 65535;
channel.MaxFragmentBytes ??= 65535;
channel.MaxPendingReliablePackets ??= 1024;
}
}
return profile;
}
static CultNetTransportProfile[] CreateInteropTransportProfiles(string runtimeId, string host, int tcpPort, int rudpPort)
{
return
[
CreateTcpFramedTransportProfile(runtimeId, host, tcpPort),
CreateRudpTransportProfile(runtimeId, host, rudpPort)
];
}
static CultNetTransportProfile[] CreateDialTransportProfiles(DialConfig config)
{
var profiles = new List<CultNetTransportProfile>();
if (config.TargetPort.HasValue)
{
profiles.Add(CreateTcpFramedTransportProfile(config.RuntimeId, config.TargetHost, config.TargetPort.Value));
}
if (config.TargetRudpPort.HasValue)
{
profiles.Add(CreateRudpTransportProfile(config.RuntimeId, config.TargetHost, config.TargetRudpPort.Value));
}
return profiles.ToArray();
}
static async Task<ISchemaMessageTransport> OpenDialTransportAsync(DialConfig config)
{
if (config.TargetRudpPort.HasValue)
{
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
socket.ReceiveTimeout = 20;
var transport = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = $"{config.RuntimeId}-interop-rudp-dial",
Socket = socket,
Mode = CultNetRudpSocketMode.Client,
RemoteEndPoint = new IPEndPoint(IPAddress.Parse(config.TargetHost), config.TargetRudpPort.Value),
ConnectionId = InteropPeerShared.RudpInteropConnectionId,
InitialSequence = 1,
ResendDelayMs = InteropPeerShared.RudpInteropResendDelayMs,
TransportId = "interop-rudp",
MaxFragmentBytes = InteropPeerShared.RudpInteropMaxFragmentBytes
});
transport.Connect(Encoding.UTF8.GetBytes("cultnet-interop-rudp"));
if (!transport.AwaitConnected(TimeSpan.FromSeconds(5)))
{
transport.Dispose();
throw new TimeoutException("Timed out waiting for RUDP connect.");
}
return new RudpSocketSchemaTransport(transport);
}
if (!config.TargetPort.HasValue)
{
throw new InvalidOperationException("dial mode requires --target-port or --target-rudp-port.");
}
var client = new TcpClient();
await client.ConnectAsync(config.TargetHost, config.TargetPort.Value);
return new TcpSchemaMessageTransport(
new TcpFramedTransportConnection(
client.GetStream(),
CreateTcpFramedTransportProfile(config.RuntimeId, config.TargetHost, config.TargetPort.Value)),
client);
2026-06-14 21:59:09 +01:00
}
static void RudpServeOnce(Dictionary<string, string> options)
{
var bindHost = options.TryGetValue("bind-host", out var configuredBindHost) ? configuredBindHost : "127.0.0.1";
var bindPort = options.TryGetValue("bind-port", out var configuredBindPort)
? int.Parse(configuredBindPort, CultureInfo.InvariantCulture)
: 0;
2026-06-15 02:26:34 +01:00
var expectedClientPayload = Ascii(options.TryGetValue("client-payload", out var configuredClientPayload)
? configuredClientPayload
: "ts-csharp-client-state");
var serverPayload = Ascii(options.TryGetValue("server-payload", out var configuredServerPayload)
? configuredServerPayload
: "csharp-server-state");
2026-06-15 02:52:01 +01:00
var serverExtraPayload = options.TryGetValue("server-extra-payload", out var configuredServerExtraPayload)
? Ascii(configuredServerExtraPayload)
: null;
var disconnectReason = options.TryGetValue("disconnect-reason", out var configuredDisconnectReason)
? Ascii(configuredDisconnectReason)
: null;
2026-06-15 02:26:34 +01:00
var maxFragmentBytes = options.TryGetValue("max-fragment-bytes", out var configuredMaxFragmentBytes)
? int.Parse(configuredMaxFragmentBytes, CultureInfo.InvariantCulture)
: (int?)null;
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
socket.Bind(new IPEndPoint(IPAddress.Parse(bindHost), bindPort));
socket.ReceiveTimeout = 20;
var local = (IPEndPoint)socket.LocalEndPoint!;
using var transport = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-interop",
Socket = socket,
Mode = CultNetRudpSocketMode.Server,
ConnectionId = 0x33557799,
InitialSequence = 100,
2026-06-15 02:26:34 +01:00
ResendDelayMs = 25,
MaxFragmentBytes = maxFragmentBytes
});
WriteJsonLine(new
{
status = "ready",
port = local.Port
});
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
while (DateTimeOffset.UtcNow < deadline)
{
var frame = transport.ReceiveOnce();
if (frame != null)
{
2026-06-15 02:26:34 +01:00
RequireRudpFrameBytes(frame, "schema", expectedClientPayload);
transport.Send("schema", serverPayload);
2026-06-15 02:52:01 +01:00
if (serverExtraPayload != null)
{
transport.Send("schema", serverExtraPayload);
}
if (disconnectReason != null)
{
transport.Disconnect(disconnectReason);
}
PollRudpAfterSend(transport, TimeSpan.FromMilliseconds(250));
WriteJsonLine(new { status = "ok" });
return;
}
transport.PollResends();
Thread.Sleep(5);
}
throw new TimeoutException("Timed out waiting for TypeScript RUDP frame.");
}
static void RudpDialOnce(Dictionary<string, string> options)
{
var targetHost = RequireArg(options, "target-host");
var targetPort = ParseIntArg(options, "target-port");
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
socket.ReceiveTimeout = 20;
using var transport = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-client-interop",
Socket = socket,
Mode = CultNetRudpSocketMode.Client,
RemoteEndPoint = new IPEndPoint(IPAddress.Parse(targetHost), targetPort),
ConnectionId = 0x99775533,
InitialSequence = 1,
ResendDelayMs = 25
});
transport.Connect(Ascii("csharp-join"));
var sent = false;
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
while (DateTimeOffset.UtcNow < deadline)
{
var frame = transport.ReceiveOnce();
if (frame != null)
{
RequireRudpFrame(frame, "schema", "ts-csharp-server-state");
WriteJsonLine(new { status = "ok" });
return;
}
transport.PollResends();
if (transport.Connected && !sent)
{
transport.Send("schema", Ascii("csharp-client-state"));
sent = true;
}
Thread.Sleep(5);
}
throw new TimeoutException("Timed out waiting for TypeScript RUDP response.");
}
2026-06-15 23:38:42 +01:00
static void RudpServeMessageOnce(Dictionary<string, string> options)
{
var bindHost = options.TryGetValue("bind-host", out var configuredBindHost) ? configuredBindHost : "127.0.0.1";
var bindPort = options.TryGetValue("bind-port", out var configuredBindPort)
? int.Parse(configuredBindPort, CultureInfo.InvariantCulture)
: 0;
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
socket.Bind(new IPEndPoint(IPAddress.Parse(bindHost), bindPort));
socket.ReceiveTimeout = 20;
var local = (IPEndPoint)socket.LocalEndPoint!;
using var transport = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-message-interop",
Socket = socket,
Mode = CultNetRudpSocketMode.Server,
ConnectionId = 0x3355779a,
InitialSequence = 100,
ResendDelayMs = 25
});
WriteJsonLine(new
{
status = "ready",
port = local.Port
});
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
while (DateTimeOffset.UtcNow < deadline)
{
var message = transport.ReceiveSchemaMessageOnce();
if (message != null)
{
if (message is not CultNetSchemaCatalogRequestMessage request ||
!string.Equals(request.MessageId, "ts-csharp-schema-message", StringComparison.Ordinal))
{
throw new InvalidOperationException($"Unexpected RUDP schema message {message.SchemaVersion}.");
}
transport.SendSchemaMessage(new CultNetHelloMessage
{
RuntimeId = "csharp-rudp-message-interop",
RuntimeKind = "csharp",
DisplayName = "C# RUDP Interop",
SupportedMessageVersions =
[
CultNetSchemaVersions.Hello,
CultNetSchemaVersions.SchemaCatalogRequest
],
TransportProfiles = [transport.Profile],
SupportsSchemaCatalog = true
});
PollRudpAfterSend(transport, TimeSpan.FromMilliseconds(250));
WriteJsonLine(new { status = "ok" });
return;
}
transport.PollResends();
Thread.Sleep(5);
}
throw new TimeoutException("Timed out waiting for TypeScript schema-v0 message.");
}
static void RudpDialMessageOnce(Dictionary<string, string> options)
{
var targetHost = RequireArg(options, "target-host");
var targetPort = ParseIntArg(options, "target-port");
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
socket.ReceiveTimeout = 20;
using var transport = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-message-client-interop",
Socket = socket,
Mode = CultNetRudpSocketMode.Client,
RemoteEndPoint = new IPEndPoint(IPAddress.Parse(targetHost), targetPort),
ConnectionId = 0x99775534,
InitialSequence = 1,
ResendDelayMs = 25
});
transport.Connect(Ascii("csharp-message-join"));
var sent = false;
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
while (DateTimeOffset.UtcNow < deadline)
{
var message = transport.ReceiveSchemaMessageOnce();
if (message != null)
{
if (message is not CultNetHelloMessage hello ||
!string.Equals(hello.RuntimeId, "ts-csharp-rudp-message-server", StringComparison.Ordinal))
{
throw new InvalidOperationException($"Unexpected RUDP schema message {message.SchemaVersion}.");
}
WriteJsonLine(new { status = "ok" });
return;
}
transport.PollResends();
if (transport.Connected && !sent)
{
transport.SendSchemaMessage(new CultNetSchemaCatalogRequestMessage
{
MessageId = "csharp-ts-schema-message",
Kinds = ["wire_message"]
});
sent = true;
}
Thread.Sleep(5);
}
throw new TimeoutException("Timed out waiting for TypeScript schema-v0 response.");
}
static void PollRudpAfterSend(CultNetRudpSocketTransportConnection transport, TimeSpan duration)
{
var deadline = DateTimeOffset.UtcNow.Add(duration);
while (DateTimeOffset.UtcNow < deadline)
{
_ = transport.ReceiveOnce();
transport.PollResends();
Thread.Sleep(5);
}
}
static void RequireRudpFrame(CultNetTransportFrame frame, string expectedChannelId, string expectedPayload)
2026-06-15 02:26:34 +01:00
{
RequireRudpFrameBytes(frame, expectedChannelId, Ascii(expectedPayload));
}
static void RequireRudpFrameBytes(CultNetTransportFrame frame, string expectedChannelId, byte[] expectedPayload)
{
if (!string.Equals(frame.ChannelId, expectedChannelId, StringComparison.Ordinal)
2026-06-15 02:26:34 +01:00
|| !frame.Payload.SequenceEqual(expectedPayload))
{
throw new InvalidOperationException(
$"Unexpected RUDP frame: channel={frame.ChannelId}, payload={Convert.ToHexString(frame.Payload)}.");
}
}
2026-06-14 21:59:09 +01:00
static async Task SendMessageAsync<TMessage>(
2026-06-17 11:22:17 +01:00
ISchemaMessageSender transport,
2026-06-14 21:59:09 +01:00
TMessage message,
CancellationToken cancellationToken = default)
2026-05-06 13:22:01 +01:00
where TMessage : ICultNetSchemaMessage
{
var payload = CultNetSchemaMessageSerialization.Serialize(message);
2026-06-17 11:22:17 +01:00
await transport.SendMessageAsync(payload, cancellationToken);
2026-05-06 13:22:01 +01:00
}
2026-06-14 21:59:09 +01:00
static async Task<TMessage> ExpectMessageAsync<TMessage>(
2026-06-17 11:22:17 +01:00
ISchemaMessageTransport transport,
2026-06-14 21:59:09 +01:00
string expectedSchemaVersion)
2026-05-06 13:22:01 +01:00
where TMessage : class, ICultNetSchemaMessage
{
2026-06-17 11:22:17 +01:00
var message = await transport.ReadMessageAsync(CancellationToken.None);
2026-05-06 13:22:01 +01:00
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 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
{
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<CultNetInteropNotePayload>(payload, CultNetSchemaMessageSerialization.Options);
return new CultNetInteropNote
{
SchemaVersion = decoded.SchemaVersion,
DocumentId = decoded.DocumentId,
AuthorRuntimeId = decoded.AuthorRuntimeId,
Title = decoded.Title,
Body = decoded.Body,
Tags = decoded.Tags ?? Array.Empty<string>()
};
}
2026-05-16 10:20:08 +01:00
static void RegisterCapabilityBindings(CultNetDocumentRegistry registry)
{
registry
.Register(CultNetDocumentBinding.ForDocument<CultNetInteropMutationIntent>(
schemaId: InteropPeerShared.MutationIntentSchemaId,
payloadSerializer: value => MessagePackSerializer.Serialize(value, CultNetSchemaMessageSerialization.Options),
payloadDeserializer: payload => MessagePackSerializer.Deserialize<CultNetInteropMutationIntent>(payload, CultNetSchemaMessageSerialization.Options)))
.Register(CultNetDocumentBinding.ForDocument<CultNetInteropMutationReceipt>(
schemaId: InteropPeerShared.MutationReceiptSchemaId,
payloadSerializer: value => MessagePackSerializer.Serialize(value, CultNetSchemaMessageSerialization.Options),
payloadDeserializer: payload => MessagePackSerializer.Deserialize<CultNetInteropMutationReceipt>(payload, CultNetSchemaMessageSerialization.Options)))
.Register(CultNetDocumentBinding.ForDocument<CultNetInteropFireCommand>(
schemaId: InteropPeerShared.FireCommandSchemaId,
payloadSerializer: value => MessagePackSerializer.Serialize(value, CultNetSchemaMessageSerialization.Options),
payloadDeserializer: payload => MessagePackSerializer.Deserialize<CultNetInteropFireCommand>(payload, CultNetSchemaMessageSerialization.Options)))
.Register(CultNetDocumentBinding.ForDocument<CultNetInteropFireReceipt>(
schemaId: InteropPeerShared.FireReceiptSchemaId,
payloadSerializer: value => MessagePackSerializer.Serialize(value, CultNetSchemaMessageSerialization.Options),
payloadDeserializer: payload => MessagePackSerializer.Deserialize<CultNetInteropFireReceipt>(payload, CultNetSchemaMessageSerialization.Options)));
}
static CultNetDocumentMessageOptions ResponseOptions(ServeConfig config, string tag)
{
return new CultNetDocumentMessageOptions
{
SourceRuntimeId = config.RuntimeId,
SourceAgentId = config.AgentId,
SourceRole = "peer",
Tags = [tag, config.RuntimeId]
};
}
2026-05-06 13:22:01 +01:00
static async Task WaitForeverAsync(CancellationToken cancellationToken)
{
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
catch (OperationCanceledException)
{
}
}
Interop: C# managed QUIC provider serving a TypeScript native consumer tests/GameCult.Networking.InteropPeer gains mode quic-realtime-serve (--port, --frames, --delivery, --interval-ms): creates a self-signed certificate the same way CultMeshQuicRealtimeTransportTests.cs does, starts CultMeshQuicRealtimeServer, and prints one ready line carrying the advertised cultmesh-state+quic:// endpoint with its cert-sha256 pin, then broadcasts N frames with increasing sequence, exiting after the loop or on stdin close. GameCult.Networking.InteropPeer.csproj references GameCult.Mesh.Quic. Discrepancy, kept: the cut map's worked line for the ready signal is `{ endpoint: "..." }` alone; this repo's spawnServeProcess harness (cultnet-interop.test.ts:2491) waits for a line whose parsed `status` is "ready", the same convention every other serve mode here already uses. The printed line is `{ status: "ready", endpoint: "..." }` — the map's shape plus the field its own named mechanism requires — rather than inventing a second readiness convention for one peer. packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts: the TypeScript half, `dial --endpoint --expect N`, connecting with CultMeshQuicRealtimeConnector and printing the received frames as one JSON line (fields plus payload hex). It reaches the connector through this package's own src/, never importing cultmesh-ts itself, keeping the dependency direction the map states (cultmesh-ts depends on cultnet-ts, never the reverse). packages/cultnet-ts/test/interop/cultnet-interop.test.ts gains "CultMesh QUIC realtime: C# managed provider and TypeScript consumer", spawning the C# peer with spawnServeProcess and the TypeScript peer with runJsonCommand, both established patterns in this file. Skips when CULTMESH_QUIC_NATIVE_DIR is unset, the same convention the unit tests use. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:33:02 +02:00
static async Task QuicRealtimeServeAsync(QuicRealtimeServeConfig config)
{
using var certificate = CreateQuicInteropCertificate();
await using var server = await CultMeshQuicRealtimeServer.ListenAsync(new CultMeshQuicRealtimeServerOptions
{
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, config.Port),
ServerCertificate = certificate
});
var pin = Convert.ToHexString(SHA256.HashData(certificate.RawData));
var endpoint = $"cultmesh-state+quic://127.0.0.1:{server.LocalEndPoint.Port}?cert-sha256={pin}";
WriteJsonLine(new { status = "ready", endpoint });
// The harness dials only after reading the line above, so broadcasting
// immediately would send to nobody: MsQuic's own send path silently no-ops
// over an empty peer set. Wait for at least one connection first, bounded,
// so a peer that never shows up still exits instead of hanging forever.
await WaitForFirstConnectionAsync(server, TimeSpan.FromSeconds(30));
Interop: C# managed QUIC provider serving a TypeScript native consumer tests/GameCult.Networking.InteropPeer gains mode quic-realtime-serve (--port, --frames, --delivery, --interval-ms): creates a self-signed certificate the same way CultMeshQuicRealtimeTransportTests.cs does, starts CultMeshQuicRealtimeServer, and prints one ready line carrying the advertised cultmesh-state+quic:// endpoint with its cert-sha256 pin, then broadcasts N frames with increasing sequence, exiting after the loop or on stdin close. GameCult.Networking.InteropPeer.csproj references GameCult.Mesh.Quic. Discrepancy, kept: the cut map's worked line for the ready signal is `{ endpoint: "..." }` alone; this repo's spawnServeProcess harness (cultnet-interop.test.ts:2491) waits for a line whose parsed `status` is "ready", the same convention every other serve mode here already uses. The printed line is `{ status: "ready", endpoint: "..." }` — the map's shape plus the field its own named mechanism requires — rather than inventing a second readiness convention for one peer. packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts: the TypeScript half, `dial --endpoint --expect N`, connecting with CultMeshQuicRealtimeConnector and printing the received frames as one JSON line (fields plus payload hex). It reaches the connector through this package's own src/, never importing cultmesh-ts itself, keeping the dependency direction the map states (cultmesh-ts depends on cultnet-ts, never the reverse). packages/cultnet-ts/test/interop/cultnet-interop.test.ts gains "CultMesh QUIC realtime: C# managed provider and TypeScript consumer", spawning the C# peer with spawnServeProcess and the TypeScript peer with runJsonCommand, both established patterns in this file. Skips when CULTMESH_QUIC_NATIVE_DIR is unset, the same convention the unit tests use. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:33:02 +02:00
var delivery = config.Delivery switch
{
"reliable-ordered" => CultMeshRealtimeDelivery.ReliableOrdered,
"latest-only" => CultMeshRealtimeDelivery.LatestOnly,
_ => throw new InvalidOperationException($"Unsupported --delivery '{config.Delivery}'.")
};
var stdinClosed = WatchStdinCloseAsync();
for (var sequence = 1; sequence <= config.Frames && !stdinClosed.IsCompleted; sequence++)
{
var frame = new CultMeshRealtimeFrame
{
ChannelId = "interop.quic-realtime",
SchemaId = "gamecult.interop.quic_realtime_frame.v0",
BodyId = "interop:quic-realtime:frame",
ProducerEpoch = 1,
Sequence = sequence,
Delivery = delivery,
Payload = Encoding.UTF8.GetBytes($"frame-{sequence}")
};
await server.BroadcastAsync(frame);
WriteLog("quic-realtime-serve", new { sent = sequence });
if (config.IntervalMs > 0) await Task.Delay(config.IntervalMs);
}
}
static async Task WaitForFirstConnectionAsync(CultMeshQuicRealtimeServer server, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (server.ConnectionCount == 0)
{
if (DateTime.UtcNow >= deadline)
throw new TimeoutException($"quic-realtime-serve: no client connected within {timeout}.");
await Task.Delay(20);
}
}
Interop: C# managed QUIC provider serving a TypeScript native consumer tests/GameCult.Networking.InteropPeer gains mode quic-realtime-serve (--port, --frames, --delivery, --interval-ms): creates a self-signed certificate the same way CultMeshQuicRealtimeTransportTests.cs does, starts CultMeshQuicRealtimeServer, and prints one ready line carrying the advertised cultmesh-state+quic:// endpoint with its cert-sha256 pin, then broadcasts N frames with increasing sequence, exiting after the loop or on stdin close. GameCult.Networking.InteropPeer.csproj references GameCult.Mesh.Quic. Discrepancy, kept: the cut map's worked line for the ready signal is `{ endpoint: "..." }` alone; this repo's spawnServeProcess harness (cultnet-interop.test.ts:2491) waits for a line whose parsed `status` is "ready", the same convention every other serve mode here already uses. The printed line is `{ status: "ready", endpoint: "..." }` — the map's shape plus the field its own named mechanism requires — rather than inventing a second readiness convention for one peer. packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts: the TypeScript half, `dial --endpoint --expect N`, connecting with CultMeshQuicRealtimeConnector and printing the received frames as one JSON line (fields plus payload hex). It reaches the connector through this package's own src/, never importing cultmesh-ts itself, keeping the dependency direction the map states (cultmesh-ts depends on cultnet-ts, never the reverse). packages/cultnet-ts/test/interop/cultnet-interop.test.ts gains "CultMesh QUIC realtime: C# managed provider and TypeScript consumer", spawning the C# peer with spawnServeProcess and the TypeScript peer with runJsonCommand, both established patterns in this file. Skips when CULTMESH_QUIC_NATIVE_DIR is unset, the same convention the unit tests use. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:33:02 +02:00
static Task WatchStdinCloseAsync()
{
return Task.Run(async () =>
{
while (await Console.In.ReadLineAsync() != null)
{
}
});
}
static QuicRealtimeServeConfig ParseQuicRealtimeServeConfig(Dictionary<string, string> options)
{
return new QuicRealtimeServeConfig(
ParseOptionalIntArg(options, "port", 0),
ParseOptionalIntArg(options, "frames", 5),
options.TryGetValue("delivery", out var delivery) ? delivery : "latest-only",
ParseOptionalIntArg(options, "interval-ms", 50));
}
static X509Certificate2 CreateQuicInteropCertificate()
{
using var key = RSA.Create(2048);
var request = new CertificateRequest(
"CN=localhost",
key,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false));
request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
new OidCollection { new("1.3.6.1.5.5.7.3.1") },
false));
var names = new SubjectAlternativeNameBuilder();
names.AddDnsName("localhost");
names.AddIpAddress(IPAddress.Loopback);
request.CertificateExtensions.Add(names.Build());
request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
using var generated = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddMinutes(-1),
DateTimeOffset.UtcNow.AddHours(1));
return X509CertificateLoader.LoadPkcs12(generated.Export(X509ContentType.Pfx), null);
}
2026-05-06 13:22:01 +01:00
static Dictionary<string, string> ParseArgs(string[] args)
{
var parsed = new Dictionary<string, string>(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<string, string> 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<string, string> options, string name)
{
return int.Parse(RequireArg(options, name), CultureInfo.InvariantCulture);
}
static int ParseOptionalIntArg(Dictionary<string, string> options, string name, int fallback)
{
return options.TryGetValue(name, out var value)
? int.Parse(value, CultureInfo.InvariantCulture)
: fallback;
}
static ServeConfig ParseServeConfig(Dictionary<string, string> options)
{
2026-06-17 11:22:17 +01:00
var tcpPort = ParseIntArg(options, "tcp-port");
2026-05-06 13:22:01 +01:00
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"),
2026-06-17 11:22:17 +01:00
tcpPort,
ParseOptionalIntArg(options, "rudp-port", tcpPort),
2026-05-06 13:22:01 +01:00
ParseIntArg(options, "discovery-port"),
IPAddress.Parse(RequireArg(options, "discovery-group")),
RequireArg(options, "schema-path"));
}
static DialConfig ParseDialConfig(Dictionary<string, string> options)
{
return new DialConfig(
RequireArg(options, "runtime-id"),
RequireArg(options, "runtime-kind"),
RequireArg(options, "display-name"),
RequireArg(options, "agent-id"),
RequireArg(options, "target-host"),
2026-06-17 11:22:17 +01:00
options.TryGetValue("target-port", out var targetPort)
? int.Parse(targetPort, CultureInfo.InvariantCulture)
: null,
options.TryGetValue("target-rudp-port", out var targetRudpPort)
? int.Parse(targetRudpPort, CultureInfo.InvariantCulture)
: null,
2026-05-06 13:22:01 +01:00
RequireArg(options, "schema-path"));
}
static void WriteJsonLine(object value)
{
Console.Out.WriteLine(JsonSerializer.Serialize(value, InteropPeerShared.JsonOptions));
}
static byte[] Ascii(string value)
{
return Encoding.ASCII.GetBytes(value);
}
2026-05-06 13:22:01 +01:00
static void WriteLog(string @event, object payload)
{
Console.Error.WriteLine(JsonSerializer.Serialize(new Dictionary<string, object?>
{
["event"] = @event,
["payload"] = payload
}, InteropPeerShared.JsonOptions));
}
2026-06-17 11:22:17 +01:00
interface ISchemaMessageSender
{
Task SendMessageAsync(byte[] payload, CancellationToken cancellationToken = default);
}
interface ISchemaMessageTransport : ISchemaMessageSender, IDisposable
{
Task<ICultNetSchemaMessage> ReadMessageAsync(CancellationToken cancellationToken = default);
}
sealed class TcpSchemaMessageTransport : ISchemaMessageTransport
{
private readonly TcpFramedTransportConnection _transport;
private readonly TcpClient? _client;
public TcpSchemaMessageTransport(TcpFramedTransportConnection transport, TcpClient? client = null)
{
_transport = transport;
_client = client;
}
public Task SendMessageAsync(byte[] payload, CancellationToken cancellationToken = default)
{
return _transport.SendAsync("schema", payload, cancellationToken);
}
public async Task<ICultNetSchemaMessage> ReadMessageAsync(CancellationToken cancellationToken = default)
{
var frame = await _transport.ReceiveAsync(cancellationToken);
return CultNetSchemaMessageSerialization.Deserialize(frame.Payload);
}
public void Dispose()
{
_client?.Dispose();
}
}
sealed class RudpSocketSchemaTransport : ISchemaMessageTransport
{
private readonly CultNetRudpSocketTransportConnection _transport;
public RudpSocketSchemaTransport(CultNetRudpSocketTransportConnection transport)
{
_transport = transport;
}
public Task SendMessageAsync(byte[] payload, CancellationToken cancellationToken = default)
{
_transport.Send("schema", payload);
return Task.CompletedTask;
}
public Task<ICultNetSchemaMessage> ReadMessageAsync(CancellationToken cancellationToken = default)
{
var message = _transport.ReceiveSchemaMessage(TimeSpan.FromSeconds(15))
?? throw new TimeoutException("Timed out waiting for RUDP schema message.");
return Task.FromResult(message);
}
public void Dispose()
{
_transport.Dispose();
}
}
sealed class RudpSessionSchemaSender : ISchemaMessageSender
{
private readonly Socket _socket;
private readonly EndPoint _remoteEndPoint;
private readonly CultNetRudpSession _session;
public RudpSessionSchemaSender(Socket socket, EndPoint remoteEndPoint, CultNetRudpSession session)
{
_socket = socket;
_remoteEndPoint = remoteEndPoint;
_session = session;
}
public Task SendMessageAsync(byte[] payload, CancellationToken cancellationToken = default)
{
foreach (var packet in _session.SendMany(
"schema",
payload,
new CultNetRudpSendOptions
{
Reliable = true,
Ordered = true,
Sequenced = false,
NowMs = InteropRudpRuntime.NowMs()
},
InteropPeerShared.RudpInteropMaxFragmentBytes))
{
InteropRudpRuntime.SendPacket(_socket, _remoteEndPoint, packet);
}
return Task.CompletedTask;
}
}
sealed record RudpPeerSession(EndPoint RemoteEndPoint, CultNetRudpSession Session);
static class InteropRudpRuntime
{
public static void SendPacket(Socket socket, EndPoint remoteEndPoint, CultNetRudpPacket packet)
{
socket.SendTo(CultNetRudpPacketCodec.Encode(packet), remoteEndPoint);
}
public static long NowMs()
{
return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
}
2026-05-06 13:22:01 +01:00
sealed record ServeConfig(
string RuntimeId,
string RuntimeKind,
string DisplayName,
string AgentId,
string BindHost,
string AdvertiseHost,
int TcpPort,
2026-06-17 11:22:17 +01:00
int RudpPort,
2026-05-06 13:22:01 +01:00
int DiscoveryPort,
IPAddress DiscoveryGroup,
string SchemaPath);
sealed record DialConfig(
string RuntimeId,
string RuntimeKind,
string DisplayName,
string AgentId,
string TargetHost,
2026-06-17 11:22:17 +01:00
int? TargetPort,
int? TargetRudpPort,
2026-05-06 13:22:01 +01:00
string SchemaPath);
sealed record QuicRealtimeServeConfig(int Port, int Frames, string Delivery, int IntervalMs);
2026-05-06 13:22:01 +01:00
static class InteropPeerShared
{
public const string InteropDocumentType = "cultnet.interop-note";
public const string InteropSchemaVersion = "cultnet.interop_note.v0";
2026-05-16 10:20:08 +01:00
public const string MutationIntentDocumentType = "cultnet.interop-note-mutation-intent";
Consolidate CultNet schema contracts into CultLib/contracts/cultnet REC-032 commit 1: move the canonical 33 TS + witness-artifact-bundle schema files (34 total) into runtime-neutral CultLib/contracts/cultnet, move the interop-note schema (and delete the zero-consumer discovery-* schemas per D3, tagged parked/cultnet-discovery-contracts) into contracts/cultnet/interop, and delete the 27 C# and 24 Rust duplicate copies after confirming byte-for-byte equivalence (only cultnet.snapshot-request differs: TS carries shardId/shardEpoch, a known drift already resolved in favor of the TS shape). Rewrite every $id from https://github.com/GameCult/cultnet-ts/contracts/ and .../cultnet-ts/integration/contracts/ to https://github.com/GameCult/CultLib/contracts/cultnet/(interop/); give the two shard-catalog schemas URL ids per D1, replacing the bare cultnet.shard_catalog_*.v0 identifiers; leave ghostlight.agent-state's Ghostlight-owned $id untouched per D5. Repoint every runtime's load path and id literal at the new location: C# csproj EmbeddedResource + LogicalName, Rust include_str! paths, TS contracts.ts imports + swarm-contracts manifest/tooling + package.json files/prepack, and the id/path literals in the C#, Rust, Python and Kotlin interop tests and peers. Python and Kotlin still build their own schema JSON in-process (D2); only their base constants, id literals, and (for Kotlin) contentHash literals were swapped. Every Kotlin contentHash was recomputed from the final canonical files using the runtime's own stable-stringify + SHA-256 function; all matched the planner's precomputed values except cultnet.schema-catalog-response, where the planner's proposed value did not match recomputation and the freshly computed value was used instead. REC-032, decisions D1/D2/D3/D5. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 17:27:41 +02:00
public const string MutationIntentSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-intent.schema.json";
2026-05-16 10:20:08 +01:00
public const string MutationIntentSchemaVersion = "cultnet.interop_note_mutation_intent.v0";
public const string MutationReceiptDocumentType = "cultnet.interop-note-mutation-receipt";
Consolidate CultNet schema contracts into CultLib/contracts/cultnet REC-032 commit 1: move the canonical 33 TS + witness-artifact-bundle schema files (34 total) into runtime-neutral CultLib/contracts/cultnet, move the interop-note schema (and delete the zero-consumer discovery-* schemas per D3, tagged parked/cultnet-discovery-contracts) into contracts/cultnet/interop, and delete the 27 C# and 24 Rust duplicate copies after confirming byte-for-byte equivalence (only cultnet.snapshot-request differs: TS carries shardId/shardEpoch, a known drift already resolved in favor of the TS shape). Rewrite every $id from https://github.com/GameCult/cultnet-ts/contracts/ and .../cultnet-ts/integration/contracts/ to https://github.com/GameCult/CultLib/contracts/cultnet/(interop/); give the two shard-catalog schemas URL ids per D1, replacing the bare cultnet.shard_catalog_*.v0 identifiers; leave ghostlight.agent-state's Ghostlight-owned $id untouched per D5. Repoint every runtime's load path and id literal at the new location: C# csproj EmbeddedResource + LogicalName, Rust include_str! paths, TS contracts.ts imports + swarm-contracts manifest/tooling + package.json files/prepack, and the id/path literals in the C#, Rust, Python and Kotlin interop tests and peers. Python and Kotlin still build their own schema JSON in-process (D2); only their base constants, id literals, and (for Kotlin) contentHash literals were swapped. Every Kotlin contentHash was recomputed from the final canonical files using the runtime's own stable-stringify + SHA-256 function; all matched the planner's precomputed values except cultnet.schema-catalog-response, where the planner's proposed value did not match recomputation and the freshly computed value was used instead. REC-032, decisions D1/D2/D3/D5. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 17:27:41 +02:00
public const string MutationReceiptSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-receipt.schema.json";
2026-05-16 10:20:08 +01:00
public const string MutationReceiptSchemaVersion = "cultnet.interop_note_mutation_receipt.v0";
public const string FireCommandDocumentType = "cultnet.interop-fire-weapon-command";
Consolidate CultNet schema contracts into CultLib/contracts/cultnet REC-032 commit 1: move the canonical 33 TS + witness-artifact-bundle schema files (34 total) into runtime-neutral CultLib/contracts/cultnet, move the interop-note schema (and delete the zero-consumer discovery-* schemas per D3, tagged parked/cultnet-discovery-contracts) into contracts/cultnet/interop, and delete the 27 C# and 24 Rust duplicate copies after confirming byte-for-byte equivalence (only cultnet.snapshot-request differs: TS carries shardId/shardEpoch, a known drift already resolved in favor of the TS shape). Rewrite every $id from https://github.com/GameCult/cultnet-ts/contracts/ and .../cultnet-ts/integration/contracts/ to https://github.com/GameCult/CultLib/contracts/cultnet/(interop/); give the two shard-catalog schemas URL ids per D1, replacing the bare cultnet.shard_catalog_*.v0 identifiers; leave ghostlight.agent-state's Ghostlight-owned $id untouched per D5. Repoint every runtime's load path and id literal at the new location: C# csproj EmbeddedResource + LogicalName, Rust include_str! paths, TS contracts.ts imports + swarm-contracts manifest/tooling + package.json files/prepack, and the id/path literals in the C#, Rust, Python and Kotlin interop tests and peers. Python and Kotlin still build their own schema JSON in-process (D2); only their base constants, id literals, and (for Kotlin) contentHash literals were swapped. Every Kotlin contentHash was recomputed from the final canonical files using the runtime's own stable-stringify + SHA-256 function; all matched the planner's precomputed values except cultnet.schema-catalog-response, where the planner's proposed value did not match recomputation and the freshly computed value was used instead. REC-032, decisions D1/D2/D3/D5. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 17:27:41 +02:00
public const string FireCommandSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-command.schema.json";
2026-05-16 10:20:08 +01:00
public const string FireCommandSchemaVersion = "cultnet.interop_fire_weapon_command.v0";
public const string FireReceiptDocumentType = "cultnet.interop-fire-weapon-receipt";
Consolidate CultNet schema contracts into CultLib/contracts/cultnet REC-032 commit 1: move the canonical 33 TS + witness-artifact-bundle schema files (34 total) into runtime-neutral CultLib/contracts/cultnet, move the interop-note schema (and delete the zero-consumer discovery-* schemas per D3, tagged parked/cultnet-discovery-contracts) into contracts/cultnet/interop, and delete the 27 C# and 24 Rust duplicate copies after confirming byte-for-byte equivalence (only cultnet.snapshot-request differs: TS carries shardId/shardEpoch, a known drift already resolved in favor of the TS shape). Rewrite every $id from https://github.com/GameCult/cultnet-ts/contracts/ and .../cultnet-ts/integration/contracts/ to https://github.com/GameCult/CultLib/contracts/cultnet/(interop/); give the two shard-catalog schemas URL ids per D1, replacing the bare cultnet.shard_catalog_*.v0 identifiers; leave ghostlight.agent-state's Ghostlight-owned $id untouched per D5. Repoint every runtime's load path and id literal at the new location: C# csproj EmbeddedResource + LogicalName, Rust include_str! paths, TS contracts.ts imports + swarm-contracts manifest/tooling + package.json files/prepack, and the id/path literals in the C#, Rust, Python and Kotlin interop tests and peers. Python and Kotlin still build their own schema JSON in-process (D2); only their base constants, id literals, and (for Kotlin) contentHash literals were swapped. Every Kotlin contentHash was recomputed from the final canonical files using the runtime's own stable-stringify + SHA-256 function; all matched the planner's precomputed values except cultnet.schema-catalog-response, where the planner's proposed value did not match recomputation and the freshly computed value was used instead. REC-032, decisions D1/D2/D3/D5. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 17:27:41 +02:00
public const string FireReceiptSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-receipt.schema.json";
2026-05-16 10:20:08 +01:00
public const string FireReceiptSchemaVersion = "cultnet.interop_fire_weapon_receipt.v0";
2026-05-06 13:22:01 +01:00
public const string DiscoveryProbeSchemaVersion = "cultnet.discovery_probe.v0";
public const string DiscoveryAnnounceSchemaVersion = "cultnet.discovery_announce.v0";
2026-06-17 11:22:17 +01:00
public const uint RudpInteropConnectionId = 0x43554C54;
public const int RudpInteropMaxFragmentBytes = 1024;
public const long RudpInteropResendDelayMs = 25;
2026-05-06 13:22:01 +01:00
public static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
2026-05-16 10:20:08 +01:00
public static CultNetDocumentMutationContract InteractionContract()
{
return new CultNetDocumentMutationContract
{
DocumentType = InteropDocumentType,
PayloadSchemaVersion = InteropSchemaVersion,
Operations = ["snapshot", "documentPut", "intentSubmit", "receiptWatch"],
Authority = CultNetMutationAuthorities.Runtime,
IntentDocumentTypes = [MutationIntentDocumentType, FireCommandDocumentType],
ReceiptDocumentTypes = [MutationReceiptDocumentType, FireReceiptDocumentType],
Notes = []
};
}
2026-05-06 13:22:01 +01:00
}
[CultDocument(InteropPeerShared.InteropDocumentType, InteropPeerShared.InteropSchemaVersion)]
[MessagePackObject]
public sealed class CultNetInteropNote
2026-05-06 13:22:01 +01:00
{
[Key(0)] public string SchemaVersion { get; set; } = InteropPeerShared.InteropSchemaVersion;
[Key(1)] [CultName] 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<string>();
2026-05-06 13:22:01 +01:00
}
[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<string>();
}
2026-05-16 10:20:08 +01:00
[CultDocument(InteropPeerShared.MutationIntentDocumentType, InteropPeerShared.MutationIntentSchemaVersion)]
[MessagePackObject]
public sealed class CultNetInteropMutationIntent
{
[Key(0)] public string SchemaVersion { get; set; } = InteropPeerShared.MutationIntentSchemaVersion;
[Key(1)] [CultName] public string IntentId { get; set; } = string.Empty;
[Key(2)] public string TargetDocumentId { get; set; } = string.Empty;
[Key(3)] public string AppendBody { get; set; } = string.Empty;
[Key(4)] public string AppendTag { get; set; } = string.Empty;
}
[CultDocument(InteropPeerShared.MutationReceiptDocumentType, InteropPeerShared.MutationReceiptSchemaVersion)]
[MessagePackObject]
public sealed class CultNetInteropMutationReceipt
{
[Key(0)] public string SchemaVersion { get; set; } = InteropPeerShared.MutationReceiptSchemaVersion;
[Key(1)] [CultName] public string IntentId { get; set; } = string.Empty;
[Key(2)] public bool Accepted { get; set; }
[Key(3)] public string DocumentId { get; set; } = string.Empty;
[Key(4)] public string Body { get; set; } = string.Empty;
[Key(5)] public string[] Tags { get; set; } = Array.Empty<string>();
}
[CultDocument(InteropPeerShared.FireCommandDocumentType, InteropPeerShared.FireCommandSchemaVersion)]
[MessagePackObject]
public sealed class CultNetInteropFireCommand
{
[Key(0)] public string SchemaVersion { get; set; } = InteropPeerShared.FireCommandSchemaVersion;
[Key(1)] [CultName] public string CommandId { get; set; } = string.Empty;
[Key(2)] public string CharacterId { get; set; } = string.Empty;
[Key(3)] public string WeaponId { get; set; } = string.Empty;
}
[CultDocument(InteropPeerShared.FireReceiptDocumentType, InteropPeerShared.FireReceiptSchemaVersion)]
[MessagePackObject]
public sealed class CultNetInteropFireReceipt
{
[Key(0)] public string SchemaVersion { get; set; } = InteropPeerShared.FireReceiptSchemaVersion;
[Key(1)] [CultName] public string CommandId { get; set; } = string.Empty;
[Key(2)] public bool Accepted { get; set; }
[Key(3)] public string CharacterId { get; set; } = string.Empty;
[Key(4)] public string WeaponId { get; set; } = string.Empty;
[Key(5)] public int ShotsFired { get; set; }
[Key(6)] public int AmmoRemaining { get; set; }
}
2026-05-06 13:22:01 +01:00
[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<string>();
2026-06-14 21:20:52 +01:00
[Key("transportProfiles")] public CultNetTransportProfile[]? TransportProfiles { get; set; }
2026-05-06 13:22:01 +01:00
[Key("supportsSchemaCatalog")] public bool SupportsSchemaCatalog { get; set; }
}