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 <noreply@anthropic.com>
This commit is contained in:
parent
1528e5693b
commit
30fa6ee507
16 changed files with 1105 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<ContentProfile>` 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 |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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>'
|
||||
> 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
|
||||
|
|
|
|||
43
src/AcDream.App/Platform/PlatformKeyNameProvider.cs
Normal file
43
src/AcDream.App/Platform/PlatformKeyNameProvider.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AcDream.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Platform owner (L0 boundary — OS checks live under <c>Platform/</c> only)
|
||||
/// for the OS-localized key-name half of retail's
|
||||
/// <c>CInputManager_WIN32::GetNameFromKey_Internal @ 0x00687800</c> 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
|
||||
/// <c>IDirectInputDevice8::GetObjectInfo</c>. This port asks Win32
|
||||
/// <c>GetKeyNameTextW</c> 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.
|
||||
/// </summary>
|
||||
public static class PlatformKeyNameProvider
|
||||
{
|
||||
/// <summary>(scanCode, isExtended) → localized key name, or null when the
|
||||
/// current platform has no OS lookup.</summary>
|
||||
public static Func<byte, bool, string?>? ForCurrentProcess()
|
||||
=> OperatingSystem.IsWindows() ? WindowsKeyName : null;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static string? WindowsKeyName(byte scan, bool extended)
|
||||
{
|
||||
Span<char> 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);
|
||||
}
|
||||
18
src/AcDream.App/UI/Layout/IRetailDialogView.cs
Normal file
18
src/AcDream.App/UI/Layout/IRetailDialogView.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// One live retail dialog root under <see cref="RetailDialogFactory"/> — the
|
||||
/// per-type presenter contract (<c>DialogFactory::CreateDialog_ @ 0x00477AD0</c>
|
||||
/// creates a fresh catalog root per shown <c>DialogInfo</c>; the factory then
|
||||
/// drives every type through the same tick/pending/teardown surface).
|
||||
/// </summary>
|
||||
internal interface IRetailDialogView
|
||||
{
|
||||
UiDialogRoot Root { get; }
|
||||
|
||||
void Tick();
|
||||
|
||||
void SetPendingCount(int count);
|
||||
|
||||
void DetachHandlers();
|
||||
}
|
||||
|
|
@ -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<string, Action<bool>> ConfirmOverwrite);
|
||||
Action<string, Action<bool>> 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<string, uint>? OpenCaptureInstructions = null,
|
||||
Action<uint>? CloseCaptureInstructions = null);
|
||||
|
||||
public OptionPage Page { get; } = new();
|
||||
public IReadOnlyList<RowView> Rows => _rows;
|
||||
|
||||
private readonly List<RowView> _rows = new();
|
||||
private readonly Dictionary<(uint LayoutId, uint ElementId), UiDatFont?> _templateFontCache = new();
|
||||
private Bindings? _bindings;
|
||||
private Func<KeyChord, string> _describe = DescribeChord;
|
||||
private Func<uint, uint, UiDatFont?>? _resolveTemplateFont;
|
||||
|
||||
private KeyboardConfigController() { }
|
||||
|
||||
|
|
@ -233,7 +247,8 @@ public sealed class KeyboardConfigController
|
|||
RetailActionMapSnapshot snapshot,
|
||||
Func<uint, uint, UiElement?> templateResolver,
|
||||
Func<uint, uint, string?> resolveString,
|
||||
Bindings bindings)
|
||||
Bindings bindings,
|
||||
Func<uint, uint, UiDatFont?>? 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
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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<KeyChord> DatDefaultsToChords(IReadOnlyList<RetailKeyChord> raw)
|
||||
{
|
||||
var result = new List<KeyChord>(raw.Count);
|
||||
|
|
@ -463,16 +510,19 @@ public sealed class KeyboardConfigController
|
|||
return result;
|
||||
}
|
||||
|
||||
private static void RefreshRowButtons(RowView view)
|
||||
private void RefreshRowButtons(RowView view)
|
||||
{
|
||||
IReadOnlyList<KeyChord> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Raw enum spelling — construction-time default until Bind swaps
|
||||
/// in <see cref="RetailKeyNames.Describe"/>, and that class's own fallback
|
||||
/// for controls outside the DIK table.</summary>
|
||||
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<RowView> 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<KeyChord> 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();
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ namespace AcDream.App.UI.Layout;
|
|||
/// <c>DialogFactory::CreateDialog_ @ 0x00477AD0</c> creates a fresh catalog root for
|
||||
/// every displayed <c>DialogInfo</c>.
|
||||
/// </summary>
|
||||
internal sealed class RetailConfirmationDialogView
|
||||
internal sealed class RetailConfirmationDialogView : IRetailDialogView
|
||||
{
|
||||
public const uint RootElementId = 0x15u;
|
||||
public const uint AcceptButtonId = 0x17u;
|
||||
|
|
|
|||
|
|
@ -112,4 +112,15 @@ public sealed class RetailDialogData
|
|||
.Set(RetailDialogProperty.Type, RetailDialogType.Confirmation)
|
||||
.Set(RetailDialogProperty.Message, message);
|
||||
}
|
||||
|
||||
/// <summary>Type-2 wait dialog data — text-only, no buttons. Sets element
|
||||
/// attribute 0x40 like <c>OpenMapWarnDialog @ 0x00488A00</c> does.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
public required uint Context { get; init; }
|
||||
public required uint QueueKey { get; init; }
|
||||
public Action<RetailDialogData>? 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Text-only wait dialog (type 2), closed by the caller via
|
||||
/// <see cref="CloseDialog"/>. Property shape mirrors
|
||||
/// <c>UIOption_ActionKeyMap::OpenMapWarnDialog @ 0x00488A00</c>: type 2,
|
||||
/// caller-chosen queue key, element attribute 0x40 set, message text.
|
||||
/// </summary>
|
||||
public uint MakeWait(string message, uint queueKey = DefaultQueueKey)
|
||||
{
|
||||
RetailDialogData data = RetailDialogData.Wait(message)
|
||||
.Set(RetailDialogProperty.QueueKey, queueKey);
|
||||
return MakeDialog(data, callback: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CloseDialog @ 0x00478160</c>. 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);
|
||||
|
|
|
|||
254
src/AcDream.App/UI/Layout/RetailKeyNames.cs
Normal file
254
src/AcDream.App/UI/Layout/RetailKeyNames.cs
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
using AcDream.App.Platform;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP8 re-gate (2026-08-14): retail's key-binding display
|
||||
/// names — the port of <c>CInputManager_WIN32::GetNameFromKey @ 0x00687F40</c>
|
||||
/// (QualifiedControl overload) over
|
||||
/// <c>GetNameFromKey_Internal @ 0x00687800</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// 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 (<c>ControlSpecification::GetDIKName @ 0x0068ACB0</c> —
|
||||
/// "DIK_W", "DIK_LSHIFT", ...): plain keys through string-table enum 4 and
|
||||
/// meta keys through enum 5 (<c>DBCache::GetDIDFromEnumStatic</c> category 4
|
||||
/// resolves those to DIDs <c>0x2300000A</c> / <c>0x2300000B</c> 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
|
||||
/// (<c>IDirectInputDevice8::GetObjectInfo</c>, DIPH_BYOFFSET) and shows
|
||||
/// <c>tszName</c>, which is why a Swedish layout displays "SKIFT". This port
|
||||
/// asks Win32 <c>GetKeyNameTextW</c> through
|
||||
/// <see cref="PlatformKeyNameProvider"/> 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).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// 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 <c>ID_KeyDescDelimiter</c> ("+", table enum 3 →
|
||||
/// DID <c>0x23000007</c>). A binding whose KEY IS a modifier key (retail's
|
||||
/// walk-mode DIK_LSHIFT row has meta-mode 0; acdream's <see cref="KeyChord"/>
|
||||
/// carries the wire-side self-modifier bit) shows only the key name — never
|
||||
/// "Shift+ShiftLeft".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RetailKeyNames
|
||||
{
|
||||
/// <summary>String-table enum 4 via GetDIDFromEnumStatic category 4.</summary>
|
||||
public const uint KeyNameTableId = 0x2300000Au;
|
||||
|
||||
/// <summary>String-table enum 5 via GetDIDFromEnumStatic category 4.</summary>
|
||||
public const uint MetaKeyNameTableId = 0x2300000Bu;
|
||||
|
||||
/// <summary>String-table enum 3 via GetDIDFromEnumStatic category 4 —
|
||||
/// holds <c>ID_KeyDescDelimiter</c>.</summary>
|
||||
public const uint DelimiterTableId = 0x23000007u;
|
||||
|
||||
private readonly Func<uint, uint, string?> _resolveString;
|
||||
private readonly Func<byte, bool, string?>? _osKeyName;
|
||||
private readonly string _delimiter;
|
||||
|
||||
/// <param name="resolveString">DAT string lookup — (tableDid, stringHash) →
|
||||
/// text, the same seam <c>KeyboardConfigController</c> already receives.</param>
|
||||
/// <param name="osKeyName">OS-localized key-name lookup — (scanCode,
|
||||
/// isExtended) → name. Null selects
|
||||
/// <see cref="PlatformKeyNameProvider.ForCurrentProcess"/> (Win32
|
||||
/// <c>GetKeyNameTextW</c> on Windows, no OS lookup elsewhere). Tests
|
||||
/// inject deterministic fakes here.</param>
|
||||
public RetailKeyNames(
|
||||
Func<uint, uint, string?> resolveString,
|
||||
Func<byte, bool, string?>? osKeyName = null)
|
||||
{
|
||||
_resolveString = resolveString
|
||||
?? throw new ArgumentNullException(nameof(resolveString));
|
||||
_osKeyName = osKeyName ?? PlatformKeyNameProvider.ForCurrentProcess();
|
||||
_delimiter = resolveString(
|
||||
DelimiterTableId, DatStringResolver.ComputeHash("ID_KeyDescDelimiter"))
|
||||
?? "+";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display name for one bound chord — retail
|
||||
/// <c>GetNameFromKey(QualifiedControl)</c>. 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).
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Silk key → DirectInput scan code + DIK name — the reverse of
|
||||
/// <see cref="RetailScanCodeMap.ToSilkKey"/>'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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
105
src/AcDream.App/UI/Layout/RetailWaitDialogView.cs
Normal file
105
src/AcDream.App/UI/Layout/RetailWaitDialogView.cs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// One live type-2 retail wait-dialog root (<c>WaitDialog</c>, catalog root
|
||||
/// <c>0x31</c> per <c>DialogFactory::CreateDialog_ @ 0x00477AD0</c>): 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 <c>0x3D</c>, message text <c>0x3E</c> — live-DAT
|
||||
/// probed 2026-08-14; the committed <c>dialogs_2100003C.json</c> fixture only
|
||||
/// carries the confirmation subtree). First consumer: the Configure Keyboard
|
||||
/// capture-instruction dialog (<c>UIOption_ActionKeyMap::InitiateBinding
|
||||
/// @ 0x004899D0</c> → <c>OpenMapWarnDialog @ 0x00488A00</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// No cancel wiring on purpose: retail's MapWarn flow routes ESC through the
|
||||
/// registered input handler (<c>KeyHitHandler</c>), not the dialog — in this
|
||||
/// port the <c>InputDispatcher</c>'s modal capture sees ESC and the capture
|
||||
/// callback closes the dialog, so a dialog-side ESC path would race it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<string, float> measure = _message.DatFont is { } font
|
||||
? font.MeasureWidth
|
||||
: static value => value.Length * 8f;
|
||||
IReadOnlyList<string> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<uint, string>
|
||||
{
|
||||
[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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -68,6 +68,10 @@ public sealed class KeyboardConfigControllerTests
|
|||
public int ToggleCalls { get; private set; }
|
||||
public Action<KeyChord?>? PendingCapture { get; private set; }
|
||||
public (string Message, Action<bool> OnResult)? PendingConfirm { get; private set; }
|
||||
public List<string> InstructionOpens { get; } = new();
|
||||
public List<uint> 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -143,6 +143,152 @@ public sealed class KeyboardConfigLiveMountProbeTests
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<DatReaderWriter.DBObjs.Font>(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<uint>();
|
||||
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 ? "<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<DatReaderWriter.DBObjs.StringTable>(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 =>
|
||||
|
|
|
|||
|
|
@ -329,6 +329,55 @@ public sealed class RetailDialogFactoryTests
|
|||
Assert.False(factory.IsOpen);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OP8 re-gate (2026-08-14): the type-2 wait dialog — retail's MapWarn
|
||||
/// capture-instruction shape (<c>OpenMapWarnDialog @ 0x00488A00</c>):
|
||||
/// text-only, no buttons wire a result, closed programmatically by the
|
||||
/// opener via <see cref="RetailDialogFactory.CloseDialog"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MakeWait_CreatesTextOnlyModal_ClosedByTheOpener()
|
||||
{
|
||||
var root = new UiRoot { Width = 1024f, Height = 768f };
|
||||
var layouts = new List<ImportedLayout>();
|
||||
// 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<ImportedLayout> layouts)
|
||||
|
|
|
|||
150
tests/AcDream.App.Tests/UI/Layout/RetailKeyNamesTests.cs
Normal file
150
tests/AcDream.App.Tests/UI/Layout/RetailKeyNamesTests.cs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
using AcDream.App.UI.Layout;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// OP8 re-gate (2026-08-14): retail's key-binding display names —
|
||||
/// <c>CInputManager_WIN32::GetNameFromKey @ 0x00687F40</c> over
|
||||
/// <c>GetNameFromKey_Internal @ 0x00687800</c>. 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).
|
||||
/// </summary>
|
||||
public sealed class RetailKeyNamesTests
|
||||
{
|
||||
private static string? NoStrings(uint table, uint hash) => null;
|
||||
|
||||
private static Func<uint, uint, string?> 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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue