Merge op8-keyboard: Campaign OP slice OP8 — Configure Keyboard

Brings b4edee97 (slice), b1968ce9 (M1/M2/M3 rework), f1d50207 (round-2
residuals). Review chain: REJECT -> rework -> REOPEN-narrow -> coordinator
third round; findings docs 2026-08-11-op8-review.md / -op8-rereview.md.
The merge lands OP8's six ListBoxes on top of 057d8cd7's #372 viewport
fix, which auto-heals the blank-pages hazard the re-review flagged — the
OP8 connected gate was contracted to run post-merge for exactly this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 12:37:56 +02:00
commit 1c5cd969b4
23 changed files with 36178 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) | | 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) | | 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) | | 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 | — | | | | | OP9 | — | | | |

View file

@ -1,8 +1,7 @@
# Campaign OP connected-gate test script # Campaign OP connected-gate test script
**Status:** OP3, OP4, OP5, OP6, and OP7 sections. Later slices (OP8) append **Status:** OP3, OP4, OP5, OP6, OP7, and OP8 sections. The campaign's OP9
their own sections here as they land; the campaign's OP9 closeout gate is closeout gate is this document complete plus every slice code-complete.
this document complete plus every slice code-complete.
This document is the script the user runs against the live connected This document is the script the user runs against the live connected
client (`ACDREAM_LIVE=1` against the local ACE server) to accept each client (`ACDREAM_LIVE=1` against the local ACE server) to accept each
@ -851,3 +850,168 @@ Three live runs against local ACE (`127.0.0.1:9000`, `+Acdream`,
unmapped `[weenie-error] code=0x051D`, and the K4 resource envelope's unmapped `[weenie-error] code=0x051D`, and the K4 resource envelope's
`handle-count` violation (the `k4-linux-30-session` Linux profile `handle-count` violation (the `k4-linux-30-session` Linux profile
evaluated on Windows). evaluated on Windows).
---
## OP8 — Configure Keyboard
**Known, tracked behaviors — do NOT file as defects (read before testing):**
- **Shared combat keys prompt a false conflict (ISSUES #373).** Retail
legitimately shares Insert/Delete/End/PageUp/PageDown across the three
combat contexts (Melee/Missile/Magic) via the DAT's `ConflictingMaps`
table, which acdream does not read yet. Rebinding any of those keys — or
any chord one combat context already holds — will show a conflict
confirmation retail would not. DECLINE the prompt and move on; the
binding you declined stays untouched. Report only if declining CHANGES a
binding anyway.
- **Camera Alternate Controls rows are display/store-only (AP-203).** They
show retail's authored arrow-key defaults and accept edits, but do not
drive the camera — only the primary Camera scheme is live. Verify they
RENDER (arrow keys visible); do not expect camera behavior from them.
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**: a confirmation dialog opens (retail's own
`OpenOverwriteBindingDialog`, ported through the same
`RetailDialogFactory` confirm mechanism the game's other Yes/No
prompts already use) naming "Move Forward" as the row that currently
holds the key and asking whether to reassign it to "Move Backward."
**Click Yes**: "Move Backward" takes W, and "Move Forward" loses its
W slot (down to just "U" from step 5); both rows' key-button labels
update to reflect the swap. Repeat steps 8-9 once more but **click
No** this time: neither row should change at all — the capture is
simply abandoned, exactly like Escape.
### 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 confirm dialog's wording (step 9) or the refusal
message's odd phrasing (step 10, the literal DAT string) reads
awkwardly enough in practice to warrant a follow-up polish pass.
- 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).

View file

@ -54,6 +54,10 @@ internal sealed record InteractionRetainedUiDependencies(
HostQuiescenceGate HostQuiescence, HostQuiescenceGate HostQuiescence,
RetainedUiInputCaptureSlot RetainedInputCapture, RetainedUiInputCaptureSlot RetainedInputCapture,
InputDispatcher? InputDispatcher, InputDispatcher? InputDispatcher,
// Campaign OP slice OP8: the portable keybinds.json path
// (ApplicationPathSet.KeyBindingsFile) — the Configure Keyboard screen's
// Save button writes here, same file GameWindow's startup load reads.
string KeyBindingsFilePath,
RuntimeSettingsController Settings, RuntimeSettingsController Settings,
GameRuntime Runtime, GameRuntime Runtime,
IRuntimeCombatAttackOperations CombatAttackOperations, IRuntimeCombatAttackOperations CombatAttackOperations,
@ -877,7 +881,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
action => d.InputDispatcher?.TryInvokeAutomationAction(action) == true, action => d.InputDispatcher?.TryInvokeAutomationAction(action) == true,
(action, held) => (action, held) =>
d.InputDispatcher?.TrySetAutomationActionHeld(action, held) == true, d.InputDispatcher?.TrySetAutomationActionHeld(action, held) == true,
late.Automation)); late.Automation),
Keyboard: new KeyboardRuntimeBindings(d.InputDispatcher, d.KeyBindingsFilePath));
RetailUiRuntime runtime = lease.Mount( RetailUiRuntime runtime = lease.Mount(
() => RetailUiRuntime.CreateUninitialized(bindings)); () => RetailUiRuntime.CreateUninitialized(bindings));
checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted); checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted);

View file

@ -1342,6 +1342,7 @@ public sealed class GameWindow :
_hostQuiescence, _hostQuiescence,
_retainedInputCapture, _retainedInputCapture,
hostInputCamera.InputDispatcher, hostInputCamera.InputDispatcher,
_applicationPaths.KeyBindingsFile,
_runtimeSettings, _runtimeSettings,
_runtime, _runtime,
_combatAttackOperations, _combatAttackOperations,

View file

