acdream/docs/research/2026-08-10-options-panel-structure.md
Erik 348d794359 fix(ui): #372 — Chat filter labels resolve from their real table 0x2300000D
The 13 ID_ChatOption_TextFilter_* labels (and their _Desc tooltips) live
in string table 0x2300000D, not the 0x23000003 options table the section
headers and slider labels use. Dat-verified: the targeted sweep missed
(0x23000001-0A), the control key resolved ('Auto Target' — machinery
fine), and the exhaustive all-tables sweep (ProbeFilterLabelHome, now a
permanent env-gated probe) hit exactly once: 0x2300000D -> 'Combat'. The
initializer decomp confirms the hash KEYS are the symbol names verbatim
(the vftable-member operands at 0x006f04cd are the known pooled-string
artifact); only the research doc's table attribution was inferred rather
than dat-verified — corrected in §8.

All 13 rows now render their captions instead of the honest-fallback
blanks the first connected gate saw. Full Release suite green (one
Core.Net loss-simulation timing flake on the first run, green targeted
and on rerun).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 12:28:43 +02:00

948 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<char>` 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 = <tab button element id>
0x31 = <tab page element id>
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, <index>,
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, <actionId>)` — 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(<the gm*UI element>, 0x100001FC); // Apply
eax_1 = GetChildRecursive(<the gm*UI element>, 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, <retail window id>)`. 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<char>` 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 <reg>,[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, <min>, <max>)` |
Range labels come from `ID_UI_Value_Transparent` (`0x0083E828`) and `ID_UI_Value_Opaque`
(`0x0083E82C`). The two sliders are then **linked**:
```
0049fd5c DualHash<UIOption_Slider*,UIOption_Slider*>::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(<windowId>)`, `SetDefaultValue(<low>, <high>)`, 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 <reg>,[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, <PlayerOption>), 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, [<global>]; 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 <reg>,[<global>]; push <reg>` 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 = 25) 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.