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

@ -137,13 +137,10 @@ public static class CharacterStatController
private const uint SkillHeaderUnusableSprite = 0x06000F89u;
private const uint RowHighlightSprite = 0x06001397u;
// Scrollbar chrome from base layout 0x2100003E, shared with chat/inventory.
private const uint ScrollTrackSprite = 0x06004C5Fu;
private const uint ScrollThumbSprite = 0x06004C63u;
private const uint ScrollThumbTop = 0x06004C60u;
private const uint ScrollThumbBot = 0x06004C66u;
private const uint ScrollUpSprite = 0x06004C69u;
private const uint ScrollDownSprite = 0x06004C6Cu;
// Scrollbar chrome from base layout 0x2100003E, shared with chat/
// inventory — sprite set + retail button seating live in
// RetailScrollbarChrome (2026-08-24: the previous local constants seated
// the DOWN-arrow art on the top button).
private enum CharacterStatTab
{
@ -681,12 +678,7 @@ public static class CharacterStatController
Func<uint, (uint handle, int w, int h)> spriteResolve)
{
bar.SpriteResolve = id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); };
bar.TrackSprite = ScrollTrackSprite;
bar.ThumbSprite = ScrollThumbSprite;
bar.ThumbTopSprite = ScrollThumbTop;
bar.ThumbBotSprite = ScrollThumbBot;
bar.UpSprite = ScrollUpSprite;
bar.DownSprite = ScrollDownSprite;
RetailScrollbarChrome.ApplyVertical(bar);
}
private static float SkillViewportWidth(UiElement statList, UiScrollbar? bar)

View file

@ -272,8 +272,11 @@ public static class ConfigOptionsPageController
public const uint ScrollThumbTop = 0x06004C60u;
public const uint ScrollThumb = 0x06004C63u;
public const uint ScrollThumbBottom = 0x06004C66u;
public const uint ScrollUp = 0x06004C69u;
public const uint ScrollDown = 0x06004C6Cu;
// Retail seating (UpdateScrollingArea @0x00470AA0): the top slot
// takes the INCREMENT designee's UP-arrow art, the bottom the
// DECREMENT designee's DOWN-arrow (see RetailScrollbarChrome).
public const uint ScrollUp = RetailScrollbarChrome.UpNormal;
public const uint ScrollDown = RetailScrollbarChrome.DownNormal;
}
/// <summary>Applies <see cref="MenuChromeSprites"/> + geometry to a

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;

View file

@ -98,13 +98,10 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
scrollbar.SpriteResolve ??= _contentsList.SpriteResolve;
// Horizontal base LayoutDesc 0x2100003E media. The compatibility
// factory treats all horizontal bars as scalar controls; this panel
// binds the authored model sprites explicitly.
scrollbar.TrackSprite = 0x06004C7Fu;
scrollbar.ThumbTopSprite = 0x06004C80u;
scrollbar.ThumbSprite = 0x06004C83u;
scrollbar.ThumbBotSprite = 0x06004C86u;
scrollbar.DownSprite = 0x06004C89u;
scrollbar.UpSprite = 0x06004C8Cu;
// binds the authored model sprites explicitly — full state set +
// retail seating from RetailScrollbarChrome (increment/left =
// 0x06004C8C, decrement/right = 0x06004C89).
RetailScrollbarChrome.ApplyHorizontal(scrollbar);
}
BindClose(layout, RequestClose);

View file

