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

@ -0,0 +1,56 @@
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Outbound GameMessages for the character-select → in-world transition.
/// The client sends two messages in order after receiving
/// <see cref="CharacterList"/>:
///
/// <list type="number">
/// <item><c>CharacterEnterWorldRequest</c> (opcode <c>0xF7C8</c>) —
/// empty body beyond the 4-byte opcode. "Hi server, I'm about
/// to try to enter the world, anything I should know?"</item>
/// <item>Server replies with
/// <c>CharacterEnterWorldServerReady</c> (0xF7DF).</item>
/// <item><c>CharacterEnterWorld</c> (opcode <c>0xF657</c>) — u32 GUID
/// of the chosen character + String16L account name. Server
/// validates ownership and begins spawning the player + world
/// entities into our session, which is where the CreateObject
/// flood starts.</item>
/// </list>
///
/// Both messages are tiny enough to fit in one fragment each. They go on
/// the <see cref="GameMessageGroup.UIQueue"/> queue per ACE's
/// <c>CharacterHandler</c> annotations.
/// </summary>
public static class CharacterEnterWorld
{
public const uint EnterWorldRequestOpcode = 0xF7C8u;
public const uint EnterWorldOpcode = 0xF657u;
/// <summary>
/// Build the body bytes for an outbound <c>CharacterEnterWorldRequest</c>.
/// Just the 4-byte opcode, nothing else.
/// </summary>
public static byte[] BuildEnterWorldRequestBody()
{
var w = new PacketWriter(8);
w.WriteUInt32(EnterWorldRequestOpcode);
return w.ToArray();
}
/// <summary>
/// Build the body bytes for an outbound <c>CharacterEnterWorld</c>.
/// Layout: opcode(4) + characterGuid(4) + String16L(accountName).
/// </summary>
public static byte[] BuildEnterWorldBody(uint characterGuid, string accountName)
{
ArgumentNullException.ThrowIfNull(accountName);
var w = new PacketWriter(32);
w.WriteUInt32(EnterWorldOpcode);
w.WriteUInt32(characterGuid);
w.WriteString16L(accountName);
return w.ToArray();
}
}

View file

@ -0,0 +1,102 @@
using System.Buffers.Binary;
using System.Text;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>CharacterList</c> GameMessage (opcode <c>0xF658</c>).
/// The server sends one of these right after ConnectResponse completes
/// the handshake — it's the client's "pick which character to log in"
/// data. Contains one entry per character on the account plus account-
/// level settings (slot count, Turbine chat toggle, ToD flag).
///
/// <para>
/// Wire layout (ported from ACE's <c>GameMessageCharacterList.cs</c>
/// writer — see NOTICE.md for attribution):
/// </para>
///
/// <code>
/// u32 0 (leading padding, always 0)
/// u32 characterCount
/// for each character:
/// u32 characterId (GUID)
/// String16L name (prefixed with '+' for admin chars)
/// u32 deleteTimeDelta (0 if not scheduled for deletion)
/// u32 0 (trailing padding)
/// u32 slotCount (max characters per account)
/// String16L accountName
/// u32 useTurbineChat (bool)
/// u32 hasThroneOfDestiny (bool — always 1 on retail-era ACE)
/// </code>
/// </summary>
public static class CharacterList
{
public const uint Opcode = 0xF658u;
public readonly record struct Character(uint Id, string Name, uint DeleteTimeDelta);
public sealed record Parsed(
IReadOnlyList<Character> Characters,
uint SlotCount,
string AccountName,
bool UseTurbineChat,
bool HasThroneOfDestiny);
/// <summary>
/// Parse a CharacterList body. <paramref name="body"/> must start with
/// the 4-byte opcode (0xF658) — i.e. pass the full reassembled
/// GameMessage output from <see cref="Packets.FragmentAssembler"/>.
/// </summary>
public static Parsed Parse(ReadOnlySpan<byte> body)
{
int pos = 0;
uint opcode = ReadU32(body, ref pos);
if (opcode != Opcode)
throw new FormatException($"expected CharacterList opcode 0x{Opcode:X4}, got 0x{opcode:X8}");
_ = ReadU32(body, ref pos); // leading 0
uint count = ReadU32(body, ref pos);
if (count > 255)
throw new FormatException($"character count {count} exceeds sanity limit");
var characters = new Character[count];
for (int i = 0; i < count; i++)
{
uint id = ReadU32(body, ref pos);
string name = ReadString16L(body, ref pos);
uint deleteDelta = ReadU32(body, ref pos);
characters[i] = new Character(id, name, deleteDelta);
}
_ = ReadU32(body, ref pos); // trailing 0
uint slotCount = ReadU32(body, ref pos);
string accountName = ReadString16L(body, ref pos);
bool useTurbineChat = ReadU32(body, ref pos) != 0;
bool hasThroneOfDestiny = ReadU32(body, ref pos) != 0;
return new Parsed(characters, slotCount, accountName, useTurbineChat, hasThroneOfDestiny);
}
private static uint ReadU32(ReadOnlySpan<byte> source, ref int pos)
{
if (source.Length - pos < 4) throw new FormatException("truncated u32");
uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos));
pos += 4;
return value;
}
private static string ReadString16L(ReadOnlySpan<byte> source, ref int pos)
{
if (source.Length - pos < 2) throw new FormatException("truncated String16L length");
ushort length = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
pos += 2;
if (source.Length - pos < length) throw new FormatException("truncated String16L body");
string result = Encoding.ASCII.GetString(source.Slice(pos, length));
pos += length;
int recordSize = 2 + length;
int padding = (4 - (recordSize & 3)) & 3;
pos += padding;
return result;
}
}

View file

