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