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

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.UI.Abstractions.Input;
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>
/// Retail <c>OptionPage</c>/<c>PlayerOptionPage</c>: a page's registered-option
/// array plus the four verbs (Apply/Reset/Defaults/visibility) with retail's