fix(ui): retail scrollbar parity — button seating, full-track thumb, hover/pressed states
All checks were successful
CI / linux-portable (push) Successful in 3m32s
CI / windows-gate (push) Successful in 6m15s
CI / release (push) Successful in 2m16s

Owner report (2026-08-24): our scrollbar arrows pointed the wrong way,
the thumb vanished when there was nothing to scroll, and neither the
thumb nor the arrow buttons reacted to hover/press.

All three are one retail mechanism we had not ported:

1. Seating: UIElement_Scrollbar::UpdateScrollingArea @0x00470AA0 moves
   the INCREMENT designee (attribute 0x77) to the top/left corner and
   the DECREMENT designee (0x78) to the bottom/right, ignoring authored
   positions. The vertical base skin (0x10000455 in layout 0x2100003E)
   authors the DOWN-arrow decrement at Y=0 and the UP-arrow increment
   at Y=32 (live-DAT probed; sprite art visually verified from decoded
   PNGs), so our authored-Y ordering drew both arrows upside down.
   DatWidgetFactory now seats by designation; the hand-wired sites
   (CharacterStatController, ExternalContainerController, the
   Config/Vendor menu chrome) share the new RetailScrollbarChrome
   catalog instead of local constants.

2. Full-track thumb: UpdateLayout @0x004710d0 sizes the thumb from
   proportion attribute 0x88, which DEFAULTS to 1.0 — a content-fits
   bar shows a thumb filling the whole track; disabled only removes
   input and the page regions. Our draw skipped the thumb entirely on
   !HasOverflow.

3. States: every arrow button and thumb slice authors Normal (red gem /
   dark navy), Normal_rollover (amber gem / bright blue) and
   Normal_pressed (gold highlight / dark) media. The widget now tracks
   thumb hover and selects rollover media on hover and pressed media
   while dragging; the factory extracts the thumb-state media for both
   the 3-slice and single-sprite thumb shapes.

ScrollbarSkinLiveDatTests pins the designations and state media against
the installed DAT so a revision or importer regression fails loudly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-24 19:20:43 +02:00
parent 35abbe1d0d
commit 8fd3d1a9f0
12 changed files with 541 additions and 64 deletions

View file

@ -232,16 +232,28 @@ public static class DatWidgetFactory
uint decrementId = ReferencedElementId(info, 0x78u);
ElementInfo? increment = info.Children.FirstOrDefault(child => child.Id == incrementId);
ElementInfo? decrement = info.Children.FirstOrDefault(child => child.Id == decrementId);
ElementInfo? leadingButton = new[] { increment, decrement }
.Where(child => child is not null)
.OrderBy(child => bar.Horizontal ? child!.X : child!.Y)
.ThenBy(child => child!.ReadOrder)
.FirstOrDefault();
ElementInfo? trailingButton = new[] { increment, decrement }
.Where(child => child is not null)
.OrderByDescending(child => bar.Horizontal ? child!.X : child!.Y)
.ThenByDescending(child => child!.ReadOrder)
.FirstOrDefault();
// Seat by DESIGNATION, not authored position: retail's
// UpdateScrollingArea @0x00470AA0 moves the increment designee to the
// top/left corner (MoveTo(0,0)) and the decrement designee to the
// bottom/right corner regardless of where the layout drew them. The
// vertical base skin authors the DOWN-arrow (0x10000071) at Y=0 and
// the UP-arrow (0x10000072) at Y=32, so the previous authored-Y
// ordering rendered both arrows upside down (2026-08-24 owner
// report). Fall back to authored order only when the designations
// are missing.
ElementInfo? leadingButton = increment;
ElementInfo? trailingButton = decrement;
if (leadingButton is null && trailingButton is null)
{
ElementInfo[] typeOneChildren = info.Children
.Where(child => child.Type == 1u && child.Id != 1u)
.OrderBy(child => bar.Horizontal ? child.X : child.Y)
.ThenBy(child => child.ReadOrder)
.ToArray();
leadingButton = typeOneChildren.FirstOrDefault();
trailingButton = typeOneChildren.Length > 1 ? typeOneChildren[^1] : null;
}
bar.UpSprite = ButtonStateImage(leadingButton, "Normal");
bar.UpRolloverSprite = ButtonStateImage(leadingButton, "Normal_rollover");
bar.UpPressedSprite = ButtonStateImage(leadingButton, "Normal_pressed");
@ -323,9 +335,24 @@ public static class DatWidgetFactory
.OrderBy(child => child.Y)
.ThenBy(child => child.ReadOrder)
.ToArray();
if (slices.Length > 0) bar.ThumbTopSprite = DefaultImage(slices[0]);
if (slices.Length > 1) bar.ThumbSprite = DefaultImage(slices[1]);
if (slices.Length > 2) bar.ThumbBotSprite = DefaultImage(slices[^1]);
if (slices.Length > 0)
{
bar.ThumbTopSprite = ButtonStateImage(slices[0], "Normal");
bar.ThumbTopRolloverSprite = ButtonStateImage(slices[0], "Normal_rollover");
bar.ThumbTopPressedSprite = ButtonStateImage(slices[0], "Normal_pressed");
}
if (slices.Length > 1)
{
bar.ThumbSprite = ButtonStateImage(slices[1], "Normal");
bar.ThumbRolloverSprite = ButtonStateImage(slices[1], "Normal_rollover");
bar.ThumbPressedSprite = ButtonStateImage(slices[1], "Normal_pressed");
}
if (slices.Length > 2)
{
bar.ThumbBotSprite = ButtonStateImage(slices[^1], "Normal");
bar.ThumbBotRolloverSprite = ButtonStateImage(slices[^1], "Normal_rollover");
bar.ThumbBotPressedSprite = ButtonStateImage(slices[^1], "Normal_pressed");
}
// R3-4/R3-7 (Campaign CC gate round 1 re-test 2): retail authors
// TWO distinct thumb shapes for UIElement_Scrollbar (Type 11) —
@ -350,7 +377,11 @@ public static class DatWidgetFactory
// slice children (chat) is unaffected since `slices.Length == 0`
// is false for that shape.
if (slices.Length == 0)
bar.ThumbSprite = DefaultImage(thumb);
{
bar.ThumbSprite = ButtonStateImage(thumb, "Normal");
bar.ThumbRolloverSprite = ButtonStateImage(thumb, "Normal_rollover");
bar.ThumbPressedSprite = ButtonStateImage(thumb, "Normal_pressed");
}
}
return bar;