diff --git a/docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md b/docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md new file mode 100644 index 00000000..9240d7b4 --- /dev/null +++ b/docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md @@ -0,0 +1,1151 @@ +# 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.