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:
parent
ff5776415b
commit
b4edee970f
23 changed files with 35760 additions and 12 deletions
239
src/AcDream.Core/Input/RetailActionMap.cs
Normal file
239
src/AcDream.Core/Input/RetailActionMap.cs
Normal 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->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 << 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 -> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue