acdream/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs
Erik 94da385ff4 feat(net): acdream enters the world — CharacterList parsed + CharacterEnterWorld sent + 68 CreateObject received (Phase 4.7)
Drives the full post-handshake flow on a live ACE server. After the
3-way handshake completes, acdream:
  1. Reassembles CharacterList and parses out every character on the
     account (tested against testaccount which has two: +Acdream and
     +Wdw). Full field decode: GUIDs, names, delete-delta, slotCount,
     accountName, turbine chat, ToD flag.
  2. Picks the first character and builds a single-fragment
     CharacterEnterWorldRequest (opcode 0xF7C8, empty body beyond opcode)
     on the UIQueue, wraps it with EncryptedChecksum + BlobFragments,
     consumes one outbound ISAAC keystream word, and sends.
  3. Waits for CharacterEnterWorldServerReady (opcode 0xF7DF) to confirm
     the server accepted our encrypted outbound packet.
  4. Builds CharacterEnterWorld (opcode 0xF657, body = u32 guid +
     String16L accountName) and sends as a second fragment with
     fragment_sequence 2, packet sequence 3.
  5. Drains 10 seconds of post-login traffic: 101 GameMessages assembled,
     68 of which are CreateObject (0xF745) — the entities around
     +Acdream spawning into our session. Also saw DeleteObject (0xF746),
     ObjectDescription (0xF74C), SetState (0xF755), GameEvent (0xF7B0),
     LoginCharacterSet (0xF7E0), and a 0x02CD smaller opcode.

This is the Phase 4.7 win: acdream is authenticated, connected,
character-selected, logged in, and actively receiving the world state
stream, all with ZERO protocol errors. Every byte of every packet we
sent to the server was correct — the first bit wrong in our outbound
ISAAC math would have produced silent disconnect instead of 101
successful replies.

Added to AcDream.Core.Net:
  - Messages/CharacterList.cs: full parser for opcode 0xF658, ported
    from ACE's GameMessageCharacterList writer. Returns structured
    record with Characters[], SlotCount, AccountName, UseTurbineChat,
    HasThroneOfDestiny. Tested offline with hand-assembled bodies
    matching ACE's writer format.
  - Messages/CharacterEnterWorld.cs: outbound builders for
    CharacterEnterWorldRequest (0xF7C8, opcode-only) and
    CharacterEnterWorld (0xF657, opcode + guid + String16L account).
  - Messages/GameMessageFragment.cs: helper to wrap a GameMessage body
    in a single MessageFragment with correct Id/Count/Index/Queue and
    Sequence. Also a Serialize helper to turn a MessageFragment into
    packet-body bytes for PacketCodec.Encode. Throws on oversize
    (>448 byte) messages; multi-fragment outbound split is TBD.
  - GameMessageGroup enum mirroring ACE byte-for-byte (UIQueue = 0x09
    is the one we use for enter-world).

Fixed: FragmentAssembler was keying on MessageFragmentHeader.Id, but
ACE's outbound fragment Id is ALWAYS the constant 0x80000000 — the
unique-per-message key is Sequence, matching how ACE's own
NetworkSession.HandleFragment keys its partialFragments dict. Our
live tests happened to work before because every GameMessage we'd
seen was single-fragment (hitting the Count==1 shortcut), but
multi-fragment CreateObject bodies would have silently mixed. Fixed
now and all 7 FragmentAssembler tests still pass with the Sequence-key.

Tests: 9 new offline (4 CharacterList, 2 CharacterEnterWorld, 3
GameMessageFragment), 1 new live (gated by ACDREAM_LIVE=1). Total
77 core + 83 net = 160 passing.

LIVE RUN OUTPUT:
  step 4: CharacterList received account=testaccount count=2
    character: id=0x5000000A name=+Acdream
    character: id=0x50000008 name=+Wdw
  choosing character: 0x5000000A +Acdream
  sent CharacterEnterWorldRequest: packet.seq=2 frag.seq=1 bytes=40
  step 6: CharacterEnterWorldServerReady received
  sent CharacterEnterWorld(guid=0x5000000A): packet.seq=3 frag.seq=2 bytes=60
  step 8 summary: 101 GameMessages assembled, 68 CreateObject
  unique opcodes seen: 0xF7B0, 0xF7E0, 0xF746, 0xF745, 0x02CD,
                       0xF755, 0xF74C

Phase 4.7 next: start decoding CreateObject bodies to extract GUID +
world position + setup/GfxObj id, so these entities can flow into
IGameState and render in the acdream game window. The foundry statue
is waiting in one of those 68 spawns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:14:31 +02:00

