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