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

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