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);