acdream/src/AcDream.Core.Net/Messages/GameEvents.cs
Erik bcfddc97e7 feat(CT): CT2 — Runtime character-title ownership + wire
Campaign CT slice CT2: the client now learns the character's earned
titles and current display title from the server, owns that state in
Runtime, and can send a display-title change. No UI (CT3/CT4).

Wire (Core.Net):
- GameEvents.ParseCharacterTitleTable (0x0029 CharacterTitle): retail
  CharacterTitleTable::UnPack @0x005c6e90 skips a leading u32 into no
  field — its own Pack @0x005c6e40 always writes the literal 1 there,
  matching ACE's unconditional Writer.Write(1u) — then reads
  displayTitleId, then a count-prefixed PList<uint> of earned ids.
- GameEvents.ParseUpdateTitle (0x002B UpdateTitle): titleId +
  setAsDisplay, per CM_Social::DispatchUI_AddOrSetCharacterTitle
  @0x006a54c0 -> Handle_Social__AddOrSetCharacterTitle @0x00564260,
  which ALWAYS adds (SendNotice_AddCharacterTitle, unconditional) and
  additionally sets display only when setAsDisplay != 0
  (SendNotice_SetDisplayCharacterTitle, gated).
- SocialActions.BuildTitleSet / WorldSession.SendSetTitle: outbound
  TitleSet (0x002C), u32 titleId, matching ACE's GameActionSetTitle.
- GameEventWiring gains onCharacterTitleTable/onUpdateTitle delegate
  holes (Core.Net cannot reference AcDream.Runtime directly).

Runtime:
- New RuntimeCharacterTitleState (RuntimeCharacterState.Titles): earned
  title id set + display title id, TableReplaced/TitleAdded/
  DisplayTitleChanged events matching retail's unconditional-add /
  gated-display-set contract, clears at generation reset.
  RuntimeCharacterOwnershipSnapshot/CaptureOwnership/IsConverged and
  RuntimeCharacterSnapshot extended (trailing optional fields, no
  existing call site broken).