@ -181,8 +181,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// list, with a real thumb/up/down-button subtree matching
/// <see cref="UiScrollbar"/>'s own shape exactly: thumb caps
/// <c>0x06004C60</c>/<c>63</c>/<c>66</c>, up button (element
/// <c>0x10000071</c>) <c>0x06004C69</c>/<c>6A</c>/<c>6B</c>, down button
/// (element <c>0x10000072</c>) <c>0x06004C6C</c>/<c>6D</c>/<c>6E</c>,
/// <c>0x10000072</c>, retail-seated on top) <c>0x06004C6C</c>/<c>6D</c>/<c>6E</c>, down button
/// (element <c>0x10000071</c>, retail-seated on the bottom) <c>0x06004C69</c>/<c>6A</c>/<c>6B</c>,
/// track <c>0x06004C5F</c>). With 18 authored categories and only 6
/// visible rows, retail's actual rendering is a single scrolling column
/// (matching the user's reference screenshot: ~visible rows + scrollbar +
@ -288,8 +288,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
private const uint TypeMenuScrollThumbTopSprite = 0x06004C60u;
private const uint TypeMenuScrollThumbSprite = 0x06004C63u;
private const uint TypeMenuScrollThumbBottomSprite = 0x06004C66u;
private const uint TypeMenuScrollUpSprite = 0x06004C69u;
private const uint TypeMenuScrollDownSprite = 0x06004C6Cu;
// Retail seating (UpdateScrollingArea @0x00470AA0): top = the INCREMENT
// designee's UP-arrow art, bottom = the DECREMENT designee's DOWN-arrow.
private const uint TypeMenuScrollUpSprite = RetailScrollbarChrome.UpNormal;
private const uint TypeMenuScrollDownSprite = RetailScrollbarChrome.DownNormal;
/// <summary>
/// Retail's ordered category table, transcribed verbatim from

View file

@ -0,0 +1,108 @@
namespace AcDream.App.UI;
/// <summary>
/// The base scrollbar skin from LayoutDesc <c>0x2100003E</c>, seated the way
/// retail seats it at runtime. <c>UIElement_Scrollbar::UpdateScrollingArea
/// @0x00470AA0</c> ignores the buttons' authored positions: it moves the
/// INCREMENT button (attribute <c>0x77</c>) to the top/left corner
/// (<c>MoveTo(0,0)</c>) and the DECREMENT button (attribute <c>0x78</c>) to
/// the bottom/right corner. The vertical skin (element <c>0x10000455</c>)
/// designates <c>0x77=0x10000072</c> (the UP-arrow art, authored at Y=32)
/// and <c>0x78=0x10000071</c> (the DOWN-arrow art, authored at Y=0) — so
/// seating by authored Y renders both arrows upside down (the 2026-08-24
/// owner report). Each button and thumb slice authors three states:
/// Normal (red center gem / dark navy thumb), Normal_rollover (amber gem /
/// bright blue thumb), Normal_pressed (gold highlight gem / dark thumb) —
/// all live-DAT probed 2026-08-24.
/// </summary>
internal static class RetailScrollbarChrome
{
// ── Vertical skin (element 0x10000455, 16 px) ────────────────────────
internal const uint Track = 0x06004C5Fu;
/// <summary>Top button = the INCREMENT designee 0x10000072 (up arrow).</summary>
internal const uint UpNormal = 0x06004C6Cu;
internal const uint UpRollover = 0x06004C6Du;
internal const uint UpPressed = 0x06004C6Eu;
/// <summary>Bottom button = the DECREMENT designee 0x10000071 (down arrow).</summary>
internal const uint DownNormal = 0x06004C69u;
internal const uint DownRollover = 0x06004C6Au;
internal const uint DownPressed = 0x06004C6Bu;
internal const uint ThumbTopNormal = 0x06004C60u;
internal const uint ThumbTopRollover = 0x06004C61u;
internal const uint ThumbTopPressed = 0x06004C62u;
internal const uint ThumbMidNormal = 0x06004C63u;
internal const uint ThumbMidRollover = 0x06004C64u;
internal const uint ThumbMidPressed = 0x06004C65u;
internal const uint ThumbBotNormal = 0x06004C66u;
internal const uint ThumbBotRollover = 0x06004C67u;
internal const uint ThumbBotPressed = 0x06004C68u;
// ── Horizontal skin (element 0x1000036D, 16 px) ──────────────────────
internal const uint HTrack = 0x06004C7Fu;
/// <summary>Left button = the INCREMENT designee 0x1000036C.</summary>
internal const uint LeftNormal = 0x06004C8Cu;
internal const uint LeftRollover = 0x06004C8Du;
internal const uint LeftPressed = 0x06004C8Eu;
/// <summary>Right button = the DECREMENT designee 0x1000036B.</summary>
internal const uint RightNormal = 0x06004C89u;
internal const uint RightRollover = 0x06004C8Au;
internal const uint RightPressed = 0x06004C8Bu;
internal const uint HThumbTopNormal = 0x06004C80u;
internal const uint HThumbTopRollover = 0x06004C81u;
internal const uint HThumbTopPressed = 0x06004C82u;
internal const uint HThumbMidNormal = 0x06004C83u;
internal const uint HThumbMidRollover = 0x06004C84u;
internal const uint HThumbMidPressed = 0x06004C85u;
internal const uint HThumbBotNormal = 0x06004C86u;
internal const uint HThumbBotRollover = 0x06004C87u;
internal const uint HThumbBotPressed = 0x06004C88u;
/// <summary>Wires the full retail vertical skin onto <paramref name="bar"/>.</summary>
internal static void ApplyVertical(UiScrollbar bar)
{
bar.TrackSprite = Track;
bar.UpSprite = UpNormal;
bar.UpRolloverSprite = UpRollover;
bar.UpPressedSprite = UpPressed;
bar.DownSprite = DownNormal;
bar.DownRolloverSprite = DownRollover;
bar.DownPressedSprite = DownPressed;
bar.ThumbTopSprite = ThumbTopNormal;
bar.ThumbTopRolloverSprite = ThumbTopRollover;
bar.ThumbTopPressedSprite = ThumbTopPressed;
bar.ThumbSprite = ThumbMidNormal;
bar.ThumbRolloverSprite = ThumbMidRollover;
bar.ThumbPressedSprite = ThumbMidPressed;
bar.ThumbBotSprite = ThumbBotNormal;
bar.ThumbBotRolloverSprite = ThumbBotRollover;
bar.ThumbBotPressedSprite = ThumbBotPressed;
}
/// <summary>Wires the full retail horizontal skin onto <paramref name="bar"/>.
/// The leading (<see cref="UiScrollbar.UpSprite"/>) slot is the LEFT edge.</summary>
internal static void ApplyHorizontal(UiScrollbar bar)
{
bar.TrackSprite = HTrack;
bar.UpSprite = LeftNormal;
bar.UpRolloverSprite = LeftRollover;
bar.UpPressedSprite = LeftPressed;
bar.DownSprite = RightNormal;
bar.DownRolloverSprite = RightRollover;
bar.DownPressedSprite = RightPressed;
bar.ThumbTopSprite = HThumbTopNormal;
bar.ThumbTopRolloverSprite = HThumbTopRollover;
bar.ThumbTopPressedSprite = HThumbTopPressed;
bar.ThumbSprite = HThumbMidNormal;
bar.ThumbRolloverSprite = HThumbMidRollover;
bar.ThumbPressedSprite = HThumbMidPressed;
bar.ThumbBotSprite = HThumbBotNormal;
bar.ThumbBotRolloverSprite = HThumbBotRollover;
bar.ThumbBotPressedSprite = HThumbBotPressed;
}
}

