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
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue