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

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

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

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

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

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

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

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

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

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));
}
}
}