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

@ -16,6 +16,7 @@ using AcDream.Core.Selection;
using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
using AcDream.Content;
using AcDream.Core.Input;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Settings;
@ -283,6 +284,19 @@ public sealed record VendorRuntimeBindings(
// own DisplaySystemMessage already uses.
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(
UiHost Host,
RetailUiAssets Assets,
@ -307,7 +321,8 @@ public sealed record RetailUiRuntimeBindings(
StackSplitQuantityState StackSplitQuantity,
BufferedUiRegistry? Plugins,
RetailUiPersistenceBindings? Persistence,
RetailUiProbeBindings Probe);
RetailUiProbeBindings Probe,
KeyboardRuntimeBindings? Keyboard = null);
/// <summary>
/// Composition owner for the production retained gameplay UI. GameWindow supplies
@ -383,6 +398,7 @@ public sealed class RetailUiRuntime : IDisposable
MountEffects();
MountIndicatorDetailPanels();
MountOptionsPanel();
MountKeyboardConfig();
MountIndicators();
MountJumpPowerbar();
MountDialogFactory();
@ -2042,7 +2058,8 @@ public sealed class RetailUiRuntime : IDisposable
UseMouseTurningSettings: ApplyMouseTurningSettingsMacro,
DisplaySystemMessage: _bindings.Options.DisplaySystemMessage,
AfterApply: () => _bindings.Options.CommandBus().Publish(
new SaveCharacterOptionsRuntimeCmd()));
new SaveCharacterOptionsRuntimeCmd()),
OpenConfigureKeyboard: () => ToggleWindow(WindowNames.KeyboardConfig));
Layout.OptionsPanelController? controller =
Layout.OptionsPanelController.Bind(layout, callbacks);
@ -2238,6 +2255,161 @@ public sealed class RetailUiRuntime : IDisposable
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(
CurrentForAction: action => dispatcher.Bindings.ForAction(action)
.Select(b => b.Chord).ToArray(),
SetForAction: (action, chords) =>
{
KeyBindings updated = CloneWithout(dispatcher.Bindings, action);
foreach (KeyChord chord in chords)
updated.Add(new Binding(chord, action));
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: () =>
{
dispatcher.Bindings.SaveToFile(keyboard.KeyBindingsFilePath);
unmapped.SaveToFile(unmappedPath);
},
Toggle: () => ToggleWindow(WindowNames.KeyboardConfig),
DisplaySystemMessage: text =>
{
if (!string.IsNullOrEmpty(text)) _bindings.Options.DisplaySystemMessage(text);
},
NonBindableRefusalText: refusalText ?? string.Empty,
// No retail string exists for "binding reassigned" — retail's
// own flow only shows the confirm-before-reassign dialog
// (research doc §5.4's OpenOverwriteBindingDialog), never a
// post-reassign notice. This port's auto-reassign (register
// row) stays silent rather than inventing English for a
// message retail never had.
NotifyReassigned: _ => string.Empty));
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()
{
ElementInfo? info;