fix #394 #395 #396: OP8 re-gate round — caption font, retail key names, capture dialog

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:
Erik 2026-08-14 13:45:55 +02:00
parent 1528e5693b
commit 30fa6ee507
16 changed files with 1105 additions and 24 deletions

View 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);
}

View 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();
}

View file

@ -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();
};
}

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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);

View 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;
}
}

View 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);
}
}

View file

@ -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)
{