diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index ab510ba1..3728c01c 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -24,6 +24,14 @@ 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.
+## #351 — LandblockBuildOriginTests.FarLoad_StripsEnvCellsAndPhysics flake (Debug, load-sensitive)
+
+**Status:** OPEN (filed 2026-08-08). Seen three times today in Debug runs
+under parallel-agent load (5.4 implementer, Slice 6 corrections, dropdown
+polish), each time proven pre-existing by re-running against the pristine
+base commit; never reproduces in clean-room Release. An async streaming
+timing assumption most likely; deflake when it next blocks a verdict.
+
## #350 — Render-shadow ledger overflow after 2h42m: lifetime int counters in a never-reset accumulator
**Status:** FIXED IN TREE 2026-08-08 (pending clean-room + landing).
diff --git a/src/AcDream.App/UI/Layout/VendorUiController.cs b/src/AcDream.App/UI/Layout/VendorUiController.cs
index 7e7f9399..47cb73c9 100644
--- a/src/AcDream.App/UI/Layout/VendorUiController.cs
+++ b/src/AcDream.App/UI/Layout/VendorUiController.cs
@@ -176,18 +176,68 @@ public sealed class VendorUiController : IRetainedPanelController
/// literally what vendor's OWN button-face child (0x1000034D)
/// resolves to in the fixture, and 0x060012B4 (the row
/// template's "Highlight" state) is the paired open/pressed look. This
- /// is a deliberate, bounded choice: retail's actual closed-button
- /// chrome is a separate two-piece label+arrow assembly
- /// (0x1000034D label + 0x1000034E a 17x19 arrow cap with
- /// its own Normal/Highlight sprites 0x060012B1/0x060012B2),
- /// and 's existing single-texture 3-slice
- /// DrawButtonFace (end caps sized for chat's 46px LED-arrow
- /// texture) does not model a second overlaid arrow icon. Reworking
- /// DrawButtonFace into a two-piece label+arrow renderer would
- /// have meant redesigning UiMenu's draw model for a purely cosmetic
- /// gain while the actual reported bug (nothing renders at all) is
- /// fixed by this simpler substitution; the arrow glyph gap is recorded
- /// as a residual visual approximation (see the register, AP-161).
+ /// remains a deliberate, bounded choice — 's
+ /// single-texture 3-slice DrawButtonFace (end caps sized for
+ /// chat's 46px LED-arrow texture) is not redesigned into a two-piece
+ /// label+arrow renderer for this plain gold body.
+ ///
+ ///
+ /// G6 (vendor gate finding, item 1 — the missing green arrow
+ /// indicator): closes the arrow-cap half of AP-161's residual.
+ /// Retail's actual closed-button chrome IS a separate two-piece
+ /// label+arrow assembly — label 0x1000034D (100x18, HJustify
+ /// LEFT, verified via the fixture) + a SEPARATE 17x19 arrow-cap image
+ /// 0x1000034E docked at the button's right edge (X=100,Y=0
+ /// within the 117-wide button), with its own Normal/Highlight states
+ /// resolving to 0x060012B1 (closed)/0x060012B2 (open) —
+ /// verified live via tools/VendorLayoutScan's resolved
+ /// command reading the element's two StateMedia images directly out of
+ /// client_local_English.dat. /
+ /// draw this AS AN OVERLAY on top
+ /// of the existing 3-slice body (additive, not a DrawButtonFace rewrite)
+ /// so the green arrow now flips down (closed)/up (open) exactly like
+ /// retail, without touching chat's face (whose arrow is baked into its
+ /// own texture and never sets these).
+ ///
+ ///
+ /// G7 (vendor gate finding, item 2 — the popup opened upward instead
+ /// of downward). Retail's UIElement_Menu::Open
+ /// (pc:120210-120252, 0x0046cc30) places the popup at
+ /// ScreenY1(button) (the button's own BOTTOM edge — i.e. BELOW
+ /// it) unless the menu authors bool attribute 5 true, in which case it
+ /// places it at ScreenY0(button) - popupHeight (ABOVE).
+ /// UIElement::GetAttribute_Bool (pc:106749-106778) defaults
+ /// an ABSENT attribute to false. Element 0x100000BF's resolved
+ /// attribute bag (the same fixture cited throughout this doc) carries NO
+ /// property "5" at all — unlike chat's channel menu (0x10000014 in
+ /// LayoutDesc 0x21000006), which explicitly authors property "5" =
+ /// true. So retail opens vendor's dropdown DOWNWARD and chat's
+ /// UPWARD — two different authored choices, not one hardcoded direction.
+ /// (default true, preserving
+ /// chat's untouched behavior) is set false here to match.
+ /// Retail's Open has no dynamic screen-edge clamp or flip of its
+ /// own — the direction is the fixed authored attribute, full stop — so
+ /// none is added here either; see 's own
+ /// doc for the citation.
+ ///
+ ///
+ /// G8 (vendor gate finding, item 3 — popup/button text overflowing
+ /// instead of sitting flush left).
+ /// (19px) and (20px) are CHAT's
+ /// own authored offsets — clearing space for a baked-in row checkbox and
+ /// a button-face LED socket, respectively. Vendor's row template
+ /// (0x10000352, live-dat verified HJustify=Left) has no
+ /// checkbox child at all, and vendor's button label child
+ /// (0x1000034D) is itself HJustify=Left at X=0 with no LED
+ /// art — so both indents are set to 0 here, retail's own left-justified
+ /// UiText convention for an icon-less label (every OTHER
+ /// dat-driven UiText in this codebase sets Padding=0f).
+ /// Reusing chat's 19px row indent measurably overflowed the authored
+ /// 100px-wide row: the longest category label, "Spell Components",
+ /// measures 92px at the default retail font (Font 0x40000000,
+ /// live-measured via tools/VendorLayoutScan measure) —
+ /// 19+92=111px > the 100px row, an 11px overflow; with 0px it fits
+ /// with 8px to spare.
///
///
private const int TypeMenuRowsPerColumn = 6;
@@ -197,6 +247,10 @@ public sealed class VendorUiController : IRetainedPanelController
private const uint TypeMenuItemHighlightSprite = 0x060012B4u;
private const uint TypeMenuNormalSprite = 0x060012B3u;
private const uint TypeMenuPressedSprite = 0x060012B4u;
+ // G6: the separate arrow-cap overlay (element 0x1000034E) — see the
+ // class doc's "G6" paragraph above.
+ private const uint TypeMenuArrowCapClosedSprite = 0x060012B1u;
+ private const uint TypeMenuArrowCapOpenSprite = 0x060012B2u;
// G5 (vendor gate finding): the popup's docked scrollbar (element
// 0x10000351, verified via tools/VendorLayoutScan against the live dat —
@@ -413,6 +467,22 @@ public sealed class VendorUiController : IRetainedPanelController
_typeMenu.ScrollThumbBottomSprite = TypeMenuScrollThumbBottomSprite;
_typeMenu.ScrollUpSprite = TypeMenuScrollUpSprite;
_typeMenu.ScrollDownSprite = TypeMenuScrollDownSprite;
+ // G6: the separate open/closed arrow-cap overlay — see the class
+ // doc's "G6" paragraph above.
+ _typeMenu.ArrowCapClosedSprite = TypeMenuArrowCapClosedSprite;
+ _typeMenu.ArrowCapOpenSprite = TypeMenuArrowCapOpenSprite;
+ // G7: retail authors NO bool attribute 5 on this menu, so
+ // UIElement_Menu::Open's absent-defaults-false path opens the popup
+ // DOWNWARD (ScreenY1, the button's own bottom edge) — see the class
+ // doc's "G7" paragraph above. Chat's own menu is unaffected (its
+ // controller never touches this property, leaving UiMenu's true
+ // default).
+ _typeMenu.OpenUpward = false;
+ // G8: vendor's row/button-label children carry no checkbox/LED art
+ // to clear (unlike chat's), so both indents are flush-left (0) —
+ // see the class doc's "G8" paragraph above.
+ _typeMenu.TextIndent = 0f;
+ _typeMenu.ButtonTextIndent = 0f;
_typeMenu.OnSelect = payload =>
{
if (payload is uint mask) SelectCategory(mask);
diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs
index 044b967f..e1cb9576 100644
--- a/src/AcDream.App/UI/UiMenu.cs
+++ b/src/AcDream.App/UI/UiMenu.cs
@@ -101,12 +101,57 @@ public sealed class UiMenu : UiElement
private const int Border = RetailChromeSprites.Border; // 8-piece bevel thickness (5px)
// The row sprites 0x0600124E/4D bake a checkbox/checkmark into the leftmost ~17px
// square; the label starts just past it (box width + small gap) so text aligns with
- // the box instead of overlapping it.
- private const float TextIndent = 19f;
+ // the box instead of overlapping it. Settable (not const) because this is a CHAT-
+ // specific authored offset: vendor's category dropdown reuses a plain row-highlight
+ // sprite with no baked checkbox (LayoutDesc 0x21000043 row template 0x10000352 has
+ // no child glyph — verified via a live-dat scan), so VendorUiController overrides
+ // this to 0 — retail's own left-justified UiText convention for an icon-less label
+ // (every other dat-driven UiText in this codebase sets Padding=0f; see
+ // DatWidgetFactory.BuildText). Leaving chat's TextIndent baked in here would overflow
+ // vendor's 100px-wide row for its longest label ("Spell Components" measures 92px at
+ // the default retail font — 19+92=111 > 100, an 11px overflow; with 0, 92 < 100 fits).
+ public float TextIndent { get; set; } = 19f;
// The button face sprite (0x06004D65/66) bakes a status LED (red→green) into its
// left socket (~x4–20 of the 46px button); the caption starts past it so it doesn't
- // render over the LED.
- private const float ButtonTextIndent = 20f;
+ // render over the LED. Settable for the same reason as TextIndent: vendor's button
+ // face substitutes a row sprite with no LED, and its authored label child
+ // (0x1000034D) is itself HJustify=Left at X=0 — VendorUiController overrides this
+ // to 0 to match.
+ public float ButtonTextIndent { get; set; } = 20f;
+
+ ///
+ /// G6 (vendor gate finding, item 1 — missing arrow indicator): a SEPARATE small
+ /// image piece some menus author to the right of the button face, whose visible
+ /// state flips between closed and open. Retail: UIElement_Menu::UpdateState
+ /// (pc:120101-120105, 0x0046cad0) writes attribute 0xe (m_open)
+ /// on every open/close, which drives the arrow-cap
+ /// child's own StateDesc selection between its "Normal" (closed) and "Highlight"
+ /// (open) states. Vendor's category dropdown authors this as element
+ /// 0x1000034E — a 17x19 image docked at the right edge of the 117-wide
+ /// button (X=100,Y=0), states Normal=0x060012B1 (closed, verified live-dat
+ /// via tools/VendorLayoutScan resolved reading its two StateMedia images)
+ /// and Highlight=0x060012B2 (open) — sibling of the label child
+ /// 0x1000034D (see 's doc). Chat's own button
+ /// face BAKES its arrow into the single /
+ /// texture already (the right cap of the 46px
+ /// 0x06004D65/66 LED-arrow art), so it needs no separate overlay — these default to
+ /// 0 (no-op, skips a 0 id) and chat never sets them.
+ ///
+ public uint ArrowCapClosedSprite { get; set; }
+ public uint ArrowCapOpenSprite { get; set; }
+ /// Authored native size of the arrow-cap overlay (17x19 for vendor's
+ /// dropdown) — drawn unstretched, right-anchored to the button's own width, exactly
+ /// mirroring the authored element's own X=Width-17,Y=0 placement.
+ public float ArrowCapWidth { get; set; } = 17f;
+ public float ArrowCapHeight { get; set; } = 19f;
+
+ /// The arrow-cap sprite id would currently
+ /// draw (0 if neither is authored) — a read-only projection of _open onto
+ /// the two configured sprites, exposed so a controller/test can assert the
+ /// closed/open flip without a full render pass (the class has no OnDraw test seam
+ /// otherwise, matching how is exposed for the same
+ /// reason).
+ public uint CurrentArrowCapSprite => _open ? ArrowCapOpenSprite : ArrowCapClosedSprite;
public UiDatFont? DatFont { get; set; }
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
@@ -144,6 +189,36 @@ public sealed class UiMenu : UiElement
private float OuterW => InteriorW + 2 * Border;
private float OuterH => InteriorH + 2 * Border;
+ ///
+ /// G7 (vendor gate finding, item 2 — popup direction): port of retail
+ /// UIElement_Menu::Open (pc:120210-120252, 0x0046cc30)'s Y placement:
+ /// edi_3 = attr5 ? ScreenY0(button) - popupHeight // ABOVE
+ /// : ScreenY1(button) // BELOW (button's own bottom edge)
+ /// where attr5 is UIElement::GetAttribute_Bool(this, 5, ...) — a PER-MENU
+ /// authored bool property, not a global convention. GetAttribute_Bool
+ /// (pc:106749-106778, 0x00460be0) defaults an ABSENT property to false
+ /// (InqProperty fails -> *arg3 = 0). Verified against both fixtures:
+ /// chat's channel menu (LayoutDesc 0x21000006, element 0x10000014)
+ /// authors property "5" = BoolValue: true (opens UP), while vendor's category
+ /// dropdown (LayoutDesc 0x21000012, element 0x100000BF) has NO property
+ /// "5" at all in its resolved attribute bag (opens DOWN, the absent-defaults-false
+ /// case). There is no dynamic screen-edge flip/clamp anywhere in Open — the
+ /// direction is a fixed per-menu authored choice, not a runtime decision, so this
+ /// port does not add one either (see 's own note on why no
+ /// clamp was added). Default true preserves chat's exact upward geometry
+ /// byte-for-byte (the class's only behavior before this property existed);
+ /// sets this false to
+ /// match its own authored (absent) attribute.
+ ///
+ public bool OpenUpward { get; set; } = true;
+
+ /// Local-space Y of the popup's own top-left corner, relative to the
+ /// button's local origin (button occupies y=0..Height). Upward: the popup's BOTTOM
+ /// touches the button's TOP (y=0), so top = -. Downward: the
+ /// popup's TOP touches the button's BOTTOM (y=Height) — retail's ScreenY1.
+ /// Shared by drawing, hit-testing, and event math so all three agree.
+ private float PopupTop => OpenUpward ? -OuterH : Height;
+
public UiMenu() { CapturesPointerDrag = true; }
/// The menu draws its own button face + popup; its dat label/row children
@@ -161,6 +236,9 @@ public sealed class UiMenu : UiElement
if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw);
}
DrawLabel(ctx, ButtonLabelProvider?.Invoke() ?? "", ButtonTextIndent, (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);
}
// 3-slice caps for the 46px LED-arrow button face (0x06004D65): a LEFT cap holding the
@@ -178,6 +256,19 @@ public sealed class UiMenu : UiElement
ctx.DrawSprite(tex, Width - FaceCapR, 0f, FaceCapR, Height, uR, 0f, 1f, 1f, Vector4.One); // arrow cap
}
+ /// G6: the closed/open arrow-cap overlay (see 's
+ /// doc comment) — right-anchored, drawn at native size, unstretched (retail's own image
+ /// element draw, no 3-slice). No-op when neither sprite id is authored (chat's case).
+ private void DrawArrowCap(UiRenderContext ctx, Func resolve)
+ {
+ uint id = _open ? ArrowCapOpenSprite : ArrowCapClosedSprite;
+ if (id == 0) return;
+ var (tex, tw, th) = resolve(id);
+ if (tex == 0 || tw == 0 || th == 0) return;
+ float dx = Width - ArrowCapWidth;
+ ctx.DrawSprite(tex, dx, 0f, ArrowCapWidth, ArrowCapHeight, 0f, 0f, 1f, 1f, Vector4.One);
+ }
+
/// The button width that fits "LED cap + channel label + arrow cap" — retail
/// sizes the talk-focus button to its selected label. The controller widens the button
/// to this and reflows the input field to start after it.
@@ -215,7 +306,7 @@ public sealed class UiMenu : UiElement
/// before G5.
private void DrawGridPopup(UiRenderContext ctx, Func resolve)
{
- float outerTop = -OuterH; // popup bottom sits at the button top (y=0)
+ float outerTop = PopupTop; // G7: direction-aware (see PopupTop's doc)
float inX = Border, inY = outerTop + Border; // interior origin (inside the bevel)
DrawBevel(ctx, resolve, 0f, outerTop, OuterW, OuterH);
@@ -252,7 +343,7 @@ public sealed class UiMenu : UiElement
{
ConfigurePopupScroll();
- float outerTop = -OuterH;
+ float outerTop = PopupTop; // G7: direction-aware (see PopupTop's doc)
float inX = Border, inY = outerTop + Border;
DrawBevel(ctx, resolve, 0f, outerTop, OuterW, OuterH);
@@ -372,8 +463,12 @@ public sealed class UiMenu : UiElement
}
protected override bool OnHitTest(float lx, float ly)
- => _open ? (lx >= 0 && lx < OuterW && ly >= -OuterH && ly < Height)
- : base.OnHitTest(lx, ly);
+ {
+ if (!_open) return base.OnHitTest(lx, ly);
+ if (lx < 0 || lx >= OuterW) return false;
+ // G7: the union of the button itself + the popup, whichever side it opens on.
+ return OpenUpward ? (ly >= -OuterH && ly < Height) : (ly >= 0 && ly < Height + OuterH);
+ }
public override bool OnEvent(in UiEvent e)
{
@@ -405,11 +500,15 @@ public sealed class UiMenu : UiElement
if (e.Type != UiEventType.MouseDown) return false;
float lx = e.Data1, ly = e.Data2;
- if (_open && ly < 0) // clicked inside the upward popup
+ // G7: direction-aware — the popup occupies ly<0 when it opens upward (chat),
+ // or ly>=Height (past the button's own bottom edge) when it opens downward
+ // (vendor). See PopupTop's doc comment.
+ bool clickedInPopup = OpenUpward ? ly < 0 : ly >= Height;
+ if (_open && clickedInPopup)
{
// Map into the bevel interior, then to (col,row). Clicks in the bevel ring
// (outside the interior) just close the menu.
- float ix = lx - Border, iy = ly - (-OuterH + Border);
+ float ix = lx - Border, iy = ly - (PopupTop + Border);
if (Scrollable)
return HandleScrollablePopupMouseDown(ix, iy);
@@ -496,7 +595,7 @@ public sealed class UiMenu : UiElement
/// case, reusing for the exact same thumb height.
private void DragPopupThumb(float ly)
{
- float iy = ly - (-OuterH + Border);
+ float iy = ly - (PopupTop + Border); // G7: direction-aware
ConfigurePopupScroll();
float decExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH);
float incExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH - decExtent);
diff --git a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs
index 1e7a0bf7..e2ea658d 100644
--- a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs
@@ -822,11 +822,14 @@ public sealed class VendorUiControllerTests
// Food) -> row index 1, column 0. Derive the click point from the
// widget's own live geometry (mirrors UiMenu.OnEvent's own math)
// rather than a hardcoded pixel constant.
+ //
+ // G7: the popup opens DOWNWARD (retail authors no bool attribute 5
+ // on 0x100000BF — see the class doc's "G7" paragraph), so its top
+ // sits at the button's own bottom edge (ly = Height), not -OuterH.
const int border = 5; // RetailChromeSprites.Border (UiMenu's private bevel thickness)
- float outerH = h.TypeMenu.RowsPerColumn * h.TypeMenu.RowHeight + 2 * border;
const int targetRow = 1;
float iy = targetRow * h.TypeMenu.RowHeight + h.TypeMenu.RowHeight / 2f;
- float ly = iy - outerH + border;
+ float ly = h.TypeMenu.Height + iy + border;
Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, (int)ly)));
@@ -834,6 +837,79 @@ public sealed class VendorUiControllerTests
Assert.Equal(FoodItemGuid, h.ItemList.GetItem(0)!.ItemId);
}
+ [Fact]
+ public void CategoryMenu_OpensDownward_NotUpward_MatchingTheAuthoredAbsentAttribute5()
+ {
+ // G7: retail's UIElement_Menu::Open (pc:120210-120252) reads a PER-MENU
+ // bool attribute 5 to decide direction; it defaults ABSENT to false
+ // (pc:106749-106778) which places the popup at the button's own bottom
+ // edge. Element 0x100000BF's resolved attribute bag carries no
+ // property "5" at all (unlike chat's, which authors it true) — so
+ // OpenUpward must be false here, and the OLD upward click position
+ // (used before this fix) must no longer resolve anything.
+ var h = new Harness();
+ h.State.Apply(VendorGuid, Profile(), new[]
+ {
+ new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
+ new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
+ });
+
+ Assert.False(h.TypeMenu.OpenUpward);
+
+ Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, 5))); // open
+
+ // The OLD (pre-fix) upward click position for row 1 ("Food") — see the
+ // math this same test used before G7 — now lands above the button,
+ // where nothing lives; UiMenu treats it as an ordinary button click
+ // and just re-closes the still-open menu instead of picking a row.
+ const int border = 5;
+ float outerH = h.TypeMenu.RowsPerColumn * h.TypeMenu.RowHeight + 2 * border;
+ const int targetRow = 1;
+ float iy = targetRow * h.TypeMenu.RowHeight + h.TypeMenu.RowHeight / 2f;
+ float oldUpwardLy = iy - outerH + border;
+ Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, (int)oldUpwardLy)));
+
+ // Selection is unchanged (still Armor, the fresh-open default) and the
+ // item list was not re-scoped to Food — proving the old position no
+ // longer hits the popup at all.
+ Assert.Equal("Armor", h.TypeMenu.Items.Single(i => Equals(i.Payload, h.TypeMenu.Selected)).Label);
+ Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId);
+ }
+
+ [Fact]
+ public void CategoryMenu_ArrowCapSprites_AreWiredAndDistinctForOpenVsClosed()
+ {
+ // G6: retail's authored arrow-cap element 0x1000034E flips between its
+ // Normal (closed, 0x060012B1) and Highlight (open, 0x060012B2) states —
+ // confirm the controller actually wires both, non-zero and distinct,
+ // and that opening the dropdown flips CurrentArrowCapSprite the same
+ // way UiMenuTests exercises the mechanism generically.
+ var h = new Harness();
+ h.State.Apply(VendorGuid, Profile(), new[]
+ {
+ new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
+ });
+
+ Assert.NotEqual(0u, h.TypeMenu.ArrowCapClosedSprite);
+ Assert.NotEqual(0u, h.TypeMenu.ArrowCapOpenSprite);
+ Assert.NotEqual(h.TypeMenu.ArrowCapClosedSprite, h.TypeMenu.ArrowCapOpenSprite);
+
+ Assert.Equal(h.TypeMenu.ArrowCapClosedSprite, h.TypeMenu.CurrentArrowCapSprite); // closed by default
+ Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, 5))); // open
+ Assert.Equal(h.TypeMenu.ArrowCapOpenSprite, h.TypeMenu.CurrentArrowCapSprite);
+ }
+
+ [Fact]
+ public void CategoryMenu_TextIndentsAreFlushLeft_NotChatsCheckboxLedOffsets()
+ {
+ // G8: vendor's row template (0x10000352, live-dat HJustify=Left) has
+ // no checkbox child and its button-label child (0x1000034D) has no
+ // LED art either — both indents must be 0, not chat's 19px/20px.
+ var h = new Harness();
+ Assert.Equal(0f, h.TypeMenu.TextIndent);
+ Assert.Equal(0f, h.TypeMenu.ButtonTextIndent);
+ }
+
[Fact]
public void CloseButton_HidesTheWindowOnly_LeavesTheSessionOpenForARefreshInPlaceReopen()
{
diff --git a/tests/AcDream.App.Tests/UI/UiMenuTests.cs b/tests/AcDream.App.Tests/UI/UiMenuTests.cs
index 15240604..5b780753 100644
--- a/tests/AcDream.App.Tests/UI/UiMenuTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiMenuTests.cs
@@ -363,4 +363,130 @@ public class UiMenuTests
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, RawX(10), ly)));
Assert.NotNull(fired); // popup was still open -> the click landed on a real row
}
+
+ // ── Vendor dropdown polish (items 1-3): arrow indicator, popup direction,
+ // left alignment. See VendorUiController's "G6"/"G7"/"G8" class-doc
+ // paragraphs for the retail citations these three fixes are ported from.
+
+ [Fact]
+ public void ArrowCapSprite_FlipsBetweenClosedAndOpen_OnToggle()
+ {
+ // G6: UiMenu.CurrentArrowCapSprite projects _open onto the two
+ // configured sprites — the same selection DrawArrowCap makes every
+ // frame, exposed as a test seam since the class has no OnDraw harness.
+ var menu = new UiMenu
+ {
+ Width = 80f, Height = 18f,
+ Items = ChannelItems,
+ ArrowCapClosedSprite = 111u,
+ ArrowCapOpenSprite = 222u,
+ };
+
+ Assert.Equal(111u, menu.CurrentArrowCapSprite); // closed by default
+
+ Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
+ Assert.Equal(222u, menu.CurrentArrowCapSprite);
+
+ // A click in the popup's bevel ring (below every real row, iy >= InteriorH)
+ // closes without picking anything — the arrow must flip straight back.
+ Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -1)));
+ Assert.Equal(111u, menu.CurrentArrowCapSprite);
+ }
+
+ [Fact]
+ public void ArrowCapSprite_DefaultsToZero_NoOpForMenusThatDontAuthorOne()
+ {
+ // Chat's own controller never sets ArrowCapClosedSprite/OpenSprite (its
+ // arrow is baked into NormalSprite/PressedSprite already) — confirm the
+ // class default stays a no-op id so DrawArrowCap's `id == 0` guard fires.
+ var menu = new UiMenu();
+ Assert.Equal(0u, menu.CurrentArrowCapSprite);
+ Assert.Equal(0u, menu.ArrowCapClosedSprite);
+ Assert.Equal(0u, menu.ArrowCapOpenSprite);
+ }
+
+ [Fact]
+ public void OpenUpward_DefaultsTrue_PreservingChatsExistingUpwardGeometry()
+ {
+ // Every test above (chat's own shape) relies on this default never
+ // changing — G7 added the property but must not move chat's popup.
+ Assert.True(new UiMenu().OpenUpward);
+ }
+
+ [Fact]
+ public void OpenUpward_False_MovesThePopupBelowTheButton_NotAbove()
+ {
+ var menu = new UiMenu
+ {
+ Width = 80f, Height = 18f,
+ Items = ChannelItems,
+ Selected = (object?)ChatChannelKind.Say,
+ EnabledProvider = ChannelAvailable,
+ OpenUpward = false,
+ };
+
+ object? fired = null;
+ menu.OnSelect = p => fired = p;
+
+ Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
+
+ // Where "Chat to All" (index 2, Say) used to resolve when the popup
+ // opened UPWARD (see Select_AvailableLeftColumnItem_FiresOnSelect,
+ // ly=-76) is now empty space above the button. With OpenUpward=false
+ // this ly no longer satisfies "clicked in popup" at all, so UiMenu
+ // treats it as an ordinary button click and just re-closes the menu —
+ // it must NOT fire a selection.
+ Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -76)));
+ Assert.Null(fired);
+
+ // Re-open and click the SAME row (index 2, "Chat to All"/Say) at its
+ // NEW, downward position: the popup's top is now at the button's own
+ // bottom edge (ly = Height), not -OuterH.
+ Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
+ const int border = 5; // RetailChromeSprites.Border
+ float iy = 2 * menu.RowHeight + menu.RowHeight / 2f;
+ int ly = (int)(menu.Height + iy + border);
+ Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, ly)));
+ Assert.Equal(ChatChannelKind.Say, fired);
+ }
+
+ [Fact]
+ public void OpenUpward_False_HitTestCoversTheButtonAndTheDownwardPopup()
+ {
+ var menu = new UiMenu { Width = 80f, Height = 18f, Items = ChannelItems, OpenUpward = false };
+ Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
+
+ // Public surface only exposes hit-testing via the protected OnHitTest,
+ // so exercise it the same indirect way the rest of this file does:
+ // a MouseDown at a point inside the downward popup's bevel is accepted
+ // as "in popup" (closes without firing), proving the popup geometry
+ // itself now lives below Height rather than above 0.
+ float outerH = menu.RowsPerColumn * menu.RowHeight + 2 * 5;
+ int belowPopupBottom = (int)(menu.Height + outerH) + 1; // just past the popup's own bottom edge
+ // Outside the popup entirely -> falls through to the button-toggle path.
+ object? fired = null;
+ menu.OnSelect = p => fired = p;
+ Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, belowPopupBottom)));
+ Assert.Null(fired);
+ }
+
+ [Fact]
+ public void TextIndent_And_ButtonTextIndent_DefaultToChatsBakedIconOffsets()
+ {
+ // G8: these used to be private consts (19f/19f-checkbox, 20f-LED);
+ // converting them to settable properties must not move chat's text.
+ var menu = new UiMenu();
+ Assert.Equal(19f, menu.TextIndent);
+ Assert.Equal(20f, menu.ButtonTextIndent);
+ }
+
+ [Fact]
+ public void TextIndent_And_ButtonTextIndent_AreSettable_ForIconLessMenus()
+ {
+ // Vendor's controller sets both to 0 (no checkbox/LED art to clear) —
+ // confirm the widget actually accepts the override.
+ var menu = new UiMenu { TextIndent = 0f, ButtonTextIndent = 0f };
+ Assert.Equal(0f, menu.TextIndent);
+ Assert.Equal(0f, menu.ButtonTextIndent);
+ }
}