The user's first click on a mapping button killed the client: the live dialog catalog's wait root 0x31 carries retail class type 0x19 (WaitDialog), which DatWidgetFactory left unmapped, so the root built as a plain UiDatElement and RetailWaitDialogView's ctor threw out of UiButton.OnClick into the render loop. The unit test missed it by standing the confirmation fixture (type 0x13, mapped) in for the wait root — the structural-false- negative class again. Pins: DatWidgetFactoryTests theory for both dialog root types, plus an installed-DAT UiDialogRoot/0x3D/0x3E assertion in the env-gated keyboard probe. OpenCaptureInstructions now converts a dialog construction failure into its contracted 0-return (log + capture refused, retail's own OpenMapWarnDialog failure shape) instead of crashing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
353 lines
17 KiB
C#
353 lines
17 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);
|
|
// The 2026-08-14 live crash pin: the wait root (retail class
|
|
// type 0x19) must build as a UiDialogRoot with the shared
|
|
// popup/message pair — the unit suite's fixture only carries
|
|
// the confirmation subtree, so the installed-DAT shape is
|
|
// asserted here.
|
|
ImportedLayout waitBuilt = LayoutImporter.Build(
|
|
waitInfo, _ => (0u, 0, 0), null, null, strings.Resolve);
|
|
Assert.IsType<UiDialogRoot>(waitBuilt.Root);
|
|
Assert.NotNull(waitBuilt.FindElement(0x3Du));
|
|
Assert.IsType<UiText>(waitBuilt.FindElement(0x3Eu));
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|