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:
parent
028920420d
commit
a9b6435f55
8 changed files with 422 additions and 9 deletions
|
|
@ -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.
|
- 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.
|
- 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)
|
## #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
|
**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
|
|
@ -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
|
`[options] gameplay button 0x... clicked` — a silent click in that log IS
|
||||||
evidence, report it per-button.
|
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
|
**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
|
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
|
rows" subsection, or the register row AD-78 for the complete four-controller
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,21 @@ public static class ConfigOptionsPageController
|
||||||
// glyph to clear space for (see MenuChromeSprites' own doc).
|
// glyph to clear space for (see MenuChromeSprites' own doc).
|
||||||
menu.TextIndent = 0f;
|
menu.TextIndent = 0f;
|
||||||
menu.ButtonTextIndent = 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
|
/// <summary>The live read/write seam every row on this page writes/reads
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,47 @@ public sealed class UiMenu : UiElement
|
||||||
public uint ItemNormalSprite { get; set; } // 0x0600124E — a row background (191×17)
|
public uint ItemNormalSprite { get; set; } // 0x0600124E — a row background (191×17)
|
||||||
public uint ItemHighlightSprite { get; set; } // 0x0600124D — the active channel's row
|
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 &&
|
||||||
|
/// 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);
|
public Vector4 TextColor { get; set; } = new(1f, 0.92f, 0.72f, 1f);
|
||||||
/// <summary>Available item text — retail white #FFFFFF (gmMainChatUI talk-focus
|
/// <summary>Available item text — retail white #FFFFFF (gmMainChatUI talk-focus
|
||||||
/// enabled state). Confirmed via decomp: enabled items render white.</summary>
|
/// enabled state). Confirmed via decomp: enabled items render white.</summary>
|
||||||
|
|
@ -232,10 +273,28 @@ public sealed class UiMenu : UiElement
|
||||||
private float InteriorW => Scrollable
|
private float InteriorW => Scrollable
|
||||||
? ColumnWidth + ScrollbarWidth
|
? ColumnWidth + ScrollbarWidth
|
||||||
: ColumnCount * ColumnWidth;
|
: 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 OuterW => InteriorW + 2 * Border;
|
||||||
private float OuterH => InteriorH + 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>
|
/// <summary>
|
||||||
/// G7 (vendor gate finding, item 2 — popup direction): port of retail
|
/// 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:
|
/// <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);
|
var (tex, tw, _) = resolve(_open ? PressedSprite : NormalSprite);
|
||||||
if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw);
|
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.
|
// G6: the open/closed arrow-cap overlay — see ArrowCapClosedSprite's doc comment.
|
||||||
if (resolve is not null) DrawArrowCap(ctx, resolve);
|
if (resolve is not null) DrawArrowCap(ctx, resolve);
|
||||||
|
|
@ -373,11 +440,21 @@ public sealed class UiMenu : UiElement
|
||||||
int col = i / RowsPerColumn, row = i % RowsPerColumn;
|
int col = i / RowsPerColumn, row = i % RowsPerColumn;
|
||||||
// Items grey out when unavailable; when EnabledProvider is null all items are enabled.
|
// Items grey out when unavailable; when EnabledProvider is null all items are enabled.
|
||||||
bool avail = EnabledProvider?.Invoke(Items[i].Payload) ?? true;
|
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);
|
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>
|
/// <summary>
|
||||||
/// G5: single-column popup with a docked scrollbar — port of the vendor category
|
/// 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
|
/// 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);
|
DrawSprite(ctx, resolve, PopupBgSprite, inX, inY, ColumnWidth, InteriorH);
|
||||||
|
|
||||||
int start = VisibleTopRow;
|
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;
|
float textY = (RowHeight - LineH()) * 0.5f;
|
||||||
for (int i = 0; i < count; i++)
|
for (int i = 0; i < count; i++)
|
||||||
{
|
{
|
||||||
|
|
@ -411,7 +488,7 @@ public sealed class UiMenu : UiElement
|
||||||
int idx = start + i;
|
int idx = start + i;
|
||||||
float y = inY + i * RowHeight;
|
float y = inY + i * RowHeight;
|
||||||
bool avail = EnabledProvider?.Invoke(Items[idx].Payload) ?? true;
|
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);
|
avail ? TextColorAvailable : TextColorGhosted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -425,7 +502,10 @@ public sealed class UiMenu : UiElement
|
||||||
{
|
{
|
||||||
int lineHeight = System.Math.Max(1, (int)MathF.Round(RowHeight));
|
int lineHeight = System.Math.Max(1, (int)MathF.Round(RowHeight));
|
||||||
PopupScroll.LineHeight = lineHeight;
|
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
|
/// <summary>Index of the first visible row — nearest-row snap of the (possibly
|
||||||
|
|
@ -436,7 +516,7 @@ public sealed class UiMenu : UiElement
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
int lineHeight = System.Math.Max(1, (int)MathF.Round(RowHeight));
|
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);
|
int row = (int)MathF.Round((float)PopupScroll.ScrollY / lineHeight);
|
||||||
return System.Math.Clamp(row, 0, maxStart);
|
return System.Math.Clamp(row, 0, maxStart);
|
||||||
}
|
}
|
||||||
|
|
@ -581,6 +661,10 @@ public sealed class UiMenu : UiElement
|
||||||
return true;
|
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
|
SetOpen(!_open); // toggle on button click
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -598,7 +682,7 @@ public sealed class UiMenu : UiElement
|
||||||
{
|
{
|
||||||
int row = (int)(iy / RowHeight);
|
int row = (int)(iy / RowHeight);
|
||||||
int idx = VisibleTopRow + row;
|
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))
|
&& (EnabledProvider?.Invoke(Items[idx].Payload) ?? true))
|
||||||
{
|
{
|
||||||
OnSelect?.Invoke(Items[idx].Payload);
|
OnSelect?.Invoke(Items[idx].Payload);
|
||||||
|
|
|
||||||
|
|
@ -564,6 +564,49 @@ public sealed class ConfigOptionsPageControllerTests
|
||||||
Assert.Equal(1, fakeBindings.AudioSaves[^1].SoundFeatures);
|
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]
|
[Fact]
|
||||||
public void MenuRow_Resolution_IsStringBacked_AndWritesThroughDisplayBindings()
|
public void MenuRow_Resolution_IsStringBacked_AndWritesThroughDisplayBindings()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -293,6 +293,149 @@ public sealed class OptionsPanelLiveMountProbeTests
|
||||||
return null;
|
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 && m_rightEdge==1</c> /
|
||||||
|
/// <c>m_topEdge==1 && 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
|
/// <summary>#380 (gate 4): exercises the PRODUCTION
|
||||||
/// <see cref="ChatOptionsDatCaptions.TryRead"/> mechanism against the real
|
/// <see cref="ChatOptionsDatCaptions.TryRead"/> mechanism against the real
|
||||||
/// DAT and asserts the two resolved captions match the user's own
|
/// DAT and asserts the two resolved captions match the user's own
|
||||||
|
|
|
||||||
|
|
@ -489,4 +489,76 @@ public class UiMenuTests
|
||||||
Assert.Equal(0f, menu.TextIndent);
|
Assert.Equal(0f, menu.TextIndent);
|
||||||
Assert.Equal(0f, menu.ButtonTextIndent);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue