using System; using System.Buffers.Binary; using System.Collections.Concurrent; 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; } /// /// Gets the number of datagrams read and discarded because they belong to no session on /// this transport: malformed frames, another session's connection id, a sender that is not /// the peer, or a packet the session refuses. /// public long PacketsDropped { get; internal set; } /// /// Gets the number of datagrams a multi-peer server could not send to one peer. Each is /// that peer's lost datagram: a reliable packet stays pending and is resent, and no other /// peer is affected. Zero for single-peer transports, whose callers get the send error. /// public long SendFailures { get; internal set; } internal CultNetTransportStats Snapshot() { return new CultNetTransportStats { FramesSent = FramesSent, FramesReceived = FramesReceived, BytesSent = BytesSent, BytesReceived = BytesReceived, PacketsDropped = PacketsDropped, SendFailures = SendFailures }; } } /// /// 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."); } // Compared as a long: a declared length past int.MaxValue is a malformed packet, not an overflow. var declaredPayloadLength = BinaryPrimitives.ReadUInt32BigEndian(wire.AsSpan(30, 4)); if (declaredPayloadLength != (long)wire.Length - headerBytes) { throw new InvalidOperationException("CultNet RUDP packet payload length does not match the packet size."); } var payloadLength = (int)declaredPayloadLength; 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 queued reliable packets admitted by newly received acknowledgements. /// public IReadOnlyList ReadyToSend { 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. Unset, each session draws its own at random: /// every session is a new identity to its peer, and an options object reused for several /// must not give them one sequence. /// public uint? InitialSequence { get; set; } /// /// 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. Unset, each session draws its own at random: /// every session is a new identity to its peer, and an options object reused for several /// must not give them one sequence. /// public uint? InitialSequence { get; set; } /// /// 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 { /// Maximum reliable packets admitted to the wire before acknowledgements advance the window. public const int ReliableSendWindowPackets = 32; // Flow window: a reliable packet is admitted only while its sequence is at most FlowWindowSequences above the // lowest unacked one and the payload above that sequence stays within FlowWindowBytes. private const uint FlowWindowSequences = 1023; private const int FlowWindowBytes = 4 * 1024 * 1024; private const int ReceivedSequenceWindow = 4096; // How long a client keeps retransmitting a Connect nobody answered before it abandons that // attempt for a fresh one. A Connect the server answers with an Ack, never an Accept, is one // the server judged stale (see AcceptConnect); retransmitting the same sequence would never // change its mind. private const long ConnectAttemptMs = 3_000; private sealed class PendingReliablePacket { public CultNetRudpPacket Packet { get; set; } = new CultNetRudpPacket(); public long LastSentAtMs { 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 readonly Dictionary _nextSequencedByChannel = new Dictionary(StringComparer.Ordinal); private ushort _nextFragmentId = 1; private readonly int? _maxPendingReliablePackets; private volatile bool _connected; // Advances every time a generation ends. Everything issued in a generation (writes, flushes) // belongs to it and dies with it. private long _generation; // True from the end of a generation until the next Connect or Accept begins one. A flush started // in that interval has no live generation to wait on. private bool _ended; // The sequence of the Connect that started the current generation: sent by this side, or // accepted from the peer. A Connect repeats exactly when the session is connected and the // Connect carries this sequence. private uint? _connectSequence; // A Connect this side sent is unanswered. Only then is an Accept honoured. private bool _awaitingAccept; // When the unanswered Connect first went out; DueResends abandons it for a fresh attempt once // ConnectAttemptMs has passed. private long _connectStartedAtMs; // The payload of the Connect this side sent, kept so a fresh attempt can always be built // whatever became of the pending packet. private byte[] _connectPayload = Array.Empty(); private long? _lastReceivedAtMs; private uint? _highestReceivedSequence; private readonly HashSet _receivedSequences = new HashSet(); private readonly Dictionary _latestSequencedByChannel = new Dictionary(StringComparer.Ordinal); private readonly object _pendingReliableGate = new object(); private readonly Dictionary _pendingReliable = new Dictionary(); // Payload sizes of acknowledged reliable sequences above the lowest unacknowledged one. The receiver still // holds those bytes behind the gap, so the flow window keeps counting them until the lowest sequence passes them. private readonly Dictionary _ackedAboveLowest = new Dictionary(); private readonly Queue _queuedReliable = new Queue(); // Every reliable sequence up to and including this one has been received since the peer state // was last reset. Only the handshake seeds it: the peer's Connect on the accepting side, the // peer's Accept on the connecting side. Reliable data that arrives before that is refused. private uint? _receivedThrough; // Ordered frames received but not yet deliverable, keyed by first sequence. A frame is held for // exactly one reason: a reliable sequence below it has not arrived. private readonly SortedDictionary _orderedHeld = new SortedDictionary(); 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)); ValidateLimits(options.InitialSequence, options.MaxPendingReliablePackets); ConnectionId = options.ConnectionId; _nextSequence = options.InitialSequence ?? DrawSequence(); ResendDelayMs = options.ResendDelayMs; _maxPendingReliablePackets = options.MaxPendingReliablePackets; } /// /// A sequence drawn from a secure source in [1, 2^31). The Connect's sequence is what tells a /// peer whether a Connect repeats one it already accepted or starts a new session, so two /// sessions must not share one by default. /// private static uint DrawSequence() { const uint ceiling = 1u << 31; var bytes = new byte[4]; using (var random = System.Security.Cryptography.RandomNumberGenerator.Create()) random.GetBytes(bytes); return 1u + BitConverter.ToUInt32(bytes, 0) % (ceiling - 1u); } // Whether `sequence` is at or before `mark` in serial order, within the receive window. The // compare is modular, so it holds across the wrap of the 32-bit space; a sequence further back // than the window is the duplicate test's below-window clause, and one ahead of the mark is // never before it. private static bool AtOrBefore(uint sequence, uint mark) => unchecked(mark - sequence) < ReceivedSequenceWindow; internal static readonly byte[] RefusedPacketReason = Encoding.UTF8.GetBytes("session refused a packet"); /// /// A session cannot admit a Connect when its sequence space starts exhausted or its reliable /// queue holds nothing, so neither is a usable configuration. /// internal static void ValidateLimits(uint? initialSequence, int? maxPendingReliablePackets) { if (initialSequence == uint.MaxValue) { throw new ArgumentOutOfRangeException(nameof(initialSequence), "RUDP InitialSequence must leave room for a reliable packet."); } if (maxPendingReliablePackets.HasValue && maxPendingReliablePackets.Value <= 0) { throw new ArgumentOutOfRangeException(nameof(maxPendingReliablePackets), "RUDP MaxPendingReliablePackets must be greater than zero."); } } /// /// 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; internal long Generation => Interlocked.Read(ref _generation); internal bool Ended => _ended; /// /// Gets the logical time of the last received packet. /// public long? LastReceivedAtMs => _lastReceivedAtMs; /// /// Gets reliable packet sequences awaiting acknowledgement. /// public IReadOnlyList PendingReliableSequences { get { lock (_pendingReliableGate) { return _pendingReliable.Keys.OrderBy(value => value).ToArray(); } } } /// Gets the number of reliable packets waiting for admission to the wire. public int QueuedReliablePacketCount { get { lock (_pendingReliableGate) return _queuedReliable.Count; } } /// Gets all reliable packets that have not yet been acknowledged. public int OutstandingReliablePacketCount { get { lock (_pendingReliableGate) return _pendingReliable.Count + _queuedReliable.Count; } } /// /// Creates a reliable ordered connect packet. /// public CultNetRudpPacket CreateConnect(long nowMs = 0, byte[]? payload = null) { // A session that has had a peer starts a new generation: nothing it learned from that // peer describes the one this Connect reaches, and what it still owed that peer no // longer takes room in the queue. if (_connectSequence.HasValue) ResetPeerState(); EnsureReliableCapacity(1); _ended = false; var packet = CreatePacket(CultNetRudpPacketType.Connect, "control", payload ?? Array.Empty(), reliable: true, ordered: true, sequenced: false); _connectSequence = packet.Sequence; _awaitingAccept = true; _connectStartedAtMs = nowMs; _connectPayload = packet.Payload ?? Array.Empty(); TrackReliable(packet, nowMs); return packet; } /// /// Whether is a Connect this session's current generation already owns: /// a retransmit of the Connect that started it (the session is connected and the sequence is /// that Connect's), or a stale copy of an earlier attempt by the same client (a sequence before /// it, within the receive window). Servers that keep one session per peer ask this to tell a /// Connect that starts a new session from one that does not; anything else that reaches /// starts a new generation. /// public bool ConnectRepeats(CultNetRudpPacket packet) { if (packet == null) throw new ArgumentNullException(nameof(packet)); return packet.PacketType == CultNetRudpPacketType.Connect && _connected && _connectSequence.HasValue && (_connectSequence.Value == packet.Sequence || ConnectIsStale(packet.Sequence, _connectSequence.Value)); } // A Connect that precedes the current generation's within the receive window is the client's // earlier attempt, delayed in the network: a client that retried never sends a lower sequence // again. Restarting on it would strand the client, which honours only the Accept for its newest // Connect. The rule is TCP's answer to a delayed SYN (RFC 5961's challenge ACK): keep the // connection and answer with an Ack. A restarted client whose random initial sequence lands in // this window is answered the same way and abandons the attempt for a fresh draw // (ConnectAttemptMs). private static bool ConnectIsStale(uint sequence, uint current) => sequence != current && AtOrBefore(sequence, current); /// /// Answers a connect packet. A repeat of the accepted connect queues nothing, so a connect storm /// cannot grow the reliable queue: the reply is the accept still awaiting acknowledgement, or an /// ack once it was acknowledged. A stale copy of an earlier attempt gets the same reply and /// changes nothing else: it is not evidence the peer is alive. Any other connect ends the /// current generation, forgets the peer and accepts a new one with 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}."); } if (ConnectRepeats(packet)) { if (_connectSequence == packet.Sequence) { ApplyAcknowledgements(packet); RememberReceived(packet.Sequence); _lastReceivedAtMs = nowMs; } return PendingAcceptForResend(nowMs) ?? CreateAck(); } ResetPeerState(); EnsureReliableCapacity(1); SeedReceived(packet.Sequence); _lastReceivedAtMs = nowMs; _connectSequence = packet.Sequence; _connected = true; _ended = false; var response = CreatePacket(CultNetRudpPacketType.Accept, "control", payload ?? Array.Empty(), reliable: true, ordered: true, sequenced: false); TrackReliable(response, nowMs); return response; } // The Accept still awaiting acknowledgement, resent. private CultNetRudpPacket? PendingAcceptForResend(long nowMs) { lock (_pendingReliableGate) { foreach (var pending in _pendingReliable.Values) { if (pending.Packet.PacketType != CultNetRudpPacketType.Accept) continue; pending.LastSentAtMs = nowMs; return ClonePacket(pending.Packet); } } return null; } /// /// Creates a data packet. /// public CultNetRudpPacket Send(string channelId, byte[] payload, CultNetRudpSendOptions? options = null) { options ??= new CultNetRudpSendOptions(); lock (_pendingReliableGate) { if (options.Reliable && (_queuedReliable.Count > 0 || !WindowAdmits(_nextSequence, payload.Length))) throw new InvalidOperationException("RUDP reliable send window is full; receive acknowledgements before sending."); } 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(); if (options.Ordered && !options.Reliable) { throw new InvalidOperationException("RUDP ordered delivery requires reliability."); } 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); packets.Add(fragmentPacket); } return options.Reliable ? AdmitReliablePackets(packets, options.NowMs) : packets; } EnsureReliableCapacity(options.Reliable ? 1 : 0); var packet = CreatePacket( CultNetRudpPacketType.Data, channelId, payload, options.Reliable, options.Ordered, options.Sequenced); if (packet.Reliable) { return AdmitReliablePackets(new[] { packet }, options.NowMs); } return new[] { packet }; } /// /// Applies a remote packet to the session. /// public CultNetRudpReceiveResult Receive(CultNetRudpPacket packet, long nowMs = 0) { RequireConnection(packet); // An Accept counts only while this side's Connect is unanswered and the Accept names it. // A late or duplicate one, or one from an earlier generation, must not seed the watermark // or revive an ended session. var honoursAccept = packet.PacketType == CultNetRudpPacketType.Accept && _awaitingAccept && _connectSequence.HasValue && PacketAcknowledges(packet, _connectSequence.Value); if (packet.PacketType == CultNetRudpPacketType.Accept && !honoursAccept) { return new CultNetRudpReceiveResult(); } // While this side's Connect awaits its Accept, only the Accept it honours retires it: an Ack // that names the Connect (a server's reply to a repeat or a stale copy) says the server did // not start a session. if (honoursAccept || !_awaitingAccept) ApplyAcknowledgements(packet); var readyToSend = PromoteQueuedReliable(nowMs); _lastReceivedAtMs = nowMs; if (honoursAccept) { _awaitingAccept = false; SeedReceived(packet.Sequence); _connected = true; return new CultNetRudpReceiveResult { ReadyToSend = readyToSend }; } if (packet.PacketType == CultNetRudpPacketType.Ping) { return new CultNetRudpReceiveResult { ReadyToSend = readyToSend, Reply = CreatePacket( CultNetRudpPacketType.Pong, "control", packet.Payload ?? Array.Empty(), reliable: false, ordered: false, sequenced: false) }; } if (packet.PacketType == CultNetRudpPacketType.Ack || packet.PacketType == CultNetRudpPacketType.Pong) { return new CultNetRudpReceiveResult { Delivered = Array.Empty(), ReadyToSend = readyToSend, Pong = packet.PacketType == CultNetRudpPacketType.Pong, PongPayload = packet.PacketType == CultNetRudpPacketType.Pong ? packet.Payload ?? Array.Empty() : Array.Empty() }; } if (packet.PacketType == CultNetRudpPacketType.Disconnect) { EndSession(); return new CultNetRudpReceiveResult { ReadyToSend = readyToSend, Disconnected = true, DisconnectReason = packet.Payload ?? Array.Empty() }; } if (packet.PacketType != CultNetRudpPacketType.Data) { return new CultNetRudpReceiveResult { ReadyToSend = readyToSend }; } // Reliable data before the handshake has seeded the watermark has no place in the order: // refuse it unremembered, so it is not acknowledged and the sender retransmits it once // the handshake is done. if (packet.Reliable && !_receivedThrough.HasValue) { return new CultNetRudpReceiveResult { ReadyToSend = readyToSend }; } var duplicate = packet.Reliable && WasReceived(packet.Sequence); if (packet.Reliable) { RememberReceived(packet.Sequence); } if (duplicate) { return new CultNetRudpReceiveResult { ReadyToSend = readyToSend }; } var delivered = new List(); var reassembled = Reassemble(packet); if (reassembled != null) { if (reassembled.Ordered) { _orderedHeld[reassembled.Frame.Sequence] = reassembled.Frame; } else if (!packet.Sequenced) { delivered.Add(reassembled.Frame); } else { var newestSequence = reassembled.NextSequence - 1; if (!_latestSequencedByChannel.TryGetValue(reassembled.Frame.ChannelId, out var latestSequence) || newestSequence > latestSequence) { _latestSequencedByChannel[reassembled.Frame.ChannelId] = newestSequence; delivered.Add(reassembled.Frame); } } } // Any reliable packet may have advanced the watermark, a fragment or an unordered frame as // much as an ordered one, so the drain runs after all of them. delivered.AddRange(DrainOrdered()); return new CultNetRudpReceiveResult { ReadyToSend = readyToSend, Delivered = delivered }; } /// /// Creates a packet carrying the current acknowledgement state. /// public CultNetRudpPacket CreateAck(uint? acknowledgedSequence = null) { var (ack, ackMask) = AckState(); if (acknowledgedSequence.HasValue) { ack = acknowledgedSequence.Value; ackMask = 0; } return new CultNetRudpPacket { PacketType = CultNetRudpPacketType.Ack, ConnectionId = ConnectionId, Sequence = 0, Ack = ack, AckMask = ackMask, ChannelId = "control", Payload = Array.Empty() }; } /// /// Creates a cumulative acknowledgement when the received packet is inside the /// acknowledgement horizon, or an exact acknowledgement for an older retransmit. /// public CultNetRudpPacket CreateAckForReceived(uint receivedSequence) { // What Receive refused is not acknowledged by name (the ack carries only what was // received), so its sender retransmits it. if (!WasReceived(receivedSequence)) return CreateAck(); var (ack, _) = AckState(); return ack >= receivedSequence && ack - receivedSequence <= 32 ? CreateAck() : CreateAck(receivedSequence); } /// /// 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); } /// /// The one way a session generation ends. The session stops being connected and what it still /// owed the peer dies with it: a write not yet acknowledged is dropped, so no later session /// retransmits it or credits an ack to it. What was learned from the peer is not touched: the /// peer may not know the session ended, and forgetting what it sent would let its retransmits /// be delivered twice. /// private void EndSession() { _connected = false; _ended = true; _awaitingAccept = false; Interlocked.Increment(ref _generation); lock (_pendingReliableGate) { _pendingReliable.Clear(); _ackedAboveLowest.Clear(); _queuedReliable.Clear(); } } /// /// Ends the current generation and forgets everything learned from the peer; sequence numbers /// already issued stay issued. /// public void ResetPeerState() { EndSession(); _lastReceivedAtMs = null; _highestReceivedSequence = null; _receivedSequences.Clear(); _nextSequencedByChannel.Clear(); _latestSequencedByChannel.Clear(); _receivedThrough = null; _orderedHeld.Clear(); _fragmentBuffers.Clear(); } /// /// Ends a session that refused a packet. Receive has already recorded the packet's reliable /// sequence, so the peer's sequences are forgotten before the goodbye is built: its ack field /// would otherwise acknowledge the very frame the session refused. /// internal CultNetRudpPacket EndRefused() => EndWith(RefusedPacketReason); /// /// Ends a session whose peer's frame the application above it refused or failed on, after the /// session acknowledged it. As with , the peer's sequences are forgotten /// before the goodbye is built. /// internal CultNetRudpPacket EndWith(byte[] reason) { ResetPeerState(); return CreateDisconnect(reason); } /// /// Whether a failed send can never succeed for the datagram as built, so resending it is /// pointless: it is too large for a datagram (EMSGSIZE, WSAEMSGSIZE), its address is /// malformed for the socket (EINVAL, WSAEINVAL), or belongs to another address family /// (EAFNOSUPPORT, WSAEAFNOSUPPORT). Everything else (no route, full buffers, firewall drops, /// a refusal reported for an earlier datagram, an interrupted call) is a property of the path /// or the moment and may pass, so it is a lost datagram, not a reason to end a session. /// internal static bool IsPermanentSendError(SocketException error) => error.SocketErrorCode == SocketError.MessageSize || error.SocketErrorCode == SocketError.InvalidArgument || error.SocketErrorCode == SocketError.AddressFamilyNotSupported; internal const string UnsendablePacketReason = "packet could not be sent"; /// /// The fixed name of a failed send's cause: EMSGSIZE, EINVAL or EAFNOSUPPORT for a failure /// that can never pass (), UNKNOWN for anything else, as /// every runtime's goodbye names it. Never the error's own message, which is the platform's /// wording. /// internal static string SendErrorCode(SocketException error) => error.SocketErrorCode switch { SocketError.MessageSize => "EMSGSIZE", SocketError.InvalidArgument => "EINVAL", SocketError.AddressFamilyNotSupported => "EAFNOSUPPORT", _ => "UNKNOWN", }; /// /// Ends a session that owes its peer a packet that can never be sent as built. Resending it /// would fail forever, and dropping it would leave a reliable sequence the peer waits on for /// good, so the session cannot be kept. The goodbye's reason names the failure by its fixed /// name (). /// internal CultNetRudpPacket EndUnsendable(SocketException error) { ResetPeerState(); return CreateDisconnect(Encoding.UTF8.GetBytes(UnsendablePacketReason + ": " + SendErrorCode(error))); } internal uint NextSequence => _nextSequence; /// /// Undoes a send that never reached the wire: every packet it created at or after /// , pending or queued, leaves no trace and its sequences are /// reissued, so the peer never sees a gap. Only valid while nothing else has been sent since. /// internal void WithdrawSend(uint firstSequence) { lock (_pendingReliableGate) { foreach (var sequence in _pendingReliable.Keys.Where(sequence => sequence >= firstSequence).ToArray()) _pendingReliable.Remove(sequence); var kept = _queuedReliable.Where(packet => packet.Sequence < firstSequence).ToArray(); _queuedReliable.Clear(); foreach (var packet in kept) _queuedReliable.Enqueue(packet); } _nextSequence = firstSequence; } /// /// Creates a packet carrying a transport-level disconnect reason. /// public CultNetRudpPacket CreateDisconnect(byte[]? reason = null) { EndSession(); 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; } EndSession(); return true; } /// /// Returns reliable packets due for resend at the supplied logical time. /// public IReadOnlyList DueResends(long nowMs) { var fresh = AbandonUnansweredConnect(nowMs); if (fresh != null) return new[] { fresh }; var due = new List(); lock (_pendingReliableGate) { 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) { uint sequence; if (reliable) { sequence = _nextSequence; _nextSequence = checked(_nextSequence + 1); } else if (sequenced) { if (!_nextSequencedByChannel.TryGetValue(channelId, out sequence)) { sequence = 1; } _nextSequencedByChannel[channelId] = checked(sequence + 1); } else { sequence = 0; } 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) { lock (_pendingReliableGate) { _pendingReliable[packet.Sequence] = new PendingReliablePacket { Packet = ClonePacket(packet), LastSentAtMs = nowMs }; } } /// /// Whether a reliable packet may go on the wire now: the window has a slot, and its sequence and the /// payload above the lowest unacked sequence stay inside the flow window. With nothing pending, any /// packet is admissible. Callers hold the pending gate. /// private bool WindowAdmits(uint sequence, int payloadLength) { if (_pendingReliable.Count >= ReliableSendWindowPackets) return false; if (_pendingReliable.Count == 0) return true; var lowest = _pendingReliable.Keys.Min(); long bytesAbove = 0; foreach (var pending in _pendingReliable) { if (pending.Key > lowest) bytesAbove += pending.Value.Packet.Payload.Length; } foreach (var length in _ackedAboveLowest.Values) bytesAbove += length; return sequence - lowest <= FlowWindowSequences && bytesAbove + payloadLength <= FlowWindowBytes; } private IReadOnlyList AdmitReliablePackets( IReadOnlyList packets, long nowMs) { lock (_pendingReliableGate) { var ready = new List(); foreach (var packet in packets) { if (_queuedReliable.Count == 0 && WindowAdmits(packet.Sequence, packet.Payload.Length)) { _pendingReliable[packet.Sequence] = new PendingReliablePacket { Packet = ClonePacket(packet), LastSentAtMs = nowMs }; ready.Add(packet); } else { _queuedReliable.Enqueue(ClonePacket(packet)); } } return ready; } } private IReadOnlyList PromoteQueuedReliable(long nowMs) { lock (_pendingReliableGate) { var ready = new List(); while (_queuedReliable.Count > 0 && WindowAdmits(_queuedReliable.Peek().Sequence, _queuedReliable.Peek().Payload.Length)) { var packet = _queuedReliable.Dequeue(); _pendingReliable[packet.Sequence] = new PendingReliablePacket { Packet = ClonePacket(packet), LastSentAtMs = nowMs }; ready.Add(packet); } return ready; } } private void EnsureReliableCapacity(int packetCount) { if (packetCount == 0 || !_maxPendingReliablePackets.HasValue) { return; } lock (_pendingReliableGate) { if (_pendingReliable.Count + _queuedReliable.Count + packetCount > _maxPendingReliablePackets.Value) { throw new InvalidOperationException("RUDP reliable send queue is full."); } } } private void ApplyAcknowledgements(CultNetRudpPacket packet) { lock (_pendingReliableGate) { Acknowledge(packet.Ack); for (var bit = 0; bit < 32; bit++) { if ((packet.AckMask & (1u << bit)) != 0 && packet.Ack > bit) { Acknowledge(packet.Ack - (uint)bit - 1); } } // Acknowledged sizes stop counting once the lowest unacknowledged sequence passes them, and all // of them stop when nothing is unacknowledged. if (_pendingReliable.Count == 0) { _ackedAboveLowest.Clear(); } else { var lowest = _pendingReliable.Keys.Min(); foreach (var sequence in _ackedAboveLowest.Keys.Where(sequence => sequence <= lowest).ToArray()) _ackedAboveLowest.Remove(sequence); } } } // Callers hold the pending gate. private void Acknowledge(uint sequence) { if (_pendingReliable.Remove(sequence, out var pending)) _ackedAboveLowest[sequence] = pending.Packet.Payload.Length; } private static bool PacketAcknowledges(CultNetRudpPacket packet, uint sequence) { if (packet.Ack == sequence) return true; for (var bit = 0; bit < 32; bit++) { if ((packet.AckMask & (1u << bit)) != 0 && packet.Ack > bit && packet.Ack - (uint)bit - 1 == sequence) return true; } return false; } // A Connect unanswered for ConnectAttemptMs is replaced, not retransmitted further, by a Connect // with the same payload and the abandoned sequence plus the receive window less one. Nothing // was ever sent in an unanswered generation, so no sequence issued so far is owed. The jump // keeps a late copy of the abandoned Connect inside the new one's stale window, so a server // that took the new Connect answers the copy with an Ack instead of restarting; and it leaves // the stale window of whatever generation the server holds, unless that generation sits // exactly at the jump, and then the next attempt leaves it. The first Connect of a session is // the only one drawn at random. private CultNetRudpPacket? AbandonUnansweredConnect(long nowMs) { if (!_awaitingAccept || nowMs - _connectStartedAtMs < ConnectAttemptMs || !_connectSequence.HasValue) return null; var jumped = (ulong)_connectSequence.Value + ReceivedSequenceWindow - 1; _nextSequence = (uint)Math.Min(jumped, uint.MaxValue - 1); return CreateConnect(nowMs, _connectPayload); } // The handshake's one act on the watermark: the peer's Connect or Accept is the first sequence // of the session, whatever else it has sent. private void SeedReceived(uint sequence) { _receivedThrough = sequence; RememberReceived(sequence); } // The one duplicate test: true for a sequence in the window that was received, for one below // the window, and for one at or before the watermark. The watermark starts at the handshake's // seed, so a frame the peer sent in an earlier generation (every sequence it issued is below // the Connect that began this one) is a duplicate of something already delivered and is // acknowledged, never delivered again. private bool WasReceived(uint sequence) { return _receivedSequences.Contains(sequence) || (_highestReceivedSequence.HasValue && sequence < _highestReceivedSequence.Value && _highestReceivedSequence.Value - sequence >= ReceivedSequenceWindow) || (_receivedThrough.HasValue && AtOrBefore(sequence, _receivedThrough.Value)); } private void RememberReceived(uint sequence) { _receivedSequences.Add(sequence); if (_receivedThrough.HasValue) { var through = _receivedThrough.Value; while (through != uint.MaxValue && _receivedSequences.Contains(through + 1)) { through++; } _receivedThrough = through; } if (!_highestReceivedSequence.HasValue || sequence > _highestReceivedSequence.Value) { _highestReceivedSequence = sequence; } if (_receivedSequences.Count > ReceivedSequenceWindow) { var keepFrom = (_highestReceivedSequence ?? sequence) >= ReceivedSequenceWindow - 1 ? (_highestReceivedSequence ?? sequence) - (uint)(ReceivedSequenceWindow - 1) : 0; _receivedSequences.RemoveWhere(received => received < keepFrom); } } 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 }; } /// /// Delivers, in sequence order, every held ordered frame whose first sequence is at most one past /// the watermark. Ordered delivery has one owner: the watermark. A frame this call does not /// deliver waits for the sequence below it, and nothing else releases it. /// private IReadOnlyList DrainOrdered() { var delivered = new List(); if (!_receivedThrough.HasValue) { return delivered; } var through = _receivedThrough.Value; foreach (var pair in _orderedHeld) { if (through != uint.MaxValue && pair.Key > through + 1) { break; } delivered.Add(pair.Value); } foreach (var frame in delivered) { _orderedHeld.Remove(frame.Sequence); } return delivered; } 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 const int MinimumReceiveBufferBytes = 4 * 1024 * 1024; // One acknowledgement carries the preceding 32-packet receive mask. // Pace at that transport window instead of sleeping after every fragment; // a one-millisecond sleep can consume a full scheduler quantum on Windows. private const int WireBurstPackets = 32; private readonly Socket _socket; private readonly CultNetRudpSession _session; private readonly object _sessionGate = new object(); 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)); if (_socket.ReceiveBufferSize < MinimumReceiveBufferBytes) _socket.ReceiveBufferSize = MinimumReceiveBufferBytes; _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 { get { lock (_sessionGate) return _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."); } DisconnectReason = null; lock (_sessionGate) 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) { lock (_sessionGate) SendPackets(_session.SendMany(channelId, payload, ChannelSendOptions(channelId), _maxFragmentBytes)); _stats.FramesSent++; } /// /// Pumps acknowledgements and resends until every queued reliable packet is acknowledged. /// public void FlushReliable(TimeSpan? timeout = null) { var deadline = DateTimeOffset.UtcNow.Add(timeout ?? TimeSpan.FromSeconds(30)); var preserved = new List(); while (_deliveredFrames.Count > 0) preserved.Add(_deliveredFrames.Dequeue()); // A flush belongs to the generation it started in: whatever ends that generation, and // whatever begins after it, the writes being waited on are gone. long generation; lock (_sessionGate) generation = _session.Generation; try { while (true) { lock (_sessionGate) { // An ended session forgot its unacknowledged writes; reporting them flushed // would be a lie. if (_session.Ended || _session.Generation != generation) throw new InvalidOperationException( "RUDP session ended before its reliable writes were acknowledged: " + (DisconnectReason == null ? "no reason given" : Encoding.UTF8.GetString(DisconnectReason))); if (_session.OutstandingReliablePacketCount == 0) return; } if (DateTimeOffset.UtcNow >= deadline) { int outstanding; lock (_sessionGate) outstanding = _session.OutstandingReliablePacketCount; throw new TimeoutException($"RUDP reliable flush timed out with {outstanding} packets outstanding."); } if (TryReceiveOnce(out var delivered) && delivered != null) preserved.Add(delivered); PollResends(); Thread.Sleep(5); } } finally { while (_deliveredFrames.Count > 0) preserved.Add(_deliveredFrames.Dequeue()); foreach (var frame in preserved) _deliveredFrames.Enqueue(frame); } } /// /// 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) { lock (_sessionGate) SendPacket(_session.CreateDisconnect(reason ?? Array.Empty())); } /// /// Sends a transport-level ping packet. /// public void Ping(byte[]? payload = null) { lock (_sessionGate) SendPacket(_session.CreatePing(payload ?? Array.Empty())); } /// /// Checks whether the session has exceeded the receive timeout. /// public bool CheckTimeout(long timeoutMs) { lock (_sessionGate) return _session.CheckTimeout(NowMs(), timeoutMs); } /// /// Polls the UDP socket once and returns the next delivered transport frame if one is available. /// public CultNetTransportFrame? ReceiveOnce() { TryReceiveOnce(out var delivered); return delivered; } /// /// Consumes one available transport work item and reports progress independently /// of whether that packet completed an application frame. /// public bool TryReceiveOnce(out CultNetTransportFrame? delivered) { if (_deliveredFrames.Count > 0) { delivered = _deliveredFrames.Dequeue(); return true; } delivered = null; var buffer = new byte[65535]; EndPoint remote = new IPEndPoint(IPAddress.Any, 0); int received; try { if (!_socket.Poll(0, SelectMode.SelectRead)) { return false; } received = _socket.ReceiveFrom(buffer, ref remote); } catch (SocketException error) when ( error.SocketErrorCode == SocketError.WouldBlock || error.SocketErrorCode == SocketError.TimedOut) { return false; } catch (SocketException error) when ( error.SocketErrorCode == SocketError.ConnectionReset || error.SocketErrorCode == SocketError.ConnectionAborted || error.SocketErrorCode == SocketError.Shutdown) { return false; } _stats.BytesReceived += received; // What a datagram carries is the sender's business, not a fault of this process: a // malformed frame, another session's connection id, a sender that is not the peer and // a packet the session refuses are dropped and counted. Only the socket ends a loop. // The id is checked before the peer endpoint is claimed, so a stray cannot become it. var wire = new byte[received]; Array.Copy(buffer, wire, received); CultNetRudpPacket packet; try { packet = CultNetRudpPacketCodec.Decode(wire); } catch (Exception error) when (error is InvalidOperationException || error is ArgumentException) { _stats.PacketsDropped++; return true; } if (packet.ConnectionId != _session.ConnectionId) { _stats.PacketsDropped++; return true; } // A Connect from another endpoint is a new client, whatever sequence it carries: the endpoint // moves and the Connect starts a new generation. Only a Connect from the peer's own // endpoint can be its repeat. var connectFromNewEndpoint = false; if (_remoteEndPoint == null) { // Only a Connect claims the endpoint of a server-mode transport. if (_mode == CultNetRudpSocketMode.Server && packet.PacketType != CultNetRudpPacketType.Connect) { _stats.PacketsDropped++; return true; } _remoteEndPoint = remote; } else if (!_remoteEndPoint.Equals(remote)) { if (_mode == CultNetRudpSocketMode.Server && packet.PacketType == CultNetRudpPacketType.Connect) { _remoteEndPoint = remote; connectFromNewEndpoint = true; } else { _stats.PacketsDropped++; return true; } } TracePacket("rx", packet, received, remote); if (_mode == CultNetRudpSocketMode.Server && packet.PacketType == CultNetRudpPacketType.Connect) { lock (_sessionGate) { CultNetRudpPacket accept; try { // The session answers a repeat of the connect it accepted with the accept // already owed, and anything else with a new generation. if (connectFromNewEndpoint) _session.ResetPeerState(); accept = _session.AcceptConnect(packet, NowMs()); DisconnectReason = null; } catch (InvalidOperationException) { _stats.PacketsDropped++; return true; } SendPacket(accept); } return true; } CultNetRudpReceiveResult result; CultNetRudpPacket? acknowledgement = null; lock (_sessionGate) { try { result = _session.Receive(packet, NowMs()); } catch (InvalidOperationException) { // Receive has already recorded the packet's reliable sequence, so the session // cannot be kept: a retransmit would be acknowledged and the frame silently lost. // End it and tell the peer; a server-mode transport also forgets its endpoint, so // only a Connect can claim it again. _stats.PacketsDropped++; var goodbye = _session.EndRefused(); DisconnectReason = CultNetRudpSession.RefusedPacketReason; try { SendPacket(goodbye); } catch (SocketException) { // Best effort: the session ends whether or not the peer hears it. } if (_mode == CultNetRudpSocketMode.Server) _remoteEndPoint = null; return true; } if (result.Reply != null) SendPacket(result.Reply); SendPackets(result.ReadyToSend); if (packet.PacketType == CultNetRudpPacketType.Accept || packet.PacketType == CultNetRudpPacketType.Data) acknowledgement = _session.CreateAckForReceived(packet.Sequence); } if (result.Pong) { _pongPayloads.Enqueue(result.PongPayload); } if (result.Disconnected) { DisconnectReason = result.DisconnectReason; return true; } foreach (var frame in result.Delivered) { _deliveredFrames.Enqueue(new CultNetTransportFrame { ChannelId = frame.ChannelId, Payload = frame.Payload }); _stats.FramesReceived++; } delivered = _deliveredFrames.Count > 0 ? _deliveredFrames.Dequeue() : null; if (acknowledgement != null) SendPacket(acknowledgement); return true; } /// /// Sends any reliable packets whose resend timers are due. /// public void PollResends() { if (_socket.Poll(0, SelectMode.SelectRead)) return; lock (_sessionGate) SendPackets(_session.DueResends(NowMs())); } private void SendPackets(IReadOnlyList packets) { for (var index = 0; index < packets.Count; index++) { SendPacket(packets[index]); if ((index + 1) % WireBurstPackets == 0 && index + 1 < packets.Count) Thread.Yield(); } } /// 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; TracePacket("tx", packet, sent, _remoteEndPoint); } private static void TracePacket(string direction, CultNetRudpPacket packet, int bytes, EndPoint? endpoint) { if (!string.Equals(Environment.GetEnvironmentVariable("CULTNET_TRACE_RUDP"), "1", StringComparison.Ordinal)) { return; } Console.Error.WriteLine( $"CultNet RUDP {direction} type={packet.PacketType} seq={packet.Sequence} ack={packet.Ack} bytes={bytes} endpoint={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(); } } /// /// 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. Unset, each /// session draws its own at random. /// public uint? InitialSequence { get; set; } /// /// 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, byte[] connectPayload, CultNetRudpSession session) { RemoteEndPoint = remoteEndPoint; ConnectPayload = connectPayload; Session = session; } internal byte[] ConnectPayload { get; } internal CultNetRudpSession Session { get; } internal object SessionGate { get; } = new object(); /// /// Gets the UDP endpoint for this peer. /// public EndPoint RemoteEndPoint { get; } /// /// Gets whether the peer RUDP handshake has completed. /// public bool Connected { get { lock (SessionGate) return Session.Connected; } } /// /// Gets the number of reliable packets still awaiting acknowledgement. /// public int PendingReliablePacketCount { get { lock (SessionGate) return Session.OutstandingReliablePacketCount; } } /// /// Gets the last transport-level remote disconnect reason, if one was received. /// public byte[]? DisconnectReason { get; internal set; } // The goodbye's reason once the session ended over a packet it could never send. Written // under SessionGate by whichever thread's send found the failure; the polling thread reads // it when it retires the peer. internal byte[]? UnsendableReason { get; 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 const int MinimumReceiveBufferBytes = 4 * 1024 * 1024; private static readonly byte[] ReplacedPeerReason = Encoding.UTF8.GetBytes("replaced by a new Connect generation"); // Keep server response pacing aligned with the 32-packet ACK mask. Large // content responses otherwise pay one Windows scheduler quantum per 1 KiB. private const int WireBurstPackets = 32; 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(); // Peers whose session ended over a packet it could never send, waiting for the polling thread // to retire them. Only the polling thread changes _peers or raises PeerDisconnected; a send on // a caller's thread ends the session and leaves the peer here. private readonly ConcurrentQueue _unsendablePeers = new ConcurrentQueue(); 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)); if (_socket.ReceiveBufferSize < MinimumReceiveBufferBytes) _socket.ReceiveBufferSize = MinimumReceiveBufferBytes; CultNetRudpSession.ValidateLimits(options.InitialSequence, options.MaxPendingReliablePackets); _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(); /// /// Raised when a tracked peer closes its RUDP session. /// public event Action? PeerDisconnected; /// /// 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)); SocketException? unsendable = null; var failedIndex = 0; long generation; lock (peer.SessionGate) { var firstSequence = peer.Session.NextSequence; var packets = peer.Session.SendMany(channelId, payload, ChannelSendOptions(channelId), _maxFragmentBytes); generation = peer.Session.Generation; unsendable = SendPackets(peer, packets, out failedIndex); // A caller-directed send that can never succeed is the caller's error and queues // nothing. Only when earlier fragments are already on the wire has the peer seen // part of it, and then the session cannot be kept. if (unsendable != null && failedIndex == 0) peer.Session.WithdrawSend(firstSequence); } if (unsendable != null) { // The polling thread retires the peer; this thread only ends its session. if (failedIndex > 0) EndUnsendableSession(peer, unsendable, generation); throw unsendable; } _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() { TryReceiveOnce(out var delivered); return delivered; } /// /// Consumes one available transport work item and reports whether transport progress was made, /// independently of whether that work item completed an application frame. /// public bool TryReceiveOnce(out CultNetRudpSocketServerFrame? delivered) { RetireUnsendablePeers(); if (_deliveredFrames.Count > 0) { delivered = _deliveredFrames.Dequeue(); return true; } delivered = null; var buffer = new byte[65535]; EndPoint remote = new IPEndPoint(IPAddress.Any, 0); int received; try { if (!_socket.Poll(0, SelectMode.SelectRead)) { return false; } received = _socket.ReceiveFrom(buffer, ref remote); } catch (SocketException error) when ( error.SocketErrorCode == SocketError.WouldBlock || error.SocketErrorCode == SocketError.TimedOut) { return false; } catch (SocketException error) when ( error.SocketErrorCode == SocketError.ConnectionReset || error.SocketErrorCode == SocketError.ConnectionAborted || error.SocketErrorCode == SocketError.Shutdown) { return false; } _stats.BytesReceived += received; var wire = new byte[received]; Array.Copy(buffer, wire, received); CultNetRudpPacket packet; try { packet = CultNetRudpPacketCodec.Decode(wire); } catch (Exception error) when (error is InvalidOperationException || error is ArgumentException) { _stats.PacketsDropped++; return true; } if (packet.ConnectionId != _connectionId) { _stats.PacketsDropped++; return true; } var peerKey = RemoteKey(remote); if (packet.PacketType == CultNetRudpPacketType.Connect) { var connectPayload = packet.Payload ?? Array.Empty(); _peers.TryGetValue(peerKey, out var admitted); // The admitted peer's session decides whether this Connect repeats the one it accepted: // answer a repeat with the Accept already owed and queue nothing. Any other Connect is // a new generation, and the admitted peer's session ends here, under the gate every // Send takes: a caller still holding the replaced peer can send nothing more into the // endpoint the new session now owns. if (admitted != null) { var repeats = false; SocketException? unsendableReply = null; long admittedGeneration; lock (admitted.SessionGate) { admittedGeneration = admitted.Session.Generation; if (!admitted.Session.ConnectRepeats(packet)) { admitted.Session.ResetPeerState(); } else { repeats = true; CultNetRudpPacket reply; try { reply = admitted.Session.AcceptConnect(packet, NowMs(), _acceptPayload); } catch (InvalidOperationException) { _stats.PacketsDropped++; return true; } unsendableReply = SendPacket(admitted.RemoteEndPoint, reply); } } if (repeats) { if (unsendableReply != null) EndUnsendablePeer(admitted, unsendableReply, admittedGeneration); return true; } } var peer = new CultNetRudpSocketServerPeer( CloneEndPoint(remote), connectPayload, new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = _connectionId, InitialSequence = _initialSequence, ResendDelayMs = _resendDelayMs, MaxPendingReliablePackets = _maxPendingReliablePackets })); SocketException? unsendableAccept; lock (peer.SessionGate) unsendableAccept = SendPacket(peer.RemoteEndPoint, peer.Session.AcceptConnect(packet, NowMs(), _acceptPayload)); if (unsendableAccept != null) { // The peer cannot be answered, so no session starts, and the one it replaced has // already ended. _peers.Remove(peerKey); _stats.PacketsDropped++; } else { _peers[peerKey] = peer; } if (admitted != null) { admitted.DisconnectReason = ReplacedPeerReason; PeerDisconnected?.Invoke(admitted); } return true; } if (!_peers.TryGetValue(peerKey, out var existingPeer)) { _stats.PacketsDropped++; return true; } CultNetRudpReceiveResult? outcome; CultNetRudpPacket? acknowledgement = null; SocketException? unsendable = null; long existingGeneration = 0; lock (existingPeer.SessionGate) { try { outcome = existingPeer.Session.Receive(packet, NowMs()); } catch (InvalidOperationException) { // Receive has already recorded the packet's reliable sequence, so the session // cannot be kept: a retransmit would be acknowledged and the frame silently lost. _stats.PacketsDropped++; outcome = null; } if (outcome == null) { // The session ends whether or not the peer hears the goodbye. SendPacket(existingPeer.RemoteEndPoint, existingPeer.Session.EndRefused()); } else { existingGeneration = existingPeer.Session.Generation; if (outcome.Reply != null) unsendable = SendPacket(existingPeer.RemoteEndPoint, outcome.Reply); unsendable ??= SendPackets(existingPeer, outcome.ReadyToSend, out _); if (packet.PacketType == CultNetRudpPacketType.Data) acknowledgement = existingPeer.Session.CreateAckForReceived(packet.Sequence); } } if (outcome == null) { existingPeer.DisconnectReason = CultNetRudpSession.RefusedPacketReason; _peers.Remove(peerKey); PeerDisconnected?.Invoke(existingPeer); return true; } var result = outcome; if (result.Disconnected) { existingPeer.DisconnectReason = result.DisconnectReason; _peers.Remove(peerKey); PeerDisconnected?.Invoke(existingPeer); return true; } foreach (var frame in result.Delivered) { _deliveredFrames.Enqueue(new CultNetRudpSocketServerFrame { Peer = existingPeer, Frame = new CultNetTransportFrame { ChannelId = frame.ChannelId, Payload = frame.Payload } }); _stats.FramesReceived++; } delivered = _deliveredFrames.Count > 0 ? _deliveredFrames.Dequeue() : null; if (acknowledgement != null && unsendable == null) unsendable = SendPacket(existingPeer.RemoteEndPoint, acknowledgement); if (unsendable != null) EndUnsendablePeer(existingPeer, unsendable, existingGeneration); return true; } /// /// Sends any reliable packets whose resend timers are due for all peers. /// public void PollResends() { RetireUnsendablePeers(); if (_socket.Poll(0, SelectMode.SelectRead)) return; foreach (var peer in _peers.Values.ToArray()) { SocketException? unsendable; long generation; lock (peer.SessionGate) { var due = peer.Session.DueResends(NowMs()); generation = peer.Session.Generation; unsendable = SendPackets(peer, due, out _); } if (unsendable != null) EndUnsendablePeer(peer, unsendable, generation); } } // Stops at the first packet that can never be sent as built and returns its error and // index; a transient failure is a lost datagram and the loop goes on. private SocketException? SendPackets(CultNetRudpSocketServerPeer peer, IReadOnlyList packets, out int failedIndex) { failedIndex = 0; for (var index = 0; index < packets.Count; index++) { var unsendable = SendPacket(peer.RemoteEndPoint, packets[index]); if (unsendable != null) { failedIndex = index; return unsendable; } if ((index + 1) % WireBurstPackets == 0 && index + 1 < packets.Count) Thread.Yield(); } return null; } // An admitted peer owes a packet that can never be sent as built. Inside a poll that ends the // peer's session, never the poll: the peer is told, and PeerDisconnected carries a reason that // names the error. private void EndUnsendablePeer(CultNetRudpSocketServerPeer peer, SocketException error, long generation) { EndUnsendableSession(peer, error, generation); RetireUnsendablePeers(); } // Ends the session and tells the peer, on whichever thread found the failure. The peer stays in // _peers until the polling thread retires it. The failure belongs to the generation the thread // read under the gate when it sent; if that generation has already ended (another thread // ended it over its own failure, or a new Connect from the endpoint replaced it), or the // thread read it only after the session had ended (a Pong or ack answered on an ended // session the polling thread has not yet retired), there is nothing left to end, and a // goodbye would reach the session that now owns the endpoint or repeat the first one. The // goodbye is built and sent under the gate, so no Connect is admitted between the two. private void EndUnsendableSession(CultNetRudpSocketServerPeer peer, SocketException error, long generation) { lock (peer.SessionGate) { if (peer.Session.Generation != generation || peer.Session.Ended) return; var goodbye = peer.Session.EndUnsendable(error); peer.UnsendableReason = goodbye.Payload; SendPacket(peer.RemoteEndPoint, goodbye); } _unsendablePeers.Enqueue(peer); } // Polling thread only. Ends the session of a peer whose delivered frame the application could not // decode or handle, with a goodbye carrying the application's reason. The frame is already // acknowledged, so the session cannot be kept: a retransmit would never be handled. The peer // is told, the drop is counted, and the peer's frames still queued are not delivered. A // session that already ended (a new Connect replaced it, or a send failure ended it) owes no // goodbye, since the endpoint may belong to a new session; the call then returns false. internal bool EndPeerSession(CultNetRudpSocketServerPeer peer, byte[] reason) { if (peer == null) throw new ArgumentNullException(nameof(peer)); if (reason == null) throw new ArgumentNullException(nameof(reason)); _stats.PacketsDropped++; lock (peer.SessionGate) { if (peer.Session.Ended) return false; SendPacket(peer.RemoteEndPoint, peer.Session.EndWith(reason)); } var kept = _deliveredFrames.Where(frame => !ReferenceEquals(frame.Peer, peer)).ToArray(); _deliveredFrames.Clear(); foreach (var frame in kept) _deliveredFrames.Enqueue(frame); var key = RemoteKey(peer.RemoteEndPoint); if (!_peers.TryGetValue(key, out var current) || !ReferenceEquals(current, peer)) return true; _peers.Remove(key); peer.DisconnectReason = reason; PeerDisconnected?.Invoke(peer); return true; } // Polling thread only. A peer a new Connect already replaced was reported then, and its key now // names the peer that replaced it. private void RetireUnsendablePeers() { while (_unsendablePeers.TryDequeue(out var peer)) { var key = RemoteKey(peer.RemoteEndPoint); if (!_peers.TryGetValue(key, out var current) || !ReferenceEquals(current, peer)) continue; _peers.Remove(key); peer.DisconnectReason = peer.UnsendableReason; PeerDisconnected?.Invoke(peer); } } /// public void Dispose() { if (_disposed) { return; } _disposed = true; _socket.Dispose(); } // A transient send failure is that peer's lost datagram: counted, never thrown. A reliable // packet stays pending and is resent; an unreliable one was allowed to be lost. A permanent // failure (CultNetRudpSession.IsPermanentSendError) is returned: the datagram can never be // sent as built, and the caller decides between refusing the send and ending the peer's // session. Only an encode failure, the listener's own bug, throws. private SocketException? SendPacket(EndPoint remoteEndPoint, CultNetRudpPacket packet) { var wire = CultNetRudpPacketCodec.Encode(packet); BeforeSend?.Invoke(remoteEndPoint, packet); try { if (UnsendableAfter.TryGetValue(remoteEndPoint, out var remaining)) { if (remaining > 0) { UnsendableAfter[remoteEndPoint] = remaining - 1; } else { UnsendableAfter.Remove(remoteEndPoint); throw new SocketException((int)SocketError.MessageSize); } } if (FailingSendPeers.Contains(remoteEndPoint)) throw new SocketException((int)SocketError.HostUnreachable); var sent = _socket.SendTo(wire, remoteEndPoint); _stats.BytesSent += sent; } catch (SocketException error) when (CultNetRudpSession.IsPermanentSendError(error)) { return error; } catch (SocketException) { _stats.SendFailures++; } return null; } // Test seams, standing in for paths a loopback peer cannot be made to have. Peers whose every // datagram fails to send transiently: internal HashSet FailingSendPeers { get; } = new HashSet(); // Peers whose next datagram after this many fails permanently, once. internal Dictionary UnsendableAfter { get; } = new Dictionary(); // Runs before each datagram is sent, to order another thread's work between two sends. internal Action? BeforeSend { get; set; } 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; } } } }