# Configure Keyboard + Gameplay Options tab actions (Lane D) Research lane D of the settings-track campaign (`docs/research/2026-08-10-settings-track-handoff.md`), answering handoff questions **Q5** (Configure Keyboard: retail's keymap UI + storage) and **Q6** (what every Gameplay Options tab button does). **Report only.** No repo code was changed. Every retail claim below carries a named symbol + address from the Sept 2013 EoR PDB-paired build. The PDB/binary pairing was verified first: ``` py tools/pdb-extract/check_exe_pdb.py "C:/Users/erikn/Downloads/acclient.exe" -> linker UTC: 2013-09-06T00:17:56+00:00 -> GUID {9e847e2f-777c-4bd9-886c-22256bb87f32} === MATCH: this exe pairs with our acclient.pdb === ``` Load-bearing string literals were byte-verified by push-imm32 sweep + direct `.rdata` reads of that binary (file offsets and VAs given inline). Ghidra MCP (port **8081**, `patchmem.gpr`) served two cross-checks where BN pseudo-C had pooled/garbled operands. --- ## 0. TL;DR for the impatient | Gameplay-tab button | Retail mechanism | Wire? | acdream today | |---|---|---|---| | Exit to Character Selection | element `0x10000203` → `CM_UI::SendNotice_EndCharacterSession(1)` → confirm dialog → `LogOffCharacter(0)` → `0xF653` → back to char-select UI | `0xF653` (have it) | **needs-new-subsystem** (no char-select UI, no in-world→char-select transition) | | Exit Game | element `0x10000617` → global msg 1 param `0x10000027` → `gmGamePlayUI::HandleKeyPress` → `QueueUIMode(0x10000009)` (epilogue) → `LogOffCharacter(1)` | `0xF653` (have it) | **exists** — `IGameplayWindowCommands`/`Window.Close` + `WorldSession.Dispose()` graceful logoff | | Configure Keyboard | opens `gmKeyboardUI` (element type `0x1000000E`) — **wiring not found in code**, presumed authored in the LayoutDesc | none (local `.keymap` file) | **adaptable** (bindings model + conflict UX exist; **no renderer** — see §6) | | Use Mouse Turning Settings | element `0x100005CC` → `BroadcastGlobalMessage(0xC)` → `gmConfigUI::SetMouseTurningDefaults` — a one-shot "apply recommended values" macro over 6 Config-tab options | 1 of 6 is `CharacterOptions2.UseMouseTurning` (0x00400000) | **adaptable** (5 client-local prefs + 1 option bit) | | In-Game Help Files | InputAction `0x7B` (ToggleHelp/F1) → `KeyStone::OpenHelp(0, 0x10000001)` → external `plugins\ACHelpPlugin.dll` via `keystone.dll` | none | **server/asset-missing** — retail help is an external plugin we do not have | | Urgent Assistance | element `0x10000206` → `ShellExecuteA("open", "http://support.turbine.com/ics/support/ticketnewwizard.asp?style=classic")` | **none in EoR** | trivially adaptable (dead URL — needs a product decision) | | Report Abuse | element `0x10000207` → **the same URL** (byte-verified: both push `0x007A82A0`) | **none in EoR** | same | The two big surprises: **(a)** in the Sept 2013 build Urgent Assistance and Report Abuse are *web links*, not wire messages — the in-client `gmUrgentAssistanceUI` / `gmAbuseUI` classes are still linked but the tab does not open them; **(b)** acdream's F11 Settings panel **does not render at all today** (§6.3) — the handoff's "what already exists" list overstates it. --- ## 1. The Gameplay Options tab class `gmGameplayOptionsUI : UIElement_Field, gmNoticeHandler` (`acclient.h:55857`). | Symbol | Address | Note | |---|---|---| | `gmGameplayOptionsUI::Register` | `0x0049E0F0` | | | `gmGameplayOptionsUI::Create` | `0x0049E060` | | | `gmGameplayOptionsUI::ListenToElementMessage` | `0x0049E110` | **the whole button dispatch** | | `gmGameplayOptionsUI::ListenToGlobalMessage` | `0x004F5860` | ICF-folded with `gmKeyboardUI`/`gmAbuseUI`/`gmUrgentAssistanceUI` — an empty body; **not** a shared behaviour | | `gmGameplayOptionsUI::PostInit` | `0x004BFA00` | ICF-folded with `gmCGProfessionPage::PostInit` (a `UIElement::PostInit` tailcall). BN prints the *other* class's name at that address — artifact, not evidence of a relationship. | `ListenToElementMessage` handles exactly **five** element ids on `idMessage == 1` (left-click). Verbatim structure (Ghidra decompile of `0x0049E110`, cross-checked against BN at `acclient_2013_pseudo_c.txt:169249`): ``` if (idMessage == 1) { if (idElement < 0x10000208) { 0x10000207 -> ShellExecuteA(open, ) // Report Abuse 0x10000203 -> CM_UI::SendNotice_EndCharacterSession(1) 0x10000206 -> ShellExecuteA(open, ) // Urgent Assistance } else { 0x100005CC -> UIElementManager::BroadcastGlobalMessage(inst, 0xC, 0) 0x10000617 -> UIElementManager::BroadcastGlobalMessage(inst, 1, 0x10000027) } } ``` Seven buttons, five handlers. The two unaccounted-for buttons are **Configure Keyboard** and **In-Game Help Files** (§4.3, §4.5). The unclaimed adjacent ids `0x10000204` and `0x10000205` appear **nowhere** in the 1.4 M-line pseudo-C — consistent with buttons wired declaratively in the LayoutDesc rather than in code. That is an inference, marked **UNVERIFIED**; settling it needs a LayoutDesc dump (§8, COORDINATOR TODO). ### 1.1 How the two "global message" buttons reach their handler `UIElementManager::BroadcastGlobalMessage(this, msgId, param)` @ `0x0045B3E0` fans a `(msgId, param)` pair to every `UIListener` registered for `msgId` via `UIListener::RegisterForGlobalMessage` @ `0x00465E60`. Message **1** is the *unconsumed key/action* channel: `UIElementManager::ActionHandler` @ `0x0045CC00` calls `UIElementManager::KeyPressEvent` @ `0x0045C300` on action-start, which broadcasts `(1, m_InputAction)` when no focused/active element ate it, then calls `DoVisibilityToggleAction`. So global message 1's payload is an **InputAction id**. Buttons can inject *synthetic* action ids into that same channel — which is exactly what `0x10000617` does with `0x10000027`. Message **0xC** has exactly one listener: `gmConfigUI::ListenToGlobalMessage` @ `0x0049EB90`: ``` if (arg2 == 0xC) gmConfigUI::SetMouseTurningDefaults(this); ``` `gmConfigUI::PostInit` @ `0x0049E840` registers it (`UIListener::RegisterForGlobalMessage(this, 0xC)` @ `0x0049E8C6`). ### 1.2 Apply / Reset / Defaults (context for the planner — Q1's lane) `gmCharacterSettingsUI::ListenToElementMessage` @ `0x0049E3A0` (element type `0x10000027`, `gmCharacterSettingsUI::GetUIElementType` @ `0x004A01C0`): | Element id | Action | |---|---| | `0x100001FC` | `SaveCurrentValues()` — **Apply** | | `0x100001FD` | `RestoreSavedValues()` — **Reset** | | `0x100001FE` | `RestoreDefaultValues()` — **Defaults** | Those three vtable slots are `OptionPage`'s (`OptionPage::SaveCurrentValues` @ `0x004F2C60`, `RestoreSavedValues` @ `0x004F2D00`, `RestoreDefaultValues` @ `0x004F2CB0`), so every option page — including `gmKeyboardUI` — shares the semantics. Handed to whichever lane owns Q1. --- ## 2. Exit to Character Selection (element `0x10000203`) ### 2.1 Client-internal notice, not a wire message `CM_UI::SendNotice_EndCharacterSession(int)` @ `0x00479B40` is a **local notice broadcast** — it walks `GetNoticeHandlers(0x004DD1E2)` and invokes vtable slot `+0x21C` on each. No packet. The consumer that matters is `gmGamePlayUI::RecvNotice_EndCharacterSession` @ `0x004EBEA0`. Ghidra decompile plus raw-byte field decoding: ``` gmGamePlayUI field offsets (from HandleKeyPress @ 0x004E9E3D, bytes c6 86 c6 00 00 00 00 / 88 86 c7 00 00 00 / 88 86 c5 00 00 00): +0xC5 m_doEndSession +0xC6 m_shouldQuitOnLogout +0xC7 m_bLogoutConfirmed (the NoticeHandler sub-object is at +0x98, so the notice thunk's esi+0x2D/0x2E/0x2F are the same three fields — bytes at 0x004EBEAF: c6 46 2d 01 / c6 46 2e 00, and at 0x004EBEF9: b0 01 / 88 46 2f / 88 46 2d / 88 46 2e) RecvNotice_EndCharacterSession(param): param != 0: m_doEndSession = 1; m_shouldQuitOnLogout = 0; MakeLogoutConfirmationDialog(ID_Client_EndCharacterSessionConfirm) param == 0: m_bLogoutConfirmed = 1; m_doEndSession = 1; m_shouldQuitOnLogout = 1 // immediate quit, no confirm ``` `ID_Client_EndCharacterSessionConfirm` byte-verified at file offset `0x3C2CEC`, VA `0x007C2CEC`. The button passes `1` → **confirmation dialog first**, and `m_shouldQuitOnLogout = 0` → after confirmation the client logs the character off but does **not** exit the process. That is the "Exit to Character Selection" semantic. ### 2.2 The drain that actually performs it `gmGamePlayUI::UseTime` @ `0x004EA3A0` (per-frame): ``` if (m_doEndSession && !m_endingSession) { m_endingSession = 1; if (m_bLogoutConfirmed) { if (m_shouldQuitOnLogout) { UIFramework::QueueUIMode(this, 0x10000009); } else if (smartbox->player) { if ((player->transient_state & 1) == 0) { // not grounded m_endingSession = 0; ECM_UI::SendNotice_DisplayStringInfo(0x1A, "Cannot log off while in mid-air."); } else { CPlayerSystem::LogOffCharacter(playerSystem, 0); } } } m_doEndSession = 0; } ``` `"Cannot log off while in mid-air."` byte-verified as UTF-16LE at VA `0x007C29C0` (pushed at `0x004EA481`). **Retail refuses to log off while airborne** — `transient_state & 1` is the on-contact/ON_WALKABLE bit. `gmGamePlayUI::EndSession` @ `0x004EA1B0` is the same graph reachable from the confirmation dialog. ### 2.3 The wire `CPlayerSystem::LogOffCharacter(bool immediate)` @ `0x00563520`: ``` CPlayerModule::SaveToServer(&playerModule, 0); // flush dirty options blob if (immediate) ExecuteLogOff(); // @ 0x0055D780 else if (prevRequest == IR_NONE) RequestLogOff(); // @ 0x00562DD0 else { AddTextToScroll("Logging off...\n"); loggingOff = 1; } ``` `CPlayerSystem::RequestLogOff` @ `0x00562DD0` reaches `Proto_UI::LogOffCharacter` @ `0x00546A20`: ``` buf = new byte[8]; buf[0..3] = 0xF653; buf[4..7] = characterId; Proto_UI::SendToLogon(buf, 8); ``` **acdream already has this exactly**: `src/AcDream.Core.Net/Messages/CharacterLogOff.cs` (`Opcode = 0xF653`, `BuildRequestBody(characterId)` writes the same 8 bytes and cites the same addresses), consumed by `WorldSession.Dispose()` (`src/AcDream.Core.Net/WorldSession.cs:2795+`) which sends the request, waits up to 35 s for the server's opcode-only confirmation, then tears the transport down. ACE's side is `Source/ACE.Server/Network/Handlers/CharacterHandler.cs:266` (`CharacterLogOff` → `session.LogOffPlayer()`; it reads no payload) and it echoes `GameMessageCharacterLogOff` back from `Session.cs:268`. Cross-check: holtburger packs `CharacterLogOff` as **opcode only, 4 bytes** (`crates/holtburger-protocol/src/messages/game_message/pack.rs:76`) and ACE accepts that too. Retail's 8-byte form is the faithful one; acdream matches retail. ### 2.4 Why this is `needs-new-subsystem` in acdream `WorldSession` has a `State.InCharacterSelect` (`WorldSession.cs:85`) but it is entered exactly once (`WorldSession.cs:997`) during `Connect()`, which blocks until `CharacterList (0xF658)` arrives and then hands off to `EnterWorld(index)`. There is no path from in-world back to character select — `Dispose()` tears down the entire session and socket. Retail instead keeps the login connection and swaps `UIFramework` mode. **Recommended initial adaptation (planner's call):** wire the button to the same graceful shutdown as Exit Game and add a divergence-register row in `docs/architecture/retail-divergence-register.md` in the same commit (register rule 1). Risk-if-assumption-breaks column: "user expects to return to a character list and instead the client exits." --- ## 3. Exit Game (element `0x10000617`) `BroadcastGlobalMessage(1, 0x10000027)` → `gmGamePlayUI::HandleKeyPress(long)` @ `0x004E9DF0` (Ghidra decompile, exact): ``` if (param_1 < 0x10000027) { if (param_1 == 0x10000026) { m_shouldQuitOnLogout = false; m_bLogoutConfirmed = true; m_doEndSession = true; return; } if (param_1 == 0x54) { Show(!Shown()); return; } // UI toggle action if (param_1 == 0x7B) { KeyStone::OpenHelp(0, 0x10000001); return; } } else if (param_1 == 0x10000027) { m_shouldQuitOnLogout = true; m_bLogoutConfirmed = true; m_doEndSession = true; } ``` `m_shouldQuitOnLogout = true` + already-confirmed → `UseTime` takes the `QueueUIMode(this, 0x10000009)` branch → the epilogue framework. `gmEpilogueUI::gmEpilogueUI` @ `0x004E9B00` then calls `CPlayerSystem::LogOffCharacter(playerSystem, 1)` at `0x004E9B7A` — `immediate = 1` → **`ExecuteLogOff` directly, no wait for the `0xF653` echo.** (Retail's Exit Game does not wait; acdream's `Dispose()` does wait, which is strictly safer against ACE's stale-session behaviour. Keep acdream's.) Note there is **no confirmation dialog** on this path in the decompiled graph — `m_bLogoutConfirmed` is set to `true` by the button itself. `0x10000026` (the "logout, don't quit" synthetic action) is **never broadcast anywhere in the pseudo-C** — another point in favour of the "unhandled ids are authored in the LayoutDesc" hypothesis, and another reason to dump the layout. **acdream seam that already exists:** `IGameplayWindowCommands` / `new GameplayWindowCommands(d.Window.Close)` (`src/AcDream.App/Composition/SessionPlayerComposition.cs:1200`), which is what Escape already uses (`GameplayInputCommandController.cs:249`). Window close → the composition shutdown → `WorldSession.Dispose()` graceful logoff. A retail Exit Game button is a one-line binding to that. --- ## 4. The other four buttons ### 4.1 Urgent Assistance (element `0x10000206`) — **web link, no wire** Byte-verified push-imm32 sweep of `0x0049E8F0`-adjacent code (`0x0049E110..0x0049E2C0`): ``` 0049e14b push 0x007A82A0 'http://support.turbine.com/ics/support/ticketnewwizard.asp?style=classic' 0049e164 push 0x00794090 'open' 0049e18b push 0x007A81E0 'An error occurred while trying to launch your web browser. (Error code %d)\n The web site to submit an urgent assistance request is listed below. Please go there to complete your request.\n%s\n' 0049e19f push 0x00794078 "Asheron's Call Error" ``` `ShellExecuteA(NULL, "open", url, NULL, NULL, SW_SHOWNORMAL)`; on failure (`ret <= 32`) a `MessageBoxA`. **The error string is what identifies this button** — the element ids themselves carry no label in code. ### 4.2 Report Abuse (element `0x10000207`) — **same web link, no wire** ``` 0049e1e7 push 0x007A82A0 'http://support.turbine.com/ics/support/ticketnewwizard.asp?style=classic' 0049e200 push 0x00794090 'open' 0049e231 push 0x007A8128 'An error occurred while trying to launch your web browser. (Error code %d)\n The web site to submit an abuse report is listed below. Please go there to complete your request.\n%s\n' 0049e245 push 0x00794078 "Asheron's Call Error" ``` Both buttons push the **identical pointer `0x007A82A0`** — this is real, not BN string pooling: the two push sites are at different addresses and both were read from the binary. #### 4.2.1 The legacy in-client abuse/urgent flows still exist (unused by this tab) Worth knowing, because a planner may prefer them to a dead URL: | Class | Type id | Entry | Wire | |---|---|---|---| | `gmAbuseUI` | `0x10000018` (`GetUIElementType` @ `0x004BBF20`) | `gmAbuseUI::ListenToElementMessage` @ `0x004BC5C0` → `gmAbuseUI::ReportAbuse` @ `0x004BC380` | `CM_Character::Event_AbuseLogRequest(name, 1, complaint)` @ `0x006A2A30` — **GameAction opcode `0x0140`** (`*(uint32*)buf = 0x140` at `0x006A2AAE`, sent via `Proto_UI::SendToWeenie`) | | `gmUrgentAssistanceUI` | `0x1000001F` (`GetUIElementType` @ `0x004A7830`) | `gmUrgentAssistanceUI::ListenToElementMessage` @ `0x004A7940` | `CM_Communication::Event_ChannelBroadcast(0x400, text)` @ `0x006A4030` | `gmAbuseUI` fields (`acclient.h:55423`): name box `0x10000105`, entry box `0x10000107`, result text `0x1000010B`, continue button `0x10000109` (`gmAbuseUI::PostInit` @ `0x004BBF30`); response comes back through `gmAbuseUI::RecvNotice_AbuseReportResponse` @ `0x004BC1B0`. `gmUrgentAssistanceUI` fields (`PostInit` @ `0x004A7840`): entry box `0x100001BA`, continue button `0x100001BD`; it enforces a non-empty entry before sending. **ACE cross-check:** - `AbuseLogRequest = 0x0140` exists in `Source/ACE.Server/Network/GameAction/GameActionType.cs:69` and in `Source/ACE.Entity/PacketOpCodeNames.cs:168` (`Evt_Character__AbuseLogRequest_ID`), but **there is no `[GameAction(GameActionType.AbuseLogRequest)]` handler anywhere in `Source/ACE.Server/`** — verified by grep. Sending it to ACE is a no-op (server-missing). - Channel `0x400` = `Channel.Help` (`Source/ACE.Entity/Enum/Channel.cs:86`, `Help = 0x00000400`). ACE **does** handle it: `GameActionChatChannel.cs` `case Channel.Help:` at line 84, reached through `GameActionType.ChatChannel = 0x0147`. So the *urgent assistance* path is server-supported today; the *abuse report* path is not. - Retail's abuse responses map to `WeenieError.AbuseNoSuchCharacter 0x04B8 / AbuseReportedSelf 0x04B9 / AbuseComplaintHandled 0x04BA` (cross-referenced in `references/holtburger/crates/holtburger-protocol/src/errors.rs:273-275`). ### 4.3 Configure Keyboard — **wiring UNKNOWN in code** `gmKeyboardUI` is a registered element class: `UIElement::RegisterElementClass(0x1000000E, gmKeyboardUI::Create)` (`gmKeyboardUI::Register` @ `0x004DCF70`, body at `0x004DCF7A`). `gmKeyboardUI::GetUIElementType` @ `0x004DBF90` returns `0x1000000E`. No code path in the pseudo-C creates or shows it in response to a Gameplay Options button. Candidate mechanisms, both authored rather than coded: 1. **`UIElementManager::DoVisibilityToggleAction(action)` @ `0x0045B660`** — looks up `m_elementInputActionListenerTable[action]` and broadcasts element message `0x31` to every element registered for that InputAction. Elements declare that registration in their `ElementDesc`. 2. A plain LayoutDesc-authored state/visibility change on the button (the same class of authoring that `CM_UI::SendNotice_SetPanelVisibility` serves elsewhere — see `gmGamePlayUI` at `0x004E9D4C`). **Marked UNVERIFIED.** Resolving it requires reading the Options-panel LayoutDesc out of `client_portal.dat` — see §8. ### 4.4 Use Mouse Turning Settings (element `0x100005CC`) `BroadcastGlobalMessage(0xC, 0)` → `gmConfigUI::SetMouseTurningDefaults` @ `0x0049E8F0`. This is **not** a screen — it is a one-shot "apply the recommended values" macro over six Config-tab options, each with a chat line. All six format strings byte-verified by push-imm32 sweep of `0x0049E8F0..0x0049EB81`: | Order | Option (UserPreferences key) | Condition | New value | Chat line (verified VA) | |---|---|---|---|---| | 1 | `Camera.Stiffness` | `!= 0.95f` | `0.95f` | `0x007A8A70` `'Camera Stiffness was changed from %f to the mouse turning default of %f.'` | | 2 | `Camera.AdjustmentSpeed` | `!= 50.0f` | `50.0f` | `0x007A8A20` `'Camera Adjustment was changed from %f to the mouse turning default of %f.'` | | 3 | `Input.MouseLookSensitivity` | `!= 0.7f` | `0.7f` | `0x007A89D0` `'Mouse Sensitivity was changed from %f to the mouse turning default of %f.'` | | 4 | `Camera.AlignToSlope` | `== 1` | `0` | `0x007A8980` `'Align To Slope was changed from TRUE to the mouse turning default of FALSE.'` | | 5 | `Input.InvertMouseLookYAxis` | `== 0` | `1` | `0x007A8928` `'Invert Mouselook Axes was changed from FALSE to the mouse turning default of TRUE.'` | | 6 | `Input.UseMouseTurning` | `== 0` | `1` | `0x007A88D0` `'Turn to Face Camera was changed from FALSE to the mouse turning default of TRUE.'` | Then `SaveCurrentValues()` at `0x0049EB57` — **the macro commits immediately; no Apply press needed.** The float comparisons are the MSVC `fcomp / fnstsw ax / test ah,0x44 / jnp` idiom. Raw bytes at `0x0049E924`: `d8 1d bc 8a 7a 00 | df e0 | f6 c4 44 | 7b 50` — `jnp` skips the body on *equal*, so the body runs when the current value **differs** from the target. BN renders that as an unresolved `bool p_1 = unimplemented {test ah, 0x44}` — do not trust its `if (p_1)` polarity without this byte check. Storage split (matters for the headless-bot constraint, handoff Q8): - Items 1–5 are **client-local `UserPreferences`** only. Grep of `references/ACE/Source/` finds no `AlignToSlope`, `InvertMouseLook`, `MouseLookSensitivity`, `Stiffness`, or `AdjustmentSpeed` character option. They are presentation settings. - Item 6 is **server-synced**: `UseMouseTurning_PlayerOption = 0x31` (`acclient.h:4214`), which ACE maps to `CharacterOptions2.UseMouseTurning = 0x00400000` (`Source/ACE.Entity/Enum/CharacterOption.cs:162-163`, `Source/ACE.Entity/Enum/CharacterOptions2.cs:35`). Settable through the `SetSingleCharacterOption (0x0005)` path acdream already has. It is registered twice on the client — as a `UserPreferences` key `"Input.UseMouseTurning"` (`0x006C374A` and four other init sites) and as a `PlayerModule` option (`PlayerModule::UseMouseTurning` @ `0x005D3380`, `SetUseMouseTurning` @ `0x005D3390`, dispatch at `0x005D4269`). The Config-tab option that item 6 flips is labelled **"Turn to Face Camera"** in the UI (per the chat string), *not* "Use Mouse Turning" — useful when transcribing the Config tab. For reference, the same options' *ordinary* defaults from `gmConfigUI::InitOptions` @ `0x0049E400`: Stiffness `0x3EE66666` = 0.45, AdjustmentSpeed `0x42200000` = 40.0, MouseLookSensitivity `0x3F0CCCCD` = 0.55, AlignToSlope = 1, InvertMouseLookYAxis = 0, UseMouseTurning = 0. **acdream today:** `MouseLookState` (`src/AcDream.UI.Abstractions/Input/MouseLookState.cs`) implements retail's MMB-hold `CameraInstantMouseLook`, **not** the persistent `Input.UseMouseTurning` "turn to face camera" mode. Whether that mode exists at all in acdream is **UNVERIFIED by this lane** — the physics/camera digest (`claude-memory/project_camera_visibility_coupling.md`) is the place to check before planning it. ### 4.5 In-Game Help Files — **external plugin, not portable** `KeyStone::OpenHelp(uint stringId, int tableEnum)` @ `0x00557010` calls through `KeyStone::m_fnAC2HelpPluginExecute` — a function pointer resolved in `KeyStone::Init` @ `0x00556CF0`. Byte-verified sweep of `0x00556CF0..0x00556E20`: ``` 00556d12 push 0x007CB834 'keystone.dll' 00556d1f push 0x007CB824 'KeystoneCreate' 00556d2c push 0x007CB808 'plugins\\ACHelpPlugin.dll' 00556d38 push 0x007CB7F8 'ExecutePlugin' 00556d4f push 0x007CB7E8 'TerminatePlugin' 00556d57 push 0x007CB7CC 'plugins\\ACPluginManager.dll' ``` So retail's help is a **third-party embedded help viewer** (Keystone), loading `plugins\ACHelpPlugin.dll`. There is no DAT-resident help content we can read and no DID to cite. The three call sites are `gmGamePlayUI::HandleKeyPress` @ `0x004E9E16`, `gmCharGenMainUI::ListenToGlobalMessage` @ `0x004E901C`, and `ClientUISystem::OnAction` @ `0x00564BCE` — all keyed on **InputAction `0x7B`**, which is `ToggleHelp` in the default keymap (`retail-default.keymap.txt:139`, `ToggleHelp [ "" [ 0 DIK_F1 ] ]`). **Verdict: needs-new-subsystem / asset-missing.** Any acdream implementation is a product decision (open a URL, open bundled markdown, open the in-repo docs) — not a port. --- ## 5. Configure Keyboard (Q5) — retail's screen ### 5.1 Class + widget inventory `gmKeyboardUI : OptionPage, gmNoticeHandler` (`acclient.h:54467`): ``` UIElement_Button *m_pKeyboardLoadKeymapButton; UIElement_Button *m_pKeyboardSaveKeymapButton; UIElement_Text *m_pKeyboardCurrentKeymapLabel; UIElement_Button *m_pKeyboardResetToDefaultsButton; UIElement_Button *m_pKeyboardRevertToSavedButton; UIElement_Button *m_pKeyboardOKButton; UIElement_Button *m_pKeyboardCancelButton; HashTable m_hashMappingListBoxes; unsigned int m_uiLoadKeymapDialogContext; unsigned int m_uiSaveKeymapDialogContext; unsigned int m_uiCantOverwriteReadOnlyKeymapDialogContext; unsigned int m_uiOverwriteKeymapDialogContext; List> m_listCachedKeymapFilenames; ``` `gmKeyboardUI::PostInit` @ `0x004DB770` wires them. The **buttons come from LayoutDesc attributes**, not hard-coded ids (`UIElement::GetAttribute_Enum(this, , &id)` then `GetChildRecursive(this, id)`): | Attribute enum | Field | |---|---| | `0x1000001B` | Reset to Defaults button | | `0x1000001C` | Revert to Saved button | | `0x1000001D` | Current-keymap label | | `0x1000001E` | Load Keymap button | | `0x1000001F` | Save Keymap button | | `0x10000019` | OK button | | `0x1000001A` | Cancel button | The **six list boxes** are hard-coded container ids, each holding a list box found by attribute `0x10000018`: | Container element id | `ActionClass` key | |---|---| | `0x1000049D` | `Movement` | | `0x1000049F` | `Camera` | | `0x100004A1` | `Combat` | | `0x100004A3` | `UI` | | `0x10000211` | `CharacterSettings` | | `0x100004A5` | `Emote` | (`gmKeyboardUI::PostInit` @ `0x004DB80C / 0x004DB859 / 0x004DB8A6 / 0x004DB8F3 / 0x004DB940 / 0x004DB98D`. BN prints the hash-table type as `HashTable` — an ICF/type-recovery artifact; the declared type in `acclient.h` is `HashTable`. The keys `Movement`, `Camera`, `Combat`, `UI`, `CharacterSettings`, `Emote` are named globals; the enum they belong to is **not** in `acclient.h` — only `unsigned int m_eActionClass` at line 27980 survives. Values UNKNOWN.) So: **six categories → six list boxes**, i.e. six panes/tabs on the Configure Keyboard screen. ### 5.2 How rows are built (`gmKeyboardUI::InitOptions` @ `0x004DD8B0`) ``` UpdateKeymapFilenameLabel(); flush every list box; actionMap = ICIDM::s_cidm->GetActionMap(); for each inputMap in actionMap->m_hashInputMaps: for each action in inputMap: if (!ActionMap::IsUserBindable(actionMap, inputMapId, action)) continue; // 0x006854E0 cls = ActionMap::GetActionClass(actionMap, inputMapId, action); // 0x006855C0 bucket[cls][inputMapId].push(action) for each class bucket: listBox = m_hashMappingListBoxes[cls] for each inputMapId in bucket: header = listBox.AddItemFromTemplateList(0) // template 0 = header row header.text = GetStringInfoFromInputMapID(inputMapId) // 0x004DA980 for each action: ActionMap::GetDescripValues(actionMap, action, inputMapId, &label, &tooltip) // 0x00685690 CInputMap::FindKeysForAction(inputMap, action, &List) AddActionKeyMap(listBox, action, inputMapId, label, tooltip, keys) // 0x004DB2F0 ``` `gmKeyboardUI::AddActionKeyMap` adds template row **1** (`AddItemFromTemplateList(arg2, 1, nullptr)`), casts to `UIOption_ActionKeyMap` (element type `0x10000034`), calls `UIOption_ActionKeyMap::Init` @ `0x004888E0` and `OptionPage::RegisterOption` @ `0x004F2E90`. `gmKeyboardUI::ListBoxEntryType` (`acclient.h:6864`) confirms exactly two row templates: `Header_ListBoxEntryType = 0`, `ActionKeyMap_ListBoxEntryType = 1`. ### 5.3 The header (category) names — all 19 byte-verified `gmKeyboardUI::GetStringInfoFromInputMapID` @ `0x004DA980` maps InputMapID → string-table id (table enum 7). All 19 literals were found in `.rdata` at `0x007BE45C..0x007BE648`: | InputMapID | String id | |---|---| | `0x00000004` | `ID_InputMap_MovementCommands` | | `0x00000005` | `ID_InputMap_CameraControls` | | `0x00000006` | `ID_InputMap_CameraAlternateControls` | | `0x00000009` | `ID_InputMap_DialogBoxes` | | `0x0000000B` | `ID_InputMap_DebugConsole` | | `0x0000000C` | `ID_InputMap_ProfilerUI` | | `0x0000000D` | `ID_InputMap_UIDebugger` | | `0x0000000E` | `ID_InputMap_DebugCommands` | | `0x10000002` | `ID_InputMap_Combat` | | `0x10000003` | `ID_InputMap_MeleeCombat` | | `0x10000004` | `ID_InputMap_MissileCombat` | | `0x10000005` | `ID_InputMap_MagicCombat` | | `0x10000006` | `ID_InputMap_Emotes` | | `0x10000007` | `ID_InputMap_ItemSelectionCommands` | | `0x10000008` | `ID_InputMap_CharacterOptionCommands` | | `0x10000009` | `ID_InputMap_UICommands` | | `0x1000000A` | `ID_InputMap_ChatCommands` | | `0x1000000C` | `ID_InputMap_QuickslotCommands` | | `0x1000000D` | `ID_InputMap_ToggleChatEntry` | Cross-check against the shipped keymap file (`docs/research/named-retail/retail-default.keymap.txt`, group headings at lines 87/113/135/159/188/193/198/203/212/221/243/252/258/275/283/291/303/ 311/331/341): the file has **20** groups. Six of them — `TargetedUsage`, `SystemKeys`, `MouseCommands`, `ScrollableControls`, `EditControls`, `CopyAndPasteControls` — have **no** case in `GetStringInfoFromInputMapID` and therefore no header. Either `IsUserBindable` filters every action in them, or they render headerless. **UNVERIFIED which.** Caveat to carry: the low-numbered InputMapIDs are **not globally unique across master maps**. `tools/dump-keymap` output (`docs/research/named-retail/keymap-default.txt`) shows two master maps — `gmDefaultMap` @ DID `0x14000000` with contexts `4,5,6,0x10000002..0x1000000D`, and `DefaultMap` @ DID `0x14000002` with contexts `3,5,7,8,9,0xA,0x10`. Context `5` means `CameraControls` in the first and something else in the second, yet `GetStringInfoFromInputMapID` is a flat switch. The screen enumerates the **merged** `ICIDM::GetActionMap()->m_hashInputMaps`, so this is a real (retail) ambiguity, not a decomp artifact. ### 5.4 The row widget: `UIOption_ActionKeyMap` `acclient.h:54392`: ``` struct UIOption_ActionKeyMap : UIOption, UIElement_Text { UIElement_Button *m_buttonClear; SmartArray m_aKeyButtons; unsigned int m_idInputAction; unsigned int m_idInputMap; List m_qclDefaults; List m_qclSaved; List m_qclCurrent; QualifiedControl m_qcBindingBeingChanged; int m_nBindingBeingChanged; unsigned int m_ctxtDialog; unsigned int m_ctxtOverwriteBindingDialog; unsigned int m_ctxtCantOverwriteBindingDialog; int m_skipConfirmation; }; ``` **Row columns:** the action label (the `UIElement_Text` base), a tooltip (`SetTooltip` @ `0x00487050`), **N key buttons** (one per bound chord — `m_aKeyButtons` is a dynamic array, so an action with two default bindings shows two), and a **Clear** button. Three current/saved/default binding lists per row are exactly retail's Apply/Reset/Defaults triple at row granularity (`SaveCurrentValue` @ `0x00488260`, `RestoreSavedValue` @ `0x00488280`, `RestoreDefaultValue` @ `0x004884F0`). **Interactions** (`UIOption_ActionKeyMap::ListenToElementMessage` @ `0x00489A80`): | Input | Behaviour | |---|---| | Left-click (`idMessage == 1`) on `m_buttonClear` | `ClearAllBindings` @ `0x00487ED0` | | Left-click on key button *i* | `InitiateBinding(i)` @ `0x004899D0` | | Right-click (`idMessage == 0x19`, `dwParam1 == 8`) on key button *i* | `EraseBinding(i)` @ `0x00487780` (only while `i < m_qclDefaults.count` and the button isn't disabled state `0xD`) | All gated on `DialogFactory::IsDialogOpen(0x10000001) == 0`. **Capture flow** (`InitiateBinding` @ `0x004899D0`): ``` StringInfo::SetStringIDandTableEnum(&si, hash("ID_ActionKeyMap_MapInstructions"), 0x10000004); si.AddVariable_String(0, this->GetText()); // substitutes the action name m_nBindingBeingChanged = slotIndex; if (OpenMapWarnDialog(&si)) // 0x00488A00 — modal "press a key" ICIDM::s_cidm->RegisterInputHandler(this, 0x20); ``` `ID_ActionKeyMap_MapInstructions` byte-verified at file offset `0x3A30AC`, VA `0x007A30AC`. **Conflict handling** (`UIOption_ActionKeyMap::KeyHitHandler` @ `0x00489570`): ``` if (qc.m_activation & 0x81) ignore; // filtered activation types deviceType = ICIDM::GetDeviceTypeFromKey(key); ... (mouse-device special case at 0x004895AF) ... UnregisterInputHandler(0x20); CloseMapWarnDialog(); if (key is already this exact binding) return; // QualifiedControl::IsExactlyEqual ICIDM::FindConflictingInputMaps(qc, &maps); for each conflicting map: ICIDM::FindConflictingControls(qc, map, &controls); collect unique (map, control, action) triples if (any conflicting action is !ActionMap::IsUserBindable) OpenCantOverwriteBindingDialog(); // 0x00489300 — refuse else if (conflicts) OpenOverwriteBindingDialog(&conflicts); // 0x00488BF0 — confirm, may be many else SetBinding(qc, slot); // 0x00487B20 ``` So retail's conflict model is **N-way and cross-input-map**, with a distinct "this key is bound to something you're not allowed to rebind" refusal. `m_skipConfirmation` suppresses the prompt (presumably after a "don't ask again"). String ids `ID_KeyMapCantOverwriteReadOnlyKeymap_Label` (`0x007BEA8C`), `ID_KeyMapOverwriteKeymap_Label` (`0x007BEAD4`), `ID_KeyMapLoadKeymap_Label` (`0x007BEAB8`), `ID_KeyMapSaveKeymap_Label` (`0x007BEAF4`) all byte-verified. ### 5.5 The screen's own buttons `gmKeyboardUI::ListenToElementMessage` @ `0x004DD230`: | Control | Behaviour | |---|---| | Load Keymap (left-click) | `MakeLoadKeymapDialog` @ `0x004DC0B0` — a `DialogFactory` dialog listing `m_listCachedKeymapFilenames`; close → `HandleCloseLoadKeymapDialog` @ `0x004DAC20` | | Save Keymap (left-click) | `MakeSaveKeymapDialog` @ `0x004DC5B0` (filename entry); close → `HandleCloseSaveKeymapDialog` @ `0x004DD160` → `SaveKeymap(name, promptOnOverwrite: true)` | | Reset to Defaults (left-click) | `RestoreDefaultValues` (§5.6) | | Revert to Saved (left-click) | `RestoreSavedValues` | | **OK** (right-click release, `idMessage == 0x19`, `dwParam1 == 7`) | `if (OptionPage::Changed(this)) SaveKeymap(Client::m_instance + 0x144, promptOnOverwrite: false);` then `SaveCurrentValues()` | | **Cancel** (same message) | `RestoreSavedValues()` | `Client::m_instance + 0x144` is the current keymap filename, written by `gmClient::SetKeyMapFileName` @ `0x00401EF0`. Both OK/Cancel are gated on button state `!= 0xD` (disabled). ### 5.6 Reset to Defaults reloads from the **DAT**, not from a table `gmKeyboardUI::RestoreDefaultValues` @ `0x004DA850`: ``` ICIDM::s_cidm->ClearKeyMap(); ICIDM::s_cidm->AddKeyMap(0x10000001); ICIDM::s_cidm->AddKeyMap(1); return OptionPage::RestoreDefaultValues(this); ``` `CInputManager_WIN32::AddKeyMap(uint32)` @ `0x006864C0`: ``` DBObj *o = DBObj::GetByEnum(id, ...); if (o) CMasterInputMap::Merge(&m_InputMap, o, 1); ``` The two enums resolve to the two DAT master input maps our own `tools/dump-keymap` already extracts (`docs/research/named-retail/keymap-default.txt`): **`gmDefaultMap` @ DID `0x14000000`** (14 input maps, the gameplay keymap) and **`DefaultMap` @ DID `0x14000002`** (7 input maps, the system/UI keymap). Which enum maps to which DID is **UNVERIFIED** (the tool hard-codes the DIDs; `DBObj::GetByEnum`'s table was not traced). `ActionMap` itself is a DAT object too: `ActionMap::GetDBOType` @ `0x00685C00` returns **`0x27`** (raw bytes `b8 27 00 00 00 c3`), with `ActionMap::Serialize` @ `0x00685F10` and `ReloadFromDisk` driven by `CInputManager_WIN32::InitializeKeymap` @ `0x00686010`. **Every row label, tooltip, user-bindable flag and action class on the Configure Keyboard screen comes from that DAT object, not from the executable.** ### 5.7 Keymap storage — a local text file, never wire-synced **Directory.** `gmKeyboardUI::GetKeymapDirectory` @ `0x004DA8E0` returns `PSUtils::get_directory(UserPreferences::sm_strDefaultFile)` — i.e. the folder holding `UserPreferences.ini`. Byte-verified format strings at `0x00792DA7`-adjacent `.rdata`: ``` "%s\\Asheron's Call" (VA ~0x00792DBB region) "%s\\UserPreferences.ini" ('UserPreferences.ini' at file 0x392DBB, VA 0x00792DBB) ``` i.e. `\Asheron's Call\`. This matches the shipped keymap file's own header text (`retail-default.keymap.txt:9-12`). **File name.** `UserPreferences` carries a key `"keymap"` described as `"The filename of the keymap file to use"` — both byte-verified at VA `0x00792FA8` and `0x00792FDA`. `gmKeyboardUI::SaveKeymap` @ `0x004DCF90`: ``` if (PSUtils::get_extension(name) != ".keymap") name.append(".keymap"); full = path_append(GetKeymapDirectory(), name); if (!check_access(full, 0)) goto write; // doesn't exist else if (check_access(full, 2)) { if (prompt) MakeOverwriteKeymapDialog(name); else goto write; } else MakeCantOverwriteReadOnlyKeymapDialog(name); write: gmClient::SetKeyMapFileName(Client::m_instance, name); ICIDM::s_cidm->SaveKeyMap(full); UpdateKeymapFilenameLabel(); UserPreferences::Save(); // 0x00437F30 ``` `".keymap"` byte-verified at VA `0x007BEA84`; the Load dialog's glob `"*.keymap"` at VA `0x007BEA79` (stored as `\*.keymap`). **Format.** `CInputManager_WIN32::SaveKeyMap(path)` @ `0x00686C20`: `CMasterInputMap::ToFileNode(&m_InputMap, node)` then `PFileParser::SaveToFile(node, path)`. Loading is the mirror: `CInputManager_WIN32::AddKeyMap(PStringBase path)` @ `0x00686A90` → `PFileParser::LoadFromFile` → `CMasterInputMap::FromFileNode` → `CMasterInputMap::Merge`. So the `.keymap` file is Turbine's generic `PFileParser` bracket-text format — exactly the `Name [ value [ value ] ]` shape of `retail-default.keymap.txt` — and the client **rewrites it on shutdown** (also stated in the file's own header, line 14-15). **Wire: none.** No packet is built anywhere in the keymap graph. Keybinds are purely client-local, confirmed independently by the file header comments and by the absence of any `Proto_*`/`CM_*` call in `gmKeyboardUI`, `CInputManager_WIN32::SaveKeyMap`, or `ActionMap`. ### 5.8 How `retail-default.keymap.txt` relates to all of this It is a **written-out user keymap**, not the DAT default. Evidence: - Its title line is `"User Defined Keymap" [ 0000004B-0500-0000-0004-000016000000 ]` (line 53) — a per-install GUID, and the name a user-saved file gets. - It enumerates *this machine's* devices, including ten joystick GUIDs (lines 60-69) that only a live `CInputManager` enumeration produces. - Its group names are the InputMap names, matching §5.3 one-for-one for the 19 named ones. - The DAT defaults are the *other* file, `docs/research/named-retail/keymap-default.txt` — our `tools/dump-keymap` output of DIDs `0x14000000` / `0x14000002`, in raw `(scan, dev, Action=0x…, Activation=0x…)` form. Practical consequence for acdream: `retail-default.keymap.txt` is a legitimate oracle for *what the retail defaults look like after a round trip*, and `keymap-default.txt` is the oracle for the **action ids** and **activation codes** (`Activation=0x03` on most keyboard bindings, `0x80` on some mouse ones). `KeyBindings.RetailDefaults()` already cites the former. --- ## 6. acdream today (Q5 design input) ### 6.1 What exists and is good | Piece | Where | Maps to retail | |---|---|---| | `InputAction` enum, ~148 values, retail-named, grouped in retail's categories (MovementCommands, ItemSelectionCommands, UICommands, QuickslotCommands, Chat, Combat, Emotes, Camera, Scroll, Mouse selection) + an explicit `Acdream*` extension block | `src/AcDream.UI.Abstractions/Input/InputAction.cs` | retail's ActionMap action ids | | `Binding(KeyChord, InputAction, ActivationType, InputScope)`; **multiple bindings per action** supported (`KeyBindings.ForAction`) | `Input/Binding.cs`, `Input/KeyBindings.cs` | retail's `List` per row | | `ActivationType { Press, Release, Hold, DoubleClick, Click, Analog }` explicitly modelled on the keymap's sixth field | `Input/ActivationType.cs` | retail `QualifiedControl::m_activation` | | `ModifierMask` / `KeyChord` | `Input/KeyChord.cs`, `Input/ModifierMask.cs` | retail MetaKeys bitfield | | `KeyBindings.RetailDefaults()` — **the only production table**; `LoadOrDefault(path)` / `SaveToFile(path)` JSON | `Input/KeyBindings.cs:147, 402, 493` | retail `AddKeyMap(dat)` + `.keymap` file | | `InputDispatcher.BeginCapture(Action)` modal capture | `Input/InputDispatcher.cs:256` | `InitiateBinding` + `RegisterInputHandler(0x20)` | | Conflict detection with a reassign prompt (`SettingsVM.PendingConflict`, "'X' is already bound to Y. Reassign it to Z?") | `Panels/Settings/SettingsVM.cs`, `Panels/Settings/SettingsPanel.cs:56-63` | `OpenOverwriteBindingDialog` (single-conflict subset) | | Storage path `%LOCALAPPDATA%\acdream\keybinds.json` (portable via `ApplicationPathSet.KeyBindingsFile`) | `src/AcDream.Runtime/Platform/ApplicationPathSet.cs:53` | `\Asheron's Call\*.keymap` | ### 6.2 What retail has that acdream does not 1. **Six-category × N-input-map two-level grouping.** acdream's enum has categories as *comments*; there is no machine-readable `ActionClass` or `InputMap` per action, so a retail-shaped screen cannot group rows today. 2. **Per-action labels and tooltips.** Retail reads them from the DAT ActionMap (`GetDescripValues`). acdream has XML-doc comments only. 3. **A user-bindable flag.** Retail's `IsUserBindable(inputMap, action)` hides debug/system actions and drives the "can't overwrite" refusal. 4. **Named keymap files: Load / Save / Revert to Saved / current-file label.** acdream has one fixed `keybinds.json`. 5. **N-way cross-map conflict listing** vs acdream's single-conflict prompt. 6. **Right-click-to-erase a single binding, Clear-all per row.** 7. **Reset to Defaults sourced from DAT** vs acdream's C# table. ### 6.3 ⚠ The correction the handoff needs The handoff says the F11 panel *is* the current rebind surface. **It is not rendered at all.** Verified: - `SettingsDevToolsComposition.cs:6-14`: "The optional ImGui developer-tools frontend that used to compose here (VitalsPanel, ChatPanel, DebugPanel, SettingsPanel via `AcDream.UI.ImGui`) was removed at Campaign V slice V11 … **until then keybind remapping falls back to editing keybinds.json.**" - `GameplayInputCommandController.cs:56` — `DevToolsGameplayCommands` implements `ToggleSettingsPanel()`, `ToggleDebugPanel()` and `FocusChatInput()` as **empty no-ops**. - Grep for `IPanelRenderer` implementations across `src/` returns **only the interface**; the sole implementation in the tree is `tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs`. So `SettingsPanel` / `SettingsVM` / `DebugPanel` are **live, tested, unrendered** code. They are an asset (the VM logic, conflict model and rebind state machine are done and unit-tested) but there is **no shipping key-rebinding UI in acdream today**. ### 6.4 The design options (planner's choice — I am not choosing) **Option A — Reuse `SettingsVM` behind the retail button.** Build one retail-authored Configure Keyboard layout, bind it to the existing `SettingsVM` rebind/conflict logic, drop Load/Save-named-keymap and the six-category grouping. *Cost:* one `IPanelRenderer`-equivalent retained controller + a layout. *Gets you:* a working rebind screen and, as a side effect, un-blocks DebugPanel/VitalsPanel/ChatPanel re-homing (a carried debt from Campaign V). *Divergence rows needed:* no named keymap files; no category grouping; no per-action tooltips; single-conflict prompt. **Option B — Port retail's screen shape without retail's data.** Add `ActionClass` + `InputMapId` + `UserBindable` + `Label`/`Tooltip` to a static C# action-metadata table (hand-authored from `retail-default.keymap.txt` + §5.3), render six list boxes with headers, N key buttons per row, Clear, right-click erase, and the N-way conflict dialog. Keep `keybinds.json` as the store but add named save/load/revert-to-saved. *Cost:* materially larger — the metadata table alone is ~150 rows × 5 fields, plus a real list-box/rows layout and three dialogs. *Divergence rows needed:* metadata hand-authored instead of DAT-sourced; JSON instead of `.keymap`. **Option C — Port retail's screen *with* retail's data.** Read the DAT `ActionMap` (DBO type `0x27`) and the two master input maps (`0x14000000` / `0x14000002`) at runtime; drive labels, tooltips, user-bindable, action class and defaults from the dats; read/write real `.keymap` files through a `PFileParser` port. *Cost:* highest — a `PFileParser` text codec, an `ActionMap` DBO reader, and a mapping from retail action ids to acdream's `InputAction` enum (`tools/dump-keymap` already proves the DAT read is feasible and gives the action ids). *Gets you:* zero divergence rows on this screen, and retail `.keymap` files become interchangeable with the real client's — a genuinely nice property. *Risk:* the action-id → `InputAction` mapping is the whole game; every unmapped id is an invisible dead row. **Cross-cutting notes for whichever is chosen:** - Retail's storage is `\Asheron's Call\.keymap` + a `keymap` key in `UserPreferences.ini`. acdream's portable equivalent is `ApplicationPathSet.ConfigDirectory`. Options A/B keep `keybinds.json`; only C needs a second format. - Retail's OK/Cancel fire on **right-click release** (`idMessage 0x19`, `dwParam1 == 7`) and Load/Save/Reset/Revert on **left-click** (`idMessage 1`). That asymmetry is real and byte-anchored — do not "normalise" it without a register row. - Retail refuses to log off in mid-air (§2.2). If acdream ever implements Exit to Character Selection for real, that check belongs with it. --- ## 7. For the planner ### 7.1 Per-button implementability | Button | Verdict | Cheapest honest first cut | |---|---|---| | **Exit Game** | **exists** | Bind to `IGameplayWindowCommands.Close` (already the Escape target). `WorldSession.Dispose()` already does retail's `RequestLogOff` → `0xF653` → confirmation. No new wire, no register row. | | **Exit to Character Selection** | **needs-new-subsystem** | Ship as "behaves as Exit Game" + **one register row** (adaptation). Real support needs a pre-world character-select UI *and* a `WorldSession` that can return to `InCharacterSelect` without socket teardown — neither exists. Retail's confirmation dialog (`ID_Client_EndCharacterSessionConfirm`) and the mid-air refusal are the behaviours to add when it lands. | | **Configure Keyboard** | **adaptable** (Option A) / **large** (B, C) | See §6.4. Note this is currently the *only* way a user could rebind anything in-client — §6.3. | | **Use Mouse Turning Settings** | **adaptable** | It is a 6-line macro, not a screen. 5 of 6 targets are client-local settings acdream can own; the 6th is `CharacterOptions2.UseMouseTurning (0x00400000)` through the existing `SetSingleCharacterOption (0x0005)` path. Blocked only on whether acdream *has* a persistent mouse-turning camera mode — **UNVERIFIED**, check the camera digest first. Emit the six retail chat lines verbatim (§4.4) if the mode exists. | | **In-Game Help Files** | **asset-missing / needs-new-subsystem** | Retail loads `plugins\ACHelpPlugin.dll` through `keystone.dll`. There is nothing to port. Product decision: open a URL, open bundled docs, or hide the button. A hidden/disabled button needs a register row. | | **Urgent Assistance** | **adaptable, but the target is dead** | EoR opens `http://support.turbine.com/ics/support/ticketnewwizard.asp?style=classic` — a defunct Turbine URL. Alternative with **server support today**: the legacy `gmUrgentAssistanceUI` path, `ChatChannel (0x0147)` on `Channel.Help (0x400)`, which ACE handles (`GameActionChatChannel.cs:84`). | | **Report Abuse** | **server-missing** | EoR uses the same dead URL. The legacy path is `AbuseLogRequest (0x0140)` — the opcode is *named* in ACE (`GameActionType.cs:69`) but **no handler exists**, so it is a no-op against our server. Either wire the legacy UI knowing it does nothing, or hide the button. Register row either way. | ### 7.2 Configure Keyboard design options Summarised in §6.4: **A** reuse `SettingsVM` behind a retail-authored layout (small, several divergence rows, also unblocks the Campaign-V panel debt); **B** port the screen shape over a hand-authored metadata table (medium, fewer rows); **C** port screen *and* data from the DAT ActionMap + `.keymap` files (large, zero rows on this screen, real `.keymap` interchange). ### 7.3 Cross-lane findings worth stealing - `gmConfigUI::InitOptions` @ `0x0049E400` is a **complete, ordered dump of the Config tab** (handoff Q3): Sound features menu; Sound/Ambient/Interface toggle+slider trios (default on, 1.0); "play sound only when active"; Camera Stiffness 0.45, Adjustment Speed 40.0, FOV 90.0, Align To Slope on; Resolution menu default `0x03200258` (800×600, with `UIOption::SetConfirmChange`), Full Screen on, Sync To Refresh off, Screen Brightness 0, Automatic Degrades off, Graphics Performance 0, Degrade Distance 50.0; Landscape/Environment texture detail 2/1, Texture Filtering 1, Landscape Draw Distance 8, Building Detail Textures on, Multi-Pass Alpha off; Mouse Look Sensitivity 0.55, Invert Mouselook Y off, Use Mouse Turning off; Chat Font Face 2, Chat Font Size 1. Hand this to whoever owns Q3. - **Q4 (the `0x01A1` blob):** `CPlayerModule::SaveToServer(force)` @ `0x0059A660` is `if (m_bDirty || force) CM_Character::Event_CharacterOptionsEvent(playerModule)`. `CPlayerModule::UseTime` @ `0x0059A710` auto-flushes a dirty module after **480 seconds**, and `CPlayerSystem::LogOffCharacter` forces a flush before logging off. So retail sends the blob on an 8-minute timer and at logoff — not (only) on Apply. - `CPlayerModule::OnInitialize` @ `0x0059A690` shows three options applied **client-side at load**: `PersistentAtDay → LScape::SetDay`, `DisableDistanceFog → LScape::m_fFogEnabled`, `DisableMostWeatherEffects → SmartBox::EnableWeather`, `ViewCombatTarget → ClientCombatSystem::TrackTarget`. Direct input for handoff Q7 (which options have live consumers). ### 7.4 Open unknowns (do not let these get lost) 1. **UNVERIFIED — which LayoutDesc element ids are Configure Keyboard and In-Game Help Files.** `0x10000204` / `0x10000205` are the unclaimed neighbours but appear nowhere in the pseudo-C. Needs a layout dump. 2. **UNVERIFIED — how the Configure Keyboard button opens `gmKeyboardUI`.** Two candidate declarative mechanisms in §4.3; neither confirmed. 3. **UNKNOWN — the `ActionClass` enum values.** The six list-box keys are named globals (`Movement`, `Camera`, `Combat`, `UI`, `CharacterSettings`, `Emote`); `acclient.h` keeps only `unsigned int m_eActionClass`. Recoverable from `.data` at the six `HashTable::add` call sites (`0x004DB80C` etc.) if a planner needs the numbers. 4. **UNKNOWN — which `DBObj::GetByEnum` enum (`0x10000001` vs `1`) is which DID (`0x14000000` vs `0x14000002`).** 5. **UNVERIFIED — whether the six unnamed InputMaps** (`TargetedUsage`, `SystemKeys`, `MouseCommands`, `ScrollableControls`, `EditControls`, `CopyAndPasteControls`) **are filtered by `IsUserBindable` or render headerless.** 6. **UNVERIFIED — whether acdream has a persistent "turn to face camera" mouse-turning mode** (as opposed to `MouseLookState`'s MMB-hold `CameraInstantMouseLook`). Check the camera/physics digest. 7. **UNKNOWN — the `0x54` InputAction** handled by `gmGamePlayUI::HandleKeyPress` (toggles the whole gameplay UI's visibility). Not needed for this lane; noted so nobody re-derives it. 8. **UNVERIFIED — retail's Exit Game confirmation.** The decompiled graph shows none (`m_bLogoutConfirmed` is set by the button). If the user's retail client *does* prompt, the prompt is authored in the layout. ### 7.5 COORDINATOR TODO (things this lane is forbidden to do) - **Dump the Options-panel and Configure-Keyboard LayoutDescs** from `client_portal.dat` (UI Studio: `AcDream.App ui-studio --dump`) and match element ids `0x10000203`–`0x10000207`, `0x100005CC`, `0x10000617`, `0x1000049D/9F/A1/A3/A5`, `0x10000211`, and attributes `0x10000018`–`0x1000001F` to actual buttons/labels. This closes unknowns 1, 2 and confirms the whole §1 mapping from the layout side rather than from error strings. - **Decide and record** whether the F11 Settings panel comes back (§6.3) — it is the difference between "Configure Keyboard is a new screen" and "Configure Keyboard is a second front-end on an existing VM". - Any `dotnet build` / `dotnet test` / client launch implied by the above. --- ## Appendix A — every retail anchor cited | Symbol | Address | |---|---| | `gmGameplayOptionsUI::Register` | `0x0049E0F0` | | `gmGameplayOptionsUI::Create` | `0x0049E060` | | `gmGameplayOptionsUI::ListenToElementMessage` | `0x0049E110` | | `gmGameplayOptionsUI::ListenToGlobalMessage` (ICF-folded, empty) | `0x004F5860` | | `gmGameplayOptionsUI::PostInit` (ICF-folded) | `0x004BFA00` | | `gmCharacterSettingsUI::ListenToElementMessage` | `0x0049E3A0` | | `gmCharacterSettingsUI::GetUIElementType` (`0x10000027`) | `0x004A01C0` | | `gmConfigUI::InitOptions` | `0x0049E400` | | `gmConfigUI::PostInit` | `0x0049E840` | | `gmConfigUI::SetMouseTurningDefaults` | `0x0049E8F0` | | `gmConfigUI::ListenToGlobalMessage` | `0x0049EB90` | | `gmConfigUI::GetUIElementType` (`0x10000028`) | `0x0049E2E0` | | `CM_UI::SendNotice_EndCharacterSession` | `0x00479B40` | | `gmGamePlayUI::RecvNotice_EndCharacterSession` | `0x004EBEA0` | | `gmGamePlayUI::HandleKeyPress` | `0x004E9DF0` | | `gmGamePlayUI::EndSession` | `0x004EA1B0` | | `gmGamePlayUI::UseTime` | `0x004EA3A0` | | `gmEpilogueUI::gmEpilogueUI` | `0x004E9B00` | | `gmIndicatorsUI::ListenToElementMessage` (element `0x100000FA` → same notice) | `0x004BFA90` | | `CPlayerSystem::LogOffCharacter` | `0x00563520` | | `CPlayerSystem::RequestLogOff` | `0x00562DD0` | | `CPlayerSystem::ExecuteLogOff` | `0x0055D780` | | `Proto_UI::LogOffCharacter` (`0xF653`) | `0x00546A20` | | `CPlayerModule::SaveToServer` | `0x0059A660` | | `CPlayerModule::OnInitialize` | `0x0059A690` | | `CPlayerModule::UseTime` (480 s dirty flush) | `0x0059A710` | | `KeyStone::Init` | `0x00556CF0` | | `KeyStone::OpenHelp` | `0x00557010` | | `ClientUISystem::OnAction` (action `0x7B` → help) | `0x00564B90` | | `UIElementManager::BroadcastGlobalMessage` | `0x0045B3E0` | | `UIElementManager::KeyPressEvent` | `0x0045C300` | | `UIElementManager::ActionHandler` | `0x0045CC00` | | `UIElementManager::DoVisibilityToggleAction` | `0x0045B660` | | `UIListener::RegisterForGlobalMessage` | `0x00465E60` | | `gmAbuseUI::PostInit` / `ReportAbuse` / `ListenToElementMessage` / `RecvNotice_AbuseReportResponse` | `0x004BBF30` / `0x004BC380` / `0x004BC5C0` / `0x004BC1B0` | | `gmAbuseUI::GetUIElementType` (`0x10000018`) | `0x004BBF20` | | `CM_Character::Event_AbuseLogRequest` (opcode `0x0140`) | `0x006A2A30` | | `CM_Character::SendNotice_AbuseReportResponse` | `0x006A24B0` | | `gmUrgentAssistanceUI::PostInit` / `ListenToElementMessage` | `0x004A7840` / `0x004A7940` | | `gmUrgentAssistanceUI::GetUIElementType` (`0x1000001F`) | `0x004A7830` | | `CM_Communication::Event_ChannelBroadcast` (channel `0x400` = Help) | `0x006A4030` | | `gmKeyboardUI::Register` (`0x1000000E`) | `0x004DCF70` | | `gmKeyboardUI::PostInit` | `0x004DB770` | | `gmKeyboardUI::InitOptions` | `0x004DD8B0` | | `gmKeyboardUI::AddActionKeyMap` | `0x004DB2F0` | | `gmKeyboardUI::GetStringInfoFromInputMapID` | `0x004DA980` | | `gmKeyboardUI::GetKeymapDirectory` | `0x004DA8E0` | | `gmKeyboardUI::SaveKeymap` | `0x004DCF90` | | `gmKeyboardUI::RestoreDefaultValues` | `0x004DA850` | | `gmKeyboardUI::ListenToElementMessage` | `0x004DD230` | | `gmKeyboardUI::MakeLoadKeymapDialog` / `MakeSaveKeymapDialog` | `0x004DC0B0` / `0x004DC5B0` | | `gmKeyboardUI::MakeOverwriteKeymapDialog` / `MakeCantOverwriteReadOnlyKeymapDialog` | `0x004DCA20` / `0x004DC7B0` | | `gmKeyboardUI::HandleCloseLoadKeymapDialog` / `HandleCloseSaveKeymapDialog` | `0x004DAC20` / `0x004DD160` | | `gmKeyboardUI::UpdateKeymapFilenameLabel` | `0x004DB290` | | `gmClient::SetKeyMapFileName` | `0x00401EF0` | | `UserPreferences::Save` | `0x00437F30` | | `UIOption_ActionKeyMap::Init` | `0x004888E0` | | `UIOption_ActionKeyMap::InitiateBinding` | `0x004899D0` | | `UIOption_ActionKeyMap::KeyHitHandler` | `0x00489570` | | `UIOption_ActionKeyMap::SetBinding` | `0x00487B20` | | `UIOption_ActionKeyMap::EraseBinding` | `0x00487780` | | `UIOption_ActionKeyMap::ClearAllBindings` | `0x00487ED0` | | `UIOption_ActionKeyMap::OpenMapWarnDialog` | `0x00488A00` | | `UIOption_ActionKeyMap::OpenOverwriteBindingDialog` | `0x00488BF0` | | `UIOption_ActionKeyMap::OpenCantOverwriteBindingDialog` | `0x00489300` | | `UIOption_ActionKeyMap::ListenToElementMessage` | `0x00489A80` | | `UIOption_ActionKeyMap::Save/Restore/RestoreDefault Value` | `0x00488260` / `0x00488280` / `0x004884F0` | | `OptionPage::SaveCurrentValues` / `RestoreSavedValues` / `RestoreDefaultValues` / `Changed` / `RegisterOption` | `0x004F2C60` / `0x004F2D00` / `0x004F2CB0` / `0x004F2D60` / `0x004F2E90` | | `ActionMap::IsUserBindable` | `0x006854E0` | | `ActionMap::GetActionClass` | `0x006855C0` | | `ActionMap::GetDescripValues` | `0x00685690` | | `ActionMap::GetDBOType` (returns `0x27`) | `0x00685C00` | | `ActionMap::Serialize` | `0x00685F10` | | `CInputManager_WIN32::InitializeKeymap` | `0x00686010` | | `CInputManager_WIN32::AddKeyMap(uint32)` | `0x006864C0` | | `CInputManager_WIN32::AddKeyMap(PStringBase)` | `0x00686A90` | | `CInputManager_WIN32::SaveKeyMap` | `0x00686C20` | ## Appendix B — byte-verified literals | VA | file offset | Content | |---|---|---| | `0x007A82A0` | `0x3A82A0` | `http://support.turbine.com/ics/support/ticketnewwizard.asp?style=classic` (pushed at `0x0049E14B` **and** `0x0049E1E7`) | | `0x007A81E0` | — | urgent-assistance browser-failure message | | `0x007A8128` | — | abuse-report browser-failure message | | `0x00794078` | — | `Asheron's Call Error` | | `0x007C2CEC` | `0x3C2CEC` | `ID_Client_EndCharacterSessionConfirm` | | `0x007C29C0` | — | `Cannot log off while in mid-air.` (UTF-16LE) | | `0x007A30AC` | `0x3A30AC` | `ID_ActionKeyMap_MapInstructions` | | `0x007BE45C`–`0x007BE648` | `0x3BE45C`–`0x3BE648` | all 19 `ID_InputMap_*` strings (§5.3) | | `0x007BEA79` / `0x007BEA84` | — | `*.keymap` / `.keymap` | | `0x007BEA8C` / `0x007BEAB8` / `0x007BEAD4` / `0x007BEAF4` | — | `ID_KeyMapCantOverwriteReadOnlyKeymap_Label` / `ID_KeyMapLoadKeymap_Label` / `ID_KeyMapOverwriteKeymap_Label` / `ID_KeyMapSaveKeymap_Label` | | `0x00792DBB` | `0x392DBB` | `UserPreferences.ini` (with `%s\Asheron's Call` adjacent) | | `0x00792FA8` / `0x00792FDA` | `0x392FA8` | `keymap` / `The filename of the keymap file to use` | | `0x007CB834` / `0x007CB808` / `0x007CB7CC` | — | `keystone.dll` / `plugins\ACHelpPlugin.dll` / `plugins\ACPluginManager.dll` | | `0x007A8A70` / `0x007A8A20` / `0x007A89D0` / `0x007A8980` / `0x007A8928` / `0x007A88D0` | — | the six mouse-turning-defaults chat lines (§4.4) | ## Appendix C — BN artifact classes hit in this lane Recorded so the next reader does not mistake them for evidence: 1. **ICF (identical-code folding) name aliasing.** `0x004F5860` is printed as `gmKeyboardUI::ListenToGlobalMessage` *and* `gmGameplayOptionsUI::…` *and* `gmAbuseUI::…` *and* `gmUrgentAssistanceUI::…`; `0x004BFA00` is printed as `gmCGProfessionPage::PostInit` when reached as `gmGameplayOptionsUI::PostInit`. Shared address ≠ shared design. 2. **vtable-slot-as-string-pointer.** BN renders string operands it cannot resolve as `&gmConfigUI::\`vftable'.RecvNotice_OpenTrade` etc. Five of the six mouse-turning chat strings appear that way; all six were recovered by the push-imm32 sweep instead. 3. **`test ah, 0x44` / `jnp` float comparisons.** BN emits `bool p_1 = unimplemented {test ah, 0x44}` and then guesses the branch. Polarity must be read from the bytes (§4.4). 4. **Wrong struct-field attribution.** `UIOption_ActionKeyMap`'s `m_buttonClear` / `m_aKeyButtons` appear as `this->m_lastCursor.m_x0 / m_y0 / m_y1`; `gmKeyboardUI`'s hash table is typed `HashTable`. Always reconcile against `acclient.h`. 5. **~33-character string previews.** `"ID_InputMap_ItemSelectionCommand…"`, `"ID_InputMap_CharacterOptionComma…"`, `"ID_InputMap_CameraAlternateContr…"` — all three were truncated and all three were completed from the binary.