using System.IO; using AcDream.App.UI; using AcDream.App.UI.Layout; using DatReaderWriter; using DatReaderWriter.Options; namespace AcDream.App.Tests.UI.Layout; /// /// 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. /// 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(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; } } /// /// 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. /// [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(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(); 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 ? "" : "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(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 ?? ""}'"); }); } 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 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); } }