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

View file

@ -1945,8 +1945,12 @@ public class CharacterStatControllerTests
Assert.NotNull(scrollbar.Model);
Assert.NotNull(scrollbar.SpriteResolve);
Assert.Equal(0x06004C5Fu, scrollbar.TrackSprite);
Assert.Equal(0x06004C69u, scrollbar.UpSprite);
Assert.Equal(0x06004C6Cu, scrollbar.DownSprite);
// Retail seating (2026-08-24): top = the INCREMENT designee's
// UP-arrow art, bottom = the DECREMENT designee's DOWN-arrow.
Assert.Equal(RetailScrollbarChrome.UpNormal, scrollbar.UpSprite);
Assert.Equal(RetailScrollbarChrome.DownNormal, scrollbar.DownSprite);
Assert.Equal(RetailScrollbarChrome.UpRollover, scrollbar.UpRolloverSprite);
Assert.Equal(RetailScrollbarChrome.ThumbMidRollover, scrollbar.ThumbRolloverSprite);
}
// ── Helpers ──────────────────────────────────────────────────────────────

View file

@ -287,8 +287,14 @@ public class ChatLayoutConformanceTests
Assert.Equal(0x06004C60u, scrollbar.ThumbTopSprite);
Assert.Equal(0x06004C63u, scrollbar.ThumbSprite);
Assert.Equal(0x06004C66u, scrollbar.ThumbBotSprite);
Assert.Equal(0x06004C69u, scrollbar.UpSprite);
Assert.Equal(0x06004C6Cu, scrollbar.DownSprite);
// Retail seating (2026-08-24): UpdateScrollingArea @0x00470AA0 puts
// the INCREMENT designee (0x10000072, UP-arrow art 0x06004C6C) on the
// top button and the DECREMENT designee (0x10000071, DOWN-arrow
// 0x06004C69) on the bottom, ignoring authored Y.
Assert.Equal(0x06004C6Cu, scrollbar.UpSprite);
Assert.Equal(0x06004C69u, scrollbar.DownSprite);
Assert.Equal(0x06004C64u, scrollbar.ThumbRolloverSprite);
Assert.Equal(0x06004C65u, scrollbar.ThumbPressedSprite);
}
[Fact]

View file

@ -786,6 +786,61 @@ public class DatWidgetFactoryTests
Assert.Equal(Thumb, bar.ThumbSprite);
Assert.Equal(0u, bar.ThumbTopSprite);
Assert.Equal(0u, bar.ThumbBotSprite);
// 2026-08-24: the thumb's own rollover/pressed media survive onto
// the widget (hover highlight / held-drag art).
Assert.Equal(0x06005A12u, bar.ThumbRolloverSprite);
Assert.Equal(0x06005A13u, bar.ThumbPressedSprite);
}
/// <summary>
/// 2026-08-24 owner report ("the arrows point in the wrong direction"):
/// retail seats scrollbar buttons by DESIGNATION —
/// <c>UIElement_Scrollbar::UpdateScrollingArea @0x00470AA0</c> moves the
/// INCREMENT designee (attribute 0x77) to the top corner and the
/// DECREMENT designee (0x78) to the bottom corner regardless of authored
/// position. The real vertical base skin (0x10000455 in layout
/// 0x2100003E) authors the DOWN-arrow decrement at Y=0 and the UP-arrow
/// increment at Y=32, so the previous authored-Y ordering put the
/// down-arrow art on the TOP button.
/// </summary>
[Fact]
public void Type11_VerticalScrollbar_SeatsButtonsByDesignation_NotAuthoredPosition()
{
const uint DecrementId = 0x10000071u; // DOWN arrow, authored at Y=0
const uint IncrementId = 0x10000072u; // UP arrow, authored at Y=32
var decrement = new ElementInfo { Id = DecrementId, Type = 1u, Y = 0f, Width = 16f, Height = 16f };
decrement.StateMedia["Normal"] = (0x06004C69u, 1);
decrement.StateMedia["Normal_rollover"] = (0x06004C6Au, 1);
decrement.StateMedia["Normal_pressed"] = (0x06004C6Bu, 1);
var increment = new ElementInfo { Id = IncrementId, Type = 1u, Y = 32f, Width = 16f, Height = 16f };
increment.StateMedia["Normal"] = (0x06004C6Cu, 1);
increment.StateMedia["Normal_rollover"] = (0x06004C6Du, 1);
increment.StateMedia["Normal_pressed"] = (0x06004C6Eu, 1);
var info = new ElementInfo
{
Type = 11u,
Width = 16f,
Height = 48f,
Children = [decrement, increment],
};
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
state.Properties.Values[0x77u] = new UiPropertyValue
{ Kind = UiPropertyKind.Enum, UnsignedValue = IncrementId };
state.Properties.Values[0x78u] = new UiPropertyValue
{ Kind = UiPropertyKind.Enum, UnsignedValue = DecrementId };
info.States[UiStateInfo.DirectStateId] = state;
var bar = Assert.IsType<UiScrollbar>(DatWidgetFactory.Create(info, NoTex, null));
// Top slot = the increment designee's UP-arrow media.
Assert.Equal(0x06004C6Cu, bar.UpSprite);
Assert.Equal(0x06004C6Du, bar.UpRolloverSprite);
Assert.Equal(0x06004C6Eu, bar.UpPressedSprite);
// Bottom slot = the decrement designee's DOWN-arrow media.
Assert.Equal(0x06004C69u, bar.DownSprite);
Assert.Equal(0x06004C6Au, bar.DownRolloverSprite);
Assert.Equal(0x06004C6Bu, bar.DownPressedSprite);
}
/// <summary>
@ -810,6 +865,8 @@ public class DatWidgetFactoryTests
topCap.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", Image = new UiImageMedia(Top, 1) };
var mid = new ElementInfo { Id = 0x10000365u, Type = 3u, Y = 3f, Width = 16f, Height = 10f };
mid.StateMedia["Normal"] = (Mid, 1);
mid.StateMedia["Normal_rollover"] = (0x06004C64u, 1);
mid.StateMedia["Normal_pressed"] = (0x06004C65u, 1);
mid.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", Image = new UiImageMedia(Mid, 1) };
var botCap = new ElementInfo { Id = 0x10000366u, Type = 3u, Y = 13f, Width = 16f, Height = 3f };
botCap.StateMedia["Normal"] = (Bot, 1);
@ -838,6 +895,9 @@ public class DatWidgetFactoryTests
Assert.Equal(Top, bar.ThumbTopSprite);
Assert.Equal(Mid, bar.ThumbSprite);
Assert.Equal(Bot, bar.ThumbBotSprite);
// 2026-08-24: slice rollover/pressed media survive onto the widget.
Assert.Equal(0x06004C64u, bar.ThumbRolloverSprite);
Assert.Equal(0x06004C65u, bar.ThumbPressedSprite);
}
[Fact]

