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>
102 lines
3.8 KiB
C#
102 lines
3.8 KiB
C#
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;
|
|
}
|
|
}
|