acdream/src/AcDream.Core.Net/Messages/GameEvents.cs
Erik 067cbea8a5 feat: secure trade with other players - wire, RuntimeTradeState, the
authored gmSecureTradeUI window, and both retail open paths

Three-lane research first (docs/research/2026-08-14-trade-lane{A,B,C}):
retail gmSecureTradeUI decode, the byte-exact ACE/decomp/holtburger
three-way wire agreement, and the acdream seam map (which found both
open paths ALREADY classified by the ported policy - OpenSecureTrade on
Use-a-player, StartSecureTrade on drag-item-onto-player with the
DragItemOnPlayerOpensSecureTrade option - dead-ending at a stub toast).

- Core.Net: TradeRequests builders (0x1F6-0x204, retail's CM_Trade
  senders byte-checked against ACE's readers; the ACE-discarded
  AcceptTrade echo carries zero-count item lists - AD-94), corrected +
  completed inbound parsers (0x1FD-0x208; the old AddToTrade parser
  missed the SIDE dword, TradeFailure missed the reason), delegate-hole
  registrars, six WorldSession sends. 10 golden-byte tests.
- Runtime: RuntimeTradeState, the third sibling J-owner (fellowship/
  allegiance shape): session-scoped, clears at generation reset (new
  stage Trade=14), staged teardown stage 11 (Identity/EntityObjects
  shift 12/13, TeardownStageCount 14 - the FA2-era per-stage-flag test
  caught the mapping exactly as designed), combined ownership ledger,
  event routing with ACE's wrong-initiator RegisterTrade landmine
  honored (partner = whichever guid is not mine). 7 conformance tests.
- App: SecureTradeUiController binds the dedicated authored LayoutDesc
  0x2100000D (root 0x1000007A - gmSecureTradeUI::PostInit's exact ids):
  partner name/status/count/grid, the authored 'Trade' accept toggle
  (accept <-> decline withdraw), 'Clear All' (ACE clears BOTH sides -
  surfaced honestly), the X close, drop-on-your-grid staging, per-mode
  accept cues (partner icon's authored Highlight state + Trade button
  Selected latch). Mounted via the vendor recipe (nine-slice chrome,
  hidden until RegisterTrade). ItemInteractionController's two policy
  arms now raise SecureTradeRequested instead of the stub toast; the
  drag path queues the dragged item until the window registers
  (ClientTradeSystem::AttemptToTradeItem @0x0056DF80's shape).

Register: AD-94 (accept-echo zero-count lists), AD-95 (numeric-only
count texts pending template verification).

Suites: App 4,990/3, Core.Net 905, Runtime 1,626 - all green. The
panel itself is user-gate acceptance (two-client connected trade), the
#372-class lesson: fixture-green alone is not acceptance for a mount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 11:49:13 +02:00

1022 lines
46 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);
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));
}
// ── 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;
}
}