Add C# CultMesh connected RUDP clients

This commit is contained in:
Meta Vens 2026-06-17 05:16:46 +01:00
commit 5af8ed57f7
7 changed files with 150 additions and 21 deletions

View file

@ -286,8 +286,11 @@ Current progress:
TypeScript, Python, Kotlin, and Rust: it parses `rudp://host:port` endpoints,
creates RUDP client/server transports from endpoint or peer-card contact
hints, and can create a RUDP client from the first peer authorized by the
authority lease catalog for a Verse role. RUDP packet/session behavior remains
owned by `GameCult.Networking`.
authority lease catalog for a Verse role. The facade can also return a
connected RUDP client from a direct endpoint, peer card, or authorized peer
lookup, so schema-message helpers can ride the RUDP pipe without caller-side
handshake ceremony. RUDP packet/session behavior remains owned by
`GameCult.Networking`.
- TypeScript now has a `CultNetRudpReconnectLoop` that consumes the shared
controller while keeping socket construction caller-owned. A closed RUDP
transport schedules the next attempt with the portable policy, opens a fresh

View file

@ -30,7 +30,7 @@ CultMesh.
| Runtime | Role | Claimed parity | Not claimed |
| --- | --- | --- | --- |
| C# | Reference implementation and production game/server substrate | CultCache typed documents and SoA, CultNet schema-v0, LiteNetLib production path with adapter profile, shared transport profiles, shared RUDP socket transport, RUDP reconnect policy/controller and reconnect-loop helper, LiteNetLib client reconnect-controller adoption, schema-client transport port for service request/response clients, CultMesh node/session/catalog/stream/simulation/RUDP surfaces, authorized peer lookup | No need to abandon LiteNetLib before the CultNet adapter fully owns production service adoption |
| C# | Reference implementation and production game/server substrate | CultCache typed documents and SoA, CultNet schema-v0, LiteNetLib production path with adapter profile, shared transport profiles, shared RUDP socket transport, connected RUDP CultMesh client helpers, RUDP reconnect policy/controller and reconnect-loop helper, LiteNetLib client reconnect-controller adoption, schema-client transport port for service request/response clients, CultMesh node/session/catalog/stream/simulation/RUDP surfaces, authorized peer lookup | No need to abandon LiteNetLib before the CultNet adapter fully owns production service adoption |
| Python | Package runtime, local daemon endpoint, raw interop peer, typed state participant | CultCache/CultNet/CultMesh package surface, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup, shard catalogs/logs, schema-transport factory for raw client request/subscription flows, local TCP CultMesh server, session/prediction/simulation fact helpers, RUDP codec/session/socket transport, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, benchmark evidence against C# cache paths | Universal C# throughput, full C# daemon/server stack, and RUDP as the only public service pipe |
| TypeScript | Node/browser-adjacent projection and interop runtime | CultCache parity tests, CultNet schema-v0 peer, advertised schema/shard catalog wire contracts plus request/sync helpers, transport profiles, TCP-framed transport port and peer helper, shared RUDP socket transport, RUDP reconnect policy/controller, RUDP reconnect-loop helper, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup | Full C# game session/server stack |
| Rust | Low-level cache/transport/runtime substrate | CultCache/CultNet interop lane, schema and shard catalog helpers, transport profiles, TCP-framed port, shared RUDP codec/session/socket transport, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, small CultMesh facade for schema registry, shard catalog, peer cards, authority leases, authorized peer lookup, RUDP endpoint parsing, and peer-card/authorized-peer RUDP client construction | Full CultMesh game-session/server facade, stream catalog, or production game-session server |

View file

