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.