- IRuntimeCharacterCommands.SetTitle: generation-gated, sends
  TitleSet only — NO optimistic local mutation. Verified against
  retail's own CM_Social::Event_SetDisplayCharacterTitle @0x006a5720,
  which sends the wire message and touches no local field; the display
  title updates only from the server's own echo (the CA-campaign
  lesson: never re-add an optimistic write). Implemented on both hosts
  (DirectGameRuntimeCommandAdapter direct-send;
  CurrentGameRuntimeCommandAdapter via LiveCommandBus /
  LiveSessionCommandRouter's new SetTitleRuntimeCmd).
- LiveSessionEventRouter wires the two inbound events unconditionally
  (RuntimeCharacterState.Titles is a required child, not an optional
  sibling like Fellowship/Allegiance).

App (non-UI plumbing + resolver):
- CharacterTitleResolver (src/AcDream.App/UI/Layout/): ports
  CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0 — titleId ->
  EnumMapper(0x22000041) canonical key -> compute_str_hash ->
  StringTable(0x2300000E) localized text. Runtime stays id-only; CT3/
  CT4 consume this for display. DIDs hardcoded per the RetailKeyNames
  precedent (CT1 verified them end-to-end).

Register: no new row. Retail's send path is non-optimistic and so is
ours — no deviation to record for this slice.

Tests: wire conformance (byte-exact + truncation) in
CharacterTitleEventsTests.cs + SocialActionsTests.cs; Runtime owner
unit tests in RuntimeCharacterTitleStateTests.cs plus integration in
RuntimeCharacterStateTests.cs; a no-local-mutation command test in
DirectGameRuntimeCommandAdapterTests.cs; an InstalledDat pin
(CharacterTitleResolverLiveDatTests.cs, ids 0/1/2/3/5/13/14, run green
with ACDREAM_RUN_INSTALLED_DAT_TESTS=1). Full solution build green;
hermetic filtered suite green (15,380 passed / 0 failed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:03:22 +02:00

1209 lines
56 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
using AcDream.Core.Items;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Parser + record types for the most-used <see cref="GameEventType"/>
/// sub-opcodes inside the <c>0xF7B0</c> envelope. Each parser takes the
/// <see cref="GameEventEnvelope.Payload"/> slice (header stripped) and
/// returns a typed record or null on malformed payload.
///
/// <para>
/// References: r08 protocol atlas §4 (wire specs) + ACE
/// <c>GameEventChat.cs</c>, <c>GameEventTell.cs</c>,
/// <c>GameEventUpdateHealth.cs</c>, <c>GameEventWeenieError.cs</c>,
/// <c>GameEventCommunicationTransientString.cs</c>.
/// </para>
/// </summary>
public static class GameEvents
{
// ── Chat / communication ─────────────────────────────────────────────────
/// <summary>0x0147 ChannelBroadcast payload.</summary>
public readonly record struct ChannelBroadcast(
uint ChannelId,
string SenderName,
string Message);
public static ChannelBroadcast? ParseChannelBroadcast(ReadOnlySpan<byte> 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; }
}
/// <summary>0x02BD Tell payload.</summary>
public readonly record struct Tell(
string Message,
string SenderName,
uint SenderGuid,
uint TargetGuid,
uint ChatType);
public static Tell? ParseTell(ReadOnlySpan<byte> 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; }
}
/// <summary>
/// 0x02EB CommunicationTransientString payload: a bare string, and
/// nothing else.
///
/// <para>Three oracles agree there is no chat type on this wire. ACE's
/// <c>GameEvent/Events/GameEventCommunicationTransientString.cs</c> writes
/// exactly one <c>WriteString16L(message)</c>. Retail's handler
/// <c>ClientCommunicationSystem::Handle_Communication__TransientString</c>
/// (0x0057d460) takes a single
/// <c>AC1Legacy::PStringBase&lt;char&gt; const*</c> argument. holtburger
/// carries no type field for it either.</para>
///
/// <para>This parser previously demanded a trailing <c>u32 chatType</c>.
/// 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.</para>
/// </summary>
public static string? ParseTransient(ReadOnlySpan<byte> payload)
{
int pos = 0;
try { return ReadString16L(payload, ref pos); }
catch { return null; }
}
/// <summary>0x0004 PopupString — modal dialog text.</summary>
public static string? ParsePopupString(ReadOnlySpan<byte> payload)
{
int pos = 0;
try { return ReadString16L(payload, ref pos); } catch { return null; }
}
/// <summary>
/// 0x01C3 QueryAgeResponse: target name (empty for self), then the
/// server-formatted played duration. Retail source:
/// <c>CM_Character::DispatchUI_QueryAgeResponse @ 0x006A2E40</c>.
/// </summary>
public readonly record struct QueryAgeResponse(string Name, string Age);
public static QueryAgeResponse? ParseQueryAgeResponse(ReadOnlySpan<byte> 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 ──────────────────────────────────────────────────────────────
/// <summary>0x028A WeenieError: generic game-logic failure code.</summary>
public static uint? ParseWeenieError(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x028B WeenieErrorWithString.</summary>
public readonly record struct WeenieErrorWithString(uint ErrorCode, string Interpolation);
public static WeenieErrorWithString? ParseWeenieErrorWithString(ReadOnlySpan<byte> 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 ─────────────────────────────────────────────────────
/// <summary>0x01C0 UpdateHealth: (guid, healthPercent 0..1).</summary>
public readonly record struct UpdateHealth(uint TargetGuid, float HealthPercent);
public static UpdateHealth? ParseUpdateHealth(ReadOnlySpan<byte> 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 ────────────────────────────────────────────────────────
/// <summary>0x01EA PingResponse has no payload; receipt is the acknowledgement.</summary>
public static bool ParsePingResponse(ReadOnlySpan<byte> payload)
=> payload.IsEmpty;
// ── Spells / magic ──────────────────────────────────────────────────────
/// <summary>0x02C1 MagicUpdateSpell: spell id added to spellbook.</summary>
public static uint? ParseMagicUpdateSpell(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
// ── Combat notifications ────────────────────────────────────────────────
/// <summary>0x01AC VictimNotification - death message for the victim.</summary>
public readonly record struct VictimNotification(string DeathMessage);
public static VictimNotification? ParseVictimNotification(ReadOnlySpan<byte> payload)
{
int pos = 0;
try { return new VictimNotification(ReadString16L(payload, ref pos)); }
catch { return null; }
}
/// <summary>0x01AD KillerNotification - death message for the killer.</summary>
public readonly record struct KillerNotification(string DeathMessage);
public static KillerNotification? ParseKillerNotification(ReadOnlySpan<byte> payload)
{
int pos = 0;
try { return new KillerNotification(ReadString16L(payload, ref pos)); }
catch { return null; }
}
/// <summary>0x01B1 AttackerNotification - "you hit X".</summary>
public readonly record struct AttackerNotification(
string DefenderName,
uint DamageType,
double HealthPercent,
uint Damage,
uint Critical,
ulong AttackConditions);
public static AttackerNotification? ParseAttackerNotification(ReadOnlySpan<byte> 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; }
}
/// <summary>0x01B2 DefenderNotification - "X hit you".</summary>
public readonly record struct DefenderNotification(
string AttackerName,
uint DamageType,
double HealthPercent,
uint Damage,
uint HitQuadrant,
uint Critical,
ulong AttackConditions);
public static DefenderNotification? ParseDefenderNotification(ReadOnlySpan<byte> 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; }
}
/// <summary>0x01B3 EvasionAttackerNotification - "X evaded".</summary>
public static string? ParseEvasionAttackerNotification(ReadOnlySpan<byte> payload)
{
int pos = 0;
try { return ReadString16L(payload, ref pos); } catch { return null; }
}
/// <summary>0x01B4 EvasionDefenderNotification - "you evaded X".</summary>
public static string? ParseEvasionDefenderNotification(ReadOnlySpan<byte> payload)
{
int pos = 0;
try { return ReadString16L(payload, ref pos); } catch { return null; }
}
/// <summary>0x01B8 CombatCommenceAttack - empty payload.</summary>
public static bool ParseCombatCommenceAttack(ReadOnlySpan<byte> payload) => payload.Length == 0;
/// <summary>0x01A7 AttackDone - single WeenieError value.</summary>
public readonly record struct AttackDone(uint AttackSequence, uint WeenieError);
public static AttackDone? ParseAttackDone(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return new AttackDone(0u, BinaryPrimitives.ReadUInt32LittleEndian(payload));
}
// ── Spell enchantments ──────────────────────────────────────────────────
/// <summary>
/// 0x02C3 MagicRemoveEnchantment — (layerId, spellId).
/// </summary>
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<byte> payload)
{
if (payload.Length < 4) return null;
return new MagicRemoveEnchantment(
BinaryPrimitives.ReadUInt16LittleEndian(payload),
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(2)));
}
/// <summary>0x01A8 MagicRemoveSpell — spell id removed from spellbook.</summary>
public static uint? ParseMagicRemoveSpell(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>
/// 0x02C2 MagicUpdateEnchantment — the Enchantment blob. Full layout
/// (ACE <c>Enchantment.Pack</c>) 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.
/// </summary>
public static PlayerDescriptionParser.EnchantmentEntry? ParseMagicUpdateEnchantment(
ReadOnlySpan<byte> payload)
{
int position = 0;
try { return EnchantmentWireReader.Read(payload, ref position); }
catch (FormatException) { return null; }
}
public static IReadOnlyList<PlayerDescriptionParser.EnchantmentEntry>?
ParseMagicUpdateMultipleEnchantments(ReadOnlySpan<byte> payload)
{
int position = 0;
try { return EnchantmentWireReader.ReadList(payload, ref position); }
catch (FormatException) { return null; }
}
/// <summary>
/// 0x02C7 MagicDispelEnchantment — (layerId, spellId).
/// Structure matches MagicRemoveEnchantment.
/// </summary>
public static MagicRemoveEnchantment? ParseMagicDispelEnchantment(ReadOnlySpan<byte> payload)
=> ParseMagicRemoveEnchantment(payload);
public static IReadOnlyList<LayeredSpellId>? ParseMagicLayeredSpellList(
ReadOnlySpan<byte> 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 ─────────────────────────────────────────────────
/// <summary>0x00C9 IdentifyObjectResponse header.</summary>
public readonly record struct IdentifyResponseHeader(
uint Guid,
uint AppraiseFlags,
bool Success);
/// <summary>
/// Parse the header of an <c>IdentifyObjectResponse (0x00C9)</c>.
/// 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.
/// </summary>
public static IdentifyResponseHeader? ParseIdentifyResponseHeader(ReadOnlySpan<byte> 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);
}
/// <summary>0x0023 WieldObject: server-driven equip.</summary>
public readonly record struct WieldObject(
uint ItemGuid,
uint EquipLoc);
public static WieldObject? ParseWieldObject(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
return new WieldObject(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
/// <summary>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).</summary>
public readonly record struct InventoryPutObjInContainer(
uint ItemGuid,
uint ContainerGuid,
uint Placement,
uint ContainerType);
public static InventoryPutObjInContainer? ParsePutObjInContainer(ReadOnlySpan<byte> 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)));
}
/// <summary>0x0196 ViewContents: full contents list of a container you opened.
/// Layout (ACE GameEventViewContents.cs): containerGuid, count, [guid, containerType]×count.
/// Client consumer: ClientUISystem::OnViewContents (PackableList&lt;ContentProfile&gt;).</summary>
public readonly record struct ViewContentsEntry(uint Guid, uint ContainerType);
public readonly record struct ViewContents(uint ContainerGuid, System.Collections.Generic.IReadOnlyList<ViewContentsEntry> Items);
public static ViewContents? ParseViewContents(ReadOnlySpan<byte> 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 ──────────────────────────────────────────
/// <summary>0x01C7 UseDone: the Use/UseWithTarget completion signal (WeenieError code).</summary>
public static uint? ParseUseDone(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x019A InventoryPutObjectIn3D: server dropped item to ground.</summary>
public static uint? ParsePutObjectIn3D(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x00A0 InventoryServerSaveFailed: revert a speculative local inventory op.
/// (itemGuid, weenieError) — ACE GameEventInventoryServerSaveFailed.cs; holtburger
/// events.rs:147 reads both fields.</summary>
public readonly record struct InventoryServerSaveFailed(uint ItemGuid, uint WeenieError);
public static InventoryServerSaveFailed? ParseInventoryServerSaveFailed(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
return new InventoryServerSaveFailed(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
/// <summary>0x0052 CloseGroundContainer: server closed a ground container view.</summary>
public static uint? ParseCloseGroundContainer(ReadOnlySpan<byte> 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.
/// <summary>0x01FD RegisterTrade: (initiator, partner, stamp). Retail
/// <c>Handle_Trade__Recv_RegisterTrade @ 0x0056E050</c>. 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).</summary>
public readonly record struct RegisterTrade(uint Initiator, uint Partner, ulong Stamp);
public static RegisterTrade? ParseRegisterTrade(ReadOnlySpan<byte> payload)
{
if (payload.Length < 16) return null;
return new RegisterTrade(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(8)));
}
/// <summary>0x01FF CloseTrade: end reason (Normal=1, EnteredCombat=2,
/// Canceled=0x51). Retail dispatch @ 0x006ACE90.</summary>
public static uint? ParseCloseTrade(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>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.</summary>
public readonly record struct AddToTrade(uint ItemGuid, uint Side, uint SlotIndex);
public static AddToTrade? ParseAddToTrade(ReadOnlySpan<byte> payload)
{
if (payload.Length < 12) return null;
return new AddToTrade(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)));
}
/// <summary>0x0201 RemoveFromTrade: (itemGuid, mode). Retail
/// <c>Handle_Trade__Recv_RemoveFromTrade @ 0x0056DC00</c>; ACE never
/// emits it (no per-item removal server-side) — parsed defensively.</summary>
public readonly record struct RemoveFromTrade(uint ItemGuid, uint Mode);
public static RemoveFromTrade? ParseRemoveFromTrade(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
return new RemoveFromTrade(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
/// <summary>0x0202 AcceptTrade: who accepted (the client compares
/// against its own guid for self-vs-partner — retail dispatch
/// @ 0x006ACDF0).</summary>
public static uint? ParseAcceptTrade(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x0203 DeclineTrade: who declined.</summary>
public static uint? ParseDeclineTrade(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x0205 ResetTrade: who reset. ACE clears BOTH sides'
/// staged items on either player's reset (lane B §quirks).</summary>
public static uint? ParseResetTrade(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x0207 TradeFailure: (itemGuid, WeenieError reason). Retail
/// <c>Handle_Trade__Recv_TradeFailure @ 0x0056D990</c> removes the item
/// locally before showing the notice.</summary>
public readonly record struct TradeFailure(uint ItemGuid, uint Reason);
public static TradeFailure? ParseTradeFailure(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
return new TradeFailure(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
/// <summary>
/// 0x0264 QueryItemManaResponse: (itemGuid, manaPercent, valid).
/// Retail anchor: <c>CM_Item::DispatchUI_QueryItemManaResponse @ 0x006A84D0</c>
/// reads the trailing 32-bit validity flag at message offset 0x0C.
/// </summary>
public readonly record struct QueryItemManaResponse(uint ItemGuid, float ManaPercent, bool Valid);
public static QueryItemManaResponse? ParseQueryItemManaResponse(ReadOnlySpan<byte> payload)
{
if (payload.Length < 12) return null;
return new QueryItemManaResponse(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(4)),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)) != 0);
}
/// <summary>
/// 0x0274 CharacterConfirmationRequest — server-driven modal confirm.
/// <see cref="Type"/> is a bare <c>uint</c>, not <see cref="ConfirmationType"/>
/// — this is the leg <c>GameplayConfirmationController.HandleRequest</c>
/// actually consumes in production today. See <see cref="ConfirmationType"/>'s
/// doc comment (blast review SF-5) for why the triple currently carries
/// its discriminator two different ways.
/// </summary>
public readonly record struct CharacterConfirmationRequest(
uint Type,
uint ContextId,
string Message);
public static CharacterConfirmationRequest? ParseCharacterConfirmationRequest(ReadOnlySpan<byte> 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; }
}
/// <summary>
/// 0x0276 CharacterConfirmationDone — server cancellation/completion of the
/// outstanding confirmation tuple. Retail dispatches the same type/context
/// pair to <c>RecvNotice_AbortConfirmationRequest</c>. <see cref="Type"/>
/// is a bare <c>uint</c>, not <see cref="ConfirmationType"/> — the leg
/// <c>GameplayConfirmationController.HandleDone</c> actually consumes;
/// see <see cref="ConfirmationType"/>'s doc comment (blast review SF-5).
/// </summary>
public readonly record struct CharacterConfirmationDone(uint Type, uint ContextId);
public static CharacterConfirmationDone? ParseCharacterConfirmationDone(
ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
return new CharacterConfirmationDone(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
/// <summary>
/// The <c>ConfirmationType</c> discriminator carried by all three legs
/// of the shared confirmation triple (<c>0x0274</c>/<c>0x0275</c>/
/// <c>0x0276</c>'s leading <c>Type</c>/<c>confirmationType</c> field).
/// Retail <c>Handle_Character__ConfirmationRequest @0x005640A0</c>'s
/// switch and ACE's <c>ConfirmationType.cs:5-12</c> agree byte-for-byte
/// (lane B §3.15, lane C §1.3). Campaign FA needs exactly
/// <see cref="SwearAllegiance"/> (1) and <see cref="Fellowship"/> (4)
/// — D6.
///
/// <para>
/// FA1 review round (blast SF-5): this enum currently types ONLY the
/// response-side leg (<see cref="ConfirmationResponse.Type"/>) —
/// <see cref="CharacterConfirmationRequest.Type"/> and
/// <see cref="CharacterConfirmationDone.Type"/>, the two legs
/// production actually reads today, remain bare <c>uint</c>. 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 <see cref="ConfirmationType"/> or treat this note as the standing
/// decision that the enum stays response-side only.
/// </para>
/// </summary>
public enum ConfirmationType : uint
{
SwearAllegiance = 1,
AlterSkill = 2,
AlterAttribute = 3,
Fellowship = 4,
CraftInteraction = 5,
Augmentation = 6,
YesNo = 7,
}
/// <summary>
/// <c>0x0275 ConfirmationResponse</c> — the CLIENT→SERVER leg of the
/// confirmation triple (<c>CM_Character::Event_ConfirmationResponse
/// @0x006A1210</c>, lane B §3.15 / lane C §3.3). Unlike
/// <see cref="CharacterConfirmationRequest"/>/<see cref="CharacterConfirmationDone"/>
/// this direction is never received by a real client — acdream already
/// builds it (<c>ClientCommandRequests.BuildConfirmationResponse</c>).
/// This record + parser exist to give the triple a complete, TYPED
/// representation in Core.Net (the <see cref="ConfirmationType"/>
/// enum, not a bare <c>uint</c>) and a round-trip conformance check —
/// see <c>ConfirmationTripleTests</c> for the golden-vector /
/// round-trip pair against <c>BuildConfirmationResponse</c>.
/// </summary>
public readonly record struct ConfirmationResponse(
ConfirmationType Type,
uint ContextId,
bool Accepted);
public static ConfirmationResponse? ParseConfirmationResponse(ReadOnlySpan<byte> 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.
/// <summary>
/// One retail <c>Fellow</c> record — <c>Fellow::Pack @0x005B9A10</c> /
/// <c>Fellow::UnPack @0x005B9AD0</c> (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 <c>0x02BE</c>, or a separate leading field in
/// <c>0x02C0</c>) — carried here as <see cref="Guid"/> for convenience
/// since both callers already have it in hand when they construct this
/// record. <see cref="ShareLoot"/> is the RAW wire u32, never a bool —
/// ACE encodes it two mutually-inconsistent ways (<c>0x10</c> in full
/// updates, <c>&lt;&lt;1</c> in incremental updates), so the only safe
/// read is <c>ShareLoot != 0</c>, never <c>== 1</c> (lane B §4.1, D5).
/// </summary>
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);
/// <summary>One entry of the <c>_fellows_departed</c> hash table (lane B §2.11/§3.9 field 8).</summary>
public readonly record struct FellowshipDepartedMember(uint Guid, int DepartedTimestamp);
/// <summary>
/// <c>0x02BE FellowshipFullUpdate</c> — <c>Fellowship::Pack</c>/
/// <c>UnPack @0x005B94F0</c> (lane B §3.9). Field 9 (the lock-name
/// table) is intentionally NOT parsed here: retail's own
/// <c>Fellowship::UnPack</c> stops reading after field 8 and never
/// consumes it (lane B §2.7 caution), and lane B's own U3 flags
/// <c>FellowshipLockData</c>'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.
/// </summary>
public readonly record struct FellowshipFullUpdate(
IReadOnlyList<FellowMember> Members,
string Name,
uint LeaderGuid,
bool ShareXp,
bool EvenXpSplit,
bool OpenFellow,
bool Locked,
IReadOnlyList<FellowshipDepartedMember> Departed);
public static FellowshipFullUpdate? ParseFellowshipFullUpdate(ReadOnlySpan<byte> 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<FellowMember>(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<FellowshipDepartedMember>(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; }
}
/// <summary>
/// <c>0x02C0 FellowshipUpdateFellow</c> —
/// <c>DispatchUI_UpdateFellow @0x006A6700</c> (lane B §3.10):
/// <c>[u32 guid][Fellow][u32 updateType]</c>, guid FIRST (Chorizite's
/// generated shape omits the guid — resolved retail-wins per lane B's
/// "Reference disagreement, resolved" note). <c>updateType</c>: 0
/// Undef, 1 Full, 2 UpdateStats (ACE never sends this), 3 UpdateVitals.
/// </summary>
public readonly record struct FellowshipUpdateFellow(
uint MemberGuid,
FellowMember Member,
uint UpdateType);
public static FellowshipUpdateFellow? ParseFellowshipUpdateFellow(ReadOnlySpan<byte> 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; }
}
/// <summary>
/// <c>0x00A3 FellowshipQuit</c>, the S→C direction —
/// <c>DispatchUI_Quit @0x006A5F5F</c> (lane B §3.12):
/// <c>[u32 quitterGuid]</c>. 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
/// <c>SocialActions.BuildFellowshipQuit</c> — a different payload
/// shape entirely, disambiguated here by record name.)
/// </summary>
public readonly record struct FellowshipQuitNotice(uint QuitterGuid);
public static FellowshipQuitNotice? ParseFellowshipQuit(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return new FellowshipQuitNotice(BinaryPrimitives.ReadUInt32LittleEndian(payload));
}
/// <summary>
/// <c>0x00A4 FellowshipDismiss</c>, the S→C direction —
/// <c>DispatchUI_Dismiss @0x006A5EC9</c> (lane B §3.13):
/// <c>[u32 dismissedGuid]</c>. ACE's own comment notes the same numeric
/// opcode value is used in both directions with different envelopes.
/// </summary>
public readonly record struct FellowshipDismissNotice(uint DismissedGuid);
public static FellowshipDismissNotice? ParseFellowshipDismiss(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return new FellowshipDismissNotice(BinaryPrimitives.ReadUInt32LittleEndian(payload));
}
/// <summary>
/// <c>0x02BF FellowshipDisband</c> — <c>DispatchUI_Disband
/// @0x006A5E80</c> 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.
/// </summary>
public static bool ParseFellowshipDisband(ReadOnlySpan<byte> payload) => true;
/// <summary>
/// <c>0x01C9 FellowshipFellowUpdateDone</c> — dead in the Sept-2013
/// client: both this and <see cref="FellowshipFellowStatsDone"/>
/// 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). <see cref="RawValue"/>
/// is surfaced only for diagnostics.
/// </summary>
public readonly record struct FellowshipFellowUpdateDone(uint? RawValue);
public static FellowshipFellowUpdateDone ParseFellowshipFellowUpdateDone(ReadOnlySpan<byte> payload)
=> new(payload.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(payload) : null);
/// <summary>
/// <c>0x01CA FellowshipFellowStatsDone</c> — same dead-COMDAT-fold
/// class as <see cref="FellowshipFellowUpdateDone"/> (lane B §2.7);
/// kept as a distinct record type to match the distinct
/// <see cref="GameEventType"/> id even though the shape is identical.
/// </summary>
public readonly record struct FellowshipFellowStatsDone(uint? RawValue);
public static FellowshipFellowStatsDone ParseFellowshipFellowStatsDone(ReadOnlySpan<byte> payload)
=> new(payload.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(payload) : null);
// ── Character titles (Campaign CT slice CT2, 2026-08-24) ────────────────
/// <summary>
/// <c>0x0029 CharacterTitle</c> — retail <c>CharacterTitleTable::UnPack
/// @0x005c6e90</c> (named-retail pseudo-C offset 471514-471526). The
/// FIRST u32 is advanced past but never stored into any field — retail's
/// own <c>CharacterTitleTable::Pack @0x005c6e40</c> (offset 471494-471510)
/// always writes the literal constant <c>1</c> there
/// (<c>**(uint32_t**)arg2 = 1</c>), and ACE's
/// <c>GameEventCharacterTitle.cs</c> matches with an unconditional
/// <c>Writer.Write(1u)</c> — a version/format tag retail itself discards
/// on read, not meaningful gameplay data (CT2 task item 1). Then the
/// current display title id (<c>mDisplayTitle</c>), then the
/// count-prefixed <c>PList&lt;uint&gt;</c> of every earned title id
/// (<c>mTitleList</c>).
/// </summary>
public readonly record struct CharacterTitleTable(
uint DisplayTitleId,
IReadOnlyList<uint> TitleIds);
public static CharacterTitleTable? ParseCharacterTitleTable(ReadOnlySpan<byte> 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<uint>::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; }
}
/// <summary>
/// <c>0x002B UpdateTitle</c> — retail's dispatch entry
/// <c>CM_Social::DispatchUI_AddOrSetCharacterTitle @0x006a54c0</c> reads
/// exactly <c>titleId</c> then <c>setAsDisplay</c> and forwards to
/// <c>ClientUISystem::Handle_Social__AddOrSetCharacterTitle
/// @0x00564260</c>, which ALWAYS broadcasts
/// <c>SendNotice_AddCharacterTitle(titleId)</c> (a title just earned is
/// unconditionally added to the earned set) and, only when
/// <c>setAsDisplay != 0</c>, ALSO broadcasts
/// <c>SendNotice_SetDisplayCharacterTitle(titleId)</c>. ACE's
/// <c>GameEventUpdateTitle.cs</c>: <c>u32 title, u32
/// setAsDisplayTitle</c> — matches exactly.
/// </summary>
public readonly record struct UpdateTitle(uint TitleId, bool SetAsDisplay);
public static UpdateTitle? ParseUpdateTitle(ReadOnlySpan<byte> 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<byte> 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<byte> 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<byte> 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.
/// <summary>
/// <c>0x027A AllegianceLoginNotification</c> —
/// <c>DispatchUI_AllegianceLoginNotificationEvent @0x006A6920</c> (lane
/// C §2 row 8, §4.5): <c>[u32 characterGuid][u32 isLoggedIn]</c>. 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.
/// </summary>
public readonly record struct AllegianceLoginNotification(uint CharacterGuid, bool IsLoggedIn);
public static AllegianceLoginNotification? ParseAllegianceLoginNotification(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
return new AllegianceLoginNotification(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)) != 0u);
}
/// <summary>
/// <c>0x01C8 AllegianceUpdateDone</c> — the panel busy-cursor
/// terminator (lane C §1.2, §2 row 6): a single <c>WeenieError</c>
/// u32 (0 on success).
/// </summary>
public static uint? ParseAllegianceUpdateDone(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>
/// <c>0x0003 AllegianceUpdateAborted</c> — declared by retail
/// (<c>DispatchUI_AllegianceUpdateAborted @0x006A6950</c>) but never
/// actually sent by ACE (lane C §2 row 7, §5.4) — a single
/// <c>WeenieError</c> u32, parsed for completeness/forward-compat.
/// </summary>
public static uint? ParseAllegianceUpdateAborted(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
// ── House ────────────────────────────────────────────────────────────────
/// <summary>
/// 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 <c>references/Chorizite.ACProtocol
/// /Chorizite.ACProtocol/Messages/S2C/Events/House_UpdateRestrictions
/// .generated.cs</c>: <c>byte Sequence, uint SenderId, RestrictionDB
/// Restrictions</c> — Sequence is a single unpadded byte, immediately
/// followed by the 4-byte SenderId (the house object whose restrictions
/// changed).
/// </summary>
public readonly record struct HouseUpdateRestrictions(
byte Sequence,
uint SenderId,
HouseRestrictionRecord Restrictions);
public static HouseUpdateRestrictions? ParseHouseUpdateRestrictions(ReadOnlySpan<byte> 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<uint, uint>((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).
/// <summary>One house purchase/maintenance line item. ACE
/// HousePaymentExtensions.Write: Num(int) + Paid(int) + WeenieID(uint) +
/// Name(String16L) + PluralName(String16L).</summary>
public readonly record struct HousePayment(
int Num, int Paid, uint WeenieID, string Name, string PluralName);
/// <summary>0x0225 HouseData: the owned-house panel snapshot. ACE
/// HouseDataExtensions.Write: BuyTime(uint) + RentTime(uint) +
/// Type(uint HouseType enum) + MaintenanceFree(uint bool) +
/// Buy(List&lt;HousePayment&gt;) + Rent(List&lt;HousePayment&gt;) +
/// Position (the same Cell+Pos.XYZ+Rotation.WXYZ 32-byte shape
/// <see cref="CreateObject.ServerPosition"/> already parses
/// elsewhere).</summary>
public readonly record struct HouseData(
uint BuyTime,
uint RentTime,
uint Type,
bool MaintenanceFree,
IReadOnlyList<HousePayment> Buy,
IReadOnlyList<HousePayment> Rent,
CreateObject.ServerPosition Position);
public static HouseData? ParseHouseData(ReadOnlySpan<byte> 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<HousePayment>? buy = ReadHousePaymentList(payload, ref pos);
if (buy is null) return null;
List<HousePayment>? 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<HousePayment>? ReadHousePaymentList(ReadOnlySpan<byte> payload, ref int pos)
{
if (payload.Length - pos < 4) return null;
uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
var list = new List<HousePayment>((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;
}
/// <summary>0x0226 HouseStatus: a single WeenieError u32 — retail's
/// <c>RecvNotice_FailedHouseTransaction</c> family (also the "no house
/// owned" reply to a HouseQuery — ACE Player_House.cs
/// HandleActionQueryHouse's <c>new GameEventHouseStatus(Session)</c>
/// defaults to <c>WeenieError.BadParam</c> (corrected 2026-08-17; an
/// earlier note here said <c>WeenieError.None</c>, which is not what
/// <c>GameEventHouseStatus</c>'s own constructor default reads). The
/// value is moot either way — decomp-confirmed retail's own
/// <c>gmHouseUI::Update(uint32_t)</c>/<c>gmMapUI::
/// RecvNotice_FailedHouseTransaction</c> never read this field
/// (<c>AcDream.Runtime.Gameplay.RuntimeHouseState.ApplyHouseStatus</c>
/// accepts and discards it for the same reason).</summary>
public static uint? ParseHouseStatus(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x0227 UpdateRentTime: a single uint (when the current
/// maintenance period began, Unix timestamp). ACE
/// GameEventHouseUpdateRentTime.cs is a STUB that always writes
/// <c>0u</c> — captured here for completeness, not exercised by any
/// live ACE install today.</summary>
public static uint? ParseUpdateRentTime(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x0228 UpdateRentPayment: a List&lt;HousePayment&gt; (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.</summary>
public static IReadOnlyList<HousePayment>? ParseUpdateRentPayment(ReadOnlySpan<byte> payload)
{
int pos = 0;
return ReadHousePaymentList(payload, ref pos);
}
// ── Shared string reader (matches LoginRequest.ReadString16L) ───────────
private static string ReadString16L(ReadOnlySpan<byte> 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;
}
}