From 30fa6ee5070fdea36bad9c2fce10bb46897128e3 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 13:45:55 +0200 Subject: [PATCH] =?UTF-8?q?fix=20#394=20#395=20#396:=20OP8=20re-gate=20rou?= =?UTF-8?q?nd=20=E2=80=94=20caption=20font,=20retail=20key=20names,=20capt?= =?UTF-8?q?ure=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the user's first Configure Keyboard look (OP8 gate, 2026-08-14), each root-caused against the named retail decomp: - #394 row-caption font: the synthesized action-label UiText never set DatFont and fell to the debug bitmap font. The authored row template (0x21000009/0x1000002F, retail UIOption_ActionKeyMap) carries FontDid 0x4000000A (18px serif) — Bind now takes resolveTemplateFont and applies the template's own authored font, resolved once per template pair. - #395 key captions: raw enum spellings ("Shift+ShiftLeft") replaced by the port of CInputManager_WIN32::GetNameFromKey @0x00687F40 / GetNameFromKey_Internal @0x00687800 (RetailKeyNames): DAT string-table override by DIK-name hash (key enum 4 -> 0x2300000A, meta enum 5 -> 0x2300000B, delimiter enum 3 -> 0x23000007 — GetDIDByEnum category 4, live-probed), else the OS keyboard layout's own key name ("SKIFT") via PlatformKeyNameProvider (Win32 GetKeyNameTextW — register row AD-96 for the DirectInput-vs-GetKeyNameText adaptation), else the DIK-suffix spelling. Bare modifier-key bindings show only the key name. - #396 capture feedback: clicking a mapping button now opens retail's instruction dialog (InitiateBinding @0x004899D0 -> OpenMapWarnDialog @0x00488A00): a type-2 WAIT dialog on retail's MapWarn queue key 0x10000001 with ID_ActionKeyMap_MapInstructions (0x23000004, ACTION variable interpolated), closed on key hit or ESC through the capture callback; capture is not armed if the dialog cannot open, matching retail. New RetailWaitDialogView (wait root 0x31 — same authored popup/message pair 0x3D/0x3E as the confirmation root, live-DAT probed) behind a shared IRetailDialogView presenter seam. Probe evidence (env-gated, kept): KeyboardConfigLiveMountProbeTests.ProbeKeyboardFontsAndKeyNameStrings. Register: AD-96 filed. Gate script OP8 section updated (step 4 rewritten; the "pressed/active state is enough" contract is retired). Full Release solution suite green (13,424 passed / 4 skips). Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 56 ++++ .../retail-divergence-register.md | 1 + .../2026-08-11-campaign-op-test-script.md | 34 ++- .../Platform/PlatformKeyNameProvider.cs | 43 +++ .../UI/Layout/IRetailDialogView.cs | 18 ++ .../UI/Layout/KeyboardConfigController.cs | 90 ++++++- .../UI/Layout/RetailConfirmationDialogView.cs | 2 +- src/AcDream.App/UI/Layout/RetailDialogData.cs | 11 + .../UI/Layout/RetailDialogFactory.cs | 29 +- src/AcDream.App/UI/Layout/RetailKeyNames.cs | 254 ++++++++++++++++++ .../UI/Layout/RetailWaitDialogView.cs | 105 ++++++++ src/AcDream.App/UI/RetailUiRuntime.cs | 39 ++- .../Layout/KeyboardConfigControllerTests.cs | 102 ++++++- .../KeyboardConfigLiveMountProbeTests.cs | 146 ++++++++++ .../UI/Layout/RetailDialogFactoryTests.cs | 49 ++++ .../UI/Layout/RetailKeyNamesTests.cs | 150 +++++++++++ 16 files changed, 1105 insertions(+), 24 deletions(-) create mode 100644 src/AcDream.App/Platform/PlatformKeyNameProvider.cs create mode 100644 src/AcDream.App/UI/Layout/IRetailDialogView.cs create mode 100644 src/AcDream.App/UI/Layout/RetailKeyNames.cs create mode 100644 src/AcDream.App/UI/Layout/RetailWaitDialogView.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/RetailKeyNamesTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index c312a54e..ef7bfad5 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,62 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #396 — Configure Keyboard: no capture-instruction dialog on a mapping-button click + +**Status:** ROOT-CAUSED + FIXED (this commit) — pending the user's re-gate. +Filed 2026-08-14 at the OP8 re-gate (user report: "nothing happens when I +press an option button", with the retail screenshot showing the instruction +dialog). The OP8 port armed `InputDispatcher.BeginCapture` with no visible +feedback; retail's `UIOption_ActionKeyMap::InitiateBinding @0x004899D0` +opens a type-2 WAIT dialog (`OpenMapWarnDialog @0x00488A00`: queue key +`0x10000001`, text `ID_ActionKeyMap_MapInstructions` from table `0x23000004` +with the row's action label as its ACTION variable — "The next key you press +or mouse button that you click will be mapped to the '…' action. … Press the +ESC key to cancel.") BEFORE registering the key handler, and refuses to arm +capture if the dialog cannot open. Fix: `RetailWaitDialogView` (wait root +`0x31` — same authored popup/message pair `0x3D`/`0x3E` as the confirmation +root, live-DAT probed) + `RetailDialogFactory.MakeWait` + +`KeyboardConfigController.Bindings.Open/CloseCaptureInstructions`, closed on +key hit or ESC through the capture callback. Retail's own text supplies the +ESC line; ESC handling stays in the dispatcher's modal capture (a dialog-side +cancel would race it). + +## #395 — Configure Keyboard: key captions show raw enum spellings, not retail's localized key names + +**Status:** ROOT-CAUSED + FIXED (this commit) — pending the user's re-gate. +Filed 2026-08-14 at the OP8 re-gate (user report: acdream shows +"Shift+ShiftLeft" where retail shows "SKIFT" on their Swedish layout). +`DescribeChord` printed Silk enum spellings; retail's +`CInputManager_WIN32::GetNameFromKey @0x00687F40` resolves each control +through `GetNameFromKey_Internal @0x00687800`: DAT string-table override by +ELF hash of the DIK name (key table enum 4 → `0x2300000A`, meta enum 5 → +`0x2300000B` — GetDIDByEnum category 4, live-probed; the shipped tables +author exactly `DIK_LCONTROL` → "Left Ctrl" and `DIK_LMENU` → "Left Alt"), +else the OS keyboard layout's own name, with modifier prefixes joined by the +authored `ID_KeyDescDelimiter` ("+", `0x23000007`) and a bare modifier-key +binding showing only the key name (retail's walk-mode row is meta-mode 0). +Fix: `RetailKeyNames` (the pipeline port) + `PlatformKeyNameProvider` +(Win32 `GetKeyNameTextW` — register row AD-96 for the +DirectInput-vs-GetKeyNameText adaptation and the non-Windows fallback). + +## #394 — Configure Keyboard: row captions render in the debug bitmap font, not the authored 18px serif + +**Status:** ROOT-CAUSED + FIXED (this commit) — pending the user's re-gate. +Filed 2026-08-14 at the OP8 re-gate (user side-by-side screenshot: acdream's +"Move Forward" label vs retail's serif). The controller-synthesized row +caption (`BuildActionRow`'s composed `UiText`) never set `DatFont`, so it +fell back to the debug bitmap font; the authored action-row template +(`0x21000009` element `0x1000002F`, retail type `UIOption_ActionKeyMap`) +carries `FontDid 0x4000000A` — the 18px serif retail draws the label with +(live-DAT probed: header `0x1000002E` = `0x4000000F` 30px gothic, key +buttons `0x10000030-32` = `0x40000001` 18px serif — the buttons already +resolved their authored font through the production template build; only the +synthesized caption was wrong). Fix: `Bind` takes `resolveTemplateFont`, +resolved once per template pair from the row template's own authored FontDid +and applied to the caption `UiText`. Probe evidence: +`KeyboardConfigLiveMountProbeTests.ProbeKeyboardFontsAndKeyNameStrings` +(env-gated, kept). + ## #393 — Texture detail options: retail's "High Resolution Textures" toggle + Landscape/Environment TextureDetail mip-skip **Status:** OPEN — filed 2026-08-14 from the highres-texture verification diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 3960fc2a..fc668ffe 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -188,6 +188,7 @@ readiness/requeue adaptation. See | AD-91 | **Filed 2026-08-13 at the #390 port.** acdream's display-change clamp covers ALL registered floating windows; retail's does not — every retail floaty overrides `MoveTo` with the clamp `x = max(0, min(x, parentW − selfW))` EXCEPT `gmFloatyChatUI` (floating chats 2–4), which has no clamp and can genuinely strand off-screen on a resolution change (decomp finding, `docs/research/2026-08-13-retail-ui-display-change.md`). The display block's product requirement ("UI windows must stay reachable on resolution change", the 2026-08-13 /goal) overrides the exception. | `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs` (`ClampAllToScreen` — clamps every attached handle, floating chats included) | User-directed reachability beats reproducing a retail defect-shaped gap; the clamp math itself is retail's own, applied uniformly. | A retail-parity comparison that deliberately strands a floating chat window will find acdream rescuing it where retail leaves it lost. | `UIElementManager::RefreshEvent @0x0045C530`; `UIElement::UpdateForParentSizeChange @0x00462640`; the per-floaty `MoveTo` clamp overrides; docs/research/2026-08-13-retail-ui-display-change.md | | AD-92 | **Filed 2026-08-13 at the #376/#388 review fix round (blast M6 / mechanism M4).** Two switcher adaptations with no retail counterpart: (1) the fullscreen refresh rate is the monitor's HIGHEST for the picked WxH — retail passed the device mode's own refresh as-is (`Device::ForceDisplayResolution`); (2) an invalid/unsupported fullscreen request is a logged refusal that leaves the window unchanged — retail attempted the switch and surfaced the device error. The persisted-flag divergence a refusal leaves behind is ISSUES #392. | `src/AcDream.App/Settings/DisplayModeSwitching.cs` (`TryFindRefreshRate`, the refusal paths); `src/AcDream.App/Settings/RuntimeSettingsTargets.cs` (`Apply`'s refused-mode logging) | Highest-refresh is strictly better on modern variable-refresh panels (retail predates them); refuse-and-log is #388's own no-crash requirement. | A capture comparing retail's exact chosen refresh for a mode will differ; a server/tooling flow expecting an error dialog on an invalid mode sees a console line instead. | `Device::ForceDisplayResolution @gmClient::Init 0x004047af`; docs/research/2026-08-13-376-388-{mechanism,blast}-review.md | | AD-94 | **Filed 2026-08-14 at the secure-trade feature.** Retail's `Event_AcceptTrade` payload (`Trade::Pack @0x005B9FF0`) appends two `PackableList` staged-item lists after the six fixed fields; acdream sends both as ZERO-COUNT lists. ACE parses and then discards the ENTIRE payload (`HandleActionAcceptTrade()` takes zero arguments — server trade state is fully self-derived; lane B §quirks), so the difference is unobservable against ACE; a byte-capture comparison against a real retail client would differ from offset 40. | `src/AcDream.Core.Net/Messages/TradeRequests.cs` (`BuildAcceptTrade`) | The `ContentProfile` pack layout was not byte-verified (ACE never reads it — no reader to check against), and guessing a wire struct violates the workflow; zero-count lists are well-formed `PackableList`s. | A future server that actually validates the accept echo would see empty item lists and could refuse or desync the accept. | `Trade::Pack @0x005B9FF0`; `GameActionAcceptTrade.cs:11-16`; `docs/research/2026-08-14-trade-laneB-wire.md` Table 1 | +| AD-96 | **Filed 2026-08-14 at the OP8 re-gate fix round (key-name display).** Retail's `GetNameFromKey_Internal @0x00687800` falls back from the DAT string tables (key enum 4 → `0x2300000A`, meta enum 5 → `0x2300000B`) to the OS keyboard layout's own key name via DirectInput `IDirectInputDevice8::GetObjectInfo` (`tszName` — "SKIFT" on a Swedish layout). acdream reads the SAME layout-resident name data through Win32 `GetKeyNameTextW` instead (no DirectInput device exists in-process); on non-Windows hosts there is no OS lookup at all and the DIK-suffix spelling shows (un-localized English, e.g. "LSHIFT"). Mouse chords keep the pre-existing enum spelling — retail names them through the DirectInput mouse device. | `src/AcDream.App/Platform/PlatformKeyNameProvider.cs`; `src/AcDream.App/UI/Layout/RetailKeyNames.cs` (`Describe`, the mouse-device early-out) | GetKeyNameText and DirectInput's key names both come from the active keyboard-layout tables; adding a DirectInput device solely for name strings would be a heavyweight, dead-end dependency. Linux graphical work is parked at Slice L1. | A key whose GetKeyNameTextW name differs from DirectInput's `tszName` on some layout shows a slightly different caption than retail did; Linux graphical shows English DIK-suffix names where retail-on-Wine would localize; a mouse-chord caption reads as the Silk enum, not retail's device string. | `CInputManager_WIN32::GetNameFromKey_Internal @0x00687800`; `GetNameFromKey @0x00687F40`; `ControlSpecification::GetDIKName @0x0068ACB0`; `DBCache::GetDIDFromEnumStatic` category-4 probe 2026-08-14 (`KeyboardConfigLiveMountProbeTests.ProbeKeyboardFontsAndKeyNameStrings`) | | AD-93 | **Filed 2026-08-13 at social gate round 2, item 5 (the refused-drop notice port).** Two narrow gaps in the `ServerSaysAttemptFailed @0x0058EAE0` port: (1) **latched-guid preference** — retail's 0x00A0 dispatcher (`@0x0055B342`) PREFERS `prevRequestObjectID` over the wire guid when picking the item to name; acdream's `InventoryTransactionState.OnMoveFailed` instead REQUIRES the wire guid to match the latch (unobservable against ACE, which always sends the request's own guid on 0x00A0, and it protects a stale latch from mislabeling an unrelated failure — acdream has no retail-style latch timeout). (2) **unlatched request kinds** — retail latches `IR_MOVE`/`IR_WIELD` too; acdream's kind enum has no Move/Wield rows because wields ride `AutoWieldController` outside the single-request gate, so a refused wield/3D-move shows only the generic `HandleFailureEvent` leg, never "The X can't be wielded/moved". | `src/AcDream.Core/Items/InventoryTransactionState.cs` (`OnMoveFailed`); `src/AcDream.Core/Chat/InventoryFailureMessages.cs` (`Compose`'s absent Move/Wield rows); `src/AcDream.App/UI/ItemInteractionController.cs` (`OnInventoryRequestFailed`) | The match requirement is the compensating guard for the missing latch timeout; adding Wield/Move kinds means routing those sends through the single-request gate they deliberately bypass today — a behavior change beyond this gate item. | Only observable against a server that sends 0x00A0 with a guid that differs from the request's item (ACE never does), or on a refused wield/move, which shows no "can't be wielded/moved" verb line where retail would show one. | `ACCWeenieObject::ServerSaysAttemptFailed @0x0058EAE0`; the 0x00A0 dispatcher `@0x0055B342`; `ACCWeenieObject::RecordRequest @0x0058C220`; `docs/research/2026-08-13-confirm-and-weenie-error-display.md` §2 | --- diff --git a/docs/research/2026-08-11-campaign-op-test-script.md b/docs/research/2026-08-11-campaign-op-test-script.md index bd27677b..93ab39cd 100644 --- a/docs/research/2026-08-11-campaign-op-test-script.md +++ b/docs/research/2026-08-11-campaign-op-test-script.md @@ -942,6 +942,28 @@ Three live runs against local ACE (`127.0.0.1:9000`, `+Acdream`, > Save As...), Command + Mapping 1-3 column headers, and NOTHING > rendered above or outside the framed panel. +> **Re-gate note (2026-08-14 fix round — #394/#395/#396):** three findings +> from the first OP8 look at this screen, all fixed: +> 1. **Row-caption font (#394):** the action labels ("Move Forward", …) +> now draw in the row template's authored 18px serif (FontDid +> `0x4000000A`), not the debug bitmap font. The gothic section headers +> and the key-button captions were already using their authored fonts. +> 2. **Key caption text (#395):** key buttons now show retail's localized +> key names — DAT overrides first ("Left Ctrl"/"Left Alt" are the only +> authored ones), then YOUR keyboard layout's own name (a Swedish +> layout shows "SKIFT" for the Shift key, exactly like your retail +> screenshot), modifiers joined with "+" (chords like "SKIFT+M"), and a +> binding to a bare modifier key shows just the key name — never +> "Shift+ShiftLeft". Mouse chords keep enum spellings (AD-96). +> 3. **Capture-instruction dialog (#396):** clicking a mapping button now +> opens retail's own instruction dialog ("The next key you press or +> mouse button that you click will be mapped to the '' +> action. … Press the ESC key to cancel.") — the wait-dialog shape from +> your retail screenshot, with the row's action name interpolated. It +> closes when you press the new key OR press ESC. Step 4 below is +> REWRITTEN accordingly; the old "a pressed/active state is enough" +> contract is retired. + **Known, tracked behaviors — do NOT file as defects (read before testing):** - **Shared combat keys prompt a false conflict (ISSUES #373).** Retail @@ -989,11 +1011,13 @@ line calling it INERT no longer applies. ### Rebind a movement key live 4. **On the Movement tab, find "Move Forward"** (should show two bound - keys, "W" and "Up"). Click the SECOND key button (currently "Up"). - The button should visually indicate it is listening for input - (retail's own capture-prompt text is not wired to a tooltip in this - port — a simple pressed/active state is enough to confirm capture - started). + keys — "W" and your layout's name for the up-arrow key). Click the + SECOND key button. **Retail's capture-instruction dialog opens** + (#396 re-gate): "The next key you press or mouse button that you + click will be mapped to the 'Move Forward' action. …Press the ESC + key to cancel." — naming THIS row's action. Press ESC once first: + the dialog closes and nothing changes. Click the button again to + re-open it for step 5. 5. **Press a different key**, e.g. `U`. The button should immediately update to show "U". 6. **Move your character forward using W and U** (both should now work diff --git a/src/AcDream.App/Platform/PlatformKeyNameProvider.cs b/src/AcDream.App/Platform/PlatformKeyNameProvider.cs new file mode 100644 index 00000000..93a04e0a --- /dev/null +++ b/src/AcDream.App/Platform/PlatformKeyNameProvider.cs @@ -0,0 +1,43 @@ +using System.Runtime.InteropServices; + +namespace AcDream.App.Platform; + +/// +/// Platform owner (L0 boundary — OS checks live under Platform/ only) +/// for the OS-localized key-name half of retail's +/// CInputManager_WIN32::GetNameFromKey_Internal @ 0x00687800 fallback: +/// when the DAT string tables have no authored name for a key, retail shows +/// the keyboard layout's own name ("SKIFT" on Swedish) via DirectInput's +/// IDirectInputDevice8::GetObjectInfo. This port asks Win32 +/// GetKeyNameTextW instead — the same layout-resident name data, no +/// DirectInput device needed (register row AD-96). Non-Windows hosts get no +/// OS lookup (null) and callers fall back to the DIK-suffix spelling. +/// +public static class PlatformKeyNameProvider +{ + /// (scanCode, isExtended) → localized key name, or null when the + /// current platform has no OS lookup. + public static Func? ForCurrentProcess() + => OperatingSystem.IsWindows() ? WindowsKeyName : null; + + /// + /// GetKeyNameTextW's lParam wants the hardware scan code in bits 16-23 + /// and the extended-key flag in bit 24 — the same split DirectInput's + /// DIK codes carry in bit 0x80. + /// + private static string? WindowsKeyName(byte scan, bool extended) + { + Span buffer = stackalloc char[64]; + int lParam = (scan << 16) | (extended ? 1 << 24 : 0); + int length; + unsafe + { + fixed (char* p = buffer) + length = GetKeyNameTextW(lParam, p, buffer.Length); + } + return length > 0 ? new string(buffer[..length]) : null; + } + + [DllImport("user32.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] + private static extern unsafe int GetKeyNameTextW(int lParam, char* lpString, int cchSize); +} diff --git a/src/AcDream.App/UI/Layout/IRetailDialogView.cs b/src/AcDream.App/UI/Layout/IRetailDialogView.cs new file mode 100644 index 00000000..1c37c098 --- /dev/null +++ b/src/AcDream.App/UI/Layout/IRetailDialogView.cs @@ -0,0 +1,18 @@ +namespace AcDream.App.UI.Layout; + +/// +/// One live retail dialog root under — the +/// per-type presenter contract (DialogFactory::CreateDialog_ @ 0x00477AD0 +/// creates a fresh catalog root per shown DialogInfo; the factory then +/// drives every type through the same tick/pending/teardown surface). +/// +internal interface IRetailDialogView +{ + UiDialogRoot Root { get; } + + void Tick(); + + void SetPendingCount(int count); + + void DetachHandlers(); +} diff --git a/src/AcDream.App/UI/Layout/KeyboardConfigController.cs b/src/AcDream.App/UI/Layout/KeyboardConfigController.cs index 83f86405..c1c67d33 100644 --- a/src/AcDream.App/UI/Layout/KeyboardConfigController.cs +++ b/src/AcDream.App/UI/Layout/KeyboardConfigController.cs @@ -210,13 +210,27 @@ public sealed class KeyboardConfigController // BEFORE reassigning a chord already bound to another row on this screen. // message is pre-composed (real row labels, no invented retail text); // the callback receives the user's Yes(true)/No(false) choice. - Action> ConfirmOverwrite); + Action> ConfirmOverwrite, + // OP8 re-gate (2026-08-14): retail's capture-instruction dialog — + // UIOption_ActionKeyMap::InitiateBinding @ 0x004899D0 opens the + // MapWarn wait dialog (ID_ActionKeyMap_MapInstructions with the row's + // action label interpolated) BEFORE registering the key handler, and + // refuses to arm capture at all if the dialog could not open. Open + // receives the row label and returns the dialog context (0 = could + // not open); Close closes that context when the capture ends (key hit + // or ESC). Null keeps the pre-dialog capture behavior for hosts with + // no dialog factory (unit fixtures). + Func? OpenCaptureInstructions = null, + Action? CloseCaptureInstructions = null); public OptionPage Page { get; } = new(); public IReadOnlyList Rows => _rows; private readonly List _rows = new(); + private readonly Dictionary<(uint LayoutId, uint ElementId), UiDatFont?> _templateFontCache = new(); private Bindings? _bindings; + private Func _describe = DescribeChord; + private Func? _resolveTemplateFont; private KeyboardConfigController() { } @@ -233,7 +247,8 @@ public sealed class KeyboardConfigController RetailActionMapSnapshot snapshot, Func templateResolver, Func resolveString, - Bindings bindings) + Bindings bindings, + Func? resolveTemplateFont = null) { ArgumentNullException.ThrowIfNull(layout); ArgumentNullException.ThrowIfNull(snapshot); @@ -249,7 +264,15 @@ public sealed class KeyboardConfigController return null; } - var controller = new KeyboardConfigController { _bindings = bindings }; + var controller = new KeyboardConfigController + { + _bindings = bindings, + _resolveTemplateFont = resolveTemplateFont, + // OP8 re-gate (2026-08-14): key-button captions through retail's + // GetNameFromKey pipeline (DAT table override → OS-localized name) + // instead of raw enum spellings ("Shift+ShiftLeft"). + _describe = new RetailKeyNames(resolveString).Describe, + }; var byClass = snapshot.Rows .Where(r => r.ActionClass != RetailActionClass.None) @@ -391,6 +414,12 @@ public sealed class KeyboardConfigController Padding = 2f, Anchors = AnchorEdges.Left | AnchorEdges.Top, DefaultColor = mapped ? Vector4.One : UiRenderContext.StoreOnlyCaptionColor, + // OP8 re-gate (2026-08-14): retail draws the row's action label + // with the row template's OWN authored font (UIOption_ActionKeyMap + // element 0x1000002F authors FontDid 0x4000000A, the 18px serif — + // live-DAT probed). The synthesized caption previously set no + // DatFont at all and fell back to the debug bitmap font. + DatFont = RowCaptionFont(listBox), }; if (label is not null) captionText.LinesProvider = () => new[] { new UiText.Line(label, captionText.DefaultColor) }; @@ -451,6 +480,24 @@ public sealed class KeyboardConfigController } } + /// The authored font of this ListBox's action-row template, + /// resolved once per (layout, element) pair. Null (template import or + /// font-load failure, or no resolver wired) keeps the debug-font fallback. + private UiDatFont? RowCaptionFont(UiTemplateListBox listBox) + { + if (_resolveTemplateFont is null + || RowTemplateIndex >= listBox.Templates.Count) + return null; + (uint layoutId, uint elementId) = ( + listBox.Templates[RowTemplateIndex].TemplateLayoutId, + listBox.Templates[RowTemplateIndex].TemplateElementId); + if (_templateFontCache.TryGetValue((layoutId, elementId), out UiDatFont? cached)) + return cached; + UiDatFont? font = _resolveTemplateFont(layoutId, elementId); + _templateFontCache[(layoutId, elementId)] = font; + return font; + } + private static IReadOnlyList DatDefaultsToChords(IReadOnlyList raw) { var result = new List(raw.Count); @@ -463,16 +510,19 @@ public sealed class KeyboardConfigController return result; } - private static void RefreshRowButtons(RowView view) + private void RefreshRowButtons(RowView view) { IReadOnlyList current = view.Model.Current; for (int i = 0; i < view.KeyButtons.Count; i++) { bool bound = i < current.Count && current[i] != default; - view.KeyButtons[i].Label = bound ? DescribeChord(current[i]) : null; + view.KeyButtons[i].Label = bound ? _describe(current[i]) : null; } } + /// Raw enum spelling — construction-time default until Bind swaps + /// in , and that class's own fallback + /// for controls outside the DIK table. private static string DescribeChord(KeyChord chord) { string mods = chord.Modifiers == ModifierMask.None ? "" : chord.Modifiers.ToString() + "+"; @@ -481,8 +531,28 @@ public sealed class KeyboardConfigController private void BeginSlotCapture(RowView view, int slot, Bindings bindings) { + // Retail InitiateBinding @ 0x004899D0: open the capture-instruction + // dialog (ID_ActionKeyMap_MapInstructions with this row's label) and + // register the input handler ONLY if the dialog opened. A host with no + // dialog seam wired (null) keeps the dialog-less capture. + uint instructionsContext = 0u; + if (bindings.OpenCaptureInstructions is { } openInstructions) + { + instructionsContext = openInstructions(view.Label ?? string.Empty); + if (instructionsContext == 0u) + { + Console.WriteLine( + "[D.2b] KeyboardConfigController: capture-instruction dialog " + + "could not open — capture not armed (retail refuses too)."); + return; + } + } + bindings.BeginCapture(captured => { + if (instructionsContext != 0u) + bindings.CloseCaptureInstructions?.Invoke(instructionsContext); + if (captured is not { } chord) return; // Escape — retail cancels silently. (ConflictOutcome outcome, List conflictRows) = FindConflicts(chord, exclude: view); @@ -504,7 +574,7 @@ public sealed class KeyboardConfigController // the first). Only on accept do the losing rows lose the slot. string names = string.Join(", ", conflictRows.Select(r => r.Label ?? "?")); string message = - $"'{DescribeChord(chord)}' is already bound to {names}. " + $"'{_describe(chord)}' is already bound to {names}. " + $"Reassign it to '{view.Label}'?"; bindings.ConfirmOverwrite(message, accepted => { @@ -525,7 +595,7 @@ public sealed class KeyboardConfigController }); } - private static void ApplySlot(RowView view, int slot, KeyChord chord) + private void ApplySlot(RowView view, int slot, KeyChord chord) { List updated = new(view.Model.Current); while (updated.Count <= slot) updated.Add(default); @@ -621,7 +691,7 @@ public sealed class KeyboardConfigController row.Model.SetDefaultValue(row.Model.DefaultValue); controller.Page.Defaults(); foreach (RowView row in controller._rows) - RefreshRowButtons(row); + controller.RefreshRowButtons(row); }; if (layout.FindElement(RevertButtonId) is UiButton revertButton) @@ -629,7 +699,7 @@ public sealed class KeyboardConfigController { controller.Page.Reset(); foreach (RowView row in controller._rows) - RefreshRowButtons(row); + controller.RefreshRowButtons(row); }; // OK — right-click release in retail (idMessage 0x19); ported as a plain @@ -650,7 +720,7 @@ public sealed class KeyboardConfigController { controller.Page.Reset(); foreach (RowView row in controller._rows) - RefreshRowButtons(row); + controller.RefreshRowButtons(row); bindings.Toggle(); }; } diff --git a/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs b/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs index 4707dbb9..71cc4673 100644 --- a/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs +++ b/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs @@ -5,7 +5,7 @@ namespace AcDream.App.UI.Layout; /// DialogFactory::CreateDialog_ @ 0x00477AD0 creates a fresh catalog root for /// every displayed DialogInfo. /// -internal sealed class RetailConfirmationDialogView +internal sealed class RetailConfirmationDialogView : IRetailDialogView { public const uint RootElementId = 0x15u; public const uint AcceptButtonId = 0x17u; diff --git a/src/AcDream.App/UI/Layout/RetailDialogData.cs b/src/AcDream.App/UI/Layout/RetailDialogData.cs index debec420..72512826 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogData.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogData.cs @@ -112,4 +112,15 @@ public sealed class RetailDialogData .Set(RetailDialogProperty.Type, RetailDialogType.Confirmation) .Set(RetailDialogProperty.Message, message); } + + /// Type-2 wait dialog data — text-only, no buttons. Sets element + /// attribute 0x40 like OpenMapWarnDialog @ 0x00488A00 does. + public static RetailDialogData Wait(string message) + { + ArgumentNullException.ThrowIfNull(message); + return new RetailDialogData() + .Set(RetailDialogProperty.Type, RetailDialogType.Wait) + .Set(RetailDialogProperty.ElementAttribute40, true) + .Set(RetailDialogProperty.Message, message); + } } diff --git a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs index 1c2e2a90..554c525c 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs @@ -16,7 +16,7 @@ public sealed class RetailDialogFactory : IDisposable public required uint Context { get; init; } public required uint QueueKey { get; init; } public Action? Callback { get; init; } - public RetailConfirmationDialogView? View { get; set; } + public IRetailDialogView? View { get; set; } } private readonly UiRoot _host; @@ -135,6 +135,19 @@ public sealed class RetailDialogFactory : IDisposable return MakeDialog(data, callback); } + /// + /// Text-only wait dialog (type 2), closed by the caller via + /// . Property shape mirrors + /// UIOption_ActionKeyMap::OpenMapWarnDialog @ 0x00488A00: type 2, + /// caller-chosen queue key, element attribute 0x40 set, message text. + /// + public uint MakeWait(string message, uint queueKey = DefaultQueueKey) + { + RetailDialogData data = RetailDialogData.Wait(message) + .Set(RetailDialogProperty.QueueKey, queueKey); + return MakeDialog(data, callback: null); + } + /// /// Retail CloseDialog @ 0x00478160. The context can identify an active /// nonqueued dialog, an active queued dialog, or an item still pending in a queue. @@ -262,16 +275,20 @@ public sealed class RetailDialogFactory : IDisposable private void CreateDialog(DialogInfo info) { RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type); - if (type != RetailDialogType.Confirmation) + if (type is not (RetailDialogType.Confirmation or RetailDialogType.Wait)) throw new NotSupportedException( $"Retail dialog type {(uint)type} does not have a ported presenter yet."); ImportedLayout layout = _createLayout(type) ?? throw new InvalidOperationException( $"Retail dialog catalog could not create type {(uint)type}."); - var view = new RetailConfirmationDialogView( - _host, layout, info.Data, info.Context, - context => CloseDialog(context)); + IRetailDialogView view = type switch + { + RetailDialogType.Wait => new RetailWaitDialogView(_host, layout, info.Data), + _ => new RetailConfirmationDialogView( + _host, layout, info.Data, info.Context, + context => CloseDialog(context)), + }; info.View = view; _host.AddChild(view.Root); _host.BringToFront(view.Root); @@ -285,7 +302,7 @@ public sealed class RetailDialogFactory : IDisposable { if (info.View is null) return; - RetailConfirmationDialogView view = info.View; + IRetailDialogView view = info.View; info.View = null; view.DetachHandlers(); _openOrder.Remove(info); diff --git a/src/AcDream.App/UI/Layout/RetailKeyNames.cs b/src/AcDream.App/UI/Layout/RetailKeyNames.cs new file mode 100644 index 00000000..770bb90b --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailKeyNames.cs @@ -0,0 +1,254 @@ +using AcDream.App.Platform; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.App.UI.Layout; + +/// +/// Campaign OP slice OP8 re-gate (2026-08-14): retail's key-binding display +/// names — the port of CInputManager_WIN32::GetNameFromKey @ 0x00687F40 +/// (QualifiedControl overload) over +/// GetNameFromKey_Internal @ 0x00687800. +/// +/// +/// Retail's name for one bound control resolves in this order: +/// (1) the authored DAT string table, keyed by the ELF hash of the control's +/// DirectInput name (ControlSpecification::GetDIKName @ 0x0068ACB0 — +/// "DIK_W", "DIK_LSHIFT", ...): plain keys through string-table enum 4 and +/// meta keys through enum 5 (DBCache::GetDIDFromEnumStatic category 4 +/// resolves those to DIDs 0x2300000A / 0x2300000B on the shipped +/// dats — live-probed 2026-08-14; the shipped tables author overrides for +/// exactly DIK_LCONTROL → "Left Ctrl" and DIK_LMENU → "Left Alt"); +/// (2) the OS's own localized key name — retail asks DirectInput +/// (IDirectInputDevice8::GetObjectInfo, DIPH_BYOFFSET) and shows +/// tszName, which is why a Swedish layout displays "SKIFT". This port +/// asks Win32 GetKeyNameTextW through +/// instead — DirectInput's key names +/// come from the identical keyboard-layout data, and acdream has no +/// DirectInput device (register row AD-96). On non-Windows hosts the OS half +/// is unavailable and the DIK-suffix spelling is the honest fallback (same +/// register row). +/// +/// +/// +/// A chord's modifier prefix comes from the QualifiedControl overload: each +/// set meta-mode bit, ascending (Shift=1, Ctrl=2, Alt=4 — the shipped +/// keymap's own Metakeys header maps those bits to DIK_LSHIFT / DIK_LCONTROL +/// / DIK_LMENU), names its METAKEY through the meta table + OS fallback and +/// joins with the authored ID_KeyDescDelimiter ("+", table enum 3 → +/// DID 0x23000007). A binding whose KEY IS a modifier key (retail's +/// walk-mode DIK_LSHIFT row has meta-mode 0; acdream's +/// carries the wire-side self-modifier bit) shows only the key name — never +/// "Shift+ShiftLeft". +/// +/// +public sealed class RetailKeyNames +{ + /// String-table enum 4 via GetDIDFromEnumStatic category 4. + public const uint KeyNameTableId = 0x2300000Au; + + /// String-table enum 5 via GetDIDFromEnumStatic category 4. + public const uint MetaKeyNameTableId = 0x2300000Bu; + + /// String-table enum 3 via GetDIDFromEnumStatic category 4 — + /// holds ID_KeyDescDelimiter. + public const uint DelimiterTableId = 0x23000007u; + + private readonly Func _resolveString; + private readonly Func? _osKeyName; + private readonly string _delimiter; + + /// DAT string lookup — (tableDid, stringHash) → + /// text, the same seam KeyboardConfigController already receives. + /// OS-localized key-name lookup — (scanCode, + /// isExtended) → name. Null selects + /// (Win32 + /// GetKeyNameTextW on Windows, no OS lookup elsewhere). Tests + /// inject deterministic fakes here. + public RetailKeyNames( + Func resolveString, + Func? osKeyName = null) + { + _resolveString = resolveString + ?? throw new ArgumentNullException(nameof(resolveString)); + _osKeyName = osKeyName ?? PlatformKeyNameProvider.ForCurrentProcess(); + _delimiter = resolveString( + DelimiterTableId, DatStringResolver.ComputeHash("ID_KeyDescDelimiter")) + ?? "+"; + } + + /// + /// Display name for one bound chord — retail + /// GetNameFromKey(QualifiedControl). Mouse chords keep the + /// pre-existing enum spelling: retail names mouse controls through the + /// DirectInput mouse device, which this port does not have (AD-95a). + /// + public string Describe(KeyChord chord) + { + if (chord == default) + return string.Empty; + if (!TryGetDik(chord.Key, out byte dik, out string? dikName)) + return FallbackSpelling(chord); + + var composed = new System.Text.StringBuilder(); + // Meta-mode bits ascending, skipping the key's own self-modifier bit + // (retail's walk-mode LSHIFT row carries meta-mode 0 on the wire; the + // chord's stored self bit is acdream's encoding, not display truth). + foreach ((ModifierMask flag, Key metaKey) in MetaOrder) + { + if ((chord.Modifiers & flag) == 0 || IsSelfModifier(chord.Key, flag)) + continue; + if (!TryGetDik(metaKey, out byte metaDik, out string? metaDikName)) + continue; + composed.Append(LookupName(metaDikName!, metaDik, MetaKeyNameTableId)); + composed.Append(_delimiter); + } + + composed.Append(LookupName(dikName!, dik, KeyNameTableId)); + return composed.ToString(); + } + + private string LookupName(string dikName, byte dik, uint tableId) + => _resolveString(tableId, DatStringResolver.ComputeHash(dikName)) + ?? _osKeyName?.Invoke((byte)(dik & 0x7F), (dik & 0x80) != 0) + ?? dikName["DIK_".Length..]; + + private static string FallbackSpelling(KeyChord chord) + { + string mods = chord.Modifiers == ModifierMask.None + ? "" + : chord.Modifiers.ToString() + "+"; + return mods + chord.Key; + } + + private static readonly (ModifierMask Flag, Key MetaKey)[] MetaOrder = + { + (ModifierMask.Shift, Key.ShiftLeft), + (ModifierMask.Ctrl, Key.ControlLeft), + (ModifierMask.Alt, Key.AltLeft), + }; + + private static bool IsSelfModifier(Key key, ModifierMask flag) + => flag switch + { + ModifierMask.Shift => key is Key.ShiftLeft or Key.ShiftRight, + ModifierMask.Ctrl => key is Key.ControlLeft or Key.ControlRight, + ModifierMask.Alt => key is Key.AltLeft or Key.AltRight, + _ => false, + }; + + /// + /// Silk key → DirectInput scan code + DIK name — the reverse of + /// 's keyboard table (same 84 + /// DAT-observed codes) plus the modifier keys live capture can produce + /// that no DAT default binds directly (DIK_LCONTROL 0x1D, DIK_LMENU 0x38, + /// DIK_RMENU 0xB8). DIK codes with bit 0x80 are the extended set — the + /// same split Win32's GetKeyNameText expects in bit 24. + /// + private static bool TryGetDik(Key key, out byte dik, out string? name) + { + (dik, name) = key switch + { + Key.Escape => ((byte)0x01, "DIK_ESCAPE"), + Key.Number1 => ((byte)0x02, "DIK_1"), + Key.Number2 => ((byte)0x03, "DIK_2"), + Key.Number3 => ((byte)0x04, "DIK_3"), + Key.Number4 => ((byte)0x05, "DIK_4"), + Key.Number5 => ((byte)0x06, "DIK_5"), + Key.Number6 => ((byte)0x07, "DIK_6"), + Key.Number7 => ((byte)0x08, "DIK_7"), + Key.Number8 => ((byte)0x09, "DIK_8"), + Key.Number9 => ((byte)0x0A, "DIK_9"), + Key.Number0 => ((byte)0x0B, "DIK_0"), + Key.Minus => ((byte)0x0C, "DIK_MINUS"), + Key.Equal => ((byte)0x0D, "DIK_EQUALS"), + Key.Backspace => ((byte)0x0E, "DIK_BACK"), + Key.Tab => ((byte)0x0F, "DIK_TAB"), + Key.Q => ((byte)0x10, "DIK_Q"), + Key.W => ((byte)0x11, "DIK_W"), + Key.E => ((byte)0x12, "DIK_E"), + Key.R => ((byte)0x13, "DIK_R"), + Key.T => ((byte)0x14, "DIK_T"), + Key.Y => ((byte)0x15, "DIK_Y"), + Key.U => ((byte)0x16, "DIK_U"), + Key.I => ((byte)0x17, "DIK_I"), + Key.O => ((byte)0x18, "DIK_O"), + Key.P => ((byte)0x19, "DIK_P"), + Key.LeftBracket => ((byte)0x1A, "DIK_LBRACKET"), + Key.RightBracket => ((byte)0x1B, "DIK_RBRACKET"), + Key.Enter => ((byte)0x1C, "DIK_RETURN"), + Key.ControlLeft => ((byte)0x1D, "DIK_LCONTROL"), + Key.A => ((byte)0x1E, "DIK_A"), + Key.S => ((byte)0x1F, "DIK_S"), + Key.D => ((byte)0x20, "DIK_D"), + Key.F => ((byte)0x21, "DIK_F"), + Key.G => ((byte)0x22, "DIK_G"), + Key.H => ((byte)0x23, "DIK_H"), + Key.J => ((byte)0x24, "DIK_J"), + Key.K => ((byte)0x25, "DIK_K"), + Key.L => ((byte)0x26, "DIK_L"), + Key.Semicolon => ((byte)0x27, "DIK_SEMICOLON"), + Key.Apostrophe => ((byte)0x28, "DIK_APOSTROPHE"), + Key.GraveAccent => ((byte)0x29, "DIK_GRAVE"), + Key.ShiftLeft => ((byte)0x2A, "DIK_LSHIFT"), + Key.BackSlash => ((byte)0x2B, "DIK_BACKSLASH"), + Key.Z => ((byte)0x2C, "DIK_Z"), + Key.X => ((byte)0x2D, "DIK_X"), + Key.C => ((byte)0x2E, "DIK_C"), + Key.V => ((byte)0x2F, "DIK_V"), + Key.B => ((byte)0x30, "DIK_B"), + Key.N => ((byte)0x31, "DIK_N"), + Key.M => ((byte)0x32, "DIK_M"), + Key.Comma => ((byte)0x33, "DIK_COMMA"), + Key.Period => ((byte)0x34, "DIK_PERIOD"), + Key.Slash => ((byte)0x35, "DIK_SLASH"), + Key.ShiftRight => ((byte)0x36, "DIK_RSHIFT"), + Key.KeypadMultiply => ((byte)0x37, "DIK_MULTIPLY"), + Key.AltLeft => ((byte)0x38, "DIK_LMENU"), + Key.Space => ((byte)0x39, "DIK_SPACE"), + Key.F1 => ((byte)0x3B, "DIK_F1"), + Key.F2 => ((byte)0x3C, "DIK_F2"), + Key.F3 => ((byte)0x3D, "DIK_F3"), + Key.F4 => ((byte)0x3E, "DIK_F4"), + Key.F5 => ((byte)0x3F, "DIK_F5"), + Key.F6 => ((byte)0x40, "DIK_F6"), + Key.F7 => ((byte)0x41, "DIK_F7"), + Key.F8 => ((byte)0x42, "DIK_F8"), + Key.F9 => ((byte)0x43, "DIK_F9"), + Key.F10 => ((byte)0x44, "DIK_F10"), + Key.NumLock => ((byte)0x45, "DIK_NUMLOCK"), + Key.ScrollLock => ((byte)0x46, "DIK_SCROLL"), + Key.Keypad7 => ((byte)0x47, "DIK_NUMPAD7"), + Key.Keypad8 => ((byte)0x48, "DIK_NUMPAD8"), + Key.Keypad9 => ((byte)0x49, "DIK_NUMPAD9"), + Key.KeypadSubtract => ((byte)0x4A, "DIK_SUBTRACT"), + Key.Keypad4 => ((byte)0x4B, "DIK_NUMPAD4"), + Key.Keypad5 => ((byte)0x4C, "DIK_NUMPAD5"), + Key.Keypad6 => ((byte)0x4D, "DIK_NUMPAD6"), + Key.KeypadAdd => ((byte)0x4E, "DIK_ADD"), + Key.Keypad1 => ((byte)0x4F, "DIK_NUMPAD1"), + Key.Keypad2 => ((byte)0x50, "DIK_NUMPAD2"), + Key.Keypad3 => ((byte)0x51, "DIK_NUMPAD3"), + Key.Keypad0 => ((byte)0x52, "DIK_NUMPAD0"), + Key.KeypadDecimal => ((byte)0x53, "DIK_DECIMAL"), + Key.F11 => ((byte)0x57, "DIK_F11"), + Key.F12 => ((byte)0x58, "DIK_F12"), + Key.KeypadEnter => ((byte)0x9C, "DIK_NUMPADENTER"), + Key.ControlRight => ((byte)0x9D, "DIK_RCONTROL"), + Key.KeypadDivide => ((byte)0xB5, "DIK_DIVIDE"), + Key.AltRight => ((byte)0xB8, "DIK_RMENU"), + Key.Home => ((byte)0xC7, "DIK_HOME"), + Key.Up => ((byte)0xC8, "DIK_UP"), + Key.PageUp => ((byte)0xC9, "DIK_PRIOR"), + Key.Left => ((byte)0xCB, "DIK_LEFT"), + Key.Right => ((byte)0xCD, "DIK_RIGHT"), + Key.End => ((byte)0xCF, "DIK_END"), + Key.Down => ((byte)0xD0, "DIK_DOWN"), + Key.PageDown => ((byte)0xD1, "DIK_NEXT"), + Key.Insert => ((byte)0xD2, "DIK_INSERT"), + Key.Delete => ((byte)0xD3, "DIK_DELETE"), + _ => ((byte)0, null), + }; + return name is not null; + } +} diff --git a/src/AcDream.App/UI/Layout/RetailWaitDialogView.cs b/src/AcDream.App/UI/Layout/RetailWaitDialogView.cs new file mode 100644 index 00000000..39355361 --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailWaitDialogView.cs @@ -0,0 +1,105 @@ +namespace AcDream.App.UI.Layout; + +/// +/// One live type-2 retail wait-dialog root (WaitDialog, catalog root +/// 0x31 per DialogFactory::CreateDialog_ @ 0x00477AD0): a +/// text-only modal with no buttons, closed programmatically by whoever opened +/// it. The shipped catalog authors the SAME popup/message child ids as the +/// confirmation root (popup 0x3D, message text 0x3E — live-DAT +/// probed 2026-08-14; the committed dialogs_2100003C.json fixture only +/// carries the confirmation subtree). First consumer: the Configure Keyboard +/// capture-instruction dialog (UIOption_ActionKeyMap::InitiateBinding +/// @ 0x004899D0OpenMapWarnDialog @ 0x00488A00). +/// +/// +/// No cancel wiring on purpose: retail's MapWarn flow routes ESC through the +/// registered input handler (KeyHitHandler), not the dialog — in this +/// port the InputDispatcher's modal capture sees ESC and the capture +/// callback closes the dialog, so a dialog-side ESC path would race it. +/// +/// +internal sealed class RetailWaitDialogView : IRetailDialogView +{ + public const uint RootElementId = 0x31u; + public const uint PopupElementId = 0x3Du; + public const uint MessageElementId = 0x3Eu; + + private readonly UiRoot _host; + private readonly UiElement _popup; + private readonly UiText _message; + private readonly float _basePopupHeight; + private readonly float _baseMessageHeight; + + public RetailWaitDialogView( + UiRoot host, + ImportedLayout layout, + RetailDialogData data) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(data); + + Root = layout.Root as UiDialogRoot + ?? throw new ArgumentException("Wait layout root is not a UiDialogRoot.", nameof(layout)); + _popup = layout.FindElement(PopupElementId) + ?? throw new ArgumentException("Wait layout is missing popup element 0x3D.", nameof(layout)); + _message = layout.FindElement(MessageElementId) as UiText + ?? throw new ArgumentException("Wait layout is missing text element 0x3E.", nameof(layout)); + + _basePopupHeight = _popup.Height; + _baseMessageHeight = _message.Height; + _popup.LayoutPolicy = null; + _popup.Anchors = AnchorEdges.None; + _message.LayoutPolicy = null; + _message.Anchors = AnchorEdges.None; + _message.Padding = 0f; + _message.Selectable = false; + + SetMessage(data.GetString(RetailDialogProperty.Message) ?? string.Empty); + SizeAndCenter(); + } + + public UiDialogRoot Root { get; } + + public void Tick() => SizeAndCenter(); + + public void SetPendingCount(int count) + { + // The wait root authors no pending-count display children (0x33/0x34 + // exist only under the confirmation root in the shipped catalog). + } + + public void DetachHandlers() + { + // No interactive handlers to detach — text-only, closed by owner. + } + + private void SetMessage(string text) + { + // Same wrap-and-grow shape as the confirmation view: retail's + // Dialog::SetData drives the shared popup/message pair for every type. + float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding); + Func measure = _message.DatFont is { } font + ? font.MeasureWidth + : static value => value.Length * 8f; + IReadOnlyList wrapped = UiText.WrapWords(text, measure, maximumWidth); + var lines = new UiText.Line[wrapped.Count]; + for (int i = 0; i < wrapped.Count; i++) + lines[i] = new UiText.Line(wrapped[i], _message.DefaultColor); + _message.LinesProvider = () => lines; + + float lineHeight = _message.DatFont?.LineHeight ?? 16f; + _message.Height = Math.Max(_baseMessageHeight, lines.Length * lineHeight); + _popup.Height = _basePopupHeight + (_message.Height - _baseMessageHeight); + } + + private void SizeAndCenter() + { + Root.Left = 0f; + Root.Top = 0f; + Root.Width = _host.Width; + Root.Height = _host.Height; + _popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f); + _popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f); + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index f40235b3..b666940e 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -2628,7 +2628,44 @@ public sealed class RetailUiRuntime : IDisposable DialogFactory.MakeConfirmation( message, data => onResult(data.GetBoolean(RetailDialogProperty.ConfirmationResult))); - })); + }, + // OP8 re-gate (2026-08-14): retail's capture-instruction dialog + // (InitiateBinding @ 0x004899D0 → OpenMapWarnDialog @ 0x00488A00): + // a type-2 wait dialog on retail's own MapWarn queue key + // 0x10000001, text = ID_ActionKeyMap_MapInstructions (table + // 0x23000004) with the row's action label as its one ACTION + // variable. Same lazy DialogFactory read as ConfirmOverwrite. + OpenCaptureInstructions: actionLabel => + { + if (DialogFactory is null) return 0u; + string? text = strings.ResolveTemplate( + 0x23000004u, + "ID_ActionKeyMap_MapInstructions", + new Dictionary + { + [DatStringResolver.ComputeHash("ACTION")] = actionLabel, + }); + if (text is null) return 0u; // no invented English + // The authored text stores its blank line as a literal + // "\n\n" two-character escape (live-probed) — same + // convention DatWidgetFactory/IndicatorDetailText already + // unescape for other DAT-authored strings. + text = text.Replace("\\n", "\n", StringComparison.Ordinal); + return DialogFactory.MakeWait(text, queueKey: 0x10000001u); + }, + CloseCaptureInstructions: context => + DialogFactory?.CloseDialog(context)), + resolveTemplateFont: (templateLayoutId, templateElementId) => + { + lock (_bindings.Assets.DatLock) + { + ElementInfo? templateInfo = LayoutImporter.ImportInfos( + _bindings.Assets.Dats, templateLayoutId, templateElementId); + return templateInfo is null || templateInfo.FontDid == 0u + ? null + : _bindings.Assets.ResolveFont(templateInfo.FontDid); + } + }); if (controller is null) { diff --git a/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigControllerTests.cs index 894c7f56..f7c50a22 100644 --- a/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigControllerTests.cs @@ -68,6 +68,10 @@ public sealed class KeyboardConfigControllerTests public int ToggleCalls { get; private set; } public Action? PendingCapture { get; private set; } public (string Message, Action OnResult)? PendingConfirm { get; private set; } + public List InstructionOpens { get; } = new(); + public List InstructionCloses { get; } = new(); + public uint NextInstructionContext { get; set; } = 7u; + public bool WireInstructions { get; set; } public void Capture(KeyChord? chord) { @@ -101,7 +105,15 @@ public sealed class KeyboardConfigControllerTests Toggle: () => ToggleCalls++, DisplaySystemMessage: msg => Messages.Add(msg), NonBindableRefusalText: "cannot overwrite", - ConfirmOverwrite: (message, onResult) => PendingConfirm = (message, onResult)); + ConfirmOverwrite: (message, onResult) => PendingConfirm = (message, onResult), + OpenCaptureInstructions: WireInstructions + ? label => + { + InstructionOpens.Add(label); + return NextInstructionContext; + } + : null, + CloseCaptureInstructions: context => InstructionCloses.Add(context)); } private static readonly KeyChord ChordW = new(Silk.NET.Input.Key.W, ModifierMask.None); @@ -270,6 +282,94 @@ public sealed class KeyboardConfigControllerTests Assert.Empty(fake.MappedSets); } + // OP8 re-gate (2026-08-14): retail InitiateBinding @ 0x004899D0 opens the + // capture-instruction wait dialog before arming the key handler, closes it + // when the capture ends (key or ESC), and refuses to arm at all when the + // dialog could not open. + [Fact] + public void KeyButtonClick_OpensInstructionDialog_AndClosesOnCapturedKey() + { + var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) }); + var fake = new FakeBindings { WireInstructions = true, NextInstructionContext = 42u }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + + KeyboardConfigController.RowView row = controller.Rows.Single(); + row.KeyButtons[0].OnClick!.Invoke(); + + Assert.Single(fake.InstructionOpens); + Assert.NotNull(fake.PendingCapture); + Assert.Empty(fake.InstructionCloses); + + fake.Capture(ChordW); + Assert.Equal(new[] { 42u }, fake.InstructionCloses); + Assert.Contains(ChordW, row.Model.Current); + } + + [Fact] + public void KeyButtonClick_EscapeCapture_StillClosesInstructionDialog() + { + var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) }); + var fake = new FakeBindings { WireInstructions = true, NextInstructionContext = 9u }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + + KeyboardConfigController.RowView row = controller.Rows.Single(); + row.KeyButtons[0].OnClick!.Invoke(); + fake.Capture(null); // Escape sentinel + + Assert.Equal(new[] { 9u }, fake.InstructionCloses); + Assert.Empty(fake.MappedSets); + } + + [Fact] + public void KeyButtonClick_InstructionDialogUnavailable_DoesNotArmCapture() + { + var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) }); + var fake = new FakeBindings { WireInstructions = true, NextInstructionContext = 0u }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + + KeyboardConfigController.RowView row = controller.Rows.Single(); + row.KeyButtons[0].OnClick!.Invoke(); + + // Retail refuses to register the input handler when OpenMapWarnDialog + // fails; the capture must not be armed either. + Assert.Null(fake.PendingCapture); + Assert.Empty(fake.InstructionCloses); + } + + [Fact] + public void Bind_ResolvesTheRowTemplatesAuthoredCaptionFont_OncePerTemplate() + { + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x4, 0x29, RetailActionClass.Movement), + Row(0x4, 0x2A, RetailActionClass.Movement), + }); + var fake = new FakeBindings(); + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + var requests = new List<(uint LayoutId, uint ElementId)>(); + KeyboardConfigController? controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings(), + resolveTemplateFont: (layoutId, elementId) => + { + requests.Add((layoutId, elementId)); + return null; // UiDatFont needs GPU atlases — the call contract is the assertion. + }); + + Assert.NotNull(controller); + // The authored action-row template (0x21000009 element 0x1000002F, + // FontDid 0x4000000A — live-DAT probed 2026-08-14) is resolved exactly + // once, not once per row: the per-template cache absorbs row N > 1. + (uint LayoutId, uint ElementId) single = Assert.Single(requests); + Assert.Equal(KeyboardConfigController.LayoutId, single.LayoutId); + Assert.Equal(0x1000002Fu, single.ElementId); + } + [Fact] public void KeyButtonRightClick_ErasesThatSlot() { diff --git a/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigLiveMountProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigLiveMountProbeTests.cs index b29d7a92..2f5cf1b8 100644 --- a/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigLiveMountProbeTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigLiveMountProbeTests.cs @@ -143,6 +143,152 @@ public sealed class KeyboardConfigLiveMountProbeTests } } + /// + /// TEMPORARY OP8 re-gate probe (2026-08-14): three user findings — wrong + /// row/button fonts, raw enum key captions ("Shift+ShiftLeft" vs retail's + /// OS-localized "SKIFT"), and no capture-instruction dialog. This dumps the + /// facts the fixes need from the INSTALLED dat: (a) authored FontDids on + /// the header/row templates + key buttons and the Font DBObj metrics behind + /// them, (b) which font DIDs the production template build actually + /// requests, (c) which string table holds ID_ActionKeyMap_MapInstructions / + /// ID_KeyDescDelimiter / ID_KeyNameWithSubControl and the DIK_* key names + /// retail's GetNameFromKey_Internal @0x687800 looks up by hash. + /// + [Fact] + public void ProbeKeyboardFontsAndKeyNameStrings() + { + if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1") + return; + + var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + using var dats = new DatCollection(datDir, DatAccessType.Read); + var strings = new DatStringResolver(dats); + + // (a) Font DBObj metrics for every DID the fixture shows in the + // template region (+ the 0x40000000 default the mount falls back to). + foreach (uint did in new[] { 0x40000000u, 0x40000001u, 0x4000000Au, 0x4000000Fu }) + { + if (dats.TryGet(did, out var font) && font is not null) + Console.WriteLine( + $"[kbfont] font 0x{did:X8} MaxCharHeight={font.MaxCharHeight} " + + $"glyphs={font.CharDescs.Count} fg=0x{font.ForegroundSurfaceDataId:X8} " + + $"bg=0x{font.BackgroundSurfaceDataId:X8}"); + else + Console.WriteLine($"[kbfont] font 0x{did:X8} -> MISSING from dat"); + } + + // Authored FontDids on the live-imported templates. + foreach (uint templateId in new[] { 0x1000002Eu, 0x1000002Fu }) + { + ElementInfo? tInfo = LayoutImporter.ImportInfos( + dats, KeyboardConfigController.LayoutId, templateId); + if (tInfo is null) + { + Console.WriteLine($"[kbfont] template 0x{templateId:X8} -> IMPORT MISSING"); + continue; + } + DumpFontDids(tInfo, 0); + } + + // (b) Which font DIDs the production-shaped template build requests. + { + ElementInfo? rowInfo = LayoutImporter.ImportInfos( + dats, KeyboardConfigController.LayoutId, 0x1000002Fu); + Assert.NotNull(rowInfo); + var requested = new List(); + UiElement built = LayoutImporter.Build( + rowInfo!, _ => (0u, 0, 0), null, + did => { requested.Add(did); return null; }, + strings.Resolve).Root; + Console.WriteLine( + "[kbfont] row-template build requested fonts: " + + string.Join(", ", requested.Select(d => $"0x{d:X8}"))); + foreach (uint keyBtn in new[] { 0x10000030u, 0x10000031u, 0x10000032u }) + { + if (UiElement.FindDescendant(built, keyBtn) is UiButton b) + Console.WriteLine( + $"[kbfont] key-button 0x{keyBtn:X8} LabelFont={(b.LabelFont is null ? "" : "set")}"); + } + } + + // (c) String sweep: which table answers the hashes retail uses. + string[] keys = + { + "ID_ActionKeyMap_MapInstructions", + "ID_KeyDescDelimiter", + "ID_KeyNameWithSubControl", + "ID_KeyMapCantOverwriteReadOnlyKeymap_Label", + "DIK_W", "DIK_X", "DIK_S", "DIK_LSHIFT", "DIK_UP", "DIK_LCONTROL", + "DIK_LMENU", "DIK_RSHIFT", "DIK_RCONTROL", "DIK_RMENU", + "DIK_NUMPADENTER", "DIK_DELETE", "DIK_INSERT", "DIK_PRIOR", "DIK_NEXT", + "MOUSE_B1", "SHIFT", "CTRL", "ALT", + }; + for (uint table = 0x23000001u; table <= 0x2300000Cu; table++) + { + DatReaderWriter.DBObjs.StringTable? st = null; + try { st = dats.Get(table); } + catch { /* absent table id — sweep continues */ } + if (st is null) continue; + foreach (string key in keys) + { + if (!st.Strings.TryGetValue(DatStringResolver.ComputeHash(key), out var entry) + || entry.Strings.Count == 0) + continue; + string fragments = string.Join( + "¦", entry.Strings.Select(s => s.Value)); + string variables = entry.Variables.Count == 0 + ? "" + : " vars=[" + string.Join(",", entry.Variables.Select(v => $"0x{v:X8}")) + "]"; + Console.WriteLine( + $"[kbstr] table 0x{table:X8} '{key}' -> '{fragments}'{variables}"); + } + } + + // Candidate variable-name hashes for the MapInstructions template slot. + foreach (string candidate in new[] { "ACTION", "NAME", "KEY", "SUBCONTROL", "PLAYER", "COMMAND" }) + Console.WriteLine( + $"[kbstr] hash('{candidate}') = 0x{DatStringResolver.ComputeHash(candidate):X8}"); + + // GetDIDByEnum sweep: which category/enum resolves the string-table + // DIDs retail's GetNameFromKey_Internal passes as "table enum" 4/5 + // (and InitiateBinding's 0x10000004)? + foreach (uint category in new uint[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }) + { + foreach (uint enumValue in new uint[] { 3, 4, 5, 0x10000004 }) + { + uint did = AcDream.Content.RetailDataIdResolver.Resolve(dats, enumValue, category); + if (did != 0) + Console.WriteLine( + $"[kbenum] category={category} enum=0x{enumValue:X} -> DID 0x{did:X8}"); + } + } + + // The Wait dialog (retail MapWarn/capture-instruction dialog, type 2, + // root 0x31 per DialogFactory::CreateDialog_ @ 0x00477AD0) — the + // committed dialogs_2100003C.json fixture only carries the + // confirmation root, so dump the wait root's authored subtree here. + { + ElementInfo? waitInfo = LayoutImporter.ImportInfos(dats, 0x2100003Cu, 0x31u); + if (waitInfo is null) + Console.WriteLine("[kbwait] wait root 0x31 -> IMPORT MISSING from 0x2100003C"); + else + DumpFontDids(waitInfo, 0); + } + } + + private static void DumpFontDids(ElementInfo info, int depth) + { + Console.WriteLine( + $"[kbfont] {new string(' ', depth * 2)}0x{info.Id:X8} type={info.Type} " + + $"FontDid=0x{info.FontDid:X8} rect=({info.X},{info.Y} {info.Width}x{info.Height})"); + foreach (ElementInfo child in info.Children) + DumpFontDids(child, depth + 1); + } + private static void DumpCaptions(string tag, UiElement root) { Walk(root, el => diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs index 7084b093..75455e7b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs @@ -329,6 +329,55 @@ public sealed class RetailDialogFactoryTests Assert.False(factory.IsOpen); } + /// + /// OP8 re-gate (2026-08-14): the type-2 wait dialog — retail's MapWarn + /// capture-instruction shape (OpenMapWarnDialog @ 0x00488A00): + /// text-only, no buttons wire a result, closed programmatically by the + /// opener via . + /// + [Fact] + public void MakeWait_CreatesTextOnlyModal_ClosedByTheOpener() + { + var root = new UiRoot { Width = 1024f, Height = 768f }; + var layouts = new List(); + // The shipped catalog authors the SAME popup/message child ids + // (0x3D/0x3E) under the wait root 0x31 as under the confirmation root + // (live-DAT probed 2026-08-14); the committed fixture only carries the + // confirmation subtree, which therefore stands in structurally here. + var factory = new RetailDialogFactory(root, type => + { + Assert.Equal(RetailDialogType.Wait, type); + ImportedLayout layout = FixtureLoader.LoadConfirmationDialog(); + layouts.Add(layout); + return layout; + }); + + uint context = factory.MakeWait( + "The next key you press will be mapped.", queueKey: 0x10000001u); + + Assert.NotEqual(0u, context); + ImportedLayout layout = Assert.Single(layouts); + Assert.Same(layout.Root, root.Modal); + Assert.Equal("The next key you press will be mapped.", Message(layout)); + + Assert.True(factory.CloseDialog(context)); + Assert.Null(root.Modal); + Assert.False(factory.IsOpen); + } + + [Fact] + public void WaitData_CarriesRetailsMapWarnPropertyShape() + { + RetailDialogData data = RetailDialogData.Wait("text"); + + // OpenMapWarnDialog @ 0x00488A00: 0x8E=2 (Wait), 0xAC=true, 0xC5=text. + Assert.Equal( + (uint)RetailDialogType.Wait, + data.GetUInt32(RetailDialogProperty.Type)); + Assert.True(data.GetBoolean(RetailDialogProperty.ElementAttribute40)); + Assert.Equal("text", data.GetString(RetailDialogProperty.Message)); + } + private static RetailDialogFactory CreateFactory( UiRoot root, List layouts) diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailKeyNamesTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailKeyNamesTests.cs new file mode 100644 index 00000000..af85104f --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/RetailKeyNamesTests.cs @@ -0,0 +1,150 @@ +using AcDream.App.UI.Layout; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// OP8 re-gate (2026-08-14): retail's key-binding display names — +/// CInputManager_WIN32::GetNameFromKey @ 0x00687F40 over +/// GetNameFromKey_Internal @ 0x00687800. DAT string-table override +/// first (key table enum 4 → DID 0x2300000A, meta enum 5 → 0x2300000B), +/// OS-localized key name second, DIK-suffix spelling last; modifier prefixes +/// join through the authored ID_KeyDescDelimiter (enum 3 → 0x23000007). +/// +public sealed class RetailKeyNamesTests +{ + private static string? NoStrings(uint table, uint hash) => null; + + private static Func Table( + params (uint Table, string Key, string Value)[] entries) + => (table, hash) => + { + foreach ((uint t, string key, string value) in entries) + if (t == table && DatStringResolver.ComputeHash(key) == hash) + return value; + return null; + }; + + [Fact] + public void DatTableOverride_WinsOverOsName() + { + // The shipped dat authors DIK_LCONTROL -> "Left Ctrl" in 0x2300000A + // (live-probed 2026-08-14); the OS name must not be consulted. + var names = new RetailKeyNames( + Table((RetailKeyNames.KeyNameTableId, "DIK_LCONTROL", "Left Ctrl")), + osKeyName: (_, _) => throw new InvalidOperationException("OS lookup must not run")); + + Assert.Equal("Left Ctrl", names.Describe(new KeyChord(Key.ControlLeft, ModifierMask.None))); + } + + [Fact] + public void OsLocalizedName_UsedWhenTheDatTableMisses() + { + // DIK_LSHIFT has no authored override in the shipped dat — retail + // shows the keyboard layout's own name ("SKIFT" on Swedish). + var names = new RetailKeyNames( + NoStrings, + osKeyName: (scan, extended) => + scan == 0x2A && !extended ? "SKIFT" : null); + + Assert.Equal("SKIFT", names.Describe(new KeyChord(Key.ShiftLeft, ModifierMask.None))); + } + + [Fact] + public void SelfModifier_ShowsOnlyTheKeyName_NeverShiftPlusShiftLeft() + { + // acdream's wire-side chord for retail's bare DIK_LSHIFT walk-mode row + // carries the self-modifier bit; retail's QualifiedControl has + // meta-mode 0 and displays just the key. + var names = new RetailKeyNames( + NoStrings, + osKeyName: (scan, _) => scan == 0x2A ? "SKIFT" : null); + + Assert.Equal("SKIFT", names.Describe(new KeyChord(Key.ShiftLeft, ModifierMask.Shift))); + // The fake OS lookup only answers LSHIFT's scan code; RSHIFT proves + // the same no-prefix rule through the DIK-suffix fallback instead. + Assert.Equal("RSHIFT", names.Describe(new KeyChord(Key.ShiftRight, ModifierMask.Shift))); + } + + [Fact] + public void ModifierPrefixes_JoinWithTheAuthoredDelimiter_InMetaBitOrder() + { + // Meta-mode bits ascending (Shift=1, Ctrl=2, Alt=4 — the shipped + // keymap's Metakeys header), each named through the meta table + OS + // fallback, joined by ID_KeyDescDelimiter. + var names = new RetailKeyNames( + Table((RetailKeyNames.DelimiterTableId, "ID_KeyDescDelimiter", "+")), + osKeyName: (scan, _) => scan switch + { + 0x2A => "SKIFT", + 0x1D => "CTRL", + 0x38 => "ALT", + 0x32 => "M", + _ => null, + }); + + Assert.Equal( + "SKIFT+CTRL+ALT+M", + names.Describe(new KeyChord( + Key.M, ModifierMask.Shift | ModifierMask.Ctrl | ModifierMask.Alt))); + } + + [Fact] + public void MetaTableOverride_WinsForTheModifierPrefix() + { + var names = new RetailKeyNames( + Table( + (RetailKeyNames.DelimiterTableId, "ID_KeyDescDelimiter", "+"), + (RetailKeyNames.MetaKeyNameTableId, "DIK_LSHIFT", "Shift")), + osKeyName: (scan, _) => scan == 0x32 ? "M" : null); + + Assert.Equal("Shift+M", names.Describe(new KeyChord(Key.M, ModifierMask.Shift))); + } + + [Fact] + public void ExtendedKeys_PassTheExtendedFlagToTheOsLookup() + { + // DIK_UP = 0xC8: scan 0x48 + the extended bit — the same split + // GetKeyNameText expects in lParam bit 24. + (byte Scan, bool Extended)? seen = null; + var names = new RetailKeyNames( + NoStrings, + osKeyName: (scan, extended) => + { + seen = (scan, extended); + return "UP ARROW"; + }); + + Assert.Equal("UP ARROW", names.Describe(new KeyChord(Key.Up, ModifierMask.None))); + Assert.Equal(((byte)0x48, true), seen); + } + + [Fact] + public void DikSuffixSpelling_WhenBothDatAndOsMiss() + { + var names = new RetailKeyNames(NoStrings, osKeyName: (_, _) => null); + + Assert.Equal("W", names.Describe(new KeyChord(Key.W, ModifierMask.None))); + Assert.Equal("NUMPADENTER", names.Describe(new KeyChord(Key.KeypadEnter, ModifierMask.None))); + } + + [Fact] + public void ControlsOutsideTheDikTable_KeepTheEnumSpelling() + { + var names = new RetailKeyNames(NoStrings, osKeyName: (_, _) => null); + + // Key.F13 never appears in the DAT's 84 observed scan codes. + Assert.Equal("F13", names.Describe(new KeyChord(Key.F13, ModifierMask.None))); + Assert.Equal( + "Shift+F13", + names.Describe(new KeyChord(Key.F13, ModifierMask.Shift))); + } + + [Fact] + public void DefaultChord_DescribesAsEmpty() + { + var names = new RetailKeyNames(NoStrings, osKeyName: (_, _) => null); + Assert.Equal(string.Empty, names.Describe(default)); + } +}