fix(ui): vendor dropdown polish — authored arrow-cap with open/closed flip, downward popup, left-aligned rows
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

Three gate findings, each settled by authored data rather than
invention: the button face is retail's two-piece assembly and the
17x19 arrow-cap 0x1000034E now renders with its authored
Normal(closed)/Highlight(open) states; the popup direction is an
AUTHORED attribute (UIElement_Menu::Open pc:120210-120252 — bool
attr 5, chat authors upward=true, the vendor menu authors nothing and
defaults downward), so both menus are now byte-faithful with no
special case; and the 19/20px text indents were chat-specific
checkbox/LED clearances the vendor rows don't author — measured
against the live retail font, "Spell Components" overflowed by 11px
and now fits with 8px to spare. Chat's menu defaults are bit-identical
and its tests untouched.

AP-161's arrow-cap note closes. #351 files the pre-existing FarLoad
Debug flake (three sightings today, never in clean-room Release).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-08 10:59:59 +02:00
parent 5224e43890
commit 33b45ee581
5 changed files with 404 additions and 25 deletions

View file

@ -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).

View file

@ -176,18 +176,68 @@ public sealed class VendorUiController : IRetainedPanelController
/// literally what vendor's OWN button-face child (<c>0x1000034D</c>)
/// resolves to in the fixture, and <c>0x060012B4</c> (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
/// (<c>0x1000034D</c> label + <c>0x1000034E</c> a 17x19 arrow cap with
/// its own Normal/Highlight sprites <c>0x060012B1</c>/<c>0x060012B2</c>),
/// and <see cref="UiMenu"/>'s existing single-texture 3-slice
/// <c>DrawButtonFace</c> (end caps sized for chat's 46px LED-arrow
/// texture) does not model a second overlaid arrow icon. Reworking
/// <c>DrawButtonFace</c> 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 — <see cref="UiMenu"/>'s
/// single-texture 3-slice <c>DrawButtonFace</c> (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.
/// </para>
/// <para>
/// <b>G6 (vendor gate finding, item 1 — the missing green arrow
/// indicator): closes the arrow-cap half of AP-161's residual.</b>
/// Retail's actual closed-button chrome IS a separate two-piece
/// label+arrow assembly — label <c>0x1000034D</c> (100x18, HJustify
/// LEFT, verified via the fixture) + a SEPARATE 17x19 arrow-cap image
/// <c>0x1000034E</c> docked at the button's right edge (X=100,Y=0
/// within the 117-wide button), with its own Normal/Highlight states
/// resolving to <c>0x060012B1</c> (closed)/<c>0x060012B2</c> (open) —
/// verified live via <c>tools/VendorLayoutScan</c>'s <c>resolved</c>
/// command reading the element's two StateMedia images directly out of
/// <c>client_local_English.dat</c>. <see cref="UiMenu.ArrowCapClosedSprite"/>/
/// <see cref="UiMenu.ArrowCapOpenSprite"/> 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).
/// </para>
/// <para>
/// <b>G7 (vendor gate finding, item 2 — the popup opened upward instead
/// of downward).</b> Retail's <c>UIElement_Menu::Open</c>
/// (<c>pc:120210-120252</c>, <c>0x0046cc30</c>) places the popup at
/// <c>ScreenY1(button)</c> (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 <c>ScreenY0(button) - popupHeight</c> (ABOVE).
/// <c>UIElement::GetAttribute_Bool</c> (<c>pc:106749-106778</c>) defaults
/// an ABSENT attribute to false. Element <c>0x100000BF</c>'s resolved
/// attribute bag (the same fixture cited throughout this doc) carries NO
/// property "5" at all — unlike chat's channel menu (<c>0x10000014</c> in
/// LayoutDesc <c>0x21000006</c>), which explicitly authors property "5" =
/// <c>true</c>. So retail opens vendor's dropdown DOWNWARD and chat's
/// UPWARD — two different authored choices, not one hardcoded direction.
/// <see cref="UiMenu.OpenUpward"/> (default <c>true</c>, preserving
/// chat's untouched behavior) is set <c>false</c> here to match.
/// Retail's <c>Open</c> 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 <see cref="UiMenu.OpenUpward"/>'s own
/// doc for the citation.
/// </para>
/// <para>
/// <b>G8 (vendor gate finding, item 3 — popup/button text overflowing
/// instead of sitting flush left).</b> <see cref="UiMenu.TextIndent"/>
/// (19px) and <see cref="UiMenu.ButtonTextIndent"/> (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
/// (<c>0x10000352</c>, live-dat verified <c>HJustify=Left</c>) has no
/// checkbox child at all, and vendor's button label child
/// (<c>0x1000034D</c>) is itself <c>HJustify=Left</c> at X=0 with no LED
/// art — so both indents are set to 0 here, retail's own left-justified
/// <c>UiText</c> convention for an icon-less label (every OTHER
/// dat-driven <c>UiText</c> in this codebase sets <c>Padding=0f</c>).
/// 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 (<c>Font 0x40000000</c>,
/// live-measured via <c>tools/VendorLayoutScan measure</c>) —
/// 19+92=111px &gt; the 100px row, an 11px overflow; with 0px it fits
/// with 8px to spare.
/// </para>
/// </summary>
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);

