diff --git a/src/AcDream.Core.Net/NetClient.cs b/src/AcDream.Core.Net/NetClient.cs
new file mode 100644
index 0000000..0292f28
--- /dev/null
+++ b/src/AcDream.Core.Net/NetClient.cs
@@ -0,0 +1,71 @@
+using System.Net;
+using System.Net.Sockets;
+
+namespace AcDream.Core.Net;
+
+///
+/// Minimum-viable UDP transport for acdream. Wraps a
+/// with synchronous send + timeout-based receive — good enough for the
+/// Phase 4.6 handshake smoke test and early state-machine bring-up.
+///
+///
+/// Not yet provided (deferred to a later phase once the handshake
+/// actually works): background receive thread, outbound queue, ack/retransmit
+/// window, heartbeat timer, concurrent send/receive. The acdream game loop
+/// will need a real async pump eventually but building that now would be
+/// debugging two things at once when we hit the first protocol mismatch.
+///
+///
+public sealed class NetClient : IDisposable
+{
+ private readonly UdpClient _udp;
+ private readonly IPEndPoint _remote;
+
+ 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));
+ }
+
+ /// 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 remote. Blocks until the OS has
+ /// accepted the bytes (fast — just a kernel buffer copy on loopback).
+ ///
+ public void Send(ReadOnlySpan datagram)
+ {
+ // UdpClient.Send on .NET doesn't take a span directly; allocate once.
+ _udp.Send(datagram.ToArray(), datagram.Length, _remote);
+ }
+
+ ///
+ /// 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)
+ {
+ _udp.Client.ReceiveTimeout = (int)timeout.TotalMilliseconds;
+ try
+ {
+ IPEndPoint any = new(IPAddress.Any, 0);
+ var bytes = _udp.Receive(ref any);
+ from = any;
+ return bytes;
+ }
+ catch (SocketException ex) when (ex.SocketErrorCode == SocketError.TimedOut)
+ {
+ from = null;
+ return null;
+ }
+ }
+
+ public void Dispose() => _udp.Dispose();
+}
diff --git a/src/AcDream.Core.Net/Packets/PacketHeaderOptional.cs b/src/AcDream.Core.Net/Packets/PacketHeaderOptional.cs
index 8ce7953..464001f 100644
--- a/src/AcDream.Core.Net/Packets/PacketHeaderOptional.cs
+++ b/src/AcDream.Core.Net/Packets/PacketHeaderOptional.cs
@@ -43,6 +43,19 @@ public sealed class PacketHeaderOptional
public uint FlowBytes { get; private set; }
public ushort FlowInterval { get; private set; }
+ // ConnectRequest fields (server → client handshake packet, 32 bytes).
+ // ACE's AGPL parser doesn't decode these because servers only send them.
+ // acdream is a client so we DO need the decoded values.
+ public double ConnectRequestServerTime { get; private set; }
+ public ulong ConnectRequestCookie { get; private set; }
+ public uint ConnectRequestClientId { get; private set; }
+ /// 4-byte seed to feed the ISAAC instance used for INBOUND
+ /// packets (server's outgoing stream = our incoming).
+ public uint ConnectRequestServerSeed { get; private set; }
+ /// 4-byte seed for the ISAAC used for OUTBOUND packets
+ /// (our outgoing stream = server's incoming).
+ public uint ConnectRequestClientSeed { get; private set; }
+
///
/// Parse the optional section from (which starts
/// right after the 20-byte header). Returns the number of bytes consumed
@@ -119,6 +132,23 @@ public sealed class PacketHeaderOptional
if (!Take(body, ref pos, 8)) return -1;
}
+ // ConnectRequest (server → client): 32-byte fixed section.
+ // Layout from ACE's PacketOutboundConnectRequest writer:
+ // double ServerTime, ulong Cookie, uint ClientId,
+ // byte[4] IsaacServerSeed, byte[4] IsaacClientSeed, uint Padding.
+ if (HasFlag(flags, PacketHeaderFlags.ConnectRequest))
+ {
+ if (body.Length - pos < 32) return -1;
+ ConnectRequestServerTime = BitConverter.Int64BitsToDouble(
+ BinaryPrimitives.ReadInt64LittleEndian(body.Slice(pos)));
+ ConnectRequestCookie = BinaryPrimitives.ReadUInt64LittleEndian(body.Slice(pos + 8));
+ ConnectRequestClientId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos + 16));
+ ConnectRequestServerSeed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos + 20));
+ ConnectRequestClientSeed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos + 24));
+ // bytes 28-31 are the trailing padding uint — skip via Take.
+ pos += 32;
+ }
+
if (HasFlag(flags, PacketHeaderFlags.ConnectResponse))
{
if (!Take(body, ref pos, 8)) return -1;
diff --git a/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs b/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs
new file mode 100644
index 0000000..0a11d3d
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs
@@ -0,0 +1,110 @@
+using System.Buffers.Binary;
+using System.Net;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Packets;
+
+namespace AcDream.Core.Net.Tests;
+
+///
+/// Live integration test that talks to a real ACE server. Skipped by
+/// default so CI doesn't fail for developers without a running server.
+/// To run:
+///
+/// set ACDREAM_LIVE=1
+/// set ACDREAM_TEST_USER=testaccount
+/// set ACDREAM_TEST_PASS=testpassword
+/// set ACDREAM_TEST_HOST=127.0.0.1 (optional, default)
+/// set ACDREAM_TEST_PORT=9000 (optional, default)
+/// dotnet test --filter LiveHandshake
+///
+///
+///
+/// Credential handling: the test reads the username and password
+/// from environment variables and uses them in one outbound LoginRequest
+/// packet. They are never written to disk, never logged to console, never
+/// included in assertion messages, and never committed. When the test
+/// prints diagnostics they are reduced to their length so mistakes in
+/// the env-var setup are distinguishable from server errors.
+///
+///
+public class LiveHandshakeTests
+{
+ [Fact]
+ public void Live_LoginRequest_ReceivesConnectRequestFromServer()
+ {
+ if (Environment.GetEnvironmentVariable("ACDREAM_LIVE") != "1")
+ return; // skipped — not a failure
+
+ var host = Environment.GetEnvironmentVariable("ACDREAM_TEST_HOST") ?? "127.0.0.1";
+ var portStr = Environment.GetEnvironmentVariable("ACDREAM_TEST_PORT") ?? "9000";
+ var user = Environment.GetEnvironmentVariable("ACDREAM_TEST_USER");
+ var pass = Environment.GetEnvironmentVariable("ACDREAM_TEST_PASS");
+
+ Assert.NotNull(user);
+ Assert.NotNull(pass);
+ Assert.NotEmpty(user!);
+ Assert.NotEmpty(pass!);
+
+ var remote = new IPEndPoint(IPAddress.Parse(host), int.Parse(portStr));
+ using var net = new NetClient(remote);
+
+ // Build and send the LoginRequest datagram. Header has only the
+ // LoginRequest flag; checksum is unencrypted (the ISAAC keystream
+ // is established only *after* the server sends us ConnectRequest).
+ uint timestamp = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ byte[] loginBody = LoginRequest.Build(user, pass, timestamp);
+ var loginHeader = new PacketHeader { Flags = PacketHeaderFlags.LoginRequest };
+ byte[] loginDatagram = PacketCodec.Encode(loginHeader, loginBody, outboundIsaac: null);
+
+ Console.WriteLine($"[live] sending {loginDatagram.Length}-byte LoginRequest to {remote} " +
+ $"(user.len={user.Length}, pass.len={pass.Length})");
+ net.Send(loginDatagram);
+
+ // Expect at least one packet back within 5 seconds. ACE can chunk
+ // the handshake across multiple datagrams so we loop until we find
+ // a ConnectRequest or hit the overall deadline.
+ var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
+ Packet? connectRequest = null;
+ int packetsReceived = 0;
+
+ while (DateTime.UtcNow < deadline && connectRequest is null)
+ {
+ var bytes = net.Receive(deadline - DateTime.UtcNow, out var from);
+ if (bytes is null) break;
+ packetsReceived++;
+ Console.WriteLine($"[live] received {bytes.Length}-byte datagram from {from}");
+
+ var decoded = PacketCodec.TryDecode(bytes, inboundIsaac: null);
+ Console.WriteLine($"[live] decode result: {decoded.Error}, " +
+ $"flags: {(decoded.Packet?.Header.Flags.ToString() ?? "n/a")}");
+
+ if (decoded.IsOk && decoded.Packet!.Header.HasFlag(PacketHeaderFlags.ConnectRequest))
+ {
+ connectRequest = decoded.Packet;
+ break;
+ }
+ }
+
+ Console.WriteLine($"[live] total packets received: {packetsReceived}");
+
+ Assert.True(packetsReceived > 0,
+ "Server did not respond at all within 5s — is ACE actually running on " +
+ $"{remote} and does the account '{user}' exist?");
+
+ Assert.NotNull(connectRequest);
+ var opt = connectRequest!.Optional;
+ Console.WriteLine($"[live] ConnectRequest decoded: " +
+ $"serverTime={opt.ConnectRequestServerTime:F3} " +
+ $"cookie=0x{opt.ConnectRequestCookie:X16} " +
+ $"clientId=0x{opt.ConnectRequestClientId:X8} " +
+ $"serverSeed=0x{opt.ConnectRequestServerSeed:X8} " +
+ $"clientSeed=0x{opt.ConnectRequestClientSeed:X8}");
+
+ Assert.NotEqual(0UL, opt.ConnectRequestCookie);
+ Assert.NotEqual(0u, opt.ConnectRequestClientId);
+ // The seeds are derived from a PRNG server-side so they should be nonzero
+ // in any realistic scenario (probability of exactly 0 is ~2^-64).
+ Assert.NotEqual(0u, opt.ConnectRequestServerSeed);
+ Assert.NotEqual(0u, opt.ConnectRequestClientSeed);
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/NetClientTests.cs b/tests/AcDream.Core.Net.Tests/NetClientTests.cs
new file mode 100644
index 0000000..b818f86
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/NetClientTests.cs
@@ -0,0 +1,39 @@
+using System.Net;
+using AcDream.Core.Net;
+
+namespace AcDream.Core.Net.Tests;
+
+public class NetClientTests
+{
+ [Fact]
+ public void SendReceive_LoopbackSelfTest_EchoRoundTrip()
+ {
+ // Two NetClients on loopback that send messages to each other.
+ // Proves Send + Receive actually work end-to-end over real UDP
+ // without depending on any remote server.
+ using var serverSide = new NetClient(new IPEndPoint(IPAddress.Loopback, 0));
+ using var clientSide = new NetClient(new IPEndPoint(IPAddress.Loopback, serverSide.LocalEndPoint.Port));
+
+ byte[] outbound = { 0xDE, 0xAD, 0xBE, 0xEF };
+ clientSide.Send(outbound);
+
+ var received = serverSide.Receive(TimeSpan.FromSeconds(2), out var from);
+
+ Assert.NotNull(received);
+ Assert.Equal(outbound, received);
+ Assert.NotNull(from);
+ Assert.Equal(IPAddress.Loopback, from!.Address);
+ Assert.Equal(clientSide.LocalEndPoint.Port, from.Port);
+ }
+
+ [Fact]
+ public void Receive_Timeout_ReturnsNullAndNoException()
+ {
+ // Listen for something that will never arrive; the timeout path
+ // must return null cleanly rather than leaking a SocketException.
+ using var client = new NetClient(new IPEndPoint(IPAddress.Loopback, 1));
+ var result = client.Receive(TimeSpan.FromMilliseconds(100), out var from);
+ Assert.Null(result);
+ Assert.Null(from);
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Packets/ConnectRequestTests.cs b/tests/AcDream.Core.Net.Tests/Packets/ConnectRequestTests.cs
new file mode 100644
index 0000000..60989ba
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Packets/ConnectRequestTests.cs
@@ -0,0 +1,93 @@
+using System.Buffers.Binary;
+using AcDream.Core.Net.Cryptography;
+using AcDream.Core.Net.Packets;
+
+namespace AcDream.Core.Net.Tests.Packets;
+
+public class ConnectRequestTests
+{
+ ///
+ /// Build a synthetic ConnectRequest packet matching the bytes ACE's
+ /// PacketOutboundConnectRequest would emit, hash it correctly, then
+ /// verify PacketCodec.TryDecode parses the fields back out.
+ ///
+ [Fact]
+ public void PacketCodec_Decodes_ConnectRequest_Fields()
+ {
+ // Body = 32 bytes of ConnectRequest section.
+ byte[] body = new byte[32];
+ double serverTime = 12345.6789;
+ ulong cookie = 0xFEEDFACECAFEBABEUL;
+ uint clientId = 0x11223344u;
+ uint serverSeed = 0xAABBCCDDu;
+ uint clientSeed = 0x01020304u;
+
+ BinaryPrimitives.WriteInt64LittleEndian(body.AsSpan(0), BitConverter.DoubleToInt64Bits(serverTime));
+ BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(8), cookie);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), clientId);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), serverSeed);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), clientSeed);
+ // bytes 28-31 = trailing padding uint, zero is fine
+
+ var header = new PacketHeader
+ {
+ Flags = PacketHeaderFlags.ConnectRequest,
+ DataSize = (ushort)body.Length,
+ };
+
+ // Compute unencrypted checksum and pack.
+ uint headerHash = header.CalculateHeaderHash32();
+ uint optionalHash = Hash32.Calculate(body);
+ header.Checksum = headerHash + optionalHash;
+
+ byte[] datagram = new byte[PacketHeader.Size + body.Length];
+ header.Pack(datagram);
+ body.CopyTo(datagram.AsSpan(PacketHeader.Size));
+
+ var result = PacketCodec.TryDecode(datagram, inboundIsaac: null);
+
+ Assert.Equal(PacketCodec.DecodeError.None, result.Error);
+ Assert.Equal(serverTime, result.Packet!.Optional.ConnectRequestServerTime, 1e-6);
+ Assert.Equal(cookie, result.Packet.Optional.ConnectRequestCookie);
+ Assert.Equal(clientId, result.Packet.Optional.ConnectRequestClientId);
+ Assert.Equal(serverSeed, result.Packet.Optional.ConnectRequestServerSeed);
+ Assert.Equal(clientSeed, result.Packet.Optional.ConnectRequestClientSeed);
+ }
+
+ [Fact]
+ public void ConnectRequest_IsaacSeeds_FeedIsaacRandomSuccessfully()
+ {
+ // Sanity: the 32-bit seeds extracted from a ConnectRequest field are
+ // what IsaacRandom needs to construct an instance. Doesn't prove
+ // the seeds match the server's — that happens at the live test.
+ byte[] body = new byte[32];
+ uint serverSeed = 0xDEADBEEFu;
+ uint clientSeed = 0xCAFEBABEu;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), serverSeed);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), clientSeed);
+
+ var header = new PacketHeader { Flags = PacketHeaderFlags.ConnectRequest, DataSize = 32 };
+ header.Checksum = header.CalculateHeaderHash32() + Hash32.Calculate(body);
+
+ byte[] datagram = new byte[PacketHeader.Size + 32];
+ header.Pack(datagram);
+ body.CopyTo(datagram.AsSpan(PacketHeader.Size));
+
+ var result = PacketCodec.TryDecode(datagram, inboundIsaac: null);
+ Assert.Equal(PacketCodec.DecodeError.None, result.Error);
+
+ // Reconstruct the seed bytes and feed IsaacRandom.
+ var serverSeedBytes = new byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(serverSeedBytes, result.Packet!.Optional.ConnectRequestServerSeed);
+ var clientSeedBytes = new byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(clientSeedBytes, result.Packet.Optional.ConnectRequestClientSeed);
+
+ var serverIsaac = new IsaacRandom(serverSeedBytes);
+ var clientIsaac = new IsaacRandom(clientSeedBytes);
+
+ // Just verify they produce nonzero output (not a correctness check).
+ uint firstServerKey = serverIsaac.Next();
+ uint firstClientKey = clientIsaac.Next();
+ Assert.NotEqual(firstServerKey, firstClientKey);
+ }
+}