acdream/docs/research/2026-08-10-set-character-options-wire.md
Erik cf6ef8b4b4 docs: Campaign OP research — character option map (lane B) + 0x01A1 wire (lane C)
Two of the four Opus research lanes for the retail Options panel campaign
(docs/research/2026-08-10-settings-track-handoff.md):

- Lane B: the complete Character-tab option map. 50 rows / 6 groups
  decomp-authored from gmCharacterSettingsUI::InitOptions @0x004a02f0
  (the screenshots' PK-death row is 2015-client-only; the 2013 enum caps
  at 0x33). Wire routing is retail's byte-verified lookup table
  CPlayerModule::IsAutoSaveOption @0x0059a600 — ~21 ids send 0x0005
  immediately, the rest dirty the module for the batched 0x01A1. Retail's
  Defaults-button table reconstructs Options1 = 0x50C4A54A exactly
  (independent confirmation of ACE's constant) and Options2 = 0x00008700
  vs ACE creation's 0x00948700 (a real client-vs-server distinction, not
  a bug). Per-option ACE handling + acdream consumer inventory included.

- Lane C: the real 0x01A1 body is PlayerModule::Pack @0x005D45C0
  (builder CM_Character::Event_CharacterOptionsEvent @0x006A10C0), flag
  enum PlayerModulePackHeader verbatim at acclient.h:7835;
  SetPackHeader @0x005D44A0 always sets 0x460 and never 0x02/0x04/0x10/
  0x80, so ACE's extra reader branches are dead legacy. Flush triggers:
  Apply, logout, 480 s autosave. ACE stores options words raw, discards
  the rest, refuses only pre-LoginComplete; unknown option ids THROW.
  CH3 post-mortem: the deleted 16-byte builder put a CharacterOptions1
  word in the section-flag slot.

