using System.Buffers.Binary; using System.Text; namespace AcDream.Core.Net.Messages; /// /// Inbound CharacterList GameMessage (opcode 0xF658). /// 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). /// /// /// Wire layout (ported from ACE's GameMessageCharacterList.cs /// writer — see NOTICE.md for attribution): /// /// /// /// 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) /// /// 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 Characters, uint SlotCount, string AccountName, bool UseTurbineChat, bool HasThroneOfDestiny); /// /// Parse a CharacterList body. must start with /// the 4-byte opcode (0xF658) — i.e. pass the full reassembled /// GameMessage output from . /// public static Parsed Parse(ReadOnlySpan 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 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 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"); // Windows-1252 matches retail (and holtburger's encoding_rs::WINDOWS_1252). string result = Encoding.GetEncoding(1252).GetString(source.Slice(pos, length)); pos += length; int recordSize = 2 + length; int padding = (4 - (recordSize & 3)) & 3; pos += padding; return result; } }