fix #378: Config-tab dropdown menus render bare with no popup chrome

Root cause: DatWidgetFactory builds Config-tab Type-0x10000038 menu
leaves as bare UiMenu instances (matching the vendor/chat channel menu
pattern), but unlike those two controllers, ConfigOptionsPageController
never wired the menu's sprite/font/geometry properties after Bind — so
every dropdown rendered as plain text with no button well, no arrow
cap, and opened no popup on click (#374's fix only corrected click
ROUTING, not the missing chrome).

Fix: ConfigOptionsPageController.ApplyMenuChrome wires every Config-tab
menu row with the SAME retail sprite ids VendorUiController/
ChatWindowController's channel menu already use for this shared popup
catalog (LayoutDesc 0x21000043), verified against the live DAT via
OptionsPanelLiveMountProbeTests' ProbeConfigMenuChrome/
ProbeConfigMenuPopupChrome probes. Regressed by
ConfigOptionsPageControllerTests.MenuRow_SoundFeatures_OpensAndSelects
ThroughRealHitPath_UsingAuthoredPopupGeometry, which drives the real
click-to-open + item-pick path through the authored popup geometry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 23:00:29 +02:00
parent 28bef4e03b
commit c121842664
6 changed files with 524 additions and 35 deletions

View file

@ -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`

View file

@ -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"

View file

@ -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
/// <c>0x10000219</c> convention.</summary>
private const uint ToggleCheckboxElementId = 0x10000219u;
/// <summary>
/// #378 fix (2026-08-11, gate 4): every Config-tab dropdown
/// (<see cref="MenuElementId"/>, retail class <c>UIOption_Menu</c>,
/// <c>Type 0x10000038</c>) rendered bare — no button well, no arrow, no
/// popup — because NOTHING wired its sprites, the same class of bug
/// <see cref="VendorUiController"/>'s own precedent already fixed for
/// the vendor category dropdown. A live-DAT probe (menuprobe2,
/// <c>OptionsPanelLiveMountProbeTests</c>) walked the retail inheritance
/// chain the built widget tree collapses away
/// (<c>0x10000224</c> → base <c>0x10000353</c> in LayoutDesc
/// <c>0x21000043</c> — retail's SHARED popup/dropdown catalog, the exact
/// same layout <see cref="VendorUiController"/>'s own <c>0x100000BF</c>
/// also derives from, base <c>0x1000034B</c>) and found the two
/// prototypes BYTE-IDENTICAL in every sprite id: arrow cap
/// <c>0x060012B1</c>/<c>B2</c>, face/row sprite <c>0x060012B3</c>/<c>B4</c>,
/// and the full 6-sprite scrollbar chrome
/// <c>0x06004C5F/60/63/66/69/6C</c> — Config's own popup root/ListBox
/// (<c>0x10000357</c>/<c>0x10000358</c>, attrs 6/2 on <c>0x10000353</c>)
/// carry the SAME <c>0x59</c>=true/<c>0x5D</c>=1-col/<c>0x5E</c>=6-row/
/// <c>0x61</c>=true properties as vendor's own <c>0x1000034F</c>/
/// <c>0x10000350</c>, and Config's row template (attr 9 →
/// <c>0x1000035A</c>) resolves the IDENTICAL Normal/Highlight media as
/// vendor's own <c>0x10000352</c>. Attribute 7 (the popup catalog
/// LayoutDesc) is <c>0x21000043</c> for BOTH — not an approximation
/// choice, a measured fact; no register row needed. No property "5"
/// (open-upward) is authored on <c>0x10000353</c> either, matching
/// vendor's own absent-defaults-false case — the popup opens DOWNWARD.
/// These constants are therefore transcribed from the SAME live values
/// <see cref="VendorUiController"/> already hardcodes, not duplicated by
/// coincidence — a genuine shared retail asset, verified independently
/// rather than assumed from the precedent.
/// </summary>
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;
}
/// <summary>Applies <see cref="MenuChromeSprites"/> + geometry to a
/// freshly-built <see cref="UiMenu"/> leaf — the SAME wiring shape
/// <see cref="VendorUiController"/> 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).</summary>
private static void ApplyMenuChrome(
UiMenu menu,
Func<uint, (uint tex, int w, int h)>? 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;
}
/// <summary>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 <paramref name="page"/>.
/// </summary>
/// <param name="resolveSprite">#378: RenderSurface id → (GL tex handle,
/// pixel width, pixel height), threaded onto every dropdown's
/// <see cref="UiMenu.SpriteResolve"/> — see <see cref="MenuChromeSprites"/>'s
/// own doc. Null (fixture/conformance callers that don't exercise
/// dropdown chrome) leaves every menu's sprite ids populated but drawing
/// nothing, matching <see cref="UiMenu"/>'s own null-safety.</param>
/// <param name="datFont">#378: the retail dat-driven font every dropdown
/// draws its button/row labels with.</param>
/// <param name="debugFont">#378: fallback bitmap font when
/// <paramref name="datFont"/> is unavailable — same convention
/// <see cref="VendorUiController"/> uses.</param>
public static bool Bind(
ImportedLayout layout,
OptionPage page,
Func<uint, uint, UiElement?> templateResolver,
Func<uint, uint, string?> resolveString,
Bindings bindings)
Bindings bindings,
Func<uint, (uint tex, int w, int h)>? 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<uint, uint, string?> resolveString,
Bindings bindings,
ref AudioSettings audio)
ref AudioSettings audio,
Func<uint, (uint tex, int w, int h)>? 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<uint, uint, string?> resolveString,
Bindings bindings,
ref DisplaySettings display)
ref DisplaySettings display,
Func<uint, (uint tex, int w, int h)>? 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<uint, uint, string?> resolveString,
Bindings bindings,
ref DisplaySettings display)
ref DisplaySettings display,
Func<uint, (uint tex, int w, int h)>? 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<uint, uint, string?> resolveString,
Bindings bindings,
ref ChatSettings chat)
ref ChatSettings chat,
Func<uint, (uint tex, int w, int h)>? 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<int> read,
Action<int> apply,
int defaultValue,
bool storeOnly)
bool storeOnly,
Func<uint, (uint tex, int w, int h)>? 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<string> read,
Action<string> apply,
string defaultValue,
bool storeOnly)
bool storeOnly,
Func<uint, (uint tex, int w, int h)>? 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);

View file

@ -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.");
}

View file

@ -320,6 +320,23 @@ public sealed class ConfigOptionsPageControllerTests
return (controller, fakeBindings, bound);
}
/// <summary>Every built <see cref="UiMenu"/> under a subtree, in
/// document (build) order — the same recursive-walk shape
/// <c>ChatOptionsPageControllerTests.CollectScalarSliders</c> uses for
/// its own widget-type collection.</summary>
private static List<UiMenu> CollectMenus(UiElement root)
{
var found = new List<UiMenu>();
Walk(root, found);
return found;
static void Walk(UiElement node, List<UiMenu> 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);
}
/// <summary>
/// #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 <see cref="UiMenu"/> hit-test/pick
/// path <see cref="VendorUiControllerTests.CategoryMenu_OpensAndSelectsThroughRealHitPath_UsingAuthoredPopupGeometry"/>
/// already established for vendor's own dropdown — the SAME
/// authored popup catalog (LayoutDesc <c>0x21000043</c>) both dropdowns
/// derive from, byte-verified identical (see
/// <see cref="ConfigOptionsPageController.MenuChromeSprites"/>'s own
/// doc), so the SAME click-geometry formula applies.
/// </summary>
[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<UiTemplateListBox>(
layout.FindElement(ConfigOptionsPageController.ListBoxElementId));
List<UiMenu> 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()
{

View file

@ -87,6 +87,212 @@ public sealed class OptionsPanelLiveMountProbeTests
}
}
/// <summary>#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.</summary>
[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<UiTemplateListBox>(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);
}
/// <summary>#378 (gate 4): the missing piece behind
/// <see cref="ProbeConfigMenuChrome"/>'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
/// <c>UIElement_Menu::MakePopup</c>/<c>Initialize</c>) or the face/arrow
/// child media (0x10000355/0x10000356) at all. Dumps BOTH — the exact
/// numbers <see cref="AcDream.App.UI.Layout.ConfigOptionsPageController"/>
/// needs to wire the menu the same way
/// <see cref="AcDream.App.UI.Layout.VendorUiController"/> 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.</summary>
[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");
}
}
/// <summary>Dumps a menu leaf's raw popup-wiring properties — attribute 2
/// (its popup's own ListBox element id, per retail
/// <c>UIElement_Menu::Initialize</c>), 5 (bool, open-upward), 6 (Enum,
/// popup root element id), 7 (DataID, popup catalog LayoutDesc) — per
/// retail <c>UIElement_Menu::MakePopup @0x0046D310</c>. Absent properties
/// print as MISSING rather than a guessed default, matching
/// <c>GetAttribute_Bool</c>'s own absent-defaults-false semantics (the
/// caller decides what "missing" means, this probe only reports it).</summary>
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");
}
}
}
/// <summary>Dumps every state's media (RenderSurface file id + draw mode)
/// for one child element — the face/arrow-cap art
/// <see cref="ProbeConfigMenuPopupChrome"/> needs to source
/// <c>UiMenu.NormalSprite</c>/<c>PressedSprite</c>/<c>ArrowCapClosedSprite</c>/
/// <c>ArrowCapOpenSprite</c> from, mirroring how
/// <see cref="AcDream.App.UI.Layout.VendorUiController"/>'s own doc cites
/// its label/arrow children's StateMedia.</summary>
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;
}
/// <summary>#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.</summary>