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