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>
1209 lines
56 KiB
C#
1209 lines
56 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);
|
||
|
||
// ── 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<uint></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<HousePayment>) + Rent(List<HousePayment>) +
|
||
/// 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<HousePayment> (the
|
||
/// rent items and how much of each has been paid this period). ACE
|
||
/// GameEventHouseUpdateRentPayment.cs is a STUB that always writes an
|
||
/// EMPTY list — captured here for completeness, not exercised by any
|
||
/// live ACE install today.</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;
|
||
}
|
||
}
|