acdream/docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md
Erik 29138430ef docs: Campaign OP research — Configure Keyboard + Gameplay-tab actions (lane D)
Third of four Opus research lanes for the retail Options panel campaign:

- Gameplay Options tab (gmGameplayOptionsUI::ListenToElementMessage
  @0x0049E110): five of seven buttons have code handlers. Exit to Char
  Selection -> local EndCharacterSession notice -> confirm dialog ->
  airborne refusal (byte-verified) -> 0xF653 logoff; Exit Game -> the
  epilogue path, no confirmation; Urgent Assistance / Report Abuse are
  WEB LINKS in the EoR build (same support-site URL, byte-verified) —
  the legacy wire paths still exist (0x0140 AbuseLogRequest: ACE names
  it but has NO handler; Help channel 0x400 broadcast: ACE handles it);
  Use Mouse Turning Settings is a 6-option macro (SetMouseTurningDefaults
  @0x0049E8F0), not a screen — five client-local prefs + one server bit
  (PlayerOption 0x31); Configure Keyboard / In-Game Help have no element
  handler in the class (help = external ACHelpPlugin.dll via keystone).

- Configure Keyboard (gmKeyboardUI): six ActionClass list boxes, rows
  from DAT ActionMaps (DBO 0x27), all 19 ID_InputMap_* strings
  byte-verified; N-way cross-map conflict handling; storage is a LOCAL
  .keymap file named in UserPreferences.ini, never wire-synced; Reset
  reloads the DAT master maps (DIDs 0x14000000/0x14000002, which
  tools/dump-keymap already extracts). retail-default.keymap.txt is a
  user-saved keymap, not the DAT default.

- LOAD-BEARING CORRECTION (verified at the seams): the F11 SettingsPanel
  is NOT rendered anywhere post-V11 — ToggleSettingsPanel() is an empty
  no-op, the only IPanelRenderer implementation is the test fake, and
  SettingsDevToolsComposition documents the keybinds.json fallback. The
  retail Options panel is therefore acdream's FIRST shipping in-client
  settings surface, and Configure Keyboard is the ONLY rebind screen —
  it also clears Campaign V's carried panel debt (#258 adjacent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 22:43:45 +02:00

1151 lines
59 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# 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, <support URL>) // Report Abuse
0x10000203 -> CM_UI::SendNotice_EndCharacterSession(1)
0x10000206 -> ShellExecuteA(open, <support URL>) // 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 15 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<unsigned long, UIElement_ListBox*, 0> m_hashMappingListBoxes;
unsigned int m_uiLoadKeymapDialogContext;
unsigned int m_uiSaveKeymapDialogContext;
unsigned int m_uiCantOverwriteReadOnlyKeymapDialogContext;
unsigned int m_uiOverwriteKeymapDialogContext;
List<PStringBase<char>> m_listCachedKeymapFilenames;
```
`gmKeyboardUI::PostInit` @ `0x004DB770` wires them. The **buttons come from
LayoutDesc attributes**, not hard-coded ids
(`UIElement::GetAttribute_Enum(this, <attr>, &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<ControlSpecification, enum ControlType, 0>` — an ICF/type-recovery
artifact; the declared type in `acclient.h` is
`HashTable<unsigned long, UIElement_ListBox*, 0>`. 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<QualifiedControl>)
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<UIElement_Button*,1> m_aKeyButtons;
unsigned int m_idInputAction;
unsigned int m_idInputMap;
List<QualifiedControl> m_qclDefaults;
List<QualifiedControl> m_qclSaved;
List<QualifiedControl> 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. `<My Documents>\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<char> 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<QualifiedControl>` 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<KeyChord>)` 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` | `<Documents>\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 `<Documents>\Asheron's Call\<name>.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<char>)` | `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<ControlSpecification, enum ControlType, 0>`. 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.