feat(ui): Campaign OP slice OP8 — Configure Keyboard

Ports retail's Configure Keyboard screen (gmKeyboardUI, LayoutDesc
0x21000009) — its own separate full-screen window, not a fifth Options-
panel tab. Retires OP3's INERT contract for the Gameplay tab's Configure
Keyboard button (0x10000204).

DAT reader (src/AcDream.Core/Input/RetailActionMap.cs): reads the
ActionMap singleton (DID 0x26000000, empirically the only one — not
0x27000000 as GetDBOType's Turbine-internal tag would suggest) and both
MasterInputMap defaults (0x14000000 "gmDefaultMap"/0x14000002
"DefaultMap"), union-merged per (InputMapId, ActionId) — proven order-
independent since the two maps' one shared context (0x5) has disjoint
action-id sets. Empirically resolved three lane-D unknowns against the
live DAT: the six ActionClass values (1=Movement, 2=Camera, 3=UI,
4=Combat, 5=Emote, 7=CharacterSettings — 6 is genuinely absent), that
the six unnamed InputMaps are 100% non-bindable (render nothing, not an
unlabeled group), and that the enum-to-DID pairing for the two master
maps is inconsequential to the merge result.

Identity table (src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs):
maps DAT (InputMapId, ActionId) pairs to acdream's InputAction where a
live consumer exists (~140 of 306 user-bindable rows — Movement/Camera/
Combat map almost completely; UI/Quickslot/Chat partially; only 5 of 87
Emotes and none of 48 CharacterSettings hotkeys, since acdream has no
general emote player or hotkey-to-option-toggle dispatcher yet). Every
entry cross-verified by label match AND a DAT-default-vs-
KeyBindings.RetailDefaults() byte comparison (RetailActionIdentityRoundTripTests),
which caught a real off-by-one in the Quickslot 13-18 block before it
shipped and found three genuine pre-existing RetailDefaults() gaps
(walk-mode's Shift-echoed chord, ten CameraAlternateControls arrow-key
alternates, and the Quickslot Ctrl+N use-vs-select ambiguity) — none
introduced by this slice, all documented rather than silently patched.

KeyboardConfigController: six ActionClass list boxes built from the
DAT, merged with live KeyBindings for mapped rows (rebind applies
immediately through the same InputDispatcher every other input path
uses) and a new sibling RetailUnmappedKeyBindings store for rows with
no InputAction yet. Left-click a key button opens real InputDispatcher
modal capture; right-click erases that slot. N-way conflict detection
scans every other row plus the live KeyBindings table for acdream-only
actions (Ctrl+M mute, debug F-keys) as the non-user-bindable refusal
analogue, using retail's own byte-verified "Could not overwrite "
string (table 0x23000004). OK/Cancel/Defaults/Revert reuse the
OptionPage/IOptionRow verb model via a new ActionKeyMapOptionRow.
Persistence is keybinds.json only (D4 — no .keymap file interchange).

Five register rows: AP-202 (.keymap interchange narrowing), AP-203
(store-only rows with no live consumer), AP-204 (silent auto-reassign
instead of retail's confirm dialog; OK/Cancel ported as left-click not
right-click-release).

Small supporting additions: UiButton.OnRightClick (additive, no
existing behavior changed), InputDispatcher.Bindings getter (the
screen's single live-truth read seam), RetailScanCodeMap (DIK scan
code <-> Silk.NET Key, keyboard + the one mouse-device row).

19 new tests (6 ActionMap reader conformance incl. live-DAT row-count/
label pins, 1 DAT-vs-RetailDefaults round-trip, 12 controller
behavior tests against the committed keyboard_config_21000009.json
fixture) — full solution suite 13,147 passed / 4 skipped / 0 failed
(baseline 13,128/4/0, zero regressions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 09:19:54 +02:00
parent ff5776415b
commit b4edee970f
23 changed files with 35760 additions and 12 deletions

File diff suppressed because one or more lines are too long

View file

@ -424,5 +424,5 @@ before anything builds on them.
| OP5 | CODE-COMPLETE, gate READY | `e71e5a96` (AP-195 retired) → fixes `6d0b0f92` → residuals `67b0815c` | combined APPROVE-WITH-FIXES (`2026-08-11-op5-review.md`) → re-check CLOSED (`2026-08-11-op5-recheck.md`) → coordinator drag residuals landed | connected gate OWED (script §OP5) |
| OP6 | CODE-COMPLETE, gate READY | `f5ac1742` (REJECTED) → rework `472525b9` → doc residuals (coordinator) | REJECT (`2026-08-11-op6-review.md`) → re-review CLOSED, all six caption sites byte-decoded (`2026-08-11-op6-rereview.md`) | connected gate OWED (script §OP6) |
| OP7 | CLOSED | `09cb548a` → fixes in `7b60e71b` (shared commit, see its message) | combined-lens APPROVE-WITH-FIXES (`2026-08-11-op7-review.md`); all nine findings closed | live bot-vs-ACE gate PASSED 2026-08-11 (coordinator; evidence in script §OP7) |
| OP8 | — | | | |
| OP8 | CODE-COMPLETE, gate READY | (this branch's commit) | not yet reviewed | connected gate OWED (script §OP8) |
| OP9 | — | | | |

View file

@ -1,8 +1,7 @@
# Campaign OP connected-gate test script
**Status:** OP3, OP4, OP5, OP6, and OP7 sections. Later slices (OP8) append
their own sections here as they land; the campaign's OP9 closeout gate is
this document complete plus every slice code-complete.
**Status:** OP3, OP4, OP5, OP6, OP7, and OP8 sections. The campaign's OP9
closeout gate is this document complete plus every slice code-complete.
This document is the script the user runs against the live connected
client (`ACDREAM_LIVE=1` against the local ACE server) to accept each
@ -851,3 +850,149 @@ Three live runs against local ACE (`127.0.0.1:9000`, `+Acdream`,
unmapped `[weenie-error] code=0x051D`, and the K4 resource envelope's
`handle-count` violation (the `k4-linux-30-session` Linux profile
evaluated on Windows).
---
## OP8 — Configure Keyboard
The screen is retail's own separate full-screen window (`gmKeyboardUI`,
LayoutDesc `0x21000009`), NOT a fifth tab of the Options panel. It opens
from the Gameplay tab's Configure Keyboard button — **the OP3 INERT
contract for that button is retired as of this slice**; OP3's own script
line calling it INERT no longer applies.
### Opening the screen
1. **Open the Options panel (F11), Gameplay tab, click "Configure
Keyboard."** A separate full-screen window opens (not layered inside
the Options panel) showing six tabs across the top: Movement, Camera,
Combat, UI, CharacterSettings, Emote — Movement is the default/first
tab. Each tab lists grouped rows: a bold-ish header naming the
sub-category (e.g. "Movement Commands"), then one row per action —
an action label on the left, up to three key-binding buttons to its
right (retail's "Mapping 1/2/3" columns) showing the currently bound
key(s) (e.g. "W", "Up").
2. **Not every group has a visible header/rows.** Debug/dialog/system
InputMaps (DialogBoxes, DebugConsole, ProfilerUI, UIDebugger,
DebugCommands, and six further unnamed contexts) are 100% non-user-
bindable in the shipped DAT and correctly show NOTHING — this is not
a bug, it is retail's own shipped data (byte-verified against the
live DAT, see `RetailActionMap.cs`'s class doc).
3. **The bottom-row buttons:** Load File…, a current-keymap-name label,
Save As…, Defaults, Revert, OK, Cancel. Load File / Save As are
**INERT** (D4 — no `.keymap` file interchange, AP-202) — clicking
them does nothing; this is correct, contracted behavior.
### Rebind a movement key live
4. **On the Movement tab, find "Move Forward"** (should show two bound
keys, "W" and "Up"). Click the SECOND key button (currently "Up").
The button should visually indicate it is listening for input
(retail's own capture-prompt text is not wired to a tooltip in this
port — a simple pressed/active state is enough to confirm capture
started).
5. **Press a different key**, e.g. `U`. The button should immediately
update to show "U".
6. **Move your character forward using W and U** (both should now work
— this is the live-effect proof: the rebind reached the SAME
`InputDispatcher`/`KeyBindings` every other input path uses, not a
screen-local shadow copy). The original "Up" arrow should no longer
move the character forward.
7. **Right-click the "U" key button** you just set. It should clear
back to blank/empty — right-click erases just that one slot
(`EraseBinding`), distinct from a hypothetical "Clear all" (there is
no Clear-all button on this screen — the shipped 2013 row template
authors none; a real, DAT-verified fact, not a limitation of this
port).
### Conflict on a taken chord
8. **Still on Movement, click "Move Backward"'s first key button**
(currently "X"), then press **W** — the SAME key you just confirmed
is bound to "Move Forward."
9. **Expect**: "Move Backward" takes W, and "Move Forward" silently
loses its W slot (down to just "U" from step 5) — the SAME outcome
you'd get after confirming retail's own "reassign?" dialog, except
this port applies it immediately without asking first (AP-204,
register row — a deliberate scope narrowing for this slice; retail
shows a confirm dialog before reassigning). No error, no crash, both
rows' key-button labels update to reflect the swap.
### Non-bindable refusal
10. **Click any key button, then press Ctrl+M** (acdream's own
debug-only mute toggle — retail has no equivalent, so no DAT row on
this screen owns it). **Expect**: the rebind is REFUSED — the key
button you clicked keeps its previous value, and a system message
appears reading "Could not overwrite " (retail's own byte-verified
`ID_KeyMapCantOverwriteReadOnlyKeymap_Label` string, table
`0x23000004` — it may read as an odd sentence fragment on its own;
that is the literal stored string, not a truncation bug in this
port). Confirm Ctrl+M still mutes/unmutes audio afterward — the
acdream-only binding was NOT touched.
### Erase, then Reset to Defaults
11. **Erase a couple more bindings** via right-click (any tab).
12. **Click Defaults.** Every row on EVERY tab should snap back to its
retail-default key(s) LIVE — including the ones you just erased,
the "Move Forward"/"Move Backward" pair from steps 5-9 (both back
to their original W/Up and X/Down), and the Ctrl+M row from step 10
is untouched (it has no DAT row, so Defaults cannot and does not
touch it). The OK/Cancel/Revert buttons should now read as
"changed" (retail never gates Defaults itself, but the page as a
whole is dirty after it runs) — confirm by tabbing away and back:
edits should still be there (Defaults applies live without
committing, exactly like a manual edit would).
13. **Click Revert** (not Cancel) with SOME rows still showing your
Defaults-restored values. Expect every changed row to revert to
whatever was bound when you last clicked OK (or, if you haven't
clicked OK yet this session, back to what was loaded from
`keybinds.json` at startup) — Revert and Cancel run the identical
verb (`RestoreSavedValues`); Revert just doesn't also close the
window.
### Persistence across relaunch
14. **Rebind one distinctive key** (e.g. change "Jump" from Space to
some other free key) and **click OK.** The window closes.
15. **Close acdream gracefully and relaunch it** (see CLAUDE.md's
logout-before-reconnect discipline — wait for the graceful-close
session-clear window before reconnecting). Open Configure Keyboard
again: the rebind from step 14 should still be there — proof it
persisted to `keybinds.json` on disk, not just the in-memory
dispatcher.
16. **Open a plain text editor on `%LOCALAPPDATA%\acdream\keybinds.json`**
(or the platform-portable equivalent) and confirm the rebound
action shows the new key. A sibling `keybinds-unmapped.json` should
also exist if you rebound anything on the CharacterSettings or
Emote tabs during this session (AP-203's store-only rows — e.g. try
rebinding one "Bow Deep"-style Emote row and confirm it shows up in
THAT file, not `keybinds.json`).
### Cancel discards uncommitted edits
17. **Rebind another key WITHOUT clicking OK**, then click **Cancel.**
The window closes; reopen it — the rebind from this step should be
GONE (reverted to the last-committed/loaded state), matching step
13's Revert behavior plus the window closing.
### What to report
- Any row that shows a DIFFERENT key than what `keybinds.json` /
`RetailDefaults()` says it should (a sign the DAT-vs-InputAction
identity table mis-mapped a row — see
`RetailActionIdentityRoundTripTests` for the automated half of this
check).
- Any tab/header that renders EMPTY where it should show rows, or vice
versa (the page-scoped element-lookup trap this slice's controller
explicitly guards against — a regression here would mean one page's
rows leaked into another, or the six reused element ids resolved to
the wrong page's instance).
- Whether the silent auto-reassign (step 9) or the refusal message's
odd phrasing (step 10) feels wrong enough in practice to warrant
building the real confirm-dialog integration AP-204 defers.
- Any row whose Emote/CharacterSettings binding visibly DOES something
in-game despite AP-203 saying it shouldn't (would mean acdream grew a
consumer for it since this table was written, and the identity table
should be updated to route it live).