@ -224,6 +224,21 @@ namespace GameCult.Mesh
public CultNetReconnectPolicy? ReconnectPolicy { get; set; }
}
/// <summary>
/// Options for CultMesh-branded connected RUDP client helpers.
/// </summary>
public sealed class CultMeshRudpClientOptions
{
/// <summary>Gets or sets the socket construction and transport profile options.</summary>
public CultMeshRudpSocketOptions SocketOptions { get; set; } = new();
/// <summary>Gets or sets the handshake payload.</summary>
public byte[] ConnectPayload { get; set; } = Array.Empty<byte>();
/// <summary>Gets or sets the handshake timeout.</summary>
public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(1);
/// <summary>Gets or sets the handshake polling interval.</summary>
public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds(5);
}
/// <summary>
/// Public CultMesh entrypoints.
/// </summary>
@ -415,6 +430,82 @@ namespace GameCult.Mesh
return CreateRudpClientForPeer(runtimeId, connectionId, peer, options);
}
/// <summary>
/// Creates and handshakes a CultNet RUDP client transport for a parsed endpoint.
/// </summary>
public static CultNetRudpSocketTransportConnection ConnectRudpClient(
string runtimeId,
uint connectionId,
CultMeshRudpEndpoint endpoint,
CultMeshRudpClientOptions? options = null)
{
options ??= new CultMeshRudpClientOptions();
var client = CreateRudpClient(runtimeId, connectionId, endpoint, options.SocketOptions);
if (!client.ConnectAndWait(options.ConnectPayload, options.ConnectTimeout, options.PollInterval))
{
client.Dispose();
throw new TimeoutException($"Timed out waiting for RUDP client {runtimeId} to connect.");
}
return client;
}
/// <summary>
/// Creates and handshakes a CultNet RUDP client transport for a rudp://host:port endpoint.
/// </summary>
public static CultNetRudpSocketTransportConnection ConnectRudpClient(
string runtimeId,
uint connectionId,
string endpoint,
CultMeshRudpClientOptions? options = null)
{
return ConnectRudpClient(runtimeId, connectionId, ParseRudpEndpoint(endpoint), options);
}
/// <summary>
/// Creates and handshakes a CultNet RUDP client transport from a peer-card contact hint.
/// </summary>
public static CultNetRudpSocketTransportConnection ConnectRudpClientForPeer(
string runtimeId,
uint connectionId,
CultMeshPeerCard peer,
CultMeshRudpClientOptions? options = null)
{
if (peer == null) throw new ArgumentNullException(nameof(peer));
var endpoint = peer.Endpoints.FirstOrDefault(value => value.StartsWith("rudp://", StringComparison.OrdinalIgnoreCase));
if (endpoint == null)
{
throw new InvalidOperationException($"Peer {peer.PeerId} does not advertise a RUDP endpoint.");
}
return ConnectRudpClient(runtimeId, connectionId, endpoint, options);
}
/// <summary>
/// Creates and handshakes a CultNet RUDP client transport from the first peer authorized for a Verse role.
/// </summary>
public static CultNetRudpSocketTransportConnection ConnectRudpClientForAuthorizedPeer(
string runtimeId,
uint connectionId,
CultMeshPeerCatalog peers,
CultMeshAuthorityLeaseCatalog leases,
string verseId,
string role,
string? shardId = null,
DateTimeOffset? at = null,
CultMeshRudpClientOptions? options = null)
{
if (peers == null) throw new ArgumentNullException(nameof(peers));
if (leases == null) throw new ArgumentNullException(nameof(leases));
var peer = peers.FirstAuthorized(verseId, role, leases, shardId, at);
if (peer == null)
{
throw new InvalidOperationException($"No authorized RUDP peer for role {role} in Verse {verseId}.");
}
return ConnectRudpClientForPeer(runtimeId, connectionId, peer, options);
}
/// <summary>
/// Creates a committer for writing quorum simulation facts to a mesh database.
/// </summary>

View file

@ -117,7 +117,7 @@ peer lookup with the lease catalog, so contact gossip stays separate from
trust. The branded RUDP helpers use the same boundary:
```csharp
using var rudpClient = CultMesh.CreateRudpClientForAuthorizedPeer(
using var rudpClient = CultMesh.ConnectRudpClientForAuthorizedPeer(
"aetheria-client",
connectionId: 0x10203040,
peers,
@ -128,9 +128,12 @@ using var rudpClient = CultMesh.CreateRudpClientForAuthorizedPeer(
```
`CreateRudpServer(...)`, `ParseRudpEndpoint(...)`,
`CreateRudpClient(...)`, and `CreateRudpClientForPeer(...)` delegate to the
CultNet RUDP socket transport. CultMesh owns peer/authority ergonomics; CultNet
owns packet reliability, resend pressure, and channel semantics.
`ConnectRudpClient(...)`, and `ConnectRudpClientForPeer(...)` delegate to the
CultNet RUDP socket transport and return the same schema-message-capable
transport after handshake. `CreateRudpClient(...)` and
`CreateRudpClientForPeer(...)` remain available when a caller intentionally owns
the handshake and polling loop. CultMesh owns peer/authority ergonomics;
CultNet owns packet reliability, resend pressure, and channel semantics.
Clients can fetch more peers from known peers:

View file

