# Retail Options panel — structure, open path, Apply/Reset/Defaults, Chat + Config tab contents **Date:** 2026-08-10 **Lane:** Settings-track RESEARCH LANE A (handoff questions Q1 and Q3, plus the Chat/Config tab derivation). **Status:** RESEARCH ONLY — no production code changed, nothing built, nothing launched. **Handoff this answers:** `docs/research/2026-08-10-settings-track-handoff.md` §"Research questions" items 1 and 3, with partial pre-work on items 5 and 6. ## Primary sources and evidence discipline - `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Binary Ninja pseudo-C, Sept 2013 EoR build, PDB names) and `docs/research/named-retail/symbols.json`. - **Byte-verified** against the PDB-paired binary `C:\Users\erikn\Downloads\acclient.exe`. `py tools/pdb-extract/check_exe_pdb.py "C:/Users/erikn/Downloads/acclient.exe"` reports `=== MATCH: this exe pairs with our acclient.pdb ===` (linker UTC 2013-09-06T00:17:56, CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`). Every load-bearing constant below that BN rendered as `0` or as a zero-idiom was re-read from the raw section bytes with a throwaway PE reader (scratchpad `peread.py` / `decode_addchild.py`, not committed). - The **installed DATs** at `%USERPROFILE%\Documents\Asheron's Call\` read through `Chorizite.DatReaderWriter` 2.1.7 via `DatCollection` — the same reader production uses. Throwaway scratchpad probe (not committed); recipe per `docs/research/2026-08-10-retail-ui-text-style.md` §tooling. **NOTE:** a standalone `PortalDatabase`/`LocalDatabase` throws on `LayoutDesc` unpack (`writer.Database.DatCollection is null!`) — you must open a `DatCollection`. - The retail `UserPreferences.ini` sitting next to the user's DATs — a live retail artifact that independently cross-checks the Config tab's preference keys and default values. Everything marked UNKNOWN / UNVERIFIED below is genuinely not established. Nothing is guessed. --- ## 0. Headline Retail's Options panel is **one LayoutDesc** — `0x2100002B` — that carries the tab control, the four mounted tab pages, **and** the option-row template list, all in one file. The four pages are four separate `UIElement` classes registered by class id, each authored in its own LayoutDesc and mounted into the tab host by `BaseLayoutId`/`BaseElement`. The panel is opened by input action **`0x1000001A` = `ToggleOptionsPanel`** (F11 by default, and the toolbar's options button fires the same action). The Apply model is the opposite of what a modern designer would build: **every LED click applies immediately**. "Apply" only commits the undo baseline (and flushes the character-options blob); "Reset" reverts to that baseline; "Defaults" loads the retail default and applies it live without committing. Hiding a page auto-reverts uncommitted edits. --- ## 1. The class family and the layout inventory (Q1) ### 1.1 Classes | Retail class | Element class id | `Register` | `Create` | `InitOptions` | Purpose | |---|---|---|---|---|---| | `OptionPage` | — (abstract) | — | — | — | base: registered-option array + save/restore/changed | | `PlayerOptionPage : OptionPage` | — (abstract) | — | — | — | base: ListBox-backed row builders | | `gmGameplayOptionsUI` | `0x10000029` | `0x0049E0F0` | `0x0049E060` | *(none — pure button list)* | Tab 1 "Gameplay Options" | | `gmCharacterSettingsUI` | `0x10000027` | `0x004A02D0` | `0x004A01D0` | `0x004A02F0` | Tab 2 "Character" | | `gmChatOptionsUI` | `0x10000042` | `0x0049FC40` | `0x0049FBA0` | `0x0049FC60` | Tab 3 "Chat" | | `gmConfigUI` | `0x10000028` | `0x0049E8D0` | `0x0049E340` | `0x0049E400` | Tab 4 "Config" | | `gmKeyboardUI` | `0x1000000E` | `0x004DCF70` | `0x004DC080` | `0x004DD8B0` | "Configure Keyboard" screen (separate window) | `OptionPage` members (`symbols.json`): `RegisterOption 0x004F2E90`, `SaveCurrentValues 0x004F2C60`, `RestoreSavedValues 0x004F2D00`, `RestoreDefaultValues 0x004F2CB0`, `Changed 0x004F2D60`. `PlayerOptionPage` members: `AddHeader 0x004F28B0`, `AddSeperator 0x004F2730` *(retail's own spelling)*, `AddToggleOption` **two overloads** `0x004F2990` (`PStringBase` prefName) and `0x004F2A00` (`enum PlayerOption`), `AddToggleWithSliderOption 0x004F2930`, `AddSliderOption 0x004F2A70`, `AddMenuOption 0x004F2AF0`, `SetToggleLabel 0x004F2750`, `SetSliderLabel 0x004F2B80`, `OnOptionChanged 0x004F27D0`, `OnOptionChangeConfirmed 0x004F2850`, `OnVisibilityChanged 0x004F26E0`, `SaveCurrentValues 0x004F2710`. `UIOption` widget classes (the row leaves), by DynamicCast id: | DynamicCast id | Class | Row child element looked up | |---|---|---| | `0x10000035` | `UIOption_Checkbox` | `0x10000219` | | `0x10000036` | `UIOption_CheckboxSlider` | *(the row root itself)* | | `0x10000037` | `UIOption_Slider` | `0x1000021C` | | `0x10000038` | `UIOption_Menu` | `0x10000224` | | `0x10000044` | `UIOption_CheckboxBitfield64` | *(the row root itself)* | There is also `UIOption_ActionKeyMap` (keyboard screen) and `UIOption_CheckboxBitfield` (32-bit variant, `Apply 0x00486490` — **not used by any of the four Options tabs**). ### 1.2 LayoutDescs (all in `client_local_English.dat`) | LayoutDesc | Root element | Root `Type` | Size | What it is | |---|---|---|---|---| | **`0x2100002B`** | `0x10000208` | `0x00000008` (TabControl) | **300 × 600** | **The Options panel**: tab strip + 4 mounted pages + 7 row templates | | `0x2100002A` | `0x10000202` | `0x10000029` | 300 × 337 | Gameplay Options page | | `0x21000028` | `0x100001F9` | `0x10000027` | 292 × 600 | Character page | | `0x2100005C` | `0x1000050A` | `0x10000042` | 292 × 600 | Chat page | | `0x21000029` | `0x100001FF` | `0x10000028` | 292 × 600 | Config page | | `0x21000009` | `0x1000001F` | `3` | 800 × 600 | Configure Keyboard full-screen screen (contains `0x10000020` `T=0x1000000E`, 590 × 462) | Each page root also carries `P0x58 = enum 1` and a `P0x57` enum — see §2.3. ### 1.3 The tab control — element `0x10000208`, Type `8` Authored properties on the tab-host root: ``` P0x58 = enum 1 P0x57 = enum 0x1000001A <- the input action that toggles this window (see §2) P0x2E = ARRAY of 4 StructBaseProperty (MasterPropertyId 0x2F), each: 0x30 = 0x31 = 0x32 = bool (true on exactly one entry = the initially selected tab) ``` The four decoded entries, **in authored array order**: | # | `0x30` tab button | `0x31` page slot | `0x32` default | Tab label (StringId, table `0x23000001`) | |---|---|---|---|---| | 0 | `0x1000020D` (0,0) 106×25 | `0x10000212` | **True** | `0x0AFFD072` → **"Gameplay Options"** | | 1 | `0x1000020E` (106,0) 64×25 | `0x10000211` | False | **"Character"** | | 2 | `0x1000050B` (172,0) 50×25 | `0x1000050C` | False | **"Chat"** | | 3 | `0x1000020F` (222,0) 56×25 | `0x10000213` | False | **"Config"** | Left-to-right x order = 0, 106, 172, 222 → **Gameplay Options | Character | Chat | Config**, exactly matching the user's screenshot. Tab buttons all inherit base `0x2100004D/0x1000043A`. Additional children of `0x10000208`: | Element | Geometry | Notes | |---|---|---| | `0x10000209` | (0,25) 2×575, image | left border strip beside the page area | | `0x10000210` | (276,0) 24×25 | **close (X) button**; `P0x12 = enum 0x1000001A`; has `Normal` + `Normal_pressed` media | | `0x10000211` | (2,25) 298×575 | Character page slot ← base `0x21000028/0x100001F9` | | `0x10000212` | (2,25) 298×575 | Gameplay page slot ← base `0x2100002A/0x10000202` | | `0x1000050C` | (2,25) 298×575 | Chat page slot ← base `0x2100005C/0x1000050A` | | `0x10000213` | (2,25) 298×575 | Config page slot ← base `0x21000029/0x100001FF` | ### 1.4 Where the Options panel is *hosted* `0x2100002B/0x10000208` is mounted (by base reference) into exactly two host layouts, both at slot element `0x1000018D` inside a shared page-stack container `0x10000180`: | Host LayoutDesc | Root | Root type | Root size | Container | Slot key `P0x10000029` | |---|---|---|---|---|---| | `0x21000017` | `0x1000017B` | `0x10000008` = **`gmPanelUI`** (Register `0x004BC9AA`) | 309×482 @ (491,28), ZLevel 800 | `0x10000180` (9,120) 300×362 | `0x1000018D` → **10 (0xA)** | | `0x2100006E` | `0x100005FE` | `0x1000004F` = **`gmFloatyPanelUI`** (Register `0x004D1A1A`) | 310×372 | `0x10000180` (5,5) 300×362 | `0x1000018D` → **10 (0xA)** | `0x21000017` is the legacy docked right-hand panel (it also carries the radar, `0x1000003D` `T=0x10000010` = `gmRadarUI`). `0x2100006E` is the modern **floating, resizable** window frame — eight 5 px border strips (`0x10000653`–`0x1000065A`) plus `T=9` resize grips / `T=2` dragbar, which is the same shell family Campaign CH6 ported. The page-stack slot is authored 300×362; the panel content is authored 300×600, so retail's parent-resize policy scales it — and because the floaty frame is resizable, the user's tall screenshot is the resized state, not a third layout. The whole panel is 300 px wide and the option ListBox is 276 px wide with a 16 px scrollbar. > The same `0x10000180` stack holds ~16 sibling panels (allegiance, skills, attributes, > inventory, character info …), each keyed by `P0x10000029`. The Options panel's key is 10. ### 1.5 The row mechanism — a ListBox with an authored template list `PostInit` on each page fetches its ListBox and stores it as `m_pOptionBox`: | Page | `GetChildRecursive(this, …)` | ListBox element | Cast | |---|---|---|---| | Character | `0x100001FA` (`gmCharacterSettingsUI::PostInit @0x004A0280`) | `0x100001FA` 276×560, `T=5` | `DynamicCast(5)` | | Config | `0x10000200` (`gmConfigUI::PostInit @0x0049E840`) | `0x10000200` 276×560, `T=5` | `DynamicCast(5)` | | Chat | `0x1000050D` (`gmChatOptionsUI::PostInit @0x0049F100`) | `0x1000050D` 276×560, `T=5` | `DynamicCast(5)` | Every row is created by `UIElement_ListBox::AddItemFromTemplateList(m_pOptionBox, , nullptr)`. **The template list is authored on the ListBox as property `0x64`** — an array of `StructBaseProperty` where `0x63` = template LayoutDesc DID and `0x62` = template element id. All templates live in `0x2100002B`. **Template arrays as authored (index → element):** | idx | Character (`0x100001FA`) | Config (`0x10000200`) | Chat (`0x1000050D`) | Template element | Builder | |---|---|---|---|---|---| | 0 | `0x10000216` | `0x10000216` | `0x10000216` | 292×22, base `0x2100003F/0x1000038C` | `AddHeader` | | 1 | `0x10000217` | `0x10000217` | `0x10000217` | 292×8, image | `AddSeperator` | | 2 | `0x10000218` | `0x10000218` | `0x10000218` | 272×20, child `0x10000219` (checkbox) | `AddToggleOption` | | 3 | — | `0x1000021A` | `0x1000021A` | 272×20, label `0x1000021B` + slider `0x1000021C` | `AddSliderOption(arg3 == 0)` | | 4 | — | `0x10000222` | `0x10000222` | 272×20, label `0x10000223` + menu `0x10000224` | `AddMenuOption` | | 5 | — | `0x10000220` | `0x10000220` | 272×20, `T=0x10000036` | `AddToggleWithSliderOption` | | 6 | — | `0x1000021D` | `0x1000021D` | 272×**36**, + range labels `0x1000021E`/`0x1000021F` | `AddSliderOption(arg3 != 0)` | | 7 | — | `0x10000221` | `0x10000221` | 272×**36**, `T=0x10000036` + range labels | *(unused by the four tabs)* | | 8 | — | — | `0x10000520` | 272×100, `T=0x10000044` | `CheckboxBitfield64` block | `0x10000521` (272×20, child `0x10000219` with base `0x21000044/0x10000329`) exists in `0x2100002B` but is **not** in any of the three template arrays — UNKNOWN consumer. **Byte-verified slider index selection.** BN renders `PlayerOptionPage::AddSliderOption`'s index as `(((eax - eax) & 3) + 3)`, which is the known `neg`/`sbb` zero-idiom artifact class. The real instruction sequence at `0x004F2A80`–`0x004F2A90` is `neg eax; sbb eax,eax; and eax,3; add eax,3`, i.e. **index 6 when `arg3 != 0`, index 3 when `arg3 == 0`**. This is corroborated behaviourally: every `AddSliderOption(..., 1)` call in `gmConfigUI::InitOptions` is followed by a `SetSliderLabel` (which writes children `0x1000021E`/`0x1000021F` — present only on template 6), and the one `AddSliderOption(..., 0)` call (`Input_MouseLookSensitivity`) is not. `PlayerOptionPage::AddHeader` casts the created row to `0xC` (`UIElement_Text`) and writes `StringInfo::SetStringIDandTableEnum(&si, arg2, 0x10000003)`. **Table enum `0x10000003` resolves to StringTable DID `0x23000003`** — confirmed by hashing every `ID_*` label name below and finding it in that table. `SetToggleLabel` writes the same table for both label (`UIElement_Text::SetStringInfo`) and tooltip (`vtable+0x2B4`). --- ## 2. How the panel opens and closes (Q1c) ### 2.1 Keybind — action `0x1000001A` = `ToggleOptionsPanel`, default **F11** - `docs/research/named-retail/keymap-default.txt:139` — `F11 (scan=0x57, dev=0) Action=0x1000001A Activation=0x03` - `docs/research/named-retail/retail-default.keymap.txt:148` — `ToggleOptionsPanel [ "" [ 0 DIK_F11 ] ]` under the `UICommands` group. acdream's `KeyBindings.RetailDefaults()` already carries this row (F11 opens the current non-retail settings panel). ### 2.2 Toolbar button — `0x1000019B` in `LayoutDesc 0x21000016` From the committed fixture `tests/AcDream.App.Tests/UI/Layout/fixtures/toolbar_21000016.json`: ``` /0x10000191/0x1000019B T=0x00000001 (UIElement_Button) pos=(204,0) 34x27 P0x12 = Enum 268435482 = 0x1000001A Normal media 0x0600051C Highlight media 0x0600051E ``` Property `0x12` on a `UIElement_Button` is the **input-action enum the button fires** — already established by Campaign CH (`docs/research/2026-08-09-chat-retail-window-shell.md` lines 208-214, `UIElement_Button` reading its own `0x12` at `0x00471E65`). The dispatch is `UIElementManager::BroadcastGlobalMessage(inst, 1, )` — global message **1 = "an input action fired"**, param = the action id (confirmed by `gmToolbarUI::ListenToGlobalMessage @0x004BE4E0`, which switches on `arg3` in `0x10000042…0x1000004D` to fire shortcut slots). For orientation, the toolbar's full action row (same fixture): | Element | x | Action `P0x12` | |---|---|---| | `0x10000197` | 55 | `0x1000000D` | | `0x10000198` | 85 | `0x10000010` | | `0x10000199` | 115 | `0x10000013` | | `0x1000055A` | 145 | `0x100001E7` | | `0x1000019A` | 175 | `0x10000016` | | **`0x1000019B`** | **204** | **`0x1000001A` — Options** | | `0x100001B1` | 238 | `0x10000019` | ### 2.3 Close — the same action The tab-host close button `0x10000210` carries `P0x12 = 0x1000001A`, i.e. it fires the same `ToggleOptionsPanel` action. The tab-host root `0x10000208` carries `P0x57 = 0x1000001A`. Each page root carries its own `P0x57`: Gameplay `0x1000001B`, Character `0x1000001C`, Config `0x1000001D`, Chat `0x10000113`. > **UNVERIFIED:** the exact semantics of property `0x57`. The correlation (host `0x57` == > close button's `0x12`) makes "the toggle action this element registers itself under" the > obvious reading, but I did not locate the `GetAttribute_Enum(this, 0x57, …)` read site in > the decomp. Treat as strongly-indicated, not proven. --- ## 3. Apply / Reset / Defaults (Q3) ### 3.1 The three buttons `gmCharacterSettingsUI::ListenToElementMessage @0x0049E3A0` — **the same address is bound to `gmChatOptionsUI::ListenToElementMessage` and `gmConfigUI::ListenToElementMessage`** in the PDB (identical-COMDAT folding; all three pages share one handler body): ``` 0049e3b5 if (idElement == 0x100001FC) this->vtable->SaveCurrentValues(); // APPLY 0049e3b8 if (idElement == 0x100001FD) this->vtable->RestoreSavedValues(); // RESET 0049e3bb if (idElement == 0x100001FE) this->vtable->RestoreDefaultValues(); // DEFAULTS ``` Authored geometry (identical on all three pages that have them — Character `0x21000028`, Config `0x21000029`, Chat `0x2100005C`; all base `0x21000040/0x100002CD`, 80×32, y = 564): | Element | x | Label | |---|---|---| | `0x100001FC` | 16 | **Apply** (`0x00A09C49` on Character, `0x09BA3C39` on Config/Chat — different StringIds, same literal) | | `0x100001FD` | 106 | **Reset** (`0x00D024B4` / `0x09CA84C4`) | | `0x100001FE` | 196 | **Defaults** (`0x051FC993` / `0x0F183833`) | **The Gameplay Options page (`0x2100002A`) has no Apply/Reset/Defaults** — it is a pure button list. This matches the user's screenshot note ("Apply / Reset / Defaults at the bottom of (at least) the Character tab"). ### 3.2 Per-tab, not global Each page is its own `OptionPage` with its own `m_UIOptionArray`. Apply/Reset/Defaults act on **that page's registered options only**. The Character page's Apply does not touch the Config page's sliders. ### 3.3 What each one does `OptionPage` (all three iterate `m_UIOptionArray` and `&=` the results, then call `this->vtable->OnOptionChanged(0)`): | Button | `OptionPage` method | Per-option call | Gated on `Changed()`? | |---|---|---|---| | Apply | `SaveCurrentValues @0x004F2C60` | vtable `+0x2C8` = `SaveCurrentValue()` | **No** — every option | | Reset | `RestoreSavedValues @0x004F2D00` | vtable slot `RestoreSavedValue()` | **Yes** — only options whose `+0x2BC Changed()` is true | | Defaults | `RestoreDefaultValues @0x004F2CB0` | vtable `+0x2C4` = `RestoreDefaultValue()` | **No** — every option | `OptionPage::Changed @0x004F2D60` returns true if **any** registered option's `Changed()` is true. Leaf semantics (`UIOption_Checkbox`, the canonical case): ``` Changed @0x004868C0 return m_saved != m_current; SaveCurrentValue @0x004868E0 m_current = GetValue(); m_saved = m_current; return 1; RestoreSavedValue @0x00486900 m_current = m_saved; Apply(0); Refresh(); RestoreDefaultValue@0x00486930 m_current = m_default; Apply(0); Refresh(); SetDefaultValue @0x00486960 m_default = arg2; SetCurrentValue @0x00486970 m_current = arg2; SetAttribute_Bool(this, 0xE, arg2); Apply(1); ``` **The load-bearing consequence: `SetCurrentValue` — which is what the user's click on the LED runs — calls `Apply(1)` immediately.** The option takes effect in the live game the instant you click it. `Apply` is therefore *not* "now do the thing"; it is "commit this as the new undo baseline". - **Apply** = `m_saved = m_current` per option ⇒ `Changed()` goes false ⇒ Reset can no longer undo. (Plus the blob flush, §3.5.) - **Reset** = `m_current = m_saved` and re-`Apply(0)` ⇒ reverts the live game state to the last committed baseline. - **Defaults** = `m_current = m_default` and `Apply(0)` ⇒ pushes the retail default into the live game state, but does **not** commit; `Changed()` becomes true, so Apply/Reset light up and Reset still undoes it. Defaults comes from `SetDefaultValue`, which is authored *in code* in each page's `InitOptions` (Config and Chat) or is UNKNOWN-defaulted (Character — see below). ### 3.4 Button enable/disable `PlayerOptionPage::OnOptionChanged @0x004F27D0`: ``` eax = GetChildRecursive(, 0x100001FC); // Apply eax_1 = GetChildRecursive(, 0x100001FD); // Reset if (OptionPage::Changed(...) == 0) { SetState(0x0D) on both } // disabled else { SetState(0x01) on both } // enabled ``` **Defaults (`0x100001FE`) is never enable-gated — it is always clickable.** `PostInit` calls `InitOptions()` then `OnOptionChanged(0)` so the buttons start disabled. `PlayerOptionPage::OnOptionChangeConfirmed @0x004F2850` calls back into the owning element when `arg3 != 0`. ### 3.5 What Apply *sends* — the character-options blob and its dirty timer `PlayerOptionPage::SaveCurrentValues @0x004F2710` overrides the base and does **two** things: ``` 004f271d CPlayerModule::SaveToServer(&CPlayerSystem::GetPlayerSystem()->playerModule, 0); 004f2725 return OptionPage::SaveCurrentValues(this); // tailcall ``` `CPlayerModule::SaveToServer @0x0059A660`: ``` if (m_bDirty != 0 || arg2 != 0) CM_Character::Event_CharacterOptionsEvent(&this->vtable); // 0x006A10C0 — the full blob m_bDirty = 0; ``` `CM_Character::Event_CharacterOptionsEvent @0x006A10C0` is the full-blob `SetCharacterOptions` sender (**the `0x01A1` builder that CH3 deleted — exact field layout is Lane B / handoff Q4**). The dirty flag is set by `CPlayerModule::OnChanged`: - `OnChanged(BaseProperty*, uint32) @0x0059A890` — sends `CM_UI::SendNotice_GameplayOptionChanged`, then, **only if not already dirty**, sets `m_bDirty = 1` (offset `+0x1A8`) and stamps `m_timeFirstDirtied` (offset `+0x1AC`, double) with `Timer::cur_time`. - `OnChanged(PlayerOption) @0x0059A8E0` — sends `CM_UI::SendNotice_PlayerOptionChanged`, applies the immediate local side effects (case 4 → `SmartBox::EnableWeather`, case 5 → `LScape::SetDay`, case 7 → `ClientCombatSystem::TrackTarget`, case `0x30` → `LScape::m_fFogEnabled`, cases 2/`0x12` → the mutual exclusion between `IgnoreFellowshipRequests` and `FellowshipAutoAcceptRequests`), then: ``` 0059a99d if (CPlayerModule::IsAutoSaveOption(this-4, arg2)) 0059a9ac CM_Character::Event_PlayerOptionChangedEvent(arg2, GetOption(this, arg2)); // SINGLE-option send 0059a9c1 else { if (!dirty) { dirty = 1; m_timeFirstDirtied = Timer::cur_time; } } ``` **So retail has BOTH paths.** An option that `CPlayerModule::IsAutoSaveOption` returns true for is sent immediately as a single-option event (this is the `SetSingleCharacterOption 0x0005` family acdream already implements). Everything else is batched into the blob. *Which options are auto-save is UNKNOWN here — enumerate `CPlayerModule::IsAutoSaveOption` in Lane B; it is the discriminator for "0x0005 per change vs one 0x01A1 blob".* **Byte-verified auto-flush timer** — `CPlayerModule::UseTime @0x0059A710`: ``` 0059a713 mov al, [esi+0x1A8] ; m_bDirty 0059a719 test al, al 0059a71b jz 0059a749 ; not dirty -> return 0059a71d fld qword [0x007E5358] ; constant 0059a723 fadd qword [esi+0x1B0] ; + m_timeFirstDirtied 0059a729 fcomp qword [0x008369A8] ; vs Timer::cur_time 0059a72f fnstsw ax 0059a731 test ah, 0x41 0059a734 jp 0059a749 ; ST0 > cur_time (or unordered) -> skip 0059a736 lea eax,[esi+4] / push / call CM_Character::Event_CharacterOptionsEvent 0059a742 mov byte [esi+0x1A8], 0 ``` Raw bytes at `0x007E5358` = `00 00 00 00 00 00 7e 40` = **`480.0`**. So: *if any non-auto-save option is dirty, the blob is flushed automatically **480 seconds (8 minutes) after the first change**, measured from the first dirtying, not the last.* **Other `SaveToServer` callers:** `CPlayerSystem::LogOffCharacter @0x00563520` calls `SaveToServer(&playerModule, 0)` before `ExecuteLogOff`. Those are the only two call sites in the binary. ### 3.6 Showing/hiding a page auto-applies / auto-reverts `PlayerOptionPage::OnVisibilityChanged @0x004F26E0`: ``` UIElement::OnVisibilityChanged(this, arg2); if (arg2 != 0) this->vtable->SaveCurrentValues(); // becoming visible -> APPLY else this->vtable->RestoreSavedValues(); // becoming hidden -> RESET ``` **Switching tabs, or closing the Options window, silently reverts any uncommitted edits on the page you left.** This is a real, user-visible retail behaviour and must be ported, not "improved". ### 3.7 `UIOption::Apply` — the three write targets `UIOption_Checkbox::Apply @0x00486C60` branches on how the option was bound: 1. **`m_propName != 0`** → gameplay-option property bag: `BaseProperty::SetPropertyName(&p, m_propName)`, set value = `m_current`, then `PlayerModule::SetOption(pm, &p)` (`0x005D52C0` — `HashTable::set` into `m_colGameplayOptions` then `OnChanged(prop, 0)`). 2. **`m_prefName` path** → `UIPreferences::ModifyPreference(m_prefName, m_current)`, and on success `CM_UI::SendNotice_UserPreferenceChanged(m_prefName)`. This is the `UserPreferences.ini` path used by the whole Config tab. **Nothing goes over the wire.** *(BN renders the branch predicate as `m_playerOption == ~AutoRepeatAttack_PlayerOption`, which is a `not`/`cmp` artifact. The branch's BODY is unambiguous; the exact predicate is UNVERIFIED. It is almost certainly "no PlayerOption bound", i.e. the prefName binding.)* 3. **else** → `PlayerModule::SetOption(pm, m_playerOption, m_current)` (`0x005D3EB0` — a switch over `enum PlayerOption` writing the `options_` word bits and calling `OnChanged(option)`). This is the Character tab path. All three then call `this->vtable->HandleDialogAndNotices(arg2)`. `UIOption::HandleDialogAndNotices @0x004EFB90`: ``` if (m_confirmChange && arg2) UIOption::HandleDialog(this); if (m_shouldReloadOptions) CM_Character::SendNotice_ReloadOptions(); if (m_shouldRefreshOptionsPanel) CM_UI::SendNotice_RefreshOptionsPanel(); if (m_noticeFuncPtr) m_noticeFuncPtr(); if (m_pOCH && arg2) m_pOCH->OnOptionChanged(this); ``` So `Apply(1)` (user click) may raise the confirmation dialog and notifies the page (which re-runs `Changed()` and lights up Apply/Reset). `Apply(0)` (from Reset/Defaults) skips both — the page calls `OnOptionChanged(0)` itself afterwards. `UIOption::SetConfirmChange` is used exactly once on the four tabs: `gmConfigUI::InitOptions @0x0049E5BF` sets it on the `Display_Resolution` menu. `UIOption_CheckboxBitfield64::Apply @0x00485FF0` has the same shape but dispatches on `m_propType`: ``` 004860b3 if (m_propType == Normal_PropType) PlayerModule::SetOption(pm, &prop); 004860c4 else if (m_propType == 1) PlayerModule::SetChatWindowOption(pm, m_nUserData, &prop); ``` `m_nUserData` is set by `UIOption::SetUserData(option, )`. The other `SetChatWindowOption` call sites in the binary pass `this->m_eWindowID` — which independently confirms Campaign CH6's identity model (main = 8, floaties = 2..5) and confirms that the Chat tab's `SetUserData` argument *is* the retail window id. --- ## 4. The **Config** tab — complete row map (`gmConfigUI::InitOptions @0x0049E400`) Six sections, **6 headers / 5 interior separators + 1 trailing / 27 option rows** (corrected 2026-08-11 at the OP6 re-review — the original 24 was a miscount; the decomp has 27 `Add*Option` calls and the OP6 slice's 39-item count pin (6+6+27) is the authoritative tally). Bindings are `PStringBase` preference-key globals whose names are `Section_Key`; they map one-to-one onto `UserPreferences.ini` sections (verified against the user's live retail `UserPreferences.ini`). All headers byte-verified from `mov eax,[global]; push eax` operands. | # | Header global (VA) | StringId | Literal | |---|---|---|---| | 1 | `ID_Sound_SoundSection` (`0x0083E750`) | `0x09FB638E` | **Sound Options** | | 2 | `ID_Camera_CameraSection` (`0x0083E754`) | `0x08A8429E` | **Camera Options** | | 3 | `ID_Graphics_GraphicsSection` (`0x0083E758`) | `0x0D95A11E` | **Graphics Options** | | 4 | `ID_Graphics_TextureSection` (`0x0083E75C`) | `0x018580EE` | **Rendering Quality Options** | | 5 | `ID_Input_InputSection` (`0x0083E760`) | `0x01BAE38E` | **Input Options** | | 6 | `ID_UI_UISection` (`0x0083E764`) | `0x0D25AC1E` | **UI Options** | | Section | # | Widget | Preference key | `SetDefaultValue` | Decomp VA | |---|---|---|---|---|---| | Sound Options | 1 | Menu (UIPreference) | `Sound_SoundFeatures` | `0` | `0x0049E418` | | | 2 | Toggle+Slider | `Sound_SoundDisabled` / `Sound_SoundVolume` | `1`, `0x3F800000` = 1.0f | `0x0049E435` | | | 3 | Toggle+Slider | `Sound_AmbientSoundDisabled` / `Sound_AmbientSoundVolume` | `1`, 1.0f | `0x0049E457` | | | 4 | Toggle+Slider | `Sound_InterfaceSoundDisabled` / `Sound_InterfaceSoundVolume` | `1`, 1.0f | `0x0049E479` | | | 5 | Toggle | `Sound_PlaySoundOnlyWhenActive` | `1` | `0x0049E496` | | Camera Options | 6 | Slider (labelled) | `Camera_Stiffness` | `0x3EE66666` = **0.45f** | `0x0049E4C4` | | | 7 | Slider (labelled) | `Camera_AdjustmentSpeed` | `0x42200000` = **40.0f** | `0x0049E501` | | | 8 | Slider (labelled) | `Render_FieldOfView` | `0x42B40000` = **90.0f** | `0x0049E53E` | | | 9 | Toggle | `Camera_AlignToSlope` | `1` | `0x0049E571` | | Graphics Options | 10 | Menu (**UserPreference**, `arg3 == 0`) | `Display_Resolution` | `0x03200258` = **800 × 600**; `SetConfirmChange(1)` | `0x0049E5A5` | | | 11 | Toggle | `Display_FullScreen` | `1` | `0x0049E5CB` | | | 12 | Toggle | `Display_SyncToRefresh` | `0` | `0x0049E5E3` | | | 13 | Slider (labelled) | `Render_ScreenBrightness` | `0` = 0.0f | `0x0049E5FD` | | | 14 | Toggle | `Render_AutomaticDegrades` | `0` | `0x0049E62C` | | | 15 | Slider (labelled) | `Render_GraphicsPerformance` | `0` = 0.0f | `0x0049E646` | | | 16 | Slider (labelled) | `Render_DegradeDistance` | `0x42480000` = **50.0f** | `0x0049E677` | | Rendering Quality | 17 | Menu | `Render_LandscapeTextureDetail` | `2` | `0x0049E6BF` | | | 18 | Menu | `Render_EnvironmentTextureDetail` | `1` | `0x0049E6D9` | | | 19 | Menu | `Render_TextureFiltering` | `1` | `0x0049E6F3` | | | 20 | Menu | `Render_LandscapeDrawDistance` | `8` | `0x0049E70D` | | | 21 | Toggle | `Render_BuildingDetailTextures` | `1` | `0x0049E725` | | | 22 | Toggle | `Render_MultiPassAlpha` | `0` | `0x0049E73D` | | Input Options | 23 | Slider (**unlabelled**, `arg3 == 0`) | `Input_MouseLookSensitivity` | `0x3F0CCCCD` = **0.55f** | `0x0049E76B` | | | 24 | Toggle | `Input_InvertMouseLookYAxis` | `0` | `0x0049E78C` | | | 25 | Toggle | `Input_UseMouseTurning` | `0` | `0x0049E7AA` | | UI Options | 26 | Menu | `UI_ChatFontFace` | `2` | `0x0049E7DE` | | | 27 | Menu | `UI_ChatFontSize` | `1` | `0x0049E7F8` | *(27 `Add*` calls; the three toggle+slider rows are one widget each, so the visible row count is 27.)* **Cross-validated against the user's live retail `UserPreferences.ini`:** `Camera Stiffness = 0.45`, `AdjustmentSpeed = 40.00`, `FieldOfView = 90.00`, `SoundVolume/AmbientSoundVolume/ InterfaceSoundVolume = 1.00`, `ScreenBrightness = 0.00`. Every decomp default that the ini still holds at its default value matches exactly. Slider range labels come from `ID_Graphics_Value_*` globals at `0x0083E768`–`0x0083E794`: `Dark/Bright`, `Speed/Detail`, `Close/Far`, `Narrow/Wide`, `Slow/Fast`, `Soft/Hard` (six labelled sliders, six label pairs — the pairing order is UNVERIFIED; the exact `SetSliderLabel` operand pairs would need the same push-imm decode I ran on `AddHeader`). `gmConfigUI` extras: - `PostInit` also `RegisterNoticeHandler(0x4DD276, …)` and `UIListener::RegisterForGlobalMessage(this, 0x0C)`. - `ListenToGlobalMessage @0x0049EB90`: `if (arg2 == 0x0C) SetMouseTurningDefaults(this)`. - `SetMouseTurningDefaults @0x0049E8F0` clamps `m_pStiffness` to `0.95f` (when below) and prints a chat line (`"Camera Stiffness was changed fro…"`), plus the same treatment on `m_pAdjustmentSpeed`, `m_pSensitivity`, `m_pInvert`, `m_pMouseTurn`, `m_pSlope`. Exact clamp set is out of Lane A scope. - `RecvNotice_UserPreferenceChanged_Menu @0x0049EBA0` reacts to `Render_LandscapeTextureDetail` changes. **Not on the 2013 Config tab, though present in `UserPreferences.ini`:** `Render_SceneryDrawDistance`, `Render_DisplayAdapter`, `Render_AspectRatio`, `Render_LandscapeDetailTextures`, `Display_RefreshRate`, `Net_*`, `Misc_TooltipDelay`, `Misc_TooltipEnable` (globals exist at `0x0083EAA4`/`0x0083EAA8` and strings "Tooltip Delay" exists in table `0x23000003`), `International_UseIME`, `Input_MouseLookSmoothingAmount`, `Input_KeymapFile`. Those are either the external config/launcher's surface or a later build's rows. **UNKNOWN which.** --- ## 5. The **Chat** tab — complete row map (`gmChatOptionsUI::InitOptions @0x0049FC60`) Six sections. Headers byte-verified from the `mov ,[global]` operands: | # | Header global (VA) | Section content | |---|---|---| | 1 | `ID_ChatOption_GeneralOptions_Section` (`0x0083E810`) | the two opacity sliders | | 2 | `ID_ChatOption_MainChatWindow_Section` (`0x0083E814`) | filter block, `SetUserData(8)` | | 3 | `ID_ChatOption_FloatyChatWindow1_Section` (`0x0083E818`) | filter block, `SetUserData(2)` | | 4 | `ID_ChatOption_FloatyChatWindow2_Section` (`0x0083E81C`) | filter block, `SetUserData(3)` | | 5 | `ID_ChatOption_FloatyChatWindow3_Section` (`0x0083E820`) | filter block, `SetUserData(4)` | | 6 | `ID_ChatOption_FloatyChatWindow4_Section` (`0x0083E824`) | filter block, `SetUserData(5)` | *(Table `0x23000003` also holds "Chat Window 1"…"Chat Window 4" at `0x0DC3B9FE`/`0x0DC3B8FE`/ `0x0DC3B7FE`/`0x0DC3B6FE`; the six headers above are the ones actually pushed.)* ### 5.1 General Options — two linked opacity sliders | Slider | Template idx | Gameplay option property | Notes | |---|---|---|---| | 1 | **3** (unlabelled row) | `0x10000080` `Option_DefaultOpacity_Property` | `UIOption_Slider::SetGameplayOptionProperty @0x0049FCC0` | | 2 | **6** (labelled row) | `0x10000081` `Option_ActiveOpacity_Property` | `@0x0049FD1A`; then `SetSliderLabel(this, slider2, , )` | Range labels come from `ID_UI_Value_Transparent` (`0x0083E828`) and `ID_UI_Value_Opaque` (`0x0083E82C`). The two sliders are then **linked**: ``` 0049fd5c DualHash::add(&this->m_hashSliderLinks, &slider1, &slider2); ``` which is the "linking DRAGS the other value (never clamps)" behaviour CH6c already ported. ### 5.2 The five per-window text-type filter blocks Each block is one `UIOption_CheckboxBitfield64` created from **template index 8** (`0x10000520`, `T=0x10000044`, 272×100), bound with `SetGameplayOptionProperty(opt, 0x1000007F, Chat_PropType)` (`Chat_PropType == 1`, so `Apply` routes through `PlayerModule::SetChatWindowOption(pm, m_nUserData, prop)`), `SetUserData()`, `SetDefaultValue(, )`, then N `AddChild(lowMask, highMask, labelId, tooltipId)` and one `CreateChildren`. Windows 2/3/4 go through the shared helper `gmChatOptionsUI::AddCheckboxBitfield64Option @0x0049EDA0`; windows 8 and 5 are written inline in `InitOptions` (the helper is inlined at those two sites — the helper's own switch covers cases 2/3/4/5/8, so the semantics are identical). **Defaults, byte-verified from the helper's switch and the inline blocks:** | Retail window id | Default filter (high:low) | Site | |---|---|---| | **8** (main) | `0x00000000_FBFFFFFF` | `0x0049FDD1` / helper case 8 `0x0049EE16` | | **2** (floaty 1) | `0x00000000_0000101C` | helper case 2 `0x0049EE2B` | | **3** (floaty 2) | `0x00000000_00040C00` | helper case 3 `0x0049EE34` | | **4** (floaty 3) | `0x00000000_00080000` | helper case 4 `0x0049EE3D` | | **5** (floaty 4) | `0x00000000_78000000` | helper case 5 `0x0049EE46` / inline `0x004A0010` | The main window's default `0xFBFFFFFF` matches CH6's finding exactly (Society bit `0x20…` opt-in). **The checkbox rows** — byte-decoded from the `mov ,[global]; push; push; push high; push low; call AddChild` sequences (`decode_addchild.py` over the raw section bytes; masks are 64-bit, `high:low`): | Row | Mask (high:low) | Label global | Label literal (table `0x2300000D` — CORRECTED 2026-08-11 at #372: the TextFilter label/tooltip family lives in its OWN table, dat-verified by exhaustive sweep; the original 0x23000003 attribution was inferred) | |---|---|---|---| | **0** | `0x00000000_83912021` | `ID_ChatOption_TextFilter_Gameplay` (`0x0083E840`) | Gameplay | | 1 | `0x00000000_00600040` | `ID_ChatOption_TextFilter_Combat` (`0x0083E830`) | Combat | | 2 | `0x00000000_00020080` | `ID_ChatOption_TextFilter_Magic` (`0x0083E848`) | Magic | | 3 | `0x00000000_00001004` | `ID_ChatOption_TextFilter_AreaSpeech` (`0x0083E838`) | Area Speech | | 4 | `0x00000000_00000018` | `ID_ChatOption_TextFilter_Tells` (`0x0083E850`) | Tells | | 5 | `0x00000000_00040C00` | `ID_ChatOption_TextFilter_Allegience` (`0x0083E858`) | Allegiance *(retail's own misspelling in the symbol)* | | 6 | `0x00000000_00080000` | `ID_ChatOption_TextFilter_Fellowship` (`0x0083E860`) | Fellowship | | 7 | `0x00000000_08000000` | `ID_ChatOption_TextFilter_General` (`0x0083E868`) | General | | 8 | `0x00000000_10000000` | `ID_ChatOption_TextFilter_Trade` (`0x0083E870`) | Trade | | 9 | `0x00000000_20000000` | `ID_ChatOption_TextFilter_LFG` (`0x0083E878`) | LFG | | 10 | `0x00000000_40000000` | `ID_ChatOption_TextFilter_Roleplay` (`0x0083E880`) | Roleplay | | 11 | `0x00000001_00000000` | `ID_ChatOption_TextFilter_Society` (`0x0083E888`) | Society | | 12 | `0x00000000_04000000` | `ID_ChatOption_TextFilter_Error` (`0x0083E890`) | Error | Every row's tooltip is the matching `…_Desc` global at label+4. **Row 0 ("Gameplay") is present on windows 2/3/4/5 and ABSENT on window 8 (main).** Verified two ways: the inline main-window block at `0x0049FDF6`–`0x0049FF1C` emits exactly 12 `AddChild` calls starting at Combat, while the inline window-5 block at `0x004A002D`– `0x004A016F` emits 13 starting at Gameplay; and in the helper, `case 8` breaks past the Gameplay `AddChild` at `0x0049EE6C` while cases 2/3/4/5 fall through the shared `label_49EE4F` into it. **Retail does not let you switch gameplay text off in the main chat window.** So the Chat tab is: **2 sliders + 5 blocks × (12 or 13) checkboxes = 2 + 12 + 13×4 = 66 interactive controls**, in 6 sections. --- ## 6. The **Gameplay Options** tab — complete map (`LayoutDesc 0x2100002A`) Seven buttons, all base `0x21000040/0x10000618`, all 240×32 at x = 26, no LEDs, no Apply/Reset/Defaults. Handler: `gmGameplayOptionsUI::ListenToElementMessage @0x0049E110` (only reacts to `idMessage == 1`). | y | Element | Label (table `0x23000001`) | What retail does | |---|---|---|---| | 20 | `0x10000203` | `0x071D9AC4` **Exit to Character Selection** | `CM_UI::SendNotice_EndCharacterSession(1)` @ `0x0049E1D0` | | 60 | `0x10000617` | `0x0BB75264` **Exit Game** | `BroadcastGlobalMessage(inst, 1, 0x10000027)` @ `0x0049E298`–`0x0049E2AB` — fires input action `0x10000027` | | 110 | `0x10000204` | `0x0694E944` **Configure Keyboard** | *not handled in C++*; authored `P0x12 = enum 0x1000001F` → the button self-fires action `0x1000001F` | | 150 | `0x100005CC` | `0x0A35EE57` **Use Mouse Turning Settings** | `BroadcastGlobalMessage(inst, 0x0C, 0)` @ `0x0049E2A1`–`0x0049E2AB` → `gmConfigUI::SetMouseTurningDefaults` | | 190 | `0x10000205` | `0x0BB03250` **In-Game Help Files** | *not handled in C++*; authored `P0x12 = enum 123 (0x7B)` = the `ToggleHelp` action (F1 in `keymap-default.txt:132`) | | 240 | `0x10000206` | `0x0145FCA5` **Urgent Assistance** | `ShellExecuteA(nullptr,"open", "http://support.turbine.com/ics/s…", …)` @ `0x0049E154`–`0x0049E16B`; on failure `MessageBoxA(…, "Asheron's Call Error", MB_OK)` | | 280 | `0x10000207` | `0x02F2FD45` **Report Abuse** | same `ShellExecuteA` shape @ `0x0049E1F0`–`0x0049E207`, different URL | The button-branch dispatch is byte-verified (`0x0049E28C`: `sub eax,0x100005CC; jz → push 0 / push 0x0C; sub eax,0x4B; jnz → fall through; push 0x10000027 / push 1` — `0x100005CC + 0x4B == 0x10000617` ✓). **Neither "Urgent Assistance" nor "Report Abuse" sends a game message.** Both are external browser launches. The two URLs are `support.turbine.com` support-ticket links (both truncated at BN's ~33-char preview; the full strings were not extracted because they are dead links today and acdream will not open them — **flagged for the planner as a deliberate divergence candidate**). **UNKNOWN / UNVERIFIED:** - Action `0x10000027` ("Exit Game") — no default keybind, and I did not identify its consumer. That it means "quit" rests on the button's authored label only. - Action `0x1000001F` ("Configure Keyboard") — no default keybind. Strongly corroborated: the Configure Keyboard screen's own **OK** (`0x1000002C`) and **Cancel** (`0x1000002D`) buttons *also* carry `P0x12 = 0x1000001F`, which is exactly the toggle-window pattern. --- ## 7. The **Character** tab — structural inventory (full option map is Lane B / Q2) `gmCharacterSettingsUI::InitOptions @0x004A02F0` — **6 headers, 5 interior separators + 1 trailing, 49 toggle rows, no sliders, no menus.** Every row is `SetToggleLabel(this, AddToggleOption(this, ), ID_PlayerOption_X, ID_PlayerOption_X_Help)` (the `enum PlayerOption` overload, `0x004F2A00`), so the tab is homogeneous. | Header global (VA) | StringId | Literal | Rows | |---|---|---|---| | `ID_CharacterOption_UIBehavior_Section` (`0x0083EA5C`) | `0x06489B6E` | User Interface Behavior | **3** | | `ID_CharacterOption_UIDisplay_Section` (`0x0083EA60`) | `0x0A9BC99E` | User Interface Display | **15** | | `ID_CharacterOption_Grouping_Section` (`0x0083EA64`) | `0x0CBAAFAE` | Grouping | **6** | | `ID_CharacterOption_OtherPlayers_Section` (`0x0083EA68`) | `0x0872DFFE` | Other Players | **11** | | `ID_CharacterOption_CharacterBehavior_Section` (`0x0083EA6C`) | `0x08674D5E` | Character Behavior | **7** | | `ID_CharacterOption_Chat_Section` (`0x0083EA70`) | `0x0987FE8E` | Chat | **7** | All six header globals byte-verified from their `mov eax,[0x0083EA5C+4n]; push eax` operands at `0x004A02EA`, `0x004A0431`, `0x004A0ABF`, `0x004A0D63`, `0x004A122A`, `0x004A154B`, and all six hashes verified name→`compute_str_hash`→string-table entry. Rows in authored order (the `PlayerOption` enumerator names, for Lane B to bind): 1. `ViewCombatTarget`, `SalvageMultiple`, `MainPackPreferred` 2. `VividTargetingIndicator`, `ShowTooltips`, `CoordinatesOnRadar`, `SideBySideVitals`, `SpellDuration`, `DisableMostWeatherEffects`, `DisableDistanceFog`, `PersistentAtDay`, `DisableHouseRestrictionEffects`, `UseCraftSuccessDialog`, `ConfirmVolatileRareUse`, `DisplayTimeStamps`, `FilterLanguage`, `ShowHelm`, `ShowCloak` 3. `IgnoreAllegianceRequests`, `IgnoreFellowshipRequests`, `DisplayAllegianceLogonNotifications`, `FellowshipShareXP`, `FellowshipShareLoot`, `FellowshipAutoAcceptRequests` 4. `AcceptLootPermits`, `UseDeception`, `AllowGive`, `IgnoreTradeRequests`, `DragItemOnPlayerOpensSecureTrade`, `DisplayDateOfBirth`, `DisplayAge`, `DisplayChessRank`, `DisplayFishingSkill`, `DisplayNumberDeaths`, `DisplayNumberCharacterTitles` 5. `ToggleRun`, `AdvancedCombatUI`, `AutoTarget`, `AutoRepeatAttack`, `UseChargeAttack`, `LeadMissileTargets`, `UseFastMissiles` 6. `StayInChatMode`, `HearAllegianceChat`, `HearGeneralChat`, `HearTradeChat`, `HearLFGChat`, `HearRoleplayChat`, `HearSocietyChat` This is a **1:1 match with the user's transcribed screenshots — with exactly one exception.** > ### ⚠ Build-version divergence: "Listen to PK death messages" > The user transcribed an 8th row in the Chat group. The **2013 EoR build's > `gmCharacterSettingsUI::InitOptions` has only 7** — it ends at `HearSocietyChat` and then > `AddSeperator`. However, StringTable `0x23000003` **does** contain > `0x0D16E9A3 = "Listen to PK death messages."`, and > `compute_str_hash("ID_PlayerOption_HearPKDeaths") == 0x0D16E9A3` exactly. There is **no** > `HearPKDeath*` reference anywhere in the 2013 pseudo-C. > > **Conclusion:** the row was added after the Sept 2013 build; the user's screenshot is from a > later client. The installed DATs carry the string, so acdream can render it, and ACE > exposes the matching option. **Lane B must decide whether acdream ships 49 or 50 rows** — > this is a real fork, not a transcription error. Note also `ID_PlayerOption_ShowHelm` resolves differently in the two tables (`0x23000003`: "Show Your Helm or Head Gear" = the label; `0x23000005`: "Your helmet or head gear is visible." = a status line). The options UI uses table enum `0x10000003` → `0x23000003`. **No `SetDefaultValue` call appears anywhere in `gmCharacterSettingsUI::InitOptions`** — so every Character-tab checkbox's `m_default` stays at whatever `UIOption_Checkbox`'s constructor leaves it. That means the **Defaults** button on the Character tab does *not* restore ACE's `CharacterOptions1.Default = 0x50C4A54A`; its behaviour is UNVERIFIED and must be established before implementing (read `UIOption_Checkbox`'s ctor, or `UIOption::InqDefaultGameplayOptionProperty @ symbols.json`). **This is the single biggest open unknown in the Apply/Reset/Defaults story.** --- ## 8. Configure Keyboard (pre-work for handoff Q5) `LayoutDesc 0x21000009` — a **full-screen 800×600** layout (root `0x1000001F`, `T=3`), frame `0x10000451` (600×476 @ 100,62, base `0x21000042/0x100002B0`), containing `0x10000020` `T=0x1000000E` = `gmKeyboardUI`, 590×462 @ (5,7). | Element | Geometry | Content | |---|---|---| | `0x1000049B` | 586×388, `T=8` (TabControl) | 6 tabs: **Movement** `0x05330A42`, **Camera** `0x0F5EE5E2`, **Combat** `0x0F1E0AC2`, **UI** `0x06FF16E2`, **Emotes** `0x0A3A04A2`, **CharacterSettings** `0x03765382` | | `0x100004A7` | 586×363 | the binding grid: column headers **Command** `0x0FDD849C`, **Mapping 1** `0x0D11C1CC`, **Mapping 2** `0x0D41C1CC`, **Mapping 3** `0x0D31C1CC`; ListBox `0x10000025` (570×345, `T=5`) + scrollbar `0x10000026` | | `0x1000002F` | 570×40, `T=0x10000034` | the `UIOption_ActionKeyMap` row template | | `0x10000027` | (50,390) 100×32 | **Load File…** `0x06E9046E` | | `0x10000028` | (150,390) 290×32 | current keymap filename label | | `0x10000029` | (440,390) 100×32 | **Save As…** `0x048840AE` | | `0x1000002A` | (0,430) 100×32 | **Defaults** `0x039D522E` | | `0x1000002B` | (100,430) 100×32 | **Revert** `0x077FC0AE` | | `0x1000002C` | (390,430) 100×32 | **OK** `0x0055D40E`, `P0x12 = 0x1000001F` | | `0x1000002D` | (490,430) 100×32 | **Cancel** `0x08653BBE`, `P0x12 = 0x1000001F` | Supporting decomp: `gmKeyboardUI::AddActionKeyMap 0x004DB2F0`, `GetKeymapDirectory 0x004DA8E0`, `GetStringInfoFromInputMapID 0x004DA980`, `SaveKeymap 0x004DCF90` (appends `.keymap` when the extension is missing), `MakeLoadKeymapDialog 0x004DC0B0`, `MakeSaveKeymapDialog 0x004DC5B0`, `MakeOverwriteKeymapDialog 0x004DCA20`, `MakeCantOverwriteReadOnlyKeymapDialog 0x004DC7B0`, `RestoreDefaultValues 0x004DA850`, `UpdateKeymapFilenameLabel 0x004DB290`, and the `UIOption_ActionKeyMap` family (`InitiateBinding`, `SetBinding`, `EraseBinding`, `ClearAllBindings`, `KeyHitHandler`, `OpenOverwriteBindingDialog`, `OpenMapWarnDialog`, `SetMouseTurningDefaults`). Storage is the on-disk `.keymap` file named by `[Input] KeymapFile` in `UserPreferences.ini` — **nothing keyboard-related goes over the wire**. The user's own DAT dir currently holds `acclient.keymap` and `test.keymap`. **Note the retail keyboard screen is NOT one of the four Options tabs** — it is a separate 800×600 modal screen reached by the "Configure Keyboard" button. --- ## 9. Binary-Ninja artifacts encountered (add to the DO-NOT-RETRY class list) 1. **`AddHeader(this, 0)` is never literally 0.** BN prints the argument as `0` because it is `mov eax, []; push eax`. The real values are the twelve `ID_*_Section` string-hash globals at `0x0083E750`+, `0x0083E810`+, `0x0083EA5C`+. Every header in this document was recovered from the raw operand bytes. 2. **`AddChild(..., 0, 0)` likewise drops the label/tooltip operands.** They too are `mov ,[]; push ` pairs. All 13 Chat-filter labels came from a push-operand decode, not from the pseudo-C. 3. **`(((eax - eax) & 3) + 3)`** in `AddSliderOption` is the `neg`/`sbb` zero-idiom class already in the chat digest. Real result: `arg3 ? 6 : 3`. 4. **Identical-COMDAT folding aliases symbols.** `gmCharacterSettingsUI::ListenToElementMessage`, `gmChatOptionsUI::ListenToElementMessage`, and `gmConfigUI::ListenToElementMessage` all resolve to `0x0049E3A0`; `gmGameplayOptionsUI::ListenToGlobalMessage` and `gmKeyboardUI::ListenToGlobalMessage` both to `0x004F5860`; `gmGameplayOptionsUI::PostInit` is listed at `0x004BFA00`, which the pseudo-C prints as `gmCGProfessionPage::PostInit`. **A shared address does not mean the class actually implements that behaviour** — verify the body is generic before attributing it. 5. **BN field-name mush.** `CPlayerModule::OnChanged` writes what BN calls `this->m_TimeStampFormat.m_buffer`; the byte-level truth is `mov byte [esi+0x1A8], 1` — the `m_bDirty` flag that `UseTime` reads. Trust the offsets, not the names. 6. **`m_playerOption == ~AutoRepeatAttack_PlayerOption`** in `UIOption_Checkbox::Apply` is a rendering of a `not`/`cmp` pair. The branch body is unambiguous (the UIPreferences path); the predicate is not. --- ## 10. For the planner ### 10.1 Structural inventory in one place | Thing | Value | |---|---| | Options panel LayoutDesc | **`0x2100002B`** (`client_local_English.dat`) | | Tab-control root element | `0x10000208`, `Type 8`, authored 300 × 600 | | Tab table property | `0x2E` = array of Structs `{0x30 = button id, 0x31 = page id, 0x32 = isDefault}` | | Tab buttons (L→R) | `0x1000020D` Gameplay(default) · `0x1000020E` Character · `0x1000050B` Chat · `0x1000020F` Config | | Close button | `0x10000210`, fires action `0x1000001A` | | Page slots | `0x10000212` · `0x10000211` · `0x1000050C` · `0x10000213`, all (2,25) 298×575 | | Page layouts | `0x2100002A` · `0x21000028` · `0x2100005C` · `0x21000029` | | Page classes | `0x10000029` · `0x10000027` · `0x10000042` · `0x10000028` | | Option ListBoxes | `—` · `0x100001FA` · `0x1000050D` · `0x10000200` (all 276 × 560, `T=5`) | | Scrollbars | `—` · `0x100001FB` · `0x10000201` · `0x10000201` (16 × 560; ListBox `P0x72` names it) | | Row template list | ListBox property `0x64`, entries `{0x63 = layout DID, 0x62 = element id}` | | Row templates (all in `0x2100002B`) | header `0x10000216` · separator `0x10000217` · toggle `0x10000218` · slider `0x1000021A`/`0x1000021D` · menu `0x10000222` · toggle+slider `0x10000220`/`0x10000221` · bitfield64 `0x10000520` | | Apply / Reset / Defaults | `0x100001FC` / `0x100001FD` / `0x100001FE`, 80×32 at y 564 | | Host windows | `0x21000017` (`gmPanelUI`, docked) and `0x2100006E` (`gmFloatyPanelUI`, floating + resizable), slot `0x1000018D`, stack key `P0x10000029 = 10` | | Open action | `0x1000001A` `ToggleOptionsPanel`, default **F11**; toolbar button `0x1000019B` in `0x21000016` | | Row counts | Gameplay **7 buttons** · Character **6 headers / 49 toggles** · Chat **6 headers / 2 sliders / 5 bitfield blocks (12+13+13+13+13 = 64 checkboxes)** · Config **6 headers / 27 rows** | | String tables | tab/button labels → `0x23000001`; option labels + tooltips + section headers → `0x23000003` (table enum `0x10000003`) | ### 10.2 What acdream can reuse from CH6 as-is - **`LayoutImporter`** already handles the exact mechanisms this panel needs: `BaseLayoutId`/`BaseElement` cross-layout inheritance (every page mounts that way), the `ElementDesc::Incorporate` child merge, `ShouldMountBaseChildren` + the ZLevel correction for pure-container sub-window mounts (the four page slots are exactly that shape), and the parent-resize policy that scales a 300×600 page into a 300×362 slot. - **`RetailWindowManager` / `UiRoot` / the floating-window shell** — the Options panel's real host `0x2100006E` (`gmFloatyPanelUI`) is the same 8-border-strip + resize-grip frame CH6 already imports for the chat windows. Opacity, all-corner resize and drag come free. - **`RetailLayoutFixtureGenerator`** — add `0x2100002B`, `0x21000028`, `0x21000029`, `0x2100002A`, `0x2100005C` and pin them, exactly as CH6 pinned `0x2100006F`/`0x2100005B`. - **`DatWidgetFactory` already maps `0x10000035` → `BuildCheckbox`** — the LED option row's leaf widget class exists today. - **`DatStringResolver.ComputeHash`** is already the exact `compute_str_hash`, so every `ID_PlayerOption_*` / `ID_ChatOption_*` / `ID_*_Section` label in this doc resolves at runtime by name with no hard-coded ids. - **CH6's window-identity model** (main = 8, floaties = 2–5) is *confirmed* by the Chat tab's `SetUserData` arguments — no rework needed there. - **`RuntimeCharacterOptionsState` + the `0x0005` codec** (CH3) is the right seam for the Character tab; the `0x1000008C` per-window blob (CH6) is the right seam for the Chat tab's filter blocks. ### 10.3 What acdream does NOT have yet - **Element `Type 8` — the tab control.** `DatWidgetFactory` has no case for it (falls to the generic `UiDatElement`), and no reader for property `0x2E`'s struct array. This is the one genuinely new widget the campaign needs. It is also needed by the Configure Keyboard screen. - **Element `Type 5` — `UIElement_ListBox` with an authored template list** (property `0x64`) and `AddItemFromTemplateList`. Also absent; also the mechanism that makes the whole options page work. - `UIOption_Slider` (`0x10000037`), `UIOption_Menu` (`0x10000038`), `UIOption_CheckboxSlider` (`0x10000036`), `UIOption_CheckboxBitfield64` (`0x10000044`) — none mapped in `DatWidgetFactory`. - The `OptionPage` / `PlayerOptionPage` **model**: a per-page registered-option array with `m_current` / `m_saved` / `m_default` triples and the four verbs. This is small and self-contained; it is what makes Apply/Reset/Defaults correct rather than approximated. - A `UserPreferences.ini`-shaped preference store for the Config tab (acdream's settings live in `%LOCALAPPDATA%\acdream\`; the Config tab's 27 rows are all `Section_Key` preferences, not wire options). **This is where the "retail options vs acdream client settings" design call lands** — see §10.5. ### 10.4 Behaviours that MUST be ported, not improved 1. **Clicking an LED applies immediately** (`SetCurrentValue → Apply(1)`). Apply/Reset/Defaults operate on an undo baseline, not on a staging buffer. 2. **Apply is per-tab.** Each page owns its own option array. 3. **Defaults is never disabled**; Apply and Reset are disabled whenever nothing changed. 4. **Hiding a page reverts uncommitted edits** (`OnVisibilityChanged(false) → RestoreSavedValues`). Switching tabs or closing the window discards. 5. **Showing a page applies + commits** (`OnVisibilityChanged(true) → SaveCurrentValues`, which also flushes the blob). 6. **The main chat window has no "Gameplay" filter checkbox** (12 rows), the four floaties do (13 rows). 7. **The default tab is Gameplay Options**, not Character (`0x32 = true` on entry 0). 8. Retail's spelling in the UI-facing strings: "Allegiance" in the string table but `Allegience` in the symbol name — use the *string table* value for anything user-visible. ### 10.5 Open unknowns for the planner to close or accept | # | Unknown | Where to look / suggested disposition | |---|---|---| | U1 | **What `m_default` is for the Character tab's 49 checkboxes.** `InitOptions` never calls `SetDefaultValue`, so "Defaults" behaviour on the biggest tab is unestablished. | Read `UIOption_Checkbox`'s ctor and `UIOption::InqDefaultGameplayOptionProperty` / `InqGameplayOptionNameAndTooltip`. **Blocking for the Defaults button.** | | U2 | **Which options are "auto-save"** (immediate `0x0005`) vs batched into the `0x01A1` blob. | `CPlayerModule::IsAutoSaveOption` — enumerate it. This is exactly handoff Q4's discriminator. | | U3 | Whether acdream ships the **50th Character row** ("Listen to PK death messages", `ID_PlayerOption_HearPKDeaths`, string `0x0D16E9A3` present in the DAT, absent from the 2013 code). | Design call. The string and ACE support exist; only the 2013 wiring is missing. | | U4 | ~~The exact **`SetSliderLabel` operand pairs** for the Config tab's six labelled sliders.~~ **CLOSED 2026-08-11 (OP6 review + rework).** An intermediate OP6 claim that retail ships ZERO captions was the SAME BN zero-fold artifact as `AddHeader` (the review byte-decoded the `mov ecx/edx,[disp32]` string-id loads at `0x0049E4C6` etc.; the rework independently re-read 2 of 6 sites + the PDB global sequence `0x0083E768`–`0x0083E794`). The six pairs, in declaration order: Stiffness `Soft/Hard`, Adjustment Speed `Slow/Fast`, FOV `Narrow/Wide`, Screen Brightness `Dark/Bright`, Graphics Performance `Speed/Detail`, Degrade Distance `Close/Far`. Implemented in the OP6 rework (`472525b9`). | Evidence: `docs/research/2026-08-11-op6-review.md` M1; the rework commit's own byte log. | | U5 | Semantics of layout property **`0x57`** (page/window registration) and **`0x58`** (`enum 1` on every options root). | Find the `GetAttribute_Enum(this, 0x57 …)` read. Not blocking — the toggle action can be driven from `0x12` on the buttons. | | U6 | Consumer of input actions **`0x10000027`** ("Exit Game") and **`0x1000001F`** ("Configure Keyboard"). | Neither has a default keybind. `0x1000001F` is well corroborated by the keyboard screen's OK/Cancel; `0x10000027` rests on the label alone. | | U7 | The two **`support.turbine.com` URLs** behind Urgent Assistance / Report Abuse. | Dead endpoints. Recommend a **register row**: acdream shows a "not available" notice rather than launching a browser at a dead Turbine host. Extract the exact strings only if the register row needs to quote them. | | U8 | `gmConfigUI::SetMouseTurningDefaults`'s **full clamp set** (only the `Camera_Stiffness → 0.95f` clamp was read). | `0x0049E8F0`. Needed only if "Use Mouse Turning Settings" ships. | | U9 | Whether the **Config tab's 27 preference rows** map onto acdream's existing quality/audio settings or need a new retail-shaped preference store. | Design call — see §10.3. The `UserPreferences.ini` key names are all established above, so a faithful store is cheap. | | U10 | Which `0x2100002B` template `0x10000521` serves (present in the layout, in no template array). | Probably a later-build or unused row style. Non-blocking. | ### 10.6 A suggested slice shape (non-binding) The natural seams fall out of the structure: (a) the two missing widget primitives — `Type 8` tab control + `Type 5` ListBox-with-template-list — are a shared prerequisite for every tab *and* for Configure Keyboard; (b) the `OptionPage`/`PlayerOptionPage` model with its four verbs is one small, testable unit with no DAT dependency; (c) each tab is then a thin `InitOptions` transcription against a binding table. The Gameplay tab is the smallest end-to-end vertical (7 buttons, no option model at all) and would prove the tab host, the mounting, and the open path in one slice before any option plumbing exists. --- ## Appendix — reproduction notes - Byte reads: a throwaway `peread.py` in the session scratchpad maps VA → file offset through the PE section table of `C:\Users\erikn\Downloads\acclient.exe` (image base from the optional header) and dumps raw bytes. `decode_addchild.py` walks a VA range decoding `a1 imm32` / `8b 0d|15|1d|35|3d imm32` global loads, `50..57` register pushes, `6a xx` / `68 imm32` immediate pushes and `e8 rel32` calls, then prints the last four pushed arguments per call — that is how all 25 `AddChild` rows and all 18 `AddHeader` operands were recovered. - DAT reads: a throwaway `OptProbe` console (`net8.0`, `Chorizite.DatReaderWriter` 2.1.7) with modes `find` (hunt element ids / types across every `0x21xxxxxx` LayoutDesc), `hosts` (find layouts referencing a given `BaseLayoutId`), `dump` (full recursive element + property dump), `str` (resolve a name or hash across every `0x23xxxxxx` StringTable) and `dumpstr`. **It must construct a `DatCollection`, not a bare `PortalDatabase`/ `LocalDatabase`** — `LayoutDesc.Unpack` needs the collection to resolve master properties. - Nothing in the repo was modified except this document.