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

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