using System.Net; using System.Net.Sockets; namespace AcDream.Core.Net; /// /// UDP transport for acdream. The live world owns one cancellable, /// caller-buffered asynchronous receive after the synchronous handshake. /// /// /// Sends consume caller spans directly through without /// materializing a transport-only array copy. /// /// public sealed class NetClient : IDisposable { private readonly UdpClient _udp; private readonly IPEndPoint _remote; private readonly IPEndPoint _anyRemote = new(IPAddress.Any, 0); /// Last value applied to the /// underlying socket, so repeated calls with the /// same timeout (every 250ms heartbeat tick) skip the setsockopt syscall. private int _lastAppliedReceiveTimeoutMs = int.MinValue; public NetClient(IPEndPoint remote) { _remote = remote; // Bind to an OS-assigned local port; server will reply to it. _udp = new UdpClient(new IPEndPoint(IPAddress.Any, 0)); if (OperatingSystem.IsWindows()) { // SIO_UDP_CONNRESET: Windows' UDP stack otherwise surfaces an // ICMP "port unreachable" reply to an EARLIER Send (e.g. a stale // server session that already tore down its socket) as a // WSAECONNRESET SocketException on this socket's NEXT unrelated // Receive call. An ICMP error from one send must not poison // receiving for the rest of the session — disable that reporting. const int SioUdpConnReset = -1744830452; // 0x9800000C _udp.Client.IOControl((IOControlCode)SioUdpConnReset, new byte[] { 0 }, null); } } /// The local endpoint the OS assigned us. public IPEndPoint LocalEndPoint => (IPEndPoint)_udp.Client.LocalEndPoint!; /// The remote endpoint we're talking to. public IPEndPoint RemoteEndPoint => _remote; /// /// Send a datagram to the configured default remote. Blocks until the /// OS has accepted the bytes (fast — just a kernel buffer copy on loopback). /// public void Send(ReadOnlySpan datagram) { _udp.Client.SendTo( datagram, SocketFlags.None, _remote); } /// /// Send a datagram to an arbitrary remote endpoint. Needed for the AC /// handshake because the server binds separate listeners on port 9000 /// (LoginRequest) and port 9001 (ConnectResponse), so the second /// handshake leg targets a different port than the first. /// public void Send(IPEndPoint remote, ReadOnlySpan datagram) { _udp.Client.SendTo( datagram, SocketFlags.None, remote); } /// /// Receive into caller-owned storage. Returns the received byte count, /// or -1 when expires. /// public int Receive( Span destination, TimeSpan timeout, out IPEndPoint? from) { int timeoutMs = checked((int)timeout.TotalMilliseconds); if (timeoutMs != _lastAppliedReceiveTimeoutMs) { _udp.Client.ReceiveTimeout = timeoutMs; _lastAppliedReceiveTimeoutMs = timeoutMs; } try { EndPoint remote = _anyRemote; int length = _udp.Client.ReceiveFrom( destination, SocketFlags.None, ref remote); from = (IPEndPoint)remote; return length; } catch (SocketException ex) when (ex.SocketErrorCode == SocketError.TimedOut) { from = null; return -1; } } /// /// One cancellable asynchronous receive into caller-owned storage. /// The returned memory remains owned by the caller. /// public async ValueTask ReceiveAsync( Memory destination, CancellationToken cancellationToken) { SocketReceiveFromResult result = await _udp.Client.ReceiveFromAsync( destination, SocketFlags.None, _anyRemote, cancellationToken).ConfigureAwait(false); return new NetReceiveResult( result.ReceivedBytes, (IPEndPoint)result.RemoteEndPoint); } /// /// Block until a datagram arrives or elapses. /// Returns the raw bytes, or null on timeout. The sender's /// endpoint is recorded in so the caller can /// verify it matches the expected remote. /// public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from) { int timeoutMs = checked((int)timeout.TotalMilliseconds); if (timeoutMs != _lastAppliedReceiveTimeoutMs) { _udp.Client.ReceiveTimeout = timeoutMs; _lastAppliedReceiveTimeoutMs = timeoutMs; } try { IPEndPoint any = new(IPAddress.Any, 0); byte[] bytes = _udp.Receive(ref any); from = any; return bytes; } catch (SocketException ex) when (ex.SocketErrorCode == SocketError.TimedOut) { from = null; return null; } } /// /// Non-blocking receive: returns the next pending datagram if one is /// already in the kernel buffer, or null if not. Never blocks, /// so it's safe to call once per game-loop frame. Uses /// under the hood. /// public byte[]? TryReceive(out IPEndPoint? from) { if (_udp.Available == 0) { from = null; return null; } try { IPEndPoint any = new(IPAddress.Any, 0); var bytes = _udp.Receive(ref any); from = any; return bytes; } catch (SocketException) { from = null; return null; } } public void Dispose() => _udp.Dispose(); } /// /// Result of receiving one UDP datagram into caller-owned memory. /// public readonly record struct NetReceiveResult( int Length, IPEndPoint RemoteEndPoint);