View file

@ -12,9 +12,13 @@ namespace AcDream.App.UI;
/// Dat element ids (chat LayoutDesc 0x2100006F, Campaign CH slice CH6a — retired the
/// wrong 0x21000006 import): track 0x10000012 (X=384 Y=0 W=16 H=73 relative to the
/// transcript panel). The track is instanced from base layout 0x2100003E which contains
/// the full scrollbar widget with distinct up/down button children:
/// Up button element 0x10000071 — Y=0, 16×16, Normal sprite 0x06004C69.
/// Down button element 0x10000072 — Y=32, 16×16, Normal sprite 0x06004C6C.
/// the full scrollbar widget with distinct button children. IMPORTANT — retail seats
/// the buttons by DESIGNATION, not authored position: <c>UpdateScrollingArea
/// @0x00470AA0</c> moves the INCREMENT designee (attribute 0x77 = 0x10000072, the
/// UP-arrow art 0x06004C6C, authored at Y=32) to the TOP and the DECREMENT designee
/// (attribute 0x78 = 0x10000071, the DOWN-arrow art 0x06004C69, authored at Y=0) to
/// the BOTTOM. Seating by authored Y renders both arrows upside down (2026-08-24
/// owner report). <see cref="RetailScrollbarChrome"/> holds the correctly-seated set.
/// Track body sprite: 0x06004C5F (48px tall in the base template; stretched to H=68 in chat).
/// Thumb is a 3-slice: top cap 0x06004C60, middle 0x06004C63, bottom cap 0x06004C66.
/// The widget reproduces referenced button children procedurally and uses their
@ -118,10 +122,31 @@ public sealed class UiScrollbar : UiElement
/// <summary>Thumb 3-slice BOTTOM cap sprite id (0x06004C66, 3px tall).</summary>
public uint ThumbBotSprite { get; set; }
/// <summary>Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071).</summary>
/// <summary>
/// Thumb rollover/pressed media. Retail's thumb (the scrollbar's
/// structural child 1 — <c>UIElement_Scrollbar::UpdateLayout
/// @0x004710d0</c> binds <c>m_pWidget = GetChild(this, 1)</c>) authors
/// three states per slice: Normal (dark navy), Normal_rollover (bright
/// blue highlight), Normal_pressed (dark again). Hovering the thumb
/// highlights it; holding a drag shows the pressed art, which is
/// authored to look like the resting color — the 2026-08-24 owner
/// report's "highlights on hover, returns to the original color while
/// you hold". Zero ids fall back to the Normal sprites.
/// </summary>
public uint ThumbRolloverSprite { get; set; }
public uint ThumbPressedSprite { get; set; }
public uint ThumbTopRolloverSprite { get; set; }
public uint ThumbTopPressedSprite { get; set; }
public uint ThumbBotRolloverSprite { get; set; }
public uint ThumbBotPressedSprite { get; set; }
/// <summary>Top/leading button sprite id. Retail seats the INCREMENT
/// designee here (vertical base skin: 0x10000072's UP-arrow art
/// 0x06004C6C) — see the class remarks and <see cref="RetailScrollbarChrome"/>.</summary>
public uint UpSprite { get; set; }
/// <summary>Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072).</summary>
/// <summary>Bottom/trailing button sprite id. Retail seats the DECREMENT
/// designee here (vertical base skin: 0x10000071's DOWN-arrow art 0x06004C69).</summary>
public uint DownSprite { get; set; }
/// <summary>Rollover and pressed media for the start/decrement button.</summary>
@ -155,6 +180,7 @@ public sealed class UiScrollbar : UiElement
private const float CapH = 3f;
private bool _draggingThumb;
private bool _hoveredThumb;
private float _dragOffsetY;
private float _dragOffsetX;
private EndButton _hoveredButton;
@ -245,7 +271,7 @@ public sealed class UiScrollbar : UiElement
}
float travel = MathF.Max(0f, Width - thumbWidth);
float x = travel * ScalarPosition;
DrawSprite(ctx, resolve, ThumbSprite, x, 0f, thumbWidth, Height);
DrawSprite(ctx, resolve, ActiveThumbSprite, x, 0f, thumbWidth, Height);
return;
}
@ -269,20 +295,24 @@ public sealed class UiScrollbar : UiElement
DrawSprite(ctx, resolve, ActiveEndSprite,
0f, Height - incrementExtent, Width, incrementExtent);
// Thumb — only when content overflows the view. Retail 3-slice: top cap +
// tiled middle + bottom cap (base layout 0x2100003E thumb sub-elements
// 0x10000364/65/66). Falls back to a single tiled middle if the caps are unset
// Thumb — drawn even with nothing to scroll: retail's proportion
// attribute 0x88 defaults to 1.0 (UpdateLayout @0x004710d0), so a
// content-fits scrollbar shows a thumb FILLING the whole track
// (ThumbRatio clamps to 1 → full trackLen); only the page-click
// regions and input go away with the disabled state. Retail 3-slice:
// top cap + tiled middle + bottom cap (base layout 0x2100003E thumb
// sub-elements 0x10000364/65/66), each with hover/pressed state
// media. Falls back to a single tiled middle if the caps are unset
// or the thumb is too short to hold both caps.
if (m.HasOverflow)
{
float trackTop = decrementExtent;
float trackLen = MathF.Max(0f, Height - decrementExtent - incrementExtent);
var (ty, th) = ThumbRect(m, trackTop, trackLen);
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && th >= 2f * CapH)
{
DrawSprite(ctx, resolve, ThumbTopSprite, 0f, ty, Width, CapH);
DrawTiled(ctx, resolve, ThumbSprite, 0f, ty + CapH, Width, th - 2f * CapH);
DrawSprite(ctx, resolve, ThumbBotSprite, 0f, ty + th - CapH, Width, CapH);
DrawSprite(ctx, resolve, ActiveThumbTopSprite, 0f, ty, Width, CapH);
DrawTiled(ctx, resolve, ActiveThumbSprite, 0f, ty + CapH, Width, th - 2f * CapH);
DrawSprite(ctx, resolve, ActiveThumbBotSprite, 0f, ty + th - CapH, Width, CapH);
}
else
{
@ -296,11 +326,29 @@ public sealed class UiScrollbar : UiElement
// (~9 repeats on Summary's overview bar, ~2 on Skills, per
// the live capture). DrawThumbMarker draws exactly ONE
// instance at its own native size.
DrawThumbMarker(ctx, resolve, ThumbSprite, 0f, ty, Width, th, vertical: true);
DrawThumbMarker(ctx, resolve, ActiveThumbSprite, 0f, ty, Width, th, vertical: true);
}
}
}
/// <summary>
/// Picks a thumb slice's sprite for the current interaction state:
/// dragging → pressed media, hovered → rollover media, else normal —
/// mirroring retail's authored Normal/Normal_rollover/Normal_pressed
/// state machine on the thumb's slice children. Zero-id media fall
/// back to the normal sprite.
/// </summary>
private uint ActiveThumb(uint normal, uint rollover, uint pressed)
=> _draggingThumb && pressed != 0u
? pressed
: _hoveredThumb && !_draggingThumb && rollover != 0u
? rollover
: normal;
private uint ActiveThumbSprite => ActiveThumb(ThumbSprite, ThumbRolloverSprite, ThumbPressedSprite);
private uint ActiveThumbTopSprite => ActiveThumb(ThumbTopSprite, ThumbTopRolloverSprite, ThumbTopPressedSprite);
private uint ActiveThumbBotSprite => ActiveThumb(ThumbBotSprite, ThumbBotRolloverSprite, ThumbBotPressedSprite);
/// <summary>
/// R4-2 (Campaign CC gate round 1 re-test 3): draws ONE instance of a
/// single-sprite scrollbar thumb at its own native size, centered
@ -351,21 +399,22 @@ public sealed class UiScrollbar : UiElement
DrawSprite(ctx, resolve, ActiveStartSprite, 0f, 0f, decrementExtent, Height);
DrawSprite(ctx, resolve, ActiveEndSprite,
Width - incrementExtent, 0f, incrementExtent, Height);
if (!model.HasOverflow) return;
// Content-fits bars draw a FULL-track thumb, same as the vertical
// path (retail proportion attribute 0x88 defaults to 1.0).
float trackLeft = decrementExtent;
float trackLength = MathF.Max(0f, Width - decrementExtent - incrementExtent);
var (tx, tw) = ThumbRect(model, trackLeft, trackLength);
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && tw >= 2f * CapH)
{
DrawSprite(ctx, resolve, ThumbTopSprite, tx, 0f, CapH, Height);
DrawTiled(ctx, resolve, ThumbSprite, tx + CapH, 0f, tw - 2f * CapH, Height);
DrawSprite(ctx, resolve, ThumbBotSprite, tx + tw - CapH, 0f, CapH, Height);
DrawSprite(ctx, resolve, ActiveThumbTopSprite, tx, 0f, CapH, Height);
DrawTiled(ctx, resolve, ActiveThumbSprite, tx + CapH, 0f, tw - 2f * CapH, Height);
DrawSprite(ctx, resolve, ActiveThumbBotSprite, tx + tw - CapH, 0f, CapH, Height);
}
else
{
// R4-2: horizontal counterpart of the vertical fallback above.
DrawThumbMarker(ctx, resolve, ThumbSprite, tx, 0f, tw, Height, vertical: false);
DrawThumbMarker(ctx, resolve, ActiveThumbSprite, tx, 0f, tw, Height, vertical: false);
}
}
@ -390,7 +439,7 @@ public sealed class UiScrollbar : UiElement
float thumbHeight = ScalarThumbExtent(resolve, Height);
float travel = MathF.Max(0f, Height - thumbHeight);
float y = travel * ScalarPosition;
DrawSprite(ctx, resolve, ThumbSprite, 0f, y, Width, thumbHeight);
DrawSprite(ctx, resolve, ActiveThumbSprite, 0f, y, Width, thumbHeight);
}
/// <summary>Draw a sprite stretched 1:1 to the dest rect.</summary>
@ -474,6 +523,7 @@ public sealed class UiScrollbar : UiElement
if (IsModelDisabled)
{
_draggingThumb = false;
_hoveredThumb = false;
_hoveredButton = EndButton.None;
_pressedButton = EndButton.None;
return false;
@ -482,15 +532,20 @@ public sealed class UiScrollbar : UiElement
if (e.Type == UiEventType.HoverEnter)
{
_hoveredButton = ButtonAt(e.Data1, e.Data2);
_hoveredThumb = ThumbAt(e.Data1, e.Data2);
return true;
}
if (e.Type == UiEventType.HoverLeave)
{
_hoveredButton = EndButton.None;
_hoveredThumb = false;
return true;
}
if (e.Type == UiEventType.MouseMove)
{
_hoveredButton = ButtonAt(e.Data1, e.Data2);
_hoveredThumb = ThumbAt(e.Data1, e.Data2);
}
// Fix round F11: retail's chargen shade scrollbar (0x10000321) is
// authored VERTICAL (measured against the installed dat), but a
@ -758,6 +813,53 @@ public sealed class UiScrollbar : UiElement
ScalarChanged?.Invoke(ScalarPosition);
}
/// <summary>
/// Whether the point sits on the thumb, for hover-state tracking —
/// covers all four modes (model/scalar × vertical/horizontal) using the
/// SAME geometry the matching draw and MouseDown paths compute.
/// </summary>
private bool ThumbAt(float x, float y)
{
if (x < 0f || x >= Width || y < 0f || y >= Height)
return false;
if (ScalarChanged is not null)
{
if (Horizontal)
{
float thumbWidth = ScalarThumbWidth(SpriteResolve);
float thumbX = MathF.Max(0f, Width - thumbWidth) * ScalarPosition;
return x >= thumbX && x <= thumbX + thumbWidth;
}
float thumbHeight = ScalarThumbExtent(SpriteResolve, Height);
float thumbY = MathF.Max(0f, Height - thumbHeight) * ScalarPosition;
return y >= thumbY && y <= thumbY + thumbHeight;
}
if (Model is not { } m) return false;
if (Horizontal)
{
float trackLeft = AxisExtent(DecrementButtonExtent, Width);
float trackLength = MathF.Max(
0f,
Width
- AxisExtent(DecrementButtonExtent, Width)
- AxisExtent(IncrementButtonExtent, Width));
var (tx, tw) = ThumbRect(m, trackLeft, trackLength);
return x >= tx && x <= tx + tw;
}
float trackTop = AxisExtent(DecrementButtonExtent, Height);
float trackLen = MathF.Max(
0f,
Height
- AxisExtent(DecrementButtonExtent, Height)
- AxisExtent(IncrementButtonExtent, Height));
var (ty, th) = ThumbRect(m, trackTop, trackLen);
return y >= ty && y <= ty + th;
}
private EndButton ButtonAt(float x, float y)
{
if (x < 0f || x >= Width || y < 0f || y >= Height)
@ -799,6 +901,9 @@ public sealed class UiScrollbar : UiElement
internal uint ActiveStartSpriteForTest => ActiveStartSprite;
internal uint ActiveEndSpriteForTest => ActiveEndSprite;
internal uint ActiveThumbSpriteForTest => ActiveThumbSprite;
internal uint ActiveThumbTopSpriteForTest => ActiveThumbTopSprite;
internal uint ActiveThumbBotSpriteForTest => ActiveThumbBotSprite;
private static float AxisExtent(float authoredExtent, float axisLength)
=> Math.Clamp(authoredExtent, 0f, MathF.Max(0f, axisLength));