using System; using System.Buffers.Binary; using System.Text; using AcDream.Core.Items; namespace AcDream.Core.Net.Messages; /// /// Outbound social + query GameActions. These share a common pattern: /// single-target / single-parameter GameActions inside the 0xF7B1 /// envelope. /// /// /// Wire format: /// /// u32 0xF7B1 envelope /// u32 gameActionSequence /// u32 subOpcode /// <payload> per-action /// /// /// /// /// References: r08 §3 rows for each opcode. /// /// public static class SocialActions { public const uint GameActionEnvelope = 0xF7B1u; // Queries public const uint QueryHealthOpcode = 0x01BFu; // u32 targetGuid public const uint QueryItemManaOpcode = 0x0263u; // u32 itemGuid; zero cancels public const uint PingRequestOpcode = 0x01E9u; // no payload // Fellowship public const uint FellowshipCreateOpcode = 0x00A2u; // string16L name, bool openness, bool shareXP public const uint FellowshipQuitOpcode = 0x00A3u; // bool disband public const uint FellowshipDismissOpcode = 0x00A4u; // u32 guid public const uint FellowshipRecruitOpcode = 0x00A5u; // u32 guid public const uint FellowshipUpdateOpcode = 0x00A6u; // bool open // Character options // CH3 (2026-08-09): the full-blob SetCharacterOptions (0x01A1) builder // and the string-payload AddChannel/RemoveChannel (0x0145/0x0146) // builders were deleted here — none had a production caller, and all // three were malformed against ACE's real reader (research doc // 2026-08-09-chat-side-channels-vs-ace.md §3.8/§3.7/§5.5). Only // SetSingleCharacterOption (0x0005) — the message that actually // changes Turbine room membership — has a caller (WorldSession. // SendSetSingleCharacterOption), so only it is implemented. public const uint SetSingleCharacterOptionOpcode = 0x0005u; // u32 optionId, u32 value (0/1) // OP1 (Campaign OP, 2026-08-10): the real batched-option blob, resurrected // per docs/research/2026-08-10-set-character-options-wire.md §2.3-§2.7 — // NOT the malformed 16-byte CH3 builder this opcode used to name (deleted // 2026-08-09, post-mortem in that doc §6). Body IS // `PlayerModule::Pack @0x005D45C0`. public const uint SetCharacterOptionsOpcode = 0x01A1u; /// /// PlayerModulePackHeader bits retail's 2013 client ALWAYS sets /// (PlayerModule::SetPackHeader @0x005D44A0, BYTE-VERIFIED — wire /// research §2.2): SpellLists8 (0x400), SpellbookFilters /// (0x020), 2ndCharacterOptions/Options2 (0x040). The other /// unconditional bits from the same disassembly are OR'd in below when /// their section is non-empty; SquelchList (0x02), /// MultiSpellList (0x04), ExtendedMultiSpellLists (0x10), /// and TimeStampFormat (0x80) are NEVER set by the 2013 client and /// never appear here; GenericQualitiesData (0x100) is never set by /// acdream (wire research §2.4d U2 — float sub-table shape disputed /// between retail and ACE, unreachable if we never set it); /// GameplayOptions (0x200) is omitted while acdream packs nothing /// into m_colGameplayOptions (safe per §2.5 — the receiver leaves /// its collection untouched when the flag is absent). /// private const uint PlayerModulePackHeaderBase = 0x400u // PM_Packed_8_SpellLists | 0x020u // PM_Packed_SpellbookFilters | 0x040u; // PM_Packed_2ndCharacterOptions private const uint PlayerModulePackHeaderShortcuts = 0x001u; // PM_Packed_ShortCutManager private const uint PlayerModulePackHeaderDesiredComps = 0x008u; // PM_Packed_DesiredComps /// Query a target's health — server replies with UpdateHealth (0x01C0). public static byte[] BuildQueryHealth(uint seq, uint targetGuid) { byte[] body = new byte[16]; BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), QueryHealthOpcode); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid); return body; } /// /// Query an owned item's mana fraction, or cancel the active query with item guid zero. /// Retail anchor: CM_Item::Event_QueryItemMana @ 0x006A8610. /// public static byte[] BuildQueryItemMana(uint seq, uint itemGuid) { byte[] body = new byte[16]; BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), QueryItemManaOpcode); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid); return body; } /// /// Request the empty PingResponse (0x01EA). Retail /// CM_Character::Event_RequestPing @ 0x006A19A0 sends no payload. /// public static byte[] BuildPingRequest(uint seq) { byte[] body = new byte[12]; BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), PingRequestOpcode); return body; } /// Create a fellowship with a chosen name + options. public static byte[] BuildFellowshipCreate( uint seq, string fellowshipName, bool openness, bool shareXp) { byte[] name = PackString16L(fellowshipName); // 2 bools consume 2 bytes + alignment pad to 4. int boolBlock = 2; int pad = (4 - ((name.Length + boolBlock) & 3)) & 3; byte[] body = new byte[12 + name.Length + boolBlock + pad]; BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipCreateOpcode); Array.Copy(name, 0, body, 12, name.Length); body[12 + name.Length] = openness ? (byte)1 : (byte)0; body[12 + name.Length + 1] = shareXp ? (byte)1 : (byte)0; return body; } /// Quit your current fellowship (optionally disband if leader). public static byte[] BuildFellowshipQuit(uint seq, bool disband) { byte[] body = new byte[16]; // envelope + 1 byte bool aligned to 4 BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipQuitOpcode); body[12] = disband ? (byte)1 : (byte)0; return body; } /// Dismiss a specific vassal from your fellowship. public static byte[] BuildFellowshipDismiss(uint seq, uint targetGuid) => SingleGuid(seq, FellowshipDismissOpcode, targetGuid); /// Recruit a target into your fellowship. public static byte[] BuildFellowshipRecruit(uint seq, uint targetGuid) => SingleGuid(seq, FellowshipRecruitOpcode, targetGuid); /// Toggle fellowship open / closed recruiting. public static byte[] BuildFellowshipUpdate(uint seq, bool open) { byte[] body = new byte[16]; BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipUpdateOpcode); body[12] = open ? (byte)1 : (byte)0; return body; } /// /// Toggle one character option and push it to the server. /// GameActionSetSingleCharacterOption @ GameActionType 0x0005 — /// the ONLY wire message that changes Turbine room membership: for the /// ListenTo*Chat option ids, ACE's handler both flips the option /// AND calls JoinTurbineChatChannel/LeaveTurbineChatChannel /// (re-pushing SetTurbineChatChannels). Payload /// u32 option, u32 value confirmed against ACE /// (GameActionSetSingleCharacterOption.cs:11-12) and holtburger /// (SetSingleCharacterOptionActionData::pack, /// messages/player/actions.rs:149-157) — both agree byte-for-byte. /// public static byte[] BuildSetSingleCharacterOption(uint seq, uint optionId, bool value) { byte[] body = new byte[20]; BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SetSingleCharacterOptionOpcode); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), optionId); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), value ? 1u : 0u); return body; } /// /// Flush the batched-option module: SetCharacterOptions (0x01A1). /// Body IS PlayerModule::Pack @0x005D45C0 — wire research §2.3 /// field-by-field, exactly. The header is always /// (0x460) OR'd with the /// per-section bits below when that section is non-empty; ACE stores /// / and discards /// the four "TODO" sections (shortcuts, spell lists, desired comps, /// spellbook filters) into their own dedicated GameActions, but retail /// still packs them, so this builder echoes the caller's last-parsed /// values instead of zeroing them (§5.3) — never invent zeros for /// state this session actually has. MUST /// have exactly 8 entries, matching retail's unconditional /// favorite_spells_[8] — an empty tab is a lone u32 0. /// Never sets header bit 0x100 (GenericQualitiesData, U2 — /// unresolved float sub-table shape) or 0x200 (GameplayOptions, /// unpacked by acdream today — CH6f). Every field is a 4-byte-aligned /// u32/record, so the trailing pad (§2.7) is always zero bytes in /// practice, but the computation is still performed for exact fidelity /// with PlayerModule::Pack's own unconditional pad step. /// public static byte[] BuildSetCharacterOptions( uint seq, uint options1, uint options2, IReadOnlyList shortcuts, IReadOnlyList> favoriteSpells, IReadOnlyDictionary desiredComponents, uint spellbookFilters) { ArgumentNullException.ThrowIfNull(shortcuts); ArgumentNullException.ThrowIfNull(favoriteSpells); ArgumentNullException.ThrowIfNull(desiredComponents); if (favoriteSpells.Count != 8) { throw new ArgumentException( "Retail PlayerModule::Pack always emits exactly 8 favorite-spell lists (acclient.h:36507 favorite_spells_[8]).", nameof(favoriteSpells)); } uint header = PlayerModulePackHeaderBase; if (shortcuts.Count > 0) header |= PlayerModulePackHeaderShortcuts; if (desiredComponents.Count > 0) header |= PlayerModulePackHeaderDesiredComps; int payloadSize = 4 // header + 4 // options1 + (shortcuts.Count > 0 ? 4 + 12 * shortcuts.Count : 0) + FavoriteSpellsPackSize(favoriteSpells) + (desiredComponents.Count > 0 ? 4 + 8 * desiredComponents.Count : 0) + 4 // spellbookFilters + 4; // options2 int pad = (4 - (payloadSize & 3)) & 3; byte[] body = new byte[12 + payloadSize + pad]; int p = 0; WriteU32(body, ref p, GameActionEnvelope); WriteU32(body, ref p, seq); WriteU32(body, ref p, SetCharacterOptionsOpcode); WriteU32(body, ref p, header); WriteU32(body, ref p, options1); if (shortcuts.Count > 0) { WriteU32(body, ref p, (uint)shortcuts.Count); foreach (ShortcutEntry entry in shortcuts) { WriteI32(body, ref p, entry.Index); WriteU32(body, ref p, entry.ObjectId); WriteU32(body, ref p, entry.SpellId); } } for (int tab = 0; tab < 8; tab++) { IReadOnlyList list = favoriteSpells[tab]; int count = list?.Count ?? 0; WriteU32(body, ref p, (uint)count); for (int i = 0; i < count; i++) WriteU32(body, ref p, list![i]); } if (desiredComponents.Count > 0) { // PackableHashTable::Pack @0x005692B0: sizeInfo = (tableSize // << 16) | count. ACE (and acdream's own inbound parser) only // reads the low 16 bits; the advisory high half is left zero. WriteU32(body, ref p, (uint)desiredComponents.Count); foreach (KeyValuePair kvp in desiredComponents) { WriteU32(body, ref p, kvp.Key); WriteU32(body, ref p, kvp.Value); } } WriteU32(body, ref p, spellbookFilters); WriteU32(body, ref p, options2); // Tail pad bytes are already zero from `new byte[]`; nothing to write. return body; } private static int FavoriteSpellsPackSize( IReadOnlyList> favoriteSpells) { int size = 0; for (int tab = 0; tab < 8; tab++) size += 4 + 4 * (favoriteSpells[tab]?.Count ?? 0); return size; } private static void WriteU32(byte[] dest, ref int pos, uint value) { BinaryPrimitives.WriteUInt32LittleEndian(dest.AsSpan(pos), value); pos += 4; } private static void WriteI32(byte[] dest, ref int pos, int value) { BinaryPrimitives.WriteInt32LittleEndian(dest.AsSpan(pos), value); pos += 4; } // ── Helpers ────────────────────────────────────────────────────────────── private static byte[] SingleGuid(uint seq, uint sub, uint guid) { byte[] body = new byte[16]; BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), sub); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), guid); return body; } private static byte[] PackString16L(string s) { ArgumentNullException.ThrowIfNull(s); // Windows-1252 matches retail (and holtburger's encoding_rs::WINDOWS_1252). byte[] data = Encoding.GetEncoding(1252).GetBytes(s); if (data.Length > ushort.MaxValue) throw new ArgumentException("String too long for 16-bit length prefix.", nameof(s)); int recordSize = 2 + data.Length; int padding = (4 - (recordSize & 3)) & 3; byte[] result = new byte[recordSize + padding]; BinaryPrimitives.WriteUInt16LittleEndian(result, (ushort)data.Length); Array.Copy(data, 0, result, 2, data.Length); return result; } } /// /// The linear PlayerOption id space (a LINEAR enum, distinct from the /// CharacterOptions1/CharacterOptions2 BITFIELDS) — the first /// u32 of a SetSingleCharacterOption (0x0005) payload, and the /// key into . /// Campaign OP slice OP1 (2026-08-10) widened this from the 6 /// ListenTo*Chat ids Campaign CH slice CH3 needed to the complete /// 0x00..0x34 set, verbatim from named-retail/acclient.h:4162 /// (enum PlayerOption) — every member below is /// <Name>_PlayerOption there with its _PlayerOption /// suffix dropped, EXCEPT the six pre-existing ListenTo*Chat members /// (retail names them Hear*Chat_PlayerOption; kept as-is rather than /// renamed, since every existing caller — TurbineChatMembershipGate, /// its tests, the CH3/CH4 chat wiring — already spells them this way). /// HearPkDeathMessages (0x34) is ACE-sourced, not present in /// the 2013 PDB (the id was TotalNumberOfPlayerOptions_PlayerOption /// there) — register row, wire research §8.1. /// public enum CharacterOptionId : uint { AutoRepeatAttack = 0x00, IgnoreAllegianceRequests = 0x01, IgnoreFellowshipRequests = 0x02, IgnoreTradeRequests = 0x03, DisableMostWeatherEffects = 0x04, PersistentAtDay = 0x05, AllowGive = 0x06, ViewCombatTarget = 0x07, ShowTooltips = 0x08, UseDeception = 0x09, ToggleRun = 0x0A, StayInChatMode = 0x0B, AdvancedCombatUI = 0x0C, AutoTarget = 0x0D, VividTargetingIndicator = 0x0E, FellowshipShareXP = 0x0F, AcceptLootPermits = 0x10, FellowshipShareLoot = 0x11, FellowshipAutoAcceptRequests = 0x12, SideBySideVitals = 0x13, CoordinatesOnRadar = 0x14, SpellDuration = 0x15, DisableHouseRestrictionEffects = 0x16, DragItemOnPlayerOpensSecureTrade = 0x17, DisplayAllegianceLogonNotifications = 0x18, UseChargeAttack = 0x19, UseCraftSuccessDialog = 0x1A, ListenToAllegianceChat = 0x1B, DisplayDateOfBirth = 0x1C, DisplayAge = 0x1D, DisplayChessRank = 0x1E, DisplayFishingSkill = 0x1F, DisplayNumberDeaths = 0x20, DisplayTimeStamps = 0x21, SalvageMultiple = 0x22, ListenToGeneralChat = 0x23, ListenToTradeChat = 0x24, ListenToLFGChat = 0x25, ListenToRoleplayChat = 0x26, AppearOffline = 0x27, DisplayNumberCharacterTitles = 0x28, MainPackPreferred = 0x29, LeadMissileTargets = 0x2A, UseFastMissiles = 0x2B, FilterLanguage = 0x2C, ConfirmVolatileRareUse = 0x2D, ListenToSocietyChat = 0x2E, ShowHelm = 0x2F, DisableDistanceFog = 0x30, UseMouseTurning = 0x31, ShowCloak = 0x32, LockUI = 0x33, HearPkDeathMessages = 0x34, }