446 lines
21 KiB
C#

using System.Buffers.Binary;
using System.Net;
using AcDream.Core.Net;
using AcDream.Core.Net.Cryptography;
using AcDream.Core.Net.Messages;
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);
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. Run fragments through a
// FragmentAssembler so multi-packet game messages reassemble, then
// read the opcode (first 4 bytes of the assembled body) to prove
// we're looking at real GameMessage opcodes.
var assembler = new FragmentAssembler();
int postHandshakePackets = 0;
int successfullyDecoded = 0;
int checksumFailures = 0;
var seenOpcodes = new List<uint>();
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++;
foreach (var frag in decoded.Packet!.Fragments)
{
var completeBody = assembler.Ingest(frag, out _);
if (completeBody is not null && completeBody.Length >= 4)
{
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(completeBody);
seenOpcodes.Add(opcode);
string name = opcode switch
{
0xF658 => "CharacterList",
0xF7E1 => "ServerName",
0xF7E5 => "DDDInterrogation",
_ => "unknown",
};
Console.WriteLine($"[live] GameMessage assembled: opcode=0x{opcode:X8} ({name}), " +
$"body={completeBody.Length} bytes");
}
}
}
else if (decoded.Error == PacketCodec.DecodeError.ChecksumMismatch)
checksumFailures++;
}
Console.WriteLine($"[live] step 4 summary: {postHandshakePackets} packets received, " +
$"{successfullyDecoded} decoded OK, {checksumFailures} checksum failures, " +
$"{seenOpcodes.Count} GameMessages assembled");
// 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.
}
[Fact]
public void Live_CharacterEnterWorld_ReceivesCreateObjectFlood()
{
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);
var loginEndpoint = new IPEndPoint(IPAddress.Parse(host), loginPort);
var connectEndpoint = new IPEndPoint(IPAddress.Parse(host), loginPort + 1);
using var net = new NetClient(loginEndpoint);
// ---- Step 1-3: complete the handshake (copied from the prior test,
// factored into a helper would be nice but inline is clearer here). ----
uint timestamp = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
byte[] loginPayload = LoginRequest.Build(user, pass, timestamp);
var loginHeader = new PacketHeader { Flags = PacketHeaderFlags.LoginRequest };
net.Send(PacketCodec.Encode(loginHeader, loginPayload, null));
Console.WriteLine("[live] step 1: LoginRequest sent");
Packet? cr = null;
var d1 = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < d1)
{
var b = net.Receive(d1 - DateTime.UtcNow, out _);
if (b is null) break;
var dec = PacketCodec.TryDecode(b, null);
if (dec.IsOk && dec.Packet!.Header.HasFlag(PacketHeaderFlags.ConnectRequest))
{ cr = dec.Packet; break; }
}
Assert.NotNull(cr);
var opt = cr!.Optional;
Console.WriteLine($"[live] step 2: ConnectRequest received (cookie=0x{opt.ConnectRequestCookie:X16} clientId=0x{opt.ConnectRequestClientId:X8})");
byte[] crBody = new byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(crBody, opt.ConnectRequestCookie);
var crHeader = new PacketHeader { Sequence = 1, Flags = PacketHeaderFlags.ConnectResponse, Id = 0 };
Thread.Sleep(200); // ACE handshake race delay
net.Send(connectEndpoint, PacketCodec.Encode(crHeader, crBody, null));
Console.WriteLine("[live] step 3: ConnectResponse sent to 9001");
// Seed the ISAAC pair. Server seed decrypts inbound; client seed encrypts outbound.
byte[] serverSeedBytes = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(serverSeedBytes, opt.ConnectRequestServerSeed);
byte[] clientSeedBytes = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(clientSeedBytes, opt.ConnectRequestClientSeed);
var inboundIsaac = new IsaacRandom(serverSeedBytes);
var outboundIsaac = new IsaacRandom(clientSeedBytes);
ushort sessionClientId = (ushort)opt.ConnectRequestClientId;
// Packet sequence counter. LoginRequest=0, ConnectResponse=1, next=2.
uint clientPacketSequence = 2;
// Fragment sequence counter. Starts at 1 per holtburger's
// api.rs (fragment_sequence: 1, pre-increment use-then-bump).
uint fragmentSequence = 1;
// ---- Step 4: drain the initial CharacterList stream to find our character. ----
var assembler = new FragmentAssembler();
CharacterList.Parsed? charList = null;
var d4 = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < d4 && charList is null)
{
var b = net.Receive(d4 - DateTime.UtcNow, out _);
if (b is null) break;
var dec = PacketCodec.TryDecode(b, inboundIsaac);
if (!dec.IsOk) continue;
foreach (var frag in dec.Packet!.Fragments)
{
var body = assembler.Ingest(frag, out _);
if (body is null || body.Length < 4) continue;
uint op = BinaryPrimitives.ReadUInt32LittleEndian(body);
if (op == CharacterList.Opcode)
{
charList = CharacterList.Parse(body);
Console.WriteLine($"[live] step 4: CharacterList received " +
$"account={charList.AccountName} count={charList.Characters.Count}");
foreach (var c in charList.Characters)
Console.WriteLine($"[live] character: id=0x{c.Id:X8} name={c.Name}");
}
}
}
Assert.NotNull(charList);
Assert.NotEmpty(charList!.Characters);
var chosen = charList.Characters[0];
Console.WriteLine($"[live] choosing character: 0x{chosen.Id:X8} {chosen.Name}");
// ---- Step 5: send CharacterEnterWorldRequest (UIQueue, encrypted checksum) ----
void SendGameMessage(byte[] gameMessageBody, string label)
{
var fragment = GameMessageFragment.BuildSingleFragment(
fragmentSequence++, GameMessageGroup.UIQueue, gameMessageBody);
byte[] packetBody = GameMessageFragment.Serialize(fragment);
var header = new PacketHeader
{
Sequence = clientPacketSequence++,
Flags = PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum,
Id = sessionClientId,
};
byte[] datagram = PacketCodec.Encode(header, packetBody, outboundIsaac);
net.Send(datagram);
Console.WriteLine($"[live] sent {label}: packet.seq={header.Sequence} " +
$"frag.seq={fragment.Header.Sequence} bytes={datagram.Length}");
}
SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody(),
"CharacterEnterWorldRequest");
// ---- Step 6: wait for CharacterEnterWorldServerReady (0xF7DF). ----
bool sawServerReady = false;
var d6 = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < d6 && !sawServerReady)
{
var b = net.Receive(d6 - DateTime.UtcNow, out _);
if (b is null) break;
var dec = PacketCodec.TryDecode(b, inboundIsaac);
if (!dec.IsOk)
{
Console.WriteLine($"[live] step 6: decode error {dec.Error}");
continue;
}
foreach (var frag in dec.Packet!.Fragments)
{
var body = assembler.Ingest(frag, out _);
if (body is null || body.Length < 4) continue;
uint op = BinaryPrimitives.ReadUInt32LittleEndian(body);
Console.WriteLine($"[live] step 6: got GameMessage opcode=0x{op:X8}");
if (op == 0xF7DFu)
{
sawServerReady = true;
Console.WriteLine("[live] step 6: CharacterEnterWorldServerReady received");
break;
}
}
}
Assert.True(sawServerReady, "Server did not send CharacterEnterWorldServerReady");
// ---- Step 7: send CharacterEnterWorld with the chosen GUID. ----
SendGameMessage(
CharacterEnterWorld.BuildEnterWorldBody(chosen.Id, user),
$"CharacterEnterWorld(guid=0x{chosen.Id:X8})");
// ---- Step 8: receive the CreateObject flood. ----
int totalMessages = 0;
int createObjectCount = 0;
var seenOpcodes = new HashSet<uint>();
var d8 = DateTime.UtcNow + TimeSpan.FromSeconds(10);
while (DateTime.UtcNow < d8)
{
var b = net.Receive(d8 - DateTime.UtcNow, out _);
if (b is null) break;
var dec = PacketCodec.TryDecode(b, inboundIsaac);
if (!dec.IsOk)
{
Console.WriteLine($"[live] step 8: decode error {dec.Error} on {b.Length}-byte packet");
continue;
}
foreach (var frag in dec.Packet!.Fragments)
{
var body = assembler.Ingest(frag, out _);
if (body is null || body.Length < 4) continue;
uint op = BinaryPrimitives.ReadUInt32LittleEndian(body);
seenOpcodes.Add(op);
totalMessages++;
if (op == 0xF745u) // CreateObject
createObjectCount++;
}
}
Console.WriteLine($"[live] step 8 summary: {totalMessages} GameMessages assembled, " +
$"{createObjectCount} CreateObject");
Console.WriteLine("[live] unique opcodes seen: " +
string.Join(", ", seenOpcodes.Select(o => $"0x{o:X8}")));
// MILESTONE: if we got at least one CreateObject, the server considers
// us logged into the world. That's the Phase 4.7 win condition.
Assert.True(createObjectCount > 0,
$"Expected at least one CreateObject message post-login, got {createObjectCount}");
}
}