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:
parent
6cda431eae
commit
0cb30aa0c8
5 changed files with 343 additions and 0 deletions
71
src/AcDream.Core.Net/NetClient.cs
Normal file
71
src/AcDream.Core.Net/NetClient.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace AcDream.Core.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum-viable UDP transport for acdream. Wraps a <see cref="UdpClient"/>
|
||||
/// with synchronous send + timeout-based receive — good enough for the
|
||||
/// Phase 4.6 handshake smoke test and early state-machine bring-up.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Not yet provided</b> (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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>The local endpoint the OS assigned us.</summary>
|
||||
public IPEndPoint LocalEndPoint => (IPEndPoint)_udp.Client.LocalEndPoint!;
|
||||
|
||||
/// <summary>The remote endpoint we're talking to.</summary>
|
||||
public IPEndPoint RemoteEndPoint => _remote;
|
||||
|
||||
/// <summary>
|
||||
/// Send a datagram to the configured remote. Blocks until the OS has
|
||||
/// accepted the bytes (fast — just a kernel buffer copy on loopback).
|
||||
/// </summary>
|
||||
public void Send(ReadOnlySpan<byte> datagram)
|
||||
{
|
||||
// UdpClient.Send on .NET doesn't take a span directly; allocate once.
|
||||
_udp.Send(datagram.ToArray(), datagram.Length, _remote);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Block until a datagram arrives or <paramref name="timeout"/> elapses.
|
||||
/// Returns the raw bytes, or <c>null</c> on timeout. The sender's
|
||||
/// endpoint is recorded in <paramref name="from"/> so the caller can
|
||||
/// verify it matches the expected remote.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
|
@ -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; }
|
||||
/// <summary>4-byte seed to feed the ISAAC instance used for INBOUND
|
||||
/// packets (server's outgoing stream = our incoming).</summary>
|
||||
public uint ConnectRequestServerSeed { get; private set; }
|
||||
/// <summary>4-byte seed for the ISAAC used for OUTBOUND packets
|
||||
/// (our outgoing stream = server's incoming).</summary>
|
||||
public uint ConnectRequestClientSeed { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parse the optional section from <paramref name="body"/> (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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue