feat(net): live ACE handshake verified — ConnectRequest received and parsed (Phase 4.6a/b/c/d)

Reaches the first major milestone of Phase 4: acdream's codec is proven
byte-compatible with a live ACE server. LiveHandshakeTests drives a real
UDP exchange against 127.0.0.1:9000 and successfully negotiates the
first half of the connect handshake.

Added:
  - Packets/PacketHeaderOptional.cs: new ConnectRequest flag branch.
    ACE's AGPL parser doesn't decode ConnectRequest (server only sends
    it) so this is new client-side code. Exposes ConnectRequestServerTime,
    Cookie, ClientId, ServerSeed, ClientSeed — the values we need to
    seed our two ISAAC instances and echo the cookie back in a
    ConnectResponse.
  - NetClient.cs: minimum-viable UDP transport, a thin UdpClient wrapper
    with synchronous Send and timeout-based Receive. No background thread
    or retransmit window yet — good enough for handshake bring-up and
    the offline state-machine tests.
  - LiveHandshakeTests.cs: gated behind ACDREAM_LIVE=1 environment
    variable so CI without a server doesn't fail. Reads credentials
    from ACDREAM_TEST_USER / ACDREAM_TEST_PASS (never logged or
    committed), builds a LoginRequest datagram via our codec, sends
    it to localhost:9000, waits for up to 5s for a response, and
    asserts we receive a ConnectRequest with non-zero cookie, clientId,
    and both ISAAC seeds.

Tests (5 new, 77 total in net project, 154 across both projects):
  - ConnectRequestTests: two offline tests exercising the new
    PacketHeaderOptional branch via synthetic datagrams. One verifies
    every field round-trips through Encode + TryDecode, one feeds the
    extracted 32-bit seeds into IsaacRandom to prove they work as
    keystream seeds.
  - NetClientTests: 2 offline tests — loopback SendReceive round-trip
    between two NetClient instances (proves UDP pump is alive without
    needing any server), and Receive-with-timeout returning null
    cleanly when no datagram arrives.
  - LiveHandshakeTests: 1 live integration test (early-exits when
    ACDREAM_LIVE env var not set, so it passes trivially in CI).

LIVE RUN OUTPUT (against user's localhost ACE server):
  [live] sending 84-byte LoginRequest to 127.0.0.1:9000 (user.len=11, pass.len=12)
  [live] received 52-byte datagram from 127.0.0.1:9000
  [live]   decode result: None, flags: ConnectRequest
  [live] ConnectRequest decoded: serverTime=290029541.121 cookie=0xAC45998D06754133
         clientId=0x00000001 serverSeed=0x4CC09763 clientSeed=0x5C3DE13E

Meaning: 84-byte LoginRequest went out, 52-byte ConnectRequest came
back, codec.TryDecode returned None error, every field parsed to a
sensible value. This proves byte-compatibility of both directions at
the protocol layer, ISAAC seed extraction path, Hash32 checksum on
both encode and decode, and the whole String16L/String32L/bodyLength
layout of LoginRequest against the real server parser.

Next step: send ConnectResponse echoing the cookie so the server
promotes us to "connected" and starts streaming CharacterList +
CreateObject messages (those will use EncryptedChecksum, which is
where our ISAAC implementation gets its ultimate test).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 14:46:19 +02:00
parent 6cda431eae
commit 0cb30aa0c8
5 changed files with 343 additions and 0 deletions

View file

@ -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;
/// <summary>
/// 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:
/// <code>
/// 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
/// </code>
///
/// <para>
/// <b>Credential handling:</b> 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.
/// </para>
/// </summary>
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);
}
}

View file

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

View file

@ -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
{
/// <summary>
/// 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.
/// </summary>
[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);
}
}