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 — field orders byte-verified against
// docs/research/2026-08-11-fa-fellowship-wire.md §3.1-§3.7 (lane B),
// three-way agreed with ACE + holtburger there. Campaign FA slice FA1
// (2026-08-11) repaired two latent defects here (lane B §5.2): (a)
// FellowshipCreateOpcode's body never had an "openness" field — the
// prior builder invented one and silently sent it as the low byte of
// shareXP, which ACE would read as an INVERTED shareXP value; (b)
// FellowshipUpdateOpcode (0x00A6) is FellowshipUpdateRequest — panel
// VISIBILITY, not fellowship openness (that's the new 0x0291 below) —
// the prior "open" parameter name/doc was wrong even though its single
// bool byte happened to be in the right wire position.
public const uint FellowshipCreateOpcode = 0x00A2u; // string16L name, u32 shareXP
public const uint FellowshipQuitOpcode = 0x00A3u; // u32 disband (0/1)
public const uint FellowshipDismissOpcode = 0x00A4u; // u32 guid
public const uint FellowshipRecruitOpcode = 0x00A5u; // u32 guid
public const uint FellowshipUpdateRequestOpcode = 0x00A6u; // u32 panelOpen (0/1) — panel visibility, NOT openness
public const uint FellowshipAssignNewLeaderOpcode = 0x0290u; // u32 newLeaderGuid
public const uint FellowshipChangeOpennessOpcode = 0x0291u; // u32 isOpen (0/1) — the REAL openness toggle
// 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. There is no "openness"
/// field on this message — retail's gmFellowshipUI::CreateFellowship
/// @0x0048F730 writes exactly [str16L name][u32 shareXP]
/// (lane B §2.2/§3.1). is the
/// FellowshipShareXP character option (0x0F), not a
/// dialog checkbox — retail reads
/// PlayerModule::FellowshipShareXP at create time and sends its
/// current value.
///
public static byte[] BuildFellowshipCreate(uint seq, string fellowshipName, bool shareXp)
{
byte[] name = PackString16L(fellowshipName);
byte[] body = new byte[12 + name.Length + 4];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipCreateOpcode);
Array.Copy(name, 0, body, 12, name.Length);
BinaryPrimitives.WriteUInt32LittleEndian(
body.AsSpan(12 + name.Length), shareXp ? 1u : 0u);
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);
///
/// Declare the fellowship panel's visibility to the server
/// (0x00A6 FellowshipUpdateRequest). This is NOT fellowship
/// openness — retail sends it from
/// gmFellowshipUI::OnVisibilityChanged @0x0048E460 on panel
/// show/hide, and ACE gates the whole 0x02C0 member-vitals
/// stream on it (Fellowship.cs:723): a client that never sends
/// this sees names/levels/max-vitals from 0x02BE but a roster
/// frozen at join time (lane B §4.5, master-table row 12 — "Panel
/// visibility → update request"). See
/// for the real openness
/// toggle, which is a separate opcode this builder used to be
/// (incorrectly) named for.
///
public static byte[] BuildFellowshipUpdateRequest(uint seq, bool panelOpen)
{
byte[] body = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipUpdateRequestOpcode);
body[12] = panelOpen ? (byte)1 : (byte)0;
return body;
}
///
/// Transfer fellowship leadership to another member
/// (0x0290 FellowshipAssignNewLeader). Retail's Quit button, when
/// pressed by the current leader, sends this FIRST (targeting
/// Fellowship::GetNonLeaderFellowID) and only then sends
/// 0x00A3 disband=0 —
/// gmFellowshipUI Quit button case 8 @0x0049034B (lane B
/// §2.5/§3.6). AssignLeadershipToFellow @0x0048F1E0 is the
/// direct (non-quit) caller.
///
public static byte[] BuildFellowshipAssignNewLeader(uint seq, uint newLeaderGuid)
=> SingleGuid(seq, FellowshipAssignNewLeaderOpcode, newLeaderGuid);
///
/// Toggle fellowship open / closed recruiting — the REAL openness
/// action (0x0291 FellowshipChangeOpenness). Retail's Open
/// button (case 9) calls
/// Event_ChangeFellowOpeness @0x006A6040 after pre-toggling its
/// own _open_fellow optimistically (lane B §2.5/§3.7) — the
/// bytes are identical in shape to the old (misnamed)
/// , but this is a distinct
/// opcode acdream did not previously send at all.
///
public static byte[] BuildFellowshipChangeOpenness(uint seq, bool isOpen)
{
byte[] body = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipChangeOpennessOpcode);
body[12] = isOpen ? (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,
}