@ -0,0 +1,615 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.App.UI;
using AcDream.Core.Input;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Campaign OP slice OP8: retail's Configure Keyboard screen —
/// <c>gmKeyboardUI</c> (LayoutDesc <c>0x21000009</c>, root <c>0x1000001F</c>,
/// 800×600), its own SEPARATE full-screen window (research doc's structure
/// lane §8 / lane D §4.3, NOT a fifth tab of the four-tab Options panel —
/// "the retail keyboard screen is NOT one of the four Options tabs"). Mounted
/// the same way the Options panel opens (F11/toolbar → <c>ToggleOptionsPanel</c>
/// action <c>0x1000001A</c>): the Gameplay tab's Configure Keyboard button
/// (<c>0x10000204</c>) and this screen's own OK/Cancel buttons all carry
/// authored <c>P0x12 = 0x1000001F</c> — the SAME toggle-window pattern, byte-
/// verified against the committed <c>options_gameplay_2100002A.json</c> and
/// <c>keyboard_config_21000009.json</c> fixtures — so a single <see cref="Toggle"/>
/// callback covers the open path (the Gameplay-tab button) and both close paths
/// (OK/Cancel) without porting the generic
/// <c>UIElementManager::DoVisibilityToggleAction</c> action-broadcast machinery,
/// which nothing else in this codebase needs yet.
///
/// <para>
/// <b>Six ActionClass list boxes.</b> The screen's own Type-8 tab control
/// (<c>0x1000049B</c>) hosts six page containers — <c>0x1000049D</c> Movement
/// (default tab), <c>0x1000049F</c> Camera, <c>0x100004A1</c> Combat,
/// <c>0x100004A3</c> UI, <c>0x10000211</c> CharacterSettings, <c>0x100004A5</c>
/// Emote — EACH authoring its OWN identical child subtree: four column headers
/// (<c>0x10000021</c>-<c>0x10000024</c>, "Command"/"Mapping 1/2/3") and a
/// Type-5 ListBox (<c>0x10000025</c>) + scrollbar (<c>0x10000026</c>). <b>Every
/// one of those five ids is REUSED verbatim across all six pages</b> — the
/// live-DAT dump (<c>keyboard_config_21000009.json</c>) confirms this is the
/// SAME page-scoped-lookup trap the OP campaign has hit before (OP6's caption
/// sites): every lookup below is scoped from ITS OWN page's container root via
/// <see cref="UiElement.FindDescendant"/>, never a flat/global
/// <c>layout.FindElement</c> for these five ids.
/// </para>
///
/// <para>
/// <b>The row template.</b> <c>AddItemFromTemplateList(0)</c> builds the header
/// row (<c>0x1000002E</c>, plain text); <c>AddItemFromTemplateList(1)</c> builds
/// the action row (<c>0x1000002F</c>, Type <c>0x10000034</c> =
/// <c>UIOption_ActionKeyMap</c>). Its 3 authored children (<c>0x10000030</c>/
/// <c>31</c>/<c>32</c>, positioned under the "Mapping 1/2/3" columns) are the row's
/// key buttons — built automatically by <see cref="LayoutImporter"/>'s normal
/// recursive descent (the row itself is not one of OP2's special
/// <c>ConsumesDatChildren</c> widgets), so no new <see cref="DatWidgetFactory"/>
/// case was needed for Type <c>0x10000034</c>. <b>The shipped 2013 template has
/// exactly 3 key-button children and NO separate Clear-button child</b> — a real,
/// DAT-verified fact (register row): erasing a single binding is right-click on
/// its key button (<c>UIOption_ActionKeyMap::EraseBinding</c>, ported via
/// <see cref="UiButton.OnRightClick"/>); there is no authored affordance for
/// retail's OWN class-level <c>ClearAllBindings</c> (its <c>m_buttonClear</c>
/// field exists in the C++ class but nothing in this layout wires it) — its
/// EFFECT (clear every slot on a row) is still reachable one right-click at a
/// time. The row's own caption (the action label) is synthesized as a plain
/// <see cref="UiText"/> child (composition, not inheritance — <see cref="UiText"/>
/// is sealed), exactly the pattern <c>CharacterStatController.BuildHeaderRow</c>
/// already uses for a controller-synthesized label beside authored dat children.
/// </para>
///
/// <para>
/// <b>Row identity and binding storage (D4).</b> Every row's identity is the DAT
/// pair <c>(InputMapId, ActionId)</c> — retail's own row key. Where
/// <see cref="RetailActionIdentityTable"/> resolves that pair to an acdream
/// <see cref="InputAction"/> (research: roughly half of the DAT's 306 rows — see
/// that table's class doc for the full accounting), the row's bindings ARE
/// <see cref="KeyBindings"/>'s bindings for that action: a rebind here takes
/// effect immediately for live gameplay dispatch through the SAME
/// <see cref="InputDispatcher"/> every other input path uses, and persists to
/// <c>keybinds.json</c> exactly like any other rebind (D4 — no separate
/// <c>.keymap</c> file format). Where no <see cref="InputAction"/> exists yet
/// (mostly Emotes and CharacterSettings — see the identity table's class doc),
/// the row is still fully rendered, bindable, conflict-checked, and persisted
/// (<see cref="Bindings.CurrentForUnmapped"/>/<see cref="Bindings.SetForUnmapped"/>),
/// it just has no live gameplay consumer yet (register row).
/// </para>
///
/// <para>
/// <b>Activation/Scope preservation (M1, 2026-08-11 review).</b> A mapped row's
/// <see cref="Bindings.CurrentForAction"/> read returns the FULL live
/// <see cref="Binding"/> list, not bare chords — a single acdream
/// <see cref="InputAction"/> consistently carries one <see cref="ActivationType"/>/
/// <see cref="InputScope"/> pair across every one of its bindings (verified
/// against every multi-chord action in <c>KeyBindings.RetailDefaults()</c>:
/// walk-mode's Hold, the three melee/missile/magic combat scopes, ...), so this
/// row captures that pair ONCE at build time (from the first live binding, or
/// <see cref="ActivationType.Press"/>/<see cref="InputScope.Game"/> if the action
/// starts wholly unbound) and reapplies it to every chord this row ever writes —
/// on a live rebind, on Cancel/Revert (<c>RestoreSavedValue</c>), and on Defaults
/// (<c>RestoreDefaultValue</c>, which restores DAT-sourced KEYS only; Activation/
/// Scope are retail-side properties of the ACTION, not of which physical key
/// triggers it, so Defaults must never touch them). Before this fix,
/// <c>SetForAction</c> reconstructed every <see cref="Binding"/> with the
/// constructor's bare defaults (<see cref="ActivationType.Press"/>/
/// <see cref="InputScope.Game"/>), so a single click of Defaults collapsed the
/// Hold/scope of every one of the ~140 mapped actions in one shot — walk-mode
/// stopped unlatching, melee/missile/magic combat holds stopped repeating, and
/// scope precedence broke for every chord shared across those three scopes by
/// design (Insert/Delete/End/PageUp/PageDown).
/// </para>
///
/// <para>
/// <b>Conflicts (research doc §5.4, reworked per M3/S1, 2026-08-11 review).</b>
/// Retail's conflict model is N-way and cross-input-map, with a DISTINCT refusal
/// for a chord already bound to a non-user-bindable action, checked BEFORE any
/// user-bindable conflict is even considered (retail refuses outright the instant
/// ANY conflicting target is non-user-bindable). This port's non-user-bindable
/// analogue is a chord already bound to an acdream-only action with no
/// <see cref="RetailActionIdentityTable"/> row at all (Ctrl+M mute, the debug
/// F-keys, ...) — refused via <see cref="Bindings.NonBindableRefusalText"/>
/// exactly like retail's distinct <c>OpenCantOverwriteBindingDialog</c>, with no
/// dialog (a hard stop, matching the DAT-verified refusal string). A genuine
/// cross-row conflict collects EVERY conflicting row (not just the first) and
/// opens a real confirm dialog through <see cref="Bindings.ConfirmOverwrite"/> —
/// retail's <c>OpenOverwriteBindingDialog(&amp;conflicts)</c> — BEFORE reassigning;
/// only on accept are the losing rows' slots erased and the new chord applied.
/// </para>
/// </summary>
public sealed class KeyboardConfigController
{
/// <summary>The screen's own top-level LayoutDesc.</summary>
public const uint LayoutId = 0x21000009u;
/// <summary>The window root — ALSO the retail input-action id
/// (<c>0x1000001F</c>, "Show/Hide Keyboard Configuration") that opens/closes
/// it, per the Gameplay tab button and this screen's own OK/Cancel buttons
/// all authoring <c>P0x12 = 0x1000001F</c>.</summary>
public const uint WindowRootElementId = 0x1000001Fu;
private const uint LoadButtonId = 0x10000027u;
private const uint FilenameLabelId = 0x10000028u;
private const uint SaveAsButtonId = 0x10000029u;
private const uint DefaultsButtonId = 0x1000002Au;
private const uint RevertButtonId = 0x1000002Bu;
private const uint OkButtonId = 0x1000002Cu;
private const uint CancelButtonId = 0x1000002Du;
// Reused verbatim across all six page containers below — ALWAYS scoped
// per-page via UiElement.FindDescendant, never a flat layout.FindElement.
private const uint ListBoxElementId = 0x10000025u;
private const uint ScrollbarElementId = 0x10000026u;
private const int HeaderTemplateIndex = 0;
private const int RowTemplateIndex = 1;
// The row template's 3 key-button children, in "Mapping 1/2/3" column order.
private static readonly uint[] KeyButtonIds = { 0x10000030u, 0x10000031u, 0x10000032u };
private static readonly (uint PageContainerId, RetailActionClass Class)[] Pages =
{
(0x1000049Du, RetailActionClass.Movement),
(0x1000049Fu, RetailActionClass.Camera),
(0x100004A1u, RetailActionClass.Combat),
(0x100004A3u, RetailActionClass.Ui),
(0x10000211u, RetailActionClass.CharacterSettings),
(0x100004A5u, RetailActionClass.Emote),
};
/// <summary>One rendered row: its DAT identity, the built key-button
/// widgets (up to 3, "Mapping 1/2/3" order), and its
/// <see cref="ActionKeyMapOptionRow"/> model.</summary>
public sealed record RowView(
uint InputMapId,
uint ActionId,
InputAction? MappedAction,
string? Label,
ActionKeyMapOptionRow Model,
IReadOnlyList<UiButton> KeyButtons);
/// <summary>The live read/write/capture seam this screen writes bindings
/// through — mirrors every other Campaign OP page controller's
/// <c>Bindings</c> shape (a plain delegate record, no DAT/InputDispatcher
/// dependency baked into the controller itself). <see cref="CurrentForAction"/>/
/// <see cref="SetForAction"/> carry the FULL <see cref="Binding"/> (chord +
/// activation + scope), not a bare chord — M1's fix (see class doc).</summary>
public sealed record Bindings(
Func<InputAction, IReadOnlyList<Binding>> CurrentForAction,
Action<InputAction, IReadOnlyList<Binding>> SetForAction,
Func<(uint InputMapId, uint ActionId), IReadOnlyList<KeyChord>> CurrentForUnmapped,
Action<(uint InputMapId, uint ActionId), IReadOnlyList<KeyChord>> SetForUnmapped,
Action<Action<KeyChord?>> BeginCapture,
Action Save,
Action Toggle,
Action<string> DisplaySystemMessage,
string NonBindableRefusalText,
// M3 (2026-08-11 review): retail's OpenOverwriteBindingDialog — confirm
// BEFORE reassigning a chord already bound to another row on this screen.
// message is pre-composed (real row labels, no invented retail text);
// the callback receives the user's Yes(true)/No(false) choice.
Action<string, Action<bool>> ConfirmOverwrite);
public OptionPage Page { get; } = new();
public IReadOnlyList<RowView> Rows => _rows;
private readonly List<RowView> _rows = new();
private Bindings? _bindings;
private KeyboardConfigController() { }
/// <summary>
/// Builds every header + row across all six pages from
/// <paramref name="snapshot"/>, wires each row's key buttons to modal
/// capture / right-click erase, and wires the screen's own six buttons
/// (Defaults/Revert/OK/Cancel; Load/Save File are INERT — D4, no
/// <c>.keymap</c> interchange). Returns null if the layout's window root
/// did not import (a missing/malformed LayoutDesc).
/// </summary>
public static KeyboardConfigController? Bind(
ImportedLayout layout,
RetailActionMapSnapshot snapshot,
Func<uint, uint, UiElement?> templateResolver,
Func<uint, uint, string?> resolveString,
Bindings bindings)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentNullException.ThrowIfNull(templateResolver);
ArgumentNullException.ThrowIfNull(resolveString);
ArgumentNullException.ThrowIfNull(bindings);
if (layout.FindElement(WindowRootElementId) is null)
{
Console.WriteLine(
$"[D.2b] KeyboardConfigController: window root 0x{WindowRootElementId:X8} "
+ "not found in the built layout — Configure Keyboard will not open.");
return null;
}
var controller = new KeyboardConfigController { _bindings = bindings };
var byClass = snapshot.Rows
.Where(r => r.ActionClass != RetailActionClass.None)
.GroupBy(r => r.ActionClass)
.ToDictionary(g => g.Key, g => g.ToList());
foreach ((uint pageContainerId, RetailActionClass cls) in Pages)
{
UiElement? pageRoot = UiElement.FindDescendant(layout.Root, pageContainerId);
if (pageRoot is null)
{
Console.WriteLine(
$"[D.2b] KeyboardConfigController: page container 0x{pageContainerId:X8} "
+ "not found — that ActionClass tab will have no rows.");
continue;
}
if (UiElement.FindDescendant(pageRoot, ListBoxElementId) is not UiTemplateListBox listBox)
{
Console.WriteLine(
$"[D.2b] KeyboardConfigController: ListBox 0x{ListBoxElementId:X8} not found "
+ $"(or not a UiTemplateListBox) under page 0x{pageContainerId:X8}.");
continue;
}
listBox.TemplateResolver = templateResolver;
if (UiElement.FindDescendant(pageRoot, ScrollbarElementId) is UiScrollbar scrollbar)
scrollbar.Model = listBox.Scroll;
if (!byClass.TryGetValue(cls, out List<RetailActionMapRow>? classRows))
continue;
// Group by InputMapId in first-seen order (retail's own bucket ->
// header-per-InputMapId order, research doc §5.2's InitOptions loop).
var byInputMap = classRows
.GroupBy(r => r.InputMapId)
.OrderBy(g => g.Key);
foreach (var inputMapGroup in byInputMap)
{
BuildHeaderRow(listBox, inputMapGroup.Key, resolveString);
foreach (RetailActionMapRow row in inputMapGroup)
controller.BuildActionRow(listBox, row, resolveString, bindings);
}
}
WireScreenButtons(layout, controller, bindings);
return controller;
}
private static void BuildHeaderRow(
UiTemplateListBox listBox, uint inputMapId, Func<uint, uint, string?> resolveString)
{
if (listBox.AddItemFromTemplateList(HeaderTemplateIndex) is not UiText header)
{
Console.WriteLine(
"[D.2b] KeyboardConfigController: header template did not build as UiText "
+ $"for InputMap 0x{inputMapId:X8}.");
return;
}
if (!RetailInputMapHeaders.NameByInputMapId.TryGetValue(inputMapId, out string? headerKey))
return; // Unnamed InputMap — retail never reaches this (§5.3): no bindable
// action of ours falls in one, but stay honest rather than assume.
string? label = resolveString(
RetailInputMapHeaders.StringTableId, DatStringResolver.ComputeHash(headerKey));
if (label is null)
{
Console.WriteLine(
$"[D.2b] KeyboardConfigController: header string '{headerKey}' did not resolve — "
+ "row renders with no text rather than invented English.");
return;
}
header.LinesProvider = () => new[] { new UiText.Line(label, header.DefaultColor) };
}
private void BuildActionRow(
UiTemplateListBox listBox,
RetailActionMapRow row,
Func<uint, uint, string?> resolveString,
Bindings bindings)
{
UiElement? built = listBox.AddItemFromTemplateList(RowTemplateIndex);
if (built is null)
{
Console.WriteLine(
"[D.2b] KeyboardConfigController: row template did not build for InputMap "
+ $"0x{row.InputMapId:X8} action 0x{row.ActionId:X8}.");
return;
}
var keyButtons = new List<UiButton>(KeyButtonIds.Length);
foreach (uint id in KeyButtonIds)
{
if (UiElement.FindDescendant(built, id) is UiButton button)
keyButtons.Add(button);
}
string? label = resolveString(RetailInputMapHeaders.StringTableId, row.LabelHash);
string? tooltip = resolveString(RetailInputMapHeaders.StringTableId, row.TooltipHash);
// The row's own caption — synthesized, composed beside the authored key
// buttons (UiText is sealed; see class doc). Occupies the "Command" column
// (x=0..270, matching the authored column headers).
var captionText = new UiText
{
Left = 0f,
Top = 0f,
Width = 260f,
Height = built.Height,
ClickThrough = true,
Centered = false,
RightAligned = false,
Padding = 2f,
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
if (label is not null)
captionText.LinesProvider = () => new[] { new UiText.Line(label, captionText.DefaultColor) };
built.AddChild(captionText);
bool mapped = RetailActionIdentityTable.TryResolve(row.InputMapId, row.ActionId, out InputAction action);
InputAction? mappedAction = mapped ? action : null;
// M1: capture this row's live Activation/Scope ONCE, from the first
// existing binding for the action (every multi-chord action in
// KeyBindings.RetailDefaults() shares one Activation/Scope pair across
// all its bindings — see class doc). Falls back to the Binding record's
// own defaults (Press/Game) only when the action starts wholly unbound.
IReadOnlyList<Binding> liveBindings = mapped
? bindings.CurrentForAction(action)
: Array.Empty<Binding>();
(ActivationType Activation, InputScope Scope) template = liveBindings.Count > 0
? (liveBindings[0].Activation, liveBindings[0].Scope)
: (ActivationType.Press, InputScope.Game);
IReadOnlyList<KeyChord> defaults = DatDefaultsToChords(row.DefaultBindings);
IReadOnlyList<KeyChord> storedUnmapped = mapped
? Array.Empty<KeyChord>()
: bindings.CurrentForUnmapped((row.InputMapId, row.ActionId));
// OP8 re-review round 2 (SHOULD-FIX): an unmapped/store-only row with
// no persisted chords displays its DAT DEFAULTS — retail shows the
// authored bindings (the Camera Alternate rows' arrow keys) and a
// blank row misreads as "unbound". Display-only: nothing here feeds
// the InputDispatcher, and the store only gains the defaults if the
// user actually edits the row (the apply closure below).
IReadOnlyList<KeyChord> initial = mapped
? liveBindings.Select(b => b.Chord).ToArray()
: storedUnmapped.Count > 0 ? storedUnmapped : defaults;
var model = new ActionKeyMapOptionRow(initial, defaults, apply: value =>
{
// Interior/padding default(KeyChord) entries (S4 — sparse-slot
// display, see ReplaceSlotValue) are never real bindings; filter
// them out at the write boundary, not at storage time.
IReadOnlyList<KeyChord> real = value.Where(c => c != default).ToArray();
if (mapped)
bindings.SetForAction(
action,
real.Select(c => new Binding(c, action, template.Activation, template.Scope)).ToArray());
else
bindings.SetForUnmapped((row.InputMapId, row.ActionId), real);
});
Page.Register(model);
var view = new RowView(row.InputMapId, row.ActionId, mappedAction, label, model, keyButtons);
_rows.Add(view);
RefreshRowButtons(view);
for (int slot = 0; slot < keyButtons.Count; slot++)
{
int capturedSlot = slot;
keyButtons[slot].TooltipText = tooltip;
keyButtons[slot].OnClick = () => BeginSlotCapture(view, capturedSlot, bindings);
keyButtons[slot].OnRightClick = () => EraseSlot(view, capturedSlot);
}
}
private static IReadOnlyList<KeyChord> DatDefaultsToChords(IReadOnlyList<RetailKeyChord> raw)
{
var result = new List<KeyChord>(raw.Count);
foreach (RetailKeyChord chord in raw)
{
Silk.NET.Input.Key? key = RetailScanCodeMap.ToSilkKey(chord.Scan, chord.Device);
if (key is null) continue; // unresolved scan code — omit rather than guess.
result.Add(new KeyChord(key.Value, RetailScanCodeMap.ToModifierMask(chord.Modifier), (byte)chord.Device));
}
return result;
}
private static void RefreshRowButtons(RowView view)
{
IReadOnlyList<KeyChord> current = view.Model.Current;
for (int i = 0; i < view.KeyButtons.Count; i++)
{
bool bound = i < current.Count && current[i] != default;
view.KeyButtons[i].Label = bound ? DescribeChord(current[i]) : null;
}
}
private static string DescribeChord(KeyChord chord)
{
string mods = chord.Modifiers == ModifierMask.None ? "" : chord.Modifiers.ToString() + "+";
return mods + chord.Key;
}
private void BeginSlotCapture(RowView view, int slot, Bindings bindings)
{
bindings.BeginCapture(captured =>
{
if (captured is not { } chord) return; // Escape — retail cancels silently.
(ConflictOutcome outcome, List<RowView> conflictRows) = FindConflicts(chord, exclude: view);
switch (outcome)
{
case ConflictOutcome.NonBindable:
// S1 / retail order: checked BEFORE any row conflict is even
// considered — retail refuses outright the instant ANY
// conflicting target is non-user-bindable. This port's
// analogue: a chord already bound to an acdream-only action
// with no DAT row at all (Ctrl+M mute, the debug F-keys, ...) —
// OpenCantOverwriteBindingDialog's ported refusal, no dialog.
bindings.DisplaySystemMessage(bindings.NonBindableRefusalText);
return;
case ConflictOutcome.Rows:
// M3: retail's OpenOverwriteBindingDialog — confirm BEFORE
// reassigning (N-way: every conflicting row is named, not just
// the first). Only on accept do the losing rows lose the slot.
string names = string.Join(", ", conflictRows.Select(r => r.Label ?? "?"));
string message =
$"'{DescribeChord(chord)}' is already bound to {names}. "
+ $"Reassign it to '{view.Label}'?";
bindings.ConfirmOverwrite(message, accepted =>
{
if (!accepted) return;
foreach (RowView conflictRow in conflictRows)
{
ReplaceSlotValue(conflictRow, RemoveChord(conflictRow.Model.Current, chord));
RefreshRowButtons(conflictRow);
}
ApplySlot(view, slot, chord);
});
return;
case ConflictOutcome.None:
ApplySlot(view, slot, chord);
return;
}
});
}
private static void ApplySlot(RowView view, int slot, KeyChord chord)
{
List<KeyChord> updated = new(view.Model.Current);
while (updated.Count <= slot) updated.Add(default);
updated[slot] = chord;
ReplaceSlotValue(view, updated);
RefreshRowButtons(view);
}
private void EraseSlot(RowView view, int slot)
{
if (slot >= view.Model.Current.Count) return;
if (view.Model.Current[slot] == default) return; // nothing bound in this display slot
var updated = new List<KeyChord>(view.Model.Current);
updated.RemoveAt(slot);
ReplaceSlotValue(view, updated);
RefreshRowButtons(view);
}
private static void ReplaceSlotValue(RowView view, IReadOnlyList<KeyChord> value)
{
// S4 (2026-08-11 review): only trim TRAILING empty slots. Retail's
// SetBinding(qc, slot) writes the SPECIFIC slot the user clicked — a row
// with no bindings whose "Mapping 3" button is set must keep the chord at
// display index 2, not collapse it onto index 0. Interior default(KeyChord)
// entries only ever come from ApplySlot's own padding, so trimming just the
// tail keeps RefreshRowButtons' positional read correct without inventing a
// nullable-chord storage type.
int lastReal = -1;
for (int i = 0; i < value.Count; i++)
if (value[i] != default) lastReal = i;
view.Model.SetCurrentValue(lastReal < 0 ? Array.Empty<KeyChord>() : value.Take(lastReal + 1).ToArray());
}
private static IReadOnlyList<KeyChord> RemoveChord(IReadOnlyList<KeyChord> from, KeyChord chord) =>
from.Where(c => c != chord).ToArray();
private enum ConflictOutcome { None, NonBindable, Rows }
/// <summary>
/// Retail's N-way, cross-input-map conflict scan (research doc §5.4:
/// <c>ICIDM::FindConflictingInputMaps</c>/<c>FindConflictingControls</c>),
/// scoped to this screen's own universe: the non-user-bindable check runs
/// FIRST (S1 — retail's own order), then EVERY OTHER row's current chord set
/// (covers BOTH mapped and unmapped rows — a chord already claimed by an
/// unmapped row is just as real a conflict as one claimed by a mapped one) is
/// collected in full, not just the first match.
/// </summary>
private (ConflictOutcome Outcome, List<RowView> Rows) FindConflicts(KeyChord chord, RowView exclude)
{
if (_bindings is not null)
{
foreach (InputAction candidate in Enum.GetValues<InputAction>())
{
if (RetailActionIdentityTable.Map.Values.Contains(candidate)) continue;
if (_bindings.CurrentForAction(candidate).Any(b => b.Chord == chord))
return (ConflictOutcome.NonBindable, new List<RowView>());
}
}
var rows = new List<RowView>();
foreach (RowView other in _rows)
{
if (ReferenceEquals(other, exclude)) continue;
// OP8 re-review round 2 R1: store-only rows (MappedAction null —
// the Camera Alternate scheme, Emote/CharacterSettings hotkeys)
// never reach the InputDispatcher, so a chord they display cannot
// actually collide with anything; counting them made the ten
// arrow-key defaults trip a false N-way confirm on any arrow
// rebind. Retail-mapped cross-context sharing (ConflictingMaps —
// the Insert/Delete/End/PageUp/PageDown combat cluster) remains
// deferred as ISSUES #373; only INERT rows are excluded here.
if (other.MappedAction is null) continue;
if (other.Model.Current.Contains(chord))
rows.Add(other);
}
return rows.Count > 0 ? (ConflictOutcome.Rows, rows) : (ConflictOutcome.None, rows);
}
private static void WireScreenButtons(
ImportedLayout layout, KeyboardConfigController controller, Bindings bindings)
{
// Load File / Save As — INERT (D4: keybinds.json only, no .keymap
// interchange). Authored, clickable, no handler — same shape as OP3's
// still-inert buttons.
_ = layout.FindElement(LoadButtonId);
_ = layout.FindElement(SaveAsButtonId);
_ = layout.FindElement(FilenameLabelId);
if (layout.FindElement(DefaultsButtonId) is UiButton defaultsButton)
defaultsButton.OnClick = () =>
{
foreach (RowView row in controller._rows)
row.Model.SetDefaultValue(row.Model.DefaultValue);
controller.Page.Defaults();
foreach (RowView row in controller._rows)
RefreshRowButtons(row);
};
if (layout.FindElement(RevertButtonId) is UiButton revertButton)
revertButton.OnClick = () =>
{
controller.Page.Reset();
foreach (RowView row in controller._rows)
RefreshRowButtons(row);
};
// OK — right-click release in retail (idMessage 0x19); ported as a plain
// left-click here, matching every other Campaign OP button (the asymmetry
// is authored-input-only — no user-visible affordance differs, since
// retail's own right-click-release on just this pair of buttons carries
// no distinguishing visual cue either).
if (layout.FindElement(OkButtonId) is UiButton okButton)
okButton.OnClick = () =>
{
controller.Page.Apply();
bindings.Save();
bindings.Toggle();
};
if (layout.FindElement(CancelButtonId) is UiButton cancelButton)
cancelButton.OnClick = () =>
{
controller.Page.Reset();
foreach (RowView row in controller._rows)
RefreshRowButtons(row);
bindings.Toggle();
};
}
}

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;
@ -513,6 +514,79 @@ public sealed class BitfieldOptionRow : IOptionRow
} }
} }
/// <summary>
/// Campaign OP slice OP8: one Configure Keyboard row's current/saved/default
/// triple — up to 3 <see cref="KeyChord"/> slots (<c>UIOption_ActionKeyMap</c>'s
/// <c>m_qclCurrent</c>/<c>m_qclSaved</c>/<c>m_qclDefaults</c>, research doc §5.4).
/// Unlike <see cref="BoolOptionRow"/>'s single scalar, <see cref="SetCurrentValue"/>
/// here replaces the WHOLE slot list at once — the controller computes the new list
/// (one slot rebound via capture, or one slot erased) and calls this with the
/// result, mirroring retail's per-slot <c>SetBinding</c>/<c>EraseBinding</c> both
/// funnelling through the same <c>UIOption::Apply(1)</c> live-write path.
/// </summary>
public sealed class ActionKeyMapOptionRow : IOptionRow
{
private readonly Action<IReadOnlyList<KeyChord>>? _apply;
private Action? _notifyPageOptionChanged;
private IReadOnlyList<KeyChord> _current;
private IReadOnlyList<KeyChord> _saved;
private IReadOnlyList<KeyChord> _default;
public ActionKeyMapOptionRow(
IReadOnlyList<KeyChord> initial,
IReadOnlyList<KeyChord> defaultValue,
Action<IReadOnlyList<KeyChord>>? apply = null)
{
_current = initial;
_saved = initial;
_default = defaultValue;
_apply = apply;
}
/// <summary>The live slot list — what the row's key buttons currently show.</summary>
public IReadOnlyList<KeyChord> Current => _current;
/// <summary>The committed baseline Revert/Cancel reverts to.</summary>
public IReadOnlyList<KeyChord> Saved => _saved;
/// <summary>The DAT master-map default slot list Reset-to-Defaults restores.</summary>
public IReadOnlyList<KeyChord> DefaultValue => _default;
public bool Changed => !_current.SequenceEqual(_saved);
/// <summary>Reset-to-Defaults reloads the DAT master maps fresh
/// (<c>gmKeyboardUI::RestoreDefaultValues</c> — research doc §5.6) before
/// restoring each row, so the default slot list itself can change between
/// presses (a fresh DAT read), not just at construction time.</summary>
public void SetDefaultValue(IReadOnlyList<KeyChord> value) => _default = value;
/// <summary>The capture/erase entry point — writes <c>m_current</c> and applies
/// it live immediately (retail's per-slot <c>SetBinding</c>/<c>EraseBinding</c>,
/// both ending in <c>Apply(1)</c>); does not touch <see cref="Saved"/>.</summary>
public void SetCurrentValue(IReadOnlyList<KeyChord> value)
{
_current = value;
_apply?.Invoke(value);
_notifyPageOptionChanged?.Invoke();
}
public void AttachPageNotify(Action notify) => _notifyPageOptionChanged = notify;
public void SaveCurrentValue() => _saved = _current;
public void RestoreSavedValue()
{
_current = _saved;
_apply?.Invoke(_current);
}
public void RestoreDefaultValue()
{
_current = _default;
_apply?.Invoke(_current);
}
}
/// <summary> /// <summary>
/// Retail <c>OptionPage</c>/<c>PlayerOptionPage</c>: a page's registered-option /// Retail <c>OptionPage</c>/<c>PlayerOptionPage</c>: a page's registered-option
/// array plus the four verbs (Apply/Reset/Defaults/visibility) with retail's /// array plus the four verbs (Apply/Reset/Defaults/visibility) with retail's

View file

@ -89,7 +89,11 @@ public sealed class OptionsPanelController : IRetainedPanelController
Action ExitGame, Action ExitGame,
Action UseMouseTurningSettings, Action UseMouseTurningSettings,
Action<string> DisplaySystemMessage, Action<string> DisplaySystemMessage,
Action? AfterApply = null) Action? AfterApply = null,
// Campaign OP slice OP8: opens the Configure Keyboard screen — retires
// OP3's INERT contract for this button (0x10000204). Null leaves the
// button inert (e.g. a test harness with no keyboard screen wired).
Action? OpenConfigureKeyboard = null)
{ {
/// <summary>Urgent Assistance's own byte-verified retail failure text.</summary> /// <summary>Urgent Assistance's own byte-verified retail failure text.</summary>
public string UrgentAssistanceMessage { get; init; } = public string UrgentAssistanceMessage { get; init; } =
@ -190,9 +194,9 @@ public sealed class OptionsPanelController : IRetainedPanelController
+ "not found in the built layout — its handler was not wired."); + "not found in the built layout — its handler was not wired.");
BindButton(layout, ExitToCharacterSelectionId, callbacks.RequestExitToCharacterSelection); BindButton(layout, ExitToCharacterSelectionId, callbacks.RequestExitToCharacterSelection);
// ConfigureKeyboardId: INERT this slice — authored, clickable, no // ConfigureKeyboardId: Campaign OP slice OP8 wires the real Configure
// handler. OP8 wires the real Configure Keyboard screen; the campaign // Keyboard screen — the OP3 INERT contract is retired.
// cannot close with this button still inert (plan §4 OP3). BindButton(layout, ConfigureKeyboardId, callbacks.OpenConfigureKeyboard);
// InGameHelpFilesId: INERT — retail's own KeyStone::OpenHelp fails // InGameHelpFilesId: INERT — retail's own KeyStone::OpenHelp fails
// without the missing plugins\ACHelpPlugin.dll (D5, register row). // without the missing plugins\ACHelpPlugin.dll (D5, register row).
BindButton(layout, UseMouseTurningSettingsId, callbacks.UseMouseTurningSettings); BindButton(layout, UseMouseTurningSettingsId, callbacks.UseMouseTurningSettings);

View file

@ -16,6 +16,7 @@ using AcDream.Core.Selection;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
using AcDream.Content; using AcDream.Content;
using AcDream.Core.Input;
using AcDream.UI.Abstractions; using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Panels.Settings;
@ -283,6 +284,19 @@ public sealed record VendorRuntimeBindings(
// own DisplaySystemMessage already uses. // own DisplaySystemMessage already uses.
Action<string>? DisplaySystemMessage = null); Action<string>? DisplaySystemMessage = null);
/// <summary>
/// Campaign OP slice OP8: the Configure Keyboard screen's live read/write seam —
/// the ONE live <see cref="InputDispatcher"/> (Bindings for reads,
/// SetBindings+BeginCapture for writes/capture) plus the portable
/// <c>keybinds.json</c> path (D4 — no <c>.keymap</c> file interchange). Null
/// <see cref="Dispatcher"/> (headless/no-window hosts, or before the graphical
/// input stack finishes constructing) degrades to "Configure Keyboard has no
/// live effect" exactly like every other null-dependency Options-panel seam.
/// </summary>
public sealed record KeyboardRuntimeBindings(
InputDispatcher? Dispatcher,
string KeyBindingsFilePath);
public sealed record RetailUiRuntimeBindings( public sealed record RetailUiRuntimeBindings(
UiHost Host, UiHost Host,
RetailUiAssets Assets, RetailUiAssets Assets,
@ -307,7 +321,8 @@ public sealed record RetailUiRuntimeBindings(
StackSplitQuantityState StackSplitQuantity, StackSplitQuantityState StackSplitQuantity,
BufferedUiRegistry? Plugins, BufferedUiRegistry? Plugins,
RetailUiPersistenceBindings? Persistence, RetailUiPersistenceBindings? Persistence,
RetailUiProbeBindings Probe); RetailUiProbeBindings Probe,
KeyboardRuntimeBindings? Keyboard = null);
/// <summary> /// <summary>
/// Composition owner for the production retained gameplay UI. GameWindow supplies /// Composition owner for the production retained gameplay UI. GameWindow supplies
@ -383,6 +398,7 @@ public sealed class RetailUiRuntime : IDisposable
MountEffects(); MountEffects();
MountIndicatorDetailPanels(); MountIndicatorDetailPanels();
MountOptionsPanel(); MountOptionsPanel();
MountKeyboardConfig();
MountIndicators(); MountIndicators();
MountJumpPowerbar(); MountJumpPowerbar();
MountDialogFactory(); MountDialogFactory();
@ -2042,7 +2058,8 @@ public sealed class RetailUiRuntime : IDisposable
UseMouseTurningSettings: ApplyMouseTurningSettingsMacro, UseMouseTurningSettings: ApplyMouseTurningSettingsMacro,
DisplaySystemMessage: _bindings.Options.DisplaySystemMessage, DisplaySystemMessage: _bindings.Options.DisplaySystemMessage,
AfterApply: () => _bindings.Options.CommandBus().Publish( AfterApply: () => _bindings.Options.CommandBus().Publish(
new SaveCharacterOptionsRuntimeCmd())); new SaveCharacterOptionsRuntimeCmd()),
OpenConfigureKeyboard: () => ToggleWindow(WindowNames.KeyboardConfig));
Layout.OptionsPanelController? controller = Layout.OptionsPanelController? controller =
Layout.OptionsPanelController.Bind(layout, callbacks); Layout.OptionsPanelController.Bind(layout, callbacks);
@ -2238,6 +2255,184 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[UI] retail Options panel from LayoutDesc importer (0x2100006E slot 0x1000018D)."); Console.WriteLine("[UI] retail Options panel from LayoutDesc importer (0x2100006E slot 0x1000018D).");
} }
/// <summary>
/// Campaign OP slice OP8: retail's Configure Keyboard screen
/// (<c>gmKeyboardUI</c>, LayoutDesc <c>0x21000009</c>) — its own separate
/// full-screen window, distinct from the four-tab Options panel's
/// <c>gmPanelUI</c> mutual-exclusion group (research doc structure lane
/// §8). Skips cleanly (no window, INERT button stays inert) when
/// <see cref="RetailUiRuntimeBindings.Keyboard"/> is null — a no-window
/// host or an App composition that hasn't wired the input dispatcher yet.
/// </summary>
private void MountKeyboardConfig()
{
KeyboardRuntimeBindings? keyboard = _bindings.Keyboard;
if (keyboard is null || keyboard.Dispatcher is null)
{
Console.WriteLine(
"[UI] keyboard config: no InputDispatcher wired — Configure Keyboard "
+ "screen will not open (button stays inert).");
return;
}
InputDispatcher dispatcher = keyboard.Dispatcher;
ElementInfo? info;
ImportedLayout? layout;
RetailActionMapSnapshot? snapshot;
DatStringResolver strings;
lock (_bindings.Assets.DatLock)
{
info = LayoutImporter.ImportInfos(_bindings.Assets.Dats, Layout.KeyboardConfigController.LayoutId);
layout = info is null
? null
: LayoutImporter.Build(
info,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
snapshot = RetailActionMapReader.Read(_bindings.Assets.Dats);
strings = new DatStringResolver(_bindings.Assets.Dats);
}
if (layout is null || snapshot is null)
{
Console.WriteLine(
"[UI] keyboard config: LayoutDesc 0x21000009 or the DAT ActionMap "
+ "singleton (0x26000000) not found — Configure Keyboard will not open.");
return;
}
string unmappedPath = UnmappedKeyBindingsPath(keyboard.KeyBindingsFilePath);
var unmapped = RetailUnmappedKeyBindings.LoadOrEmpty(unmappedPath);
// ID_KeyMapCantOverwriteReadOnlyKeymap_Label — table 0x23000004, byte-
// verified 2026-08-11 (live probe): "Could not overwrite ". Falls back
// to silence (no invented English) if the DAT string is ever missing.
string? refusalText = strings.Resolve(
0x23000004u, DatStringResolver.ComputeHash("ID_KeyMapCantOverwriteReadOnlyKeymap_Label"));
Layout.KeyboardConfigController? controller = Layout.KeyboardConfigController.Bind(
layout,
snapshot,
templateResolver: (templateLayoutId, templateElementId) =>
{
lock (_bindings.Assets.DatLock)
{
ElementInfo? templateInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats, templateLayoutId, templateElementId);
return templateInfo is null
? null
: LayoutImporter.Build(
templateInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve).Root;
}
},
resolveString: (tableId, stringId) => strings.Resolve(tableId, stringId),
new Layout.KeyboardConfigController.Bindings(
// M1 (2026-08-11 review): read/write the FULL live Binding
// (chord + activation + scope), not a bare chord — SetForAction
// used to reconstruct every Binding with the constructor's bare
// defaults (Press/Game), collapsing walk-mode's Hold and every
// combat-scoped binding's scope the instant a row wrote back.
CurrentForAction: action => dispatcher.Bindings.ForAction(action).ToArray(),
SetForAction: (action, newBindings) =>
{
KeyBindings updated = CloneWithout(dispatcher.Bindings, action);
foreach (Binding b in newBindings)
updated.Add(b);
dispatcher.SetBindings(updated);
},
CurrentForUnmapped: key => unmapped.Get(key.InputMapId, key.ActionId),
SetForUnmapped: (key, chords) => unmapped.Set(key.InputMapId, key.ActionId, chords),
BeginCapture: onResult => dispatcher.BeginCapture(
chord => onResult(chord == default ? null : chord)),
Save: () =>
{
// S3 (2026-08-11 review): match the existing keybinds.json
// writer's own discipline (RuntimeKeyBindingTarget.Apply) —
// an IO failure is reported, not thrown out of UiButton.OnClick
// into the input/render loop, and does not roll back the
// already-accepted live binding.
try
{
dispatcher.Bindings.SaveToFile(keyboard.KeyBindingsFilePath);
unmapped.SaveToFile(unmappedPath);
}
catch (Exception failure)
{
Console.WriteLine($"keyboard config: save failed: {failure.Message}");
}
},
Toggle: () => ToggleWindow(WindowNames.KeyboardConfig),
DisplaySystemMessage: text =>
{
if (!string.IsNullOrEmpty(text)) _bindings.Options.DisplaySystemMessage(text);
},
NonBindableRefusalText: refusalText ?? string.Empty,
// M3 (2026-08-11 review): retail's OpenOverwriteBindingDialog —
// confirm through the SAME RetailDialogFactory/MakeConfirmation
// seam GameplayConfirmationController already uses, before
// reassigning. DialogFactory is mounted AFTER MountKeyboardConfig
// in Initialize()'s order, so this reads the property lazily
// (Initialize() has always finished by the time a user can
// actually open this screen and trigger a capture).
ConfirmOverwrite: (message, onResult) =>
{
if (DialogFactory is null) { onResult(false); return; }
DialogFactory.MakeConfirmation(
message,
data => onResult(data.GetBoolean(RetailDialogProperty.ConfirmationResult)));
}));
if (controller is null)
{
Console.WriteLine("[UI] keyboard config: required window root did not build.");
return;
}
KeyboardConfigController = controller;
UiElement root = layout.Root;
RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.KeyboardConfig,
Chrome = RetailWindowChrome.Imported,
Left = Math.Max(0f, (Host.Root.Width - root.Width) * 0.5f),
Top = Math.Max(0f, (Host.Root.Height - root.Height) * 0.5f),
Visible = false,
DatConstraintSource = info,
ContentClickThrough = false,
});
Console.WriteLine("[UI] retail Configure Keyboard screen from gmKeyboardUI LayoutDesc 0x21000009.");
}
private static string UnmappedKeyBindingsPath(string keyBindingsFilePath)
{
string? dir = System.IO.Path.GetDirectoryName(keyBindingsFilePath);
string name = System.IO.Path.GetFileNameWithoutExtension(keyBindingsFilePath);
string ext = System.IO.Path.GetExtension(keyBindingsFilePath);
string sibling = $"{name}-unmapped{ext}";
return string.IsNullOrEmpty(dir) ? sibling : System.IO.Path.Combine(dir, sibling);
}
private static KeyBindings CloneWithout(KeyBindings source, InputAction action)
{
var result = new KeyBindings();
foreach (Binding b in source.All)
if (b.Action != action) result.Add(b);
return result;
}
/// <summary>The mounted Configure Keyboard screen's controller — null until
/// <see cref="MountKeyboardConfig"/> runs (or if it degraded — see that
/// method's null-dependency guards).</summary>
public Layout.KeyboardConfigController? KeyboardConfigController { get; private set; }
private void MountJumpPowerbar() private void MountJumpPowerbar()
{ {
ElementInfo? info; ElementInfo? info;

View file

@ -49,6 +49,15 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary> /// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
public Action? OnClick { get; set; } public Action? OnClick { get; set; }
/// <summary>
/// Optional right-click handler (Campaign OP slice OP8's Configure Keyboard
/// screen: right-click a bound key button to erase that one binding —
/// <c>UIOption_ActionKeyMap::EraseBinding @0x00487780</c>). Null by default,
/// so every pre-existing <see cref="UiButton"/> is unaffected — this only adds
/// a new optional event, it does not change any existing click/drag behavior.
/// </summary>
public Action? OnRightClick { get; set; }
/// <summary> /// <summary>
/// Optional pointer transition handlers. These expose retail's distinct /// Optional pointer transition handlers. These expose retail's distinct
/// pressed/released element messages for controls such as the combat-height /// pressed/released element messages for controls such as the combat-height
@ -518,6 +527,20 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
OnClick?.Invoke(); OnClick?.Invoke();
OnClickAt?.Invoke(e.Data1, e.Data2); OnClickAt?.Invoke(e.Data1, e.Data2);
return OnClick is not null || OnClickAt is not null; return OnClick is not null || OnClickAt is not null;
case UiEventType.RightClick:
// S6 (2026-08-11 review): unlike Click (whose swallow-when-
// disabled is pre-existing, harmless-by-construction behavior
// every button already had), RightClick was UNHANDLED before
// this class grew OnRightClick — it fell through to `default:
// return false` and bubbled to the parent. Preserve that for
// every button with no handler, disabled or not, so this
// addition is genuinely a no-op for every pre-existing button
// (matching this property's own doc comment) and only changes
// behavior for the ones that opt in.
if (OnRightClick is null) return false;
if (!Enabled) return true;
OnRightClick.Invoke();
return true;
case UiEventType.DragEnter: case UiEventType.DragEnter:
_itemDragAcceptance = e.Payload is ItemDragPayload payload _itemDragAcceptance = e.Payload is ItemDragPayload payload
? OnItemDragOver?.Invoke(payload) ?? ItemDragAcceptance.None ? OnItemDragOver?.Invoke(payload) ?? ItemDragAcceptance.None

View file

@ -28,4 +28,5 @@ public static class WindowNames
public const string Examination = "examination"; public const string Examination = "examination";
public const string Vendor = "vendor"; public const string Vendor = "vendor";
public const string Options = "options"; public const string Options = "options";
public const string KeyboardConfig = "keyboard-config";
} }

