diff --git a/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md b/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md index a619347c..e57ce04b 100644 --- a/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md +++ b/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md @@ -126,3 +126,6 @@ re-review, merge to the campaign branch, then the owner's visual gate. this sub-slice's scope. Screenshots NOT captured (same ACE-down reason as S7.1/S7.2). Owner's visual gate against a running VTank is still owed for both S7.1/S7.2 and S7.3. +- 2026-09-07 07:55 live screenshots of `89a49836c` captured (gate worktree, ACE up — the earlier "ACE down" was a TCP probe against a UDP server) → `docs/research/2026-09-07-slice7-screenshots/` (cropped to the panel, 864×360). Lead's read for the review + fix round: (1) Vitals reads like VTank; (2) Options has the right arrangement but VTank's literal `L,T,W,H` do not fit our DAT font — captions overlap the next row ("Follow/Nav Min Distance" over "Enable Navigation"): scale VTank's geometry to our font (row pitch from the DAT font's line height instead of VVS's 16 px; widths likewise), keep the proportions, never pixel-match; (3) Profiles still shows legacy controls (three stacked "New" buttons, the Loot engine row, the macro-profile path string drawn twice) — delete them; (4) the window is 856×350 with ~180 px of dead space below the content: bring the main panel back to VTank's 210-tall proportion (scaled) and make Advanced Options / Loot Editor their own plugin panels (separate windows, like VTank's popups) instead of in-panel groups; (5) the Route, Meta and Loot-editor pages show blank trailing button slots. +- 2026-09-07 08:05 owner looked at the live gate build: (a) "The Options tab labels overlap, fix that"; (b) "Those BIG gold/yellow buttons HAS to go. That is not how vtank looks." — the plugin `` renders retail's gold pushbutton art; VTank's combos are plain (flat dark box, thin border, value text, small arrow at right — the same look as its lists). Fix round: plugin-markup `` draws the plain combo by default (a `style="retail"` opt-in keeps the gold art for anyone who wants it), plus the geometry scaling, the Profiles leftovers, the 210-tall main window and the two popups as separate panels. +- 2026-09-07 09:10 S7.3 Monsters landed on the panel worktree (`57ced0aff`, `c3b4f7862`; MossTank suite 645 → 651) — same commits as the entry above; recorded again here because the plain-menu-style branch (`cfa703065`) merged into the panel worktree afterward to pick up fix (b) before fix round A started. Fix round A (grid scaling, Profiles leftovers, 260-tall window, Advanced Options / Loot Editor as their own panels, blank trailing slots, fresh screenshots) dispatched on the same worktree after merging the plain-menu style in. S7.4–S7.6 follow. diff --git a/docs/plugin-ui-markup.md b/docs/plugin-ui-markup.md index 8e8c7c64..4fff0e82 100644 --- a/docs/plugin-ui-markup.md +++ b/docs/plugin-ui-markup.md @@ -108,9 +108,17 @@ vanishing from the built tree. | `toggle` | Lamp-style checkbox | `x y w h text checked onclick color` | | `slider` | Horizontal scalar | `x y w h value onchange min max` | | `field` | Single-line editable text | `x y w h text maxlength clearonsubmit onchange onsubmit color background` | -| `menu` | Dropdown selector | `x y w h items selected onchange rows rowheight openupward scroll` | +| `menu` | Dropdown selector | `x y w h items selected onchange rows rowheight openupward scroll style` | | `list` | Scrollable row list (+ Slice B icon column, + Campaign VT slice 1 multi-column) | `x y w h selected onchange rowheight` + either the single-column `items colors icons iconkind`, or one-to-many `` children (see "Columns" below) — never both | +`menu style` is `plain` (the default) or `retail`: retail's gold pushbutton +art read as an out-of-place "big yellow button" next to a plugin's own dark +list boxes (owner live-client report, 2026-09-07), so a plugin `` now +draws the flat VTank/Decal `HudCombo` box (list-matching fill/border, a +left-aligned value, and a small ▾) by default; `style="retail"` opts back +into the gold face for a panel that genuinely wants it. Any other value +throws `FormatException` at `Build`. + Common to every element via `ApplyCommon`: `name`/`id` (a stable control name), `visible` (literal `true`/`false` or a bound `bool` property), `enabled` (same rule), and `tooltip` (a literal string or `{Binding}` shown diff --git a/docs/research/2026-09-07-slice7-screenshots/popup-advanced.png b/docs/research/2026-09-07-slice7-screenshots/popup-advanced.png new file mode 100644 index 00000000..10e838cd Binary files /dev/null and b/docs/research/2026-09-07-slice7-screenshots/popup-advanced.png differ diff --git a/docs/research/2026-09-07-slice7-screenshots/popup-loot-editor.png b/docs/research/2026-09-07-slice7-screenshots/popup-loot-editor.png new file mode 100644 index 00000000..6a66ee09 Binary files /dev/null and b/docs/research/2026-09-07-slice7-screenshots/popup-loot-editor.png differ diff --git a/docs/research/2026-09-07-slice7-screenshots/tab-buffs.png b/docs/research/2026-09-07-slice7-screenshots/tab-buffs.png new file mode 100644 index 00000000..25d50312 Binary files /dev/null and b/docs/research/2026-09-07-slice7-screenshots/tab-buffs.png differ diff --git a/docs/research/2026-09-07-slice7-screenshots/tab-consumables.png b/docs/research/2026-09-07-slice7-screenshots/tab-consumables.png new file mode 100644 index 00000000..9587707b Binary files /dev/null and b/docs/research/2026-09-07-slice7-screenshots/tab-consumables.png differ diff --git a/docs/research/2026-09-07-slice7-screenshots/tab-items.png b/docs/research/2026-09-07-slice7-screenshots/tab-items.png new file mode 100644 index 00000000..64c2ed37 Binary files /dev/null and b/docs/research/2026-09-07-slice7-screenshots/tab-items.png differ diff --git a/docs/research/2026-09-07-slice7-screenshots/tab-meta.png b/docs/research/2026-09-07-slice7-screenshots/tab-meta.png new file mode 100644 index 00000000..701f21b0 Binary files /dev/null and b/docs/research/2026-09-07-slice7-screenshots/tab-meta.png differ diff --git a/docs/research/2026-09-07-slice7-screenshots/tab-monsters.png b/docs/research/2026-09-07-slice7-screenshots/tab-monsters.png new file mode 100644 index 00000000..ca5d2804 Binary files /dev/null and b/docs/research/2026-09-07-slice7-screenshots/tab-monsters.png differ diff --git a/docs/research/2026-09-07-slice7-screenshots/tab-options.png b/docs/research/2026-09-07-slice7-screenshots/tab-options.png new file mode 100644 index 00000000..217935a2 Binary files /dev/null and b/docs/research/2026-09-07-slice7-screenshots/tab-options.png differ diff --git a/docs/research/2026-09-07-slice7-screenshots/tab-profiles.png b/docs/research/2026-09-07-slice7-screenshots/tab-profiles.png new file mode 100644 index 00000000..85d08b0b Binary files /dev/null and b/docs/research/2026-09-07-slice7-screenshots/tab-profiles.png differ diff --git a/docs/research/2026-09-07-slice7-screenshots/tab-route.png b/docs/research/2026-09-07-slice7-screenshots/tab-route.png new file mode 100644 index 00000000..0487d4e8 Binary files /dev/null and b/docs/research/2026-09-07-slice7-screenshots/tab-route.png differ diff --git a/docs/research/2026-09-07-slice7-screenshots/tab-vitals.png b/docs/research/2026-09-07-slice7-screenshots/tab-vitals.png new file mode 100644 index 00000000..18595704 Binary files /dev/null and b/docs/research/2026-09-07-slice7-screenshots/tab-vitals.png differ diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs index 6045c091..8b015e95 100644 --- a/src/AcDream.App/UI/MarkupDocument.cs +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -481,6 +481,7 @@ public static class MarkupDocument Func menuSelected = BindString( (string?)el.Attribute("selected"), binding); + bool menuRetailButtonArt = ValidateMenuStyle((string?)el.Attribute("style")); var menu = new UiMenu { Left = F(el, "x"), @@ -515,6 +516,7 @@ public static class MarkupDocument PopupBgSprite = 0x0600124Cu, ItemNormalSprite = 0x0600124Eu, ItemHighlightSprite = 0x0600124Du, + RetailButtonArt = menuRetailButtonArt, ButtonLabelProvider = () => menuSelected() ?? string.Empty, OnSelect = payload => { @@ -668,6 +670,25 @@ public static class MarkupDocument $"{context} must be did, spell, or item (got \"{other}\")"), }; + /// + /// Owner live-client report 2026-09-07 ("Those BIG gold/yellow buttons HAS + /// to go. That is not how vtank looks."): validates <menu + /// style="..."> and returns the + /// value it selects. Default (attribute absent, or explicit + /// style="plain") is the flat VTank/Decal HudCombo box + /// (false) — retail's gold pushbutton art is now an explicit + /// style="retail" opt-in for a plugin panel that genuinely wants + /// it. Any other value is a Build-time author error, same rule as + /// . + /// + private static bool ValidateMenuStyle(string? style) => style switch + { + null or "plain" => false, + "retail" => true, + var other => throw new FormatException( + $" must be plain or retail"), + }; + /// /// Builds the zero-argument icon resolver the <icon> element /// uses: dispatch by iconkind (default "did") to the diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index d33df13b..810886a0 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -304,6 +304,41 @@ public sealed class UiMenu : UiElement /// StateDesc (not a code symbol); ~0.5 neutral grey here pending a live cdb dump. public Vector4 TextColorGhosted { get; set; } = new(0.5f, 0.5f, 0.5f, 1f); + /// + /// Owner live-client report 2026-09-07 ("Those BIG gold/yellow buttons HAS + /// to go. That is not how vtank looks."): whether the CLOSED-state button + /// face draws retail's gold pushbutton art (/ + /// + the arrow-cap overlay) or the plain flat + /// box below () that reads as the same + /// widget family as 's dark list boxes — the + /// VTank/Decal HudCombo shape (docs/research/vtank-kb/08-ui-views.md + /// §2: a flat box, left-aligned current value, small down-arrow at the + /// right edge). Default TRUE so every existing non-plugin + /// user (chat's channel menu, vendor's category dropdown, the Config + /// option menus, the retail confirmation-dialog menu, and every generic + /// dat Type-6 element built by DatWidgetFactory) keeps its exact + /// retail look untouched; flips this to + /// false for plugin <menu> markup by default, with + /// style="retail" as the opt-out back to this art. + /// + public bool RetailButtonArt { get; set; } = true; + + // ── Plain closed-state chrome (RetailButtonArt = false). Colors mirror + // UiMarkupList's own list chrome (BackgroundColor/BorderColor/TextColor) + // so a plugin's dropdown reads as the same widget family as its lists. + public Vector4 PlainBackgroundColor { get; set; } = new(0f, 0f, 0f, 0.92f); + public Vector4 PlainBorderColor { get; set; } = new(0.46f, 0.37f, 0.16f, 1f); + /// Border tint while the popup is open or the face is physically + /// pressed — the only visual change those states make in plain mode (no + /// gold art swap, ever). + public Vector4 PlainOpenBorderColor { get; set; } = new(0.70f, 0.58f, 0.24f, 1f); + public Vector4 PlainTextColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f); + public Vector4 PlainTriangleColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f); + /// Left-inset of the plain box's value text — matches + /// 's default so a combo's text lines up + /// with the list rows beneath it. + public const float PlainPadding = 3f; + private bool _open; /// @@ -457,6 +492,12 @@ public sealed class UiMenu : UiElement protected override void OnDraw(UiRenderContext ctx) { + if (!RetailButtonArt) + { + DrawPlainClosedState(ctx); + return; + } + var resolve = SpriteResolve; // Button face (3-sliced so it can widen to fit the label) + the active-target label. @@ -486,6 +527,52 @@ public sealed class UiMenu : UiElement if (resolve is not null) DrawArrowCap(ctx, resolve); } + /// + /// The VTank/Decal HudCombo closed-state shape (see + /// 's doc comment): flat fill, 1px border, + /// left-aligned value text at , and a small ▾ + /// triangle right-aligned — no sprite or DAT quad at all, drawn entirely + /// with / + /// (the same untextured sprite-bucket primitives + /// already uses for its own chrome). Open/pressed only tints the border — + /// never a sprite swap. + /// + private void DrawPlainClosedState(UiRenderContext ctx) + { + ctx.DrawFill(0f, 0f, Width, Height, PlainBackgroundColor); + Vector4 border = (_open || _facePressed) ? PlainOpenBorderColor : PlainBorderColor; + ctx.DrawRectOutline(0f, 0f, Width, Height, border, 1f); + + string caption = ButtonLabelProvider?.Invoke() ?? ""; + UiDatFont? captionFont = ButtonDatFont ?? DatFont; + float captionLineH = captionFont?.LineHeight ?? Font?.LineHeight ?? 14f; + float textY = (Height - captionLineH) * 0.5f; + if (captionFont is { } cf) + ctx.DrawStringDat(cf, caption, PlainPadding, textY, PlainTextColor, Outline, OutlineColor); + else + ctx.DrawString(caption, PlainPadding, textY, PlainTextColor, Font); + + DrawPlainTriangle(ctx); + } + + /// + /// A 7px-wide, 4px-tall ▾ glyph built from four stacked + /// bands — the same "no DAT art, + /// just fills" technique uses for its lamp + /// glyph. Right-aligned with a small margin so it never crowds the box's + /// own border. + /// + private void DrawPlainTriangle(UiRenderContext ctx) + { + const float w = 7f, rightMargin = 6f; + float x = Width - rightMargin - w; + float y = (Height - 4f) * 0.5f; + ctx.DrawFill(x, y, w, 1f, PlainTriangleColor); + ctx.DrawFill(x + 1f, y + 1f, w - 2f, 1f, PlainTriangleColor); + ctx.DrawFill(x + 2f, y + 2f, w - 4f, 1f, PlainTriangleColor); + ctx.DrawFill(x + 3f, y + 3f, w - 6f, 1f, PlainTriangleColor); + } + // 3-slice caps for the 46px LED-arrow button face (0x06004D65): a LEFT cap holding the // round LED socket, a stretchable plain-gold MIDDLE, and a RIGHT cap holding the arrow // point. Slicing keeps the LED + arrow undistorted when the button widens to its label. diff --git a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs index a168ee97..220c63de 100644 --- a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs +++ b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs @@ -507,4 +507,58 @@ public class MarkupDocumentTests { public IGpuFrame? CurrentFrame => null; } + + // ── S7 fix ("BIG gold/yellow buttons has to go" — owner live-client + // report 2026-09-07): selects UiMenu.RetailButtonArt. + + private sealed class MenuStyleBinding + { + public IReadOnlyList Choices => ["First", "Second"]; + public string Selected { get; } = "First"; + } + + private static string MenuXml(string? styleAttribute) => + "" + + $"" + + ""; + + [Fact] + public void Menu_NoStyleAttribute_DefaultsToPlain_RetailButtonArtFalse() + { + var panel = MarkupDocument.Build(MenuXml(styleAttribute: ""), new MenuStyleBinding(), _ => (1u, 32, 32)); + var menu = Assert.IsType(panel.Children[0]); + + Assert.False(menu.RetailButtonArt); + } + + [Fact] + public void Menu_StylePlain_Explicit_RetailButtonArtFalse() + { + var panel = MarkupDocument.Build( + MenuXml(" style=\"plain\""), new MenuStyleBinding(), _ => (1u, 32, 32)); + var menu = Assert.IsType(panel.Children[0]); + + Assert.False(menu.RetailButtonArt); + } + + [Fact] + public void Menu_StyleRetail_OptsIntoTheGoldButtonArt() + { + var panel = MarkupDocument.Build( + MenuXml(" style=\"retail\""), new MenuStyleBinding(), _ => (1u, 32, 32)); + var menu = Assert.IsType(panel.Children[0]); + + Assert.True(menu.RetailButtonArt); + } + + [Fact] + public void Menu_UnknownStyle_ThrowsFormatException_NamingTheElement() + { + var ex = Assert.Throws( + () => MarkupDocument.Build( + MenuXml(" style=\"chrome\""), new MenuStyleBinding(), _ => (1u, 32, 32))); + + Assert.Contains("menu", ex.Message); + Assert.Contains("chrome", ex.Message); + } } diff --git a/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs b/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs new file mode 100644 index 00000000..013181e6 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs @@ -0,0 +1,211 @@ +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; + +/// +/// 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 <menu> closed state does not match VTank's +/// own HudCombo shape, a flat dark box with a thin border, left- +/// aligned value text, and a small down-arrow — the same widget family as +/// 's own list boxes +/// (docs/research/vtank-kb/08-ui-views.md §2). +/// +/// +/// Draw-level pins against the same / +/// apparatus MarkupIconTests uses: the +/// plain closed state ( = 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 +/// caller) is untouched — it still resolves and draws its gold +/// / face. +/// +/// +/// +/// merges CONTIGUOUS +/// same-texture draws into one segment (NextSpriteSeg'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. +/// +/// +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 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 + { + ['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)); + } +}