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;
}
}