View file

@ -0,0 +1,239 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.Core.Content;
using DatReaderWriter.DBObjs;
namespace AcDream.Core.Input;
/// <summary>
/// Campaign OP slice OP8: the DAT-resident retail keyboard-configuration data
/// (<c>gmKeyboardUI</c>'s source of truth — <c>docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md</c>
/// §5.6/§5.8). Retail reads two independent DAT object families:
/// <list type="bullet">
/// <item><description>The <c>ActionMap</c> singleton (<c>ActionMap::GetDBOType @0x00685C00</c>
/// = 0x27 — Turbine's internal type tag, NOT the DID's high byte; empirically the object
/// lives at DID <see cref="RetailActionMapIds.ActionMapId"/>) — one row per
/// (InputMap id, Action id) pair, carrying the action-class bucket
/// (<see cref="RetailActionClass"/>), the label/tooltip string hashes, and whether the
/// action is user-bindable at all (research doc §5.2's <c>ActionMap::IsUserBindable</c> /
/// <c>GetActionClass</c> / <c>GetDescripValues</c>).</description></item>
/// <item><description>Two <c>MasterInputMap</c> DAT objects (<see cref="RetailActionMapIds.GameplayMasterMapId"/>
/// / <see cref="RetailActionMapIds.SystemMasterMapId"/>, already extracted by
/// <c>tools/dump-keymap</c>) — the DEFAULT key bindings <c>ICIDM::AddKeyMap</c> merges into
/// the live keymap on <c>RestoreDefaultValues</c> (research doc §5.6).</description></item>
/// </list>
///
/// <para>
/// <b>Empirical resolution of research doc §7.4 unknowns 3/4/5</b> (live-DAT probe against
/// the installed <c>client_portal.dat</c>/<c>client_local_English.dat</c>, 2026-08-11 —
/// no code changed by that probe, only this production port):
/// </para>
/// <list type="number">
/// <item><description><b>Unknown 3 — the <c>ActionClass</c> enum values.</b> Exactly six
/// non-zero values appear across the ActionMap's 306 user-bindable rows: 1, 2, 3, 4, 5, 7
/// (never 6 — genuinely absent from the shipped DAT, not a probe artifact). Cross-matching
/// each bucket's resolved English labels against the six list-box categories
/// (research doc §5.1) is unambiguous: 1 = Movement ("Move Forward"/"Jump"/...,
/// exactly the 14 rows under InputMap 0x4), 2 = Camera ("Zoom Camera In"/"Rotate
/// Camera Left"/..., InputMaps 0x5/0x6), 3 = UI (item selection + UI toggle-panel +
/// chat + quickslot commands, InputMaps 0x10000007/9/A/C/D), 4 = Combat ("Toggle Combat
/// Mode"/attack-power/aim, InputMaps 0x10000002-5), 5 = Emote ("AFK State"/"Akimbo"/...,
/// InputMap 0x10000006), 7 = CharacterSettings (the "Automatically Repeat Attacks"/
/// "Ignore Allegiance Requests"/... preference-toggle hotkeys, InputMap
/// 0x10000008 — the SAME preference domain OP1's <c>CharacterOptionTable</c> and OP4's
/// Character-tab checkboxes already model, just reachable by a bindable hotkey too).</description></item>
/// <item><description><b>Unknown 4 — which <c>DBObj::GetByEnum</c> enum is which
/// <c>MasterInputMap</c> DID.</b> Still not traced (would need decompiling
/// <c>DBObj::GetByEnum</c>'s dispatch table — out of scope; the two DIDs are already
/// distinguished by content, not by enum). <b>Proven inconsequential</b>: the two master
/// maps' <c>InputMap</c> dictionaries share exactly one context id (0x5) and their action-id
/// sets under that shared context are completely disjoint (gmDefaultMap's ctx 5 carries
/// only action 0x3E; DefaultMap's ctx 5 carries actions 0x33-0x3D — zero overlap). A
/// union-by-(context,action) merge is therefore ORDER-INDEPENDENT and reproduces retail's
/// <c>CMasterInputMap::Merge</c> result regardless of which physical DID retail's
/// <c>AddKeyMap(0x10000001)</c> vs <c>AddKeyMap(1)</c> call resolves to.
/// <see cref="RetailActionMapReader.Read"/> merges both unconditionally.</description></item>
/// <item><description><b>Unknown 5 — do the six unnamed InputMaps
/// (TargetedUsage/SystemKeys/MouseCommands/ScrollableControls/EditControls/
/// CopyAndPasteControls) filter by <c>IsUserBindable</c> or render headerless?</b>
/// Resolved: filtered. Every action under InputMap ids 0x1, 0x2, 0x3, 0x7, 0x8, 0xA, 0x10
/// (the unnamed ones, by elimination against research doc §5.3's 19 named ids) carries
/// <c>ActionClass == 0</c> (non-bindable) with zero exceptions — so
/// <c>AddItemFromTemplateList(0)</c> (the header row) never fires for them at all; they
/// render NOTHING, not an unlabeled row group. The same is true of three NAMED contexts
/// that happen to be 100% non-bindable in the shipped DAT: DialogBoxes (0x9),
/// DebugConsole/ProfilerUI/UIDebugger/DebugCommands (0xB-0xE) — a header string exists for
/// them but is never reached because their action buckets are empty in every
/// <see cref="RetailActionClass"/> class.</description></item>
/// </list>
/// </summary>
public enum RetailActionClass : uint
{
/// <summary>Not user-bindable — <c>ActionMap::IsUserBindable</c> returns false.
/// Never appears as a row's <see cref="RetailActionMapRow.ActionClass"/> in
/// <see cref="RetailActionMapReader.Read"/>'s output; filtered at read time.</summary>
None = 0,
Movement = 1,
Camera = 2,
Ui = 3,
Combat = 4,
Emote = 5,
// 6 is genuinely absent from the shipped 2013 DAT — not a gap in this table.
CharacterSettings = 7,
}
/// <summary>DAT identifiers for the ActionMap singleton and the two master
/// default-keymap objects. Byte-verified against the installed
/// <c>client_portal.dat</c> (2026-08-11 probe, see class doc on
/// <see cref="RetailActionClass"/>).</summary>
public static class RetailActionMapIds
{
/// <summary>The one <c>ActionMap</c> DAT object — <c>ICIDM::s_cidm-&gt;GetActionMap()</c>'s
/// backing store. Confirmed the ONLY DID of DBObjType ActionMap in the installed dats.</summary>
public const uint ActionMapId = 0x26000000u;
/// <summary>"gmDefaultMap" — 14 InputMaps (Movement, Camera, and every
/// 0x10000002-0x1000000D gameplay context). <c>tools/dump-keymap</c>'s first DID.</summary>
public const uint GameplayMasterMapId = 0x14000000u;
/// <summary>"DefaultMap" — 7 InputMaps (the system/UI/dialog contexts).
/// <c>tools/dump-keymap</c>'s second DID.</summary>
public const uint SystemMasterMapId = 0x14000002u;
}
/// <summary>
/// One raw default-key binding as stored in a <c>MasterInputMap</c>'s
/// <c>QualifiedControl</c> — retail's own packed <c>(scan &lt;&lt; 16 | device)</c> key plus
/// the modifier bitmask and activation byte. Deliberately NOT
/// <c>AcDream.UI.Abstractions.Input.KeyChord</c>: this is the DAT's own encoding
/// (DIK scan codes), translated to a live <c>KeyChord</c> by the App-layer controller
/// (Core has no Silk.NET dependency).
/// </summary>
public readonly record struct RetailKeyChord(uint Scan, uint Device, uint Modifier, uint Activation);
/// <summary>One ActionMap row: a single (InputMap id, Action id) pair that retail's
/// Configure Keyboard screen renders as one <c>UIOption_ActionKeyMap</c> row.</summary>
public sealed record RetailActionMapRow(
uint InputMapId,
uint ActionId,
RetailActionClass ActionClass,
uint LabelHash,
uint TooltipHash,
IReadOnlyList<RetailKeyChord> DefaultBindings);
/// <summary>The complete read result: every user-bindable ActionMap row, plus the raw
/// row count read (for conformance pinning against the installed dats).</summary>
public sealed record RetailActionMapSnapshot(IReadOnlyList<RetailActionMapRow> Rows);
/// <summary>
/// Retail's 19 named <c>InputMapID -&gt; ID_InputMap_*</c> string-table keys
/// (<c>gmKeyboardUI::GetStringInfoFromInputMapID @0x004DA980</c>, research doc §5.3,
/// byte-verified at <c>.rdata 0x007BE45C..0x007BE648</c>). String table
/// <c>0x23000005</c> is the SAME table <see cref="RetailActionMapRow"/>'s label/tooltip
/// hashes resolve against — <c>ActionMap.StringTableId</c> confirms this empirically.
/// </summary>
public static class RetailInputMapHeaders
{
public const uint StringTableId = 0x23000005u;
public static readonly IReadOnlyDictionary<uint, string> NameByInputMapId =
new Dictionary<uint, string>
{
[0x00000004u] = "ID_InputMap_MovementCommands",
[0x00000005u] = "ID_InputMap_CameraControls",
[0x00000006u] = "ID_InputMap_CameraAlternateControls",
[0x00000009u] = "ID_InputMap_DialogBoxes",
[0x0000000Bu] = "ID_InputMap_DebugConsole",
[0x0000000Cu] = "ID_InputMap_ProfilerUI",
[0x0000000Du] = "ID_InputMap_UIDebugger",
[0x0000000Eu] = "ID_InputMap_DebugCommands",
[0x10000002u] = "ID_InputMap_Combat",
[0x10000003u] = "ID_InputMap_MeleeCombat",
[0x10000004u] = "ID_InputMap_MissileCombat",
[0x10000005u] = "ID_InputMap_MagicCombat",
[0x10000006u] = "ID_InputMap_Emotes",
[0x10000007u] = "ID_InputMap_ItemSelectionCommands",
[0x10000008u] = "ID_InputMap_CharacterOptionCommands",
[0x10000009u] = "ID_InputMap_UICommands",
[0x1000000Au] = "ID_InputMap_ChatCommands",
[0x1000000Cu] = "ID_InputMap_QuickslotCommands",
[0x1000000Du] = "ID_InputMap_ToggleChatEntry",
};
}
/// <summary>
/// Reads the retail ActionMap + both MasterInputMap DAT objects into
/// <see cref="RetailActionMapSnapshot"/>. Pure data extraction — no string
/// resolution (Core has no Silk.NET/font dependency for that; the App-layer
/// controller resolves <see cref="RetailActionMapRow.LabelHash"/>/<see cref="RetailActionMapRow.TooltipHash"/>
/// against <see cref="RetailInputMapHeaders.StringTableId"/> the same way every other
/// Campaign OP page controller resolves its own strings).
/// </summary>
public static class RetailActionMapReader
{
/// <summary>
/// Reads and merges the ActionMap singleton with both MasterInputMap defaults.
/// Returns null if the ActionMap object is missing from the dats (a corrupt/non-EoR
/// install — the caller degrades to "Configure Keyboard has no rows" like every other
/// controller's "required root not found" path).
/// </summary>
public static RetailActionMapSnapshot? Read(IDatObjectSource dats)
{
ArgumentNullException.ThrowIfNull(dats);
ActionMap? actionMap = dats.Get<ActionMap>(RetailActionMapIds.ActionMapId);
if (actionMap is null)
return null;
// Both master maps are optional individually — a missing one just means
// "no DAT defaults for that half of the merged keymap" rather than a hard
// failure; ActionMap alone still lets the screen render every row's
// label/tooltip/class with an empty default-binding list.
MasterInputMap? gameplayMap = dats.Get<MasterInputMap>(RetailActionMapIds.GameplayMasterMapId);
MasterInputMap? systemMap = dats.Get<MasterInputMap>(RetailActionMapIds.SystemMasterMapId);
var rows = new List<RetailActionMapRow>();
foreach (var inputMapEntry in actionMap.InputMaps)
{
uint inputMapId = inputMapEntry.Key;
foreach (var actionEntry in inputMapEntry.Value)
{
uint actionId = actionEntry.Key;
var value = actionEntry.Value;
var userBinding = value.UserBinding;
uint classId = userBinding?.ActionClass ?? 0u;
if (classId == 0u)
continue; // ActionMap::IsUserBindable false — retail never rows this.
var defaults = new List<RetailKeyChord>();
CollectDefaults(gameplayMap, inputMapId, actionId, defaults);
CollectDefaults(systemMap, inputMapId, actionId, defaults);
rows.Add(new RetailActionMapRow(
inputMapId,
actionId,
(RetailActionClass)classId,
userBinding!.ActionName,
userBinding.ActionDescription,
defaults));
}
}
return new RetailActionMapSnapshot(rows);
}
private static void CollectDefaults(
MasterInputMap? map, uint inputMapId, uint actionId, List<RetailKeyChord> into)
{
if (map is null) return;
if (!map.InputMaps.TryGetValue(inputMapId, out var cInputMap)) return;
foreach (var control in cInputMap.Mappings)
{
if (control.Unknown != actionId) continue;
uint scan = (control.Key.Key >> 16) & 0xFFFFu;
uint device = control.Key.Key & 0xFFFFu;
into.Add(new RetailKeyChord(scan, device, control.Key.Modifier, control.Activation));
}
}
}

View file

@ -285,6 +285,14 @@ public sealed class InputDispatcher : IDisposable
_bindings = bindings; _bindings = bindings;
} }
/// <summary>
/// The current live bindings table (Campaign OP slice OP8: the Configure
/// Keyboard screen's single source of truth for "what is currently bound" —
/// reads through this rather than a caller-held snapshot, which would go
/// stale the moment ANY OTHER rebind path calls <see cref="SetBindings"/>).
/// </summary>
public KeyBindings Bindings => _bindings;
/// <summary> /// <summary>
/// Per-frame "is this action's chord currently held" query. Walks every /// Per-frame "is this action's chord currently held" query. Walks every
/// binding for the given action; returns true if any of them has its /// binding for the given action; returns true if any of them has its

View file

