feat(chat): port retail client command families

Expand the typed client-command boundary across travel, character queries, local UI and layout controls, AFK and consent, emotes, friends, squelch and filters, and fill-components. Preserve retail packet layouts and queue ownership, import the confirmation dialog, and keep authoritative social state in Core.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-13 14:50:15 +02:00
parent 5a45a7ac7f
commit 9ea579bdd0
47 changed files with 6659 additions and 99 deletions

View file

@ -0,0 +1,138 @@
using System.Buffers.Binary;
using AcDream.Core.Social;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Retail social-state parsers. Sources: <c>CM_Social::DispatchUI_FriendsUpdate
/// @ 0x006A5DD0</c>, <c>FriendData::UnPack @ 0x005B9D20</c>, and
/// <c>SquelchDB::UnPack @ 0x006B1900</c>.
/// </summary>
public static class SocialStateMessages
{
public static FriendsUpdate? ParseFriendsUpdate(ReadOnlySpan<byte> payload)
{
int pos = 0;
try
{
uint count = ReadU32(payload, ref pos);
// PList<FriendData>::UnPack @ 0x0048CD30 stores a 32-bit count
// and bounds it only by the remaining packet. Keep a generous
// allocation guard without inventing the UI's 50-friend policy
// at the wire layer.
if (count > 65_536) return null;
var entries = new List<FriendEntry>((int)count);
for (uint i = 0; i < count; i++)
{
uint id = ReadU32(payload, ref pos);
bool online = ReadU32(payload, ref pos) != 0;
bool appearOffline = ReadU32(payload, ref pos) != 0;
string name = StringReader.ReadString16L(payload, ref pos);
IReadOnlyList<uint> friends = ReadUInt32List(payload, ref pos);
IReadOnlyList<uint> friendOf = ReadUInt32List(payload, ref pos);
entries.Add(new FriendEntry(id, name, online, appearOffline, friends, friendOf));
}
FriendsUpdateType type = (FriendsUpdateType)ReadU32(payload, ref pos);
return Enum.IsDefined(type) ? new FriendsUpdate(type, entries) : null;
}
catch (FormatException)
{
return null;
}
}
public static SquelchDatabase? ParseSquelchDatabase(ReadOnlySpan<byte> payload)
{
int pos = 0;
try
{
IReadOnlyDictionary<string, uint> accounts = ReadAccountTable(payload, ref pos);
IReadOnlyDictionary<uint, SquelchInfo> characters = ReadCharacterTable(payload, ref pos);
SquelchInfo global = ReadSquelchInfo(payload, ref pos);
return pos <= payload.Length
? new SquelchDatabase(accounts, characters, global)
: null;
}
catch (FormatException)
{
return null;
}
}
private static IReadOnlyDictionary<string, uint> ReadAccountTable(
ReadOnlySpan<byte> source, ref int pos)
{
(ushort count, ushort buckets) = ReadHashHeader(source, ref pos);
// A zero-sized retail table is valid only when it is empty (see the
// early return in PackableHashTable::UnPack @ 0x006B1A86).
if (buckets == 0 && count != 0)
throw new FormatException("invalid account squelch table");
var result = new Dictionary<string, uint>(count, StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < count; i++)
{
string name = StringReader.ReadString16L(source, ref pos);
result[name] = ReadU32(source, ref pos);
}
return result;
}
private static IReadOnlyDictionary<uint, SquelchInfo> ReadCharacterTable(
ReadOnlySpan<byte> source, ref int pos)
{
(ushort count, ushort buckets) = ReadHashHeader(source, ref pos);
if (buckets == 0 && count != 0)
throw new FormatException("invalid character squelch table");
var result = new Dictionary<uint, SquelchInfo>(count);
for (int i = 0; i < count; i++)
{
uint id = ReadU32(source, ref pos);
result[id] = ReadSquelchInfo(source, ref pos);
}
return result;
}
private static SquelchInfo ReadSquelchInfo(ReadOnlySpan<byte> source, ref int pos)
{
uint wordCount = ReadU32(source, ref pos);
if (wordCount > 4) throw new FormatException("invalid vlong word count");
var messageTypes = new HashSet<uint>();
for (uint word = 0; word < wordCount; word++)
{
uint bits = ReadU32(source, ref pos);
for (uint bit = 0; bit < 32; bit++)
{
if ((bits & (1u << (int)bit)) != 0)
messageTypes.Add(word * 32 + bit);
}
}
string name = StringReader.ReadString16L(source, ref pos);
bool accountWide = ReadU32(source, ref pos) != 0;
return new SquelchInfo(name, accountWide, messageTypes);
}
private static (ushort Count, ushort Buckets) ReadHashHeader(
ReadOnlySpan<byte> source, ref int pos)
{
uint header = ReadU32(source, ref pos);
return ((ushort)(header & 0xFFFFu), (ushort)(header >> 16));
}
private static IReadOnlyList<uint> ReadUInt32List(ReadOnlySpan<byte> source, ref int pos)
{
uint count = ReadU32(source, ref pos);
if (count > 10_000) throw new FormatException("invalid packed list count");
var result = new uint[count];
for (int i = 0; i < result.Length; i++) result[i] = ReadU32(source, ref pos);
return result;
}
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, 4));
pos += 4;
return value;
}
}