fix #385: Options dropdowns — white centered text + size-to-content popup

User gate report (Campaign OP happy-testing round, 2026-08-13): every
Config-tab dropdown drew its text gold + left-aligned and its popup a
fixed 6 rows regardless of item count. All three were unmeasured styling
divergences — the authored data (new probe menuprobe3, live DAT) says:

- button label child 0x10000355: fontColor white, hJustify=Center
- row template 0x1000035A: fontColor white, hJustify=Center
- popup ListBox 0x10000358: edge-docked L=T=R=B=1, the authored condition
  arming retail UIElement_Menu::RecalculatePopupSize @0x0046caf0 —
  popup resizes to the ListBox's summed content height, uncapped
  (0x0046e5f4..0046e66c via ResizeScrollableArea's 0x32 broadcast)

UiMenu gains three opt-in properties (ButtonTextCentered,
ItemTextCentered, PopupSizeToContent) plus retail Open @0x0046cc42's
empty-list gate; chat + vendor keep the class defaults, so their shipped
behavior is untouched. ConfigOptionsPageController.ApplyMenuChrome wires
all four corrections for the 8 Config menus with the probe citation.

The same probe found vendor's authored popup ListBox is ALSO docked while
our vendor dropdown ships G5's fixed 6-row window — filed as #386 +
register row AD-88 (UNCLEAR: the G5 retail screenshot and the decomp
mechanism conflict) instead of silently reworking a user-gated surface.

The "resolution change resizes the window" observation from the same
report is #374's designed windowed-mode behavior (display-mode switching
is #376/#377) — no change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-13 08:51:57 +02:00
parent 028920420d
commit a9b6435f55
8 changed files with 422 additions and 9 deletions

View file

@ -564,6 +564,49 @@ public sealed class ConfigOptionsPageControllerTests
Assert.Equal(1, fakeBindings.AudioSaves[^1].SoundFeatures);
}
/// <summary>User gate report 2026-08-13: every Config dropdown drew its
/// text gold + left-aligned and its popup a fixed 6 rows. The corrected
/// values are MEASURED authored facts (menuprobe3,
/// <see cref="OptionsPanelLiveMountProbeTests.ProbeMenuPopupSizingAndTextStyle"/>):
/// label child 0x10000355 + row template 0x1000035A are white +
/// hJustify=Center, and popup ListBox 0x10000358 is edge-docked
/// (L=T=R=B=1), arming retail's RecalculatePopupSize size-to-content
/// path. Asserted on ALL 8 menus — one shared ApplyMenuChrome must not
/// quietly skip any.</summary>
[Fact]
public void MenuRows_All8_UseTheAuthoredTextStyleAndSizeToContent()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
OptionsPanelController controller = OptionsPanelController.Bind(
layout,
new OptionsPanelController.Callbacks(
Toggle: () => { },
RequestExitToCharacterSelection: () => { },
ExitGame: () => { },
UseMouseTurningSettings: () => { },
DisplaySystemMessage: _ => { }))!;
var fakeBindings = new FakeBindings();
Assert.True(ConfigOptionsPageController.Bind(
layout,
controller.ConfigPage,
MakeTemplateResolver(),
(_, _) => null,
fakeBindings.ToBindings(),
resolveSprite: _ => (1u, 8, 8)));
var listBox = Assert.IsType<UiTemplateListBox>(
layout.FindElement(ConfigOptionsPageController.ListBoxElementId));
List<UiMenu> menus = CollectMenus(listBox);
Assert.Equal(8, menus.Count);
foreach (UiMenu menu in menus)
{
Assert.Equal(System.Numerics.Vector4.One, menu.TextColor);
Assert.True(menu.ButtonTextCentered);
Assert.True(menu.ItemTextCentered);
Assert.True(menu.PopupSizeToContent);
}
}
[Fact]
public void MenuRow_Resolution_IsStringBacked_AndWritesThroughDisplayBindings()
{

View file

@ -293,6 +293,149 @@ public sealed class OptionsPanelLiveMountProbeTests
return null;
}
/// <summary>User gate report 2026-08-13 (Campaign OP happy-testing round):
/// every Options-panel dropdown draws its text yellow + left-aligned (user:
/// retail is white + centered) and its popup a fixed 6 rows tall (user:
/// retail conforms to the item count). Retail mechanism, decomp-verified:
/// <c>UIElement_Menu::RecalculatePopupSize @0x0046caf0</c> resizes the popup
/// to the ListBox's content extent (<c>m_iScrollableHeight</c> = SUM of laid
/// out row heights, uncapped — <c>0x0046e5f4..0046e66c</c>) + the authored
/// popup-minus-ListBox border, but ONLY when the authored ListBox is docked
/// on both edges (<c>m_leftEdge==1 &amp;&amp; m_rightEdge==1</c> /
/// <c>m_topEdge==1 &amp;&amp; m_bottomEdge==1</c>); the caption is an
/// authored <c>UIElement_Text</c> child named by menu attr 8
/// (<c>NewSelection @0x0046cd60</c>) whose color/justify come from the
/// LayoutDesc, and <c>Open @0x0046cc30</c> centers the POPUP over the
/// button when bool attr 3 is authored. This probe measures all of that
/// authored data for the Config option-menu chain (catalog 0x21000043,
/// base 0x10000353) with vendor's dropdown (0x1000034F chain — visibly a
/// FIXED 6-row + scrollbar popup in retail, user-gated during the vendor
/// campaign) as the contrast control.</summary>
[Fact]
public void ProbeMenuPopupSizingAndTextStyle()
{
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("[menuprobe3] === Config option-menu base 0x21000043/0x10000353 ===");
ElementInfo? menuBase = LayoutImporter.ImportInfos(dats, 0x21000043u, 0x10000353u);
if (menuBase is null)
{
Console.WriteLine("[menuprobe3] 0x10000353 FAILED to import");
}
else
{
DumpMenuAttrs3(menuBase, "base 0x10000353", new[] { 2u, 3u, 5u, 6u, 7u, 8u, 9u });
foreach (ElementInfo c in menuBase.Children)
DumpTextStyle(c, $"base child 0x{c.Id:X8}");
}
Console.WriteLine("[menuprobe3] === Config leaf 0x2100002B/0x10000224 (attrs 3+8) ===");
ElementInfo? configRow = LayoutImporter.ImportInfos(dats, 0x2100002Bu, 0x10000222u);
if (configRow is not null && FindInfo(configRow, 0x10000224u) is { } leaf)
{
DumpMenuAttrs3(leaf, "leaf 0x10000224", new[] { 3u, 8u });
foreach (ElementInfo c in leaf.Children)
DumpTextStyle(c, $"leaf child 0x{c.Id:X8}");
}
else
{
Console.WriteLine("[menuprobe3] leaf 0x10000224 MISSING");
}
Console.WriteLine("[menuprobe3] === Config popup root/ListBox/row template ===");
DumpDockAndSize(dats, 0x21000043u, 0x10000357u, "Config popup root");
DumpDockAndSize(dats, 0x21000043u, 0x10000358u, "Config popup ListBox");
ElementInfo? rowTemplate = LayoutImporter.ImportInfos(dats, 0x21000043u, 0x1000035Au);
if (rowTemplate is not null)
{
DumpTextStyle(rowTemplate, "Config row template 0x1000035A");
foreach (ElementInfo c in rowTemplate.Children)
DumpTextStyle(c, $"row-template child 0x{c.Id:X8}");
}
else
{
Console.WriteLine("[menuprobe3] row template 0x1000035A FAILED to import");
}
Console.WriteLine("[menuprobe3] === CONTROL: vendor chain (fixed 6-row + scrollbar in retail) ===");
DumpDockAndSize(dats, 0x21000043u, 0x1000034Fu, "Vendor popup root");
DumpDockAndSize(dats, 0x21000043u, 0x10000350u, "Vendor popup ListBox");
ElementInfo? vendorBase = LayoutImporter.ImportInfos(dats, 0x21000043u, 0x1000034Bu);
if (vendorBase is not null)
{
DumpMenuAttrs3(vendorBase, "vendor base 0x1000034B", new[] { 2u, 3u, 5u, 6u, 7u, 8u, 9u });
foreach (ElementInfo c in vendorBase.Children)
DumpTextStyle(c, $"vendor base child 0x{c.Id:X8}");
}
ElementInfo? vendorRowTemplate = LayoutImporter.ImportInfos(dats, 0x21000043u, 0x10000352u);
if (vendorRowTemplate is not null)
{
DumpTextStyle(vendorRowTemplate, "Vendor row template 0x10000352");
foreach (ElementInfo c in vendorRowTemplate.Children)
DumpTextStyle(c, $"vendor row-template child 0x{c.Id:X8}");
}
}
private static void DumpMenuAttrs3(ElementInfo el, string label, uint[] attrs)
{
Console.WriteLine(
$"[menuprobe3] {label}: type=0x{el.Type:X8} ({el.X},{el.Y} {el.Width}x{el.Height}) "
+ $"edges L={el.Left} T={el.Top} R={el.Right} B={el.Bottom} "
+ $"children=[{string.Join(",", el.Children.ConvertAll(c => $"0x{c.Id:X8}"))}]");
foreach (uint attr in attrs)
{
if (el.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($"[menuprobe3] attr[{attr}] kind={value.Kind} value={rendered}");
}
else
{
Console.WriteLine($"[menuprobe3] attr[{attr}] MISSING");
}
}
}
private static void DumpDockAndSize(DatCollection dats, uint layoutId, uint elementId, string label)
{
ElementInfo? el = LayoutImporter.ImportInfos(dats, layoutId, elementId);
if (el is null)
{
Console.WriteLine($"[menuprobe3] {label} 0x{elementId:X8}: FAILED to import");
return;
}
Console.WriteLine(
$"[menuprobe3] {label} 0x{elementId:X8}: type=0x{el.Type:X8} "
+ $"({el.X},{el.Y} {el.Width}x{el.Height}) "
+ $"edges L={el.Left} T={el.Top} R={el.Right} B={el.Bottom} "
+ $"children=[{string.Join(",", el.Children.ConvertAll(c => $"0x{c.Id:X8}"))}]");
}
private static void DumpTextStyle(ElementInfo el, string label)
{
string color = el.FontColor is { } fc
? $"({fc.X:F2},{fc.Y:F2},{fc.Z:F2},{fc.W:F2})"
: "null(default-white)";
Console.WriteLine(
$"[menuprobe3] {label}: type=0x{el.Type:X8} ({el.X},{el.Y} {el.Width}x{el.Height}) "
+ $"hJustify={el.HJustify} vJustify={el.VJustify} fontColor={color} fontDid=0x{el.FontDid:X8} "
+ $"edges L={el.Left} T={el.Top} R={el.Right} B={el.Bottom}");
}
/// <summary>#380 (gate 4): exercises the PRODUCTION
/// <see cref="ChatOptionsDatCaptions.TryRead"/> mechanism against the real
/// DAT and asserts the two resolved captions match the user's own

