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