View file

@ -0,0 +1,67 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using DatReaderWriter;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// 2026-08-24 pin: the base scrollbar skin (layout <c>0x2100003E</c>)
/// authors the button DESIGNATIONS that drive retail's runtime seating
/// (<c>UIElement_Scrollbar::UpdateScrollingArea @0x00470AA0</c> moves the
/// increment designee to the top/left, the decrement designee to the
/// bottom/right, ignoring authored positions). The vertical skin
/// designates 0x77=0x10000072 (UP-arrow art) and 0x78=0x10000071
/// (DOWN-arrow art) — seating by authored Y renders both arrows upside
/// down, the owner-reported bug. Also pins the three-state media sets
/// <see cref="RetailScrollbarChrome"/> mirrors, so a DAT revision or
/// importer regression that loses a state fails loudly.
/// </summary>
[Trait("Lane", "InstalledDat")]
public sealed class ScrollbarSkinLiveDatTests
{
private static string DatDirectory =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
[InstalledDatFact]
public void VerticalBaseSkin_DesignatesUpArrowAsIncrement_WithThreeStateMedia()
{
using var dats = new DatCollection(
DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x2100003Eu);
Assert.NotNull(tree);
ElementInfo bar = Assert.Single(Flatten(tree!), e => e.Id == 0x10000455u);
Assert.True(bar.TryGetEffectiveProperty(0x77u, out UiPropertyValue inc));
Assert.True(bar.TryGetEffectiveProperty(0x78u, out UiPropertyValue dec));
Assert.Equal(0x10000072u, inc.UnsignedValue); // increment = UP arrow (authored Y=32)
Assert.Equal(0x10000071u, dec.UnsignedValue); // decrement = DOWN arrow (authored Y=0)
// Increment (top after seating): red-gem normal, amber rollover,
// highlight pressed — the RetailScrollbarChrome Up set.
ElementInfo up = Assert.Single(bar.Children, c => c.Id == 0x10000072u);
Assert.Equal(RetailScrollbarChrome.UpNormal, up.StateMedia["Normal"].File);
Assert.Equal(RetailScrollbarChrome.UpRollover, up.StateMedia["Normal_rollover"].File);
Assert.Equal(RetailScrollbarChrome.UpPressed, up.StateMedia["Normal_pressed"].File);
ElementInfo down = Assert.Single(bar.Children, c => c.Id == 0x10000071u);
Assert.Equal(RetailScrollbarChrome.DownNormal, down.StateMedia["Normal"].File);
Assert.Equal(RetailScrollbarChrome.DownRollover, down.StateMedia["Normal_rollover"].File);
Assert.Equal(RetailScrollbarChrome.DownPressed, down.StateMedia["Normal_pressed"].File);
// Thumb (structural child 1) slices each author the three states.
ElementInfo thumb = Assert.Single(bar.Children, c => c.Id == 1u);
ElementInfo mid = Assert.Single(thumb.Children, c => c.Id == 0x10000365u);
Assert.Equal(RetailScrollbarChrome.ThumbMidNormal, mid.StateMedia["Normal"].File);
Assert.Equal(RetailScrollbarChrome.ThumbMidRollover, mid.StateMedia["Normal_rollover"].File);
Assert.Equal(RetailScrollbarChrome.ThumbMidPressed, mid.StateMedia["Normal_pressed"].File);
}
private static IEnumerable<ElementInfo> Flatten(ElementInfo e)
{
yield return e;
foreach (var c in e.Children)
foreach (var d in Flatten(c))
yield return d;
}
}