View file

@ -489,4 +489,76 @@ public class UiMenuTests
Assert.Equal(0f, menu.TextIndent);
Assert.Equal(0f, menu.ButtonTextIndent);
}
// ------------------------------------------------------------------
// User gate report 2026-08-13: fixed-6-row popups for short option
// lists. Retail (UIElement_Menu::RecalculatePopupSize @0x0046caf0)
// sizes the popup to the ListBox's summed content height, uncapped,
// when the authored ListBox is edge-docked — see
// UiMenu.PopupSizeToContent's own doc for the full mechanism.
// ------------------------------------------------------------------
private static UiMenu MakeScrollableMenu(int itemCount, bool sizeToContent) => new UiMenu
{
Width = 120f, Height = 18f,
Scrollable = true,
OpenUpward = false,
RowsPerColumn = 6,
RowHeight = 18f,
ColumnWidth = 100f,
PopupSizeToContent = sizeToContent,
Items = Enumerable.Range(0, itemCount)
.Select(i => new UiMenu.MenuItem($"Item {i}", (object?)i)).ToArray(),
};
[Fact]
public void SizeToContent_ShrinksThePopupToTheItemCount()
{
// 3 items: interior 3*18, plus the 5px bevel top+bottom.
Assert.Equal(3 * 18f + 10f, MakeScrollableMenu(3, sizeToContent: true).PopupOuterHeight);
// Control: without size-to-content the fixed 6-row window stands.
Assert.Equal(6 * 18f + 10f, MakeScrollableMenu(3, sizeToContent: false).PopupOuterHeight);
}
[Fact]
public void SizeToContent_GrowsPastTheFixedWindow_AndTheLastRowIsPickable()
{
// Retail's content sum is UNCAPPED — 9 items = 9 rows, no scrolling.
UiMenu menu = MakeScrollableMenu(9, sizeToContent: true);
Assert.Equal(9 * 18f + 10f, menu.PopupOuterHeight);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
Assert.True(menu.IsOpen);
object? fired = null;
menu.OnSelect = p => fired = p;
// Row 8 (the 9th item) sits past the old 6-row window: it must be
// directly pickable with NO scroll. Downward popup: interior starts
// at Height + border.
float ly = menu.Height + 5f + 8 * menu.RowHeight + menu.RowHeight / 2f;
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, (int)ly)));
Assert.Equal(8, fired);
Assert.False(menu.IsOpen); // picking closes, same as every other path
}
[Fact]
public void EmptyItems_ButtonClickDoesNotOpen()
{
// Retail UIElement_Menu::Open @0x0046cc42 gates on
// m_listBox->m_listItems.m_num != 0 — an itemless menu never opens.
UiMenu menu = MakeScrollableMenu(0, sizeToContent: true);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)));
Assert.False(menu.IsOpen);
}
[Fact]
public void TextStyleDefaults_PreserveChatAndVendorBehavior()
{
// The three 2026-08-13 additions are opt-in: chat's gold left-aligned
// caption and vendor's fixed 6-row window are untouched by default.
var menu = new UiMenu();
Assert.False(menu.ButtonTextCentered);
Assert.False(menu.ItemTextCentered);
Assert.False(menu.PopupSizeToContent);
}
}