using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
using AcDream.Core.Items;
namespace AcDream.Core.Net.Messages;
///
/// Parser + record types for the most-used
/// sub-opcodes inside the 0xF7B0 envelope. Each parser takes the
/// slice (header stripped) and
/// returns a typed record or null on malformed payload.
///
///
/// References: r08 protocol atlas §4 (wire specs) + ACE
/// GameEventChat.cs, GameEventTell.cs,
/// GameEventUpdateHealth.cs, GameEventWeenieError.cs,
/// GameEventCommunicationTransientString.cs.
///
///
public static class GameEvents
{
// ── Chat / communication ─────────────────────────────────────────────────
/// 0x0147 ChannelBroadcast payload.
public readonly record struct ChannelBroadcast(
uint ChannelId,
string SenderName,
string Message);
public static ChannelBroadcast? ParseChannelBroadcast(ReadOnlySpan payload)
{
int pos = 0;
if (payload.Length < 4) return null;
uint channelId = BinaryPrimitives.ReadUInt32LittleEndian(payload);
pos += 4;
try
{
string sender = ReadString16L(payload, ref pos);
string message = ReadString16L(payload, ref pos);
return new ChannelBroadcast(channelId, sender, message);
}
catch { return null; }
}
/// 0x02BD Tell payload.
public readonly record struct Tell(
string Message,
string SenderName,
uint SenderGuid,
uint TargetGuid,
uint ChatType);
public static Tell? ParseTell(ReadOnlySpan payload)
{
int pos = 0;
try
{
string message = ReadString16L(payload, ref pos);
string sender = ReadString16L(payload, ref pos);
if (payload.Length - pos < 12) return null;
uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint targetGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint chatType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
return new Tell(message, sender, senderGuid, targetGuid, chatType);
}
catch { return null; }
}
///
/// 0x02EB CommunicationTransientString payload: a bare string, and
/// nothing else.
///
/// Three oracles agree there is no chat type on this wire. ACE's
/// GameEvent/Events/GameEventCommunicationTransientString.cs writes
/// exactly one WriteString16L(message). Retail's handler
/// ClientCommunicationSystem::Handle_Communication__TransientString
/// (0x0057d460) takes a single
/// AC1Legacy::PStringBase<char> const* argument. holtburger
/// carries no type field for it either.
///
/// This parser previously demanded a trailing u32 chatType.
/// Because the string is padded to a 4-byte boundary, the remaining length
/// was always 0, so the guard tripped and every single transient string
/// was dropped.
///
public static string? ParseTransient(ReadOnlySpan payload)
{
int pos = 0;
try { return ReadString16L(payload, ref pos); }
catch { return null; }
}
/// 0x0004 PopupString — modal dialog text.
public static string? ParsePopupString(ReadOnlySpan payload)
{
int pos = 0;
try { return ReadString16L(payload, ref pos); } catch { return null; }
}
///
/// 0x01C3 QueryAgeResponse: target name (empty for self), then the
/// server-formatted played duration. Retail source:
/// CM_Character::DispatchUI_QueryAgeResponse @ 0x006A2E40.
///
public readonly record struct QueryAgeResponse(string Name, string Age);
public static QueryAgeResponse? ParseQueryAgeResponse(ReadOnlySpan payload)
{
int pos = 0;
try
{
string name = ReadString16L(payload, ref pos);
string age = ReadString16L(payload, ref pos);
return new QueryAgeResponse(name, age);
}
catch { return null; }
}
// ── Errors ──────────────────────────────────────────────────────────────
/// 0x028A WeenieError: generic game-logic failure code.
public static uint? ParseWeenieError(ReadOnlySpan payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// 0x028B WeenieErrorWithString.
public readonly record struct WeenieErrorWithString(uint ErrorCode, string Interpolation);
public static WeenieErrorWithString? ParseWeenieErrorWithString(ReadOnlySpan payload)
{
if (payload.Length < 4) return null;
uint code = BinaryPrimitives.ReadUInt32LittleEndian(payload);
int pos = 4;
try
{
string interp = ReadString16L(payload, ref pos);
return new WeenieErrorWithString(code, interp);
}
catch { return null; }
}
// ── Vitals / combat ─────────────────────────────────────────────────────
/// 0x01C0 UpdateHealth: (guid, healthPercent 0..1).
public readonly record struct UpdateHealth(uint TargetGuid, float HealthPercent);
public static UpdateHealth? ParseUpdateHealth(ReadOnlySpan payload)
{
if (payload.Length < 8) return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload);
float pct = BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(4));
return new UpdateHealth(guid, pct);
}
// ── Pings / misc ────────────────────────────────────────────────────────
/// 0x01EA PingResponse has no payload; receipt is the acknowledgement.
public static bool ParsePingResponse(ReadOnlySpan payload)
=> payload.IsEmpty;
// ── Spells / magic ──────────────────────────────────────────────────────
/// 0x02C1 MagicUpdateSpell: spell id added to spellbook.
public static uint? ParseMagicUpdateSpell(ReadOnlySpan payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
// ── Combat notifications ────────────────────────────────────────────────
/// 0x01AC VictimNotification - death message for the victim.
public readonly record struct VictimNotification(string DeathMessage);
public static VictimNotification? ParseVictimNotification(ReadOnlySpan payload)
{
int pos = 0;
try { return new VictimNotification(ReadString16L(payload, ref pos)); }
catch { return null; }
}
/// 0x01AD KillerNotification - death message for the killer.
public readonly record struct KillerNotification(string DeathMessage);
public static KillerNotification? ParseKillerNotification(ReadOnlySpan payload)
{
int pos = 0;
try { return new KillerNotification(ReadString16L(payload, ref pos)); }
catch { return null; }
}
/// 0x01B1 AttackerNotification - "you hit X".
public readonly record struct AttackerNotification(
string DefenderName,
uint DamageType,
double HealthPercent,
uint Damage,
uint Critical,
ulong AttackConditions);
public static AttackerNotification? ParseAttackerNotification(ReadOnlySpan payload)
{
int pos = 0;
try
{
string name = ReadString16L(payload, ref pos);
if (payload.Length - pos < 28) return null;
uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
double pct = BinaryPrimitives.ReadDoubleLittleEndian(payload.Slice(pos)); pos += 8;
uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
ulong cond = BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(pos)); pos += 8;
return new AttackerNotification(name, damageType, pct, damage, crit, cond);
}
catch { return null; }
}
/// 0x01B2 DefenderNotification - "X hit you".
public readonly record struct DefenderNotification(
string AttackerName,
uint DamageType,
double HealthPercent,
uint Damage,
uint HitQuadrant,
uint Critical,
ulong AttackConditions);
public static DefenderNotification? ParseDefenderNotification(ReadOnlySpan payload)
{
int pos = 0;
try
{
string name = ReadString16L(payload, ref pos);
if (payload.Length - pos < 32) return null;
uint dtype = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
double pct = BinaryPrimitives.ReadDoubleLittleEndian(payload.Slice(pos)); pos += 8;
uint dmg = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint quad = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
ulong cond = BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(pos)); pos += 8;
return new DefenderNotification(name, dtype, pct, dmg, quad, crit, cond);
}
catch { return null; }
}
/// 0x01B3 EvasionAttackerNotification - "X evaded".
public static string? ParseEvasionAttackerNotification(ReadOnlySpan payload)
{
int pos = 0;
try { return ReadString16L(payload, ref pos); } catch { return null; }
}
/// 0x01B4 EvasionDefenderNotification - "you evaded X".
public static string? ParseEvasionDefenderNotification(ReadOnlySpan payload)
{
int pos = 0;
try { return ReadString16L(payload, ref pos); } catch { return null; }
}
/// 0x01B8 CombatCommenceAttack - empty payload.
public static bool ParseCombatCommenceAttack(ReadOnlySpan payload) => payload.Length == 0;
/// 0x01A7 AttackDone - single WeenieError value.
public readonly record struct AttackDone(uint AttackSequence, uint WeenieError);
public static AttackDone? ParseAttackDone(ReadOnlySpan payload)
{
if (payload.Length < 4) return null;
return new AttackDone(0u, BinaryPrimitives.ReadUInt32LittleEndian(payload));
}
// ── Spell enchantments ──────────────────────────────────────────────────
///
/// 0x02C3 MagicRemoveEnchantment — (layerId, spellId).
///
public readonly record struct LayeredSpellId(ushort SpellId, ushort Layer)
{
public uint Packed => SpellId | ((uint)Layer << 16);
}
public readonly record struct MagicRemoveEnchantment(ushort SpellId, ushort Layer);
public static MagicRemoveEnchantment? ParseMagicRemoveEnchantment(ReadOnlySpan payload)
{
if (payload.Length < 4) return null;
return new MagicRemoveEnchantment(
BinaryPrimitives.ReadUInt16LittleEndian(payload),
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(2)));
}
/// 0x01A8 MagicRemoveSpell — spell id removed from spellbook.
public static uint? ParseMagicRemoveSpell(ReadOnlySpan payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
///
/// 0x02C2 MagicUpdateEnchantment — the Enchantment blob. Full layout
/// (ACE Enchantment.Pack) is ~80+ bytes of spell metadata +
/// stat mods. We expose the first few fields that drive the enchant
/// bar UI; the rest is available via the raw payload view.
///
public static PlayerDescriptionParser.EnchantmentEntry? ParseMagicUpdateEnchantment(
ReadOnlySpan payload)
{
int position = 0;
try { return EnchantmentWireReader.Read(payload, ref position); }
catch (FormatException) { return null; }
}
public static IReadOnlyList?
ParseMagicUpdateMultipleEnchantments(ReadOnlySpan payload)
{
int position = 0;
try { return EnchantmentWireReader.ReadList(payload, ref position); }
catch (FormatException) { return null; }
}
///
/// 0x02C7 MagicDispelEnchantment — (layerId, spellId).
/// Structure matches MagicRemoveEnchantment.
///
public static MagicRemoveEnchantment? ParseMagicDispelEnchantment(ReadOnlySpan payload)
=> ParseMagicRemoveEnchantment(payload);
public static IReadOnlyList? ParseMagicLayeredSpellList(
ReadOnlySpan payload)
{
if (payload.Length < 4) return null;
uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload);
if (count > 0x4000 || payload.Length - 4 < checked((int)count * 4)) return null;
var result = new LayeredSpellId[count];
for (int i = 0; i < result.Length; i++)
{
int offset = 4 + i * 4;
result[i] = new LayeredSpellId(
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(offset, 2)),
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(offset + 2, 2)));
}
return result;
}
// ── Appraise / identify ─────────────────────────────────────────────────
/// 0x00C9 IdentifyObjectResponse header.
public readonly record struct IdentifyResponseHeader(
uint Guid,
uint AppraiseFlags,
bool Success);
///
/// Parse the header of an IdentifyObjectResponse (0x00C9).
/// Full property-bundle deserialization (int / bool / float / string
/// tables per the AppraiseFlags bitfield) is a future pass; this
/// header alone is enough for the UI to display "Appraise complete
/// on target X" and to route into the repository.
///
public static IdentifyResponseHeader? ParseIdentifyResponseHeader(ReadOnlySpan payload)
{
if (payload.Length < 12) return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4));
uint success = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8));
return new IdentifyResponseHeader(guid, flags, success != 0);
}
/// 0x0023 WieldObject: server-driven equip.
public readonly record struct WieldObject(
uint ItemGuid,
uint EquipLoc);
public static WieldObject? ParseWieldObject(ReadOnlySpan payload)
{
if (payload.Length < 8) return null;
return new WieldObject(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
/// 0x0022 InventoryPutObjInContainer: server puts item into container slot.
/// 4 fields (ACE GameEventItemServerSaysContainId.cs): itemGuid, containerGuid,
/// placement, containerType. ContainerType (0=item,1=container,2=foci) confirmed
/// vs holtburger events.rs fixture (slot=3 type=1).
public readonly record struct InventoryPutObjInContainer(
uint ItemGuid,
uint ContainerGuid,
uint Placement,
uint ContainerType);
public static InventoryPutObjInContainer? ParsePutObjInContainer(ReadOnlySpan payload)
{
if (payload.Length < 16) return null;
return new InventoryPutObjInContainer(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(12)));
}
/// 0x0196 ViewContents: full contents list of a container you opened.
/// Layout (ACE GameEventViewContents.cs): containerGuid, count, [guid, containerType]×count.
/// Client consumer: ClientUISystem::OnViewContents (PackableList<ContentProfile>).
public readonly record struct ViewContentsEntry(uint Guid, uint ContainerType);
public readonly record struct ViewContents(uint ContainerGuid, System.Collections.Generic.IReadOnlyList Items);
public static ViewContents? ParseViewContents(ReadOnlySpan payload)
{
if (payload.Length < 8) return null;
uint containerGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload);
uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4));
int pos = 8;
if ((long)payload.Length - pos < (long)count * 8) return null;
var items = new ViewContentsEntry[count];
for (int i = 0; i < count; i++)
{
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint type = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
items[i] = new ViewContentsEntry(guid, type);
}
return new ViewContents(containerGuid, items);
}
// ── Other small-payload events ──────────────────────────────────────────
/// 0x01C7 UseDone: the Use/UseWithTarget completion signal (WeenieError code).
public static uint? ParseUseDone(ReadOnlySpan payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// 0x019A InventoryPutObjectIn3D: server dropped item to ground.
public static uint? ParsePutObjectIn3D(ReadOnlySpan payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// 0x00A0 InventoryServerSaveFailed: revert a speculative local inventory op.
/// (itemGuid, weenieError) — ACE GameEventInventoryServerSaveFailed.cs; holtburger
/// events.rs:147 reads both fields.
public readonly record struct InventoryServerSaveFailed(uint ItemGuid, uint WeenieError);
public static InventoryServerSaveFailed? ParseInventoryServerSaveFailed(ReadOnlySpan payload)
{
if (payload.Length < 8) return null;
return new InventoryServerSaveFailed(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
/// 0x0052 CloseGroundContainer: server closed a ground container view.
public static uint? ParseCloseGroundContainer(ReadOnlySpan payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
// ── Secure trade (docs/research/2026-08-14-trade-laneB-wire.md) ────────
// ACE writers + retail parsers agree on every field below; retail
// dispatch addresses cited per event.
/// 0x01FD RegisterTrade: (initiator, partner, stamp). Retail
/// Handle_Trade__Recv_RegisterTrade @ 0x0056E050. ACE landmine:
/// BOTH sides receive initiator == partner == the non-self player's guid
/// (never the true initiator) and stamp is always 0 — consumers must
/// derive "who opened" themselves (lane B §quirks).
public readonly record struct RegisterTrade(uint Initiator, uint Partner, ulong Stamp);
public static RegisterTrade? ParseRegisterTrade(ReadOnlySpan payload)
{
if (payload.Length < 16) return null;
return new RegisterTrade(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(8)));
}
/// 0x01FF CloseTrade: end reason (Normal=1, EnteredCombat=2,
/// Canceled=0x51). Retail dispatch @ 0x006ACE90.
public static uint? ParseCloseTrade(ReadOnlySpan payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// 0x0200 AddToTrade: (itemGuid, side, slot). Side: 1 = the
/// receiving client's own offer, 2 = the partner's. Slot is always 0
/// from ACE. Retail dispatch @ 0x006ACE20 reads three dwords.
public readonly record struct AddToTrade(uint ItemGuid, uint Side, uint SlotIndex);
public static AddToTrade? ParseAddToTrade(ReadOnlySpan payload)
{
if (payload.Length < 12) return null;
return new AddToTrade(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)));
}
/// 0x0201 RemoveFromTrade: (itemGuid, mode). Retail
/// Handle_Trade__Recv_RemoveFromTrade @ 0x0056DC00; ACE never
/// emits it (no per-item removal server-side) — parsed defensively.
public readonly record struct RemoveFromTrade(uint ItemGuid, uint Mode);
public static RemoveFromTrade? ParseRemoveFromTrade(ReadOnlySpan payload)
{
if (payload.Length < 8) return null;
return new RemoveFromTrade(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
/// 0x0202 AcceptTrade: who accepted (the client compares
/// against its own guid for self-vs-partner — retail dispatch
/// @ 0x006ACDF0).
public static uint? ParseAcceptTrade(ReadOnlySpan payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// 0x0203 DeclineTrade: who declined.
public static uint? ParseDeclineTrade(ReadOnlySpan payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// 0x0205 ResetTrade: who reset. ACE clears BOTH sides'
/// staged items on either player's reset (lane B §quirks).
public static uint? ParseResetTrade(ReadOnlySpan payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// 0x0207 TradeFailure: (itemGuid, WeenieError reason). Retail
/// Handle_Trade__Recv_TradeFailure @ 0x0056D990 removes the item
/// locally before showing the notice.
public readonly record struct TradeFailure(uint ItemGuid, uint Reason);
public static TradeFailure? ParseTradeFailure(ReadOnlySpan payload)
{
if (payload.Length < 8) return null;
return new TradeFailure(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
///
/// 0x0264 QueryItemManaResponse: (itemGuid, manaPercent, valid).
/// Retail anchor: CM_Item::DispatchUI_QueryItemManaResponse @ 0x006A84D0
/// reads the trailing 32-bit validity flag at message offset 0x0C.
///
public readonly record struct QueryItemManaResponse(uint ItemGuid, float ManaPercent, bool Valid);
public static QueryItemManaResponse? ParseQueryItemManaResponse(ReadOnlySpan payload)
{
if (payload.Length < 12) return null;
return new QueryItemManaResponse(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(4)),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)) != 0);
}
///
/// 0x0274 CharacterConfirmationRequest — server-driven modal confirm.
/// is a bare uint, not
/// — this is the leg GameplayConfirmationController.HandleRequest
/// actually consumes in production today. See 's
/// doc comment (blast review SF-5) for why the triple currently carries
/// its discriminator two different ways.
///
public readonly record struct CharacterConfirmationRequest(
uint Type,
uint ContextId,
string Message);
public static CharacterConfirmationRequest? ParseCharacterConfirmationRequest(ReadOnlySpan payload)
{
if (payload.Length < 8) return null;
int pos = 0;
uint type = BinaryPrimitives.ReadUInt32LittleEndian(payload); pos += 4;
uint contextId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
try
{
string msg = ReadString16L(payload, ref pos);
return new CharacterConfirmationRequest(type, contextId, msg);
}
catch { return null; }
}
///
/// 0x0276 CharacterConfirmationDone — server cancellation/completion of the
/// outstanding confirmation tuple. Retail dispatches the same type/context
/// pair to RecvNotice_AbortConfirmationRequest.
/// is a bare uint, not — the leg
/// GameplayConfirmationController.HandleDone actually consumes;
/// see 's doc comment (blast review SF-5).
///
public readonly record struct CharacterConfirmationDone(uint Type, uint ContextId);
public static CharacterConfirmationDone? ParseCharacterConfirmationDone(
ReadOnlySpan payload)
{
if (payload.Length < 8) return null;
return new CharacterConfirmationDone(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
///
/// The ConfirmationType discriminator carried by all three legs
/// of the shared confirmation triple (0x0274/0x0275/
/// 0x0276's leading Type/confirmationType field).
/// Retail Handle_Character__ConfirmationRequest @0x005640A0's
/// switch and ACE's ConfirmationType.cs:5-12 agree byte-for-byte
/// (lane B §3.15, lane C §1.3). Campaign FA needs exactly
/// (1) and (4)
/// — D6.
///
///
/// FA1 review round (blast SF-5): this enum currently types ONLY the
/// response-side leg () —
/// and
/// , the two legs
/// production actually reads today, remain bare uint. This is a
/// deliberate, not-yet-decided split, not a double parser: FA1 did not
/// touch the inbound legs. Before FA4 wires fellowship (4) and
/// allegiance (1) confirmations, either promote both inbound records
/// to or treat this note as the standing
/// decision that the enum stays response-side only.
///
///
public enum ConfirmationType : uint
{
SwearAllegiance = 1,
AlterSkill = 2,
AlterAttribute = 3,
Fellowship = 4,
CraftInteraction = 5,
Augmentation = 6,
YesNo = 7,
}
///
/// 0x0275 ConfirmationResponse — the CLIENT→SERVER leg of the
/// confirmation triple (CM_Character::Event_ConfirmationResponse
/// @0x006A1210, lane B §3.15 / lane C §3.3). Unlike
/// /
/// this direction is never received by a real client — acdream already
/// builds it (ClientCommandRequests.BuildConfirmationResponse).
/// This record + parser exist to give the triple a complete, TYPED
/// representation in Core.Net (the
/// enum, not a bare uint) and a round-trip conformance check —
/// see ConfirmationTripleTests for the golden-vector /
/// round-trip pair against BuildConfirmationResponse.
///
public readonly record struct ConfirmationResponse(
ConfirmationType Type,
uint ContextId,
bool Accepted);
public static ConfirmationResponse? ParseConfirmationResponse(ReadOnlySpan payload)
{
if (payload.Length < 12) return null;
return new ConfirmationResponse(
(ConfirmationType)BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)) != 0);
}
// ── Fellowship (Campaign FA slice FA1, 2026-08-11) ──────────────────────
//
// UNWIRED: these are pure parse functions + typed records only. FA2
// registers them against a Runtime-owned RuntimeFellowshipState via
// GameEventWiring.WireAll — see docs/research/2026-08-11-fa-acdream-seams.md
// §2. Field orders are byte-verified against
// docs/research/2026-08-11-fa-fellowship-wire.md (lane B) §3.8-§3.13,
// three-way agreed with ACE + holtburger there.
///
/// One retail Fellow record — Fellow::Pack @0x005B9A10 /
/// Fellow::UnPack @0x005B9AD0 (lane B §3.8). The member GUID is
/// NOT part of this struct on the wire (it is the enclosing hash
/// table's key in 0x02BE, or a separate leading field in
/// 0x02C0) — carried here as for convenience
/// since both callers already have it in hand when they construct this
/// record. is the RAW wire u32, never a bool —
/// ACE encodes it two mutually-inconsistent ways (0x10 in full
/// updates, <<1 in incremental updates), so the only safe
/// read is ShareLoot != 0, never == 1 (lane B §4.1, D5).
///
public readonly record struct FellowMember(
uint Guid,
uint CpCache,
uint LumCache,
uint Level,
uint MaxHealth,
uint MaxStamina,
uint MaxMana,
uint CurrentHealth,
uint CurrentStamina,
uint CurrentMana,
uint ShareLoot,
string Name);
/// One entry of the _fellows_departed hash table (lane B §2.11/§3.9 field 8).
public readonly record struct FellowshipDepartedMember(uint Guid, int DepartedTimestamp);
///
/// 0x02BE FellowshipFullUpdate — Fellowship::Pack/
/// UnPack @0x005B94F0 (lane B §3.9). Field 9 (the lock-name
/// table) is intentionally NOT parsed here: retail's own
/// Fellowship::UnPack stops reading after field 8 and never
/// consumes it (lane B §2.7 caution), and lane B's own U3 flags
/// FellowshipLockData's three unknown u32s AND a possible ACE/
/// pcap struct-width mismatch as unverified — the BN-fold rule says
/// stop and report rather than guess a shape for an unverified table,
/// so this parser is deliberately silent on it rather than risk
/// desynchronizing on a table nothing in this campaign needs yet.
///
public readonly record struct FellowshipFullUpdate(
IReadOnlyList Members,
string Name,
uint LeaderGuid,
bool ShareXp,
bool EvenXpSplit,
bool OpenFellow,
bool Locked,
IReadOnlyList Departed);
public static FellowshipFullUpdate? ParseFellowshipFullUpdate(ReadOnlySpan payload)
{
try
{
int pos = 0;
ushort memberCount = FellowshipReadU16(payload, ref pos);
_ = FellowshipReadU16(payload, ref pos); // numBuckets — server-chosen (16), not consulted (lane B §3.9)
var members = new List(memberCount);
for (int i = 0; i < memberCount; i++)
{
uint guid = FellowshipReadU32(payload, ref pos);
members.Add(ReadFellow(payload, ref pos, guid));
}
string name = ReadString16L(payload, ref pos);
uint leaderGuid = FellowshipReadU32(payload, ref pos);
bool shareXp = FellowshipReadU32(payload, ref pos) != 0u;
bool evenXpSplit = FellowshipReadU32(payload, ref pos) != 0u;
bool openFellow = FellowshipReadU32(payload, ref pos) != 0u;
bool locked = FellowshipReadU32(payload, ref pos) != 0u;
ushort departedCount = FellowshipReadU16(payload, ref pos);
_ = FellowshipReadU16(payload, ref pos); // numBuckets (32) — not consulted
var departed = new List(departedCount);
for (int i = 0; i < departedCount; i++)
{
uint guid = FellowshipReadU32(payload, ref pos);
int timestamp = unchecked((int)FellowshipReadU32(payload, ref pos));
departed.Add(new FellowshipDepartedMember(guid, timestamp));
}
return new FellowshipFullUpdate(
members, name, leaderGuid, shareXp, evenXpSplit, openFellow, locked, departed);
}
catch (FormatException) { return null; }
}
///
/// 0x02C0 FellowshipUpdateFellow —
/// DispatchUI_UpdateFellow @0x006A6700 (lane B §3.10):
/// [u32 guid][Fellow][u32 updateType], guid FIRST (Chorizite's
/// generated shape omits the guid — resolved retail-wins per lane B's
/// "Reference disagreement, resolved" note). updateType: 0
/// Undef, 1 Full, 2 UpdateStats (ACE never sends this), 3 UpdateVitals.
///
public readonly record struct FellowshipUpdateFellow(
uint MemberGuid,
FellowMember Member,
uint UpdateType);
public static FellowshipUpdateFellow? ParseFellowshipUpdateFellow(ReadOnlySpan payload)
{
try
{
int pos = 0;
uint guid = FellowshipReadU32(payload, ref pos);
FellowMember member = ReadFellow(payload, ref pos, guid);
uint updateType = FellowshipReadU32(payload, ref pos);
return new FellowshipUpdateFellow(guid, member, updateType);
}
catch (FormatException) { return null; }
}
///
/// 0x00A3 FellowshipQuit, the S→C direction —
/// DispatchUI_Quit @0x006A5F5F (lane B §3.12):
/// [u32 quitterGuid]. Sent both to the quitter and to every
/// remaining member; the recipient distinguishes by comparing the guid
/// to its own. (The C→S direction of the same opcode is
/// SocialActions.BuildFellowshipQuit — a different payload
/// shape entirely, disambiguated here by record name.)
///
public readonly record struct FellowshipQuitNotice(uint QuitterGuid);
public static FellowshipQuitNotice? ParseFellowshipQuit(ReadOnlySpan payload)
{
if (payload.Length < 4) return null;
return new FellowshipQuitNotice(BinaryPrimitives.ReadUInt32LittleEndian(payload));
}
///
/// 0x00A4 FellowshipDismiss, the S→C direction —
/// DispatchUI_Dismiss @0x006A5EC9 (lane B §3.13):
/// [u32 dismissedGuid]. ACE's own comment notes the same numeric
/// opcode value is used in both directions with different envelopes.
///
public readonly record struct FellowshipDismissNotice(uint DismissedGuid);
public static FellowshipDismissNotice? ParseFellowshipDismiss(ReadOnlySpan payload)
{
if (payload.Length < 4) return null;
return new FellowshipDismissNotice(BinaryPrimitives.ReadUInt32LittleEndian(payload));
}
///
/// 0x02BF FellowshipDisband — DispatchUI_Disband
/// @0x006A5E80 reads ONLY the opcode and calls straight into the
/// handler; it never inspects, validates, or even looks at a body
/// length. ACE writes no body today (lane B §3.11), but encoding a
/// length check retail itself does not perform would make a future
/// non-empty body (an ACE change, a trailing pad) silently swallow the
/// disband and leave the roster stuck in a fellowship the server
/// already destroyed. Accept unconditionally — this always succeeds.
///
public static bool ParseFellowshipDisband(ReadOnlySpan payload) => true;
///
/// 0x01C9 FellowshipFellowUpdateDone — dead in the Sept-2013
/// client: both this and
/// COMDAT-fold onto the identical no-op body (lane B §2.7). The
/// dispatcher DOES read a trailing u32 payload even though nothing
/// consumes it, and ACE currently writes a zero-length body — so this
/// parser must succeed on BOTH an empty payload and a trailing u32,
/// and never fail the message (parse-and-ignore).
/// is surfaced only for diagnostics.
///
public readonly record struct FellowshipFellowUpdateDone(uint? RawValue);
public static FellowshipFellowUpdateDone ParseFellowshipFellowUpdateDone(ReadOnlySpan payload)
=> new(payload.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(payload) : null);
///
/// 0x01CA FellowshipFellowStatsDone — same dead-COMDAT-fold
/// class as (lane B §2.7);
/// kept as a distinct record type to match the distinct
/// id even though the shape is identical.
///
public readonly record struct FellowshipFellowStatsDone(uint? RawValue);
public static FellowshipFellowStatsDone ParseFellowshipFellowStatsDone(ReadOnlySpan payload)
=> new(payload.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(payload) : null);
// ── Character titles (Campaign CT slice CT2, 2026-08-24) ────────────────
///
/// 0x0029 CharacterTitle — retail CharacterTitleTable::UnPack
/// @0x005c6e90 (named-retail pseudo-C offset 471514-471526). The
/// FIRST u32 is advanced past but never stored into any field — retail's
/// own CharacterTitleTable::Pack @0x005c6e40 (offset 471494-471510)
/// always writes the literal constant 1 there
/// (**(uint32_t**)arg2 = 1), and ACE's
/// GameEventCharacterTitle.cs matches with an unconditional
/// Writer.Write(1u) — a version/format tag retail itself discards
/// on read, not meaningful gameplay data (CT2 task item 1). Then the
/// current display title id (mDisplayTitle), then the
/// count-prefixed PList<uint> of every earned title id
/// (mTitleList).
///
public readonly record struct CharacterTitleTable(
uint DisplayTitleId,
IReadOnlyList TitleIds);
public static CharacterTitleTable? ParseCharacterTitleTable(ReadOnlySpan payload)
{
try
{
int pos = 0;
_ = FellowshipReadU32(payload, ref pos); // discarded pack-version tag — see doc comment above
uint displayTitleId = FellowshipReadU32(payload, ref pos);
uint count = FellowshipReadU32(payload, ref pos);
// PList::UnPack stores a 32-bit count bounded only by the
// remaining packet — same generous guard as
// SocialStateMessages.ParseFriendsUpdate.
if (count > 65_536) return null;
var titleIds = new uint[count];
for (int i = 0; i < titleIds.Length; i++)
titleIds[i] = FellowshipReadU32(payload, ref pos);
return new CharacterTitleTable(displayTitleId, titleIds);
}
catch (FormatException) { return null; }
}
///
/// 0x002B UpdateTitle — retail's dispatch entry
/// CM_Social::DispatchUI_AddOrSetCharacterTitle @0x006a54c0 reads
/// exactly titleId then setAsDisplay and forwards to
/// ClientUISystem::Handle_Social__AddOrSetCharacterTitle
/// @0x00564260, which ALWAYS broadcasts
/// SendNotice_AddCharacterTitle(titleId) (a title just earned is
/// unconditionally added to the earned set) and, only when
/// setAsDisplay != 0, ALSO broadcasts
/// SendNotice_SetDisplayCharacterTitle(titleId). ACE's
/// GameEventUpdateTitle.cs: u32 title, u32
/// setAsDisplayTitle — matches exactly.
///
public readonly record struct UpdateTitle(uint TitleId, bool SetAsDisplay);
public static UpdateTitle? ParseUpdateTitle(ReadOnlySpan payload)
{
try
{
int pos = 0;
uint titleId = FellowshipReadU32(payload, ref pos);
bool setAsDisplay = FellowshipReadU32(payload, ref pos) != 0u;
return new UpdateTitle(titleId, setAsDisplay);
}
catch (FormatException) { return null; }
}
private static FellowMember ReadFellow(ReadOnlySpan payload, ref int pos, uint guid)
{
uint cpCache = FellowshipReadU32(payload, ref pos);
uint lumCache = FellowshipReadU32(payload, ref pos);
uint level = FellowshipReadU32(payload, ref pos);
uint maxHealth = FellowshipReadU32(payload, ref pos);
uint maxStamina = FellowshipReadU32(payload, ref pos);
uint maxMana = FellowshipReadU32(payload, ref pos);
uint currentHealth = FellowshipReadU32(payload, ref pos);
uint currentStamina = FellowshipReadU32(payload, ref pos);
uint currentMana = FellowshipReadU32(payload, ref pos);
uint shareLoot = FellowshipReadU32(payload, ref pos); // RAW — D5/lane B §4.1: != 0, NEVER == 1
string name = ReadString16L(payload, ref pos);
return new FellowMember(
guid, cpCache, lumCache, level, maxHealth, maxStamina, maxMana,
currentHealth, currentStamina, currentMana, shareLoot, name);
}
private static uint FellowshipReadU32(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 ushort FellowshipReadU16(ReadOnlySpan source, ref int pos)
{
if (source.Length - pos < 2) throw new FormatException("truncated u16");
ushort value = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
pos += 2;
return value;
}
// ── Allegiance small events (Campaign FA slice FA1, 2026-08-11) ─────────
//
// UNWIRED (FA2 connects them). The heavyweight 0x0020 AllegianceUpdate
// (profile push) and its shared-parser reuse of
// ClientCommandResponses.ParseAllegianceInfoResponse live in
// ClientCommandResponses.cs (lane C §7.2's explicit reuse verdict), not
// here — this section covers the small fixed-shape allegiance events.
///
/// 0x027A AllegianceLoginNotification —
/// DispatchUI_AllegianceLoginNotificationEvent @0x006A6920 (lane
/// C §2 row 8, §4.5): [u32 characterGuid][u32 isLoggedIn]. Retail
/// prints nothing if the guid is not already in the cached profile
/// (lane C §1.6) — that filtering is a display-time concern for the
/// consumer, not this parser.
///
public readonly record struct AllegianceLoginNotification(uint CharacterGuid, bool IsLoggedIn);
public static AllegianceLoginNotification? ParseAllegianceLoginNotification(ReadOnlySpan payload)
{
if (payload.Length < 8) return null;
return new AllegianceLoginNotification(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)) != 0u);
}
///
/// 0x01C8 AllegianceUpdateDone — the panel busy-cursor
/// terminator (lane C §1.2, §2 row 6): a single WeenieError
/// u32 (0 on success).
///
public static uint? ParseAllegianceUpdateDone(ReadOnlySpan payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
///
/// 0x0003 AllegianceUpdateAborted — declared by retail
/// (DispatchUI_AllegianceUpdateAborted @0x006A6950) but never
/// actually sent by ACE (lane C §2 row 7, §5.4) — a single
/// WeenieError u32, parsed for completeness/forward-compat.
///
public static uint? ParseAllegianceUpdateAborted(ReadOnlySpan payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
// ── House ────────────────────────────────────────────────────────────────
///
/// 0x0248 House_UpdateRestrictions: retail's live refresh of a house
/// object's guest/ban list (whole-unit replace, not a delta). Wire shape
/// confirmed verbatim against references/Chorizite.ACProtocol
/// /Chorizite.ACProtocol/Messages/S2C/Events/House_UpdateRestrictions
/// .generated.cs: byte Sequence, uint SenderId, RestrictionDB
/// Restrictions — Sequence is a single unpadded byte, immediately
/// followed by the 4-byte SenderId (the house object whose restrictions
/// changed).
///
public readonly record struct HouseUpdateRestrictions(
byte Sequence,
uint SenderId,
HouseRestrictionRecord Restrictions);
public static HouseUpdateRestrictions? ParseHouseUpdateRestrictions(ReadOnlySpan payload)
{
// Sequence(1) + SenderId(4) + RestrictionDB{Version(4)+Flags(4)+MonarchId(4)+PHashTable-header(4)} = 21
if (payload.Length < 21) return null;
int pos = 0;
byte sequence = payload[pos]; pos += 1;
uint senderId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
pos += 4; // Version — not consulted
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint allegianceMonarchId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint packedSize = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint entryCount = packedSize & 0xFFFFFFu;
long entryBytes = (long)entryCount * 8;
if (payload.Length - pos < entryBytes) return null;
var guests = new Dictionary((int)entryCount);
for (uint i = 0; i < entryCount; i++)
{
uint guestId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint permission = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
guests[guestId] = permission;
}
return new HouseUpdateRestrictions(
sequence,
senderId,
new HouseRestrictionRecord(
OpenToPublic: flags != 0,
AllegianceMonarchId: allegianceMonarchId,
Guests: guests));
}
// ── House panel (Batch C, Map/House toolbar panel, 2026-08-17) ─────────
// gmHouseUI::PostInit @0x004a2710 registers notice handlers for wire
// opcodes 0x0225-0x0228; the recon doc (docs/research/2026-08-17-map-
// house-recon.md) is the SSOT for the retail-side call sites and the
// two ACE writer stubs (UpdateRentTime always writes 0u; UpdateRentPayment
// always writes an empty list — captured verbatim below, not guessed).
/// One house purchase/maintenance line item. ACE
/// HousePaymentExtensions.Write: Num(int) + Paid(int) + WeenieID(uint) +
/// Name(String16L) + PluralName(String16L).
public readonly record struct HousePayment(
int Num, int Paid, uint WeenieID, string Name, string PluralName);
/// 0x0225 HouseData: the owned-house panel snapshot. ACE
/// HouseDataExtensions.Write: BuyTime(uint) + RentTime(uint) +
/// Type(uint HouseType enum) + MaintenanceFree(uint bool) +
/// Buy(List<HousePayment>) + Rent(List<HousePayment>) +
/// Position (the same Cell+Pos.XYZ+Rotation.WXYZ 32-byte shape
/// already parses
/// elsewhere).
public readonly record struct HouseData(
uint BuyTime,
uint RentTime,
uint Type,
bool MaintenanceFree,
IReadOnlyList Buy,
IReadOnlyList Rent,
CreateObject.ServerPosition Position);
public static HouseData? ParseHouseData(ReadOnlySpan payload)
{
try
{
int pos = 0;
if (payload.Length - pos < 16) return null;
uint buyTime = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint rentTime = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint type = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
bool maintenanceFree = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)) != 0; pos += 4;
List? buy = ReadHousePaymentList(payload, ref pos);
if (buy is null) return null;
List? rent = ReadHousePaymentList(payload, ref pos);
if (rent is null) return null;
if (payload.Length - pos < 32) return null;
var position = new CreateObject.ServerPosition(
LandblockId: BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos + 0)),
PositionX: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 4)),
PositionY: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 8)),
PositionZ: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 12)),
RotationW: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 16)),
RotationX: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 20)),
RotationY: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 24)),
RotationZ: BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos + 28)));
return new HouseData(buyTime, rentTime, type, maintenanceFree, buy, rent, position);
}
catch { return null; }
}
private static List? ReadHousePaymentList(ReadOnlySpan payload, ref int pos)
{
if (payload.Length - pos < 4) return null;
uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
var list = new List((int)Math.Min(count, 4096));
for (uint i = 0; i < count; i++)
{
if (payload.Length - pos < 8) return null;
int num = BinaryPrimitives.ReadInt32LittleEndian(payload.Slice(pos)); pos += 4;
int paid = BinaryPrimitives.ReadInt32LittleEndian(payload.Slice(pos)); pos += 4;
if (payload.Length - pos < 4) return null;
uint weenieId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
string name = ReadString16L(payload, ref pos);
string pluralName = ReadString16L(payload, ref pos);
list.Add(new HousePayment(num, paid, weenieId, name, pluralName));
}
return list;
}
/// 0x0226 HouseStatus: a single WeenieError u32 — retail's
/// RecvNotice_FailedHouseTransaction family (also the "no house
/// owned" reply to a HouseQuery — ACE Player_House.cs
/// HandleActionQueryHouse's new GameEventHouseStatus(Session)
/// defaults to WeenieError.BadParam (corrected 2026-08-17; an
/// earlier note here said WeenieError.None, which is not what
/// GameEventHouseStatus's own constructor default reads). The
/// value is moot either way — decomp-confirmed retail's own
/// gmHouseUI::Update(uint32_t)/gmMapUI::
/// RecvNotice_FailedHouseTransaction never read this field
/// (AcDream.Runtime.Gameplay.RuntimeHouseState.ApplyHouseStatus
/// accepts and discards it for the same reason).
public static uint? ParseHouseStatus(ReadOnlySpan payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// 0x0227 UpdateRentTime: a single uint (when the current
/// maintenance period began, Unix timestamp). ACE
/// GameEventHouseUpdateRentTime.cs is a STUB that always writes
/// 0u — captured here for completeness, not exercised by any
/// live ACE install today.
public static uint? ParseUpdateRentTime(ReadOnlySpan payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// 0x0228 UpdateRentPayment: a List<HousePayment> (the
/// rent items and how much of each has been paid this period). ACE
/// GameEventHouseUpdateRentPayment.cs is a STUB that always writes an
/// EMPTY list — captured here for completeness, not exercised by any
/// live ACE install today.
public static IReadOnlyList? ParseUpdateRentPayment(ReadOnlySpan payload)
{
int pos = 0;
return ReadHousePaymentList(payload, ref pos);
}
// ── Shared string reader (matches LoginRequest.ReadString16L) ───────────
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;
}
}