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>
59 KiB
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 = 0x0140exists inSource/ACE.Server/Network/GameAction/GameActionType.cs:69and inSource/ACE.Entity/PacketOpCodeNames.cs:168(Evt_Character__AbuseLogRequest_ID), but there is no[GameAction(GameActionType.AbuseLogRequest)]handler anywhere inSource/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.cscase Channel.Help:at line 84, reached throughGameActionType.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 inreferences/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:
UIElementManager::DoVisibilityToggleAction(action)@0x0045B660— looks upm_elementInputActionListenerTable[action]and broadcasts element message0x31to every element registered for that InputAction. Elements declare that registration in theirElementDesc.- A plain LayoutDesc-authored state/visibility change on the button
(the same class of authoring that
CM_UI::SendNotice_SetPanelVisibilityserves elsewhere — seegmGamePlayUIat0x004E9D4C).
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
UserPreferencesonly. Grep ofreferences/ACE/Source/finds noAlignToSlope,InvertMouseLook,MouseLookSensitivity,Stiffness, orAdjustmentSpeedcharacter option. They are presentation settings. - Item 6 is server-synced:
UseMouseTurning_PlayerOption = 0x31(acclient.h:4214), which ACE maps toCharacterOptions2.UseMouseTurning = 0x00400000(Source/ACE.Entity/Enum/CharacterOption.cs:162-163,Source/ACE.Entity/Enum/CharacterOptions2.cs:35). Settable through theSetSingleCharacterOption (0x0005)path acdream already has. It is registered twice on the client — as aUserPreferenceskey"Input.UseMouseTurning"(0x006C374Aand four other init sites) and as aPlayerModuleoption (PlayerModule::UseMouseTurning@0x005D3380,SetUseMouseTurning@0x005D3390, dispatch at0x005D4269).
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
CInputManagerenumeration 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— ourtools/dump-keymapoutput of DIDs0x14000000/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
- Six-category × N-input-map two-level grouping. acdream's enum has
categories as comments; there is no machine-readable
ActionClassorInputMapper action, so a retail-shaped screen cannot group rows today. - Per-action labels and tooltips. Retail reads them from the DAT
ActionMap (
GetDescripValues). acdream has XML-doc comments only. - A user-bindable flag. Retail's
IsUserBindable(inputMap, action)hides debug/system actions and drives the "can't overwrite" refusal. - Named keymap files: Load / Save / Revert to Saved / current-file
label. acdream has one fixed
keybinds.json. - N-way cross-map conflict listing vs acdream's single-conflict prompt.
- Right-click-to-erase a single binding, Clear-all per row.
- 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 viaAcDream.UI.ImGui) was removed at Campaign V slice V11 … until then keybind remapping falls back to editing keybinds.json."GameplayInputCommandController.cs:56—DevToolsGameplayCommandsimplementsToggleSettingsPanel(),ToggleDebugPanel()andFocusChatInput()as empty no-ops.- Grep for
IPanelRendererimplementations acrosssrc/returns only the interface; the sole implementation in the tree istests/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+ akeymapkey inUserPreferences.ini. acdream's portable equivalent isApplicationPathSet.ConfigDirectory. Options A/B keepkeybinds.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@0x0049E400is 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 default0x03200258(800×600, withUIOption::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
0x01A1blob):CPlayerModule::SaveToServer(force)@0x0059A660isif (m_bDirty || force) CM_Character::Event_CharacterOptionsEvent(playerModule).CPlayerModule::UseTime@0x0059A710auto-flushes a dirty module after 480 seconds, andCPlayerSystem::LogOffCharacterforces a flush before logging off. So retail sends the blob on an 8-minute timer and at logoff — not (only) on Apply. CPlayerModule::OnInitialize@0x0059A690shows 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)
- UNVERIFIED — which LayoutDesc element ids are Configure Keyboard and
In-Game Help Files.
0x10000204/0x10000205are the unclaimed neighbours but appear nowhere in the pseudo-C. Needs a layout dump. - UNVERIFIED — how the Configure Keyboard button opens
gmKeyboardUI. Two candidate declarative mechanisms in §4.3; neither confirmed. - UNKNOWN — the
ActionClassenum values. The six list-box keys are named globals (Movement,Camera,Combat,UI,CharacterSettings,Emote);acclient.hkeeps onlyunsigned int m_eActionClass. Recoverable from.dataat the sixHashTable::addcall sites (0x004DB80Cetc.) if a planner needs the numbers. - UNKNOWN — which
DBObj::GetByEnumenum (0x10000001vs1) is which DID (0x14000000vs0x14000002). - UNVERIFIED — whether the six unnamed InputMaps (
TargetedUsage,SystemKeys,MouseCommands,ScrollableControls,EditControls,CopyAndPasteControls) are filtered byIsUserBindableor render headerless. - UNVERIFIED — whether acdream has a persistent "turn to face camera"
mouse-turning mode (as opposed to
MouseLookState's MMB-holdCameraInstantMouseLook). Check the camera/physics digest. - UNKNOWN — the
0x54InputAction handled bygmGamePlayUI::HandleKeyPress(toggles the whole gameplay UI's visibility). Not needed for this lane; noted so nobody re-derives it. - UNVERIFIED — retail's Exit Game confirmation. The decompiled graph
shows none (
m_bLogoutConfirmedis 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 ids0x10000203–0x10000207,0x100005CC,0x10000617,0x1000049D/9F/A1/A3/A5,0x10000211, and attributes0x10000018–0x1000001Fto 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:
- ICF (identical-code folding) name aliasing.
0x004F5860is printed asgmKeyboardUI::ListenToGlobalMessageandgmGameplayOptionsUI::…andgmAbuseUI::…andgmUrgentAssistanceUI::…;0x004BFA00is printed asgmCGProfessionPage::PostInitwhen reached asgmGameplayOptionsUI::PostInit. Shared address ≠ shared design. - 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. test ah, 0x44/jnpfloat comparisons. BN emitsbool p_1 = unimplemented {test ah, 0x44}and then guesses the branch. Polarity must be read from the bytes (§4.4).- Wrong struct-field attribution.
UIOption_ActionKeyMap'sm_buttonClear/m_aKeyButtonsappear asthis->m_lastCursor.m_x0 / m_y0 / m_y1;gmKeyboardUI's hash table is typedHashTable<ControlSpecification, enum ControlType, 0>. Always reconcile againstacclient.h. - ~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.