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>
1022 lines
46 KiB
C#
1022 lines
46 KiB
C#
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<char> 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<ContentProfile>).</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><<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;
|
||
}
|
||
}
|