@ -20,6 +20,9 @@ Namespace: `GameCult.Mesh`
- `CultMesh.CreateRudpClient(...)`
- `CultMesh.CreateRudpClientForPeer(...)`
- `CultMesh.CreateRudpClientForAuthorizedPeer(...)`
- `CultMesh.ConnectRudpClient(...)`
- `CultMesh.ConnectRudpClientForPeer(...)`
- `CultMesh.ConnectRudpClientForAuthorizedPeer(...)`
- `CultMesh.CreateSimulationFactCommitter(...)`
- `CultMesh.CreateGameSession(...)`
- `CultMesh.CreateClient(...)`
@ -89,9 +92,12 @@ the world.
`CultMesh.ParseRudpEndpoint(...)`, `CreateRudpServer(...)`,
`CreateRudpClient(...)`, `CreateRudpClientForPeer(...)`, and
`CreateRudpClientForAuthorizedPeer(...)` provide the branded CultMesh path to
the native CultNet RUDP socket transport. These helpers parse endpoint contact
hints and choose authorized peers; `GameCult.Networking` still owns the RUDP
packet codec, session state, resend, fragmentation, and channel semantics.
the native CultNet RUDP socket transport. `ConnectRudpClient(...)`,
`ConnectRudpClientForPeer(...)`, and `ConnectRudpClientForAuthorizedPeer(...)`
also perform the client handshake before returning the same transport. These
helpers parse endpoint contact hints and choose authorized peers;
`GameCult.Networking` still owns the RUDP packet codec, session state, resend,
fragmentation, and channel semantics.
### Shard Authority

View file

@ -379,6 +379,11 @@ The native RUDP socket path also exposes `CultNetRudpReconnectLoop` for
caller-owned game or service loops. The caller reports closure, calls
`ReconnectIfDue(nowMs)` from its own scheduler, and the shared
`CultNetReconnectController` owns retry attempts, delay, and exhaustion state.
`GameCult.Mesh` adds discovery-shaped helpers such as
`CultMesh.ConnectRudpClientForAuthorizedPeer(...)`, which perform authorized
peer lookup and handshake before returning the same RUDP transport. Packet
semantics, schema-channel send/receive helpers, and reconnect state still live
in `GameCult.Networking`.
## Important Constraints

View file

@ -7,6 +7,7 @@ using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
@ -2146,7 +2147,18 @@ namespace GameCult.Networking.Tests
now.AddSeconds(-1),
now.AddSeconds(30)));
using var client = CultMesh.CreateRudpClientForAuthorizedPeer(
using var serverPumpCts = new CancellationTokenSource();
var serverPump = Task.Run(() =>
{
while (!server.Connected && !serverPumpCts.IsCancellationRequested)
{
_ = server.ReceiveOnce();
server.PollResends();
Thread.Sleep(TimeSpan.FromMilliseconds(5));
}
});
using var client = CultMesh.ConnectRudpClientForAuthorizedPeer(
"csharp-cultmesh-rudp-client",
connectionId,
peers,
@ -2154,22 +2166,31 @@ namespace GameCult.Networking.Tests
"local",
"schema",
at: now,
options: new CultMeshRudpSocketOptions
options: new CultMeshRudpClientOptions
{
ResendDelayMs = 25,
MaxFragmentBytes = 1024,
MaxPendingReliablePackets = 16
ConnectPayload = Encoding.UTF8.GetBytes("join"),
SocketOptions = new CultMeshRudpSocketOptions
{
ResendDelayMs = 25,
MaxFragmentBytes = 1024,
MaxPendingReliablePackets = 16
}
});
serverPumpCts.Cancel();
Assert.That(serverPump.Wait(TimeSpan.FromSeconds(1)), Is.True);
client.Connect(Encoding.UTF8.GetBytes("join"));
PumpRudpHandshake(client, server);
Assert.That(client.Connected, Is.True);
Assert.That(server.Connected, Is.True);
client.SendSchema(Encoding.UTF8.GetBytes("client-state"));
var serverFrame = ReceiveRudpFrame(server);
Assert.That(serverFrame.ChannelId, Is.EqualTo("schema"));
Assert.That(Encoding.UTF8.GetString(serverFrame.Payload), Is.EqualTo("client-state"));
client.SendSchemaMessage(new CultNetSchemaCatalogRequestMessage
{
MessageId = "csharp-cultmesh-rudp-schema-catalog",
Kinds = ["document_payload"],
IncludeSchemaJson = true
});
var request = ReceiveRudpSchemaMessage<CultNetSchemaCatalogRequestMessage>(server);
Assert.That(request.MessageId, Is.EqualTo("csharp-cultmesh-rudp-schema-catalog"));
Assert.That(request.Kinds, Is.EqualTo(new[] { "document_payload" }));
Assert.That(server.Profile.Transports[0].Protocol, Is.EqualTo("rudp"));
Assert.That(client.Profile.Transports[0].Protocol, Is.EqualTo("rudp"));
}