Both lanes independently converged on the same latent defect: the
headless DirectGameRuntimeCommandAdapter.SetSingleOption sends the wire
but skips the local Options.SetOptionBit write the graphical path does
(LiveSessionRuntimeFactory.cs:348) — the CH4 stale-membership-gate bug
class reproduced on the bot side. Flagged for the campaign plan, not
fixed here (research-only lanes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 22:37:46 +02:00

51 KiB
Raw Blame History

SetCharacterOptions (0x01A1) — the real blob, the wire policy, ACE's acceptance, and the CH3 post-mortem

Research lane C of the settings-track campaign (retail four-tab Options panel). Answers handoff question Q4 in full and the ACE-acceptance tail of Q8 (docs/research/2026-08-10-settings-track-handoff.md).

Companions: claude-memory/project_chat_digest.md, docs/research/2026-08-09-chat-retail-window-shell.md §4 (the per-window GameplayOptions structure — not re-derived here), docs/research/2026-08-09-chat-side-channels-vs-ace.md §3.8 / §5.5 (the CH3 defect note this doc supersedes with the real layout).

Verification legend used throughout

marker meaning
BYTE-VERIFIED read out of the PDB-paired binary C:\Users\erikn\Downloads\acclient.exe (check_exe_pdb.py=== MATCH ===, GUID 9e847e2f-777c-4bd9-886c-22256bb87f32, linker 2013-09-06). Disassembly quoted.
PDB-TYPE verbatim from docs/research/named-retail/acclient.h (IDA-exported PDB type info, not BN inference).
BN Binary Ninja pseudo-C only. Cross-checked against ACE/holtburger where possible.
ACE references/ACE source (the server acdream actually talks to).
UNKNOWN / UNVERIFIED explicitly not established. Never guessed.

0. TL;DR for the impatient

  1. The 0x01A1 body is PlayerModule::Pack — the whole player-module object, not an options word. Four unconditional u32s (header, options1, spellbook filters, options2) plus five flag-gated sub-records, in a specific order. ACE's reader and retail's writer agree exactly once you account for three legacy branches retail 2013 never emits.
  2. Retail's send decision is a byte-verified 52-entry lookup table, CPlayerModule::IsAutoSaveOption @0x0059A600: 21 of the 52 PlayerOption ids send 0x0005 immediately and never dirty the module; the other 31 (plus every gameplay/window option) only mark the module dirty and ride the 0x01A1 blob out on Apply, logout, or an 8-minute (480.0 s, BYTE-VERIFIED) auto-save timer.
  3. ACE validates nothing. It clamps nothing. It raw-stores options1, options2 and the opaque GameplayOptions byte[] and discards every other section of the blob (each has its own dedicated GameAction). Its only refusal is 0x01A1 before LoginComplete (0x00A1) — silently dropped with a log warning. 0x0005 has no gate at all.
  4. The deleted CH3 builder was a 16-byte message with one u32 where ACE expects a section-flag word followed by ~40+ bytes. It could not have worked; the "0x40 collision" is the sharpest symptom of a whole-word slot error.
  5. A resurrected builder plugs into SocialActionsWorldSession.SendSetCharacterOptionsIRuntimeCharacterCommands (a new sibling of SetSingleOption), with the local-write-then-notify pattern already proven by RuntimeCharacterOptionsState.SetOptionBit.
  6. Defect found in passing: the headless command path (DirectGameRuntimeCommandAdapter.SetSingleOption) omits the local option write that the graphical path performs — see §7.4. This is directly load-bearing for the bot design in Q8.

1. The message envelope

Retail builder — CM_Character::Event_CharacterOptionsEvent @0x006A10C0 (BN, cross-checked against the sibling builders in the same block):

006a10c6  eax     = Proto_UI::GetNextUICounter()
006a10ee  eax_1   = playerModule->vtable->Pack(&null, 0)      // MEASURE pass, returns size
006a1108  buf     = operator new[](eax_1 + 0xc)               // 0xc = order header + opcode
006a1120  OrderHdr::Pack(&hdr, &p, eax_1 + 0xc)               // 8 bytes
006a1129  *(u32*)p = 0x1a1                                    // the GameAction opcode
006a113e  p += 4
006a114a  playerModule->vtable->Pack(&p, remaining)           // WRITE pass
006a1154  Proto_UI::SendToWeenie(buf, eax_1 + 0xc)

The 0xc prologue is the same 12-byte envelope every other acdream SocialActions builder writes:

u32 0xF7B1                 GameAction envelope
u32 gameActionSequence
u32 0x000001A1             SetCharacterOptions
<PlayerModule::Pack …>     everything below

Sanity anchor from the same code block: Event_LoginCompleteNotification allocates exactly 0xc for an empty payload and writes opcode 0xa1 (BN, 0x006A1480) — so envelope = 12 bytes, payload starts at offset 12. Event_PlayerOptionChangedEvent allocates 0x14 = 12 + 4 + 4, matching acdream's existing 20-byte BuildSetSingleCharacterOption (src/AcDream.Core.Net/Messages/SocialActions.cs:151-160).


2. The blob body — PlayerModule::Pack @0x005D45C0

2.1 The object being packed (PDB-TYPE, acclient.h:36507)

struct __cppobj PlayerModule : PackObj
{
  ShortCutManager                                    *shortcuts_;
  PackableList<unsigned long>                         favorite_spells_[8];
  PackableHashTable<IDClass<_tagDataID,32,0>,long>   *desired_comps_;
  unsigned int                                        options_;      // CharacterOptions1
  unsigned int                                        options2_;     // CharacterOptions2
  unsigned int                                        spell_filters_;
  GenericQualitiesData                               *m_pPlayerOptionsData;
  PackObjPropertyCollection                           m_colGameplayOptions;
  AC1Legacy::PStringBase<char>                        m_TimeStampFormat;
};

Declaration order is not wire order. Wire order is §2.3.

2.2 The header flag word (PDB-TYPE, acclient.h:7835)

enum PlayerModulePackHeader
{
  PM_Packed_None                    = 0x0,
  PM_Packed_ShortCutManager         = 0x1,
  PM_Packed_SquelchList             = 0x2,
  PM_Packed_MultiSpellLists         = 0x4,
  PM_Packed_DesiredComps            = 0x8,
  PM_Packed_ExtendedMultiSpellLists = 0x10,
  PM_Packed_SpellbookFilters        = 0x20,
  PM_Packed_2ndCharacterOptions     = 0x40,
  PM_Packed_TimeStampFormat         = 0x80,
  PM_Packed_GenericQualitiesData    = 0x100,
  PM_Packed_GameplayOptions         = 0x200,
  PM_Packed_8_SpellLists            = 0x400,
};

This is the correct enum. It matches ACE's CharacterOptionDataFlag (references/ACE/Source/ACE.Entity/Enum/CharacterOptionDataFlag.cs) value for value, and matches acdream's existing inbound copy at src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:184-198. The only naming differences are cosmetic (SpellLists8 vs PM_Packed_8_SpellLists, CharacterOptions2 vs PM_Packed_2ndCharacterOptions).

What retail actually setsPlayerModule::SetPackHeader @0x005D44A0, BYTE-VERIFIED:

8b 41 04           mov  eax,[ecx+4]              ; shortcuts_
85 c0              test eax,eax
8b 44 24 04        mov  eax,[esp+4]
74 03              je   +3
83 08 01           or   dword [eax], 1           ; ShortCutManager        (conditional)
8b 10              mov  edx,[eax]
81 ca 00 04 00 00  or   edx, 0x400               ; 8_SpellLists           (ALWAYS)
56                 push esi
89 10              mov  [eax],edx
8b b1 88 00 00 00  mov  esi,[ecx+0x88]           ; desired_comps_
85 f6              test esi,esi
74 05              je   +5
83 ca 08           or   edx, 8                   ; DesiredComps           (conditional)
89 10              mov  [eax],edx
8b 10              mov  edx,[eax]
83 ca 60           or   edx, 0x60                ; SpellbookFilters |
89 10              mov  [eax],edx                ;   2ndCharacterOptions  (ALWAYS)
8b b1 98 00 00 00  mov  esi,[ecx+0x98]           ; m_pPlayerOptionsData
85 f6              test esi,esi
5e                 pop  esi
74 08              je   +8
81 ca 00 01 00 00  or   edx, 0x100               ; GenericQualitiesData   (conditional)
89 10              mov  [eax],edx
8b 91 9c 01 00 00  mov  edx,[ecx+0x19c]          ; m_colGameplayOptions … m_numElements
85 d2              test edx,edx
74 06              je   +6
81 08 00 02 00 00  or   dword [eax], 0x200       ; GameplayOptions        (conditional)
c2 04 00           ret  4

So retail's header is always at least 0x460 (8_SpellLists | SpellbookFilters | 2ndCharacterOptions), OR'd with 0x01 / 0x08 / 0x100 / 0x200 when the corresponding member is populated.

0x02 SquelchList, 0x04 MultiSpellLists, 0x10 ExtendedMultiSpellLists and 0x80 TimeStampFormat are NEVER set by the 2013 client. ACE's own comment ("SquelchList doesn't get used by the client, so should never be set", GameActionSetCharacterOptions.cs:138) agrees for 0x02, and this disassembly proves the other three. 0x04/0x10 are the pre-8-tab spell-bar formats; 0x80 is superseded by the timestamp string living inside GenericQualitiesData (see §2.5).

2.3 Field-by-field wire layout

PlayerModule::Pack @0x005D45C0 (BN, prologue BYTE-VERIFIED — see below the table). Read top to bottom; every offset is 4-byte aligned.

# field present when encoding
1 header always u32 PlayerModulePackHeader (§2.2)
2 options_ always u32 CharacterOptions1 bitfield
3 shortcuts_ header & 0x001 ShortCutManager::Pack — §2.4a
4 favorite_spells_[0..7] always 8 lists, flagged 0x400 8 × PackableList<u32>::Pack — §2.4b
5 desired_comps_ header & 0x008 PackableHashTable<DataID,i32>::Pack — §2.4c
6 spell_filters_ always (0x020 always set) u32 spellbook filter bitfield
7 options2_ always (0x040 always set) u32 CharacterOptions2 bitfield
8 m_pPlayerOptionsData header & 0x100 GenericQualitiesData::Pack — §2.4d
9 m_colGameplayOptions header & 0x200 PackObjPropertyCollection::Pack — §2.4e / §4
10 tail padding always zero bytes to the next 4-byte boundary

BYTE-VERIFIED prologue (0x005D45C0, first 0x78 bytes), showing fields 1, 2 and the entry into field 4:

8b 07 ff 50 08            mov eax,[edi]; call [eax+8]      ; GetPackSize()
3b e8 … 0f 82 f8 00 00 00 jb  bail-out (buffer too small, return size)
e8 b1 fe ff ff            call PlayerModule::SetPackHeader
8b 44 24 18 / 89 02       mov [edx], header                ; FIELD 1
8b 16 83 c2 04 89 16      p += 4
8b 8f 8c 00 00 00 89 08   mov [p], [edi+0x8c]  = options_  ; FIELD 2
83 06 04                  p += 4
8b 4f 04 85 c9 74 07 …    if (shortcuts_) shortcuts_->Pack ; FIELD 3
8d 5f 08                  lea ebx,[edi+8]      = &favorite_spells_[0]
c7 44 24 1c 08 00 00 00   loop counter = 8                 ; FIELD 4 × 8

PlayerModule::GetPackSize @0x005D4500 opens with be 10 00 00 00 (mov esi, 0x10) — BYTE-VERIFIED 16 bytes of unconditional payload, i.e. exactly fields 1, 2, 6 and 7. That is an independent proof that no other field is unconditional.

2.4 Sub-record encodings

(a) ShortCutManager::Pack @0x005D5710 (BN; struct PDB-TYPE acclient.h:36484-36495)

u32  count            // number of NON-NULL slots, NOT the array length
repeat count times:
  i32 index_          // 0..17  (ShortCutManager holds shortCuts_[18])
  u32 objectID_
  u32 spellID_

pack_size = 4 + 12 × count. ACE reads exactly this (GameActionSetCharacterOptions.cs:51-60).

(b) PackableList<unsigned long>::Pack @0x0048B710 (BN)

u32 curNum
repeat curNum times: u32 element

pack_size = 4 + 4 × curNum. All eight favorite_spells_ lists use this; an empty tab is a lone u32 0.

(c) PackableHashTable<K,V>::Pack @0x005692B0 (BN)

u32 sizeInfo = (_table_size << 16) | _currNum
repeat (_currNum) times:   u32 key, u32 value

ACE reads num = sizeInfo & 0xFFFF (GameActionSetCharacterOptions.cs:109-114) — agrees. Iteration order is bucket order, i.e. unspecified for a builder's purposes; the receiver is a hash table too, so order does not matter. _table_size in the high half is advisory (ACE ignores it); acdream's own inbound parser also ignores it (PlayerDescriptionParser.cs:414-415, where it reads it as u16 count + u16 discard — the same 4 bytes read the other way round, which is equivalent on little-endian for counts < 65536).

(d) GenericQualitiesData::Pack @0x006B78F0 (BN — note BN mislabels the size helper as CEnchantmentRegistry::pack_size, a COMDAT-folding artifact, not a real call into the enchantment registry)

u32 header:  0x1 = int table, 0x2 = bool table, 0x4 = float table, 0x8 = string table
[if 0x1] PackableHashTable<u32,i32>       (u32 key, i32 value)
[if 0x2] PackableHashTable<u32,i32>       (u32 key, i32 value)
[if 0x4] PackableHashTable<u32,double>    (u32 key, PStringChar?)   ← see caution
[if 0x8] PackableHashTable<u32,PString>   (u32 key, string)

Caution / divergence: ACE's reader for the float sub-table reads u32 key then a ReadString16L (GameActionSetCharacterOptions.cs:163-172), while the retail member is PackableHashTable<unsigned long,double>. One of the two is wrong. Since retail never sets 0x100 unless m_pPlayerOptionsData != 0, and the only thing the 2013 client ever puts in it is the timestamp format string (key 1, see §2.5), the float table is almost certainly always absent in practice and the discrepancy is unreachable. Recommendation: never set 0x100 from acdream. Marked UNRESOLVED — do not build on either reading.

(e) PackObjPropertyCollection::Pack — see §4. Opaque byte run.

2.5 Absent-section defaults (from PlayerModule::UnPack @0x005D49D0, BN)

The unpacker is the authority on what "flag not set" means:

flag absent resulting state
0x020 SpellbookFilters spell_filters_ = 0x3FFF
0x040 2ndCharacterOptions options2_ = 0x00948700
0x001 ShortCutManager existing shortcuts_ destroyed (set to null)
0x008 DesiredComps existing desired_comps_ destroyed
0x100 GenericQualitiesData existing m_pPlayerOptionsData destroyed
0x200 GameplayOptions collection left untouched (no destroy branch)

favorite_spells_[0] is unpacked unconditionally; then exactly one of 0x04 → 4 more lists, 0x10 → 6 more, 0x400 → 7 more (an if/else-if chain, so a sender that sets two of them gets only the first). Retail always takes the 0x400 branch → 1 + 7 = 8 lists.

When 0x100 is present, UnPack additionally does GenericQualitiesData::InqString(m_pPlayerOptionsData, 1, &m_TimeStampFormat)the timestamp format string is string-key 1 inside GenericQualitiesData, which is why PM_Packed_TimeStampFormat (0x80) is dead in the 2013 client. The constructor default is "%#H:%M:%S " (PlayerModule::PlayerModule @0x005D51F0, BN string literal — useful for the Chat tab's timestamp work, but the literal itself is BN-sourced and not byte-verified).

Constructor defaults, BYTE-VERIFIED at 0x005D5231:

c7 86 8c 00 00 00 4a a5 c4 50   mov dword [esi+0x8c], 0x50C4A54A   ; options_
c7 86 90 00 00 00 00 87 94 00   mov dword [esi+0x90], 0x00948700   ; options2_
c7 86 94 00 00 00 ff 3f 00 00   mov dword [esi+0x94], 0x00003FFF   ; spell_filters_

This independently confirms CharacterOptions1.Default = 0x50C4A54A (the value in PlayerDescriptionParser.cs:217 and RuntimeCharacterOptionsState) and retires any lingering doubt from the retracted register row UN-9.

2.6 Retail writer vs ACE reader — the agreement matrix

ACE's read order (references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionSetCharacterOptions.cs:45-191):

ACE step gate retail equivalent verdict
flags = ReadUInt32() field 1
options1 = ReadInt32()SetCharacterOptions1 field 2
shortcuts 0x01 field 3
numTab1Spells + spells unconditional favorite_spells_[0]
4 tabs 0x04 never emitted by 2013 retail dead branch
6 tabs 0x10 never emitted dead branch
7 tabs 0x400 favorite_spells_[1..7]
desired comps 0x08 field 5
spellbook filters (else 0x3FFF) 0x20 field 6 (+ §2.5 default)
options2SetCharacterOptions2 0x40 field 7 (+ §2.5 default)
ReadString16L() 0x80 never emitted dead branch
GenericQualitiesData 0x100 field 8 ⚠️ float sub-table shape disputed (§2.4d)
rest-of-payload byte[] 0x200 field 9

Conclusion: ACE's reader is layout-correct for a faithful retail blob. No byte-level disagreement exists on any path the 2013 client can produce. The one unresolved item (float sub-table) is unreachable if acdream never sets 0x100.

2.7 The minimal correct blob acdream can emit

u32 0xF7B1
u32 seq
u32 0x000001A1
u32 header        = 0x00000460            (| 0x01 | 0x08 | 0x200 as populated)
u32 options1
[shortcuts, if 0x01]
u32 tab0Count ; tab0 spells               ┐
u32 tab1Count ; tab1 spells               │ always 8 lists
…                                          │
u32 tab7Count ; tab7 spells               ┘
[desired comps, if 0x08]
u32 spellbookFilters                       (echo the parsed value; 0x3FFF if unknown)
u32 options2
[gameplay-options blob, if 0x200 — MUST be last]

Everything is u32, so the payload is inherently 4-aligned and retail's tail pad is a no-op unless the gameplay-options blob has a length that is not a multiple of 4 — see §4.3.


3. Wire policy: when 0x0005, when 0x01A1

3.1 The decision point — CPlayerModule::OnChanged(PlayerOption) @0x0059A8E0

Every option write funnels here (each PlayerModule::SetXxx accessor tail-calls vtable slot +0x14 with its PlayerOption id — e.g. SetHearGeneralChat @0x005D35C0 writes options2_ |= 0x100 then jumps with arg2 = 0x23).

1. CM_UI::SendNotice_PlayerOptionChanged(option)     // local UI broadcast, always
2. local side-effect switch:
     0x02 IgnoreFellowshipRequests → if set, clear FellowshipAutoAcceptRequests
     0x04 DisableMostWeatherEffects → SmartBox::EnableWeather(!v)
     0x05 PersistentAtDay          → LScape::SetDay(v)
     0x07 ViewCombatTarget         → ClientCombatSystem::TrackTarget(v)
     0x12 FellowshipAutoAccept     → if set, clear IgnoreFellowshipRequests
     0x30 DisableDistanceFog       → LScape::m_fFogEnabled = !v
3. if (IsAutoSaveOption(option))
       CM_Character::Event_PlayerOptionChangedEvent(option, GetOption(option))   // 0x0005 NOW
       return                                                                    // never dirties
4. else
       if (!m_bDirty) { m_bDirty = 1; m_timeFirstDirtied = Timer::cur_time; }

Note step 3 returns — an auto-save option never contributes to the blob's dirty flag. And note the SetXxx accessors early-return when the value is unchanged (SetHearGeneralChat opens with a compare-and-return), so an unchanged option produces no notice, no side effect, no message at all. That idempotency is retail's, not something acdream has to invent.

3.2 IsAutoSaveOption — BYTE-VERIFIED

CPlayerModule::IsAutoSaveOption @0x0059A600:

8b 44 24 04              mov   eax,[esp+4]              ; PlayerOption
83 f8 33                 cmp   eax, 0x33
77 13                    ja    return-0                 ; > 0x33 → NOT auto-save
0f b6 80 2c a6 59 00     movzx eax, byte [eax+0x0059A62C]
ff 24 85 24 a6 59 00     jmp   [eax*4 + 0x0059A624]     ; [0]=ret 1, [1]=ret 0

Table at 0x0059A62C (52 bytes, BYTE-VERIFIED):

00 00 00 01 01 01 01 01 01 01 01 01 01 01 01 00
00 00 00 01 01 01 01 01 01 00 01 00 01 01 01 01
01 01 01 00 00 00 00 00 01 01 00 00 01 01 00 00
01 00 00 00

Byte 0x00auto-save (send 0x0005 immediately). The 21 such ids:

id retail PlayerOption storage ACE CharacterOption name
0x00 AutoRepeatAttack Opts1 0x00000002 AutoRepeatAttacks
0x01 IgnoreAllegianceRequests Opts1 0x00000004 IgnoreAllegianceRequests
0x02 IgnoreFellowshipRequests Opts1 0x00000008 IgnoreFellowshipRequests
0x0F FellowshipShareXP Opts1 0x00040000 ShareFellowshipExpAndLuminance
0x10 AcceptLootPermits Opts1 0x00080000 AcceptCorpseLootingPermissions
0x11 FellowshipShareLoot Opts1 0x00100000 ShareFellowshipLoot
0x12 FellowshipAutoAcceptRequests Opts1 0x20000000 AutomaticallyAcceptFellowshipRequests
0x19 UseChargeAttack Opts1 0x10000000 UseChargeAttack
0x1B HearAllegianceChat Opts1 0x40000000 ListenToAllegianceChat
0x23 HearGeneralChat Opts2 0x00000100 ListenToGeneralChat
0x24 HearTradeChat Opts2 0x00000200 ListenToTradeChat
0x25 HearLFGChat Opts2 0x00000400 ListenToLFGChat
0x26 HearRoleplayChat Opts2 0x00000800 ListenToRoleplayChat
0x27 AppearOffline Opts2 0x00001000 AppearOffline
0x2A LeadMissileTargets Opts2 0x00008000 LeadMissileTargets
0x2B UseFastMissiles Opts2 0x00010000 UseFastMissiles
0x2E HearSocietyChat Opts2 0x00080000 ListenToSocietyChat
0x2F ShowHelm Opts2 0x00100000 ShowYourHelmOrHeadGear
0x31 UseMouseTurning Opts2 0x00400000 UseMouseTurning
0x32 ShowCloak Opts2 0x00800000 ShowYourCloak
0x33 LockUI Opts2 0x01000000 LockUI

The pattern is legible: options with a server-side consequence (fellowship/allegiance/trade/loot state, chat-room membership, friend-list visibility, appearance broadcast, and the combat pacing flags the server simulates) go out immediately; pure client-presentation options wait for the blob. ACE's GameActionSetSingleCharacterOption special-cases a subset of exactly this set (AppearOffline, the two fellowship ones, cloak/helm, and the six ListenTo*) — every ACE special case is in the auto-save list. Independent corroboration.

Everything not in that table — 31 of the 52 ids, including every "User Interface Display" row the user screenshotted, ToggleRun (Run as Default Movement, 0x0A), AdvancedCombatUI (0x0C), AutoTarget (0x0D), ShowTooltips (0x08), DisplayTimeStamps (0x21), FilterLanguage (0x2C), ConfirmVolatileRareUse (0x2D), DisableDistanceFog (0x30) — only ever reaches the server inside the 0x01A1 blob.

3.3 What flushes the dirty blob

trigger site force? notes
Options panel commit PlayerOptionPage::SaveCurrentValues @0x004F2710CPlayerModule::SaveToServer(pm, 0) no PlayerOptionPage's constructor installs a vtable BN labels gmCharacterSettingsUI::'vftable' (0x004F269A) — a strong lead that this is the Character tab's class, but treat the NAME as a lead for lane A/B rather than proof (folded/aliased vtable labels are a known BN artifact class). PlayerOptionPage::OnVisibilityChanged @0x004F26E0 routes to SaveCurrentValues / RestoreSavedValues depending on the visibility argument — which branch is "shown" vs "hidden" is UNVERIFIED; lane A/B owns OptionPage's base semantics.
Logout CPlayerSystem::LogOffCharacter @0x00563520SaveToServer(pm, 0) no Fires before ExecuteLogOff / RequestLogOff.
8-minute auto-save CPlayerModule::UseTime @0x0059A710 BYTE-VERIFIED below.

CPlayerModule::SaveToServer @0x0059A660 is if (m_bDirty || force) send; m_bDirty = 0;both production call sites pass force = 0, so a clean module sends nothing. This is precisely the behaviour ACE's header comment describes: flipping only single-option toggles and then clicking Apply sends no 0x01A1 at all.

CPlayerModule::UseTime @0x0059A710, BYTE-VERIFIED:

8a 86 a8 01 00 00   mov  al,[esi+0x1a8]          ; m_bDirty
84 c0 / 74 2c       test/je  → return if clean
dd 05 58 53 7e 00   fld   qword [0x007E5358]     ; = 480.0  (BYTE-VERIFIED: 00 00 00 00 00 00 7e 40)
dc 86 b0 01 00 00   fadd  qword [esi+0x1b0]      ; + m_timeFirstDirtied
dc 1d a8 69 83 00   fcomp qword [0x008369A8]     ; vs Timer::cur_time
df e0 / f6 c4 41    fnstsw ax / test ah,0x41
7a 13               jp    skip                   ; PF=1 ⇔ result 0x00 (ST>mem) or 0x41 (unordered)
                    call CM_Character::Event_CharacterOptionsEvent
c6 86 a8 01 00 00 00  mov byte [esi+0x1a8], 0    ; m_bDirty = 0

test ah,0x41 after fcomp: C0 (0x01) = "less", C3 (0x40) = "equal". PF is set only for results 0x00 (greater) and 0x41 (unordered), and jp skips the send in both. So the blob is sent when 480.0 + m_timeFirstDirtied <= Timer::cur_time — i.e. 480 seconds (8 minutes) after the module FIRST went dirty, not after the last change. Branch direction is verified at the byte level, not inferred.

3.4 Gameplay/window options never use 0x0005

PlayerModule::SetOption(BaseProperty) @0x005D52C0 (the gameplay-option setter that SetChatWindowOption and the geometry writers use) stores into m_colGameplayOptions and calls CPlayerModule::OnChanged(BaseProperty, flags) @0x0059A890, which is:

CM_UI::SendNotice_GameplayOptionChanged(prop, flags)   // local only
if (!m_bDirty) { m_bDirty = 1; m_timeFirstDirtied = cur_time; }

No immediate send, ever. Every chat-window filter, position, size, visibility, title and the two opacity values therefore reach the server only through the 0x01A1 blob, on the same Apply / logout / 8-minute schedule. (BN field naming here is an artifact — BN renders the dirty flag as m_TimeStampFormat…; the byte dump in §3.3 shows the real offsets 0x1A8 / 0x1B0.)

3.5 The policy table (the answer to Q4's "when")

user action message(s) when
Toggle one of the 21 auto-save options (LED click) 0x0005 only immediately, on the click
Toggle any other option none on the wire dirty flag set
Click Apply (or the panel's commit path) 0x01A1 iff something dirty on the click
Move/resize/close/rename a chat window; change a filter or opacity none on the wire dirty flag set
Nothing pressed, 8 minutes since first dirty change 0x01A1 timer
Log off 0x01A1 iff dirty before the logoff request
Re-set an option to its current value nothing at all early-return in the accessor

4. The GameplayOptions blob (0x1000008C / 0x1000008B)

4.1 What it is on the wire

m_colGameplayOptions is a PackObjPropertyCollection, i.e. PackUsingSerialize<PackObj> + PropertyCollection. Its Pack (PackUsingSerialize<PackObj>::Pack @0x005D4E50) does not write fields — it memcpys the bytes of an already-serialized AutoStoreVersionArchive:

GetPackSize()  → if (!m_fArchiveValid) { m_ar.InitForPacking(); Serialize(&m_ar);
                   AutoStoreVersionArchive::OnSerializingDone(&m_ar);
                   m_fArchiveValid = 1; }
                 return Archive::GetCurrentPosition(&m_ar);
Pack()         → memcpy(dest, SmartBuffer(m_ar), size); reset archive

and PropertyCollection::Serialize @0x00681420 is SerializeIntrusiveHashTable<int, IntrusiveHashTable<u32, HashTableData<u32,BaseProperty>*>, …, SB_Default> over the property bag — a versioned archive of a hash table of BaseProperty objects, each of which is itself polymorphic (int / bool / float / string / array / nested bag, per the 0x11 array type seen at PlayerModule::GetChatOptionStructure @0x005D5300).

The exact archive byte format (version row layout, SB_Default element framing, BaseProperty type tags) is UNKNOWN and was not derived in this lane. It is a self-contained sub-project: Archive, AutoStoreVersionArchive, SerializeVersionRow, BaseProperty::Serialize and the PropertyCollection hash-table serializer all have named symbols and can be walked when the campaign needs the wire round-trip.

4.2 Semantic content — already researched

docs/research/2026-08-09-chat-retail-window-shell.md §4.1/§4.2 has the complete decoded structure (array 0x1000008C indexed by windowId - 1, element name 0x1000008B, and the seven per-window property ids 0x1000007F filter / 0x10000086-89 geometry / 0x1000008A visible / 0x1000008D title), plus the two guards (main window m_eWindowID == 0 never persists; geometry restore is skipped when a local screen-layout file was loaded). The two global opacity options 0x10000080 / 0x10000081 live in the same collection. Not re-derived here.

4.3 Two hazards for whoever packs it

  1. It must be the last section. ACE reads it as "all remaining bytes" (GameActionSetCharacterOptions.cs:185-190). Anything appended after it is silently swallowed into the stored blob.
  2. Retail's tail padding lands inside it. PlayerModule::Pack zero-pads the whole payload up to a 4-byte boundary after the collection. If the archive length is not a multiple of 4, ACE stores up to 3 extra zero bytes and echoes them back verbatim on the next PlayerDescription. Any decoder — ours or retail's — must tolerate trailing zeros, and a bit-exact round-trip test must account for them.

4.4 What acdream does with it today

Inbound: PlayerDescriptionParser.cs:433-442 slices the blob out heuristically (TryHeuristicInventoryStart scans forward for the inventory section) and never parses it. Outbound: nothing. Local window layout persists to SettingsStore instead (RetailWindowLayoutPersistence), per the CH6 decision to defer the wire (…window-shell.md §6.2 row CH6f).


5. ACE acceptance (Q4 part 4 + the Q8 tail)

5.1 Validation and clamping: there is none

Player_Character.cs:80-120:

public void SetCharacterOptions1(int value)  { Character.CharacterOptions1 = value; CharacterChangesDetected = true; }
public void SetCharacterOptions2(int value)  { Character.CharacterOptions2 = value; CharacterChangesDetected = true; }
public void SetCharacterGameplayOptions(byte[] v) { Character.GameplayOptions = v; CharacterChangesDetected = true; }

Raw stores under a write lock. No mask, no whitelist, no range check, no rejection. Bits ACE's enum calls NotUsed1..5 are stored happily and echoed back. The same is true of the 0x0005 path — SetCharacterOption just ORs/ANDs the attribute's mask into the same field.

5.2 The only refusal: FirstEnterWorldDone

GameActionSetCharacterOptions.cs:25-35 drops the whole 0x01A1 message, with a log.Warn, if session.Player.FirstEnterWorldDone is false — a deliberate guard against a client that logs out of the pink-bubble state before receiving its options and overwrites them with defaults. The flag is set by GameActionLoginComplete (0x00A1) (GameActionLoginComplete.cs:15-17), which acdream already sends (src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs).

Constraint: never send 0x01A1 before LoginComplete. There is no response, no error, no retry — the message just vanishes.

SetSingleCharacterOption (0x0005) has no such gate and no validation of any kind.

5.3 What ACE does with each section

section ACE behaviour persisted? echoed in PlayerDescription?
options1 SetCharacterOptions1(value) Character.CharacterOptions1 always
shortcuts read, discarded (// TODO) from Character.GetShortcuts()
8 spell tabs read, discarded (// TODO) from GetSpellsInSpellBar(0..7)
desired comps read into a local dict, discarded from GetFillComponents()
spellbook filters read into a local, discarded from Character.SpellbookFilters
options2 SetCharacterOptions2(value) (0x40 always set outbound)
timestamp string (0x80) read, discarded never sent
GenericQualitiesData (0x100) read, discarded never sent
GameplayOptions (0x200) SetCharacterGameplayOptions(bytes) Character.GameplayOptions byte[] verbatim when non-empty

The four "discarded" sections each have a dedicated ACE GameAction that is the real persistence channel: AddShortcut/RemoveShortcut, AddSpellFavorite/RemoveSpellFavorite, SetDesiredComponentLevel (0x0224), SpellbookFilter (0x0286). So sending them inside the blob neither helps nor hurts on ACE.

But do echo the real values anyway. A blob that zeroes them would be wrong against a retail server and would look wrong in a packet capture; echoing what PlayerDescription last delivered costs nothing and keeps the message faithful.

ACE's outbound echo (GameEventPlayerDescription.cs:340-397) sets CharacterOptions2 | SpellLists8 | SpellbookFilters unconditionally, plus Shortcut / DesiredComps / GameplayOptions when non-empty, and never sets GenericQualitiesData or TimestampFormat. Its SpellLists8 branch writes 8 lists in one loop, which the retail unpacker consumes as 1 unconditional + 7 flagged.

5.4 Failure modes a bot must avoid

  1. Malformed blob → partial application, silently. ACE's per-action try/catch (InboundMessageManager.cs:135-143) logs the exception and continues; the session survives. But SetCharacterOptions1 runs before any read that could throw, so a truncated blob can leave options1 applied and options2 not. Never send a speculative blob.
  2. Out-of-range option id on 0x0005 throws. ACE casts the id to CharacterOption unchecked, then CharacterOptionExtensions does a Dictionary[val] lookup built from Enum.GetValues — an id that is not an enum member raises KeyNotFoundException, caught and logged, option lost. Only send ids in the enumerated set.
  3. Ids 0x35 and 0x36 are landmines. ACE defines CharacterOptions1Default = 0x35 and CharacterOptions2Default = 0x36 with the whole default mask as their attribute. Sending 0x35 = true ORs the entire 0x50C4A54A default into options1. These are not real options — never emit them.
  4. 0x34 is version-dependent — see §8.1.

5.5 Answering Q8 directly

verify which options a bot can meaningfully hold and whether ACE rejects any option change from a logged-in session.

  • ACE rejects no option change from a logged-in session. The single gate is pre-LoginComplete for 0x01A1. Post-login, every option in both bitfields is settable to any value at any time.
  • Every option a bot could want is meaningfully holdable, because ACE stores the raw bitfields regardless of whether it simulates the behaviour. The ones ACE actually acts on server-side are: the six ListenTo*Chat (Turbine room join/leave), AppearOffline (friend-status broadcast), ShowYourHelm / ShowYourCloak (GameMessageObjDescEvent re-broadcast), the fellowship request/share flags, and the trade/allegiance ignore flags. The rest are stored-and-echoed only.
  • The declared-vs-actual diff at login is sound and idempotent — and it is exactly what retail does anyway (§3.1 step 0: an accessor set to its current value emits nothing). Two constraints:
    1. Diff against the PlayerDescription-seeded state, after LoginComplete, then send.
    2. Prefer 0x0005 per changed option for the 21 auto-save ids (retail-exact, and the only path that triggers ACE's Turbine join/leave). Use one 0x01A1 blob for a batch of non-auto-save changes — which requires the builder this doc specifies, so until it exists, a bot can only hold the 21.
  • Presentation-only settings must stay out of the bot schema (opacity, window geometry, audio, quality) — they live in the GameplayOptions archive that acdream cannot yet pack, and they are meaningless without a window.

6. CH3 deletion post-mortem

6.1 What existed

Introduced by fa266aaa (2026-04-19, "feat(net): SocialActions — query / fellowship / channel / options outbound"), removed by 614a1e05 (2026-08-09, Campaign CH slice CH3). The whole builder:

public const uint SetCharacterOptionsOpcode = 0x01A1u; // u32 options bitmap

/// <summary>Push the client's character-options bitmap to the server.</summary>
public static byte[] BuildSetCharacterOptions(uint seq, uint optionsBitmap)
{
    byte[] body = new byte[16];
    BinaryPrimitives.WriteUInt32LittleEndian(body,            GameActionEnvelope);
    BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4),  seq);
    BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8),  SetCharacterOptionsOpcode);
    BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), optionsBitmap);
    return body;
}

Its only reachable entrance was IGameRuntimeCommands.SetOptions1, which had no production caller (tests only). Ten byte-exact unit tests asserted the encoding — of a message shape that does not exist.

6.2 Exactly what was wrong

One error, three consequences. The builder put a CharacterOptions1 bitfield in the payload's first u32 slot. That slot is the section-flag word, not an options word (§2.2). Therefore:

  1. Every options1 bit aliases onto a section flag. Feeding CharacterOptions1.Default = 0x50C4A54A into the flags slot lights five sections at once:

    bit as CharacterOptions1 read by ACE as
    0x002 AutoRepeatAttack SquelchList (never read — ACE's branch is commented out)
    0x008 IgnoreFellowshipRequests DesiredComps
    0x040 AllowGive CharacterOptions2
    0x100 ShowTooltips GenericQualitiesData
    0x400 ToggleRun SpellLists8

    (and UseDeception 0x200GameplayOptions, DisableMostWeatherEffects 0x10000 → nothing, etc. for non-default values). The handoff's shorthand — "CharacterOptionDataFlag.CharacterOptions2 = 0x40 collides with CharacterOptions1.AllowGive = 0x40" — is the single sharpest instance of this aliasing (AllowGive is in the default mask), not a separate bug.

  2. The payload ends immediately after that word, so ACE's very next read — the unconditional characterOptions1 = ReadInt32() — runs off the end of the 16-byte body and throws EndOfStreamException, caught and logged by InboundMessageManager. Nothing would have been applied except… nothing, because options1 is read after flags. (Had the payload been one word longer, SetCharacterOptions1 would have been called with garbage before the throw — the partial-application hazard of §5.4.1.)

  3. No spell lists, no filters, no options2, no gameplay options — the message could not carry the state it was named for even in principle.

CH3's judgement (delete rather than fix) was right: a builder with no caller, no correct layout, and a green test suite pinning the wrong bytes is worse than nothing, because the tests make it look verified.

6.3 Adjacent facts worth carrying forward

  • The same commit deleted AddChannel (0x0145) / RemoveChannel (0x0146), which sent string16L where ACE reads (Channel)ReadUInt32(). Same class of error: a shape asserted from a name rather than read from the receiver.
  • Register row UN-9 (a suspected CharacterOptions1.Default mismatch) was filed and then retracted the same day — the wrong literal 0x50C48D4A existed only in a research doc. §2.5's byte dump of the retail constructor now confirms 0x50C4A54A from a third independent source. Do not re-open it.
  • Ten obsolete tests went with the builder; a resurrected one needs new conformance tests written against §2.3, ideally including a round-trip against PlayerDescriptionParser.

7. acdream today — what a resurrected builder plugs into

7.1 The working 0x0005 codec

layer file:line role
Wire builder src/AcDream.Core.Net/Messages/SocialActions.cs:151-160 BuildSetSingleCharacterOption(seq, optionId, value) → 20 bytes
Option ids src/AcDream.Core.Net/Messages/SocialActions.cs:199-207 enum CharacterOptionIdonly the six ListenTo*Chat ids modelled; the full 0x000x33 set is needed for the Options panel
Session send src/AcDream.Core.Net/WorldSession.cs:2202-2206 SendSetSingleCharacterOption (allocates the game-action sequence)
Runtime command src/AcDream.Runtime/GameRuntimeCommands.cs:249-252 IRuntimeCharacterCommands.SetSingleOption(generation, optionId, value)
Graphical route src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs:676src/AcDream.App/Net/LiveSessionCommandRouter.cs:69,167,477src/AcDream.App/Net/LiveSessionRuntimeFactory.cs:346-350 publishes SetSingleCharacterOptionRuntimeCmd; the factory's SendSingleCharacterOption is the single local-write chokepoint
Headless route src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs:652-666 direct send — missing the local write, see §7.4
Settings entrance src/AcDream.App/Settings/RuntimeSettingsController.cs:538-566 PublishHearOptionChange — diffs previous vs current and publishes changed bits only (already retail's idempotency rule, §3.1)

7.2 RuntimeCharacterOptionsState — the local-write-then-notify owner

src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs:628-712.

  • DefaultOptions1 = 0x50C4A54A, DefaultOptions2 = 0x00948700 — both now byte-confirmed against the retail constructor (§2.5).
  • Replace(options1, options2) — called from src/AcDream.Runtime/Session/LiveSessionEventRouter.cs:210-213 on every PlayerDescription. This is the seed the bot diff must run against.
  • SetOptionBit(characterOptionId, bool) — the local write. Currently recognises only the six ListenTo*Chat ids and silently no-ops every other id (_ => (false, 0u) at :687). The Options panel needs the full PlayerOption → (word, mask) table here; §3.2's table plus acclient.h:3404 (CharacterOption) and acclient.h:3451 (CharacterOptions2) are the complete verbatim source.
  • Snapshot carries a Revision counter, incremented only on an actual change — a natural dirty-tracking hook for the blob (see §9).

There is no dirty flag, no m_timeFirstDirtied equivalent, and no auto-save timer anywhere in acdream today.

7.3 The inbound parser (round-trip partner)

src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:353-447. It agrees with §2.3 on every path ACE can produce, with three noted deltas — all harmless against ACE, all worth a comment if the builder makes the parser its round-trip oracle:

  1. It reads the 8 spell lists only under SpellLists8, with a single-list fallback otherwise; it ignores MultiSpellList (0x04) and ExtendedMultiSpellLists (0x10) entirely. Retail reads list[0] unconditionally, then 4 / 6 / 7 more. Equivalent for ACE (which always sets 0x400); divergent against a hypothetical legacy sender.
  2. It reads spellbookFilters whenever ≥4 bytes remain rather than gating on 0x20, and defaults to 0x3FFF (matching retail's UnPack default) if absent. Equivalent for ACE (always sets 0x20).
  3. It never reads the 0x80 or 0x100 sections. ACE never sets them; retail would, if acdream ever talked to one.

7.4 Defect spotted in passing (headless local-write gap)

DirectGameRuntimeCommandAdapter.SetSingleOption (src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs:652-666) sends the wire message but does not perform the local Character.Options.SetOptionBit(optionId, value) write that the graphical path does at LiveSessionRuntimeFactory.cs:348. That local write exists precisely because CH4's re-review found TurbineChatMembershipGate refusing a room the player had just joined until the next PlayerDescription arrived (the reasoning is written out at LiveSessionRuntimeFactory.cs:330-345).

A headless bot that joins a Turbine channel through IRuntimeCharacterCommands.SetSingleOption therefore hits exactly the bug CH4 fixed for the graphical client: the room is joined server-side, but the local gate keeps refusing outbound chat on it until a fresh PlayerDescription lands. The adapter holds _runtime (:57) and can reach GameRuntime.Character.Options, so the fix is a one-line local write before the send — or, better, hoisting the write into Runtime so neither host can omit it. Filed here for the planner; not fixed in this lane (research-only).


8. Known divergences, cautions and UNKNOWNs

8.1 Our decomp is one build older than the option set the user screenshotted

The named-retail PDB is the Sept 2013 build. Its PlayerOption enum (acclient.h:4162) ends:

LockUI_PlayerOption                     = 0x33,
TotalNumberOfPlayerOptions_PlayerOption = 0x34,

and its CharacterOptions2 bitfield (acclient.h:3451) ends at LockUI_CharacterOptions2 = 0x1000000. HearPKDeath does not exist in the 2013 client — no enum entry, no PlayerModule::SetHearPKDeath accessor, no bit. ACE has ListenToPKDeathMessages = 0x34CharacterOptions2 0x02000000, and the user's Character-tab screenshot shows "Listen to PK death messages".

Conclusion: the option was added after Sept 2013, and 0x34 shifted from "total count" to a real option in the final EoR client.

  • The 0x340x02000000 mapping is ACE-sourced, UNVERIFIABLE against our binary. Use it, cite ACE, and mark it in the register.
  • Whether the final client treats 0x34 as auto-save is UNKNOWN. Its five Hear*Chat siblings all are, so treating it as auto-save is the best-supported guess — but it is a guess, and on ACE the end state is identical either way (the 0x0005 handler's default: branch just sets the bit; there is no PK-death Turbine room).
  • IsAutoSaveOption's cmp eax, 0x33 / ja → 0 means the 2013 client would classify 0x34 as non-auto-save by construction. Do not read that as evidence about the later build.

8.2 GetDefaultOptionValue disagrees with the constructor defaults

PlayerModule::GetDefaultOptionValue @0x005D2A30 — the per-option default the Defaults button would consult — is BYTE-VERIFIED:

83 f8 2a               cmp   eax, 0x2A
77 13                  ja    return-0                    ; > 0x2A → default OFF
0f b6 80 5c 2a 5d 00   movzx eax, byte [eax+0x005D2A5C]
ff 24 85 54 2a 5d 00   jmp   [eax*4 + 0x005D2A54]        ; [0]=ret 1, [1]=ret 0

Table at 0x005D2A5C (BYTE-VERIFIED) ⇒ default-ON ids: 0x00 0x02 0x06 0x08 0x0A 0x0D 0x0E 0x0F 0x14 0x15 0x19 0x1B 0x23 0x24 0x25 0x2A.

Those are exactly the twelve CharacterOptions1.Default bits plus four of the seven CharacterOptions2.Default bits. ConfirmVolatileRareUse (0x2D), ShowHelm (0x2F) and ShowCloak (0x32) are ON in the constructor default 0x00948700 but report default-OFF here, because the function's range check tops out at 0x2A — it predates those three options and was never extended.

So pressing "Defaults" in retail does not reproduce a fresh PlayerModule; it turns helm, cloak and the rare-gem confirmation off. This is retail behaviour, byte-proven, and acdream should reproduce it rather than "fix" it — with a register row explaining why, so a future reader does not "correct" it back. (Whether the panel's Defaults button actually calls this function is lane A/B's question; this lane only establishes what the function returns.)

8.3 Explicit UNKNOWNs

# unknown why it matters
U1 PropertyCollection / AutoStoreVersionArchive byte format (§4.1) blocks the 0x1000008B/0x1000008C wire (CH6f) — local persistence only until then
U2 GenericQualitiesData float sub-table: double (retail struct) vs string16L (ACE reader) (§2.4d) unreachable if we never set 0x100; do not set it
U3 PlayerOptionPage::OnVisibilityChanged argument polarity — which of show/hide commits vs reverts (§3.3) Apply/Reset/Defaults semantics; lane A/B territory (OptionPage base class)
U4 Whether the final EoR client made 0x34 HearPKDeath auto-save (§8.1) cosmetic on ACE; a divergence row either way
U5 Whether the 2017 client changed PlayerModule::Pack at all (new sections, new flags) our layout is the 2013 one; ACE's reader is 2017-era and matches it, which is strong evidence nothing changed — but it is inference, not proof
U6 The default timestamp format string "%#H:%M:%S " is BN-sourced, not byte-verified only matters when the Chat tab implements timestamp formatting

9. For the planner

What is now settled and can be specified without further research

  1. The blob layout (§2.3 + §2.4 + §2.7) is complete and cross-verified against ACE's reader. A builder can be written from this doc alone.
  2. The send policy (§3.5) is byte-verified, including the 21-id auto-save table (§3.2) and the 480-second timer (§3.3).
  3. ACE accepts anything post-LoginComplete and stores only options1, options2, GameplayOptions (§5).

Recommended slice shape

  • S-a — the option map. Extend enum CharacterOptionId to all ids 0x00..0x33 (verbatim from acclient.h:4162) plus ACE's 0x34, and extend RuntimeCharacterOptionsState.SetOptionBit from six ids to the full PlayerOption → (Options1|Options2, mask) table (acclient.h:3404 / :3451). Add IsAutoSave(id) as a table on the Runtime side — it is the policy, so it belongs with the state, not in the panel. Pure Runtime + tests, no visual gate. Do this first; the panel and the bot both depend on it. Guard-rail: reject 0x35/0x36 at the seam (§5.4.3).
  • S-b — the dirty model. Add retail's m_bDirty + m_timeFirstDirtied (Revision already gives change detection) and the three flush triggers (Apply / logout / 480 s). Runtime-owned, so the headless host inherits it. Decide explicitly whether acdream ships the 8-minute autosave or defers it with a register row — retail-faithful says ship it.
  • S-c — the builder. SocialActions.BuildSetCharacterOptions(...) per §2.7
    • WorldSession.SendSetCharacterOptions + a new IRuntimeCharacterCommands.SetAllOptions-style command beside SetSingleOption. Echo the last-parsed shortcuts / spell tabs / desired comps / spellbook filters rather than zeroing them (§5.3). Omit 0x100 entirely (U2). Conformance tests must round-trip through PlayerDescriptionParser and must include a golden byte vector, since the last builder's tests pinned a wrong shape and looked green.
  • S-d — the headless characterOptions block. Strict JSON schema keyed by option name, resolved through S-a's map; diff against the PlayerDescription-seeded state after LoginComplete; auto-save ids go out as 0x0005, the remainder as one blob. Presentation-only settings are excluded by construction. Fix §7.4 in this slice or before it.
  • CH6f (gameplay-options packing) stays out of scope until U1 is researched. The blob builder must simply omit 0x200 when acdream has nothing to pack — which §2.5 confirms is safe (the receiver leaves its collection untouched).

Design calls this doc recommends

  • Mirror retail's auto-save split exactly rather than sending everything as 0x0005. It is not merely cosmetic: 0x0005 is the only path that makes ACE join/leave Turbine rooms, and the blob is the only path that carries the window/gameplay options — the split is load-bearing in both directions.
  • Reproduce the GetDefaultOptionValue quirk (§8.2) with a register row rather than "fixing" it.
  • Keep SetOptionBit's local-write-then-notify shape for every option, not just the six chat ones — it is retail's own ordering (SetHearGeneralChat @0x005D35C0 writes the bit, then jumps to OnChanged) and it is what makes the client's own consumers correct before the round trip.

Register rows this work will need

  • 0x34 HearPKDeath mapping sourced from ACE, unverifiable against our 2013 binary (§8.1).
  • The GetDefaultOptionValue vs constructor-default disagreement, if the Defaults button ships (§8.2).
  • The GameplayOptions blob remaining unpacked / local-only until CH6f (already anticipated by …window-shell.md §6.3).
  • Any decision to defer the 480-second auto-save.