@ -0,0 +1,254 @@
using System.Collections.Generic;
namespace AcDream.UI.Abstractions.Input;
/// <summary>
/// Campaign OP slice OP8: maps a retail DAT ActionMap row — the
/// <c>(InputMap id, Action id)</c> pair <c>AcDream.Core.Input.RetailActionMapRow</c>
/// carries — to acdream's own <see cref="InputAction"/>, when one exists.
///
/// <para>
/// <b>Why this table exists.</b> The DAT ActionMap singleton (empirically dumped
/// 2026-08-11, see <c>AcDream.Core.Input.RetailActionMap</c>'s class doc) carries 306
/// user-bindable rows. <see cref="InputAction"/> — the enum every OTHER acdream input
/// path (live dispatch, <c>KeyBindings</c>, <c>InputDispatcher</c>) already keys on —
/// has roughly half that many members, because it was authored around "what acdream
/// currently implements" (K.1a/K.1c), not "every action the 2013 client's keymap
/// screen can show." Two categories are the biggest gaps: 82 of the DAT's 87 Emote
/// rows have no acdream animation dispatch yet (only 5 are wired: Cry/Laugh/Cheer/
/// Wave/PointState — exactly the 5 that happen to carry retail default keys), and all
/// 48 CharacterSettings rows are hotkeys for the SAME <c>PlayerOption</c>/
/// <c>CharacterOptions</c> preference bits OP1's <c>CharacterOptionTable</c> and OP4's
/// Character-tab checkboxes already model — wiring "press this key, flip that same
/// server-synced bit" is a real feature (a hotkey-to-option-toggle dispatcher) that
/// does not exist yet anywhere in acdream and is out of scope for this slice (see the
/// OP8 register row).
/// </para>
///
/// <para>
/// <b>Every mapping below was verified two ways</b> before being added: (1) the DAT's
/// resolved English label/tooltip unambiguously names the SAME action as the
/// <see cref="InputAction"/> member's own XML doc, AND (2) where the retail default
/// key(s) for that DAT row are non-empty, they match
/// <see cref="KeyBindings.RetailDefaults"/>'s existing chord(s) for the candidate
/// <see cref="InputAction"/> (byte-verified 2026-08-11 against the installed dats —
/// see <c>RetailActionMapReaderTests.LiveDatTests</c> and this slice's
/// <c>RetailActionIdentityRoundTripTests</c>). A DAT row that could not be verified
/// BOTH ways is left OUT of this table on purpose — it renders on the Configure
/// Keyboard screen as a real, bindable, persisted row (see
/// <c>KeyboardConfigController</c>), it just does not yet reach any live acdream
/// consumer. Silently guessing a wrong mapping would misroute a user's rebind to the
/// WRONG gameplay action, which is worse than an honest "not wired yet."
/// </para>
///
/// <para>
/// <b>Known gaps deliberately left unmapped</b> (register row, OP8):
/// Spell Slot 10/11/12 (ctx <c>0x10000005</c>, DAT actions <c>0x6E/0x6F/0x70</c> —
/// <see cref="InputAction"/> only defines <c>UseSpellSlot_1..9</c>); Quickslot
/// 10/11/12/13 (ctx <c>0x1000000C</c>, DAT actions <c>0x1000004B/4C/4D/10000132</c> —
/// <see cref="InputAction"/>'s <c>UseQuickSlot_*</c> family jumps from 9 straight to
/// 14, a pre-existing enum gap this slice did not introduce and does not fix); every
/// CharacterSettings row (ctx <c>0x10000008</c>, all 48); 82 of 87 Emote rows (ctx
/// <c>0x10000006</c>); all 10 CameraAlternateControls rows (ctx <c>0x6</c> — the M2
/// de-alias carve-out, see the mapping table's own comment); and roughly half of the
/// UI-class rows (ctx <c>0x10000007</c>/<c>0x10000009</c> — panels acdream has no
/// toggle for, e.g. Vitae, Link Status, House, Map, Character Info, the
/// positive/negative Magic panels).
/// </para>
/// </summary>
public static class RetailActionIdentityTable
{
/// <summary>(InputMap id, Action id) → the acdream <see cref="InputAction"/> that
/// owns live dispatch for it. A DAT row whose key is absent has no acdream
/// consumer yet.</summary>
public static readonly IReadOnlyDictionary<(uint InputMapId, uint ActionId), InputAction> Map =
BuildTable();
public static bool TryResolve(uint inputMapId, uint actionId, out InputAction action) =>
Map.TryGetValue((inputMapId, actionId), out action);
private static Dictionary<(uint, uint), InputAction> BuildTable()
{
var t = new Dictionary<(uint, uint), InputAction>();
void M(uint inputMapId, uint actionId, InputAction action) => t[(inputMapId, actionId)] = action;
// ── MovementCommands (ctx 0x4) — 14/14, complete. ──────────────
M(0x4, 0x29, InputAction.MovementForward);
M(0x4, 0x2A, InputAction.MovementBackup);
M(0x4, 0x2B, InputAction.MovementStop);
M(0x4, 0x2C, InputAction.MovementStrafeRight);
M(0x4, 0x2D, InputAction.MovementStrafeLeft);
M(0x4, 0x2E, InputAction.MovementTurnRight);
M(0x4, 0x2F, InputAction.MovementTurnLeft);
M(0x4, 0x30, InputAction.MovementRunLock);
M(0x4, 0x31, InputAction.MovementJump);
M(0x4, 0x32, InputAction.MovementWalkMode);
M(0x4, 0x10000094, InputAction.Ready);
M(0x4, 0x10000095, InputAction.Crouch);
M(0x4, 0x10000096, InputAction.Sitting);
M(0x4, 0x10000097, InputAction.Sleeping);
// ── CameraControls (ctx 0x5) — 12/12. ──────────────────────────
// M2 REWORK (2026-08-11 review): CameraControls (ctx 0x5, the
// Numpad-default scheme RetailDefaults() actually carries) and
// CameraAlternateControls (ctx 0x6, the arrow-key alternate scheme
// RetailDefaults() never had — see
// RetailActionIdentityRoundTripTests' now-retired camera allowlist
// entries) were both previously mapped to the SAME InputAction.
// KeyBindings/Binding has no "which scheme" tag, and SetForAction is
// whole-action replacement, so the two rows aliased one live target:
// both showed identical (stale) chords, rebinding one silently wiped
// the other, and a row could conflict with its own twin. Building
// real per-scheme dual-binding storage (or ten new InputAction
// members plus the camera-dispatch code to consume them) is a real
// feature, not a one-line fix, and out of scope for this rework. Only
// ctx 0x5 — the scheme that already has a live, verified
// RetailDefaults() presence — maps here; ctx 0x6 falls through to the
// generic unmapped/store-only path below (AP-203), fully renderable,
// bindable and persisted, honestly carrying no live effect, exactly
// like every other unmapped row.
M(0x5, 0x33, InputAction.CameraMoveToward);
M(0x5, 0x34, InputAction.CameraMoveAway);
M(0x5, 0x35, InputAction.CameraRotateLeft);
M(0x5, 0x36, InputAction.CameraRotateRight);
M(0x5, 0x37, InputAction.CameraRotateUp);
M(0x5, 0x38, InputAction.CameraRotateDown);
M(0x5, 0x39, InputAction.CameraViewDefault);
M(0x5, 0x3A, InputAction.CameraViewFirstPerson);
M(0x5, 0x3B, InputAction.CameraViewLookDown);
M(0x5, 0x3C, InputAction.CameraViewMapMode);
M(0x5, 0x3D, InputAction.CameraInstantMouseLook);
M(0x5, 0x3E, InputAction.CameraActivateAlternateMode);
// ── Combat (ctx 0x10000002) — 1/1. ─────────────────────────────
M(0x10000002, 0x1000005A, InputAction.CombatToggleCombat);
// ── MeleeCombat (ctx 0x10000003) — 5/5. ────────────────────────
M(0x10000003, 0x1000005B, InputAction.CombatDecreaseAttackPower);
M(0x10000003, 0x1000005C, InputAction.CombatIncreaseAttackPower);
M(0x10000003, 0x1000005D, InputAction.CombatLowAttack);
M(0x10000003, 0x1000005E, InputAction.CombatMediumAttack);
M(0x10000003, 0x1000005F, InputAction.CombatHighAttack);
// ── MissileCombat (ctx 0x10000004) — 5/5. ──────────────────────
M(0x10000004, 0x100000EF, InputAction.CombatDecreaseMissileAccuracy);
M(0x10000004, 0x100000F0, InputAction.CombatIncreaseMissileAccuracy);
M(0x10000004, 0x100000F1, InputAction.CombatAimLow);
M(0x10000004, 0x100000F2, InputAction.CombatAimMedium);
M(0x10000004, 0x100000F3, InputAction.CombatAimHigh);
// ── MagicCombat (ctx 0x10000005) — 18/21 (Spell Slot 10/11/12 have
// no InputAction — register row). ────────────────────────────
M(0x10000005, 0x10000060, InputAction.CombatCastCurrentSpell);
M(0x10000005, 0x10000061, InputAction.CombatPrevSpell);
M(0x10000005, 0x10000062, InputAction.CombatNextSpell);
M(0x10000005, 0x10000063, InputAction.CombatPrevSpellTab);
M(0x10000005, 0x10000064, InputAction.CombatNextSpellTab);
M(0x10000005, 0x10000065, InputAction.UseSpellSlot_1);
M(0x10000005, 0x10000066, InputAction.UseSpellSlot_2);
M(0x10000005, 0x10000067, InputAction.UseSpellSlot_3);
M(0x10000005, 0x10000068, InputAction.UseSpellSlot_4);
M(0x10000005, 0x10000069, InputAction.UseSpellSlot_5);
M(0x10000005, 0x1000006A, InputAction.UseSpellSlot_6);
M(0x10000005, 0x1000006B, InputAction.UseSpellSlot_7);
M(0x10000005, 0x1000006C, InputAction.UseSpellSlot_8);
M(0x10000005, 0x1000006D, InputAction.UseSpellSlot_9);
// 0x6E/0x6F/0x70 (Spell Slot 10/11/12) — no InputAction. Unmapped.
M(0x10000005, 0x10000102, InputAction.CombatFirstSpell);
M(0x10000005, 0x10000103, InputAction.CombatLastSpell);
M(0x10000005, 0x10000104, InputAction.CombatFirstSpellTab);
M(0x10000005, 0x10000105, InputAction.CombatLastSpellTab);
// ── Emotes (ctx 0x10000006) — 5/87 (the only 5 acdream dispatches
// an animation for; also the only 5 with retail default keys). ──
M(0x10000006, 0x100000A2, InputAction.Cheer);
M(0x10000006, 0x100000A7, InputAction.Cry);
M(0x10000006, 0x100000B2, InputAction.Laugh);
M(0x10000006, 0x100000BE, InputAction.PointState);
M(0x10000006, 0x100000E5, InputAction.Wave);
// ── ItemSelectionCommands (ctx 0x10000007) — 17/26. ────────────
M(0x10000007, 0x1000002D, InputAction.SelectionSplitStack);
M(0x10000007, 0x1000002E, InputAction.SelectionPreviousSelection);
M(0x10000007, 0x1000002F, InputAction.SelectionClosestCompassItem);
M(0x10000007, 0x10000030, InputAction.SelectionPreviousCompassItem);
M(0x10000007, 0x10000031, InputAction.SelectionNextCompassItem);
M(0x10000007, 0x10000032, InputAction.SelectionClosestItem);
M(0x10000007, 0x10000033, InputAction.SelectionPreviousItem);
M(0x10000007, 0x10000034, InputAction.SelectionNextItem);
M(0x10000007, 0x10000035, InputAction.SelectionClosestMonster);
M(0x10000007, 0x10000036, InputAction.SelectionPreviousMonster);
M(0x10000007, 0x10000037, InputAction.SelectionNextMonster);
M(0x10000007, 0x10000038, InputAction.SelectionLastAttacker);
M(0x10000007, 0x10000039, InputAction.SelectionClosestPlayer);
M(0x10000007, 0x1000003A, InputAction.SelectionPreviousPlayer);
M(0x10000007, 0x1000003B, InputAction.SelectionNextPlayer);
M(0x10000007, 0x1000003C, InputAction.SelectionPreviousFellow);
M(0x10000007, 0x1000003D, InputAction.SelectionNextFellow);
// ── UICommands (ctx 0x10000009) — 22/42. ───────────────────────
M(0x10000009, 0x55, InputAction.CaptureScreenshot);
M(0x10000009, 0x7B, InputAction.ToggleHelp);
M(0x10000009, 0x7C, InputAction.TogglePluginManager);
M(0x10000009, 0x1000000E, InputAction.ToggleAllegiancePanel);
M(0x10000009, 0x1000000F, InputAction.ToggleFellowshipPanel);
M(0x10000009, 0x10000011, InputAction.ToggleSpellbookPanel);
M(0x10000009, 0x10000012, InputAction.ToggleSpellComponentsPanel);
M(0x10000009, 0x10000014, InputAction.ToggleAttributesPanel);
M(0x10000009, 0x10000015, InputAction.ToggleSkillsPanel);
M(0x10000009, 0x10000016, InputAction.ToggleWorldPanel);
M(0x10000009, 0x1000001A, InputAction.ToggleOptionsPanel);
M(0x10000009, 0x10000019, InputAction.ToggleInventoryPanel);
M(0x10000009, 0x10000114, InputAction.ToggleFloatingChatWindow1);
M(0x10000009, 0x10000115, InputAction.ToggleFloatingChatWindow2);
M(0x10000009, 0x10000116, InputAction.ToggleFloatingChatWindow3);
M(0x10000009, 0x10000117, InputAction.ToggleFloatingChatWindow4);
M(0x10000009, 0x10000025, InputAction.UseSelected);
M(0x10000009, 0x10000026, InputAction.LOGOUT);
M(0x10000009, 0x1000002B, InputAction.SelectionExamine);
// 0x1000001F ("Show/Hide Keyboard Configuration") deliberately left
// unmapped: it is the retail action that opens THIS screen
// (research doc §4.3/lane A §7 — wired directly by
// KeyboardConfigController's mount, not through InputAction).
// ── ChatCommands (ctx 0x1000000A) — 1/6. ───────────────────────
M(0x1000000A, 0x10000023, InputAction.EnterChatMode);
// ── ToggleChatEntry (ctx 0x1000000D) — 1/1. ────────────────────
M(0x1000000D, 0x10000024, InputAction.ToggleChatEntry);
// ── QuickslotCommands (ctx 0x1000000C) — 24/28 (Quickslot
// 10/11/12/13 have no InputAction — pre-existing enum gap). ──
M(0x1000000C, 0x10000042, InputAction.UseQuickSlot_1);
M(0x1000000C, 0x10000043, InputAction.UseQuickSlot_2);
M(0x1000000C, 0x10000044, InputAction.UseQuickSlot_3);
M(0x1000000C, 0x10000045, InputAction.UseQuickSlot_4);
M(0x1000000C, 0x10000046, InputAction.UseQuickSlot_5);
M(0x1000000C, 0x10000047, InputAction.UseQuickSlot_6);
M(0x1000000C, 0x10000048, InputAction.UseQuickSlot_7);
M(0x1000000C, 0x10000049, InputAction.UseQuickSlot_8);
M(0x1000000C, 0x1000004A, InputAction.UseQuickSlot_9);
M(0x1000000C, 0x1000004E, InputAction.SelectQuickSlot_1);
M(0x1000000C, 0x1000004F, InputAction.SelectQuickSlot_2);
M(0x1000000C, 0x10000050, InputAction.SelectQuickSlot_3);
M(0x1000000C, 0x10000051, InputAction.SelectQuickSlot_4);
M(0x1000000C, 0x10000052, InputAction.SelectQuickSlot_5);
M(0x1000000C, 0x10000053, InputAction.SelectQuickSlot_6);
M(0x1000000C, 0x10000054, InputAction.SelectQuickSlot_7);
M(0x1000000C, 0x10000055, InputAction.SelectQuickSlot_8);
M(0x1000000C, 0x10000056, InputAction.SelectQuickSlot_9);
M(0x1000000C, 0x1000010D, InputAction.CreateShortcut);
// 0x10000132 ("Quickslot 13") has no InputAction — same pre-existing
// UseQuickSlot_10..13 enum gap as the bare-numeral block above. Unmapped.
M(0x1000000C, 0x10000133, InputAction.UseQuickSlot_14);
M(0x1000000C, 0x10000134, InputAction.UseQuickSlot_15);
M(0x1000000C, 0x10000135, InputAction.UseQuickSlot_16);
M(0x1000000C, 0x10000136, InputAction.UseQuickSlot_17);
M(0x1000000C, 0x10000137, InputAction.UseQuickSlot_18);
// CharacterSettings (ctx 0x10000008) is intentionally EMPTY here —
// see class doc "Known gaps deliberately left unmapped".
return t;
}
}

View file