View file

@ -471,6 +471,108 @@ public class UiScrollbarTests
Assert.Equal(expectedWidth, width, 3);
}
/// <summary>
/// 2026-08-24 owner report: retail shows a thumb FILLING the whole
/// track when there is nothing to scroll — the proportion attribute
/// 0x88 defaults to 1.0 in <c>UIElement_Scrollbar::UpdateLayout
/// @0x004710d0</c>, so a content-fits bar sizes the widget to the full
/// scrolling area; only input goes away with the disabled state. The
/// previous draw skipped the thumb entirely on <c>!HasOverflow</c>.
/// </summary>
[Fact]
public void NoOverflow_DrawsAFullTrackThumb()
{
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(800f, 600f));
var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
const uint topTex = 60u, midTex = 63u, botTex = 66u;
var model = new UiScrollable { ContentHeight = 150, ViewHeight = 150 };
var bar = new UiScrollbar
{
Width = 16f,
Height = 200f,
SpriteResolve = id => id is topTex or midTex or botTex ? (id, 16, 3) : (0u, 0, 0),
ThumbTopSprite = topTex,
ThumbSprite = midTex,
ThumbBotSprite = botTex,
Model = model,
};
Assert.True(bar.IsModelDisabled);
bar.DrawSelfAndChildren(ctx);
// Top cap sits at the top of the track (below the 16px up button)…
var top = Assert.Single(
renderer.DebugSpriteSegmentVerts, s => s.Texture == topTex);
float topMinY = Enumerable.Range(0, top.Verts.Count / 8)
.Min(i => top.Verts[i * 8 + 1]);
Assert.Equal(16f, topMinY, 1);
// …and the bottom cap ends at the bottom of the track (above the
// 16px down button) — a full-track thumb.
var bot = Assert.Single(
renderer.DebugSpriteSegmentVerts, s => s.Texture == botTex);
float botMaxY = Enumerable.Range(0, bot.Verts.Count / 8)
.Max(i => bot.Verts[i * 8 + 1]);
Assert.Equal(184f, botMaxY, 1);
}
/// <summary>
/// 2026-08-24 owner report: hovering the thumb highlights it
/// (Normal_rollover media — bright blue on the base skin) and holding a
/// drag shows the pressed media (authored to look like the resting
/// color). Mirrors retail's authored three-state thumb slices.
/// </summary>
[Fact]
public void ThumbHoverAndDrag_SelectRolloverAndPressedMedia()
{
var model = new UiScrollable { ContentHeight = 200, ViewHeight = 150, LineHeight = 10 };
var bar = new UiScrollbar
{
Width = 16f,
Height = 200f,
Model = model,
ThumbSprite = 1u,
ThumbRolloverSprite = 2u,
ThumbPressedSprite = 3u,
ThumbTopSprite = 10u,
ThumbTopRolloverSprite = 20u,
ThumbTopPressedSprite = 30u,
ThumbBotSprite = 100u,
ThumbBotRolloverSprite = 200u,
ThumbBotPressedSprite = 300u,
};
// Track 16..184 (168px), ratio 0.75 → thumb 16..142 at position 0.
Assert.Equal(1u, bar.ActiveThumbSpriteForTest);
// Hover over the thumb → rollover on every slice.
bar.OnEvent(new UiEvent(0u, bar, UiEventType.HoverEnter, Data1: 8, Data2: 50));
Assert.Equal(2u, bar.ActiveThumbSpriteForTest);
Assert.Equal(20u, bar.ActiveThumbTopSpriteForTest);
Assert.Equal(200u, bar.ActiveThumbBotSpriteForTest);
// Press and hold (drag) → pressed media.
bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 8, Data2: 50));
Assert.True(bar.IsDragging);
Assert.Equal(3u, bar.ActiveThumbSpriteForTest);
Assert.Equal(30u, bar.ActiveThumbTopSpriteForTest);
Assert.Equal(300u, bar.ActiveThumbBotSpriteForTest);
// Release while still over the thumb → back to the hover highlight.
bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 8, Data2: 50));
Assert.Equal(2u, bar.ActiveThumbSpriteForTest);
// Move to the track BELOW the thumb → back to normal.
bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 8, Data2: 170));
Assert.Equal(1u, bar.ActiveThumbSpriteForTest);
// Leave the bar entirely → normal.
bar.OnEvent(new UiEvent(0u, bar, UiEventType.HoverEnter, Data1: 8, Data2: 50));
bar.OnEvent(new UiEvent(0u, bar, UiEventType.HoverLeave));
Assert.Equal(1u, bar.ActiveThumbSpriteForTest);
}
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;