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>
341 lines
16 KiB
C#
341 lines
16 KiB
C#
using System.IO;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.Options;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// TEMPORARY #375 gate-failure probe (Campaign OP connected gate 2,
|
|
/// 2026-08-11): the user found the Configure Keyboard screen a visual mess —
|
|
/// missing button/tab captions, buttons outside the window, overlapping text —
|
|
/// while KeyboardConfigControllerTests stays green against the committed
|
|
/// fixture (the #372 structural-false-negative class again). This probe runs
|
|
/// the PRODUCTION mount path against the live DATs and dumps per-element
|
|
/// rect + caption evidence for each observation. Env-gated like the other
|
|
/// live probes so CI/dat-less runs skip it.
|
|
/// </summary>
|
|
public sealed class KeyboardConfigLiveMountProbeTests
|
|
{
|
|
[Fact]
|
|
public void ProbeKeyboardLiveMount()
|
|
{
|
|
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);
|
|
|
|
ElementInfo? info = LayoutImporter.ImportInfos(
|
|
dats, KeyboardConfigController.LayoutId);
|
|
Assert.NotNull(info);
|
|
|
|
// Production shape A — what MountKeyboardConfig actually built at the
|
|
// gate (NO string resolver on the main Build; the template build DOES
|
|
// pass one — the asymmetry under investigation).
|
|
ImportedLayout withoutStrings = LayoutImporter.Build(
|
|
info!, _ => (0u, 0, 0), null);
|
|
// Shape B — the same build WITH the resolver every sibling mount passes.
|
|
ImportedLayout withStrings = LayoutImporter.Build(
|
|
info!, _ => (0u, 0, 0), null, null, strings.Resolve);
|
|
|
|
Console.WriteLine(
|
|
$"[kbprobe] layout root id=0x{info!.Id:X8} rect=({withoutStrings.Root.Left},{withoutStrings.Root.Top} "
|
|
+ $"{withoutStrings.Root.Width}x{withoutStrings.Root.Height}) children={withoutStrings.Root.Children.Count}");
|
|
|
|
// The window-vs-content geometry question (observation 2): the layout
|
|
// is an 800x600 retail full-SCREEN — which child is the visible framed
|
|
// panel, and what escapes ITS rect?
|
|
DumpTreeRects(withoutStrings.Root, 0, maxDepth: 3);
|
|
UiElement? windowRoot = withoutStrings.FindElement(
|
|
KeyboardConfigController.WindowRootElementId);
|
|
Console.WriteLine(windowRoot is null
|
|
? "[kbprobe] window root 0x1000001F: MISSING from flat index"
|
|
: $"[kbprobe] window root 0x1000001F rect=({windowRoot.Left},{windowRoot.Top} "
|
|
+ $"{windowRoot.Width}x{windowRoot.Height}) children={windowRoot.Children.Count} "
|
|
+ $"type={windowRoot.GetType().Name}");
|
|
|
|
// Observation 1+3 — "buttons lacked descriptive text", "no text in
|
|
// tabs": dump every UiButton/UiText caption in BOTH shapes.
|
|
DumpCaptions("without-strings", withoutStrings.Root);
|
|
DumpCaptions("with-strings", withStrings.Root);
|
|
|
|
// Observation 2 — "some buttons were outside of the window": every
|
|
// element whose screen rect escapes the root's own extent.
|
|
DumpOutOfBounds(withoutStrings.Root);
|
|
|
|
// #375 regression pins (live-DAT, env-gated — the fix evidence):
|
|
// (a) the same-layout row-template prototypes (0x1000002E header,
|
|
// 0x1000002F action row) must NOT build as live elements —
|
|
// the parked copies were the "buttons outside the window" /
|
|
// "overlapping text" halves of the gate report.
|
|
Assert.Null(withStrings.FindElement(0x1000002Eu));
|
|
Assert.Null(withStrings.FindElement(0x1000002Fu));
|
|
// (b) the resolver-passing build resolves the screen's authored
|
|
// captions — the "buttons/tabs lacked text" halves. (Production
|
|
// now passes the resolver; the without-strings shape above is
|
|
// kept only as the delta record.)
|
|
foreach ((uint id, string expected) in new[]
|
|
{
|
|
(0x1000002Au, "Defaults"),
|
|
(0x1000002Bu, "Revert"),
|
|
(0x1000002Cu, "OK"),
|
|
(0x1000002Du, "Cancel"),
|
|
})
|
|
{
|
|
UiElement? el = withStrings.FindElement(id);
|
|
UiButton button = Assert.IsType<UiButton>(el);
|
|
Assert.Equal(expected, button.Label);
|
|
}
|
|
|
|
// Observation 4 — "text next to the buttons was overlapping": the row
|
|
// template's authored geometry vs the synthesized 260px caption column.
|
|
foreach ((string name, uint pageId) in new[]
|
|
{
|
|
("Movement", 0x1000049Du),
|
|
("Camera", 0x1000049Fu),
|
|
("Combat", 0x100004A1u),
|
|
("UI", 0x100004A3u),
|
|
("CharacterSettings", 0x10000211u),
|
|
("Emote", 0x100004A5u),
|
|
})
|
|
{
|
|
UiElement? page = UiElement.FindDescendant(withStrings.Root, pageId);
|
|
UiElement? lb = page is null ? null : UiElement.FindDescendant(page, 0x10000025u);
|
|
Console.WriteLine(
|
|
$"[kbprobe] page {name} 0x{pageId:X8} -> {(page is null ? "MISSING" : $"({page.Left},{page.Top} {page.Width}x{page.Height})")} "
|
|
+ $"listbox -> {(lb is null ? "MISSING" : $"{lb.GetType().Name} ({lb.Left},{lb.Top} {lb.Width}x{lb.Height})")}");
|
|
if (lb is UiTemplateListBox tlb)
|
|
{
|
|
for (int i = 0; i < tlb.Templates.Count; i++)
|
|
{
|
|
(uint layoutId, uint elementId) =
|
|
(tlb.Templates[i].TemplateLayoutId, tlb.Templates[i].TemplateElementId);
|
|
ElementInfo? tInfo = LayoutImporter.ImportInfos(dats, layoutId, elementId);
|
|
if (tInfo is null)
|
|
{
|
|
Console.WriteLine($"[kbprobe] template[{i}] 0x{layoutId:X8}/0x{elementId:X8} -> IMPORT MISSING");
|
|
continue;
|
|
}
|
|
UiElement built = LayoutImporter.Build(
|
|
tInfo, _ => (0u, 0, 0), null, null, strings.Resolve).Root;
|
|
Console.WriteLine(
|
|
$"[kbprobe] template[{i}] 0x{layoutId:X8}/0x{elementId:X8} -> {built.GetType().Name} "
|
|
+ $"({built.Left},{built.Top} {built.Width}x{built.Height}) children={built.Children.Count}");
|
|
foreach (uint keyBtn in new[] { 0x10000030u, 0x10000031u, 0x10000032u })
|
|
{
|
|
UiElement? b = UiElement.FindDescendant(built, keyBtn);
|
|
if (b is not null)
|
|
Console.WriteLine(
|
|
$"[kbprobe] key-button 0x{keyBtn:X8} ({b.Left},{b.Top} {b.Width}x{b.Height})");
|
|
}
|
|
}
|
|
}
|
|
// Only the first page's templates matter for geometry (all six share
|
|
// the same authored list) — stop after one full dump.
|
|
if (lb is not null) break;
|
|
}
|
|
}
|
|
|
|
/// <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 =>
|
|
{
|
|
string? caption = el switch
|
|
{
|
|
UiButton b => b.Label,
|
|
UiText t => string.Join(
|
|
" / ", t.LinesProvider().Select(static l => l.Text)),
|
|
_ => null,
|
|
};
|
|
if (el is UiButton or UiText)
|
|
Console.WriteLine(
|
|
$"[kbprobe] {tag} 0x{el.EventId:X8} {el.GetType().Name} "
|
|
+ $"({el.Left},{el.Top} {el.Width}x{el.Height}) caption='{caption ?? "<null>"}'");
|
|
});
|
|
}
|
|
|
|
private static void DumpOutOfBounds(UiElement root)
|
|
{
|
|
Walk(root, el =>
|
|
{
|
|
var p = el.ScreenPosition;
|
|
bool outside = p.X < root.Left - 0.5f || p.Y < root.Top - 0.5f
|
|
|| p.X + el.Width > root.Left + root.Width + 0.5f
|
|
|| p.Y + el.Height > root.Top + root.Height + 0.5f;
|
|
if (outside)
|
|
Console.WriteLine(
|
|
$"[kbprobe] OUT-OF-BOUNDS 0x{el.EventId:X8} {el.GetType().Name} "
|
|
+ $"screen=({p.X},{p.Y} {el.Width}x{el.Height}) rootExtent={root.Width}x{root.Height}");
|
|
});
|
|
}
|
|
|
|
private static void Walk(UiElement el, Action<UiElement> visit)
|
|
{
|
|
visit(el);
|
|
foreach (UiElement c in el.Children)
|
|
Walk(c, visit);
|
|
}
|
|
|
|
private static void DumpTreeRects(UiElement el, int depth, int maxDepth)
|
|
{
|
|
Console.WriteLine(
|
|
$"[kbprobe] tree {new string(' ', depth * 2)}{el.GetType().Name} "
|
|
+ $"({el.Left},{el.Top} {el.Width}x{el.Height}) children={el.Children.Count}");
|
|
if (depth >= maxDepth) return;
|
|
foreach (UiElement c in el.Children)
|
|
DumpTreeRects(c, depth + 1, maxDepth);
|
|
}
|
|
}
|