using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using GameCult.Caching; using GameCult.Mesh; using GameCult.Mesh.Quic; 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 | rudp-serve-once | rudp-dial-once | rudp-serve-message-once | rudp-dial-message-once | quic-realtime-serve"); } 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; case "rudp-serve-message-once": RudpServeMessageOnce(options); return 0; case "rudp-dial-message-once": RudpDialMessageOnce(options); return 0; case "quic-realtime-serve": await QuicRealtimeServeAsync(ParseQuicRealtimeServeConfig(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.ForDocument( schemaId: schemaRegistration.SchemaId, payloadSerializer: SerializeInteropNotePayload, payloadDeserializer: DeserializeInteropNotePayload)); RegisterCapabilityBindings(documentRegistry); var customSchemaRegistry = new CultNetSchemaRegistry().Register(schemaRegistration); var cache = new CultCache(); var localNote = BuildInteropNote(config.RuntimeId, config.DisplayName); await cache.AddAsync(localNote, new CultRecordHandle(new CultRecordKey(localNote.DocumentId))); using var udpSocket = CreateDiscoverySocket(config.DiscoveryPort, config.DiscoveryGroup); using var tcpListener = new TcpListener(IPAddress.Parse(config.BindHost), config.TcpPort); using var rudpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); rudpSocket.Bind(new IPEndPoint(IPAddress.Parse(config.BindHost), config.RudpPort)); rudpSocket.ReceiveTimeout = 20; 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); _ = RunRudpServerAsync(rudpSocket, config, cache, documentRegistry, customSchemaRegistry, cancellationSource.Token); WriteJsonLine(new { status = "ready", mode = "serve", runtimeId = config.RuntimeId, runtimeKind = config.RuntimeKind, tcpPort = config.TcpPort, rudpPort = config.RudpPort, 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, transportProfiles = peer.TransportProfiles, 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( schemaId: schemaRegistration.SchemaId, payloadSerializer: SerializeInteropNotePayload, payloadDeserializer: DeserializeInteropNotePayload)); RegisterCapabilityBindings(documentRegistry); using var transport = await OpenDialTransportAsync(config); var transportName = config.TargetRudpPort.HasValue ? "rudp" : "tcp_framed"; 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 = CreateDialTransportProfiles(config), SupportsSchemaCatalog = true }); var remoteHello = await ExpectMessageAsync(transport, CultNetSchemaVersions.Hello); await SendMessageAsync(transport, new CultNetSchemaCatalogRequestMessage { MessageId = $"{config.RuntimeId}-catalog", IncludeSchemaJson = true, SchemaIds = [schemaRegistration.SchemaId], Kinds = ["document_payload"] }); var catalog = await ExpectMessageAsync(transport, CultNetSchemaVersions.SchemaCatalogResponse); await SendMessageAsync(transport, new CultNetSnapshotRequestMessage { MessageId = $"{config.RuntimeId}-snapshot", SchemaIds = [schemaRegistration.SchemaId], RecordKeys = [$"note:{remoteHello.RuntimeId}"] }); var snapshot = await ExpectMessageAsync(transport, 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)); var mutation = await MutateRemoteNoteAsync(transport, cache, documentRegistry, config.RuntimeId, note); var fireReceipt = await FireRemoteWeaponAsync(transport, cache, documentRegistry, config.RuntimeId, remoteHello.RuntimeId); WriteJsonLine(new { mode = "dial", transport = transportName, runtimeId = config.RuntimeId, targetHost = config.TargetHost, targetPort = config.TargetRudpPort ?? 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, transportProfiles = remoteHello.TransportProfiles, supportsSchemaCatalog = remoteHello.SupportsSchemaCatalog }, hasInteropSchema, retrievedNote = new { schemaVersion = note.SchemaVersion, documentId = note.DocumentId, authorRuntimeId = note.AuthorRuntimeId, title = note.Title, body = note.Body, tags = note.Tags }, mutatedNote = mutation, fireReceipt }); } static async Task MutateRemoteNoteAsync( ISchemaMessageTransport transport, 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}" }; await SendMessageAsync(transport, documentRegistry.CreateRawDocumentPutMessage( $"{runtimeId}-decorate-put", new CultRecordHandle(new CultRecordKey(intent.IntentId)), intent)); await documentRegistry.ApplyRawDocumentPutMessageAsync( cache, await ExpectMessageAsync(transport, CultNetSchemaVersions.DocumentPutRaw)); var mutated = await documentRegistry.ApplyRawDocumentPutMessageAsync( cache, await ExpectMessageAsync(transport, CultNetSchemaVersions.DocumentPutRaw)); return new { schemaVersion = mutated.SchemaVersion, documentId = mutated.DocumentId, authorRuntimeId = mutated.AuthorRuntimeId, title = mutated.Title, body = mutated.Body, tags = mutated.Tags }; } static async Task FireRemoteWeaponAsync( ISchemaMessageTransport transport, CultCache cache, CultNetDocumentRegistry documentRegistry, string runtimeId, string remoteRuntimeId) { var command = new CultNetInteropFireCommand { CommandId = $"{runtimeId}-fire", CharacterId = remoteRuntimeId, WeaponId = "interop-rifle" }; await SendMessageAsync(transport, documentRegistry.CreateRawDocumentPutMessage( $"{runtimeId}-fire-put", new CultRecordHandle(new CultRecordKey(command.CommandId)), command)); var receipt = await documentRegistry.ApplyRawDocumentPutMessageAsync( cache, await ExpectMessageAsync(transport, CultNetSchemaVersions.DocumentPutRaw)); return new { schemaVersion = receipt.SchemaVersion, commandId = receipt.CommandId, accepted = receipt.Accepted, characterId = receipt.CharacterId, weaponId = receipt.WeaponId, shotsFired = receipt.ShotsFired, ammoRemaining = receipt.AmmoRemaining }; } 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], TransportProfiles = CreateInteropTransportProfiles(config.RuntimeId, config.AdvertiseHost, config.TcpPort, config.RudpPort), 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(); using var transport = new TcpSchemaMessageTransport(new TcpFramedTransportConnection( stream, CreateTcpFramedTransportProfile(config.RuntimeId, config.AdvertiseHost, config.TcpPort))); while (!cancellationToken.IsCancellationRequested) { ICultNetSchemaMessage message; try { message = await transport.ReadMessageAsync(cancellationToken); } catch (EndOfStreamException) { break; } await HandleServerMessageAsync(transport, message, config, cache, documentRegistry, customSchemaRegistry, cancellationToken); } } catch (Exception error) { WriteLog("tcpServeError", new { runtimeId = config.RuntimeId, error = error.Message }); } }, cancellationToken); } } static Task RunRudpServerAsync( Socket socket, ServeConfig config, CultCache cache, CultNetDocumentRegistry documentRegistry, CultNetSchemaRegistry customSchemaRegistry, CancellationToken cancellationToken) { return Task.Run(async () => { var peers = new Dictionary(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; } } static async Task HandleRawPutAsync( ISchemaMessageSender transport, ServeConfig config, CultCache cache, CultNetDocumentRegistry documentRegistry, CultNetDocumentPutRawMessage rawPut, CancellationToken cancellationToken) { switch (rawPut.Document.SchemaId) { case InteropPeerShared.MutationIntentSchemaId: { var intent = await documentRegistry.ApplyRawDocumentPutMessageAsync(cache, rawPut); var note = cache.AllEntries .OfType() .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(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"); await SendMessageAsync(transport, documentRegistry.CreateRawDocumentPutMessage( $"{config.RuntimeId}-mutation-receipt", new CultRecordHandle(new CultRecordKey(receipt.IntentId)), receipt, options), cancellationToken); await SendMessageAsync(transport, documentRegistry.CreateRawDocumentPutMessage( $"{config.RuntimeId}-mutated-note", new CultRecordHandle(new CultRecordKey(note.DocumentId)), note, options), cancellationToken); break; } case InteropPeerShared.FireCommandSchemaId: { var command = await documentRegistry.ApplyRawDocumentPutMessageAsync(cache, rawPut); var receipt = new CultNetInteropFireReceipt { CommandId = command.CommandId, Accepted = true, CharacterId = command.CharacterId, WeaponId = command.WeaponId, ShotsFired = 1, AmmoRemaining = 29 }; await SendMessageAsync(transport, documentRegistry.CreateRawDocumentPutMessage( $"{config.RuntimeId}-fire-receipt", new CultRecordHandle(new CultRecordKey(receipt.CommandId)), receipt, ResponseOptions(config, "side-effect")), cancellationToken); break; } } } 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(); 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; } return new CultNetSchemaCatalogResponseMessage { MessageId = request.MessageId, Schemas = schemas }; } static CultNetTransportProfile CreateTcpFramedTransportProfile(string runtimeId, string host, int port) { return NormalizeInteropTransportProfile(CultNetTransportProfiles.CreateTcpFramed(runtimeId, new TcpFramedTransportProfileOptions { TransportId = "interop-tcp", Host = host, Port = port })); } 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(); 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 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); } static void RudpServeOnce(Dictionary 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; 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"); 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; 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, 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) { RequireRudpFrameBytes(frame, "schema", expectedClientPayload); transport.Send("schema", serverPayload); 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 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."); } static void RudpServeMessageOnce(Dictionary 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 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) { RequireRudpFrameBytes(frame, expectedChannelId, Ascii(expectedPayload)); } static void RequireRudpFrameBytes(CultNetTransportFrame frame, string expectedChannelId, byte[] expectedPayload) { if (!string.Equals(frame.ChannelId, expectedChannelId, StringComparison.Ordinal) || !frame.Payload.SequenceEqual(expectedPayload)) { throw new InvalidOperationException( $"Unexpected RUDP frame: channel={frame.ChannelId}, payload={Convert.ToHexString(frame.Payload)}."); } } static async Task SendMessageAsync( ISchemaMessageSender transport, TMessage message, CancellationToken cancellationToken = default) where TMessage : ICultNetSchemaMessage { var payload = CultNetSchemaMessageSerialization.Serialize(message); await transport.SendMessageAsync(payload, cancellationToken); } static async Task ExpectMessageAsync( ISchemaMessageTransport transport, string expectedSchemaVersion) where TMessage : class, ICultNetSchemaMessage { var message = await transport.ReadMessageAsync(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 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(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() }; } static void RegisterCapabilityBindings(CultNetDocumentRegistry registry) { registry .Register(CultNetDocumentBinding.ForDocument( schemaId: InteropPeerShared.MutationIntentSchemaId, payloadSerializer: value => MessagePackSerializer.Serialize(value, CultNetSchemaMessageSerialization.Options), payloadDeserializer: payload => MessagePackSerializer.Deserialize(payload, CultNetSchemaMessageSerialization.Options))) .Register(CultNetDocumentBinding.ForDocument( schemaId: InteropPeerShared.MutationReceiptSchemaId, payloadSerializer: value => MessagePackSerializer.Serialize(value, CultNetSchemaMessageSerialization.Options), payloadDeserializer: payload => MessagePackSerializer.Deserialize(payload, CultNetSchemaMessageSerialization.Options))) .Register(CultNetDocumentBinding.ForDocument( schemaId: InteropPeerShared.FireCommandSchemaId, payloadSerializer: value => MessagePackSerializer.Serialize(value, CultNetSchemaMessageSerialization.Options), payloadDeserializer: payload => MessagePackSerializer.Deserialize(payload, CultNetSchemaMessageSerialization.Options))) .Register(CultNetDocumentBinding.ForDocument( schemaId: InteropPeerShared.FireReceiptSchemaId, payloadSerializer: value => MessagePackSerializer.Serialize(value, CultNetSchemaMessageSerialization.Options), payloadDeserializer: payload => MessagePackSerializer.Deserialize(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] }; } static async Task WaitForeverAsync(CancellationToken cancellationToken) { try { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); } catch (OperationCanceledException) { } } 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)); 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); } } static Task WatchStdinCloseAsync() { return Task.Run(async () => { while (await Console.In.ReadLineAsync() != null) { } }); } static QuicRealtimeServeConfig ParseQuicRealtimeServeConfig(Dictionary 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); } 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) { var tcpPort = ParseIntArg(options, "tcp-port"); 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"), tcpPort, ParseOptionalIntArg(options, "rudp-port", tcpPort), 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"), 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, 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); } static void WriteLog(string @event, object payload) { Console.Error.WriteLine(JsonSerializer.Serialize(new Dictionary { ["event"] = @event, ["payload"] = payload }, InteropPeerShared.JsonOptions)); } interface ISchemaMessageSender { Task SendMessageAsync(byte[] payload, CancellationToken cancellationToken = default); } interface ISchemaMessageTransport : ISchemaMessageSender, IDisposable { Task 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 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 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(); } } sealed record ServeConfig( string RuntimeId, string RuntimeKind, string DisplayName, string AgentId, string BindHost, string AdvertiseHost, int TcpPort, int RudpPort, int DiscoveryPort, IPAddress DiscoveryGroup, string SchemaPath); sealed record DialConfig( string RuntimeId, string RuntimeKind, string DisplayName, string AgentId, string TargetHost, int? TargetPort, int? TargetRudpPort, string SchemaPath); sealed record QuicRealtimeServeConfig(int Port, int Frames, string Delivery, int IntervalMs); static class InteropPeerShared { public const string InteropDocumentType = "cultnet.interop-note"; public const string InteropSchemaVersion = "cultnet.interop_note.v0"; public const string MutationIntentDocumentType = "cultnet.interop-note-mutation-intent"; public const string MutationIntentSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-intent.schema.json"; public const string MutationIntentSchemaVersion = "cultnet.interop_note_mutation_intent.v0"; public const string MutationReceiptDocumentType = "cultnet.interop-note-mutation-receipt"; public const string MutationReceiptSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-receipt.schema.json"; public const string MutationReceiptSchemaVersion = "cultnet.interop_note_mutation_receipt.v0"; public const string FireCommandDocumentType = "cultnet.interop-fire-weapon-command"; public const string FireCommandSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-command.schema.json"; public const string FireCommandSchemaVersion = "cultnet.interop_fire_weapon_command.v0"; public const string FireReceiptDocumentType = "cultnet.interop-fire-weapon-receipt"; public const string FireReceiptSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-receipt.schema.json"; public const string FireReceiptSchemaVersion = "cultnet.interop_fire_weapon_receipt.v0"; public const string DiscoveryProbeSchemaVersion = "cultnet.discovery_probe.v0"; public const string DiscoveryAnnounceSchemaVersion = "cultnet.discovery_announce.v0"; public const uint RudpInteropConnectionId = 0x43554C54; public const int RudpInteropMaxFragmentBytes = 1024; public const long RudpInteropResendDelayMs = 25; public static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; 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 = [] }; } } [CultDocument(InteropPeerShared.InteropDocumentType, InteropPeerShared.InteropSchemaVersion)] [MessagePackObject] public sealed class CultNetInteropNote { [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(); } [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(); } [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(); } [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; } } [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("transportProfiles")] public CultNetTransportProfile[]? TransportProfiles { get; set; } [Key("supportsSchemaCatalog")] public bool SupportsSchemaCatalog { get; set; } }