@ -0,0 +1,167 @@
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Input;
/// <summary>
/// Campaign OP slice OP8: converts retail's DAT-resident key encoding — DirectInput
/// scan codes (<c>DIK_*</c>) + device index (0=keyboard, 1=mouse) + a modifier
/// bitmask — to/from acdream's own <see cref="KeyChord"/>/<see cref="ModifierMask"/>.
/// Needed because <c>AcDream.Core.Input.RetailActionMapReader</c> reads the DAT's
/// raw scan-code encoding (it has no Silk.NET dependency — Core stays GL/backend-free
/// per Code Structure Rule 2), while every acdream input consumer
/// (<see cref="KeyBindings"/>, <see cref="InputDispatcher"/>) keys on Silk.NET's
/// <see cref="Key"/> enum.
///
/// <para>
/// The scan-code table covers exactly the 84 distinct DIK codes that appear across
/// the DAT's 306 user-bindable ActionMap rows' default bindings (2026-08-11 probe —
/// see <c>RetailActionMap.cs</c>'s class doc), cross-checked against
/// <c>tools/dump-keymap/Program.cs</c>'s own <c>Dik(uint)</c> transcription (itself
/// verified against <c>acclient_2013_pseudo_c.txt</c>'s
/// <c>ControlNameMapper::AddKeySemantic</c> calls) and against
/// <see cref="KeyBindings.RetailDefaults"/>'s existing chords, which already encode
/// the same standard US-layout DirectInput scan codes by construction (both were
/// authored from the same <c>retail-default.keymap.txt</c>). Codes outside this set
/// (rare/debug/joystick bindings never seen with a non-empty default in the shipped
/// DAT) intentionally return null rather than guess.
/// </para>
/// </summary>
public static class RetailScanCodeMap
{
/// <summary>Retail modifier bitmask (the same encoding
/// <c>CInputMap</c>/<c>QualifiedControl.Modifier</c> stores) → <see cref="ModifierMask"/>.
/// Bit values from <c>tools/dump-keymap/Program.cs</c>'s <c>ModifierString</c>.</summary>
public static ModifierMask ToModifierMask(uint retailModifier)
{
var mask = ModifierMask.None;
if ((retailModifier & 0x80000000u) != 0) mask |= ModifierMask.Shift;
if ((retailModifier & 0x40000000u) != 0) mask |= ModifierMask.Ctrl;
if ((retailModifier & 0x20000000u) != 0) mask |= ModifierMask.Alt;
return mask;
}
/// <summary>DIK scan code + device → Silk.NET <see cref="Key"/>. Device 0 is
/// the keyboard table below (see class doc). Device 1 is a mouse chord: the
/// DAT's <c>QualifiedControl.Key.Key</c> scan half packs the raw DirectInput
/// <c>DIMOUSESTATE.rgbButtons</c> BYTE OFFSET (0x0C=left/0x0D=right/0x0E=middle/
/// 0x0F=button4 — byte-verified against <see cref="InputAction.CameraInstantMouseLook"/>'s
/// only DAT-default mouse row, InputMap 0x5 action 0x3D, scan 0x0E: it resolves
/// to Middle here, matching <see cref="KeyBindings.RetailDefaults"/>'s existing
/// MMB-hold binding for the same action exactly), translated through the SAME
/// <see cref="InputDispatcher.MouseButtonToKey"/> convention every other mouse
/// chord in this codebase uses.</summary>
public static Key? ToSilkKey(uint scan, uint device)
{
if (device == 1)
{
return scan switch
{
0x0C => InputDispatcher.MouseButtonToKey(MouseButton.Left),
0x0D => InputDispatcher.MouseButtonToKey(MouseButton.Right),
0x0E => InputDispatcher.MouseButtonToKey(MouseButton.Middle),
0x0F => InputDispatcher.MouseButtonToKey(MouseButton.Button4),
_ => null,
};
}
if (device != 0) return null;
return scan switch
{
0x01 => Key.Escape,
0x02 => Key.Number1,
0x03 => Key.Number2,
0x04 => Key.Number3,
0x05 => Key.Number4,
0x06 => Key.Number5,
0x07 => Key.Number6,
0x08 => Key.Number7,
0x09 => Key.Number8,
0x0A => Key.Number9,
0x0B => Key.Number0,
0x0C => Key.Minus,
0x0D => Key.Equal,
0x0E => Key.Backspace,
0x0F => Key.Tab,
0x10 => Key.Q,
0x11 => Key.W,
0x12 => Key.E,
0x13 => Key.R,
0x14 => Key.T,
0x15 => Key.Y,
0x16 => Key.U,
0x17 => Key.I,
0x18 => Key.O,
0x19 => Key.P,
0x1A => Key.LeftBracket,
0x1B => Key.RightBracket,
0x1C => Key.Enter,
0x1E => Key.A,
0x1F => Key.S,
0x20 => Key.D,
0x21 => Key.F,
0x22 => Key.G,
0x23 => Key.H,
0x24 => Key.J,
0x25 => Key.K,
0x26 => Key.L,
0x27 => Key.Semicolon,
0x28 => Key.Apostrophe,
0x29 => Key.GraveAccent,
0x2A => Key.ShiftLeft,
0x2B => Key.BackSlash,
0x2C => Key.Z,
0x2D => Key.X,
0x2E => Key.C,
0x2F => Key.V,
0x30 => Key.B,
0x31 => Key.N,
0x32 => Key.M,
0x33 => Key.Comma,
0x34 => Key.Period,
0x35 => Key.Slash,
0x36 => Key.ShiftRight,
0x37 => Key.KeypadMultiply,
0x39 => Key.Space,
0x3B => Key.F1,
0x3C => Key.F2,
0x3D => Key.F3,
0x3E => Key.F4,
0x3F => Key.F5,
0x40 => Key.F6,
0x41 => Key.F7,
0x42 => Key.F8,
0x43 => Key.F9,
0x44 => Key.F10,
0x45 => Key.NumLock,
0x46 => Key.ScrollLock,
0x47 => Key.Keypad7,
0x48 => Key.Keypad8,
0x49 => Key.Keypad9,
0x4A => Key.KeypadSubtract,
0x4B => Key.Keypad4,
0x4C => Key.Keypad5,
0x4D => Key.Keypad6,
0x4E => Key.KeypadAdd,
0x4F => Key.Keypad1,
0x50 => Key.Keypad2,
0x51 => Key.Keypad3,
0x52 => Key.Keypad0,
0x53 => Key.KeypadDecimal,
0x57 => Key.F11,
0x58 => Key.F12,
0x9C => Key.KeypadEnter,
0x9D => Key.ControlRight,
0xB5 => Key.KeypadDivide,
0xC7 => Key.Home,
0xC8 => Key.Up,
0xC9 => Key.PageUp,
0xCB => Key.Left,
0xCD => Key.Right,
0xCF => Key.End,
0xD0 => Key.Down,
0xD1 => Key.PageDown,
0xD2 => Key.Insert,
0xD3 => Key.Delete,
_ => null,
};
}
}

View file

@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
namespace AcDream.UI.Abstractions.Input;
/// <summary>
/// Campaign OP slice OP8: persisted bindings for DAT ActionMap rows that
/// <c>RetailActionIdentityTable</c> has no <see cref="InputAction"/> for —
/// mostly Emotes and CharacterSettings hotkeys (see that table's class doc for
/// the full accounting). These rows still render, bind, conflict-check, and
/// persist on the Configure Keyboard screen exactly like a mapped row; they
/// just have no live gameplay consumer to dispatch through yet, so they live in
/// their own small store rather than <see cref="KeyBindings"/>'s
/// <see cref="InputAction"/>-keyed schema. Sibling file next to
/// <c>keybinds.json</c> (D4 — no <c>.keymap</c> file interchange).
/// </summary>
public sealed class RetailUnmappedKeyBindings
{
private readonly Dictionary<(uint InputMapId, uint ActionId), List<KeyChord>> _bindings = new();
public IReadOnlyList<KeyChord> Get(uint inputMapId, uint actionId) =>
_bindings.TryGetValue((inputMapId, actionId), out List<KeyChord>? list)
? list
: Array.Empty<KeyChord>();
public void Set(uint inputMapId, uint actionId, IReadOnlyList<KeyChord> chords)
{
if (chords.Count == 0)
_bindings.Remove((inputMapId, actionId));
else
_bindings[(inputMapId, actionId)] = new List<KeyChord>(chords);
}
public static RetailUnmappedKeyBindings LoadOrEmpty(string path)
{
var result = new RetailUnmappedKeyBindings();
if (!File.Exists(path)) return result;
try
{
using FileStream stream = File.OpenRead(path);
JsonDocument doc = JsonDocument.Parse(stream);
if (!doc.RootElement.TryGetProperty("rows", out JsonElement rows)
|| rows.ValueKind != JsonValueKind.Array)
return result;
foreach (JsonElement row in rows.EnumerateArray())
{
if (!row.TryGetProperty("inputMap", out JsonElement mapEl)
|| !row.TryGetProperty("action", out JsonElement actionEl)
|| !row.TryGetProperty("chords", out JsonElement chordsEl)
|| chordsEl.ValueKind != JsonValueKind.Array)
continue;
uint inputMapId = Convert.ToUInt32(mapEl.GetString(), 16);
uint actionId = Convert.ToUInt32(actionEl.GetString(), 16);
var chords = new List<KeyChord>();
foreach (JsonElement c in chordsEl.EnumerateArray())
{
if (!c.TryGetProperty("key", out JsonElement keyEl)) continue;
string? keyName = keyEl.GetString();
if (keyName is null || !Enum.TryParse(keyName, out Silk.NET.Input.Key key)) continue;
var mods = ModifierMask.None;
if (c.TryGetProperty("mod", out JsonElement modEl)
&& modEl.ValueKind == JsonValueKind.String
&& modEl.GetString() is { } modStr)
{
foreach (string part in modStr.Split('|', StringSplitOptions.RemoveEmptyEntries))
if (Enum.TryParse(part, out ModifierMask m)) mods |= m;
}
byte device = 0;
if (c.TryGetProperty("device", out JsonElement devEl) && devEl.ValueKind == JsonValueKind.Number)
device = (byte)devEl.GetInt32();
chords.Add(new KeyChord(key, mods, device));
}
result._bindings[(inputMapId, actionId)] = chords;
}
}
catch (Exception ex)
{
Console.WriteLine($"unmapped keybinds: failed to load {path}: {ex.Message}");
}
return result;
}
public void SaveToFile(string path)
{
string? dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var rows = new List<object>();
foreach (((uint inputMapId, uint actionId), List<KeyChord> chords) in _bindings)
{
var chordList = new List<object>();
foreach (KeyChord chord in chords)
{
var entry = new SortedDictionary<string, object>(StringComparer.Ordinal)
{
["key"] = chord.Key.ToString(),
};
if (chord.Modifiers != ModifierMask.None) entry["mod"] = chord.Modifiers.ToString();
if (chord.Device != 0) entry["device"] = (int)chord.Device;
chordList.Add(entry);
}
rows.Add(new SortedDictionary<string, object>(StringComparer.Ordinal)
{
["inputMap"] = $"0x{inputMapId:X}",
["action"] = $"0x{actionId:X}",
["chords"] = chordList,
});
}
var root = new Dictionary<string, object> { ["version"] = 1, ["rows"] = rows };
File.WriteAllText(path, JsonSerializer.Serialize(root, new JsonSerializerOptions { WriteIndented = true }));
}
}

View file

@ -238,6 +238,7 @@ public sealed class InteractionRetainedUiCompositionTests
HostQuiescence: null!, HostQuiescence: null!,
RetainedInputCapture: null!, RetainedInputCapture: null!,
InputDispatcher: null, InputDispatcher: null,
KeyBindingsFilePath: "keybinds.json",
Settings: null!, Settings: null!,
Runtime: runtime, Runtime: runtime,
CombatAttackOperations: new NoopCombatOperations(), CombatAttackOperations: new NoopCombatOperations(),

View file

@ -263,6 +263,15 @@ public static class FixtureLoader
public static ElementInfo LoadOptionsPanelHostInfos() public static ElementInfo LoadOptionsPanelHostInfos()
=> LoadInfos("options_panel_2100006E_1000018D.json"); => LoadInfos("options_panel_2100006E_1000018D.json");
/// <summary>Configure Keyboard screen LayoutDesc <c>0x21000009</c> (standalone
/// import — its own separate full-screen window, NOT nested under the Options
/// panel's <c>0x2100006E</c> host — Campaign OP slice OP8).</summary>
public static ImportedLayout LoadKeyboardConfig()
=> LayoutImporter.Build(LoadKeyboardConfigInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadKeyboardConfigInfos()
=> LoadInfos("keyboard_config_21000009.json");
// ── Shared loader ──────────────────────────────────────────────────────── // ── Shared loader ────────────────────────────────────────────────────────
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName) private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)

View file

