acdream/docs/research/2026-08-10-options-panel-structure.md
Erik ff5776415b docs: OP6 re-review CLOSED — slice code-complete, gate ready; doc residuals
The re-reviewer byte-decoded ALL six caption sites independently (no
transposition; a third evidence line from the fixture's authored
left/right label geometry), re-verified the audio chain through the one
place an inversion could still hide (the checkbox pass-throughs), and
hand-traced the 97-key conformance table (exactly 97, none invented or
dropped). Residuals applied here: SF-1 the AudioSettings doc comment's
wrong function attribution (the SetDefaultValue literals live in
gmConfigUI::InitOptions @0x0049E435/E457/E479, not InitUIPreferences);
SF-2 the gate script no longer asserts an unread DAT caption — it gates
on behaviour and asks the tester to report the authored English
verbatim; lane A's stale '24 option rows' corrected to 27 (the 39-item
pin is the authoritative tally). AudioSettings.cs change is
comment-only (no executable-code delta; suite state carries from
67b0815c, re-verified at the next code commit).

Campaign state: OP1-OP7 ALL CODE-COMPLETE; OP3/OP4/OP5/OP6 gates ready;
OP8 (Configure Keyboard) is the sole remaining implementation slice.

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

58 KiB
Raw Blame History

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 LayoutDesc0x2100002B — 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 0x00486490not 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 0x1000018D10 (0xA)
0x2100006E 0x100005FE 0x1000004F = gmFloatyPanelUI (Register 0x004D1A1A) 310×372 0x10000180 (5,5) 300×362 0x1000018D10 (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 (0x100006530x1000065A) 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 0x004F2A800x004F2A90 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:139F11 (scan=0x57, dev=0) Action=0x1000001A Activation=0x03
  • docs/research/named-retail/retail-default.keymap.txt:148ToggleOptionsPanel [ "" [ 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 @0x0049E3A0the 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 0x30LScape::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 timerCPlayerModule::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) (0x005D52C0HashTable::set into m_colGameplayOptions then OnChanged(prop, 0)).
  2. m_prefName pathUIPreferences::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. elsePlayerModule::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 0x0083E7680x0083E794: 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 0x23000003)
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 0x0049FDF60x0049FF1C 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) @ 0x0049E2980x0049E2AB — 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) @ 0x0049E2A10x0049E2ABgmConfigUI::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…", …) @ 0x0049E1540x0049E16B; on failure MessageBoxA(…, "Asheron's Call Error", MB_OK)
280 0x10000207 0x02F2FD45 Report Abuse same ShellExecuteA shape @ 0x0049E1F00x0049E207, 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 10x100005CC + 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 @0x004A02F06 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 0x100000030x23000003.

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.ininothing 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 0x10000035BuildCheckbox — 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 5UIElement_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 0x0083E7680x0083E794). 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/ LocalDatabaseLayoutDesc.Unpack needs the collection to resolve master properties.
  • Nothing in the repo was modified except this document.