From a40e0f248b5ae100e1aecba9b8a249b9e43957fe Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 15 Jun 2026 23:52:29 +0100 Subject: [PATCH] Add C# RUDP channel ergonomics --- src/GameCult.Networking/CultNetTransport.cs | 156 ++++++++++++++++++ .../NetworkingTests.cs | 59 +++++++ 2 files changed, 215 insertions(+) diff --git a/src/GameCult.Networking/CultNetTransport.cs b/src/GameCult.Networking/CultNetTransport.cs index f379a092..cbf32584 100644 --- a/src/GameCult.Networking/CultNetTransport.cs +++ b/src/GameCult.Networking/CultNetTransport.cs @@ -1272,6 +1272,59 @@ namespace GameCult.Networking SendPacket(_session.CreateConnect(NowMs(), payload ?? Array.Empty())); } + /// + /// Sends the client connect packet with a UTF-8 payload. + /// + public void Connect(string payload) + { + Connect(Encoding.UTF8.GetBytes(payload ?? string.Empty)); + } + + /// + /// Sends the client connect packet and polls until the handshake completes or times out. + /// + public bool ConnectAndWait( + byte[]? payload = null, + TimeSpan? timeout = null, + TimeSpan? pollInterval = null) + { + Connect(payload); + return AwaitConnected(timeout, pollInterval); + } + + /// + /// Sends the client connect packet with a UTF-8 payload and polls until the handshake completes or times out. + /// + public bool ConnectAndWait( + string payload, + TimeSpan? timeout = null, + TimeSpan? pollInterval = null) + { + return ConnectAndWait(Encoding.UTF8.GetBytes(payload ?? string.Empty), timeout, pollInterval); + } + + /// + /// Polls the transport until the RUDP handshake completes or times out. + /// + public bool AwaitConnected(TimeSpan? timeout = null, TimeSpan? pollInterval = null) + { + var deadline = DateTimeOffset.UtcNow.Add(timeout ?? TimeSpan.FromSeconds(1)); + var interval = pollInterval ?? TimeSpan.FromMilliseconds(5); + while (DateTimeOffset.UtcNow < deadline) + { + _ = ReceiveOnce(); + PollResends(); + if (Connected) + { + return true; + } + + Thread.Sleep(interval); + } + + return Connected; + } + /// /// Sends a logical transport frame through the RUDP session. /// @@ -1284,6 +1337,22 @@ namespace GameCult.Networking _stats.FramesSent++; } + /// + /// Sends a reliable ordered schema-channel payload. + /// + public void SendSchema(byte[] payload) + { + Send("schema", payload); + } + + /// + /// Sends a reliable ordered schema-channel UTF-8 payload. + /// + public void SendSchema(string payload) + { + SendSchema(Encoding.UTF8.GetBytes(payload ?? string.Empty)); + } + /// /// Sends a CultNet schema-v0 message on the reliable ordered schema channel. /// @@ -1294,6 +1363,38 @@ namespace GameCult.Networking Send("schema", CultNetSchemaMessageSerialization.Serialize(message)); } + /// + /// Sends an unreliable sequenced latest-state payload. + /// + public void SendLatest(byte[] payload) + { + Send("latest", payload); + } + + /// + /// Sends an unreliable sequenced latest-state UTF-8 payload. + /// + public void SendLatest(string payload) + { + SendLatest(Encoding.UTF8.GetBytes(payload ?? string.Empty)); + } + + /// + /// Sends an unreliable unordered realtime payload. + /// + public void SendRealtime(byte[] payload) + { + Send("realtime", payload); + } + + /// + /// Sends an unreliable unordered realtime UTF-8 payload. + /// + public void SendRealtime(string payload) + { + SendRealtime(Encoding.UTF8.GetBytes(payload ?? string.Empty)); + } + /// /// Attempts to receive the next CultNet schema-v0 message. /// @@ -1325,6 +1426,61 @@ namespace GameCult.Networking return ReceiveSchemaMessageOnce() as TMessage; } + /// + /// Polls until a delivered transport frame matches the predicate or the timeout expires. + /// + public CultNetTransportFrame? ReceiveUntil( + TimeSpan timeout, + Func? predicate = null, + TimeSpan? pollInterval = null) + { + var deadline = DateTimeOffset.UtcNow.Add(timeout); + var interval = pollInterval ?? TimeSpan.FromMilliseconds(5); + predicate ??= _ => true; + while (DateTimeOffset.UtcNow < deadline) + { + var frame = ReceiveOnce(); + if (frame != null && predicate(frame)) + { + return frame; + } + + PollResends(); + Thread.Sleep(interval); + } + + return null; + } + + /// + /// Polls until a schema-channel payload arrives or the timeout expires. + /// + public byte[]? ReceiveSchema(TimeSpan timeout, TimeSpan? pollInterval = null) + { + return ReceiveUntil( + timeout, + frame => string.Equals(frame.ChannelId, "schema", StringComparison.Ordinal), + pollInterval)?.Payload; + } + + /// + /// Polls until a CultNet schema-v0 message arrives or the timeout expires. + /// + public ICultNetSchemaMessage? ReceiveSchemaMessage(TimeSpan timeout, TimeSpan? pollInterval = null) + { + var payload = ReceiveSchema(timeout, pollInterval); + return payload == null ? null : CultNetSchemaMessageSerialization.Deserialize(payload); + } + + /// + /// Polls until a CultNet schema-v0 message of the requested type arrives or the timeout expires. + /// + public TMessage? ReceiveSchemaMessage(TimeSpan timeout, TimeSpan? pollInterval = null) + where TMessage : class, ICultNetSchemaMessage + { + return ReceiveSchemaMessage(timeout, pollInterval) as TMessage; + } + /// /// Sends a transport-level disconnect packet. /// diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index b4a5dbd3..60f7236f 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -721,6 +721,65 @@ namespace GameCult.Networking.Tests Assert.That(profile.Transports[0].Protocol, Is.EqualTo("rudp")); } + [Test] + public void RudpSocketTransport_ErgonomicHelpersCarryNamedChannels() + { + using var serverSocket = BindUdpSocket(); + using var clientSocket = BindUdpSocket(); + var serverEndPoint = serverSocket.LocalEndPoint!; + const uint connectionId = 0x10203044; + using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions + { + RuntimeId = "csharp-rudp-helper-server", + Socket = serverSocket, + Mode = CultNetRudpSocketMode.Server, + ConnectionId = connectionId, + InitialSequence = 100, + ResendDelayMs = 25 + }); + using var client = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions + { + RuntimeId = "csharp-rudp-helper-client", + Socket = clientSocket, + Mode = CultNetRudpSocketMode.Client, + RemoteEndPoint = serverEndPoint, + ConnectionId = connectionId, + InitialSequence = 1, + ResendDelayMs = 25 + }); + + client.Connect("join"); + PumpRudpHandshake(client, server); + + client.SendSchema("client-state"); + var schemaPayload = server.ReceiveSchema(TimeSpan.FromSeconds(1)); + Assert.That(schemaPayload, Is.Not.Null); + Assert.That(Encoding.UTF8.GetString(schemaPayload!), Is.EqualTo("client-state")); + + server.SendSchemaMessage(new CultNetHelloMessage + { + RuntimeId = "csharp-rudp-helper-server", + RuntimeKind = "csharp" + }); + var hello = client.ReceiveSchemaMessage(TimeSpan.FromSeconds(1)); + Assert.That(hello, Is.Not.Null); + Assert.That(hello!.RuntimeId, Is.EqualTo("csharp-rudp-helper-server")); + + client.SendLatest("latest-state"); + var latest = server.ReceiveUntil( + TimeSpan.FromSeconds(1), + frame => string.Equals(frame.ChannelId, "latest", StringComparison.Ordinal)); + Assert.That(latest, Is.Not.Null); + Assert.That(Encoding.UTF8.GetString(latest!.Payload), Is.EqualTo("latest-state")); + + client.SendRealtime("tick"); + var realtime = server.ReceiveUntil( + TimeSpan.FromSeconds(1), + frame => string.Equals(frame.ChannelId, "realtime", StringComparison.Ordinal)); + Assert.That(realtime, Is.Not.Null); + Assert.That(Encoding.UTF8.GetString(realtime!.Payload), Is.EqualTo("tick")); + } + [Test] public void RudpSocketTransport_CarriesFragmentedReliableOrderedSchemaFrames() {