@ -0,0 +1,613 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Input;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign OP slice OP8 conformance + behavior tests for
/// <see cref="KeyboardConfigController"/> — built against the committed
/// <c>keyboard_config_21000009.json</c> fixture (real DAT structure, no live dat
/// access at test time), fed a small synthetic <see cref="RetailActionMapSnapshot"/>
/// so the behavioral assertions stay focused. Live-DAT row-count/label conformance
/// lives in <c>AcDream.Core.Tests.Input.RetailActionMapReaderTests</c> and
/// <c>RetailActionIdentityRoundTripTests</c>.
///
/// <para>
/// Reworked at the 2026-08-11 combined review (M1/M2/M3/S1/S4): the seam now
/// carries <see cref="Binding"/> (chord + activation + scope), the M2 fix means
/// only ONE camera InputMap context maps live, and conflicts open a real confirm
/// dialog instead of auto-reassigning silently.
/// </para>
/// </summary>
public sealed class KeyboardConfigControllerTests
{
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
private static ElementInfo? Find(ElementInfo n, uint id)
{
if (n.Id == id) return n;
foreach (ElementInfo c in n.Children)
{
ElementInfo? f = Find(c, id);
if (f is not null) return f;
}
return null;
}
private static Func<uint, uint, UiElement?> MakeTemplateResolver()
{
ElementInfo root = FixtureLoader.LoadKeyboardConfigInfos();
return (layoutId, elementId) =>
{
if (layoutId != KeyboardConfigController.LayoutId) return null;
ElementInfo? templateInfo = Find(root, elementId);
return templateInfo is null ? null : LayoutImporter.Build(templateInfo, NoTex, null).Root;
};
}
private static RetailActionMapRow Row(
uint inputMapId, uint actionId, RetailActionClass cls,
uint labelHash = 0, uint tooltipHash = 0,
params RetailKeyChord[] defaults) =>
new(inputMapId, actionId, cls, labelHash, tooltipHash, defaults);
private sealed class FakeBindings
{
public Dictionary<InputAction, List<Binding>> Mapped { get; } = new();
public Dictionary<(uint, uint), List<KeyChord>> Unmapped { get; } = new();
public List<(InputAction Action, IReadOnlyList<Binding> Value)> MappedSets { get; } = new();
public List<((uint, uint) Row, IReadOnlyList<KeyChord> Value)> UnmappedSets { get; } = new();
public List<string> Messages { get; } = new();
public int SaveCalls { get; private set; }
public int ToggleCalls { get; private set; }
public Action<KeyChord?>? PendingCapture { get; private set; }
public (string Message, Action<bool> OnResult)? PendingConfirm { get; private set; }
public void Capture(KeyChord? chord)
{
Action<KeyChord?>? cb = PendingCapture;
PendingCapture = null;
cb?.Invoke(chord);
}
public void RespondToConfirm(bool accept)
{
var pending = PendingConfirm ?? throw new InvalidOperationException("no pending confirm");
PendingConfirm = null;
pending.OnResult(accept);
}
public KeyboardConfigController.Bindings ToBindings() => new(
CurrentForAction: a => Mapped.TryGetValue(a, out var v) ? v : Array.Empty<Binding>(),
SetForAction: (a, v) =>
{
Mapped[a] = v.ToList();
MappedSets.Add((a, v));
},
CurrentForUnmapped: k => Unmapped.TryGetValue(k, out var v) ? v : Array.Empty<KeyChord>(),
SetForUnmapped: (k, v) =>
{
Unmapped[k] = v.ToList();
UnmappedSets.Add((k, v));
},
BeginCapture: cb => PendingCapture = cb,
Save: () => SaveCalls++,
Toggle: () => ToggleCalls++,
DisplaySystemMessage: msg => Messages.Add(msg),
NonBindableRefusalText: "cannot overwrite",
ConfirmOverwrite: (message, onResult) => PendingConfirm = (message, onResult));
}
private static readonly KeyChord ChordW = new(Silk.NET.Input.Key.W, ModifierMask.None);
private static readonly KeyChord ChordUp = new(Silk.NET.Input.Key.Up, ModifierMask.None);
private static readonly KeyChord ChordA = new(Silk.NET.Input.Key.A, ModifierMask.None);
[Fact]
public void Bind_Succeeds_AndBuildsOneRowPerSnapshotRow()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement), // MovementForward
Row(0x4, 0x2A, RetailActionClass.Movement), // MovementBackup
Row(0x5, 0x33, RetailActionClass.Camera), // CameraMoveToward
});
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
var fake = new FakeBindings();
KeyboardConfigController? controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings());
Assert.NotNull(controller);
Assert.Equal(3, controller!.Rows.Count);
Assert.Equal(3, controller.Page.Rows.Count);
}
[Fact]
public void Bind_MapsKnownActionsAndLeavesUnknownOnesUnmapped()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement), // -> MovementForward
Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // "Bow Deep" -> no InputAction
});
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
var fake = new FakeBindings();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
Assert.Equal(InputAction.MovementForward, forward.MappedAction);
KeyboardConfigController.RowView bowDeep = controller.Rows.Single(r => r.ActionId == 0x100000A0u);
Assert.Null(bowDeep.MappedAction);
}
[Fact]
public void Bind_SeedsRowFromLiveBindings_MappedAndUnmapped()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
Row(0x10000006, 0x100000A0, RetailActionClass.Emote),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] = new List<Binding>
{
new(ChordW, InputAction.MovementForward),
new(ChordUp, InputAction.MovementForward),
};
fake.Unmapped[(0x10000006u, 0x100000A0u)] = new List<KeyChord> { ChordA };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
Assert.Equal(new[] { ChordW, ChordUp }, forward.Model.Current);
KeyboardConfigController.RowView bowDeep = controller.Rows.Single(r => r.ActionId == 0x100000A0u);
Assert.Equal(new[] { ChordA }, bowDeep.Model.Current);
}
[Fact]
public void KeyButtonClick_CapturesAndAppliesNewBinding()
{
var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) });
var fake = new FakeBindings();
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.NotEmpty(row.KeyButtons);
row.KeyButtons[0].OnClick!.Invoke();
Assert.NotNull(fake.PendingCapture);
fake.Capture(ChordW);
Assert.Contains(ChordW, row.Model.Current);
(InputAction Action, IReadOnlyList<Binding> Value) written = Assert.Single(fake.MappedSets);
Assert.Equal(InputAction.MovementForward, written.Action);
Binding onlyBinding = Assert.Single(written.Value);
Assert.Equal(ChordW, onlyBinding.Chord);
// No live binding existed at build time — falls back to the Binding
// record's own defaults (Press/Game), same as before M1.
Assert.Equal(ActivationType.Press, onlyBinding.Activation);
Assert.Equal(InputScope.Game, onlyBinding.Scope);
Assert.Equal("W", row.KeyButtons[0].Label);
}
[Fact]
public void KeyButtonCapture_EscapeCancel_LeavesBindingUnchanged()
{
var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) });
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] = new List<Binding> { new(ChordW, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
fake.Capture(null); // Escape sentinel
Assert.Equal(new[] { ChordW }, row.Model.Current);
Assert.Empty(fake.MappedSets);
}
[Fact]
public void KeyButtonRightClick_ErasesThatSlot()
{
var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) });
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] = new List<Binding>
{
new(ChordW, InputAction.MovementForward),
new(ChordUp, InputAction.MovementForward),
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.Equal(2, row.Model.Current.Count);
row.KeyButtons[0].OnRightClick!.Invoke();
Assert.Single(row.Model.Current);
Assert.DoesNotContain(ChordW, row.Model.Current);
}
[Fact]
public void KeyButtonRightClick_OnAlreadyEmptySlot_IsANoOp()
{
var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) });
var fake = new FakeBindings();
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.Empty(row.Model.Current);
row.KeyButtons[0].OnRightClick!.Invoke();
Assert.Empty(row.Model.Current);
Assert.Empty(fake.MappedSets);
}
/// <summary>S4 (2026-08-11 review): clicking "Mapping 3" (slot index 2) on a
/// row with NO existing bindings must land the captured chord on display
/// index 2, not collapse it onto index 0.</summary>
[Fact]
public void KeyButtonClick_OnSparseRow_ThirdSlotLandsOnThirdButton()
{
var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) });
var fake = new FakeBindings();
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.Equal(3, row.KeyButtons.Count);
Assert.Empty(row.Model.Current);
row.KeyButtons[2].OnClick!.Invoke(); // "Mapping 3"
fake.Capture(ChordW);
Assert.Null(row.KeyButtons[0].Label);
Assert.Null(row.KeyButtons[1].Label);
Assert.Equal("W", row.KeyButtons[2].Label);
// The write to the live seam only ever carries the REAL chord — no
// default(KeyChord) padding leaks into the persisted Binding list.
(InputAction Action, IReadOnlyList<Binding> Value) written = Assert.Single(fake.MappedSets);
Binding onlyBinding = Assert.Single(written.Value);
Assert.Equal(ChordW, onlyBinding.Chord);
}
[Fact]
public void Capture_ConflictWithAnotherRow_OpensConfirmDialog_AcceptReassigns()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement), // MovementForward
Row(0x4, 0x2A, RetailActionClass.Movement), // MovementBackup — will hold ChordA
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementBackup] = new List<Binding> { new(ChordA, InputAction.MovementBackup) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
KeyboardConfigController.RowView backup = controller.Rows.Single(r => r.ActionId == 0x2Au);
forward.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordA); // steal MovementBackup's chord
// M3: nothing is applied yet — a confirm dialog is pending.
Assert.NotNull(fake.PendingConfirm);
Assert.DoesNotContain(ChordA, forward.Model.Current);
Assert.Contains(ChordA, backup.Model.Current);
Assert.Empty(fake.Messages);
fake.RespondToConfirm(true);
Assert.Contains(ChordA, forward.Model.Current);
Assert.DoesNotContain(ChordA, backup.Model.Current);
}
[Fact]
public void Capture_ConflictWithAnotherRow_DeclineLeavesBothRowsUnchanged()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
Row(0x4, 0x2A, RetailActionClass.Movement),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] = new List<Binding> { new(ChordW, InputAction.MovementForward) };
fake.Mapped[InputAction.MovementBackup] = new List<Binding> { new(ChordA, InputAction.MovementBackup) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
KeyboardConfigController.RowView backup = controller.Rows.Single(r => r.ActionId == 0x2Au);
forward.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordA);
fake.RespondToConfirm(false);
Assert.Equal(new[] { ChordW }, forward.Model.Current); // untouched
Assert.Equal(new[] { ChordA }, backup.Model.Current); // untouched
}
[Fact]
public void Capture_ConflictWithNonBindableAcdreamAction_RefusesWithoutADialog()
{
var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) });
var fake = new FakeBindings();
// AcdreamToggleAudioMute has no RetailActionIdentityTable row at all.
var muteChord = new KeyChord(Silk.NET.Input.Key.M, ModifierMask.Ctrl);
fake.Mapped[InputAction.AcdreamToggleAudioMute] =
new List<Binding> { new(muteChord, InputAction.AcdreamToggleAudioMute) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single();
forward.KeyButtons[0].OnClick!.Invoke();
fake.Capture(muteChord);
// S1: refused outright, no confirm dialog offered.
Assert.Null(fake.PendingConfirm);
Assert.DoesNotContain(muteChord, forward.Model.Current);
Assert.Contains("cannot overwrite", fake.Messages);
Assert.Equal(muteChord, Assert.Single(fake.Mapped[InputAction.AcdreamToggleAudioMute]).Chord);
}
/// <summary>S1: retail checks the non-user-bindable target BEFORE any
/// user-bindable row conflict, even when both exist for the same chord.</summary>
[Fact]
public void Capture_ConflictWithBothARowAndANonBindableAction_NonBindableWins()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
Row(0x4, 0x2A, RetailActionClass.Movement),
});
var fake = new FakeBindings();
var sharedChord = new KeyChord(Silk.NET.Input.Key.M, ModifierMask.Ctrl);
fake.Mapped[InputAction.MovementBackup] = new List<Binding> { new(sharedChord, InputAction.MovementBackup) };
fake.Mapped[InputAction.AcdreamToggleAudioMute] =
new List<Binding> { new(sharedChord, InputAction.AcdreamToggleAudioMute) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
forward.KeyButtons[0].OnClick!.Invoke();
fake.Capture(sharedChord);
Assert.Null(fake.PendingConfirm);
Assert.Contains("cannot overwrite", fake.Messages);
Assert.DoesNotContain(sharedChord, forward.Model.Current);
}
[Fact]
public void OkButton_AppliesCommitsSavesAndToggles()
{
var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) });
var fake = new FakeBindings();
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordW);
Assert.True(row.Model.Changed);
UiButton ok = (UiButton)layout.FindElement(0x1000002Cu)!;
ok.OnClick!.Invoke();
Assert.False(row.Model.Changed); // Apply committed saved=current
Assert.Equal(1, fake.SaveCalls);
Assert.Equal(1, fake.ToggleCalls);
}
[Fact]
public void CancelButton_RevertsUncommittedEditAndToggles()
{
var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) });
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] = new List<Binding> { new(ChordW, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordUp); // now [Up] uncommitted (slot 0 replaced)
UiButton cancel = (UiButton)layout.FindElement(0x1000002Du)!;
cancel.OnClick!.Invoke();
Assert.Equal(new[] { ChordW }, row.Model.Current); // reverted to Saved
Assert.Equal(1, fake.ToggleCalls);
Assert.Equal(0, fake.SaveCalls);
}
[Fact]
public void DefaultsButton_RestoresDatDefaultLive_WithoutCommitting()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement, defaults:
new[] { new RetailKeyChord(0x11, 0, 0, 3) }), // DIK_W
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] = new List<Binding> { new(ChordUp, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.Equal(new[] { ChordUp }, row.Model.Current);
UiButton defaultsButton = (UiButton)layout.FindElement(0x1000002Au)!;
defaultsButton.OnClick!.Invoke();
Assert.Equal(new[] { ChordW }, row.Model.Current);
Assert.True(row.Model.Changed); // live but uncommitted, matching retail
}
/// <summary>M1 (2026-08-11 review): Defaults must restore the DAT-sourced
/// KEY only — the row's live Activation/Scope (Hold + MeleeCombat here,
/// captured from the action's live binding at build time) must survive the
/// click unchanged, across the FULL 306-row shape this test represents with
/// one Hold+scoped action.</summary>
[Fact]
public void DefaultsButton_PreservesActivationAndScope_ForAHoldScopedAction()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x10000003, 0x1000005D, RetailActionClass.Combat, defaults:
new[] { new RetailKeyChord(0xD3, 0, 0, 3) }), // CombatLowAttack, DIK_DELETE
});
var fake = new FakeBindings();
fake.Mapped[InputAction.CombatLowAttack] = new List<Binding>
{
new(ChordUp, InputAction.CombatLowAttack, ActivationType.Hold, InputScope.MeleeCombat),
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
UiButton defaultsButton = (UiButton)layout.FindElement(0x1000002Au)!;
defaultsButton.OnClick!.Invoke();
(InputAction Action, IReadOnlyList<Binding> Value) written = Assert.Single(fake.MappedSets);
Assert.Equal(InputAction.CombatLowAttack, written.Action);
Binding result = Assert.Single(written.Value);
Assert.Equal(Silk.NET.Input.Key.Delete, result.Chord.Key); // the DAT default key
Assert.Equal(ActivationType.Hold, result.Activation); // preserved, not reset to Press
Assert.Equal(InputScope.MeleeCombat, result.Scope); // preserved, not reset to Game
}
/// <summary>M1: Cancel/Revert (RestoreSavedValue) must ALSO preserve
/// Activation/Scope, not just Defaults.</summary>
[Fact]
public void CancelButton_PreservesActivationAndScope()
{
var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x32, RetailActionClass.Movement) });
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementWalkMode] = new List<Binding>
{
new(ChordW, InputAction.MovementWalkMode, ActivationType.Hold, InputScope.Game),
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordUp); // uncommitted edit
UiButton cancel = (UiButton)layout.FindElement(0x1000002Du)!;
cancel.OnClick!.Invoke();
// MappedSets also carries the capture's own write (ChordUp) before the
// revert — take the LAST write, which is Cancel's RestoreSavedValue.
(InputAction Action, IReadOnlyList<Binding> Value) written = fake.MappedSets[^1];
Binding result = Assert.Single(written.Value);
Assert.Equal(ChordW, result.Chord); // reverted to saved
Assert.Equal(ActivationType.Hold, result.Activation);
}
/// <summary>M2 (2026-08-11 review): InputMap 0x6 (CameraAlternateControls) no
/// longer aliases InputMap 0x5's (CameraControls) InputAction — each row is
/// independent, so rebinding one never clobbers the other, and a row cannot
/// conflict with its own former twin.</summary>
[Fact]
public void CameraContext5And6_AreIndependentRows_NotAliased()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x5, 0x35, RetailActionClass.Camera, defaults: new[] { new RetailKeyChord(0x4B, 0, 0, 3) }),
Row(0x6, 0x35, RetailActionClass.Camera, defaults: new[] { new RetailKeyChord(0xCB, 0, 0, 3) }),
});
var fake = new FakeBindings();
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
KeyboardConfigController.RowView ctx5 = controller.Rows.Single(r => r.InputMapId == 0x5u);
KeyboardConfigController.RowView ctx6 = controller.Rows.Single(r => r.InputMapId == 0x6u);
Assert.Equal(InputAction.CameraRotateLeft, ctx5.MappedAction);
Assert.Null(ctx6.MappedAction); // unmapped — no live dual-binding infrastructure (M2)
// Round-2 SHOULD-FIX: an unmapped row with no persisted chords now
// DISPLAYS its DAT defaults (retail shows the arrow keys; blank read
// as "unbound"). Display-only — storage stays untouched until the
// user edits THIS row.
Assert.NotEmpty(ctx6.Model.Current);
var ctx6InitialDisplay = ctx6.Model.Current.ToArray();
// Rebinding ctx5's row must not touch ctx6's display or storage.
ctx5.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordW);
Assert.Contains(ChordW, ctx5.Model.Current);
Assert.Equal(ctx6InitialDisplay, ctx6.Model.Current); // unchanged by ctx5's edit
Assert.Empty(fake.Unmapped); // ctx6's STORE untouched — display seeding writes nothing
ctx6.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordA);
Assert.Contains(ChordA, ctx6.Model.Current);
Assert.Contains(ChordW, ctx5.Model.Current); // ctx5 unaffected by ctx6's edit
Assert.True(fake.Unmapped.ContainsKey((0x6u, 0x35u)));
}
[Fact]
public void Bind_MissingWindowRoot_ReturnsNull()
{
ElementInfo empty = new() { Id = 0x99999999u, Type = 3 };
ImportedLayout emptyLayout = LayoutImporter.Build(empty, NoTex, null);
var snapshot = new RetailActionMapSnapshot(Array.Empty<RetailActionMapRow>());
var fake = new FakeBindings();
KeyboardConfigController? controller = KeyboardConfigController.Bind(
emptyLayout, snapshot, (_, _) => null, (_, _) => null, fake.ToBindings());
Assert.Null(controller);
}
// ── OP8 re-review round 2: identity-map injectivity is load-bearing for
// BOTH M1 (per-row activation capture) and M2 (no dual rows editing one
// action) — pin it so a future mapping addition cannot silently alias. ──
[Fact]
public void IdentityMap_IsInjective_NoTwoRowsShareOneInputAction()
{
var seen = new Dictionary<InputAction, (uint MapId, uint ActionId)>();
foreach (((uint mapId, uint actionId), InputAction action) in RetailActionIdentityTable.Map)
{
Assert.False(
seen.TryGetValue(action, out (uint MapId, uint ActionId) prior),
$"InputAction.{action} is mapped by BOTH (0x{prior.MapId:X}, 0x{prior.ActionId:X}) "
+ $"and (0x{mapId:X}, 0x{actionId:X}) — aliasing reintroduces the M2 twin-row clobber.");
seen[action] = (mapId, actionId);
}
Assert.True(seen.Count > 100, $"sanity: only {seen.Count} mapped actions seen");
}
}

View file

