acdream/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs
Erik 67aba8c386 fix: plain <menu> popup scrollbar draws retail chrome, not a flat bar
Owner live-client report 2026-09-07: "For scrollable dropdown or the meta
window we use the same assets as we do in for example chat or inventory
window." The plain-style <menu> popup's scrollable-overflow scrollbar
(DrawScrollablePopupPlain / DrawPopupScrollbarPlain in UiMenu.cs) drew a
home-made flat 1px track + flat thumb instead of the gold track + up/down
arrow buttons + thumb the chat SpewBox and inventory UiItemList already
use through RetailScrollbarChrome. The owner only ever objected to the
retail ROW art (checkmark glyph, gradient panel) — the bar itself was
never in scope for the plain-row fix, so this change touches only the
scrollbar draw call and leaves the plain row rendering untouched.

DrawScrollablePopupPlain now calls the existing DrawPopupScrollbar helper
(the same procedural sprite-chrome draw VendorUiController/
ConfigOptionsPageController already use) whenever a SpriteResolve is
wired, falling back to the old flat DrawPopupScrollbarPlain only for a
hand-built UiMenu with no resolver at all. New
RetailScrollbarChrome.ApplyToMenuPopup(UiMenu) wires the same vertical
skin ids (Track/Up/Down/ThumbTop/Mid/Bot Normal) the chat/inventory
scrollbar uses onto a menu's own ScrollTrackSprite/etc properties.

Mutation shown to fail first: UiMenuPlainStyleTests's
Plain_OpenPopup_ScrollableOverflow_DrawsPlainTrackAndFlatThumb_NoDatArt
and Plain_ScrollablePopup_ContentFits_DrawsTrackWithNoThumb asserted
resolveCalls==0 and an all-fill scrollbar — both failed (6 resolve calls,
6 sprite quads instead of 0) against the new DrawPopupScrollbar call
before being rewritten to
Plain_OpenPopup_ScrollableOverflow_DrawsRetailScrollbarChrome_RowsStayPlain
and Plain_ScrollablePopup_ContentFits_DrawsNoScrollbarAtAll, which pin the
new sprite-chrome behavior (6 resolved ids on overflow: track, up, down,
thumb top/mid/bottom; 3 on content-fits: track+up+down, no thumb; 0 on a
menu built with no resolver) while re-asserting the rows are still plain
fills with zero retail row-sprite quads. Retail's own
RetailButtonArt=true popup path (DrawGridPopup/DrawScrollablePopup) is
untouched — its regression golden
(Retail_OpenPopup_DrawIsByteForByteUnchanged_RegressionGolden) still
passes byte-for-byte.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:42:25 +02:00

