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

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