using System; using System.Buffers.Binary; using System.Collections.Generic; using System.Linq; using System.IO; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; using LiteNetLib; namespace GameCult.Networking { /// /// Transfer counters for a CultNet transport connection. /// public sealed class CultNetTransportStats { /// /// Gets the number of payload frames sent through the connection. /// public long FramesSent { get; internal set; } /// /// Gets the number of payload frames received through the connection. /// public long FramesReceived { get; internal set; } /// /// Gets the number of bytes sent, including transport framing. /// public long BytesSent { get; internal set; } /// /// Gets the number of bytes received, including transport framing. /// public long BytesReceived { get; internal set; } internal CultNetTransportStats Snapshot() { return new CultNetTransportStats { FramesSent = FramesSent, FramesReceived = FramesReceived, BytesSent = BytesSent, BytesReceived = BytesReceived }; } } /// /// Payload delivered by a CultNet transport connection. /// public sealed class CultNetTransportFrame { /// /// Gets or sets the logical transport channel. /// public string ChannelId { get; set; } = "schema"; /// /// Gets or sets the raw payload bytes carried by the frame. /// public byte[] Payload { get; set; } = Array.Empty(); } /// /// Portable reconnect backoff policy document. /// [MessagePack.MessagePackObject] public sealed class CultNetReconnectPolicy { /// /// Gets or sets the shared reconnect policy schema version. /// [MessagePack.Key("schemaVersion")] public string SchemaVersion { get; set; } = "cultnet.reconnect_policy.v0"; /// /// Gets or sets the policy identifier. /// [MessagePack.Key("policyId")] public string PolicyId { get; set; } = "default"; /// /// Gets or sets the first reconnect delay. /// [MessagePack.Key("baseDelayMs")] public int BaseDelayMs { get; set; } = 1_000; /// /// Gets or sets the maximum exponential backoff delay before jitter. /// [MessagePack.Key("maxDelayMs")] public int MaxDelayMs { get; set; } = 30_000; /// /// Gets or sets the maximum positive jitter a caller may add. /// [MessagePack.Key("maxJitterMs")] public int MaxJitterMs { get; set; } = 250; /// /// Gets or sets the optional maximum reconnect attempts. /// [MessagePack.Key("maxAttempts")] public int? MaxAttempts { get; set; } } /// /// Helpers for portable reconnect policy documents. /// public static class CultNetReconnectPolicies { /// /// Creates a reconnect policy using the shared default values. /// public static CultNetReconnectPolicy CreateDefault( string policyId = "default", int baseDelayMs = 1_000, int maxDelayMs = 30_000, int maxJitterMs = 250, int? maxAttempts = null) { return new CultNetReconnectPolicy { PolicyId = string.IsNullOrWhiteSpace(policyId) ? "default" : policyId, BaseDelayMs = baseDelayMs, MaxDelayMs = maxDelayMs, MaxJitterMs = maxJitterMs, MaxAttempts = maxAttempts }; } /// /// Computes the deterministic exponential reconnect delay for an attempt. /// public static int ComputeDelayMs(CultNetReconnectPolicy policy, int attempt, int jitterMs = 0) { if (policy == null) throw new ArgumentNullException(nameof(policy)); var normalizedAttempt = Math.Max(1, attempt); var cappedBaseDelay = Math.Min( policy.MaxDelayMs, (int)Math.Min(int.MaxValue, policy.BaseDelayMs * Math.Pow(2, normalizedAttempt - 1))); var boundedJitter = Math.Max(0, Math.Min(policy.MaxJitterMs, jitterMs)); return cappedBaseDelay + boundedJitter; } } /// /// Decision emitted by the portable reconnect controller. /// public sealed class CultNetReconnectDecision { /// /// Gets or sets the scheduled attempt number. /// public int Attempt { get; set; } /// /// Gets or sets whether another attempt should be made. /// public bool ShouldRetry { get; set; } /// /// Gets or sets the computed delay before the next attempt. /// public int DelayMs { get; set; } /// /// Gets or sets the absolute scheduler time for the next attempt. /// public long? NextAttemptAtMs { get; set; } /// /// Gets or sets whether the policy has exhausted its attempts. /// public bool Exhausted { get; set; } } /// /// Portable reconnect attempt scheduler for CultNet transports. /// public sealed class CultNetReconnectController { /// /// Initializes a new instance of the class. /// public CultNetReconnectController(CultNetReconnectPolicy? policy = null) { Policy = policy ?? CultNetReconnectPolicies.CreateDefault(); } /// /// Gets the reconnect policy. /// public CultNetReconnectPolicy Policy { get; } /// /// Gets the last scheduled attempt number. /// public int Attempt { get; private set; } /// /// Gets the absolute scheduler time for the next attempt. /// public long? NextAttemptAtMs { get; private set; } /// /// Gets a value indicating whether the policy exhausted reconnect attempts. /// public bool Exhausted { get; private set; } /// /// Clears attempt state after a successful connection. /// public void Reset() { Attempt = 0; NextAttemptAtMs = null; Exhausted = false; } /// /// Returns whether a caller may attempt to connect at the supplied scheduler time. /// public bool CanAttempt(long nowMs) { return !Exhausted && (!NextAttemptAtMs.HasValue || nowMs >= NextAttemptAtMs.Value); } /// /// Records a failed connection attempt and schedules the next retry. /// public CultNetReconnectDecision RecordFailure(long nowMs, int jitterMs = 0) { var nextAttempt = Attempt + 1; if (Policy.MaxAttempts.HasValue && nextAttempt > Policy.MaxAttempts.Value) { Exhausted = true; NextAttemptAtMs = null; return new CultNetReconnectDecision { Attempt = Attempt, ShouldRetry = false, DelayMs = 0, NextAttemptAtMs = null, Exhausted = true }; } Attempt = nextAttempt; var delayMs = CultNetReconnectPolicies.ComputeDelayMs(Policy, Attempt, jitterMs); NextAttemptAtMs = nowMs + delayMs; return new CultNetReconnectDecision { Attempt = Attempt, ShouldRetry = true, DelayMs = delayMs, NextAttemptAtMs = NextAttemptAtMs, Exhausted = false }; } } /// /// Options for creating a TCP framed transport profile. /// public sealed class TcpFramedTransportProfileOptions { /// /// Gets or sets the advertised transport id. /// public string TransportId { get; set; } = "tcp-framed"; /// /// Gets or sets the advertised host. /// public string? Host { get; set; } /// /// Gets or sets the advertised port. /// public int? Port { get; set; } /// /// Gets or sets the maximum payload size for the schema channel. /// public int? MaxPayloadBytes { get; set; } /// /// Gets or sets the maximum fragment size for the schema channel. /// public int? MaxFragmentBytes { get; set; } /// /// Gets or sets the advertised reconnect policy for this TCP transport. /// public CultNetReconnectPolicy? ReconnectPolicy { get; set; } } /// /// Options for creating a LiteNetLib transport profile. /// public sealed class LiteNetLibTransportProfileOptions { /// /// Gets or sets the advertised transport id. /// public string TransportId { get; set; } = "litenetlib"; /// /// Gets or sets the advertised host. /// public string? Host { get; set; } /// /// Gets or sets the advertised port. /// public int? Port { get; set; } /// /// Gets or sets the maximum payload size for LiteNetLib channels. /// public int? MaxPayloadBytes { get; set; } /// /// Gets or sets the advertised reconnect policy for this LiteNetLib transport. /// public CultNetReconnectPolicy? ReconnectPolicy { get; set; } } /// /// Options for creating a CultNet RUDP transport profile. /// public sealed class RudpTransportProfileOptions { /// /// Gets or sets the advertised transport id. /// public string TransportId { get; set; } = "rudp"; /// /// Gets or sets the advertised host. /// public string? Host { get; set; } /// /// Gets or sets the advertised port. /// public int? Port { get; set; } /// /// Gets or sets the maximum payload size for RUDP channels. /// public int? MaxPayloadBytes { get; set; } /// /// Gets or sets the maximum fragment size for RUDP channels. /// public int? MaxFragmentBytes { get; set; } /// /// Gets or sets the maximum pending reliable packet count for RUDP channels. /// public int? MaxPendingReliablePackets { get; set; } /// /// Gets or sets the advertised reconnect policy for this RUDP transport. /// public CultNetReconnectPolicy? ReconnectPolicy { get; set; } } /// /// Helpers for creating CultNet transport profile documents. /// public static class CultNetTransportProfiles { /// /// Creates a profile for the current length-prefixed TCP schema lane. /// public static CultNetTransportProfile CreateTcpFramed( string runtimeId, TcpFramedTransportProfileOptions? options = null) { if (string.IsNullOrWhiteSpace(runtimeId)) throw new ArgumentException("Runtime id is required.", nameof(runtimeId)); options ??= new TcpFramedTransportProfileOptions(); return new CultNetTransportProfile { RuntimeId = runtimeId, Transports = [ new CultNetTransportDescriptor { TransportId = string.IsNullOrWhiteSpace(options.TransportId) ? "tcp-framed" : options.TransportId, Protocol = "tcp_framed", Host = options.Host, Port = options.Port, WireContracts = [CultNetWireContracts.SchemaV0], ReconnectPolicy = options.ReconnectPolicy ?? CultNetReconnectPolicies.CreateDefault(), Channels = [ new CultNetTransportChannel { ChannelId = "schema", Delivery = "reliable", Ordering = "ordered", MaxPayloadBytes = options.MaxPayloadBytes, MaxFragmentBytes = options.MaxFragmentBytes } ] } ] }; } /// /// Creates a profile for the current C# LiteNetLib production lane. /// public static CultNetTransportProfile CreateLiteNetLib( string runtimeId, LiteNetLibTransportProfileOptions? options = null) { if (string.IsNullOrWhiteSpace(runtimeId)) throw new ArgumentException("Runtime id is required.", nameof(runtimeId)); options ??= new LiteNetLibTransportProfileOptions(); return new CultNetTransportProfile { RuntimeId = runtimeId, Transports = [ new CultNetTransportDescriptor { TransportId = string.IsNullOrWhiteSpace(options.TransportId) ? "litenetlib" : options.TransportId, Protocol = "litenetlib", Host = options.Host, Port = options.Port, WireContracts = [ CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 ], ReconnectPolicy = options.ReconnectPolicy ?? CultNetReconnectPolicies.CreateDefault(), Channels = [ new CultNetTransportChannel { ChannelId = "schema", Delivery = "reliable", Ordering = "ordered", MaxPayloadBytes = options.MaxPayloadBytes }, new CultNetTransportChannel { ChannelId = "legacy", Delivery = "reliable", Ordering = "ordered", MaxPayloadBytes = options.MaxPayloadBytes } ] } ] }; } /// /// Creates a profile for the CultNet reliable UDP transport. /// public static CultNetTransportProfile CreateRudp( string runtimeId, RudpTransportProfileOptions? options = null) { if (string.IsNullOrWhiteSpace(runtimeId)) throw new ArgumentException("Runtime id is required.", nameof(runtimeId)); options ??= new RudpTransportProfileOptions(); return new CultNetTransportProfile { RuntimeId = runtimeId, Transports = [ new CultNetTransportDescriptor { TransportId = string.IsNullOrWhiteSpace(options.TransportId) ? "rudp" : options.TransportId, Protocol = "rudp", Host = options.Host, Port = options.Port, WireContracts = [CultNetWireContracts.SchemaV0], ReconnectPolicy = options.ReconnectPolicy ?? CultNetReconnectPolicies.CreateDefault(), Channels = [ new CultNetTransportChannel { ChannelId = "schema", Delivery = "reliable", Ordering = "ordered", MaxPayloadBytes = options.MaxPayloadBytes, MaxFragmentBytes = options.MaxFragmentBytes, MaxPendingReliablePackets = options.MaxPendingReliablePackets }, new CultNetTransportChannel { ChannelId = "latest", Delivery = "unreliable", Ordering = "sequenced", MaxPayloadBytes = options.MaxPayloadBytes, MaxFragmentBytes = options.MaxFragmentBytes, MaxPendingReliablePackets = options.MaxPendingReliablePackets }, new CultNetTransportChannel { ChannelId = "realtime", Delivery = "unreliable", Ordering = "unordered", MaxPayloadBytes = options.MaxPayloadBytes, MaxFragmentBytes = options.MaxFragmentBytes, MaxPendingReliablePackets = options.MaxPendingReliablePackets } ] } ] }; } } /// /// Packet type codes for CultNet reliable UDP transport packets. /// public enum CultNetRudpPacketType : byte { /// Connection request packet. Connect = 1, /// Connection accepted packet. Accept = 2, /// Payload data packet. Data = 3, /// Selective acknowledgement packet. Ack = 4, /// Ping packet. Ping = 5, /// Pong packet. Pong = 6, /// Disconnect packet. Disconnect = 7 } /// /// Binary packet for the CultNet reliable UDP transport. /// public sealed class CultNetRudpPacket { /// /// Gets or sets the packet type. /// public CultNetRudpPacketType PacketType { get; set; } /// /// Gets or sets the connection/session binding id. /// public uint ConnectionId { get; set; } /// /// Gets or sets the packet sequence number. /// public uint Sequence { get; set; } /// /// Gets or sets the latest sequence acknowledged by this packet. /// public uint Ack { get; set; } /// /// Gets or sets the selective acknowledgement mask. /// public uint AckMask { get; set; } /// /// Gets or sets the logical channel id. /// public string ChannelId { get; set; } = string.Empty; /// /// Gets or sets whether the packet participates in reliable delivery. /// public bool Reliable { get; set; } /// /// Gets or sets whether the packet participates in ordered delivery. /// public bool Ordered { get; set; } /// /// Gets or sets whether the packet is a latest-state sequenced packet. /// public bool Sequenced { get; set; } /// /// Gets or sets the fragment id, or zero when unfragmented. /// public ushort FragmentId { get; set; } /// /// Gets or sets the zero-based fragment index. /// public ushort FragmentIndex { get; set; } /// /// Gets or sets the fragment count, or zero when unfragmented. /// public ushort FragmentCount { get; set; } /// /// Gets or sets the transport-neutral payload bytes. /// public byte[] Payload { get; set; } = Array.Empty(); } /// /// Encodes and decodes CultNet reliable UDP packet bytes. /// public static class CultNetRudpPacketCodec { private const int FixedHeaderBytes = 36; private const byte Version = 0; private static readonly byte[] Magic = [0x43, 0x4e, 0x52, 0x30]; /// /// Encodes a RUDP packet into the canonical binary envelope. /// public static byte[] Encode(CultNetRudpPacket packet) { if (packet == null) throw new ArgumentNullException(nameof(packet)); var channelId = Encoding.UTF8.GetBytes(packet.ChannelId ?? string.Empty); if (channelId.Length > 255) { throw new InvalidOperationException("CultNet RUDP channel id cannot exceed 255 UTF-8 bytes."); } var payload = packet.Payload ?? Array.Empty(); var headerBytes = checked(FixedHeaderBytes + channelId.Length); var wire = new byte[checked(headerBytes + payload.Length)]; Magic.CopyTo(wire, 0); wire[4] = Version; wire[5] = (byte)packet.PacketType; wire[6] = EncodeFlags(packet); wire[7] = checked((byte)headerBytes); BinaryPrimitives.WriteUInt32BigEndian(wire.AsSpan(8, 4), packet.ConnectionId); BinaryPrimitives.WriteUInt32BigEndian(wire.AsSpan(12, 4), packet.Sequence); BinaryPrimitives.WriteUInt32BigEndian(wire.AsSpan(16, 4), packet.Ack); BinaryPrimitives.WriteUInt32BigEndian(wire.AsSpan(20, 4), packet.AckMask); BinaryPrimitives.WriteUInt16BigEndian(wire.AsSpan(24, 2), packet.FragmentId); BinaryPrimitives.WriteUInt16BigEndian(wire.AsSpan(26, 2), packet.FragmentIndex); BinaryPrimitives.WriteUInt16BigEndian(wire.AsSpan(28, 2), packet.FragmentCount); BinaryPrimitives.WriteUInt32BigEndian(wire.AsSpan(30, 4), checked((uint)payload.Length)); wire[34] = checked((byte)channelId.Length); wire[35] = 0; channelId.CopyTo(wire.AsSpan(FixedHeaderBytes)); payload.CopyTo(wire.AsSpan(headerBytes)); return wire; } /// /// Decodes a RUDP packet from the canonical binary envelope. /// public static CultNetRudpPacket Decode(byte[] wire) { if (wire == null) throw new ArgumentNullException(nameof(wire)); if (wire.Length < FixedHeaderBytes) { throw new InvalidOperationException("CultNet RUDP packet is shorter than the fixed header."); } for (var index = 0; index < Magic.Length; index++) { if (wire[index] != Magic[index]) { throw new InvalidOperationException("CultNet RUDP packet has the wrong magic."); } } if (wire[4] != Version) { throw new InvalidOperationException($"Unsupported CultNet RUDP packet version {wire[4]}."); } var type = (CultNetRudpPacketType)wire[5]; if (!Enum.IsDefined(typeof(CultNetRudpPacketType), type)) { throw new InvalidOperationException($"Unsupported CultNet RUDP packet type {wire[5]}."); } var headerBytes = wire[7]; var channelIdLength = wire[34]; if (headerBytes != FixedHeaderBytes + channelIdLength) { throw new InvalidOperationException("CultNet RUDP packet header length does not match the channel id length."); } var payloadLength = checked((int)BinaryPrimitives.ReadUInt32BigEndian(wire.AsSpan(30, 4))); if (wire.Length != headerBytes + payloadLength) { throw new InvalidOperationException("CultNet RUDP packet payload length does not match the packet size."); } var flags = wire[6]; var payload = new byte[payloadLength]; Array.Copy(wire, headerBytes, payload, 0, payload.Length); return new CultNetRudpPacket { PacketType = type, Reliable = (flags & 0b0000_0001) != 0, Ordered = (flags & 0b0000_0010) != 0, Sequenced = (flags & 0b0000_0100) != 0, ConnectionId = BinaryPrimitives.ReadUInt32BigEndian(wire.AsSpan(8, 4)), Sequence = BinaryPrimitives.ReadUInt32BigEndian(wire.AsSpan(12, 4)), Ack = BinaryPrimitives.ReadUInt32BigEndian(wire.AsSpan(16, 4)), AckMask = BinaryPrimitives.ReadUInt32BigEndian(wire.AsSpan(20, 4)), FragmentId = BinaryPrimitives.ReadUInt16BigEndian(wire.AsSpan(24, 2)), FragmentIndex = BinaryPrimitives.ReadUInt16BigEndian(wire.AsSpan(26, 2)), FragmentCount = BinaryPrimitives.ReadUInt16BigEndian(wire.AsSpan(28, 2)), ChannelId = Encoding.UTF8.GetString(wire, FixedHeaderBytes, channelIdLength), Payload = payload }; } private static byte EncodeFlags(CultNetRudpPacket packet) { return (byte)( (packet.Reliable ? 0b0000_0001 : 0) | (packet.Ordered ? 0b0000_0010 : 0) | (packet.Sequenced ? 0b0000_0100 : 0) | (packet.FragmentCount > 0 ? 0b0000_1000 : 0)); } } /// /// Payload delivered by the CultNet RUDP reliability state machine. /// public sealed class CultNetRudpDeliveredFrame { /// /// Gets or sets the logical channel id. /// public string ChannelId { get; set; } = string.Empty; /// /// Gets or sets the delivered payload bytes. /// public byte[] Payload { get; set; } = Array.Empty(); /// /// Gets or sets the packet sequence that delivered the frame. /// public uint Sequence { get; set; } } /// /// Result of feeding one packet into a CultNet RUDP session. /// public sealed class CultNetRudpReceiveResult { /// /// Gets or sets delivered frames. /// public IReadOnlyList Delivered { get; set; } = Array.Empty(); /// /// Gets or sets an optional immediate reply packet. /// public CultNetRudpPacket? Reply { get; set; } /// /// Gets or sets whether the packet was a pong response. /// public bool Pong { get; set; } /// /// Gets or sets the pong payload bytes. /// public byte[] PongPayload { get; set; } = Array.Empty(); /// /// Gets or sets whether the remote peer sent a disconnect packet. /// public bool Disconnected { get; set; } /// /// Gets or sets the remote disconnect reason bytes. /// public byte[] DisconnectReason { get; set; } = Array.Empty(); } /// /// Options for the in-memory CultNet RUDP reliability state machine. /// public sealed class CultNetRudpSessionOptions { /// /// Gets or sets the connection/session binding id. /// public uint ConnectionId { get; set; } /// /// Gets or sets the first local packet sequence. /// public uint InitialSequence { get; set; } = 1; /// /// Gets or sets the resend delay in milliseconds. /// public long ResendDelayMs { get; set; } = 250; /// /// Gets or sets the maximum pending reliable packet count. /// public int? MaxPendingReliablePackets { get; set; } } /// /// Send options for RUDP data packets. /// public sealed class CultNetRudpSendOptions { /// /// Gets or sets whether the packet participates in reliable delivery. /// public bool Reliable { get; set; } /// /// Gets or sets whether the packet participates in ordered delivery. /// public bool Ordered { get; set; } /// /// Gets or sets whether the packet is latest-state sequenced. /// public bool Sequenced { get; set; } /// /// Gets or sets the current logical time in milliseconds. /// public long NowMs { get; set; } } /// /// Role for a single-peer CultNet RUDP socket transport. /// public enum CultNetRudpSocketMode { /// /// Initiates the connect packet. /// Client, /// /// Accepts the first connect packet from a remote endpoint. /// Server } /// /// Options for binding a CultNet RUDP session to a UDP socket. /// public sealed class CultNetRudpSocketTransportOptions { /// /// Gets or sets the runtime id advertised by this transport. /// public string RuntimeId { get; set; } = string.Empty; /// /// Gets or sets the bound UDP socket. /// public Socket Socket { get; set; } = null!; /// /// Gets or sets whether this side initiates or accepts the handshake. /// public CultNetRudpSocketMode Mode { get; set; } /// /// Gets or sets the expected remote endpoint. Servers may leave this unset until the first connect packet. /// public EndPoint? RemoteEndPoint { get; set; } /// /// Gets or sets the RUDP connection/session binding id. /// public uint ConnectionId { get; set; } /// /// Gets or sets the first local packet sequence. /// public uint InitialSequence { get; set; } = 1; /// /// Gets or sets the resend delay in milliseconds. /// public long ResendDelayMs { get; set; } = 250; /// /// Gets or sets the advertised transport id. /// public string TransportId { get; set; } = "rudp"; /// /// Gets or sets the maximum payload size for RUDP channels. /// public int? MaxPayloadBytes { get; set; } /// /// Gets or sets the maximum fragment size for RUDP channels. /// public int? MaxFragmentBytes { get; set; } /// /// Gets or sets the maximum pending reliable packet count for RUDP channels. /// public int? MaxPendingReliablePackets { get; set; } /// /// Gets or sets the advertised reconnect policy for this RUDP transport. /// public CultNetReconnectPolicy? ReconnectPolicy { get; set; } } /// /// Socket-free reliability state machine for CultNet RUDP. /// public sealed class CultNetRudpSession { private sealed class PendingReliablePacket { public CultNetRudpPacket Packet { get; set; } = new CultNetRudpPacket(); public long LastSentAtMs { get; set; } } private sealed class PendingOrderedFrame { public CultNetRudpDeliveredFrame Frame { get; set; } = new CultNetRudpDeliveredFrame(); public uint NextSequence { get; set; } } private sealed class FragmentBuffer { public string ChannelId { get; set; } = string.Empty; public bool Ordered { get; set; } public ushort FragmentCount { get; set; } public Dictionary Payloads { get; } = new Dictionary(); public Dictionary Sequences { get; } = new Dictionary(); } private uint _nextSequence; private ushort _nextFragmentId = 1; private readonly int? _maxPendingReliablePackets; private bool _connected; private long? _lastReceivedAtMs; private uint? _highestReceivedSequence; private readonly HashSet _receivedSequences = new HashSet(); private readonly Dictionary _pendingReliable = new Dictionary(); private readonly Dictionary _orderedNextSequenceByChannel = new Dictionary(StringComparer.Ordinal); private readonly Dictionary> _orderedBuffers = new Dictionary>(StringComparer.Ordinal); private readonly Dictionary _fragmentBuffers = new Dictionary(StringComparer.Ordinal); /// /// Initializes a RUDP reliability session. /// public CultNetRudpSession(CultNetRudpSessionOptions options) { if (options == null) throw new ArgumentNullException(nameof(options)); if (options.MaxPendingReliablePackets.HasValue && options.MaxPendingReliablePackets.Value <= 0) { throw new ArgumentOutOfRangeException(nameof(options), "RUDP MaxPendingReliablePackets must be greater than zero."); } ConnectionId = options.ConnectionId; _nextSequence = options.InitialSequence; ResendDelayMs = options.ResendDelayMs; _maxPendingReliablePackets = options.MaxPendingReliablePackets; } /// /// Gets the connection/session binding id. /// public uint ConnectionId { get; } /// /// Gets the resend delay in milliseconds. /// public long ResendDelayMs { get; } /// /// Gets whether the session has completed the connect/accept handshake. /// public bool Connected => _connected; /// /// Gets the logical time of the last received packet. /// public long? LastReceivedAtMs => _lastReceivedAtMs; /// /// Gets reliable packet sequences awaiting acknowledgement. /// public IReadOnlyList PendingReliableSequences => _pendingReliable.Keys.OrderBy(value => value).ToArray(); /// /// Creates a reliable ordered connect packet. /// public CultNetRudpPacket CreateConnect(long nowMs = 0, byte[]? payload = null) { EnsureReliableCapacity(1); var packet = CreatePacket(CultNetRudpPacketType.Connect, "control", payload ?? Array.Empty(), reliable: true, ordered: true, sequenced: false); TrackReliable(packet, nowMs); return packet; } /// /// Accepts a connect packet and returns a reliable ordered accept packet. /// public CultNetRudpPacket AcceptConnect(CultNetRudpPacket packet, long nowMs = 0, byte[]? payload = null) { RequireConnection(packet); if (packet.PacketType != CultNetRudpPacketType.Connect) { throw new InvalidOperationException($"Expected RUDP connect packet, got {packet.PacketType}."); } EnsureReliableCapacity(1); RememberReceived(packet.Sequence); _connected = true; var response = CreatePacket(CultNetRudpPacketType.Accept, "control", payload ?? Array.Empty(), reliable: true, ordered: true, sequenced: false); TrackReliable(response, nowMs); return response; } /// /// Creates a data packet. /// public CultNetRudpPacket Send(string channelId, byte[] payload, CultNetRudpSendOptions? options = null) { return SendMany(channelId, payload, options).First(); } /// /// Creates one or more data packets, fragmenting when requested. /// public IReadOnlyList SendMany(string channelId, byte[] payload, CultNetRudpSendOptions? options = null, int? maxFragmentBytes = null) { if (!_connected) { throw new InvalidOperationException("Cannot send RUDP data before the session is connected."); } options ??= new CultNetRudpSendOptions(); payload ??= Array.Empty(); if (maxFragmentBytes.HasValue && maxFragmentBytes.Value <= 0) { throw new ArgumentOutOfRangeException(nameof(maxFragmentBytes), "RUDP maxFragmentBytes must be greater than zero."); } if (maxFragmentBytes.HasValue && payload.Length > maxFragmentBytes.Value) { var fragmentCount = (payload.Length + maxFragmentBytes.Value - 1) / maxFragmentBytes.Value; if (fragmentCount > ushort.MaxValue) { throw new InvalidOperationException("RUDP payload requires more than 65535 fragments."); } EnsureReliableCapacity(options.Reliable ? fragmentCount : 0); var fragmentId = AllocateFragmentId(); var packets = new List(); for (var index = 0; index < fragmentCount; index++) { var start = index * maxFragmentBytes.Value; var length = Math.Min(maxFragmentBytes.Value, payload.Length - start); var chunk = new byte[length]; Array.Copy(payload, start, chunk, 0, length); var fragmentPacket = CreatePacket( CultNetRudpPacketType.Data, channelId, chunk, options.Reliable, options.Ordered, options.Sequenced, fragmentId, (ushort)index, (ushort)fragmentCount); if (fragmentPacket.Reliable) { TrackReliable(fragmentPacket, options.NowMs); } packets.Add(fragmentPacket); } return packets; } EnsureReliableCapacity(options.Reliable ? 1 : 0); var packet = CreatePacket( CultNetRudpPacketType.Data, channelId, payload, options.Reliable, options.Ordered, options.Sequenced); if (packet.Reliable) { TrackReliable(packet, options.NowMs); } return new[] { packet }; } /// /// Applies a remote packet to the session. /// public CultNetRudpReceiveResult Receive(CultNetRudpPacket packet, long nowMs = 0) { RequireConnection(packet); ApplyAcknowledgements(packet); _lastReceivedAtMs = nowMs; var expectedSequenceIfUninitialized = _highestReceivedSequence.HasValue ? _highestReceivedSequence.Value + 1 : packet.Sequence; if (packet.PacketType == CultNetRudpPacketType.Accept) { RememberReceived(packet.Sequence); _connected = true; return new CultNetRudpReceiveResult(); } if (packet.PacketType == CultNetRudpPacketType.Ping) { RememberReceived(packet.Sequence); return new CultNetRudpReceiveResult { Reply = CreatePacket( CultNetRudpPacketType.Pong, "control", packet.Payload ?? Array.Empty(), reliable: false, ordered: false, sequenced: false) }; } if (packet.PacketType == CultNetRudpPacketType.Ack || packet.PacketType == CultNetRudpPacketType.Pong) { if (packet.PacketType == CultNetRudpPacketType.Pong) RememberReceived(packet.Sequence); return new CultNetRudpReceiveResult { Delivered = DrainUnblockedOrdered(), Pong = packet.PacketType == CultNetRudpPacketType.Pong, PongPayload = packet.PacketType == CultNetRudpPacketType.Pong ? packet.Payload ?? Array.Empty() : Array.Empty() }; } if (packet.PacketType == CultNetRudpPacketType.Disconnect) { RememberReceived(packet.Sequence); _connected = false; return new CultNetRudpReceiveResult { Disconnected = true, DisconnectReason = packet.Payload ?? Array.Empty() }; } if (packet.PacketType != CultNetRudpPacketType.Data) { return new CultNetRudpReceiveResult(); } var duplicate = _receivedSequences.Contains(packet.Sequence); RememberReceived(packet.Sequence); if (duplicate) { return new CultNetRudpReceiveResult(); } var reassembled = Reassemble(packet); if (reassembled == null) { return new CultNetRudpReceiveResult(); } return new CultNetRudpReceiveResult { Delivered = reassembled.Ordered ? DeliverOrdered(reassembled.Frame, reassembled.NextSequence, expectedSequenceIfUninitialized) : new[] { reassembled.Frame } }; } /// /// Creates a packet carrying the current acknowledgement state. /// public CultNetRudpPacket CreateAck() { var (ack, ackMask) = AckState(); return new CultNetRudpPacket { PacketType = CultNetRudpPacketType.Ack, ConnectionId = ConnectionId, Sequence = 0, Ack = ack, AckMask = ackMask, ChannelId = "control", Payload = Array.Empty() }; } /// /// Creates a packet carrying a keepalive ping payload. /// public CultNetRudpPacket CreatePing(byte[]? payload = null) { return CreatePacket(CultNetRudpPacketType.Ping, "control", payload ?? Array.Empty(), reliable: false, ordered: false, sequenced: false); } /// /// Creates a packet carrying a transport-level disconnect reason. /// public CultNetRudpPacket CreateDisconnect(byte[]? reason = null) { _connected = false; return CreatePacket(CultNetRudpPacketType.Disconnect, "control", reason ?? Array.Empty(), reliable: false, ordered: false, sequenced: false); } /// /// Marks the session disconnected when no packet has arrived within the timeout window. /// public bool CheckTimeout(long nowMs, long timeoutMs) { if (!_connected || !_lastReceivedAtMs.HasValue) { return false; } if (nowMs - _lastReceivedAtMs.Value <= timeoutMs) { return false; } _connected = false; return true; } /// /// Returns reliable packets due for resend at the supplied logical time. /// public IReadOnlyList DueResends(long nowMs) { var due = new List(); foreach (var pending in _pendingReliable.Values) { if (nowMs - pending.LastSentAtMs >= ResendDelayMs) { pending.LastSentAtMs = nowMs; due.Add(ClonePacket(pending.Packet)); } } return due.OrderBy(packet => packet.Sequence).ToArray(); } private CultNetRudpPacket CreatePacket( CultNetRudpPacketType packetType, string channelId, byte[] payload, bool reliable, bool ordered, bool sequenced) { return CreatePacket(packetType, channelId, payload, reliable, ordered, sequenced, 0, 0, 0); } private CultNetRudpPacket CreatePacket( CultNetRudpPacketType packetType, string channelId, byte[] payload, bool reliable, bool ordered, bool sequenced, ushort fragmentId, ushort fragmentIndex, ushort fragmentCount) { var sequence = _nextSequence; _nextSequence = checked(_nextSequence + 1); var (ack, ackMask) = AckState(); return new CultNetRudpPacket { PacketType = packetType, ConnectionId = ConnectionId, Sequence = sequence, Ack = ack, AckMask = ackMask, ChannelId = channelId, Reliable = reliable, Ordered = ordered, Sequenced = sequenced, FragmentId = fragmentId, FragmentIndex = fragmentIndex, FragmentCount = fragmentCount, Payload = payload ?? Array.Empty() }; } private void TrackReliable(CultNetRudpPacket packet, long nowMs) { _pendingReliable[packet.Sequence] = new PendingReliablePacket { Packet = ClonePacket(packet), LastSentAtMs = nowMs }; } private void EnsureReliableCapacity(int packetCount) { if (packetCount == 0 || !_maxPendingReliablePackets.HasValue) { return; } if (_pendingReliable.Count + packetCount > _maxPendingReliablePackets.Value) { throw new InvalidOperationException("RUDP reliable send queue is full."); } } private void ApplyAcknowledgements(CultNetRudpPacket packet) { _pendingReliable.Remove(packet.Ack); for (var bit = 0; bit < 32; bit++) { if ((packet.AckMask & (1u << bit)) != 0 && packet.Ack > bit) { _pendingReliable.Remove(packet.Ack - (uint)bit - 1); } } } private void RememberReceived(uint sequence) { _receivedSequences.Add(sequence); if (!_highestReceivedSequence.HasValue || sequence > _highestReceivedSequence.Value) { _highestReceivedSequence = sequence; } } private (uint Ack, uint AckMask) AckState() { var ack = _highestReceivedSequence ?? 0; uint ackMask = 0; for (var bit = 0; bit < 32; bit++) { if (ack > bit && _receivedSequences.Contains(ack - (uint)bit - 1)) { ackMask |= 1u << bit; } } return (ack, ackMask); } private sealed class ReassembledFrame { public CultNetRudpDeliveredFrame Frame { get; set; } = new CultNetRudpDeliveredFrame(); public bool Ordered { get; set; } public uint NextSequence { get; set; } } private ReassembledFrame? Reassemble(CultNetRudpPacket packet) { if (packet.FragmentCount == 0) { return new ReassembledFrame { Frame = new CultNetRudpDeliveredFrame { ChannelId = packet.ChannelId, Payload = packet.Payload ?? Array.Empty(), Sequence = packet.Sequence }, Ordered = packet.Ordered, NextSequence = packet.Sequence + 1 }; } if (packet.FragmentId == 0) { throw new InvalidOperationException("RUDP fragmented packet must have a non-zero fragment id."); } if (packet.FragmentIndex >= packet.FragmentCount) { throw new InvalidOperationException("RUDP fragment index must be lower than fragment count."); } var key = $"{packet.ChannelId}\0{packet.FragmentId}"; if (!_fragmentBuffers.TryGetValue(key, out var buffer)) { buffer = new FragmentBuffer { ChannelId = packet.ChannelId, Ordered = packet.Ordered, FragmentCount = packet.FragmentCount }; _fragmentBuffers[key] = buffer; } if (buffer.FragmentCount != packet.FragmentCount || buffer.Ordered != packet.Ordered) { throw new InvalidOperationException("RUDP fragment metadata changed within a fragment set."); } buffer.Payloads[packet.FragmentIndex] = packet.Payload?.ToArray() ?? Array.Empty(); buffer.Sequences[packet.FragmentIndex] = packet.Sequence; if (buffer.Payloads.Count < packet.FragmentCount) { return null; } var payloadLength = buffer.Payloads.Values.Sum(chunk => chunk.Length); var payload = new byte[payloadLength]; var offset = 0; for (ushort index = 0; index < packet.FragmentCount; index++) { var chunk = buffer.Payloads[index]; Array.Copy(chunk, 0, payload, offset, chunk.Length); offset += chunk.Length; } var sequences = buffer.Sequences.Values.ToArray(); _fragmentBuffers.Remove(key); return new ReassembledFrame { Frame = new CultNetRudpDeliveredFrame { ChannelId = buffer.ChannelId, Payload = payload, Sequence = sequences.Min() }, Ordered = buffer.Ordered, NextSequence = sequences.Max() + 1 }; } private IReadOnlyList DeliverOrdered( CultNetRudpDeliveredFrame frame, uint nextSequenceAfterFrame, uint expectedSequenceIfUninitialized) { if (!_orderedNextSequenceByChannel.TryGetValue(frame.ChannelId, out var next)) { next = Math.Min(expectedSequenceIfUninitialized, frame.Sequence); _orderedNextSequenceByChannel[frame.ChannelId] = next; } while (frame.Sequence > next && _receivedSequences.Contains(next) && (!_orderedBuffers.TryGetValue(frame.ChannelId, out var pendingBuffer) || !pendingBuffer.ContainsKey(next))) { next++; _orderedNextSequenceByChannel[frame.ChannelId] = next; } if (frame.Sequence < next) { return Array.Empty(); } if (frame.Sequence > next) { if (!_orderedBuffers.TryGetValue(frame.ChannelId, out var buffer)) { buffer = new SortedDictionary(); _orderedBuffers[frame.ChannelId] = buffer; } buffer[frame.Sequence] = new PendingOrderedFrame { Frame = frame, NextSequence = nextSequenceAfterFrame }; return Array.Empty(); } _orderedNextSequenceByChannel[frame.ChannelId] = nextSequenceAfterFrame; return new[] { frame }.Concat(DrainOrdered(frame.ChannelId)).ToArray(); } private IReadOnlyList DrainOrdered(string channelId) { var delivered = new List(); if (!_orderedBuffers.TryGetValue(channelId, out var buffer)) { return delivered; } while (_orderedNextSequenceByChannel.TryGetValue(channelId, out var next) && buffer.TryGetValue(next, out var pending)) { buffer.Remove(next); delivered.Add(pending.Frame); _orderedNextSequenceByChannel[channelId] = pending.NextSequence; SkipReceivedNonChannelSequences(channelId); } return delivered; } private IReadOnlyList DrainUnblockedOrdered() { var delivered = new List(); foreach (var channelId in _orderedBuffers.Keys.ToArray()) { SkipReceivedNonChannelSequences(channelId); delivered.AddRange(DrainOrdered(channelId)); } return delivered; } private void SkipReceivedNonChannelSequences(string channelId) { if (!_orderedNextSequenceByChannel.TryGetValue(channelId, out var next)) { return; } while (_receivedSequences.Contains(next) && (!_orderedBuffers.TryGetValue(channelId, out var buffer) || !buffer.ContainsKey(next))) { next++; _orderedNextSequenceByChannel[channelId] = next; } } private ushort AllocateFragmentId() { var fragmentId = _nextFragmentId; _nextFragmentId++; if (_nextFragmentId == 0) { _nextFragmentId = 1; } return fragmentId; } private void RequireConnection(CultNetRudpPacket packet) { if (packet.ConnectionId != ConnectionId) { throw new InvalidOperationException($"RUDP packet connection id {packet.ConnectionId} does not match {ConnectionId}."); } } private static CultNetRudpPacket ClonePacket(CultNetRudpPacket packet) { return new CultNetRudpPacket { PacketType = packet.PacketType, ConnectionId = packet.ConnectionId, Sequence = packet.Sequence, Ack = packet.Ack, AckMask = packet.AckMask, ChannelId = packet.ChannelId, Reliable = packet.Reliable, Ordered = packet.Ordered, Sequenced = packet.Sequenced, FragmentId = packet.FragmentId, FragmentIndex = packet.FragmentIndex, FragmentCount = packet.FragmentCount, Payload = packet.Payload?.ToArray() ?? Array.Empty() }; } } /// /// Single-peer UDP socket binding for the CultNet RUDP reliability session. /// public sealed class CultNetRudpSocketTransportConnection : IDisposable { private readonly Socket _socket; private readonly CultNetRudpSession _session; private readonly CultNetRudpSocketMode _mode; private readonly int? _maxFragmentBytes; private readonly CultNetTransportStats _stats = new CultNetTransportStats(); private readonly Queue _deliveredFrames = new Queue(); private readonly Queue _pongPayloads = new Queue(); private EndPoint? _remoteEndPoint; private bool _disposed; /// /// Initializes a UDP socket binding around a CultNet RUDP session. /// public CultNetRudpSocketTransportConnection(CultNetRudpSocketTransportOptions options) { if (options == null) throw new ArgumentNullException(nameof(options)); if (string.IsNullOrWhiteSpace(options.RuntimeId)) throw new ArgumentException("Runtime id is required.", nameof(options)); _socket = options.Socket ?? throw new ArgumentNullException(nameof(options.Socket)); _mode = options.Mode; _remoteEndPoint = options.RemoteEndPoint; _maxFragmentBytes = options.MaxFragmentBytes; _session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = options.ConnectionId, InitialSequence = options.InitialSequence, ResendDelayMs = options.ResendDelayMs, MaxPendingReliablePackets = options.MaxPendingReliablePackets }); var local = _socket.LocalEndPoint as IPEndPoint; Profile = CultNetTransportProfiles.CreateRudp( options.RuntimeId, new RudpTransportProfileOptions { TransportId = options.TransportId, Host = local?.Address.ToString(), Port = local?.Port, MaxPayloadBytes = options.MaxPayloadBytes, MaxFragmentBytes = options.MaxFragmentBytes, MaxPendingReliablePackets = options.MaxPendingReliablePackets, ReconnectPolicy = options.ReconnectPolicy }); } /// /// Gets the profile this connection implements. /// public CultNetTransportProfile Profile { get; } /// /// Gets whether the RUDP handshake has completed. /// public bool Connected => _session.Connected; /// /// Gets a snapshot of the current transfer counters. /// public CultNetTransportStats Stats => _stats.Snapshot(); /// /// Gets the last transport-level remote disconnect reason, if one was received. /// public byte[]? DisconnectReason { get; private set; } /// /// Attempts to dequeue the next received pong payload. /// public bool TryDequeuePongPayload(out byte[] payload) { if (_pongPayloads.Count > 0) { payload = _pongPayloads.Dequeue(); return true; } payload = Array.Empty(); return false; } /// /// Sends the client connect packet. /// public void Connect(byte[]? payload = null) { if (_mode != CultNetRudpSocketMode.Client) { throw new InvalidOperationException("Only a client RUDP socket transport can initiate connect."); } 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. /// public void Send(string channelId, byte[] payload) { foreach (var packet in _session.SendMany(channelId, payload, ChannelSendOptions(channelId), _maxFragmentBytes)) { SendPacket(packet); } _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. /// public void SendSchemaMessage(TMessage message) where TMessage : ICultNetSchemaMessage { if (message == null) throw new ArgumentNullException(nameof(message)); 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. /// public ICultNetSchemaMessage? ReceiveSchemaMessageOnce() { while (true) { var frame = ReceiveOnce(); if (frame == null) { return null; } if (!string.Equals(frame.ChannelId, "schema", StringComparison.Ordinal)) { continue; } return CultNetSchemaMessageSerialization.Deserialize(frame.Payload); } } /// /// Attempts to receive the next CultNet schema-v0 message of the requested type. /// public TMessage? ReceiveSchemaMessageOnce() where TMessage : class, ICultNetSchemaMessage { 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. /// public void Disconnect(byte[]? reason = null) { SendPacket(_session.CreateDisconnect(reason ?? Array.Empty())); } /// /// Sends a transport-level ping packet. /// public void Ping(byte[]? payload = null) { SendPacket(_session.CreatePing(payload ?? Array.Empty())); } /// /// Checks whether the session has exceeded the receive timeout. /// public bool CheckTimeout(long timeoutMs) { return _session.CheckTimeout(NowMs(), timeoutMs); } /// /// Polls the UDP socket once and returns the next delivered transport frame if one is available. /// public CultNetTransportFrame? ReceiveOnce() { if (_deliveredFrames.Count > 0) { return _deliveredFrames.Dequeue(); } var buffer = new byte[65535]; EndPoint remote = new IPEndPoint(IPAddress.Any, 0); int received; try { if (!_socket.Poll(0, SelectMode.SelectRead)) { return null; } received = _socket.ReceiveFrom(buffer, ref remote); } catch (SocketException error) when ( error.SocketErrorCode == SocketError.WouldBlock || error.SocketErrorCode == SocketError.TimedOut) { return null; } catch (SocketException error) when ( error.SocketErrorCode == SocketError.ConnectionReset || error.SocketErrorCode == SocketError.ConnectionAborted || error.SocketErrorCode == SocketError.Shutdown) { DisconnectReason = Array.Empty(); return null; } _stats.BytesReceived += received; if (_remoteEndPoint == null) { _remoteEndPoint = remote; } else if (!_remoteEndPoint.Equals(remote)) { return null; } var wire = new byte[received]; Array.Copy(buffer, wire, received); var packet = CultNetRudpPacketCodec.Decode(wire); if (_mode == CultNetRudpSocketMode.Server && packet.PacketType == CultNetRudpPacketType.Connect) { SendPacket(_session.AcceptConnect(packet, NowMs())); return null; } var result = _session.Receive(packet, NowMs()); if (result.Reply != null) { SendPacket(result.Reply); } if (result.Pong) { _pongPayloads.Enqueue(result.PongPayload); } if (result.Disconnected) { DisconnectReason = result.DisconnectReason; return null; } foreach (var frame in result.Delivered) { _deliveredFrames.Enqueue(new CultNetTransportFrame { ChannelId = frame.ChannelId, Payload = frame.Payload }); _stats.FramesReceived++; } var delivered = _deliveredFrames.Count > 0 ? _deliveredFrames.Dequeue() : null; if (packet.PacketType == CultNetRudpPacketType.Accept || packet.PacketType == CultNetRudpPacketType.Data || delivered != null) { SendPacket(_session.CreateAck()); } return delivered; } /// /// Sends any reliable packets whose resend timers are due. /// public void PollResends() { foreach (var packet in _session.DueResends(NowMs())) { SendPacket(packet); } } /// public void Dispose() { if (_disposed) { return; } _disposed = true; _socket.Dispose(); } private void SendPacket(CultNetRudpPacket packet) { if (_remoteEndPoint == null) { throw new InvalidOperationException("RUDP socket transport does not have a remote endpoint."); } var wire = CultNetRudpPacketCodec.Encode(packet); var sent = _socket.SendTo(wire, _remoteEndPoint); _stats.BytesSent += sent; } private static CultNetRudpSendOptions ChannelSendOptions(string channelId) { return string.Equals(channelId, "schema", StringComparison.Ordinal) ? new CultNetRudpSendOptions { Reliable = true, Ordered = true, NowMs = NowMs() } : string.Equals(channelId, "latest", StringComparison.Ordinal) ? new CultNetRudpSendOptions { Sequenced = true, NowMs = NowMs() } : new CultNetRudpSendOptions { NowMs = NowMs() }; } private static long NowMs() { return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); } } /// /// Options for a multi-peer CultNet RUDP UDP listener. /// public sealed class CultNetRudpSocketTransportServerOptions { /// /// Gets or sets the runtime id advertised by this listener. /// public string RuntimeId { get; set; } = string.Empty; /// /// Gets or sets the bound UDP socket. /// public Socket Socket { get; set; } = null!; /// /// Gets or sets the RUDP connection/session binding id accepted by this listener. /// public uint ConnectionId { get; set; } /// /// Gets or sets the first local packet sequence for each accepted peer session. /// public uint InitialSequence { get; set; } = 100; /// /// Gets or sets the resend delay in milliseconds. /// public long ResendDelayMs { get; set; } = 250; /// /// Gets or sets the advertised transport id. /// public string TransportId { get; set; } = "rudp"; /// /// Gets or sets the maximum payload size for RUDP channels. /// public int? MaxPayloadBytes { get; set; } /// /// Gets or sets the maximum fragment size for RUDP channels. /// public int? MaxFragmentBytes { get; set; } /// /// Gets or sets the maximum pending reliable packet count for RUDP channels. /// public int? MaxPendingReliablePackets { get; set; } /// /// Gets or sets the advertised reconnect policy for this RUDP listener. /// public CultNetReconnectPolicy? ReconnectPolicy { get; set; } /// /// Gets or sets the payload sent with accept packets. /// public byte[]? AcceptPayload { get; set; } } /// /// Peer context owned by a multi-peer CultNet RUDP listener. /// public sealed class CultNetRudpSocketServerPeer { internal CultNetRudpSocketServerPeer(EndPoint remoteEndPoint, CultNetRudpSession session) { RemoteEndPoint = remoteEndPoint; Session = session; } internal CultNetRudpSession Session { get; } /// /// Gets the UDP endpoint for this peer. /// public EndPoint RemoteEndPoint { get; } /// /// Gets whether the peer RUDP handshake has completed. /// public bool Connected => Session.Connected; /// /// Gets the last transport-level remote disconnect reason, if one was received. /// public byte[]? DisconnectReason { get; internal set; } } /// /// A frame delivered by a multi-peer CultNet RUDP listener. /// public sealed class CultNetRudpSocketServerFrame { /// /// Gets the peer that delivered the frame. /// public CultNetRudpSocketServerPeer Peer { get; set; } = null!; /// /// Gets the delivered transport frame. /// public CultNetTransportFrame Frame { get; set; } = new CultNetTransportFrame(); } /// /// Multi-peer UDP socket listener for CultNet RUDP sessions. /// public sealed class CultNetRudpSocketTransportServer : IDisposable { private readonly Socket _socket; private readonly uint _connectionId; private readonly uint _initialSequence; private readonly long _resendDelayMs; private readonly int? _maxFragmentBytes; private readonly int? _maxPendingReliablePackets; private readonly byte[] _acceptPayload; private readonly CultNetTransportStats _stats = new CultNetTransportStats(); private readonly Dictionary _peers = new Dictionary(StringComparer.Ordinal); private readonly Queue _deliveredFrames = new Queue(); private bool _disposed; /// /// Initializes a multi-peer UDP listener for CultNet RUDP sessions. /// public CultNetRudpSocketTransportServer(CultNetRudpSocketTransportServerOptions options) { if (options == null) throw new ArgumentNullException(nameof(options)); if (string.IsNullOrWhiteSpace(options.RuntimeId)) throw new ArgumentException("Runtime id is required.", nameof(options)); _socket = options.Socket ?? throw new ArgumentNullException(nameof(options.Socket)); _connectionId = options.ConnectionId; _initialSequence = options.InitialSequence; _resendDelayMs = options.ResendDelayMs; _maxFragmentBytes = options.MaxFragmentBytes; _maxPendingReliablePackets = options.MaxPendingReliablePackets; _acceptPayload = options.AcceptPayload ?? Array.Empty(); var local = _socket.LocalEndPoint as IPEndPoint; Profile = CultNetTransportProfiles.CreateRudp( options.RuntimeId, new RudpTransportProfileOptions { TransportId = options.TransportId, Host = local?.Address.ToString(), Port = local?.Port, MaxPayloadBytes = options.MaxPayloadBytes, MaxFragmentBytes = options.MaxFragmentBytes, MaxPendingReliablePackets = options.MaxPendingReliablePackets, ReconnectPolicy = options.ReconnectPolicy }); } /// /// Gets the profile this listener implements. /// public CultNetTransportProfile Profile { get; } /// /// Gets currently tracked peers indexed by remote endpoint. /// public IReadOnlyCollection Peers => _peers.Values.ToArray(); /// /// Gets a snapshot of the current transfer counters. /// public CultNetTransportStats Stats => _stats.Snapshot(); /// /// Sends a logical transport frame to a connected peer. /// public void Send(CultNetRudpSocketServerPeer peer, string channelId, byte[] payload) { if (peer == null) throw new ArgumentNullException(nameof(peer)); foreach (var packet in peer.Session.SendMany(channelId, payload, ChannelSendOptions(channelId), _maxFragmentBytes)) { SendPacket(peer.RemoteEndPoint, packet); } _stats.FramesSent++; } /// /// Sends a reliable ordered schema-channel payload to a peer. /// public void SendSchema(CultNetRudpSocketServerPeer peer, byte[] payload) { Send(peer, "schema", payload); } /// /// Sends a reliable ordered schema-channel UTF-8 payload to a peer. /// public void SendSchema(CultNetRudpSocketServerPeer peer, string payload) { SendSchema(peer, Encoding.UTF8.GetBytes(payload ?? string.Empty)); } /// /// Sends a CultNet schema-v0 message to a peer on the reliable ordered schema channel. /// public void SendSchemaMessage(CultNetRudpSocketServerPeer peer, TMessage message) where TMessage : ICultNetSchemaMessage { if (message == null) throw new ArgumentNullException(nameof(message)); SendSchema(peer, CultNetSchemaMessageSerialization.Serialize(message)); } /// /// Polls the UDP socket once and returns the next delivered peer frame if one is available. /// public CultNetRudpSocketServerFrame? ReceiveOnce() { if (_deliveredFrames.Count > 0) { return _deliveredFrames.Dequeue(); } var buffer = new byte[65535]; EndPoint remote = new IPEndPoint(IPAddress.Any, 0); int received; try { if (!_socket.Poll(0, SelectMode.SelectRead)) { return null; } received = _socket.ReceiveFrom(buffer, ref remote); } catch (SocketException error) when ( error.SocketErrorCode == SocketError.WouldBlock || error.SocketErrorCode == SocketError.TimedOut) { return null; } catch (SocketException error) when ( error.SocketErrorCode == SocketError.ConnectionReset || error.SocketErrorCode == SocketError.ConnectionAborted || error.SocketErrorCode == SocketError.Shutdown) { return null; } _stats.BytesReceived += received; var wire = new byte[received]; Array.Copy(buffer, wire, received); CultNetRudpPacket packet; try { packet = CultNetRudpPacketCodec.Decode(wire); } catch (InvalidOperationException) { return null; } if (packet.ConnectionId != _connectionId) { return null; } var peerKey = RemoteKey(remote); if (packet.PacketType == CultNetRudpPacketType.Connect) { var peer = new CultNetRudpSocketServerPeer( CloneEndPoint(remote), new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = _connectionId, InitialSequence = _initialSequence, ResendDelayMs = _resendDelayMs, MaxPendingReliablePackets = _maxPendingReliablePackets })); _peers[peerKey] = peer; SendPacket(peer.RemoteEndPoint, peer.Session.AcceptConnect(packet, NowMs(), _acceptPayload)); return null; } if (!_peers.TryGetValue(peerKey, out var existingPeer)) { return null; } var result = existingPeer.Session.Receive(packet, NowMs()); if (result.Reply != null) { SendPacket(existingPeer.RemoteEndPoint, result.Reply); } if (result.Disconnected) { existingPeer.DisconnectReason = result.DisconnectReason; _peers.Remove(peerKey); return null; } foreach (var frame in result.Delivered) { _deliveredFrames.Enqueue(new CultNetRudpSocketServerFrame { Peer = existingPeer, Frame = new CultNetTransportFrame { ChannelId = frame.ChannelId, Payload = frame.Payload } }); _stats.FramesReceived++; } var delivered = _deliveredFrames.Count > 0 ? _deliveredFrames.Dequeue() : null; if (packet.PacketType == CultNetRudpPacketType.Data || delivered != null) { SendPacket(existingPeer.RemoteEndPoint, existingPeer.Session.CreateAck()); } return delivered; } /// /// Sends any reliable packets whose resend timers are due for all peers. /// public void PollResends() { foreach (var peer in _peers.Values.ToArray()) { foreach (var packet in peer.Session.DueResends(NowMs())) { SendPacket(peer.RemoteEndPoint, packet); } } } /// public void Dispose() { if (_disposed) { return; } _disposed = true; _socket.Dispose(); } private void SendPacket(EndPoint remoteEndPoint, CultNetRudpPacket packet) { var wire = CultNetRudpPacketCodec.Encode(packet); var sent = _socket.SendTo(wire, remoteEndPoint); _stats.BytesSent += sent; } private static string RemoteKey(EndPoint endpoint) { return endpoint.ToString() ?? string.Empty; } private static EndPoint CloneEndPoint(EndPoint endpoint) { if (endpoint is IPEndPoint ip) { return new IPEndPoint(ip.Address, ip.Port); } return endpoint; } private static CultNetRudpSendOptions ChannelSendOptions(string channelId) { return string.Equals(channelId, "schema", StringComparison.Ordinal) ? new CultNetRudpSendOptions { Reliable = true, Ordered = true, NowMs = NowMs() } : string.Equals(channelId, "latest", StringComparison.Ordinal) ? new CultNetRudpSendOptions { Sequenced = true, NowMs = NowMs() } : new CultNetRudpSendOptions { NowMs = NowMs() }; } private static long NowMs() { return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); } } /// /// Caller-owned reconnect loop for the socket-backed CultNet RUDP transport. /// public sealed class CultNetRudpReconnectLoop : IDisposable { private readonly Func _createTransport; private readonly byte[] _connectPayload; private CultNetRudpSocketTransportConnection? _transport; private bool _stopped = true; private bool _disposed; /// /// Initializes a new reconnect loop around caller-owned RUDP transport construction. /// public CultNetRudpReconnectLoop( Func createTransport, CultNetReconnectPolicy? reconnectPolicy = null, byte[]? connectPayload = null) { _createTransport = createTransport ?? throw new ArgumentNullException(nameof(createTransport)); _connectPayload = connectPayload?.ToArray() ?? Array.Empty(); ReconnectController = new CultNetReconnectController(reconnectPolicy); } /// /// Gets the shared reconnect controller that owns attempt, delay, and exhaustion state. /// public CultNetReconnectController ReconnectController { get; } /// /// Gets the current transport, if the loop has one open. /// public CultNetRudpSocketTransportConnection? Transport => _transport; /// /// Opens the first transport and sends its connect packet. /// public CultNetRudpSocketTransportConnection Start() { ThrowIfDisposed(); _stopped = false; ReconnectController.Reset(); return OpenTransport(); } /// /// Stops reconnecting, disposes the current transport, and clears retry state. /// public void Stop() { _stopped = true; DisposeTransport(); ReconnectController.Reset(); } /// /// Clears retry state after the caller observes an established connection. /// public void MarkConnected() { ReconnectController.Reset(); } /// /// Reports that the current transport closed and records the next retry decision. /// public CultNetReconnectDecision? HandleClosed(long nowMs, int jitterMs = 0) { ThrowIfDisposed(); DisposeTransport(); return _stopped ? null : ReconnectController.RecordFailure(nowMs, jitterMs); } /// /// Opens a new transport when the shared controller says the next attempt is due. /// public bool ReconnectIfDue(long nowMs) { ThrowIfDisposed(); if (_stopped || !ReconnectController.CanAttempt(nowMs)) { return false; } OpenTransport(); return true; } /// public void Dispose() { if (_disposed) { return; } _disposed = true; Stop(); } private CultNetRudpSocketTransportConnection OpenTransport() { var next = _createTransport(); try { next.Connect(_connectPayload); } catch { next.Dispose(); throw; } DisposeTransport(); _transport = next; return next; } private void DisposeTransport() { _transport?.Dispose(); _transport = null; } private void ThrowIfDisposed() { if (_disposed) { throw new ObjectDisposedException(nameof(CultNetRudpReconnectLoop)); } } } /// /// Channel-aware send adapter for the C# LiteNetLib production lane. /// public sealed class LiteNetLibTransportConnection { private readonly NetPeer? _peer; private readonly CultNetTransportStats _stats = new CultNetTransportStats(); /// /// Initializes a new channel-aware adapter around a LiteNetLib peer. /// public LiteNetLibTransportConnection(NetPeer peer, CultNetTransportProfile? profile = null) { _peer = peer ?? throw new ArgumentNullException(nameof(peer)); Profile = profile ?? CultNetTransportProfiles.CreateLiteNetLib("csharp-litenetlib-peer"); } /// /// Initializes a receive-only channel-aware adapter for LiteNetLib payload classification. /// public LiteNetLibTransportConnection(CultNetTransportProfile? profile = null) { Profile = profile ?? CultNetTransportProfiles.CreateLiteNetLib("csharp-litenetlib-peer"); } /// /// Gets the transport profile this adapter implements. /// public CultNetTransportProfile Profile { get; } /// /// Gets a snapshot of outbound transfer counters. /// public CultNetTransportStats Stats => _stats.Snapshot(); /// /// Classifies an inbound LiteNetLib payload into the adapter's schema or legacy channel shape. /// public CultNetTransportFrame Receive(byte[] payload) { var frame = Decode(payload); _stats.BytesReceived += frame.Payload.Length; _stats.FramesReceived++; return frame; } /// /// Sends a payload over a supported LiteNetLib transport channel. /// public void Send(string channelId, byte[] payload) { if (payload == null) throw new ArgumentNullException(nameof(payload)); if (_peer == null) throw new InvalidOperationException("A LiteNetLib peer is required to send through this transport."); if (!string.Equals(channelId, "schema", StringComparison.Ordinal) && !string.Equals(channelId, "legacy", StringComparison.Ordinal)) { throw new InvalidOperationException($"litenetlib transport only supports schema and legacy channels, got \"{channelId}\"."); } _peer.Send(payload, DeliveryMethod.ReliableOrdered); _stats.BytesSent += payload.Length; _stats.FramesSent++; } /// /// Serializes and sends a legacy GameCult.Networking union message. /// public void SendLegacy(T message) where T : Message { if (message == null) throw new ArgumentNullException(nameof(message)); Send("legacy", MessageSerialization.Serialize(message)); } /// /// Serializes and sends a CultNet schema-v0 message. /// public void SendSchema(T message) where T : ICultNetSchemaMessage { if (message == null) throw new ArgumentNullException(nameof(message)); Send("schema", CultNetSchemaMessageSerialization.Serialize(message)); } /// /// Decodes an inbound LiteNetLib payload into the adapter's schema or legacy channel shape. /// public static CultNetTransportFrame Decode(byte[] payload) { if (payload == null) throw new ArgumentNullException(nameof(payload)); if (TryDeserializeCultNet(payload, out _)) { return new CultNetTransportFrame { ChannelId = "schema", Payload = payload.ToArray() }; } return new CultNetTransportFrame { ChannelId = "legacy", Payload = payload.ToArray() }; } /// /// Decodes a schema-channel frame as a CultNet schema-v0 message. /// public static ICultNetSchemaMessage DecodeSchema(CultNetTransportFrame frame) { if (frame == null) throw new ArgumentNullException(nameof(frame)); if (!string.Equals(frame.ChannelId, "schema", StringComparison.Ordinal)) { throw new InvalidOperationException($"Expected schema channel, got \"{frame.ChannelId}\"."); } return CultNetSchemaMessageSerialization.Deserialize(frame.Payload); } /// /// Decodes a legacy-channel frame as a GameCult.Networking union message. /// public static Message DecodeLegacy(CultNetTransportFrame frame) { if (frame == null) throw new ArgumentNullException(nameof(frame)); if (!string.Equals(frame.ChannelId, "legacy", StringComparison.Ordinal)) { throw new InvalidOperationException($"Expected legacy channel, got \"{frame.ChannelId}\"."); } return MessageSerialization.Deserialize(frame.Payload); } private static bool TryDeserializeCultNet(byte[] payload, out ICultNetSchemaMessage message) { try { message = CultNetSchemaMessageSerialization.Deserialize(payload); return true; } catch (MessagePack.MessagePackSerializationException) { message = null!; return false; } } } /// /// Transport connection for the current TCP length-prefixed CultNet schema lane. /// public sealed class TcpFramedTransportConnection { private const int HeaderBytes = 4; private readonly Stream _stream; private readonly CultNetTransportStats _stats = new CultNetTransportStats(); /// /// Initializes a TCP framed transport over an existing stream. /// public TcpFramedTransportConnection(Stream stream, CultNetTransportProfile profile) { _stream = stream ?? throw new ArgumentNullException(nameof(stream)); Profile = profile ?? throw new ArgumentNullException(nameof(profile)); } /// /// Gets the profile this connection implements. /// public CultNetTransportProfile Profile { get; } /// /// Gets a snapshot of the current transfer counters. /// public CultNetTransportStats Stats => _stats.Snapshot(); /// /// Sends a schema-channel payload. /// public async Task SendAsync(string channelId, byte[] payload, CancellationToken cancellationToken = default) { if (!string.Equals(channelId, "schema", StringComparison.Ordinal)) { throw new InvalidOperationException($"tcp_framed transport only supports the schema channel, got \"{channelId}\"."); } if (payload == null) throw new ArgumentNullException(nameof(payload)); var header = new byte[HeaderBytes]; BinaryPrimitives.WriteUInt32BigEndian(header, checked((uint)payload.Length)); await _stream.WriteAsync(header, cancellationToken).ConfigureAwait(false); await _stream.WriteAsync(payload, cancellationToken).ConfigureAwait(false); await _stream.FlushAsync(cancellationToken).ConfigureAwait(false); _stats.BytesSent += HeaderBytes + payload.Length; _stats.FramesSent++; } /// /// Receives the next schema-channel payload. /// public async Task ReceiveAsync(CancellationToken cancellationToken = default) { var header = new byte[HeaderBytes]; await ReadExactlyAsync(header, cancellationToken).ConfigureAwait(false); var payloadLength = checked((int)BinaryPrimitives.ReadUInt32BigEndian(header)); var payload = new byte[payloadLength]; await ReadExactlyAsync(payload, cancellationToken).ConfigureAwait(false); _stats.BytesReceived += HeaderBytes + payloadLength; _stats.FramesReceived++; return new CultNetTransportFrame { ChannelId = "schema", Payload = payload }; } private async Task ReadExactlyAsync(byte[] buffer, CancellationToken cancellationToken) { var offset = 0; while (offset < buffer.Length) { var read = await _stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), cancellationToken) .ConfigureAwait(false); if (read == 0) { throw new EndOfStreamException(); } offset += read; } } } }