# `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 `u32`s (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 `SocialActions` → `WorldSession.SendSetCharacterOptions` → `IRuntimeCharacterCommands` (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 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`) ```c struct __cppobj PlayerModule : PackObj { ShortCutManager *shortcuts_; PackableList favorite_spells_[8]; PackableHashTable,long> *desired_comps_; unsigned int options_; // CharacterOptions1 unsigned int options2_; // CharacterOptions2 unsigned int spell_filters_; GenericQualitiesData *m_pPlayerOptionsData; PackObjPropertyCollection m_colGameplayOptions; AC1Legacy::PStringBase m_TimeStampFormat; }; ``` Declaration order is **not** wire order. Wire order is §2.3. ### 2.2 The header flag word (PDB-TYPE, `acclient.h:7835`) ```c 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 sets** — `PlayerModule::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::Pack` — §2.4b | | 5 | `desired_comps_` | `header & 0x008` | `PackableHashTable::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::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::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 key, i32 value) [if 0x2] PackableHashTable (u32 key, i32 value) [if 0x4] PackableHashTable (u32 key, PStringChar?) ← see caution [if 0x8] PackableHashTable (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`. 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) | ✅ | | `options2` → `SetCharacterOptions2` | `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 `0x00` ⇒ **auto-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 @0x004F2710` → `CPlayerModule::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 @0x00563520` → `SaveToServer(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` + `PropertyCollection`. Its `Pack` (`PackUsingSerialize::Pack @0x005D4E50`) does **not** write fields — it `memcpy`s 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*>, …, 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`: ```csharp 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: ```csharp public const uint SetCharacterOptionsOpcode = 0x01A1u; // u32 options bitmap /// Push the client's character-options bitmap to the server. 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 0x200` → `GameplayOptions`, `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 CharacterOptionId` — **only the six `ListenTo*Chat` ids modelled**; the full 0x00–0x33 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:676` → `src/AcDream.App/Net/LiveSessionCommandRouter.cs:69,167,477` → `src/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 = 0x34` ↔ `CharacterOptions2 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 `0x34` ↔ `0x02000000` 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.