480 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Linq;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.App.UI;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.App.Tests.UI;
/// <summary>
/// S7 fix ("BIG gold/yellow buttons has to go" — owner live-client report
/// 2026-09-07, looking at the live client: retail's gold pushbutton art on
/// the plugin-markup <c>&lt;menu&gt;</c> closed state does not match VTank's
/// own <c>HudCombo</c> shape, a flat dark box with a thin border, left-
/// aligned value text, and a small down-arrow — the same widget family as
/// <see cref="UiMarkupList"/>'s own list boxes
/// (<c>docs/research/vtank-kb/08-ui-views.md</c> §2).
///
/// <para>
/// Draw-level pins against the same <see cref="RecordingGpuDevice"/>/
/// <see cref="TextRenderer"/> apparatus <c>MarkupIconTests</c> uses: the
/// plain closed state (<see cref="UiMenu.RetailButtonArt"/> = false) emits
/// no textured button-face quad at all, only untextured fills (background +
/// 1px border + the ▾ triangle) plus the DAT-font caption glyphs; the retail
/// closed state (the class default, and every non-markup <see cref="UiMenu"/>
/// caller) is untouched — it still resolves and draws its gold
/// <see cref="UiMenu.NormalSprite"/>/<see cref="UiMenu.PressedSprite"/> face.
/// </para>
///
/// <para>
/// <see cref="TextRenderer.DebugSpriteSegmentVerts"/> merges CONTIGUOUS
/// same-texture draws into one segment (<c>NextSpriteSeg</c>'s "extend the
/// current same-texture run" rule) — a segment boundary appears only where
/// the texture actually changes in submission order. So these tests count
/// QUADS (48 floats = 6 verts × 8 floats each) summed across every segment
/// of a given texture, rather than assuming one segment per draw call.
/// </para>
/// </summary>
public sealed class UiMenuPlainStyleTests
{
private const int FloatsPerQuad = 48; // 6 vertices/quad × 8 floats/vertex (AppendQuad).
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
private static (TextRenderer renderer, UiRenderContext ctx) MakeContext(float w, float h)
{
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(w, h));
var ctx = new UiRenderContext(renderer, new Vector2(w, h));
return (renderer, ctx);
}
private static int QuadCount(
System.Collections.Generic.IReadOnlyList<(uint Texture, System.Collections.Generic.IReadOnlyList<float> Verts)> segs,
uint texture)
=> segs.Where(s => s.Texture == texture).Sum(s => s.Verts.Count) / FloatsPerQuad;
// A distinctive, obviously-not-zero texture id for the retail gold face —
// if the plain path ever regressed into calling SpriteResolve for its
// face, this id would show up in the recorded segments.
private const uint FaceTexture = 77u;
private const uint FontTexture = 1u;
private static UiDatFont MakeFont()
{
var glyphs = new System.Collections.Generic.Dictionary<char, FontCharDesc>
{
['W'] = new FontCharDesc { Unicode = 'W', Width = 8, Height = 8 },
};
return new UiDatFont(
fgTex: FontTexture, fgW: 32, fgH: 32,
bgTex: 0, bgW: 0, bgH: 0,
lineHeight: 16f, baselineOffset: 12f,
glyphs);
}
private static UiMenu MakeMenu(bool retailButtonArt) => new()
{
Width = 100f, Height = 20f,
DatFont = MakeFont(),
SpriteResolve = _ => (FaceTexture, 46, 17),
RetailButtonArt = retailButtonArt,
NormalSprite = 0x06004D65u,
PressedSprite = 0x06004D66u,
Items = new[] { new UiMenu.MenuItem("Warrior", (object?)"Warrior") },
ButtonLabelProvider = () => "W",
};
[Fact]
public void Plain_ClosedState_DrawsNoTexturedFaceQuad()
{
var menu = MakeMenu(retailButtonArt: false);
var (renderer, ctx) = MakeContext(200f, 200f);
menu.DrawSelfAndChildren(ctx);
Assert.Equal(0, QuadCount(renderer.DebugSpriteSegmentVerts, FaceTexture));
}
[Fact]
public void Plain_ClosedState_DrawsFillOutlineTextAndTriangle()
{
var menu = MakeMenu(retailButtonArt: false);
var (renderer, ctx) = MakeContext(200f, 200f);
menu.DrawSelfAndChildren(ctx);
var segs = renderer.DebugSpriteSegmentVerts;
// Exactly: background fill (1 quad) + 1px outline (4 sides) +
// the ▾ triangle (4 stacked bands) = 9 untextured quads.
Assert.Equal(9, QuadCount(segs, 0u));
// The caption glyph drew exactly one quad through the DAT font texture.
Assert.Equal(1, QuadCount(segs, FontTexture));
}
[Fact]
public void Plain_ClosedState_TriangleSitsRightAligned_TextSitsAtListPadding()
{
var menu = MakeMenu(retailButtonArt: false);
var (renderer, ctx) = MakeContext(200f, 200f);
menu.DrawSelfAndChildren(ctx);
// The glyph's dest quad starts at UiMenu.PlainPadding (left-aligned,
// no arrow-cap offset baked in the way the retail face indents it).
var glyphSeg = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == FontTexture);
Assert.Equal(UiMenu.PlainPadding, glyphSeg.Verts[0], 3);
// DrawPlainClosedState submits fill, then the 4 outline sides
// (all texture 0, merged into one segment), THEN the caption glyph
// (texture 1, its own segment), THEN the 4 triangle bands (texture 0
// again — a NEW segment, since the glyph draw broke the run). That
// last texture-0 segment is exactly the triangle: 4 quads, 192 floats.
var untexturedSegs = renderer.DebugSpriteSegmentVerts.Where(s => s.Texture == 0u).ToList();
Assert.Equal(2, untexturedSegs.Count); // [fill+outline], [triangle]
var triangleSeg = untexturedSegs[^1];
Assert.Equal(4 * FloatsPerQuad, triangleSeg.Verts.Count);
// Each band's right edge (Verts[8] within its own 48-float quad chunk
// — the second AppendQuad vertex's X, same convention
// MarkupIconTests.UiMarkupList_IconColumn_... uses) must sit within
// the box's right 13px (7px glyph + 6px margin), never touching the
// left-aligned text.
for (int q = 0; q < 4; q++)
{
float rightEdgeX = triangleSeg.Verts[q * FloatsPerQuad + 8];
Assert.True(rightEdgeX <= menu.Width - 6f + 0.01f);
Assert.True(rightEdgeX >= menu.Width - 13f - 0.01f);
}
}
[Fact]
public void Retail_ClosedState_StillEmitsItsGoldFaceSprite()
{
var menu = MakeMenu(retailButtonArt: true);
var (renderer, ctx) = MakeContext(200f, 200f);
menu.DrawSelfAndChildren(ctx);
Assert.True(QuadCount(renderer.DebugSpriteSegmentVerts, FaceTexture) > 0);
}
[Fact]
public void RetailButtonArt_DefaultsTrue_SoNonMarkupCallersAreUnaffected()
{
// Every existing UiMenu construction site (chat, vendor, config
// options, the retail confirmation dialog, DatWidgetFactory's generic
// Type-6 element) never sets RetailButtonArt at all — the class
// default must keep drawing retail's gold art.
Assert.True(new UiMenu().RetailButtonArt);
}
[Fact]
public void Retail_ClosedState_DrawIsByteForByteUnchanged_RegressionGolden()
{
// A golden pin for a plain (non-markup) UiMenu built exactly the way
// pre-S7 code built one: no RetailButtonArt set at all (class
// default). Its drawn quad counts/textures must be identical to what
// the retail branch always produced — the S7 style switch must not
// have touched this path at all.
UiMenu menu = new()
{
Width = 100f, Height = 20f,
DatFont = MakeFont(),
SpriteResolve = _ => (FaceTexture, 46, 17),
NormalSprite = 0x06004D65u,
PressedSprite = 0x06004D66u,
Items = new[] { new UiMenu.MenuItem("Warrior", (object?)"Warrior") },
ButtonLabelProvider = () => "W",
};
var (renderer, ctx) = MakeContext(200f, 200f);
menu.DrawSelfAndChildren(ctx);
var segs = renderer.DebugSpriteSegmentVerts;
// 3-slice face (LED cap, stretched body, arrow cap) = 3 quads of
// FaceTexture, plus exactly one caption glyph quad (FontTexture). No
// arrow-cap overlay sprite (ids left at 0 -> DrawArrowCap no-ops) and
// no untextured fill/outline/triangle quad at all — the plain path is
// never reached for a menu that never sets RetailButtonArt.
Assert.Equal(3, QuadCount(segs, FaceTexture));
Assert.Equal(1, QuadCount(segs, FontTexture));
Assert.Equal(0, QuadCount(segs, 0u));
}
// ── OPEN-popup coverage (owner live-client report 2026-09-07: "Drop down
// menus look horrible, there is also a checkmark on the text there") ────
//
// The S7 fix above only replaced the CLOSED-state button face. The tests
// below pin the OPEN popup: plain mode draws no sprite/gradient/checkmark
// art at all (only untextured fills via DrawFill/DrawRectOutline, exactly
// like UiMarkupList's own chrome), while the retail popup — the class
// default, and every non-markup UiMenu caller — is unchanged (the
// existing golden above only covers the closed state; the golden here
// covers the open popup).
private const float PlainRowHeight = 18f;
private const float PlainColumnWidth = 90f;
private static UiMenu MakePopupMenu(
bool retailButtonArt, int itemCount, int rowsPerColumn, bool scrollable,
System.Action<int>? countResolveCall = null)
{
var items = Enumerable.Range(0, itemCount)
.Select(i => new UiMenu.MenuItem(i == 0 ? "W" : $"row{i}", (object?)i))
.ToArray();
return new UiMenu
{
Width = 100f, Height = 20f,
DatFont = MakeFont(),
// Retail tests read the texture id straight back (id => (id, w, h)) so a
// texture id is a specific sprite by construction — the same convention
// UiAncestorClipTests uses. Plain tests wrap this to prove it is NEVER
// invoked (no gradient/sprite of ANY kind, not just the ones this class
// happens to name).
SpriteResolve = id =>
{
countResolveCall?.Invoke(1);
return (id, 8, 8);
},
RetailButtonArt = retailButtonArt,
NormalSprite = 0x06004D65u,
PressedSprite = 0x06004D66u,
PopupBgSprite = 0x0600124Cu,
ItemNormalSprite = 0x0600124Eu,
ItemHighlightSprite = 0x0600124Du,
// Non-zero retail scrollbar chrome ids (UiScrollbar.cs's own doc-cited
// values) so a plain test can assert these are never resolved — a zero
// id would be indistinguishable from "never set", and would collide
// with the untextured-fill bucket's own texture-0 key.
ScrollTrackSprite = 0x06004C5Fu,
ScrollThumbSprite = 0x06004C63u,
ScrollThumbTopSprite = 0x06004C60u,
ScrollThumbBottomSprite = 0x06004C66u,
ScrollUpSprite = 0x06004C6Cu,
ScrollDownSprite = 0x06004C69u,
ColumnWidth = PlainColumnWidth,
RowHeight = PlainRowHeight,
RowsPerColumn = rowsPerColumn,
Scrollable = scrollable,
OpenUpward = false, // downward: PopupTop == Height, simplest math for these tests
Items = items,
ButtonLabelProvider = () => "W",
};
}
private static bool HasFillQuad(
System.Collections.Generic.IReadOnlyList<(uint Texture, System.Collections.Generic.IReadOnlyList<float> Verts)> segs,
float x, float y, float w, float h, Vector4 color, float tol = 0.05f)
{
foreach (var seg in segs)
{
if (seg.Texture != 0u) continue;
var v = seg.Verts;
for (int b = 0; b + FloatsPerQuad <= v.Count; b += FloatsPerQuad)
{
float qx = v[b], qy = v[b + 1];
float qw = v[b + 8] - qx, qh = v[b + 9] - qy;
float r = v[b + 4], g = v[b + 5], bl = v[b + 6], a = v[b + 7];
if (MathF.Abs(qx - x) < tol && MathF.Abs(qy - y) < tol
&& MathF.Abs(qw - w) < tol && MathF.Abs(qh - h) < tol
&& MathF.Abs(r - color.X) < tol && MathF.Abs(g - color.Y) < tol
&& MathF.Abs(bl - color.Z) < tol && MathF.Abs(a - color.W) < tol)
return true;
}
}
return false;
}
/// <summary>Opens the popup (MouseDown on the closed face) then, if given,
/// hovers a row via MouseMove — the same (Data1,Data2) local-coordinate
/// convention <see cref="UiMenu.OnEvent"/> already uses for MouseDown.</summary>
private static void OpenAndHover(UiMenu menu, int? hoverRow = null)
{
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, Data1: 10, Data2: 10)));
Assert.True(menu.IsOpen);
if (hoverRow is { } row)
{
// ix = lx - Border, iy = ly - (PopupTop + Border); PopupTop == Height (20)
// for these OpenUpward=false menus, Border == RetailChromeSprites.Border (5).
int ly = 20 + RetailChromeSprites.Border + row * (int)PlainRowHeight + (int)(PlainRowHeight / 2);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseMove, Data1: 10, Data2: ly)));
}
}
[Fact]
public void Plain_OpenPopup_GridMode_DrawsFlatFillsSelectedAndHover_NoSpriteResolveCalls()
{
int resolveCalls = 0;
var menu = MakePopupMenu(retailButtonArt: false, itemCount: 3, rowsPerColumn: 7, scrollable: false,
countResolveCall: n => resolveCalls += n);
menu.Selected = 1; // row 1 is "current"
OpenAndHover(menu, hoverRow: 2); // row 2 is hovered (not selected)
var (renderer, ctx) = MakeContext(200f, 200f);
menu.DrawOverlays(ctx);
var segs = renderer.DebugSpriteSegmentVerts;
Assert.Equal(0, resolveCalls); // no DAT art resolved at all — not even by id
Assert.Equal(0, QuadCount(segs, 0x0600124Cu)); // retail PopupBgSprite never drawn
Assert.Equal(0, QuadCount(segs, 0x0600124Du)); // retail ItemHighlightSprite (bakes the checkmark) never drawn
Assert.Equal(0, QuadCount(segs, 0x0600124Eu)); // retail ItemNormalSprite never drawn
float outerTop = menu.Height; // OpenUpward=false
float outerW = menu.PopupOuterWidth, outerH = menu.PopupOuterHeight;
float inX = RetailChromeSprites.Border, inY = outerTop + RetailChromeSprites.Border;
Assert.True(HasFillQuad(segs, 0f, outerTop, outerW, outerH, menu.PlainBackgroundColor),
"expected the plain popup background fill");
Assert.True(HasFillQuad(segs, inX, inY + 1 * PlainRowHeight, PlainColumnWidth, PlainRowHeight, menu.PlainSelectedColor),
"expected row 1 (selected/current) filled with PlainSelectedColor");
Assert.True(HasFillQuad(segs, inX, inY + 2 * PlainRowHeight, PlainColumnWidth, PlainRowHeight, menu.PlainHoverColor),
"expected row 2 (hovered) filled with PlainHoverColor");
// background(1) + outline(4 sides) + selected row(1) + hovered row(1) = 7,
// nothing else untextured.
Assert.Equal(7, QuadCount(segs, 0u));
}
[Fact]
public void Plain_OpenPopup_RowText_LeftAlignedAtPlainPadding()
{
var menu = MakePopupMenu(retailButtonArt: false, itemCount: 1, rowsPerColumn: 7, scrollable: false);
OpenAndHover(menu);
var (renderer, ctx) = MakeContext(200f, 200f);
menu.DrawOverlays(ctx);
// Item 0's label is "W" — the one glyph MakeFont() defines — so exactly
// one FontTexture quad renders, at column 0's PlainPadding inset (no
// authored TextIndent/centering in plain mode).
var glyphSeg = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == FontTexture);
Assert.Equal(RetailChromeSprites.Border + UiMenu.PlainPadding, glyphSeg.Verts[0], 3);
}
// ── Owner live-client report 2026-09-07 ("For scrollable dropdown or the
// meta window we use the same assets as we do in for example chat or
// inventory window"): the plain popup's SCROLLBAR now draws retail's own
// chrome (the exact sprite ids RetailScrollbarChrome wires onto
// chat/inventory's own bar) — only the ROWS stayed plain. These two tests
// used to pin a fully flat/untextured scrollbar; they now pin the
// opposite: real sprite draws for the bar, untouched plain fills for the
// rows, and no visible bar at all once the content fits.
[Fact]
public void Plain_OpenPopup_ScrollableOverflow_DrawsRetailScrollbarChrome_RowsStayPlain()
{
int resolveCalls = 0;
var menu = MakePopupMenu(retailButtonArt: false, itemCount: 12, rowsPerColumn: 5, scrollable: true,
countResolveCall: n => resolveCalls += n);
menu.Selected = 0; // row 0 (visible) is "current"
OpenAndHover(menu);
var (renderer, ctx) = MakeContext(200f, 200f);
menu.DrawOverlays(ctx);
var segs = renderer.DebugSpriteSegmentVerts;
Assert.True(menu.PopupScroll.HasOverflow);
// Track + up + down + thumb top/mid/bottom = 6 resolved sprite ids —
// the SAME chrome ids the chat/inventory scrollbar resolves through
// the same SpriteResolve seam, no longer the flat DrawFill-only path.
Assert.Equal(6, resolveCalls);
Assert.Equal(1, QuadCount(segs, menu.ScrollTrackSprite));
Assert.Equal(1, QuadCount(segs, menu.ScrollUpSprite));
Assert.Equal(1, QuadCount(segs, menu.ScrollDownSprite));
Assert.Equal(1, QuadCount(segs, menu.ScrollThumbTopSprite));
Assert.Equal(1, QuadCount(segs, menu.ScrollThumbSprite));
Assert.Equal(1, QuadCount(segs, menu.ScrollThumbBottomSprite));
float outerTop = menu.Height;
float inX = RetailChromeSprites.Border, inY = outerTop + RetailChromeSprites.Border;
// The rows are untouched by the chrome swap: still a plain fill, no
// DAT row/checkbox art at all (RetailButtonArt=false's own contract).
Assert.True(HasFillQuad(segs, inX, inY, PlainColumnWidth, PlainRowHeight, menu.PlainSelectedColor),
"expected visible row 0 (selected/current) still filled with PlainSelectedColor");
Assert.Equal(0, QuadCount(segs, menu.ItemHighlightSprite));
Assert.Equal(0, QuadCount(segs, menu.ItemNormalSprite));
// popup bg(1)+outline(4) + selected row(1) = 6 untextured quads;
// the scrollbar itself no longer contributes any (it is all sprite
// draws now).
Assert.Equal(6, QuadCount(segs, 0u));
}
[Fact]
public void Plain_ScrollablePopup_ContentFits_DrawsNoScrollbarAtAll()
{
int resolveCalls = 0;
var menu = MakePopupMenu(retailButtonArt: false, itemCount: 3, rowsPerColumn: 5, scrollable: true,
countResolveCall: n => resolveCalls += n);
OpenAndHover(menu);
var (renderer, ctx) = MakeContext(200f, 200f);
menu.DrawOverlays(ctx);
var segs = renderer.DebugSpriteSegmentVerts;
Assert.False(menu.PopupScroll.HasOverflow);
// Content-fits still draws the track + up/down buttons (retail's own
// proportion-0x88-defaults-to-1.0 rule — a content-fits bar shows a
// full-track thumb elsewhere in this class), but no thumb: 3 resolves.
Assert.Equal(3, resolveCalls);
Assert.Equal(1, QuadCount(segs, menu.ScrollTrackSprite));
Assert.Equal(1, QuadCount(segs, menu.ScrollUpSprite));
Assert.Equal(1, QuadCount(segs, menu.ScrollDownSprite));
Assert.Equal(0, QuadCount(segs, menu.ScrollThumbSprite));
// popup bg(1)+outline(4) = 5 untextured quads (nothing
// selected/hovered here either, and the scrollbar draws no fills).
Assert.Equal(5, QuadCount(segs, 0u));
}
[Fact]
public void Plain_OpenPopup_HitTesting_SelectsHoveredRow_ClosesPopup()
{
// The new hover-tracking MouseMove handling must not change what a
// MouseDown on the same row does — same rows, same scroll, same pick.
object? picked = null;
var menu = MakePopupMenu(retailButtonArt: false, itemCount: 3, rowsPerColumn: 7, scrollable: false);
menu.OnSelect = p => picked = p;
OpenAndHover(menu, hoverRow: 2);
int ly = 20 + RetailChromeSprites.Border + 2 * (int)PlainRowHeight + (int)(PlainRowHeight / 2);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, Data1: 10, Data2: ly)));
Assert.Equal(2, picked);
Assert.False(menu.IsOpen);
}
[Fact]
public void Retail_OpenPopup_DrawIsByteForByteUnchanged_RegressionGolden()
{
// A golden pin for the OPEN popup on a retail-styled (RetailButtonArt=true,
// the class default) menu — proves the S7-follow-up refactor of
// OnDrawOverlay (adding the plain branch) left the retail branch
// byte-identical: same bevel, same panel-fill sprite, same per-row
// highlight/normal sprite, and critically NO untextured fill anywhere
// (the plain path is a fully separate branch, never blended in).
var menu = MakePopupMenu(retailButtonArt: true, itemCount: 2, rowsPerColumn: 7, scrollable: false);
menu.Selected = 1;
OpenAndHover(menu);
var (renderer, ctx) = MakeContext(200f, 200f);
menu.DrawOverlays(ctx);
var segs = renderer.DebugSpriteSegmentVerts;
Assert.Equal(1, QuadCount(segs, RetailChromeSprites.CenterFill)); // bevel drawn
Assert.Equal(1, QuadCount(segs, 0x0600124Cu)); // PopupBgSprite panel fill
Assert.Equal(1, QuadCount(segs, 0x0600124Du)); // ItemHighlightSprite (row 1, selected)
Assert.Equal(1, QuadCount(segs, 0x0600124Eu)); // ItemNormalSprite (row 0)
Assert.Equal(0, QuadCount(segs, 0u)); // no untextured fill in the retail path
}
}