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>
This commit is contained in:
Erik 2026-04-11 15:14:31 +02:00
parent 0aea24c78e
commit 94da385ff4
8 changed files with 629 additions and 10 deletions

View file

@ -2,6 +2,7 @@ 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;
@ -256,4 +257,190 @@ public class LiveHandshakeTests
// 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}");
}
}

View file

@ -0,0 +1,90 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Tests.Messages;
public class CharacterEnterWorldTests
{
[Fact]
public void BuildEnterWorldRequestBody_IsJustTheOpcode()
{
var body = CharacterEnterWorld.BuildEnterWorldRequestBody();
Assert.Equal(4, body.Length);
Assert.Equal(CharacterEnterWorld.EnterWorldRequestOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body));
}
[Fact]
public void BuildEnterWorldBody_Layout_OpcodeThenGuidThenAccountName()
{
var body = CharacterEnterWorld.BuildEnterWorldBody(
characterGuid: 0x50000001u,
accountName: "testaccount");
int pos = 0;
Assert.Equal(CharacterEnterWorld.EnterWorldOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4;
Assert.Equal(0x50000001u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4;
// String16L("testaccount") = u16(11), 11 ASCII bytes, pad to 4-byte
// boundary from start of u16: 2 + 11 = 13, padded to 16 → 3 pad bytes.
ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos));
Assert.Equal(11, len); pos += 2;
string name = System.Text.Encoding.ASCII.GetString(body.AsSpan(pos, 11));
Assert.Equal("testaccount", name); pos += 11;
// Verify padding bytes are zero and record size is aligned to 4.
Assert.Equal(0, body[pos++]);
Assert.Equal(0, body[pos++]);
Assert.Equal(0, body[pos++]);
Assert.Equal(4 + 4 + 16, body.Length); // opcode + guid + padded string
}
}
public class GameMessageFragmentTests
{
[Fact]
public void BuildSingleFragment_FragmentHeaderMatchesProtocol()
{
byte[] msg = { 0x58, 0xF6, 0x00, 0x00, 0x01, 0x00, 0x00, 0x50 }; // fake opcode + 4 body bytes
var frag = GameMessageFragment.BuildSingleFragment(
fragmentSequence: 42,
queue: GameMessageGroup.UIQueue,
gameMessageBytes: msg);
Assert.Equal(42u, frag.Header.Sequence);
Assert.Equal(GameMessageFragment.OutboundFragmentId, frag.Header.Id);
Assert.Equal(1, frag.Header.Count);
Assert.Equal(0, frag.Header.Index);
Assert.Equal(24, frag.Header.TotalSize); // 16 header + 8 payload
Assert.Equal((ushort)GameMessageGroup.UIQueue, frag.Header.Queue);
Assert.Equal(msg, frag.Payload);
}
[Fact]
public void BuildSingleFragment_OversizeBody_Throws()
{
var big = new byte[MessageFragmentHeader.MaxFragmentDataSize + 1];
Assert.Throws<ArgumentException>(
() => GameMessageFragment.BuildSingleFragment(0, GameMessageGroup.UIQueue, big));
}
[Fact]
public void Serialize_Then_MessageFragment_TryParse_RoundTrips()
{
byte[] msg = { 0x01, 0x02, 0x03, 0x04, 0x05 };
var original = GameMessageFragment.BuildSingleFragment(
fragmentSequence: 7, queue: GameMessageGroup.UIQueue, gameMessageBytes: msg);
byte[] serialized = GameMessageFragment.Serialize(original);
var (reparsed, consumed) = MessageFragment.TryParse(serialized);
Assert.NotNull(reparsed);
Assert.Equal(serialized.Length, consumed);
Assert.Equal(original.Header.Sequence, reparsed!.Value.Header.Sequence);
Assert.Equal(original.Header.Count, reparsed.Value.Header.Count);
Assert.Equal(original.Payload, reparsed.Value.Payload);
}
}

View file

@ -0,0 +1,87 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Tests.Messages;
public class CharacterListTests
{
[Fact]
public void Parse_SingleCharacter_ExtractsGuidAndName()
{
// Hand-assemble a CharacterList body matching ACE's writer format:
// u32 opcode, u32 0, u32 count=1,
// u32 guid, String16L name, u32 deleteDelta,
// u32 0, u32 slotCount, String16L accountName,
// u32 useTurbineChat, u32 hasToD
var w = new PacketWriter(128);
w.WriteUInt32(CharacterList.Opcode);
w.WriteUInt32(0);
w.WriteUInt32(1); // count
w.WriteUInt32(0x50000001u);
w.WriteString16L("+Acdream");
w.WriteUInt32(0); // deleteDelta
w.WriteUInt32(0);
w.WriteUInt32(11); // slotCount
w.WriteString16L("testaccount");
w.WriteUInt32(1); // useTurbineChat
w.WriteUInt32(1); // hasToD
var parsed = CharacterList.Parse(w.ToArray());
Assert.Single(parsed.Characters);
Assert.Equal(0x50000001u, parsed.Characters[0].Id);
Assert.Equal("+Acdream", parsed.Characters[0].Name);
Assert.Equal(0u, parsed.Characters[0].DeleteTimeDelta);
Assert.Equal(11u, parsed.SlotCount);
Assert.Equal("testaccount", parsed.AccountName);
Assert.True(parsed.UseTurbineChat);
Assert.True(parsed.HasThroneOfDestiny);
}
[Fact]
public void Parse_MultipleCharacters_PreservesOrder()
{
var w = new PacketWriter(128);
w.WriteUInt32(CharacterList.Opcode);
w.WriteUInt32(0);
w.WriteUInt32(3); // count
w.WriteUInt32(0x50000001u);
w.WriteString16L("Alice");
w.WriteUInt32(0);
w.WriteUInt32(0x50000002u);
w.WriteString16L("Bob");
w.WriteUInt32(0);
w.WriteUInt32(0x50000003u);
w.WriteString16L("Carol");
w.WriteUInt32(0);
w.WriteUInt32(0);
w.WriteUInt32(11);
w.WriteString16L("acct");
w.WriteUInt32(0);
w.WriteUInt32(1);
var parsed = CharacterList.Parse(w.ToArray());
Assert.Equal(3, parsed.Characters.Count);
Assert.Equal("Alice", parsed.Characters[0].Name);
Assert.Equal("Bob", parsed.Characters[1].Name);
Assert.Equal("Carol", parsed.Characters[2].Name);
}
[Fact]
public void Parse_WrongOpcode_Throws()
{
byte[] bytes = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0xDEADBEEFu);
Assert.Throws<FormatException>(() => CharacterList.Parse(bytes));
}
[Fact]
public void Parse_Truncated_Throws()
{
byte[] bytes = new byte[8]; // just opcode + first zero, missing count
BinaryPrimitives.WriteUInt32LittleEndian(bytes, CharacterList.Opcode);
Assert.Throws<FormatException>(() => CharacterList.Parse(bytes));
}
}

View file

@ -4,11 +4,15 @@ namespace AcDream.Core.Net.Tests.Packets;
public class FragmentAssemblerTests
{
// NOTE: the first parameter name remains `id` for test-call-site clarity,
// but it now sets the fragment Sequence (the actual message-group key —
// the Id field is a constant on outbound fragments per AC protocol).
private static MessageFragment MakeFrag(uint id, ushort count, ushort index, byte[] payload, ushort queue = 7)
=> new(
new MessageFragmentHeader
{
Id = id,
Sequence = id,
Id = 0x80000000u, // matches ACE outbound constant
Count = count,
Index = index,
TotalSize = (ushort)(MessageFragmentHeader.Size + payload.Length),