feat(net): full 3-way handshake + ISAAC-encrypted decode proven live (Phase 4.6e)

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>
This commit is contained in:
Erik 2026-04-11 14:51:41 +02:00
parent 0cb30aa0c8
commit a961d842d4
2 changed files with 140 additions and 5 deletions

View file

@ -35,15 +35,25 @@ public sealed class NetClient : IDisposable
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).
/// 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)
{
// UdpClient.Send on .NET doesn't take a span directly; allocate once.
_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