diff --git a/src/AcDream.Core.Net/NetClient.cs b/src/AcDream.Core.Net/NetClient.cs
index 0292f28..4c0e34c 100644
--- a/src/AcDream.Core.Net/NetClient.cs
+++ b/src/AcDream.Core.Net/NetClient.cs
@@ -35,15 +35,25 @@ public sealed class NetClient : IDisposable
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).
+ /// 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)
{
- // UdpClient.Send on .NET doesn't take a span directly; allocate once.
_udp.Send(datagram.ToArray(), datagram.Length, _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.Send(datagram.ToArray(), datagram.Length, remote);
+ }
+
///
/// Block until a datagram arrives or elapses.
/// Returns the raw bytes, or null on timeout. The sender's
diff --git a/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs b/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs
index 0a11d3d..c5d3f66 100644
--- a/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs
+++ b/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs
@@ -1,6 +1,7 @@
using System.Buffers.Binary;
using System.Net;
using AcDream.Core.Net;
+using AcDream.Core.Net.Cryptography;
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Tests;
@@ -102,9 +103,133 @@ public class LiveHandshakeTests
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);
}
+
+ [Fact]
+ public void Live_FullThreeWayHandshake_ReachesConnectedState()
+ {
+ if (Environment.GetEnvironmentVariable("ACDREAM_LIVE") != "1")
+ return;
+
+ 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")!;
+
+ int loginPort = int.Parse(portStr);
+ int connectPort = loginPort + 1;
+
+ var loginEndpoint = new IPEndPoint(IPAddress.Parse(host), loginPort);
+ var connectEndpoint = new IPEndPoint(IPAddress.Parse(host), connectPort);
+
+ using var net = new NetClient(loginEndpoint);
+
+ // Step 1: send LoginRequest to port 9000.
+ uint timestamp = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ byte[] loginPayload = LoginRequest.Build(user, pass, timestamp);
+ var loginHeader = new PacketHeader { Flags = PacketHeaderFlags.LoginRequest };
+ byte[] loginDatagram = PacketCodec.Encode(loginHeader, loginPayload, outboundIsaac: null);
+
+ Console.WriteLine($"[live] step 1: sending {loginDatagram.Length}-byte LoginRequest to {loginEndpoint}");
+ net.Send(loginDatagram);
+
+ // Step 2: receive ConnectRequest (from port 9000).
+ Packet? connectRequest = null;
+ var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
+ while (DateTime.UtcNow < deadline)
+ {
+ var bytes = net.Receive(deadline - DateTime.UtcNow, out var from);
+ if (bytes is null) break;
+ var decoded = PacketCodec.TryDecode(bytes, inboundIsaac: null);
+ Console.WriteLine($"[live] step 2: got {bytes.Length}-byte datagram from {from}, " +
+ $"decode={decoded.Error}, flags={decoded.Packet?.Header.Flags}");
+ if (decoded.IsOk && decoded.Packet!.Header.HasFlag(PacketHeaderFlags.ConnectRequest))
+ {
+ connectRequest = decoded.Packet;
+ break;
+ }
+ }
+ Assert.NotNull(connectRequest);
+
+ var cr = connectRequest!.Optional;
+ Console.WriteLine($"[live] step 2: ConnectRequest cookie=0x{cr.ConnectRequestCookie:X16} " +
+ $"clientId=0x{cr.ConnectRequestClientId:X8}");
+
+ // Step 3: send ConnectResponse echoing the cookie to port 9001.
+ // Protocol details confirmed against references/holtburger/crates/
+ // holtburger-session/src/session/auth.rs::handle_handshake_request:
+ // - Sequence = 1 (LoginRequest was seq 0; our next outbound is seq 1)
+ // - Id = 0 (NOT the clientId from ConnectRequest; that's ACE's
+ // internal session id, not the packet header Id field)
+ // - 200ms delay before send to avoid a race with the server that
+ // holtburger discovered empirically (ACE_HANDSHAKE_RACE_DELAY_MS)
+ // - Body is 8 bytes, the cookie as little-endian u64
+ byte[] connectResponseBody = new byte[8];
+ BinaryPrimitives.WriteUInt64LittleEndian(connectResponseBody, cr.ConnectRequestCookie);
+
+ var crHeader = new PacketHeader
+ {
+ Sequence = 1,
+ Flags = PacketHeaderFlags.ConnectResponse,
+ Id = 0,
+ Time = 0,
+ Iteration = 0,
+ };
+ byte[] connectResponseDatagram = PacketCodec.Encode(crHeader, connectResponseBody, outboundIsaac: null);
+
+ Console.WriteLine($"[live] step 3: sleeping 200ms (ACE handshake race delay) then sending " +
+ $"{connectResponseDatagram.Length}-byte ConnectResponse to {connectEndpoint}");
+ Thread.Sleep(200);
+ net.Send(connectEndpoint, connectResponseDatagram);
+
+ // Seed the two ISAAC streams NOW. From this point on the server
+ // will use EncryptedChecksum for everything it sends us, and expects
+ // the same from us.
+ var serverSeedBytes = new byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(serverSeedBytes, cr.ConnectRequestServerSeed);
+ var clientSeedBytes = new byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(clientSeedBytes, cr.ConnectRequestClientSeed);
+ var inboundIsaac = new IsaacRandom(serverSeedBytes); // decrypts server's outbound
+ var outboundIsaac = new IsaacRandom(clientSeedBytes); // encrypts our outbound
+ Console.WriteLine($"[live] step 3: ISAAC seeds primed, " +
+ $"inbound.next=0x{new IsaacRandom(serverSeedBytes).Next():X8}, " +
+ $"outbound.next=0x{new IsaacRandom(clientSeedBytes).Next():X8}");
+
+ // Step 4: receive post-handshake traffic. We expect EncryptedChecksum
+ // packets containing CharacterList and friends. If our ISAAC seed is
+ // wrong or our CRC math is off, TryDecode will return ChecksumMismatch.
+ int postHandshakePackets = 0;
+ int successfullyDecoded = 0;
+ int checksumFailures = 0;
+ var postDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
+ while (DateTime.UtcNow < postDeadline)
+ {
+ var bytes = net.Receive(postDeadline - DateTime.UtcNow, out var from);
+ if (bytes is null) break;
+ postHandshakePackets++;
+ var decoded = PacketCodec.TryDecode(bytes, inboundIsaac);
+ Console.WriteLine($"[live] step 4: got {bytes.Length}-byte datagram from {from}, " +
+ $"decode={decoded.Error}, flags={decoded.Packet?.Header.Flags}, " +
+ $"seq={decoded.Packet?.Header.Sequence}");
+ if (decoded.IsOk)
+ successfullyDecoded++;
+ else if (decoded.Error == PacketCodec.DecodeError.ChecksumMismatch)
+ checksumFailures++;
+ }
+
+ Console.WriteLine($"[live] step 4 summary: {postHandshakePackets} packets received, " +
+ $"{successfullyDecoded} decoded OK, {checksumFailures} checksum failures");
+
+ // The contract of Phase 4.6e is "server accepted our ConnectResponse
+ // and started streaming". Any post-handshake traffic at all proves
+ // step 3 worked. Successful decoding proves ISAAC is correct.
+ Assert.True(postHandshakePackets > 0,
+ "Server did not send any post-handshake packets — ConnectResponse rejected?");
+ // ISAAC correctness is the stretch goal. Log but don't assert yet —
+ // if our ISAAC matches ACE's, successfullyDecoded > 0. If there's
+ // a seed-direction mismatch or a CRC math bug this will be 0 and
+ // we'll see checksumFailures > 0.
+ }
}