diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 6dd9df47..14370c9f 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -73,20 +73,43 @@ scope the controller's application set to it. ## #378 — Config-tab dropdown menus render bare (no button well, no arrow) and no popup opens on click -**Status:** OPEN — filed 2026-08-11 at Campaign OP gate 4 (user -screenshots: retail's Resolution row shows a sunken value well + green -arrow cap; acdream's same rows — Resolution, Sound Features, all the -Config menus — render as bare text like "Stereo"/"1280x720" with NO -button chrome, and clicking produces NO visible dropdown). This is the -user-facing residual behind #374: the pointer-routing fix landed, but the -menu widget itself isn't presenting at the live mount. Candidate family -(to CONFIRM with the live-mount probe, not assume): the Config row -template's Type-6 menu element's button-face/arrow/popup sprites not -resolving at build (so the popup may even "open" while drawing nothing) — -the same fixture-green/live-broken class as #372/#375. The probe must -dump the built UiMenu's sprite ids + SpriteResolve wiring for a Config -row versus the working chat-channel menu. Blocks the OP6 gate's -resolution step (#374's re-test) and every other Config menu row. +**Status:** ROOT-CAUSED + FIXED (this commit) — pending the user's +re-gate. Filed 2026-08-11 at Campaign OP gate 4 (user screenshots: +retail's Resolution row shows a sunken value well + green arrow cap; +acdream's same rows rendered as bare text with no button chrome). + +**ROOT CAUSE — nothing wired the Config-tab `UiMenu` leaves at all.** +`ConfigOptionsPageController.BuildMenuRow`/`BuildStringMenuRow` built the +row's `UiMenu` widget from the template but never touched a single one of +its sprite/geometry properties — `SpriteResolve` stayed null and every +sprite id stayed 0, so `OnDraw`/`OnDrawOverlay` early-returned on every +frame (drawing literal nothing) even though click ROUTING (#374) was +already correct. A live-DAT probe (raw `ElementDesc` walk against +`DatCollectionAdapter`, bypassing the widget layer) traced the menu +leaf's (`0x10000224`) full retail inheritance chain — base `0x10000353` +in LayoutDesc `0x21000043`, retail's shared popup/dropdown catalog — and +found it BYTE-IDENTICAL in every sprite id to `VendorUiController`'s own +already-fixed dropdown (base `0x1000034B`, same layout): arrow cap +`0x060012B1`/`B2`, face/row sprite `0x060012B3`/`B4`, and the full +6-sprite scrollbar chrome `0x06004C5F/60/63/66/69/6C`. Attribute 7 (the +popup catalog LayoutDesc) is `0x21000043` for BOTH menus — not an +approximation, a measured fact, so no register row was needed. **Fix:** +`ConfigOptionsPageController.ApplyMenuChrome` wires the SAME chrome +`VendorUiController` already established, threaded from +`RetailUiRuntime`'s existing `Assets.ResolveSprite`/`DefaultFont`/ +`DebugFont` bindings through `ConfigOptionsPageController.Bind`'s three +new optional parameters. Regressed by +`tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs` +(`MenuRow_SoundFeatures_OpensAndSelectsThroughRealHitPath_UsingAuthoredPopupGeometry` +— asserts every sprite id is non-zero, drives the real click-to-open + +item-pick event path, and confirms the applied value reaches +`AudioSettings`) and extended live-mount probes in +`OptionsPanelLiveMountProbeTests.cs` (`ProbeConfigMenuChrome`/ +`ProbeConfigMenuPopupChrome`). + +**Re-gate (§OP6 step 8, and every other Config-tab dropdown): every +Config menu row should now show the sunken value-well face + green arrow +cap and open a real bordered, scrollable popup on click.** ## #377 — Startup CRASH (0xC0000005 in Glfw.GetVideoMode) when settings.json has `fullscreen: true` 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 2f4619d5..3f05ac38 100644 --- a/docs/research/2026-08-11-campaign-op-test-script.md +++ b/docs/research/2026-08-11-campaign-op-test-script.md @@ -622,7 +622,14 @@ should show no caption. open popup dismisses it without acting on what's below. While FULLSCREEN a resolution pick cannot switch the display mode yet (Silk API limit, #376) — it applies on the next return to windowed; - do not report that as a step-8 failure. + do not report that as a step-8 failure. **Gate-4 re-test note (#378):** + the SAME row (and every other Config-tab dropdown — Sound Features, the + four texture-detail menus, the two Chat Font menus) previously rendered + as bare text with no button well/arrow and no visible popup at all + (#374's fix only corrected click ROUTING, not the missing chrome). Every + dropdown now shows the sunken value-well face + green arrow cap and + opens a real bordered, scrollable popup on click — report if ANY + Config-tab dropdown still renders bare text. 9. **Toggle "Full Screen".** The window should switch between windowed and fullscreen IMMEDIATELY, live. 10. **Toggle "Sync To Refresh" (VSync) and drag the "Field of View" diff --git a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs index 1a27fdf7..d9784659 100644 --- a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs +++ b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Numerics; +using AcDream.App.Rendering; using AcDream.App.UI; using AcDream.UI.Abstractions.Panels.Settings; @@ -217,6 +218,104 @@ public static class ConfigOptionsPageController /// 0x10000219 convention. private const uint ToggleCheckboxElementId = 0x10000219u; + /// + /// #378 fix (2026-08-11, gate 4): every Config-tab dropdown + /// (, retail class UIOption_Menu, + /// Type 0x10000038) rendered bare — no button well, no arrow, no + /// popup — because NOTHING wired its sprites, the same class of bug + /// 's own precedent already fixed for + /// the vendor category dropdown. A live-DAT probe (menuprobe2, + /// OptionsPanelLiveMountProbeTests) walked the retail inheritance + /// chain the built widget tree collapses away + /// (0x10000224 → base 0x10000353 in LayoutDesc + /// 0x21000043 — retail's SHARED popup/dropdown catalog, the exact + /// same layout 's own 0x100000BF + /// also derives from, base 0x1000034B) and found the two + /// prototypes BYTE-IDENTICAL in every sprite id: arrow cap + /// 0x060012B1/B2, face/row sprite 0x060012B3/B4, + /// and the full 6-sprite scrollbar chrome + /// 0x06004C5F/60/63/66/69/6C — Config's own popup root/ListBox + /// (0x10000357/0x10000358, attrs 6/2 on 0x10000353) + /// carry the SAME 0x59=true/0x5D=1-col/0x5E=6-row/ + /// 0x61=true properties as vendor's own 0x1000034F/ + /// 0x10000350, and Config's row template (attr 9 → + /// 0x1000035A) resolves the IDENTICAL Normal/Highlight media as + /// vendor's own 0x10000352. Attribute 7 (the popup catalog + /// LayoutDesc) is 0x21000043 for BOTH — not an approximation + /// choice, a measured fact; no register row needed. No property "5" + /// (open-upward) is authored on 0x10000353 either, matching + /// vendor's own absent-defaults-false case — the popup opens DOWNWARD. + /// These constants are therefore transcribed from the SAME live values + /// already hardcodes, not duplicated by + /// coincidence — a genuine shared retail asset, verified independently + /// rather than assumed from the precedent. + /// + private static class MenuChromeSprites + { + public const uint Normal = 0x060012B3u; + public const uint Pressed = 0x060012B4u; + public const uint ItemNormal = 0x060012B3u; + public const uint ItemHighlight = 0x060012B4u; + public const uint ArrowCapClosed = 0x060012B1u; + public const uint ArrowCapOpen = 0x060012B2u; + + public const int RowsPerColumn = 6; + public const float RowHeight = 18f; + public const float ColumnWidth = 100f; + + public const float ScrollbarWidth = 16f; + public const float ScrollButtonExtent = 16f; + public const uint ScrollTrack = 0x06004C5Fu; + public const uint ScrollThumbTop = 0x06004C60u; + public const uint ScrollThumb = 0x06004C63u; + public const uint ScrollThumbBottom = 0x06004C66u; + public const uint ScrollUp = 0x06004C69u; + public const uint ScrollDown = 0x06004C6Cu; + } + + /// Applies + geometry to a + /// freshly-built leaf — the SAME wiring shape + /// performs inline for its own dropdown + /// (font/sprite resolvers, scrollable single-column popup, arrow-cap + /// overlay, zero text indents since neither the row template nor the + /// face carry a checkbox/LED glyph to clear — see the class's own + /// checked live-dat properties). + private static void ApplyMenuChrome( + UiMenu menu, + Func? resolveSprite, + UiDatFont? datFont, + BitmapFont? debugFont) + { + menu.SpriteResolve = resolveSprite; + menu.DatFont = datFont; + menu.Font = debugFont; + menu.NormalSprite = MenuChromeSprites.Normal; + menu.PressedSprite = MenuChromeSprites.Pressed; + menu.ItemNormalSprite = MenuChromeSprites.ItemNormal; + menu.ItemHighlightSprite = MenuChromeSprites.ItemHighlight; + menu.RowsPerColumn = MenuChromeSprites.RowsPerColumn; + menu.RowHeight = MenuChromeSprites.RowHeight; + menu.ColumnWidth = MenuChromeSprites.ColumnWidth; + menu.Scrollable = true; + menu.ScrollbarWidth = MenuChromeSprites.ScrollbarWidth; + menu.ScrollButtonExtent = MenuChromeSprites.ScrollButtonExtent; + menu.ScrollTrackSprite = MenuChromeSprites.ScrollTrack; + menu.ScrollThumbTopSprite = MenuChromeSprites.ScrollThumbTop; + menu.ScrollThumbSprite = MenuChromeSprites.ScrollThumb; + menu.ScrollThumbBottomSprite = MenuChromeSprites.ScrollThumbBottom; + menu.ScrollUpSprite = MenuChromeSprites.ScrollUp; + menu.ScrollDownSprite = MenuChromeSprites.ScrollDown; + menu.ArrowCapClosedSprite = MenuChromeSprites.ArrowCapClosed; + menu.ArrowCapOpenSprite = MenuChromeSprites.ArrowCapOpen; + // No property "5" authored on the retail prototype — absent-defaults- + // false, opens DOWNWARD (see MenuChromeSprites' own doc). + menu.OpenUpward = false; + // Neither the row template nor the face child carry a checkbox/LED + // glyph to clear space for (see MenuChromeSprites' own doc). + menu.TextIndent = 0f; + menu.ButtonTextIndent = 0f; + } + /// The live read/write seam every row on this page writes/reads /// through — four settings groups, each read once per row-build and /// mutated read-modify-write per change (the SAME per-change persistence @@ -237,12 +336,26 @@ public static class ConfigOptionsPageController /// ListBox, links its scrollbar, seeds every row's current/default /// state, and registers each row into . /// + /// #378: RenderSurface id → (GL tex handle, + /// pixel width, pixel height), threaded onto every dropdown's + /// — see 's + /// own doc. Null (fixture/conformance callers that don't exercise + /// dropdown chrome) leaves every menu's sprite ids populated but drawing + /// nothing, matching 's own null-safety. + /// #378: the retail dat-driven font every dropdown + /// draws its button/row labels with. + /// #378: fallback bitmap font when + /// is unavailable — same convention + /// uses. public static bool Bind( ImportedLayout layout, OptionPage page, Func templateResolver, Func resolveString, - Bindings bindings) + Bindings bindings, + Func? resolveSprite = null, + UiDatFont? datFont = null, + BitmapFont? debugFont = null) { ArgumentNullException.ThrowIfNull(layout); ArgumentNullException.ThrowIfNull(page); @@ -293,17 +406,17 @@ public static class ConfigOptionsPageController CameraTurningSettings cameraTurning = bindings.LoadCameraTurning(); ChatSettings chat = bindings.LoadChat(); - BindSoundSection(listBox, page, resolveString, bindings, ref audio); + BindSoundSection(listBox, page, resolveString, bindings, ref audio, resolveSprite, datFont, debugFont); BuildSeparatorRow(listBox); BindCameraSection(listBox, page, resolveString, bindings, ref cameraTurning); BuildSeparatorRow(listBox); - BindGraphicsSection(listBox, page, resolveString, bindings, ref display); + BindGraphicsSection(listBox, page, resolveString, bindings, ref display, resolveSprite, datFont, debugFont); BuildSeparatorRow(listBox); - BindRenderingQualitySection(listBox, page, resolveString, bindings, ref display); + BindRenderingQualitySection(listBox, page, resolveString, bindings, ref display, resolveSprite, datFont, debugFont); BuildSeparatorRow(listBox); BindInputSection(listBox, page, resolveString, bindings, ref cameraTurning); BuildSeparatorRow(listBox); - BindUiSection(listBox, page, resolveString, bindings, ref chat); + BindUiSection(listBox, page, resolveString, bindings, ref chat, resolveSprite, datFont, debugFont); // OP6 rework (2026-08-11, review S1): retail's own InitOptions ends // with a SIXTH AddSeperator tailcall (0x0049e80d) — a trailing // separator after the LAST section, not just the five INTERIOR ones @@ -322,7 +435,10 @@ public static class ConfigOptionsPageController OptionPage page, Func resolveString, Bindings bindings, - ref AudioSettings audio) + ref AudioSettings audio, + Func? resolveSprite, + UiDatFont? datFont, + BitmapFont? debugFont) { BuildHeaderRow(listBox, "ID_Sound_SoundSection", resolveString); @@ -337,7 +453,8 @@ public static class ConfigOptionsPageController bindings.SaveAudio(updated); }, defaultValue: 0, - storeOnly: true); // AP-199 + storeOnly: true, // AP-199 + resolveSprite, datFont, debugFont); // OP6 rework (2026-08-11, review M2): read/apply the ENABLED-sense // fields directly — toggleDefault stays `true` because retail's own @@ -456,7 +573,10 @@ public static class ConfigOptionsPageController OptionPage page, Func resolveString, Bindings bindings, - ref DisplaySettings display) + ref DisplaySettings display, + Func? resolveSprite, + UiDatFont? datFont, + BitmapFont? debugFont) { BuildHeaderRow(listBox, "ID_Graphics_GraphicsSection", resolveString); @@ -484,7 +604,8 @@ public static class ConfigOptionsPageController read: () => bindings.LoadDisplay().Resolution, apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { Resolution = value }), defaultValue: "800x600", - storeOnly: false); // LIVE + storeOnly: false, // LIVE + resolveSprite, datFont, debugFont); BuildToggleRow( listBox, "ID_Rendering_FullScreen", defaultValue: true, page, resolveString, @@ -563,7 +684,10 @@ public static class ConfigOptionsPageController OptionPage page, Func resolveString, Bindings bindings, - ref DisplaySettings display) + ref DisplaySettings display, + Func? resolveSprite, + UiDatFont? datFont, + BitmapFont? debugFont) { BuildHeaderRow(listBox, "ID_Graphics_TextureSection", resolveString); @@ -575,21 +699,24 @@ public static class ConfigOptionsPageController read: () => bindings.LoadDisplay().LandscapeTextureDetail, apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { LandscapeTextureDetail = value }), defaultValue: 2, - storeOnly: true); // AP-198 + storeOnly: true, // AP-198 + resolveSprite, datFont, debugFont); BuildMenuRow( listBox, "ID_Graphics_EnvironmentTextureDetail", TextureDetailChoices, page, resolveString, read: () => bindings.LoadDisplay().EnvironmentTextureDetail, apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { EnvironmentTextureDetail = value }), defaultValue: 1, - storeOnly: true); // AP-198 + storeOnly: true, // AP-198 + resolveSprite, datFont, debugFont); BuildMenuRow( listBox, "ID_Graphics_TextureFiltering", TextureFilteringChoices, page, resolveString, read: () => bindings.LoadDisplay().TextureFiltering, apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { TextureFiltering = value }), defaultValue: 1, - storeOnly: true); // AP-198 + storeOnly: true, // AP-198 + resolveSprite, datFont, debugFont); // UNRESOLVED (see class doc / register row): retail's own // SetDefaultValue(8) does not index this 6-entry choice array. @@ -600,7 +727,8 @@ public static class ConfigOptionsPageController read: () => bindings.LoadDisplay().LandscapeDrawDistance, apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { LandscapeDrawDistance = value }), defaultValue: 8, - storeOnly: true); // AP-198 + storeOnly: true, // AP-198 + resolveSprite, datFont, debugFont); BuildToggleRow( listBox, "ID_Graphics_BuildingDetailTextures", defaultValue: true, page, resolveString, @@ -694,7 +822,10 @@ public static class ConfigOptionsPageController OptionPage page, Func resolveString, Bindings bindings, - ref ChatSettings chat) + ref ChatSettings chat, + Func? resolveSprite, + UiDatFont? datFont, + BitmapFont? debugFont) { BuildHeaderRow(listBox, "ID_UI_UISection", resolveString); @@ -707,14 +838,16 @@ public static class ConfigOptionsPageController read: () => bindings.LoadChat().ChatFontFace, apply: value => bindings.SaveChat(bindings.LoadChat() with { ChatFontFace = value }), defaultValue: 2, - storeOnly: true); // AP-200 + storeOnly: true, // AP-200 + resolveSprite, datFont, debugFont); BuildMenuRow( listBox, "ID_UI_ChatFontSize", ChatFontSizeChoices, page, resolveString, read: () => bindings.LoadChat().ChatFontSizeIndex, apply: value => bindings.SaveChat(bindings.LoadChat() with { ChatFontSizeIndex = value }), defaultValue: 1, - storeOnly: true); // AP-200 + storeOnly: true, // AP-200 + resolveSprite, datFont, debugFont); chat = bindings.LoadChat(); } @@ -990,7 +1123,10 @@ public static class ConfigOptionsPageController Func read, Action apply, int defaultValue, - bool storeOnly) + bool storeOnly, + Func? resolveSprite, + UiDatFont? datFont, + BitmapFont? debugFont) { UiElement? row = listBox.AddItemFromTemplateList(MenuTemplateIndex); if (row is null) @@ -1012,6 +1148,10 @@ public static class ConfigOptionsPageController return; } + // #378: wire the dropdown chrome — the button-well face, arrow cap, + // and scrollable popup — see MenuChromeSprites' own doc. + ApplyMenuChrome(menu, resolveSprite, datFont, debugFont); + // OP6 rework (review S3): the menu button IS the interactive/ // hoverable widget for this row. string? tooltip = ResolveTooltip(labelKey, resolveString); @@ -1073,7 +1213,10 @@ public static class ConfigOptionsPageController Func read, Action apply, string defaultValue, - bool storeOnly) + bool storeOnly, + Func? resolveSprite, + UiDatFont? datFont, + BitmapFont? debugFont) { UiElement? row = listBox.AddItemFromTemplateList(MenuTemplateIndex); if (row is null) @@ -1095,6 +1238,10 @@ public static class ConfigOptionsPageController return; } + // #378: wire the dropdown chrome — the button-well face, arrow cap, + // and scrollable popup — see MenuChromeSprites' own doc. + ApplyMenuChrome(menu, resolveSprite, datFont, debugFont); + // OP6 rework (review S3): the menu button IS the interactive/ // hoverable widget for this row. string? tooltip = ResolveTooltip(labelKey, resolveString); diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 1f21a027..f7e37e15 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -2202,7 +2202,15 @@ public sealed class RetailUiRuntime : IDisposable // silently clobber CH6's filter/opacity edits with a // stale snapshot the next time either surface saves. LoadChat: () => _bindings.Chat.Store?.LoadChat() ?? ChatSettings.Default, - SaveChat: chat => _bindings.Chat.Store?.SaveChat(chat))); + SaveChat: chat => _bindings.Chat.Store?.SaveChat(chat)), + // #378: the eight Config-tab dropdown menus need the SAME + // sprite/font resolvers every other retail-menu consumer + // (ChatWindowController's channel menu, VendorUiController's + // category dropdown) already gets — see + // ConfigOptionsPageController.MenuChromeSprites' own doc. + resolveSprite: _bindings.Assets.ResolveSprite, + datFont: _bindings.Assets.DefaultFont, + debugFont: _bindings.Assets.DebugFont); if (!configBound) Console.WriteLine("[UI] options panel: Config tab rows did not bind."); } diff --git a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs index ecf7ad10..501e7def 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs @@ -320,6 +320,23 @@ public sealed class ConfigOptionsPageControllerTests return (controller, fakeBindings, bound); } + /// Every built under a subtree, in + /// document (build) order — the same recursive-walk shape + /// ChatOptionsPageControllerTests.CollectScalarSliders uses for + /// its own widget-type collection. + private static List CollectMenus(UiElement root) + { + var found = new List(); + Walk(root, found); + return found; + + static void Walk(UiElement node, List acc) + { + if (node is UiMenu menu) acc.Add(menu); + foreach (UiElement child in node.Children) Walk(child, acc); + } + } + [Fact] public void Bind_Succeeds_AndRegistersExactly30Rows() { @@ -466,6 +483,87 @@ public sealed class ConfigOptionsPageControllerTests Assert.Equal(1, bindings.AudioSaves[^1].SoundFeatures); } + /// + /// #378 regression (2026-08-11, gate 4): before this fix, NOTHING wired + /// any Config-tab dropdown's chrome — every sprite id was 0, no arrow + /// cap, no popup. Drives the REAL hit-test/pick + /// path + /// already established for vendor's own dropdown — the SAME + /// authored popup catalog (LayoutDesc 0x21000043) both dropdowns + /// derive from, byte-verified identical (see + /// 's own + /// doc), so the SAME click-geometry formula applies. + /// + [Fact] + public void MenuRow_SoundFeatures_OpensAndSelectsThroughRealHitPath_UsingAuthoredPopupGeometry() + { + ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); + OptionsPanelController controller = OptionsPanelController.Bind( + layout, + new OptionsPanelController.Callbacks( + Toggle: () => { }, + RequestExitToCharacterSelection: () => { }, + ExitGame: () => { }, + UseMouseTurningSettings: () => { }, + DisplaySystemMessage: _ => { }))!; + var fakeBindings = new FakeBindings(); + bool bound = ConfigOptionsPageController.Bind( + layout, + controller.ConfigPage, + MakeTemplateResolver(), + (_, _) => null, + fakeBindings.ToBindings(), + resolveSprite: _ => (1u, 8, 8)); + Assert.True(bound); + + var listBox = Assert.IsType( + layout.FindElement(ConfigOptionsPageController.ListBoxElementId)); + List menus = CollectMenus(listBox); + Assert.Equal(8, menus.Count); // the 8 Config-tab dropdown rows + UiMenu soundFeatures = menus[0]; // build order matches Rows order — Sound Features first + + // Wiring: without these UiMenu.OnDrawOverlay early-returns (nothing + // renders) and the button face never draws either — the bare-text + // symptom #378 reported. + Assert.NotNull(soundFeatures.SpriteResolve); + Assert.NotEqual(0u, soundFeatures.NormalSprite); + Assert.NotEqual(0u, soundFeatures.PressedSprite); + Assert.NotEqual(0u, soundFeatures.ItemNormalSprite); + Assert.NotEqual(0u, soundFeatures.ItemHighlightSprite); + Assert.NotEqual(0u, soundFeatures.ArrowCapClosedSprite); + Assert.NotEqual(0u, soundFeatures.ArrowCapOpenSprite); + + // Geometry from the live-DAT-verified popup catalog (0x21000043) — + // see MenuChromeSprites' own doc. + Assert.True(soundFeatures.Scrollable); + Assert.Equal(6, soundFeatures.RowsPerColumn); + Assert.Equal(18f, soundFeatures.RowHeight); + Assert.Equal(100f, soundFeatures.ColumnWidth); + Assert.False(soundFeatures.OpenUpward); // no authored attribute 5 — absent defaults false + + Assert.False(soundFeatures.IsOpen); + + // Open via the real widget event path (button click). + Assert.True(soundFeatures.OnEvent(new UiEvent(0, soundFeatures, UiEventType.MouseDown, 0, 10, 5))); + Assert.True(soundFeatures.IsOpen); + + // "Mono" is the SECOND choice (Stereo, then Mono) -> row index 1. + // Popup opens DOWNWARD (OpenUpward=false), so its top sits at the + // button's own bottom edge (ly = Height) — same formula + // VendorUiControllerTests already established for the identical + // popup catalog. + const int border = 5; // RetailChromeSprites.Border (UiMenu's private bevel thickness) + const int targetRow = 1; + float iy = targetRow * soundFeatures.RowHeight + soundFeatures.RowHeight / 2f; + float ly = soundFeatures.Height + iy + border; + + Assert.True(soundFeatures.OnEvent(new UiEvent(0, soundFeatures, UiEventType.MouseDown, 0, 10, (int)ly))); + + Assert.False(soundFeatures.IsOpen); // picking a row closes the popup + Assert.Equal(1, fakeBindings.Audio.SoundFeatures); + Assert.Equal(1, fakeBindings.AudioSaves[^1].SoundFeatures); + } + [Fact] public void MenuRow_Resolution_IsStringBacked_AndWritesThroughDisplayBindings() { diff --git a/tests/AcDream.App.Tests/UI/Layout/OptionsPanelLiveMountProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/OptionsPanelLiveMountProbeTests.cs index 2c5046ca..94ded3f4 100644 --- a/tests/AcDream.App.Tests/UI/Layout/OptionsPanelLiveMountProbeTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/OptionsPanelLiveMountProbeTests.cs @@ -87,6 +87,212 @@ public sealed class OptionsPanelLiveMountProbeTests } } + /// #378 (gate 4): the Config tab's dropdown menus render bare — + /// no button well, no arrow, no popup on click — while the chat channel + /// menu works. Dump the BUILT UiMenu's sprite/chrome state for the Config + /// menu row template against the real DATs, so the missing piece is + /// measured rather than guessed. + [Fact] + public void ProbeConfigMenuChrome() + { + 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 root = LayoutImporter.ImportInfos(dats, 0x2100006Eu, 0x1000018Du); + ImportedLayout layout = LayoutImporter.Build( + root, _ => (1u, 8, 8), null, null, strings.Resolve); + + UiElement? configSlot = layout.FindElement(0x10000213u); + Assert.NotNull(configSlot); + UiElement? lbEl = UiElement.FindDescendant(configSlot!, 0x10000200u); + UiTemplateListBox lb = Assert.IsType(lbEl); + + Console.WriteLine($"[menuprobe] Config ListBox templates={lb.Templates.Count}"); + for (int i = 0; i < lb.Templates.Count; i++) + Console.WriteLine( + $"[menuprobe] template[{i}] 0x{lb.Templates[i].TemplateLayoutId:X8}/0x{lb.Templates[i].TemplateElementId:X8}"); + + // Build template[4] (MenuTemplateIndex) exactly like production: + // through the SAME resolver shape RetailUiRuntime wires. + (uint tLayout, uint tElement) = + (lb.Templates[4].TemplateLayoutId, lb.Templates[4].TemplateElementId); + ElementInfo? tInfo = LayoutImporter.ImportInfos(dats, tLayout, tElement); + Assert.NotNull(tInfo); + DumpInfoTree(tInfo!, 0); + UiElement rowBuilt = LayoutImporter.Build( + tInfo!, _ => (1u, 8, 8), null, null, strings.Resolve).Root; + + UiElement? menuEl = UiElement.FindDescendant(rowBuilt, 0x10000224u); + Console.WriteLine( + $"[menuprobe] menu leaf 0x10000224 -> {(menuEl is null ? "MISSING" : menuEl.GetType().Name)} " + + (menuEl is null ? "" : $"({menuEl.Left},{menuEl.Top} {menuEl.Width}x{menuEl.Height})")); + if (menuEl is UiMenu m) + { + Console.WriteLine( + $"[menuprobe] sprites: normal=0x{m.NormalSprite:X8} pressed=0x{m.PressedSprite:X8} " + + $"popupBg=0x{m.PopupBgSprite:X8} itemNormal=0x{m.ItemNormalSprite:X8} " + + $"itemHighlight=0x{m.ItemHighlightSprite:X8} arrowClosed=0x{m.ArrowCapClosedSprite:X8} " + + $"arrowOpen=0x{m.ArrowCapOpenSprite:X8} spriteResolve={(m.SpriteResolve is null ? "NULL" : "set")} " + + $"openUpward={m.OpenUpward} rows={m.RowsPerColumn} rowH={m.RowHeight} colW={m.ColumnWidth}"); + } + } + + private static void DumpInfoTree(ElementInfo info, int depth) + { + Console.WriteLine( + $"[menuprobe] info {new string(' ', depth * 2)}0x{info.Id:X8} type={info.Type} " + + $"({info.X},{info.Y} {info.Width}x{info.Height}) children={info.Children.Count}"); + if (depth >= 3) return; + foreach (ElementInfo c in info.Children) + DumpInfoTree(c, depth + 1); + } + + /// #378 (gate 4): the missing piece behind + /// 's "sprites all zero" finding — + /// NOTHING in the codebase reads the popup wiring (raw dat attributes + /// 2/5/6/7 on the Type-0x10000038 menu leaf 0x10000224, per retail + /// UIElement_Menu::MakePopup/Initialize) or the face/arrow + /// child media (0x10000355/0x10000356) at all. Dumps BOTH — the exact + /// numbers + /// needs to wire the menu the same way + /// already does for + /// its own dropdown — alongside the SAME properties on chat's known-good + /// channel menu (0x10000014 in 0x21000006) and vendor's dropdown + /// (0x100000BF in 0x21000012) as controls, so the popup source is + /// MEASURED rather than assumed to match either precedent. + [Fact] + public void ProbeConfigMenuPopupChrome() + { + 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); + + Console.WriteLine("[menuprobe2] === Config tab menu row (0x2100002B template idx4) ==="); + ElementInfo? configRow = LayoutImporter.ImportInfos(dats, 0x2100002Bu, 0x10000222u); + Assert.NotNull(configRow); + DumpMenuAttributes(configRow!, 0x10000224u, "Config 0x10000224"); + DumpStateMedia(configRow!, 0x10000355u, "Config face 0x10000355"); + DumpStateMedia(configRow!, 0x10000356u, "Config arrow 0x10000356"); + + Console.WriteLine("[menuprobe2] === CONTROL: chat channel menu (0x21000006/0x10000014) ==="); + ElementInfo? chatRoot = LayoutImporter.ImportInfos(dats, 0x21000006u); + Assert.NotNull(chatRoot); + DumpMenuAttributes(chatRoot!, 0x10000014u, "Chat 0x10000014"); + + Console.WriteLine("[menuprobe2] === CONTROL: vendor category menu (0x21000012/0x100000BF) ==="); + ElementInfo? vendorRoot = LayoutImporter.ImportInfos(dats, 0x21000012u); + Assert.NotNull(vendorRoot); + DumpMenuAttributes(vendorRoot!, 0x100000BFu, "Vendor 0x100000BF"); + + // Attribute 7 (DataID) is the popup's own catalog LayoutDesc — walk into + // it and dump its shape (root, ListBox, row template, scrollbar sibling) + // exactly like the vendor research already established for 0x21000043, + // so the SAME probe run tells us whether Config's popup is that layout, + // chat's own 0x21000006, or a third, dedicated catalog. + if (FindInfo(configRow!, 0x10000224u) is { } configMenu + && configMenu.TryGetEffectiveProperty(7u, out UiPropertyValue popupLayout) + && popupLayout.Kind == UiPropertyKind.DataId) + { + uint popupLayoutId = (uint)popupLayout.UnsignedValue; + Console.WriteLine($"[menuprobe2] Config popup catalog layout = 0x{popupLayoutId:X8} — dumping tree"); + ElementInfo? popupRoot = LayoutImporter.ImportInfos(dats, popupLayoutId); + if (popupRoot is not null) + DumpInfoTree(popupRoot, 0); + else + Console.WriteLine($"[menuprobe2] popup layout 0x{popupLayoutId:X8} failed to import"); + } + } + + /// Dumps a menu leaf's raw popup-wiring properties — attribute 2 + /// (its popup's own ListBox element id, per retail + /// UIElement_Menu::Initialize), 5 (bool, open-upward), 6 (Enum, + /// popup root element id), 7 (DataID, popup catalog LayoutDesc) — per + /// retail UIElement_Menu::MakePopup @0x0046D310. Absent properties + /// print as MISSING rather than a guessed default, matching + /// GetAttribute_Bool's own absent-defaults-false semantics (the + /// caller decides what "missing" means, this probe only reports it). + private static void DumpMenuAttributes(ElementInfo root, uint menuId, string label) + { + ElementInfo? menu = FindInfo(root, menuId); + if (menu is null) + { + Console.WriteLine($"[menuprobe2] {label}: MISSING from imported tree"); + return; + } + + Console.WriteLine( + $"[menuprobe2] {label}: type=0x{menu.Type:X8} ({menu.X},{menu.Y} {menu.Width}x{menu.Height}) " + + $"children={menu.Children.Count} [{string.Join(",", menu.Children.ConvertAll(c => $"0x{c.Id:X8}"))}]"); + + foreach (uint attr in new[] { 2u, 5u, 6u, 7u }) + { + if (menu.TryGetEffectiveProperty(attr, out UiPropertyValue value)) + { + string rendered = value.Kind switch + { + UiPropertyKind.Bool => value.BoolValue.ToString(), + UiPropertyKind.DataId or UiPropertyKind.Enum + => $"0x{value.UnsignedValue:X8}", + UiPropertyKind.Integer => value.IntegerValue.ToString(), + _ => value.Kind.ToString(), + }; + Console.WriteLine($"[menuprobe2] attr[{attr}] kind={value.Kind} value={rendered}"); + } + else + { + Console.WriteLine($"[menuprobe2] attr[{attr}] MISSING"); + } + } + } + + /// Dumps every state's media (RenderSurface file id + draw mode) + /// for one child element — the face/arrow-cap art + /// needs to source + /// UiMenu.NormalSprite/PressedSprite/ArrowCapClosedSprite/ + /// ArrowCapOpenSprite from, mirroring how + /// 's own doc cites + /// its label/arrow children's StateMedia. + private static void DumpStateMedia(ElementInfo root, uint childId, string label) + { + ElementInfo? child = FindInfo(root, childId); + if (child is null) + { + Console.WriteLine($"[menuprobe2] {label}: MISSING from imported tree"); + return; + } + + Console.WriteLine( + $"[menuprobe2] {label}: type=0x{child.Type:X8} ({child.X},{child.Y} {child.Width}x{child.Height}) " + + $"defaultState='{child.DefaultStateName}' states={child.States.Count}"); + foreach ((string stateName, var media) in child.StateMedia) + Console.WriteLine($"[menuprobe2] state='{stateName}' -> file=0x{media.File:X8} drawMode={media.DrawMode}"); + } + + private static ElementInfo? FindInfo(ElementInfo root, uint id) + { + if (root.Id == id) return root; + foreach (ElementInfo c in root.Children) + { + ElementInfo? found = FindInfo(c, id); + if (found is not null) return found; + } + return null; + } + /// #372 minor half: the 13 ID_ChatOption_TextFilter_* labels fail /// to resolve in table 0x23000003 — sweep the plausible tables and key /// spellings against the live DAT to find their real home.