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:
parent
28bef4e03b
commit
c121842664
6 changed files with 524 additions and 35 deletions
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue