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

@ -24,6 +24,51 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #386 — Vendor category dropdown: authored ListBox is edge-docked — retail would size the popup to content, our shipped 6-row window may diverge
**Status:** OPEN — filed 2026-08-13 while fixing #385. The #385 probe
(`OptionsPanelLiveMountProbeTests.ProbeMenuPopupSizingAndTextStyle`,
menuprobe3) measured the vendor category popup's authored ListBox
(`0x21000043/0x10000350`) as edge-docked on all four sides (L=T=R=B=1) —
the exact authored condition that arms retail's
`UIElement_Menu::RecalculatePopupSize @0x0046caf0` size-to-content path
(popup grows/shrinks to the summed row heights, uncapped). Our vendor
dropdown ships G5's fixed 6-row scrollable window instead, which the G5
retail screenshot ("~one-column-with-scrollbar look") appeared to support
and the vendor connected gate user-passed. The two pieces of evidence
conflict: the decomp mechanism says an 18-category popup should open
full-height (~324 px) with an inert stretched scrollbar strip; the G5
screenshot was read as a 6-row scroll window. Next step is a retail
side-by-side of the vendor category dropdown specifically (open the
category menu at a vendor with many categories). If retail shows the
full-height popup, flip `UiMenu.PopupSizeToContent = true` in
`VendorUiController` (one line — the mechanism shipped with #385) and
retire the divergence; if retail truly shows a 6-row window, document WHY
the docked ListBox does not trigger RecalculatePopupSize there (a message
routing difference is plausible: the vendor popup's items are inserted
BEFORE `RegisterForElementMessages`, so the 0x32 broadcast may never reach
the menu). Register row AD-88 (unclear) tracks it.
## #385 — Options-panel dropdowns: gold left-aligned text + fixed 6-row popup (retail: white, centered, size-to-content)
**Status:** DONE 2026-08-13 (this commit). User gate report (Campaign OP
happy-testing round): every Config-tab dropdown (Sound Features,
Resolution, …) drew its text yellow and left-aligned, and the popup a
fixed 6 rows regardless of item count. All three symptoms were
unmeasured-styling divergences in `UiMenu`/`ApplyMenuChrome` — the
authored data (menuprobe3, live-DAT) says: button label child
`0x10000355` white + hJustify=Center; row template `0x1000035A` white +
hJustify=Center; popup ListBox `0x10000358` edge-docked, arming retail's
`RecalculatePopupSize @0x0046caf0` size-to-content resize (content =
summed laid-out row heights, uncapped — `0x0046e5f4..0046e66c`). Fix:
three opt-in `UiMenu` properties (`ButtonTextCentered`, `ItemTextCentered`,
`PopupSizeToContent` — chat + vendor keep class defaults) plus retail's
`Open @0x0046cc42` empty-list gate, wired for all 8 Config menus in
`ConfigOptionsPageController.ApplyMenuChrome`. The resolution-row
"changing resolution resizes the window" observation from the same report
is the #374 designed windowed-mode behavior (true display-mode switching
is #376/#377) — no change.
## #384 — FA6 allegiance-swear bot gate: ACE returns no response to 0x001D swear (no confirmation/0x0020/error)
**Status:** OPEN — filed 2026-08-12 at Campaign FA slice FA6. The two-bot

File diff suppressed because one or more lines are too long

View file

@ -9,6 +9,16 @@ Re-gate note for the previously-blank tabs: #372's viewport fix landed
`[options] gameplay button 0x... clicked` — a silent click in that log IS
evidence, report it per-button.
**Dropdown appearance (fix #385, 2026-08-13 happy-testing round).** Every
Config-tab dropdown (Sound Features, Resolution, texture menus, Chat Font
Size, …) now matches the authored retail style: the button caption and the
popup rows draw WHITE and CENTERED (not gold/left), and the popup's height
conforms to its item count (a 3-choice menu opens 3 rows tall, a 10-mode
Resolution list opens 10 rows tall — retail's `RecalculatePopupSize`
size-to-content port; there is no 6-row scroll window on these menus any
more). A clicked dropdown with NO choices no longer opens an empty popup.
Report any dropdown still showing gold/left text or a fixed-height popup.
**Caption dimming (AD-78, user-directed, gate 2).** As of this session, every
row this script marks as a store-only row (see each section's own "Store-only
rows" subsection, or the register row AD-78 for the complete four-controller

View file

@ -314,6 +314,21 @@ public static class ConfigOptionsPageController
// glyph to clear space for (see MenuChromeSprites' own doc).
menu.TextIndent = 0f;
menu.ButtonTextIndent = 0f;
// User gate report 2026-08-13 (happy-testing round): dropdown text
// drew gold + left-aligned and the popup a fixed 6 rows. All four
// corrections below are MEASURED authored facts (menuprobe3,
// OptionsPanelLiveMountProbeTests.ProbeMenuPopupSizingAndTextStyle),
// not styling choices:
// - button label child 0x10000355: fontColor white, hJustify=Center;
// - row template 0x1000035A: fontColor white, hJustify=Center;
// - popup ListBox 0x10000358: edges L=T=R=B=1 (docked), which arms
// retail's RecalculatePopupSize @0x0046caf0 size-to-content path
// (see UiMenu.PopupSizeToContent's own doc for the mechanism).
menu.TextColor = Vector4.One;
menu.ButtonTextCentered = true;
menu.ItemTextCentered = true;
menu.PopupSizeToContent = true;
}
/// <summary>The live read/write seam every row on this page writes/reads

View file

@ -190,6 +190,47 @@ public sealed class UiMenu : UiElement
public uint ItemNormalSprite { get; set; } // 0x0600124E — a row background (191×17)
public uint ItemHighlightSprite { get; set; } // 0x0600124D — the active channel's row
/// <summary>
/// Port of retail <c>UIElement_Menu::RecalculatePopupSize @0x0046caf0</c>
/// (user gate report 2026-08-13: the Sound Features popup drew a fixed 6
/// rows tall for a 3-item list): when the authored popup ListBox is
/// edge-docked on both top+bottom (<c>m_topEdge==1 &amp;&amp;
/// m_bottomEdge==1</c>), retail resizes the popup to the ListBox's
/// scrollable CONTENT extent — the SUM of laid-out row heights, uncapped
/// (<c>0x0046e5f4..0046e66c</c> feeding
/// <c>UIElement_Scrollable::ResizeScrollableArea @0x00474730</c>, whose
/// 0x32 broadcast RecalculatePopupSize answers) — so the popup shrinks
/// AND grows to the item count. Whether the path is active is an
/// authored per-menu fact, not a convention: the Config option-menu
/// popup ListBox (0x21000043/0x10000358) reads edges L=T=R=B=1
/// (menuprobe3, <c>OptionsPanelLiveMountProbeTests</c>), so
/// <see cref="AcDream.App.UI.Layout.ConfigOptionsPageController"/> sets
/// this true; chat's grid popup and vendor's shipped 6-row window keep
/// the class default false (vendor's authored ListBox is ALSO docked —
/// tracked as its own issue, not silently reworked here).
/// When set, <see cref="RowsPerColumn"/> stops being the visible-window
/// height and the popup shows every item with no scroll overflow.
/// </summary>
public bool PopupSizeToContent { get; set; }
/// <summary>
/// Retail draws the button caption through the authored label child named
/// by menu attr 8 (<c>UIElement_Menu::NewSelection @0x0046cd60</c> writes
/// the selected item's text into it) — its justification comes from the
/// LayoutDesc, not menu code. The Config option-menu label child
/// (0x21000043/0x10000355) authors hJustify=Center (menuprobe3);
/// chat's own label child authors Left, the class default.
/// </summary>
public bool ButtonTextCentered { get; set; }
/// <summary>
/// Same authored-justification rule for the popup rows: each row is an
/// authored text template (menu attr 9). The Config option-menu row
/// template (0x21000043/0x1000035A) authors hJustify=Center
/// (menuprobe3); chat's and vendor's row templates author Left.
/// </summary>
public bool ItemTextCentered { get; set; }
public Vector4 TextColor { get; set; } = new(1f, 0.92f, 0.72f, 1f);
/// <summary>Available item text — retail white #FFFFFF (gmMainChatUI talk-focus
/// enabled state). Confirmed via decomp: enabled items render white.</summary>
@ -232,10 +273,28 @@ public sealed class UiMenu : UiElement
private float InteriorW => Scrollable
? ColumnWidth + ScrollbarWidth
: ColumnCount * ColumnWidth;
private float InteriorH => RowsPerColumn * RowHeight;
/// <summary>The popup's visible row count. Size-to-content (retail's
/// RecalculatePopupSize path — see <see cref="PopupSizeToContent"/>):
/// every item, uncapped; otherwise the authored fixed window
/// (<see cref="RowsPerColumn"/>). Max(1,·) keeps a detached/empty test
/// menu's geometry finite — a live empty menu never opens (retail
/// <c>Open @0x0046cc42</c> gates on <c>m_listItems.m_num != 0</c>,
/// ported in <see cref="OnEvent"/>).</summary>
private int EffectiveVisibleRows => Scrollable && PopupSizeToContent
? System.Math.Max(1, Items.Count)
: RowsPerColumn;
private float InteriorH => EffectiveVisibleRows * RowHeight;
private float OuterW => InteriorW + 2 * Border;
private float OuterH => InteriorH + 2 * Border;
/// <summary>The open popup's outer (bevel-inclusive) height — read-only
/// test seam, same rationale as <see cref="PopupScroll"/>/<see cref="IsOpen"/>:
/// the size-to-content geometry has no other assertable surface short of
/// a full render pass.</summary>
public float PopupOuterHeight => OuterH;
/// <summary>
/// G7 (vendor gate finding, item 2 — popup direction): port of retail
/// <c>UIElement_Menu::Open</c> (pc:120210-120252, <c>0x0046cc30</c>)'s Y placement:
@ -282,7 +341,15 @@ public sealed class UiMenu : UiElement
var (tex, tw, _) = resolve(_open ? PressedSprite : NormalSprite);
if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw);
}
DrawLabel(ctx, ButtonLabelProvider?.Invoke() ?? "", ButtonTextIndent, (Height - LineH()) * 0.5f, TextColor);
string caption = ButtonLabelProvider?.Invoke() ?? "";
// Centered captions centre within the label-child band — the authored
// label child spans the button MINUS the arrow-cap overlay's right
// socket (0x10000355 is 100 wide of the 117 button, docked; the
// arrow child overlays the last 17px — menuprobe3).
float capX = ButtonTextCentered
? MathF.Max(0f, (Width - (ArrowCapClosedSprite != 0 ? ArrowCapWidth : 0f) - MeasureText(caption)) * 0.5f)
: ButtonTextIndent;
DrawLabel(ctx, caption, capX, (Height - LineH()) * 0.5f, TextColor);
// G6: the open/closed arrow-cap overlay — see ArrowCapClosedSprite's doc comment.
if (resolve is not null) DrawArrowCap(ctx, resolve);
@ -373,11 +440,21 @@ public sealed class UiMenu : UiElement
int col = i / RowsPerColumn, row = i % RowsPerColumn;
// Items grey out when unavailable; when EnabledProvider is null all items are enabled.
bool avail = EnabledProvider?.Invoke(Items[i].Payload) ?? true;
DrawLabel(ctx, Items[i].Label, inX + col * ColumnWidth + TextIndent, inY + row * RowHeight + textY,
DrawLabel(ctx, Items[i].Label, inX + col * ColumnWidth + ItemTextX(Items[i].Label),
inY + row * RowHeight + textY,
avail ? TextColorAvailable : TextColorGhosted);
}
}
/// <summary>A row label's X offset within its column — the authored row
/// template's own justification (see <see cref="ItemTextCentered"/>).</summary>
private float ItemTextX(string label) => ItemTextCentered
? MathF.Max(0f, (ColumnWidth - MeasureText(label)) * 0.5f)
: TextIndent;
private float MeasureText(string s)
=> DatFont?.MeasureWidth(s) ?? Font?.MeasureWidth(s) ?? s.Length * 7f;
/// <summary>
/// G5: single-column popup with a docked scrollbar — port of the vendor category
/// dropdown's authored shape (LayoutDesc <c>0x21000043</c>, see <see cref="Scrollable"/>'s
@ -397,7 +474,7 @@ public sealed class UiMenu : UiElement
DrawSprite(ctx, resolve, PopupBgSprite, inX, inY, ColumnWidth, InteriorH);
int start = VisibleTopRow;
int count = System.Math.Min(RowsPerColumn, Items.Count - start);
int count = System.Math.Min(EffectiveVisibleRows, Items.Count - start);
float textY = (RowHeight - LineH()) * 0.5f;
for (int i = 0; i < count; i++)
{
@ -411,7 +488,7 @@ public sealed class UiMenu : UiElement
int idx = start + i;
float y = inY + i * RowHeight;
bool avail = EnabledProvider?.Invoke(Items[idx].Payload) ?? true;
DrawLabel(ctx, Items[idx].Label, inX + TextIndent, y + textY,
DrawLabel(ctx, Items[idx].Label, inX + ItemTextX(Items[idx].Label), y + textY,
avail ? TextColorAvailable : TextColorGhosted);
}
@ -425,7 +502,10 @@ public sealed class UiMenu : UiElement
{
int lineHeight = System.Math.Max(1, (int)MathF.Round(RowHeight));
PopupScroll.LineHeight = lineHeight;
PopupScroll.SetExtents(Items.Count * lineHeight, RowsPerColumn * lineHeight);
// Size-to-content: view == content, so HasOverflow is false and the
// scrollbar draws its chrome with no thumb (retail's authored
// scrollbar sibling stretches with the docked popup the same way).
PopupScroll.SetExtents(Items.Count * lineHeight, EffectiveVisibleRows * lineHeight);
}
/// <summary>Index of the first visible row — nearest-row snap of the (possibly
@ -436,7 +516,7 @@ public sealed class UiMenu : UiElement
get
{
int lineHeight = System.Math.Max(1, (int)MathF.Round(RowHeight));
int maxStart = System.Math.Max(0, Items.Count - RowsPerColumn);
int maxStart = System.Math.Max(0, Items.Count - EffectiveVisibleRows);
int row = (int)MathF.Round((float)PopupScroll.ScrollY / lineHeight);
return System.Math.Clamp(row, 0, maxStart);
}
@ -581,6 +661,10 @@ public sealed class UiMenu : UiElement
return true;
}
// Retail Open @0x0046cc42 refuses an empty list (gates on
// m_listBox->m_listItems.m_num != 0) — a bare click on an itemless
// menu is a no-op rather than an empty popup.
if (!_open && Items.Count == 0) return true;
SetOpen(!_open); // toggle on button click
return true;
}
@ -598,7 +682,7 @@ public sealed class UiMenu : UiElement
{
int row = (int)(iy / RowHeight);
int idx = VisibleTopRow + row;
if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count
if (row >= 0 && row < EffectiveVisibleRows && idx >= 0 && idx < Items.Count
&& (EnabledProvider?.Invoke(Items[idx].Payload) ?? true))
{
OnSelect?.Invoke(Items[idx].Payload);

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);
}
}