Applies both MUST-FIX items and the code-facing SHOULD-FIX items from the dual-lens FA1 review (docs/research/2026-08-12-fa1-review-mechanism.md, docs/research/2026-08-12-fa1-review-blast.md): Mechanism MF-1 / blast SF-2: AllegianceHierarchy::Add @0x005B6E90 wraps its entire body in `if (_id != 0)` -- a record whose own id is zero discards the WHOLE message, for both the monarch and a child record, and this is also what makes treeParent == 0 unconditionally fatal for a non-monarch record. ReadAllegianceProfileBody now rejects CharacterId == 0 on both paths; four new boundary tests in AllegianceProfileVersionGateTests.cs (zero-id monarch, zero-id child, zero treeParent, plus the existing orphan/self-parent/duplicate trio). Mechanism MF-2: added the missing 0x001F AllegianceUpdateRequest builder -- the structural twin of the fellowship 0x00A6 this slice already repaired -- with golden-vector tests for both on/off. Mechanism SF-1 / blast SF-3: UnPack's last act before returning success forces the monarch's MayPassupExperience to false regardless of the wire bit or the HasPackedLevel-absent legacy-compat fallback. Ported at the end of the record loop; the pre-existing HasPackedLevel-absent test moved off the monarch record (which the new clear makes indistinguishable from "the fallback never fired") onto a vassal record, and a new test proves the monarch clear fires even when the wire bit explicitly asks for true. Mechanism SF-2: removed ParseFellowshipDisband's invented body-length validation -- retail's DispatchUI_Disband reads only the opcode and never inspects a trailing body. The parser now always succeeds; the matching test flips from asserting rejection to asserting acceptance. Mechanism SF-3: added the D5 `<<1` shareLoot-shape test at the 0x02C0 FellowshipUpdateFellow site -- previously only pinned at 0x02BE, so a future split of the shared ReadFellow helper could silently reintroduce a bool read on this leg undetected. Mechanism SF-5: renumbered the version-gate comments in ReadAllegianceProfileBody to the true AllegianceVersion enum values (1-11, matching acclient.h's SpokespersonAdded..ApprovedVassal) instead of wire-appearance order, which only reached 10 and silently dropped gate 5 (BannedCharactersAdded, which is real but gates nothing in UnPack -- now called out explicitly). Fixed the stale "lane B §12" citation in SocialActions.cs to the actual master-table row. Blast SF-1: pinned the two retail-faithful but user-visible behavior changes FA1 made to the ALREADY-LIVE `@allegiance info` command -- reversed vassal print order (3-vassal test through FormatAllegianceInfoLines) and malformed-tree silent-drop (test at the GameEventWiring registration layer, which is `if (info is null) return;`). Blast SF-4: fixed a doc comment citing a nonexistent `ConfirmationResponseTests` class; the actual class is `ConfirmationTripleTests`. Blast SF-5: cross-referenced the confirmation-triple discriminator's split representation (ConfirmationType on the response leg only; bare uint on the two inbound legs production actually reads) at both sites, so FA4 inherits a stated decision rather than an unexplained inconsistency. Full Release suite: 13,158 passed / 4 skipped / 0 failed (13,162 total), up from the pre-fix-round 13,149/4/0 (+9 tests this round). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
480 lines
22 KiB
C#
480 lines
22 KiB
C#
using System;
|
|
using System.Buffers.Binary;
|
|
using System.Text;
|
|
using AcDream.Core.Items;
|
|
|
|
namespace AcDream.Core.Net.Messages;
|
|
|
|
/// <summary>
|
|
/// Outbound social + query GameActions. These share a common pattern:
|
|
/// single-target / single-parameter GameActions inside the <c>0xF7B1</c>
|
|
/// envelope.
|
|
///
|
|
/// <para>
|
|
/// Wire format:
|
|
/// <code>
|
|
/// u32 0xF7B1 envelope
|
|
/// u32 gameActionSequence
|
|
/// u32 subOpcode
|
|
/// <payload> per-action
|
|
/// </code>
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// References: r08 §3 rows for each opcode.
|
|
/// </para>
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// <c>PlayerModulePackHeader</c> bits retail's 2013 client ALWAYS sets
|
|
/// (<c>PlayerModule::SetPackHeader @0x005D44A0</c>, BYTE-VERIFIED — wire
|
|
/// research §2.2): <c>SpellLists8 (0x400)</c>, <c>SpellbookFilters
|
|
/// (0x020)</c>, <c>2ndCharacterOptions/Options2 (0x040)</c>. The other
|
|
/// unconditional bits from the same disassembly are OR'd in below when
|
|
/// their section is non-empty; <c>SquelchList (0x02)</c>,
|
|
/// <c>MultiSpellList (0x04)</c>, <c>ExtendedMultiSpellLists (0x10)</c>,
|
|
/// and <c>TimeStampFormat (0x80)</c> are NEVER set by the 2013 client and
|
|
/// never appear here; <c>GenericQualitiesData (0x100)</c> 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);
|
|
/// <c>GameplayOptions (0x200)</c> is omitted while acdream packs nothing
|
|
/// into <c>m_colGameplayOptions</c> (safe per §2.5 — the receiver leaves
|
|
/// its collection untouched when the flag is absent).
|
|
/// </summary>
|
|
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
|
|
|
|
/// <summary>Query a target's health — server replies with UpdateHealth (0x01C0).</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Query an owned item's mana fraction, or cancel the active query with item guid zero.
|
|
/// Retail anchor: <c>CM_Item::Event_QueryItemMana @ 0x006A8610</c>.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request the empty PingResponse (0x01EA). Retail
|
|
/// <c>CM_Character::Event_RequestPing @ 0x006A19A0</c> sends no payload.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a fellowship with a chosen name. There is no "openness"
|
|
/// field on this message — retail's <c>gmFellowshipUI::CreateFellowship
|
|
/// @0x0048F730</c> writes exactly <c>[str16L name][u32 shareXP]</c>
|
|
/// (lane B §2.2/§3.1). <paramref name="shareXp"/> is the
|
|
/// <c>FellowshipShareXP</c> character option (<c>0x0F</c>), not a
|
|
/// dialog checkbox — retail reads
|
|
/// <c>PlayerModule::FellowshipShareXP</c> at create time and sends its
|
|
/// current value.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Quit your current fellowship (optionally disband if leader).</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Dismiss a specific vassal from your fellowship.</summary>
|
|
public static byte[] BuildFellowshipDismiss(uint seq, uint targetGuid)
|
|
=> SingleGuid(seq, FellowshipDismissOpcode, targetGuid);
|
|
|
|
/// <summary>Recruit a target into your fellowship.</summary>
|
|
public static byte[] BuildFellowshipRecruit(uint seq, uint targetGuid)
|
|
=> SingleGuid(seq, FellowshipRecruitOpcode, targetGuid);
|
|
|
|
/// <summary>
|
|
/// Declare the fellowship panel's visibility to the server
|
|
/// (<c>0x00A6 FellowshipUpdateRequest</c>). This is NOT fellowship
|
|
/// openness — retail sends it from
|
|
/// <c>gmFellowshipUI::OnVisibilityChanged @0x0048E460</c> on panel
|
|
/// show/hide, and ACE gates the whole <c>0x02C0</c> member-vitals
|
|
/// stream on it (<c>Fellowship.cs:723</c>): a client that never sends
|
|
/// this sees names/levels/max-vitals from <c>0x02BE</c> but a roster
|
|
/// frozen at join time (lane B §4.5, master-table row 12 — "Panel
|
|
/// visibility → update request"). See
|
|
/// <see cref="BuildFellowshipChangeOpenness"/> for the real openness
|
|
/// toggle, which is a separate opcode this builder used to be
|
|
/// (incorrectly) named for.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Transfer fellowship leadership to another member
|
|
/// (<c>0x0290 FellowshipAssignNewLeader</c>). Retail's Quit button, when
|
|
/// pressed by the current leader, sends this FIRST (targeting
|
|
/// <c>Fellowship::GetNonLeaderFellowID</c>) and only then sends
|
|
/// <c>0x00A3</c> disband=0 —
|
|
/// <c>gmFellowshipUI</c> Quit button case 8 @0x0049034B (lane B
|
|
/// §2.5/§3.6). <c>AssignLeadershipToFellow @0x0048F1E0</c> is the
|
|
/// direct (non-quit) caller.
|
|
/// </summary>
|
|
public static byte[] BuildFellowshipAssignNewLeader(uint seq, uint newLeaderGuid)
|
|
=> SingleGuid(seq, FellowshipAssignNewLeaderOpcode, newLeaderGuid);
|
|
|
|
/// <summary>
|
|
/// Toggle fellowship open / closed recruiting — the REAL openness
|
|
/// action (<c>0x0291 FellowshipChangeOpenness</c>). Retail's Open
|
|
/// button (case 9) calls
|
|
/// <c>Event_ChangeFellowOpeness @0x006A6040</c> after pre-toggling its
|
|
/// own <c>_open_fellow</c> optimistically (lane B §2.5/§3.7) — the
|
|
/// bytes are identical in shape to the old (misnamed)
|
|
/// <see cref="BuildFellowshipUpdateRequest"/>, but this is a distinct
|
|
/// opcode acdream did not previously send at all.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Toggle one character option and push it to the server.
|
|
/// <c>GameActionSetSingleCharacterOption @ GameActionType 0x0005</c> —
|
|
/// the ONLY wire message that changes Turbine room membership: for the
|
|
/// <c>ListenTo*Chat</c> option ids, ACE's handler both flips the option
|
|
/// AND calls <c>JoinTurbineChatChannel</c>/<c>LeaveTurbineChatChannel</c>
|
|
/// (re-pushing <c>SetTurbineChatChannels</c>). Payload
|
|
/// <c>u32 option, u32 value</c> confirmed against ACE
|
|
/// (<c>GameActionSetSingleCharacterOption.cs:11-12</c>) and holtburger
|
|
/// (<c>SetSingleCharacterOptionActionData::pack</c>,
|
|
/// <c>messages/player/actions.rs:149-157</c>) — both agree byte-for-byte.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Flush the batched-option module: <c>SetCharacterOptions (0x01A1)</c>.
|
|
/// Body IS <c>PlayerModule::Pack @0x005D45C0</c> — wire research §2.3
|
|
/// field-by-field, exactly. The header is always
|
|
/// <see cref="PlayerModulePackHeaderBase"/> (<c>0x460</c>) OR'd with the
|
|
/// per-section bits below when that section is non-empty; ACE stores
|
|
/// <paramref name="options1"/>/<paramref name="options2"/> 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) — <b>never</b> invent zeros for
|
|
/// state this session actually has. <see cref="favoriteSpells"/> MUST
|
|
/// have exactly 8 entries, matching retail's unconditional
|
|
/// <c>favorite_spells_[8]</c> — an empty tab is a lone <c>u32 0</c>.
|
|
/// Never sets header bit <c>0x100</c> (GenericQualitiesData, U2 —
|
|
/// unresolved float sub-table shape) or <c>0x200</c> (GameplayOptions,
|
|
/// unpacked by acdream today — CH6f). Every field is a 4-byte-aligned
|
|
/// <c>u32</c>/record, so the trailing pad (§2.7) is always zero bytes in
|
|
/// practice, but the computation is still performed for exact fidelity
|
|
/// with <c>PlayerModule::Pack</c>'s own unconditional pad step.
|
|
/// </summary>
|
|
public static byte[] BuildSetCharacterOptions(
|
|
uint seq,
|
|
uint options1,
|
|
uint options2,
|
|
IReadOnlyList<ShortcutEntry> shortcuts,
|
|
IReadOnlyList<IReadOnlyList<uint>> favoriteSpells,
|
|
IReadOnlyDictionary<uint, uint> 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<uint> 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<K,V>::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<uint, uint> 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<IReadOnlyList<uint>> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The linear <c>PlayerOption</c> id space (a LINEAR enum, distinct from the
|
|
/// <c>CharacterOptions1</c>/<c>CharacterOptions2</c> BITFIELDS) — the first
|
|
/// <c>u32</c> of a <c>SetSingleCharacterOption (0x0005)</c> payload, and the
|
|
/// key into <see cref="AcDream.Runtime.Gameplay.CharacterOptionTable"/>.
|
|
/// Campaign OP slice OP1 (2026-08-10) widened this from the 6
|
|
/// <c>ListenTo*Chat</c> ids Campaign CH slice CH3 needed to the complete
|
|
/// <c>0x00..0x34</c> set, verbatim from <c>named-retail/acclient.h:4162</c>
|
|
/// (<c>enum PlayerOption</c>) — every member below is
|
|
/// <c><Name>_PlayerOption</c> there with its <c>_PlayerOption</c>
|
|
/// suffix dropped, EXCEPT the six pre-existing <c>ListenTo*Chat</c> members
|
|
/// (retail names them <c>Hear*Chat_PlayerOption</c>; kept as-is rather than
|
|
/// renamed, since every existing caller — <c>TurbineChatMembershipGate</c>,
|
|
/// its tests, the CH3/CH4 chat wiring — already spells them this way).
|
|
/// <c>HearPkDeathMessages</c> (<c>0x34</c>) is ACE-sourced, not present in
|
|
/// the 2013 PDB (the id was <c>TotalNumberOfPlayerOptions_PlayerOption</c>
|
|
/// there) — register row, wire research §8.1.
|
|
/// </summary>
|
|
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,
|
|
}
|