@ -0,0 +1,89 @@
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Helper to wrap a single GameMessage (opcode + body) in one
/// <see cref="MessageFragment"/>. Only handles the common "small message
/// fits in one fragment" case — the &gt;448-byte multi-fragment split is
/// deferred until we actually need it for outbound traffic.
///
/// <para>
/// Callers build the message body via <see cref="PacketWriter"/>
/// (starting with a 4-byte opcode, then the fields), pick a
/// <see cref="GameMessageGroup"/> for the queue, pick a fragment sequence
/// number for this message, and receive a fully-formed
/// <see cref="MessageFragment"/> ready to embed in a packet body.
/// </para>
/// </summary>
public static class GameMessageFragment
{
/// <summary>
/// Constant Id used on every outbound fragment. ACE's server-side
/// code uses the same literal — the per-message uniqueness lives in
/// the <c>Sequence</c> field, not the <c>Id</c> field.
/// </summary>
public const uint OutboundFragmentId = 0x80000000u;
/// <summary>
/// Build a single fragment that carries an entire GameMessage in its
/// payload. Throws if the payload exceeds the max single-fragment
/// body size (448 bytes).
/// </summary>
public static MessageFragment BuildSingleFragment(
uint fragmentSequence,
GameMessageGroup queue,
ReadOnlySpan<byte> gameMessageBytes)
{
if (gameMessageBytes.Length > MessageFragmentHeader.MaxFragmentDataSize)
throw new ArgumentException(
$"game message body ({gameMessageBytes.Length} bytes) exceeds single-fragment capacity " +
$"({MessageFragmentHeader.MaxFragmentDataSize} bytes). Multi-fragment split TBD.",
nameof(gameMessageBytes));
var header = new MessageFragmentHeader
{
Sequence = fragmentSequence,
Id = OutboundFragmentId,
Count = 1,
TotalSize = (ushort)(MessageFragmentHeader.Size + gameMessageBytes.Length),
Index = 0,
Queue = (ushort)queue,
};
return new MessageFragment(header, gameMessageBytes.ToArray());
}
/// <summary>
/// Concatenate a fragment's header + payload into the bytes that go
/// into a packet's body. Use when building the full <c>body</c> span
/// passed to <see cref="PacketCodec.Encode"/>.
/// </summary>
public static byte[] Serialize(in MessageFragment fragment)
{
byte[] buffer = new byte[MessageFragmentHeader.Size + fragment.Payload.Length];
fragment.Header.Pack(buffer);
fragment.Payload.CopyTo(buffer.AsSpan(MessageFragmentHeader.Size));
return buffer;
}
}
/// <summary>
/// AC's per-queue routing for GameMessages. Matches ACE's GameMessageGroup
/// enum byte-for-byte so ported handlers are unambiguous.
/// </summary>
public enum GameMessageGroup : ushort
{
InvalidQueue = 0x00,
EventQueue = 0x01,
ControlQueue = 0x02,
WeenieQueue = 0x03,
LoginQueue = 0x04,
DatabaseQueue = 0x05,
SecureControlQueue = 0x06,
SecureWeenieQueue = 0x07,
SecureLoginQueue = 0x08,
UIQueue = 0x09,
SmartboxQueue = 0x0A,
ObserverQueue = 0x0B,
}

View file

@ -3,9 +3,11 @@ namespace AcDream.Core.Net.Packets;
/// <summary>
/// Reassembles multi-fragment GameMessages. UDP packets can arrive in any
/// order and individual fragments within a logical message can be split
/// across packets, so we buffer partial messages keyed by fragment Id and
/// only yield a complete byte stream once every <c>Count</c> fragment for
/// that Id has arrived.
/// across packets, so we buffer partial messages keyed by fragment
/// <b>Sequence</b> (the actual unique identifier — ACE's outbound fragment
/// <c>Id</c> field is always the constant <c>0x80000000</c>; the
/// per-message-unique value is the <c>Sequence</c>, matching how ACE's
/// own <c>NetworkSession.HandleFragment</c> keys its partialFragments dict).
///
/// <para>
/// <b>Correctness properties:</b>
@ -14,11 +16,11 @@ namespace AcDream.Core.Net.Packets;
/// the full message is released on the last fragment regardless of
/// its index.</item>
/// <item>Duplicate-fragment idempotence: receiving index N twice for the
/// same Id is harmless — the second copy is silently ignored.</item>
/// same Sequence is harmless — the second copy is silently ignored.</item>
/// <item>Single-fragment messages: Count=1 releases immediately on
/// that one fragment with no buffering.</item>
/// <item>Orphaned partials: if fragments for an Id arrive but the message
/// never completes, they stay buffered until
/// <item>Orphaned partials: if fragments for a Sequence arrive but the
/// message never completes, they stay buffered until
/// <see cref="DropAll"/> is called or the assembler is disposed.
/// A future phase will add a TTL-based eviction.</item>
/// </list>
@ -56,10 +58,12 @@ public sealed class FragmentAssembler
return fragment.Payload;
}
if (!_inFlight.TryGetValue(h.Id, out var partial))
// Key on Sequence, not Id — ACE's outbound Id is a constant and
// its own inbound assembler keys on Sequence for the same reason.
if (!_inFlight.TryGetValue(h.Sequence, out var partial))
{
partial = new PartialMessage(h.Count, h.Queue);
_inFlight[h.Id] = partial;
_inFlight[h.Sequence] = partial;
}
// Idempotent: receiving the same index twice is not an error.
@ -86,7 +90,7 @@ public sealed class FragmentAssembler
offset += p.Length;
}
_inFlight.Remove(h.Id);
_inFlight.Remove(h.Sequence);
messageQueue = partial.Queue;
return combined;
}