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 the retail status, separate active and deleted character /// collections, plus account-level settings. /// /// /// Wire layout ported from retail CharacterSet::UnPack at /// 0x004FE340 and CharacterIdentity::UnPack at /// 0x004FE880. ACE currently emits zero for both status and deleted /// count, which is why older readers mislabeled them as padding: /// /// /// /// u32 status /// u32 activeCount /// for each active character: /// u32 characterId (GUID) /// String16L name (prefixed with '+' for admin chars) /// u32 secondsGreyedOut /// u32 deletedCount /// for each deleted character: same CharacterIdentity record /// 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 SecondsGreyedOut); public readonly record struct Selection(int ActiveIndex, Character Character); public sealed record Parsed( uint Status, IReadOnlyList Characters, IReadOnlyList DeletedCharacters, int 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}"); uint status = ReadU32(body, ref pos); Character[] characters = ReadCharacters(body, ref pos, "active"); Character[] deletedCharacters = ReadCharacters(body, ref pos, "deleted"); int slotCount = unchecked((int)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( status, characters, deletedCharacters, slotCount, accountName, useTurbineChat, hasThroneOfDestiny); } /// /// acdream's unattended character-management adaptation: choose the first /// active, non-greyed identity in retail wire order. Deleted identities are /// intentionally outside the candidate collection. /// public static bool TrySelectFirstAvailable(Parsed parsed, out Selection selection) { ArgumentNullException.ThrowIfNull(parsed); for (int i = 0; i < parsed.Characters.Count; i++) { Character character = parsed.Characters[i]; if (!IsAvailableActiveIdentity(character)) continue; selection = new Selection(i, character); return true; } selection = default; return false; } /// /// Returns whether an identity from the active collection can be selected. /// The collection context is deliberate: deleted identities share the same /// wire record but are never selectable. /// public static bool IsAvailableActiveIdentity(Character character) => character.Id != 0 && character.SecondsGreyedOut == 0; private static Character[] ReadCharacters( ReadOnlySpan body, ref int pos, string collectionName) { uint count = ReadU32(body, ref pos); // CharacterIdentity's minimum aligned wire size is twelve bytes: // guid + empty String16L record + secondsGreyedOut. Derive the bound // from the packet rather than inventing a retail-external count cap. if (count > (uint)((body.Length - pos) / 12)) throw new FormatException( $"{collectionName} character count {count} exceeds remaining payload"); var characters = new Character[checked((int)count)]; for (int i = 0; i < characters.Length; i++) { uint id = ReadU32(body, ref pos); string name = ReadString16L(body, ref pos); uint secondsGreyedOut = ReadU32(body, ref pos); characters[i] = new Character(id, name, secondsGreyedOut); } return characters; } 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; if (source.Length - pos < padding) throw new FormatException("truncated String16L padding"); pos += padding; return result; } }