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>
58 KiB
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) anddocs/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 GUID9e847e2f-777c-4bd9-886c-22256bb87f32). Every load-bearing constant below that BN rendered as0or as a zero-idiom was re-read from the raw section bytes with a throwaway PE reader (scratchpadperead.py/decode_addchild.py, not committed). - The installed DATs at
%USERPROFILE%\Documents\Asheron's Call\read throughChorizite.DatReaderWriter2.1.7 viaDatCollection— the same reader production uses. Throwaway scratchpad probe (not committed); recipe perdocs/research/2026-08-10-retail-ui-text-style.md§tooling. NOTE: a standalonePortalDatabase/LocalDatabasethrows onLayoutDescunpack (writer.Database.DatCollection is null!) — you must open aDatCollection. - The retail
UserPreferences.inisitting 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
0x10000180stack holds ~16 sibling panels (allegiance, skills, attributes, inventory, character info …), each keyed byP0x10000029. 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=0x03docs/research/named-retail/retail-default.keymap.txt:148—ToggleOptionsPanel [ "" [ 0 DIK_F11 ] ]under theUICommandsgroup.
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 (host0x57== close button's0x12) makes "the toggle action this element registers itself under" the obvious reading, but I did not locate theGetAttribute_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_currentper option ⇒Changed()goes false ⇒ Reset can no longer undo. (Plus the blob flush, §3.5.) - Reset =
m_current = m_savedand re-Apply(0)⇒ reverts the live game state to the last committed baseline. - Defaults =
m_current = m_defaultandApply(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 fromSetDefaultValue, which is authored in code in each page'sInitOptions(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— sendsCM_UI::SendNotice_GameplayOptionChanged, then, only if not already dirty, setsm_bDirty = 1(offset+0x1A8) and stampsm_timeFirstDirtied(offset+0x1AC, double) withTimer::cur_time.OnChanged(PlayerOption) @0x0059A8E0— sendsCM_UI::SendNotice_PlayerOptionChanged, applies the immediate local side effects (case 4 →SmartBox::EnableWeather, case 5 →LScape::SetDay, case 7 →ClientCombatSystem::TrackTarget, case0x30→LScape::m_fFogEnabled, cases 2/0x12→ the mutual exclusion betweenIgnoreFellowshipRequestsandFellowshipAutoAcceptRequests), then:
So retail has BOTH paths. An option that0059a99d 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; } }CPlayerModule::IsAutoSaveOptionreturns true for is sent immediately as a single-option event (this is theSetSingleCharacterOption 0x0005family acdream already implements). Everything else is batched into the blob. Which options are auto-save is UNKNOWN here — enumerateCPlayerModule::IsAutoSaveOptionin 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:
m_propName != 0→ gameplay-option property bag:BaseProperty::SetPropertyName(&p, m_propName), set value =m_current, thenPlayerModule::SetOption(pm, &p)(0x005D52C0—HashTable::setintom_colGameplayOptionsthenOnChanged(prop, 0)).m_prefNamepath →UIPreferences::ModifyPreference(m_prefName, m_current), and on successCM_UI::SendNotice_UserPreferenceChanged(m_prefName). This is theUserPreferences.inipath used by the whole Config tab. Nothing goes over the wire. (BN renders the branch predicate asm_playerOption == ~AutoRepeatAttack_PlayerOption, which is anot/cmpartifact. The branch's BODY is unambiguous; the exact predicate is UNVERIFIED. It is almost certainly "no PlayerOption bound", i.e. the prefName binding.)- else →
PlayerModule::SetOption(pm, m_playerOption, m_current)(0x005D3EB0— a switch overenum PlayerOptionwriting theoptions_word bits and callingOnChanged(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:
PostInitalsoRegisterNoticeHandler(0x4DD276, …)andUIListener::RegisterForGlobalMessage(this, 0x0C).ListenToGlobalMessage @0x0049EB90:if (arg2 == 0x0C) SetMouseTurningDefaults(this).SetMouseTurningDefaults @0x0049E8F0clampsm_pStiffnessto0.95f(when below) and prints a chat line ("Camera Stiffness was changed fro…"), plus the same treatment onm_pAdjustmentSpeed,m_pSensitivity,m_pInvert,m_pMouseTurn,m_pSlope. Exact clamp set is out of Lane A scope.RecvNotice_UserPreferenceChanged_Menu @0x0049EBA0reacts toRender_LandscapeTextureDetailchanges.
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 carryP0x12 = 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):
ViewCombatTarget,SalvageMultiple,MainPackPreferredVividTargetingIndicator,ShowTooltips,CoordinatesOnRadar,SideBySideVitals,SpellDuration,DisableMostWeatherEffects,DisableDistanceFog,PersistentAtDay,DisableHouseRestrictionEffects,UseCraftSuccessDialog,ConfirmVolatileRareUse,DisplayTimeStamps,FilterLanguage,ShowHelm,ShowCloakIgnoreAllegianceRequests,IgnoreFellowshipRequests,DisplayAllegianceLogonNotifications,FellowshipShareXP,FellowshipShareLoot,FellowshipAutoAcceptRequestsAcceptLootPermits,UseDeception,AllowGive,IgnoreTradeRequests,DragItemOnPlayerOpensSecureTrade,DisplayDateOfBirth,DisplayAge,DisplayChessRank,DisplayFishingSkill,DisplayNumberDeaths,DisplayNumberCharacterTitlesToggleRun,AdvancedCombatUI,AutoTarget,AutoRepeatAttack,UseChargeAttack,LeadMissileTargets,UseFastMissilesStayInChatMode,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::InitOptionshas only 7 — it ends atHearSocietyChatand thenAddSeperator. However, StringTable0x23000003does contain0x0D16E9A3 = "Listen to PK death messages.", andcompute_str_hash("ID_PlayerOption_HearPKDeaths") == 0x0D16E9A3exactly. There is noHearPKDeath*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)
AddHeader(this, 0)is never literally 0. BN prints the argument as0because it ismov eax, [<global>]; push eax. The real values are the twelveID_*_Sectionstring-hash globals at0x0083E750+,0x0083E810+,0x0083EA5C+. Every header in this document was recovered from the raw operand bytes.AddChild(..., 0, 0)likewise drops the label/tooltip operands. They too aremov <reg>,[<global>]; push <reg>pairs. All 13 Chat-filter labels came from a push-operand decode, not from the pseudo-C.(((eax - eax) & 3) + 3)inAddSliderOptionis theneg/sbbzero-idiom class already in the chat digest. Real result:arg3 ? 6 : 3.- Identical-COMDAT folding aliases symbols.
gmCharacterSettingsUI::ListenToElementMessage,gmChatOptionsUI::ListenToElementMessage, andgmConfigUI::ListenToElementMessageall resolve to0x0049E3A0;gmGameplayOptionsUI::ListenToGlobalMessageandgmKeyboardUI::ListenToGlobalMessageboth to0x004F5860;gmGameplayOptionsUI::PostInitis listed at0x004BFA00, which the pseudo-C prints asgmCGProfessionPage::PostInit. A shared address does not mean the class actually implements that behaviour — verify the body is generic before attributing it. - BN field-name mush.
CPlayerModule::OnChangedwrites what BN callsthis->m_TimeStampFormat.m_buffer; the byte-level truth ismov byte [esi+0x1A8], 1— them_bDirtyflag thatUseTimereads. Trust the offsets, not the names. m_playerOption == ~AutoRepeatAttack_PlayerOptioninUIOption_Checkbox::Applyis a rendering of anot/cmppair. 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
LayoutImporteralready handles the exact mechanisms this panel needs:BaseLayoutId/BaseElementcross-layout inheritance (every page mounts that way), theElementDesc::Incorporatechild 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 host0x2100006E(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— add0x2100002B,0x21000028,0x21000029,0x2100002A,0x2100005Cand pin them, exactly as CH6 pinned0x2100006F/0x2100005B.DatWidgetFactoryalready maps0x10000035→BuildCheckbox— the LED option row's leaf widget class exists today.DatStringResolver.ComputeHashis already the exactcompute_str_hash, so everyID_PlayerOption_*/ID_ChatOption_*/ID_*_Sectionlabel in this doc resolves at runtime by name with no hard-coded ids.- CH6's window-identity model (main = 8, floaties = 2–5) is confirmed by the Chat tab's
SetUserDataarguments — no rework needed there. RuntimeCharacterOptionsState+ the0x0005codec (CH3) is the right seam for the Character tab; the0x1000008Cper-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.DatWidgetFactoryhas no case for it (falls to the genericUiDatElement), and no reader for property0x2E'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_ListBoxwith an authored template list (property0x64) andAddItemFromTemplateList. 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 inDatWidgetFactory.- The
OptionPage/PlayerOptionPagemodel: a per-page registered-option array withm_current/m_saved/m_defaulttriples 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 allSection_Keypreferences, 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
- Clicking an LED applies immediately (
SetCurrentValue → Apply(1)). Apply/Reset/Defaults operate on an undo baseline, not on a staging buffer. - Apply is per-tab. Each page owns its own option array.
- Defaults is never disabled; Apply and Reset are disabled whenever nothing changed.
- Hiding a page reverts uncommitted edits (
OnVisibilityChanged(false) → RestoreSavedValues). Switching tabs or closing the window discards. - Showing a page applies + commits (
OnVisibilityChanged(true) → SaveCurrentValues, which also flushes the blob). - The main chat window has no "Gameplay" filter checkbox (12 rows), the four floaties do (13 rows).
- The default tab is Gameplay Options, not Character (
0x32 = trueon entry 0). - Retail's spelling in the UI-facing strings: "Allegiance" in the string table but
Allegiencein 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 | SetSliderLabel operand pairs for the Config tab's six labelled sliders.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.pyin the session scratchpad maps VA → file offset through the PE section table ofC:\Users\erikn\Downloads\acclient.exe(image base from the optional header) and dumps raw bytes.decode_addchild.pywalks a VA range decodinga1 imm32/8b 0d|15|1d|35|3d imm32global loads,50..57register pushes,6a xx/68 imm32immediate pushes ande8 rel32calls, then prints the last four pushed arguments per call — that is how all 25AddChildrows and all 18AddHeaderoperands were recovered. - DAT reads: a throwaway
OptProbeconsole (net8.0,Chorizite.DatReaderWriter2.1.7) with modesfind(hunt element ids / types across every0x21xxxxxxLayoutDesc),hosts(find layouts referencing a givenBaseLayoutId),dump(full recursive element + property dump),str(resolve a name or hash across every0x23xxxxxxStringTable) anddumpstr. It must construct aDatCollection, not a barePortalDatabase/LocalDatabase—LayoutDesc.Unpackneeds the collection to resolve master properties. - Nothing in the repo was modified except this document.