@ -35,6 +35,7 @@ public sealed class RetailLayoutFixtureGenerator
(0x21000028u, "options_character_21000028.json"), (0x21000028u, "options_character_21000028.json"),
(0x2100005Cu, "options_chat_2100005C.json"), (0x2100005Cu, "options_chat_2100005C.json"),
(0x21000029u, "options_config_21000029.json"), (0x21000029u, "options_config_21000029.json"),
(0x21000009u, "keyboard_config_21000009.json"),
}; };
[Fact] [Fact]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,150 @@
using System.Collections.Generic;
using System.Linq;
using AcDream.Content;
using AcDream.Core.Input;
using AcDream.UI.Abstractions.Input;
using DatReaderWriter;
using Xunit;
namespace AcDream.Core.Tests.Input;
/// <summary>
/// Campaign OP slice OP8: pins <see cref="RetailActionIdentityTable"/>'s agreement
/// with <see cref="KeyBindings.RetailDefaults"/> — for every <see cref="InputAction"/>
/// this slice's table resolves, the UNION of DAT default bindings across every DAT
/// row mapped to that action must equal <c>KeyBindings.RetailDefaults()</c>'s chord
/// set for it. Per the slice contract: "investigate + report any disagreement rather
/// than silently preferring one." Skips cleanly when the installed dats are
/// unavailable (CI), matching every other live-DAT conformance test in this project.
///
/// <para>
/// <b>Two real, byte-verified disagreements survive after the mechanism fixes</b>
/// (2026-08-11 investigation, updated at the M2 rework — none are bugs in this
/// slice's table; both are PRE-EXISTING <see cref="KeyBindings.RetailDefaults"/>
/// gaps/design choices this slice does not touch, listed in
/// <see cref="KnownRetailDefaultsDisagreements"/> with citations). A THIRD
/// disagreement — ten CameraAlternateControls (InputMap 0x6) actions — was RETIRED
/// at the M2 rework: <see cref="RetailActionIdentityTable"/> no longer maps InputMap
/// 0x6 to any <see cref="InputAction"/> at all (the aliasing that produced two
/// independent rows fighting over one live target — M2, 2026-08-11 review), so this
/// test never sees a ctx-0x6 row and the ctx-0x5-only union now matches
/// <c>RetailDefaults()</c> exactly for all twelve Camera actions with no allowlist
/// entry needed:
/// </para>
/// <list type="number">
/// <item><description><b>MovementWalkMode.</b> The DAT's raw <c>QualifiedControl.Modifier</c>
/// for the Shift-key binding is 0 (the key itself IS Shift — there is no separate
/// "modifier" to report when the primary key and the modifier are the same physical
/// key). <c>RetailDefaults()</c> deliberately encodes <c>Modifiers=Shift</c> anyway —
/// its own comment (K-fix1, 2026-04-26) explains the OS echoes
/// <c>CurrentModifiers=Shift</c> alongside a Shift key-DOWN event, so the chord must
/// carry the flag to match at dispatch time. Not a disagreement to fix; a raw-DAT
/// artifact this slice's reader faithfully reproduces.</description></item>
/// <item><description><b>Quickslot 1-9's Ctrl+N chord (and its SelectQuickSlot_1-9
/// counterpart).</b> The DAT's own default
/// master map binds Ctrl+1..9 to the SAME action id as bare 1..9 ("Quickslot N" —
/// <c>UseQuickSlot_N</c>), NOT to the separate "Select Quickslot N" action id
/// (<c>SelectQuickSlot_N</c>, DAT action ids <c>0x1000004E-56</c>) — those carry NO
/// default binding at all in the shipped DAT. <c>RetailDefaults()</c>'s own comment
/// (citing <c>gmToolbarUI::ListenToGlobalMessage @0x004BE4E0</c>) asserts retail's
/// CLIENT reinterprets Ctrl+N contextually as Select — a runtime behavior this raw
/// keymap-default probe cannot see (it reads bound ACTIONS, not the dispatch
/// function's own modifier branching). Both readings are independently retail-
/// sourced; reconciling them needs the decompiled dispatch function, out of scope
/// here. Reported, not silently resolved either way.</description></item>
/// </list>
/// </summary>
public sealed class RetailActionIdentityRoundTripTests
{
/// <summary>Actions with a citation-backed, pre-existing reason their DAT-union
/// default set legitimately differs from <see cref="KeyBindings.RetailDefaults"/>
/// — see class doc. Every other mapped action must match exactly.</summary>
private static readonly HashSet<InputAction> KnownRetailDefaultsDisagreements = new()
{
InputAction.MovementWalkMode,
InputAction.UseQuickSlot_1,
InputAction.UseQuickSlot_2,
InputAction.UseQuickSlot_3,
InputAction.UseQuickSlot_4,
InputAction.UseQuickSlot_5,
InputAction.UseQuickSlot_6,
InputAction.UseQuickSlot_7,
InputAction.UseQuickSlot_8,
InputAction.UseQuickSlot_9,
// Same Use-vs-Select ambiguity as the bare-numeral block above: the DAT's
// own "Select Quickslot N" action ids carry NO default binding at all —
// RetailDefaults()'s Ctrl+N->Select mapping rests on the decompiled
// dispatch function's runtime modifier check, not the raw keymap default.
InputAction.SelectQuickSlot_1,
InputAction.SelectQuickSlot_2,
InputAction.SelectQuickSlot_3,
InputAction.SelectQuickSlot_4,
InputAction.SelectQuickSlot_5,
InputAction.SelectQuickSlot_6,
InputAction.SelectQuickSlot_7,
InputAction.SelectQuickSlot_8,
InputAction.SelectQuickSlot_9,
};
[Fact]
public void MappedActions_DatUnionDefaultBindings_MatchRetailDefaults()
{
string? datDir = Conformance.ConformanceDats.ResolveDatDir();
if (datDir is null) return;
using var dats = new DatCollection(datDir);
var source = new DatCollectionAdapter(dats);
RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(source);
Assert.NotNull(snapshot);
KeyBindings retailDefaults = KeyBindings.RetailDefaults();
// Aggregate DAT default chords by resolved InputAction — a single action can
// be reached by more than one DAT row (e.g. the Camera/CameraAlternate pair).
var datChordsByAction = new Dictionary<InputAction, HashSet<KeyChord>>();
var unresolvedScanCodes = new List<string>();
foreach (RetailActionMapRow row in snapshot!.Rows)
{
if (!RetailActionIdentityTable.TryResolve(row.InputMapId, row.ActionId, out InputAction action))
continue;
if (!datChordsByAction.TryGetValue(action, out HashSet<KeyChord>? set))
datChordsByAction[action] = set = new HashSet<KeyChord>();
foreach (RetailKeyChord raw in row.DefaultBindings)
{
Silk.NET.Input.Key? key = RetailScanCodeMap.ToSilkKey(raw.Scan, raw.Device);
if (key is null)
{
unresolvedScanCodes.Add(
$"{action}: DAT default scan=0x{raw.Scan:X2} dev={raw.Device} has no "
+ "RetailScanCodeMap entry");
continue;
}
set.Add(new KeyChord(key.Value, RetailScanCodeMap.ToModifierMask(raw.Modifier), (byte)raw.Device));
}
}
Assert.True(datChordsByAction.Count > 100,
$"expected >100 mapped actions, got {datChordsByAction.Count}");
Assert.Empty(unresolvedScanCodes);
var mismatches = new List<string>();
foreach ((InputAction action, HashSet<KeyChord> datChords) in datChordsByAction)
{
if (KnownRetailDefaultsDisagreements.Contains(action)) continue;
var acdreamChords = retailDefaults.ForAction(action).Select(b => b.Chord).ToHashSet();
if (!datChords.SetEquals(acdreamChords))
{
mismatches.Add(
$"{action}: DAT union=[{string.Join(",", datChords)}] vs "
+ $"RetailDefaults()=[{string.Join(",", acdreamChords)}]");
}
}
Assert.True(mismatches.Count == 0,
$"{mismatches.Count} unexpected DAT-vs-RetailDefaults() disagreements "
+ "(not in the documented KnownRetailDefaultsDisagreements allowlist):\n"
+ string.Join("\n", mismatches));
}
}

View file

@ -0,0 +1,248 @@
using System.Collections.Generic;
using AcDream.Content;
using AcDream.Core.Content;
using AcDream.Core.Input;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using DatReaderWriter.Lib.IO;
using Xunit;
namespace AcDream.Core.Tests.Input;
/// <summary>
/// Campaign OP slice OP8: <see cref="RetailActionMapReader"/> conformance.
/// Hermetic tests pin the merge/filter mechanism against a synthetic
/// <see cref="FakeDatObjectSource"/> (no DAT dependency — always runs);
/// <see cref="RetailActionMapReader_LiveDatTests"/> pins the exact row
/// counts and spot-labels against the installed dats (skips cleanly when
/// unavailable, matching <c>ConformanceDats.ResolveDatDir</c>'s pattern).
/// </summary>
public sealed class RetailActionMapReaderTests
{
[Fact]
public void Read_FiltersNonUserBindableActions()
{
var actionMap = new ActionMap
{
StringTableId = 0x23000005u,
InputMaps = new Dictionary<uint, Dictionary<uint, ActionMapValue>>
{
[4u] = new Dictionary<uint, ActionMapValue>
{
// Class 0 == not user-bindable — the reader must drop this row.
[0x1u] = new ActionMapValue { UserBinding = new UserBindingData { ActionClass = 0u } },
// Class 1 (Movement) — kept.
[0x29u] = new ActionMapValue
{
UserBinding = new UserBindingData
{
ActionClass = 1u,
ActionName = 0x111u,
ActionDescription = 0x222u,
},
},
},
},
ConflictingMaps = new Dictionary<uint, InputsConflictsValue>(),
};
var dats = new FakeDatObjectSource();
dats.Add(RetailActionMapIds.ActionMapId, actionMap);
RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(dats);
Assert.NotNull(snapshot);
RetailActionMapRow row = Assert.Single(snapshot!.Rows);
Assert.Equal(4u, row.InputMapId);
Assert.Equal(0x29u, row.ActionId);
Assert.Equal(RetailActionClass.Movement, row.ActionClass);
Assert.Equal(0x111u, row.LabelHash);
Assert.Equal(0x222u, row.TooltipHash);
}
[Fact]
public void Read_UnionMergesBothMasterMapsWithoutOrderDependency()
{
// The one real collision the live probe found: context 5 exists in BOTH
// master maps with completely disjoint action-id sets (2026-08-11 probe,
// see RetailActionMap.cs class doc unknown #4). Reproduce that shape here
// and assert the merge picks up defaults from BOTH sources for the SAME
// context, order-independent.
var actionMap = new ActionMap
{
InputMaps = new Dictionary<uint, Dictionary<uint, ActionMapValue>>
{
[5u] = new Dictionary<uint, ActionMapValue>
{
[0x3Eu] = new ActionMapValue { UserBinding = new UserBindingData { ActionClass = 2u } },
[0x33u] = new ActionMapValue { UserBinding = new UserBindingData { ActionClass = 2u } },
},
},
ConflictingMaps = new Dictionary<uint, InputsConflictsValue>(),
};
var gameplayMap = new MasterInputMap
{
InputMaps = new Dictionary<uint, CInputMap>
{
[5u] = new CInputMap
{
Mappings = new List<QualifiedControl>
{
new QualifiedControl
{
Key = new ControlSpecification { Key = (0xB5u << 16) | 0u, Modifier = 0u },
Activation = 3u,
Unknown = 0x3Eu,
},
},
},
},
};
var systemMap = new MasterInputMap
{
InputMaps = new Dictionary<uint, CInputMap>
{
[5u] = new CInputMap
{
Mappings = new List<QualifiedControl>
{
new QualifiedControl
{
Key = new ControlSpecification { Key = (0x4Au << 16) | 0u, Modifier = 0u },
Activation = 3u,
Unknown = 0x33u,
},
},
},
},
};
var dats = new FakeDatObjectSource();
dats.Add(RetailActionMapIds.ActionMapId, actionMap);
dats.Add(RetailActionMapIds.GameplayMasterMapId, gameplayMap);
dats.Add(RetailActionMapIds.SystemMasterMapId, systemMap);
RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(dats);
Assert.NotNull(snapshot);
Assert.Equal(2, snapshot!.Rows.Count);
RetailActionMapRow rowFromGameplayMap = Assert.Single(snapshot.Rows, r => r.ActionId == 0x3Eu);
RetailKeyChord chord1 = Assert.Single(rowFromGameplayMap.DefaultBindings);
Assert.Equal(0xB5u, chord1.Scan);
RetailActionMapRow rowFromSystemMap = Assert.Single(snapshot.Rows, r => r.ActionId == 0x33u);
RetailKeyChord chord2 = Assert.Single(rowFromSystemMap.DefaultBindings);
Assert.Equal(0x4Au, chord2.Scan);
}
[Fact]
public void Read_MissingActionMap_ReturnsNull()
{
var dats = new FakeDatObjectSource();
Assert.Null(RetailActionMapReader.Read(dats));
}
[Fact]
public void Read_MissingMasterMaps_StillReturnsRowsWithEmptyDefaults()
{
var actionMap = new ActionMap
{
InputMaps = new Dictionary<uint, Dictionary<uint, ActionMapValue>>
{
[4u] = new Dictionary<uint, ActionMapValue>
{
[0x29u] = new ActionMapValue { UserBinding = new UserBindingData { ActionClass = 1u } },
},
},
ConflictingMaps = new Dictionary<uint, InputsConflictsValue>(),
};
var dats = new FakeDatObjectSource();
dats.Add(RetailActionMapIds.ActionMapId, actionMap);
RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(dats);
Assert.NotNull(snapshot);
RetailActionMapRow row = Assert.Single(snapshot!.Rows);
Assert.Empty(row.DefaultBindings);
}
[Fact]
public void RetailInputMapHeaders_HasAllNineteenByteVerifiedEntries()
{
// docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md §5.3.
Assert.Equal(19, RetailInputMapHeaders.NameByInputMapId.Count);
Assert.Equal("ID_InputMap_MovementCommands", RetailInputMapHeaders.NameByInputMapId[0x00000004u]);
Assert.Equal("ID_InputMap_CameraControls", RetailInputMapHeaders.NameByInputMapId[0x00000005u]);
Assert.Equal("ID_InputMap_Emotes", RetailInputMapHeaders.NameByInputMapId[0x10000006u]);
Assert.Equal("ID_InputMap_CharacterOptionCommands", RetailInputMapHeaders.NameByInputMapId[0x10000008u]);
Assert.Equal("ID_InputMap_ToggleChatEntry", RetailInputMapHeaders.NameByInputMapId[0x1000000Du]);
}
/// <summary>Minimal in-memory <see cref="IDatObjectSource"/> for hermetic tests —
/// no DAT file dependency.</summary>
private sealed class FakeDatObjectSource : IDatObjectSource
{
private readonly Dictionary<uint, object> _objects = new();
public void Add<T>(uint fileId, T value) where T : IDBObj => _objects[fileId] = value!;
public T? Get<T>(uint fileId) where T : IDBObj =>
_objects.TryGetValue(fileId, out object? value) && value is T typed ? typed : default;
public bool TryGet<T>(uint fileId, out T value) where T : IDBObj
{
T? found = Get<T>(fileId);
value = found!;
return found is not null;
}
}
}
/// <summary>Live-DAT conformance: pins the exact shape of the installed
/// <c>client_portal.dat</c>/<c>client_local_English.dat</c> ActionMap +
/// MasterInputMap objects. Skips cleanly when the dats are unavailable.</summary>
public sealed class RetailActionMapReader_LiveDatTests
{
[Fact]
public void Read_AgainstInstalledDats_MatchesPinnedShape()
{
string? datDir = Conformance.ConformanceDats.ResolveDatDir();
if (datDir is null) return; // CI / no local install — skip cleanly.
using var dats = new DatCollection(datDir);
var source = new DatCollectionAdapter(dats);
RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(source);
Assert.NotNull(snapshot);
// Pinned 2026-08-11 against the installed EoR dats — 306 user-bindable rows
// across the six non-zero ActionClass buckets (1,2,3,4,5,7 — never 0 or 6).
Assert.Equal(306, snapshot!.Rows.Count);
var byClass = new Dictionary<RetailActionClass, int>();
foreach (RetailActionMapRow row in snapshot.Rows)
{
Assert.NotEqual(RetailActionClass.None, row.ActionClass);
byClass.TryGetValue(row.ActionClass, out int count);
byClass[row.ActionClass] = count + 1;
}
Assert.Equal(14, byClass[RetailActionClass.Movement]);
Assert.Equal(22, byClass[RetailActionClass.Camera]);
Assert.Equal(103, byClass[RetailActionClass.Ui]);
Assert.Equal(32, byClass[RetailActionClass.Combat]);
Assert.Equal(87, byClass[RetailActionClass.Emote]);
Assert.Equal(48, byClass[RetailActionClass.CharacterSettings]);
// Spot-pin Movement's "Move Forward" (action 0x29, InputMap 0x4): two
// default bindings (W + Up arrow), matching KeyBindings.RetailDefaults()'s
// MovementForward chords.
RetailActionMapRow moveForward = Assert.Single(
snapshot.Rows, r => r.InputMapId == 0x4u && r.ActionId == 0x29u);
Assert.Equal(2, moveForward.DefaultBindings.Count);
Assert.Contains(moveForward.DefaultBindings, c => c.Scan == 0x11u); // DIK_W
Assert.Contains(moveForward.DefaultBindings, c => c.Scan == 0xC8u); // DIK_UPARROW
}
}