# Character-tab option map — settings-track research lane B **Campaign:** settings track (retail four-tab Options panel). Handoff: `docs/research/2026-08-10-settings-track-handoff.md`. **Lane:** B — handoff research questions **Q2** (complete Character-tab option map), **Q7** (which options have live acdream consumers), and the **bot-relevance half of Q8**. **Date:** 2026-08-10. **Mode:** research only — no code changed, nothing built, nothing launched. **Sources.** `docs/research/named-retail/acclient.h` (verbatim retail `PlayerOption` enum + `PlayerModule`/`UIOption_Checkbox` structs), `docs/research/named-retail/acclient_2013_pseudo_c.txt` (BN pseudo-C), the PDB-paired binary at `C:\Users\erikn\Downloads\acclient.exe` (`check_exe_pdb.py` → `=== MATCH ===`, GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`, linker 2013-09-06T00:17:56Z), vendored ACE at `references/ACE/`, and acdream `src/` in this worktree. --- ## 0. Verdict summary | Question | Answer | |---|---| | Do we have the complete Character-tab row list? | **Yes, and it is decomp-authored, not inferred.** `gmCharacterSettingsUI::InitOptions @0x004a02f0` emits exactly **50 toggle rows in 6 header groups**, in an order that matches the user's three screenshots **row-for-row**. | | Is ACE's option-id → bit map right? | **Yes, byte-verified.** The client's own `PlayerModule::GetOption @0x005d3aa0` switch assigns the same bit to all 52 options present in the 2013 build. `CharacterOptions1.Default = 0x50C4A54A` also reconstructs exactly from retail's own per-option default table (§1.4). | | 0x0005 vs 0x01A1 — what decides? | **`CPlayerModule::IsAutoSaveOption @0x0059a600`**, a 0x34-byte lookup table (byte-verified, §1.3). 21 of 53 options send `SetSingleCharacterOption (0x0005)` **immediately**; the other 32 only mark the module dirty and ride the batched `SetCharacterOptions (0x01A1)` blob 480 s later or at forced save. | | Does the Apply button send the blob? | **Not necessarily.** Each checkbox applies through `PlayerModule::SetOption` the moment Apply runs; auto-save ids leave as 0x0005 and never enter the blob. ACE's own header comment in `GameActionSetCharacterOptions.cs:11-16` independently describes this behaviour (and its stated rule — "options with a value set in the enum" — is **wrong**; all 53 have values. The real rule is the `IsAutoSaveOption` table). | | Does acdream track these today? | **Two bits are live; everything else is either a local `settings.json` bool the server never sees, or absent.** 6 `Hear*Chat` bits drive `TurbineChatMembershipGate`; `DragItemOnPlayerOpensSecureTrade` drives item interaction. That is the whole list. | | Is there a shared Runtime seam for bots? | **Yes, it already exists**: `IRuntimeCharacterCommands.SetSingleOption` (`src/AcDream.Runtime/GameRuntimeCommands.cs:249`), implemented by both hosts. It has **one defect** (§4.4): the headless implementation skips the local option-bit write the graphical one does. | | Does ACE reject option changes from a live session? | **0x0005: no gate at all.** **0x01A1: refused before `FirstEnterWorldDone`.** But an *unknown* option id makes ACE throw (§5.3) — a real bot-safety constraint. | Two divergences worth naming immediately, because they are behavioural and currently unfiled: 1. **`/acceptcorpselooting` toggles a local bool the server never sees.** `ClientCommandController.cs:444-450` flips `GameplaySettings.AcceptLootPermits` only. Retail sends `0x0005 (0x10)` immediately (auto-save), and ACE honours the bit at `Player_Death.cs:755`. Today the corpse permission is a lie. 2. **`AutoRepeatAttack` is client-local in acdream but server-authoritative in ACE.** `LiveCombatAttackOperations.cs:95,169` reads the local setting; ACE independently re-attacks based on its own stored bit (`Player_Melee.cs:375`, `Player_Missile.cs:284`). The two can disagree. --- ## 1. The retail mechanism (settled, byte-verified) ### 1.1 Storage `PlayerModule` (`acclient.h:36507`) holds the two option words plus the non-boolean option payloads: ```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; }; ``` The linear id space is `enum PlayerOption` (`acclient.h:4162-4217`), `0x00`..`0x33`, terminated by `TotalNumberOfPlayerOptions_PlayerOption = 0x34`. **The 2013 build has no `0x34`.** ACE's `ListenToPKDeathMessages = 0x34` is a post-PDB addition (ACE says so itself at `PlayerFactory.cs:659` — *"possibly was added to Defaults post PDB we have"*). ### 1.2 Two kinds of option row — the tab discriminator `UIOption_Checkbox` (`acclient.h:6108`) carries **both** a `PlayerOption m_playerOption` and a `PStringBase m_prefName` / `unsigned int m_propName`. `UIOption_Checkbox::GetValue @0x00486f60` branches on which is set: * `m_playerOption != Invalid (0xFFFFFFFF)` → `PlayerModule::GetOption` → the server-synced character option. * otherwise → `UIPreferences::InqPreferenceValue(m_prefName)` → a **client-local preference**, applied via `UIPreferences::ModifyPreference` + `CM_UI::SendNotice_UserPreferenceChanged` (the apply path at `0x00486e2b`-`0x00486e49`). **Cross-lane fact, cheap to state here:** `gmConfigUI::InitOptions @0x0049e400` and `gmChatOptionsUI::InitOptions @0x0049fc60` contain **zero** `AddToggleOption(_PlayerOption)` calls (12 `AddHeader` + 12 `AddSeperator` between them, no PlayerOption rows). Every server-synced character option in retail lives on the **Character tab**; Config and Chat are `m_prefName`/per-window-blob territory. ### 1.3 Apply → wire: `IsAutoSaveOption` ``` PlayerModule::SetOption(opt, v) @0x005d3eb0 → writes the bit into options_ / options2_ → virtual vtable+0x14 == CPlayerModule::OnChanged(PlayerOption) @0x0059a8e0 → CM_UI::SendNotice_PlayerOptionChanged(opt) // local UI fan-out → local side-effect switch (6 cases only — §1.5) → if (CPlayerModule::IsAutoSaveOption(opt)) @0x0059a600 CM_Character::Event_PlayerOptionChangedEvent(opt, GetOption(opt)) // == GameAction opcode 5, 0x14-byte body: u32 option, u32 value return; else m_bDirty = 1; m_timeFirstDirtied = Timer::cur_time; CPlayerModule::UseTime @0x0059a710 : dirty && (cur_time - firstDirtied) > 480.0 → CM_Character::Event_CharacterOptionsEvent CPlayerModule::SaveToServer @0x0059a660 : (dirty || force) → same CM_Character::Event_CharacterOptionsEvent @0x006a10c0 : opcode 0x1A1, body is literally PlayerModule::Pack(...) ``` So the **0x01A1 body is the packed `PlayerModule`** — flags-driven, exactly what ACE's `GameActionSetCharacterOptions` reader parses. (Lane for Q4; recorded here because the builder address is the answer.) **`IsAutoSaveOption` lookup table — byte-verified** from the PDB-paired binary at VA `0x0059a62c` (file offset `0x19a62c`, `.text`), 0x34 bytes: ``` 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 ``` `0x00` → `jump_table[0]` → `return 1` (auto-save). The 21 auto-save ids are therefore: **0x00, 0x01, 0x02, 0x0F, 0x10, 0x11, 0x12, 0x19, 0x1B, 0x23, 0x24, 0x25, 0x26, 0x27, 0x2A, 0x2B, 0x2E, 0x2F, 0x31, 0x32, 0x33**. Every other id (0x03–0x0E, 0x13–0x18, 0x1A, 0x1C–0x22, 0x28, 0x29, 0x2C, 0x2D, 0x30) is batched. Sanity check against an independent source: ACE's comment says *"Auto Repeat Attacks → SetSingleCharacterOption; Disable Most Weather Effects → won't trigger"*. Table: `0x00` = auto-save ✔, `0x04` = batched ✔. ### 1.4 The Defaults button — retail's own per-option default table `PlayerModule::GetDefaultOptionValue @0x005d2a30`. The `UIOption_Checkbox` caches it into `m_default` at `SetPlayerOption @0x00486f2d`, so this **is** what the Character tab's Defaults button restores. Byte-verified at VA `0x005d2a5c` (file `0x1d2a5c`), **0x2B bytes** — indices `0x00`..`0x2A` only, followed by `90` padding: ``` 00 01 00 01 01 01 00 01 00 01 00 01 01 00 00 00 01 01 01 01 00 00 01 01 01 00 01 00 01 01 01 01 01 01 01 00 00 00 01 01 01 01 00 ``` `0x00` → default **true**. **Options `0x2B`..`0x33` fall off the end of the table and default to `false`** (`UseFastMissiles`, `FilterLanguage`, `ConfirmVolatileRareUse`, `HearSocietyChat`, `ShowHelm`, `DisableDistanceFog`, `UseMouseTurning`, `ShowCloak`, `LockUI`) — the table was never extended when those options were added. Reconstructing the words from the true entries: * **Options1** = `0x02|0x08|0x40|0x100|0x400|0x2000|0x8000|0x40000|0x400000|0x800000|0x10000000|0x40000000` = **`0x50C4A54A`** — **identical to ACE's `CharacterOptions1.Default`.** This is an independent binary confirmation of that constant *and* of the id→bit map for those 12 ids. * **Options2** = `0x100|0x200|0x400|0x8000` = **`0x00008700`**, whereas ACE's `CharacterOptions2.Default = 0x00948700`. The three extra ACE bits (`ConfirmVolatileRareUse 0x40000`, `ShowHelm 0x100000`, `ShowCloak 0x800000`) are exactly three of the ids past the table's end. **This is not a divergence to file** — retail's table is the *client Defaults button*, ACE's constant is the *server character-creation* value; they are different mechanisms and retail's own server-side creation value is not observable to us. It IS a behavioural difference the panel must reproduce: our Defaults button must restore `0x50C4A54A` / `0x00008700`, not ACE's creation mask. ### 1.5 Retail's immediate local side effects (only six) `CPlayerModule::OnChanged(PlayerOption)` switch — lookup table at `0x0059aa04` (0x2F bytes, index = `arg2 - 2`), byte-verified: | Option | Effect | |---|---| | `0x02 IgnoreFellowshipRequests` | if set → clears `FellowshipAutoAcceptRequests` | | `0x04 DisableMostWeatherEffects` | `SmartBox::EnableWeather(!value)` | | `0x05 PersistentAtDay` | `LScape::SetDay(value)` | | `0x07 ViewCombatTarget` | `ClientCombatSystem::TrackTarget(value)` | | `0x12 FellowshipAutoAcceptRequests` | if set → clears `IgnoreFellowshipRequests` | | `0x30 DisableDistanceFog` | `LScape::m_fFogEnabled = !value` | `CPlayerModule::OnInitialize @0x0059a690` applies four of these at login (`PersistentAtDay`, `DisableDistanceFog`, `DisableMostWeatherEffects`, `ViewCombatTarget`). **Everything else is consumed lazily at its point of use** (§2's "retail consumer" column). > **BN artifact note.** `OnInitialize`/`OnChanged` render the logical NOT > as `eax = -(eax); x = ((eax - eax) + 1)` — that is `neg` / `sbb eax,eax` > / `add eax,1`, i.e. `x = !value`. Reading it literally as arithmetic > would invert the weather and fog semantics. This is the exact artifact > class the chat digest's DO-NOT-RETRY table warns about. ### 1.6 Every option is also a bindable keyboard action `CPlayerSystem::OnAction(InputEvent*) @0x00561890` contains one `case` per option that does `Set