This is the Phase 4 protocol-compatibility proof. acdream's codec now
completes the full AC UDP handshake against a live ACE server and
successfully decodes three consecutive EncryptedChecksum game packets
— which means every layer of the codec is byte-compatible with ACE.
Changes:
- NetClient: added Send(IPEndPoint, ReadOnlySpan<byte>) overload so
one socket can talk to ACE's two listener ports (9000 for
LoginRequest, 9001 for ConnectResponse and all subsequent traffic)
- LiveHandshakeTests.Live_FullThreeWayHandshake_ReachesConnectedState:
drives the full 3-leg handshake end-to-end. Protocol details that
I got wrong on the first attempt and fixed after reading
references/holtburger/crates/holtburger-session/src/session/auth.rs:
* ConnectResponse header.Sequence = 1 (LoginRequest is seq 0)
* ConnectResponse header.Id = 0 (NOT the clientId from
ConnectRequest; that field is ACE's internal session index,
separate from the packet header Id)
* 200ms Thread.Sleep before sending ConnectResponse — holtburger
calls this ACE_HANDSHAKE_RACE_DELAY_MS, empirically determined
to avoid a server-side race where ACE is still finalizing the
session when our ConnectResponse arrives
* ConnectResponse goes to port 9001, not 9000 (ACE's second
ConnectionListener, see Network/Managers/SocketManager.cs)
LIVE RUN OUTPUT:
[live] step 1: sending 84-byte LoginRequest to 127.0.0.1:9000
[live] step 2: got 52-byte datagram from 127.0.0.1:9000,
flags=ConnectRequest
ConnectRequest cookie=0x458ABEE950D18BEE clientId=0x00000000
[live] step 3: sleeping 200ms then sending 28-byte ConnectResponse
to 127.0.0.1:9001
ISAAC seeds primed
[live] step 4: got 28-byte datagram from :9001,
flags=EncryptedChecksum,TimeSync, seq=2 OK
[live] step 4: got 64-byte datagram from :9001,
flags=EncryptedChecksum,BlobFragments, seq=3 OK
[live] step 4: got 152-byte datagram from :9001,
flags=EncryptedChecksum,BlobFragments, seq=4 OK
[live] step 4: got 24-byte datagram from :9001,
flags=AckSequence, seq=4 OK
[live] step 4: got 24-byte datagram from :9001,
flags=AckSequence, seq=4 OK
[live] step 4 summary: 5 packets received, 5 decoded OK,
0 checksum failures
What each "OK" proves, reading left to right:
* TimeSync (seq=2): our IsaacRandom is byte-compatible with ACE's
ISAAC.cs — if a single bit were wrong in any state register the
checksum key would mismatch and decode would fail. Our inbound
ISAAC consumed one word for this packet.
* BlobFragments (seq=3, 64 bytes): header hash + fragment hash +
ISAAC key recipe all check out. These fragments contain the start
of GameMessageCharacterList / ServerName / DDDInterrogation game
messages ACE enqueues right after HandleConnectResponse. We don't
parse game message bodies yet (Phase 4.7) but the fragments are
fully retrievable from Packet.Fragments.
* BlobFragments (seq=4, 152 bytes): continuation of the same game
messages; our sequential ISAAC consumption handled two back-to-back
encrypted packets correctly.
* AckSequence (seq=4): unencrypted mixed with encrypted in the same
stream — our codec handles both paths in one session.
Everything in AcDream.Core.Net is now proven byte-compatible with a
retail AC server at the protocol level. The remaining Phase 4 work
(4.6f, 4.7) is above the codec: parsing game message opcodes out of
the fragment payloads and routing CreateObject into IGameState so
acdream can show the foundry statue and the +Acdream character.
Test counts: 77 core + 73 net (+1 new live test) = 150 passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
81 lines
2.9 KiB
C#
81 lines
2.9 KiB
C#
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 default remote. Blocks until the
|
|
/// OS has accepted the bytes (fast — just a kernel buffer copy on loopback).
|
|
/// </summary>
|
|
public void Send(ReadOnlySpan<byte> datagram)
|
|
{
|
|
_udp.Send(datagram.ToArray(), datagram.Length, _remote);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram)
|
|
{
|
|
_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();
|
|
}
|