View file

@ -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 (~x420 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;
/// <summary>
/// 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: <c>UIElement_Menu::UpdateState</c>
/// (pc:120101-120105, <c>0x0046cad0</c>) writes attribute <c>0xe</c> (<c>m_open</c>)
/// 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
/// <c>0x1000034E</c> — a 17x19 image docked at the right edge of the 117-wide
/// button (X=100,Y=0), states Normal=<c>0x060012B1</c> (closed, verified live-dat
/// via <c>tools/VendorLayoutScan resolved</c> reading its two StateMedia images)
/// and Highlight=<c>0x060012B2</c> (open) — sibling of the label child
/// <c>0x1000034D</c> (see <see cref="ButtonTextIndent"/>'s doc). Chat's own button
/// face BAKES its arrow into the single <see cref="NormalSprite"/>/
/// <see cref="PressedSprite"/> 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, <see cref="DrawArrowCap"/> skips a 0 id) and chat never sets them.
/// </summary>
public uint ArrowCapClosedSprite { get; set; }
public uint ArrowCapOpenSprite { get; set; }
/// <summary>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.</summary>
public float ArrowCapWidth { get; set; } = 17f;
public float ArrowCapHeight { get; set; } = 19f;
/// <summary>The arrow-cap sprite id <see cref="DrawArrowCap"/> would currently
/// draw (0 if neither is authored) — a read-only projection of <c>_open</c> 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 <see cref="PopupScroll"/> is exposed for the same
/// reason).</summary>
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;
/// <summary>
/// G7 (vendor gate finding, item 2 — popup direction): port of retail
/// <c>UIElement_Menu::Open</c> (pc:120210-120252, <c>0x0046cc30</c>)'s Y placement:
/// <c>edi_3 = attr5 ? ScreenY0(button) - popupHeight // ABOVE
/// : ScreenY1(button)</c> // BELOW (button's own bottom edge)
/// where <c>attr5</c> is <c>UIElement::GetAttribute_Bool(this, 5, ...)</c> — a PER-MENU
/// authored bool property, not a global convention. <c>GetAttribute_Bool</c>
/// (pc:106749-106778, <c>0x00460be0</c>) defaults an ABSENT property to <c>false</c>
/// (<c>InqProperty</c> fails -&gt; <c>*arg3 = 0</c>). Verified against both fixtures:
/// chat's channel menu (LayoutDesc <c>0x21000006</c>, element <c>0x10000014</c>)
/// authors property "5" = <c>BoolValue: true</c> (opens UP), while vendor's category
/// dropdown (LayoutDesc <c>0x21000012</c>, element <c>0x100000BF</c>) 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 <c>Open</c> — the
/// direction is a fixed per-menu authored choice, not a runtime decision, so this
/// port does not add one either (see <see cref="OpenUpward"/>'s own note on why no
/// clamp was added). Default <c>true</c> preserves chat's exact upward geometry
/// byte-for-byte (the class's only behavior before this property existed);
/// <see cref="AcDream.App.UI.Layout.VendorUiController"/> sets this <c>false</c> to
/// match its own authored (absent) attribute.
/// </summary>
public bool OpenUpward { get; set; } = true;
/// <summary>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 = -<see cref="OuterH"/>. Downward: the
/// popup's TOP touches the button's BOTTOM (y=Height) — retail's <c>ScreenY1</c>.
/// Shared by drawing, hit-testing, and event math so all three agree.</summary>
private float PopupTop => OpenUpward ? -OuterH : Height;
public UiMenu() { CapturesPointerDrag = true; }
/// <summary>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
}
/// <summary>G6: the closed/open arrow-cap overlay (see <see cref="ArrowCapClosedSprite"/>'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).</summary>
private void DrawArrowCap(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> 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);
}
/// <summary>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.</summary>
@ -215,7 +306,7 @@ public sealed class UiMenu : UiElement
/// before G5.</summary>
private void DrawGridPopup(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> 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 <see cref="UiScrollbar.ThumbRect"/> for the exact same thumb height.</summary>
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);

View file

@ -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()
{

View file

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