diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 702fe441..7c3ed56f 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -44,29 +44,50 @@ modes; validate against the monitor's mode list first). ## #375 — Configure Keyboard screen renders as a visual mess at the live mount (missing button/tab captions, buttons outside the window, overlapping text) -**Status:** OPEN — filed 2026-08-11 at Campaign OP's second connected gate -(user report, verbatim observations): "all buttons lacked descriptive -text", "some buttons were outside of the window", "lacked text in the -tabs", "text next to the buttons was overlapping. Looked like a mess." -The screen OPENED (the `[options] gameplay button 0x10000204 clicked` -log line fired and the user saw the window), so the OP8 mount/wiring is -alive — the defect family is presentation at the LIVE mount. +**Status:** ROOT-CAUSED + FIXED (this commit) — pending the user's +re-gate. Filed 2026-08-11 at Campaign OP's second connected gate (user +report, verbatim): "all buttons lacked descriptive text", "some buttons +were outside of the window", "lacked text in the tabs", "text next to +the buttons was overlapping. Looked like a mess." -**Same false-negative class as #372:** `KeyboardConfigControllerTests` -runs green against the committed `keyboard_config_21000009.json` fixture, -so the structural conformance suite cannot see whatever the live -DAT mount does differently (string resolution, template sizing, -anchor baselines, window extent). Diagnosis must start from live-mount -evidence, not the fixture: extend the `ACDREAM_PROBE_LIVE_MOUNT=1` probe -(`OptionsPanelLiveMountProbeTests` pattern) to mount `0x21000009` against -the real DATs and dump per-element rect + resolved caption, then compare -against the user's four observations. Candidate families (to CONFIRM, not -assume): caption lookups missing their real string table (the exact -`0x2300000D` class from #372's session), row-template text elements -sized/positioned from degenerate baselines, and the screen's authored -extent vs where children actually land. +**TWO root causes, both proven by the live-DAT probe** +(`tests/AcDream.App.Tests/UI/Layout/KeyboardConfigLiveMountProbeTests.cs`, +`ACDREAM_PROBE_LIVE_MOUNT=1` — the #372-class fixture suite was green +throughout, again): -**Blocks the OP8 connected gate.** +1. **The missing string resolver** ("buttons lacked text" + "no text in + tabs"): `RetailUiRuntime.MountKeyboardConfig`'s main + `LayoutImporter.Build` call was the ONE mount in that class not + passing `strings.Resolve` — every AUTHORED caption (OK / Cancel / + Defaults / Revert / Load File... / Save As..., the six ActionClass + tab labels, the Command / Mapping 1-3 column headers) built empty, + while the controller's own `resolveString` lookups (row captions) + worked, which is why the screen was recognizable but textless. The + probe builds the same layout both ways: resolver-less = every caption + `''`; with resolver = `Movement/Camera/Combat/UI/CharacterSettings/ + Emotes`, `Command`, `Mapping 1/2/3`, `OK`, `Cancel`, ... Fix: pass + the resolver, like every sibling mount. +2. **Parked row-template prototypes** ("buttons outside the window" + + "overlapping text"): `gmKeyboardUI` (`0x21000009`) authors its + ListBox row templates — the header text `0x1000002E` and the action + row `0x1000002F` carrying the three 100x32 key buttons — as ordinary + TOP-LEVEL siblings of the screen, referenced by dat property `0x64` + (the template list). Retail never instantiates template-list elements + as live widgets (`AddItemFromTemplateList` clones from the desc — the + same re-import our `UiTemplateListBox.TemplateResolver` performs), but + `LayoutImporter.ImportInfos` built them as live elements parked at the + screen's (0,0): three key buttons at screen top y=0..32, ABOVE the + framed panel (which starts at y=62) — the "outside the window" + buttons — with the 570x40 header text overlapping them and the + window's top chrome. Fix: `ImportInfos(dats, layoutId)` now skips + top-level elements referenced by a SAME-LAYOUT template list (the + same skip class as the existing BaseElement-prototype filter; + same-layout only, because element ids collide across layouts — + `0x10000211` is a page in BOTH the options and keyboard layouts). + Post-fix the import collapses to the framed 600x476 panel with every + screen button inside its bounds. + +**Blocks the OP8 connected gate** — re-gate §OP8 after this commit. ## #374 — Config tab: picking a new Resolution does not resize the window (live gate failure) diff --git a/docs/research/2026-08-11-campaign-op-test-script.md b/docs/research/2026-08-11-campaign-op-test-script.md index 5770890b..af8b95ba 100644 --- a/docs/research/2026-08-11-campaign-op-test-script.md +++ b/docs/research/2026-08-11-campaign-op-test-script.md @@ -869,6 +869,17 @@ Three live runs against local ACE (`127.0.0.1:9000`, `+Acdream`, ## OP8 — Configure Keyboard +> **Gate-2 re-test note (#375):** the first look at this screen was a +> visual mess — textless buttons/tabs, stray buttons above the window, +> overlapping text. Two root causes, both fixed: the screen's build was +> missing its string resolver (every authored caption rendered empty), +> and the row-template prototypes (a header + a three-button row) were +> being built as live widgets parked at the screen's top-left. Expect +> now: captioned tabs (Movement/Camera/Combat/UI/CharacterSettings/ +> Emotes), captioned buttons (Defaults/Revert/OK/Cancel, Load File.../ +> Save As...), Command + Mapping 1-3 column headers, and NOTHING +> rendered above or outside the framed panel. + **Known, tracked behaviors — do NOT file as defects (read before testing):** - **Shared combat keys prompt a false conflict (ISSUES #373).** Retail diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index e65f2318..3c3f72d4 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -220,6 +220,35 @@ public static class LayoutImporter tops.Add(Resolve(dats, d, new HashSet<(uint, uint)>())); } + // #375: a Type-5 ListBox's row-template list (dat property 0x64) can + // name SAME-LAYOUT elements as its row prototypes — gmKeyboardUI + // (0x21000009) authors its header (0x1000002E) and action-row + // (0x1000002F, the three key buttons) templates as ordinary top-level + // siblings of the screen. Retail never instantiates a template-list + // element as a live widget (AddItemFromTemplateList clones from the + // desc on demand — the SAME re-import our UiTemplateListBox's + // TemplateResolver performs), so building them here parked two live + // prototype rows at the screen's (0,0), over and outside the framed + // panel. Same skip class as the BaseElement prototypes above, keyed on + // the template-list reference instead. Same-LAYOUT references only: + // element ids collide across layouts (0x10000211 is a page in BOTH the + // options and keyboard layouts), so a cross-layout entry must never + // suppress a coincidentally-same-id element here. + var referencedAsTemplate = new HashSet(); + foreach (ElementInfo top in tops) + CollectTemplateRefs(top, layoutId, referencedAsTemplate); + if (referencedAsTemplate.Count > 0) + { + for (int i = tops.Count - 1; i >= 0; i--) + { + if (!referencedAsTemplate.Contains(tops[i].Id)) continue; + Console.WriteLine( + $"[D.2b] LayoutImporter: skipping row-template element 0x{tops[i].Id:X8} " + + $"in layout 0x{layoutId:X8} (referenced by a same-layout template list)."); + tops.RemoveAt(i); + } + } + if (tops.Count == 1) return tops[0]; @@ -235,6 +264,21 @@ public static class LayoutImporter }; } + /// Recursively gathers every SAME-LAYOUT element id referenced by a + /// row-template list (dat property 0x64) anywhere in 's + /// resolved subtree — the skip set for the #375 parked-prototype filter above. + private static void CollectTemplateRefs( + ElementInfo info, uint layoutId, HashSet referenced) + { + foreach (UiTemplateListEntry entry in info.TemplateList) + { + if (entry.TemplateLayoutId == layoutId) + referenced.Add(entry.TemplateElementId); + } + foreach (ElementInfo child in info.Children) + CollectTemplateRefs(child, layoutId, referenced); + } + /// /// Retail UIElementManager::CreateRootElementByDataID counterpart: resolve one /// authored root from a catalog-style LayoutDesc instead of instantiating every diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index a8476af4..1f21a027 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -2283,15 +2283,23 @@ public sealed class RetailUiRuntime : IDisposable lock (_bindings.Assets.DatLock) { info = LayoutImporter.ImportInfos(_bindings.Assets.Dats, Layout.KeyboardConfigController.LayoutId); + strings = new DatStringResolver(_bindings.Assets.Dats); layout = info is null ? null : LayoutImporter.Build( info, _bindings.Assets.ResolveSprite, _bindings.Assets.DefaultFont, - _bindings.Assets.ResolveFont); + _bindings.Assets.ResolveFont, + // #375: the main screen build was the ONE mount in this class + // missing the string resolver — every AUTHORED caption + // (OK/Cancel/Defaults/Revert/Load/Save, the six ActionClass + // tab labels, the Command/Mapping column headers) rendered + // empty at the gate, while the controller's own resolveString + // lookups (row captions, headers) worked. Keep in step with + // every sibling Mount* Build call above. + strings.Resolve); snapshot = RetailActionMapReader.Read(_bindings.Assets.Dats); - strings = new DatStringResolver(_bindings.Assets.Dats); } if (layout is null || snapshot is null) { diff --git a/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigLiveMountProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigLiveMountProbeTests.cs new file mode 100644 index 00000000..b29d7a92 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigLiveMountProbeTests.cs @@ -0,0 +1,195 @@ +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; + } + } + + 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); + } +}