fix(ui): retail scrollbar parity — button seating, full-track thumb, hover/pressed states
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:
parent
35abbe1d0d
commit
8fd3d1a9f0
12 changed files with 541 additions and 64 deletions
|
|
@ -137,13 +137,10 @@ public static class CharacterStatController
|
||||||
private const uint SkillHeaderUnusableSprite = 0x06000F89u;
|
private const uint SkillHeaderUnusableSprite = 0x06000F89u;
|
||||||
private const uint RowHighlightSprite = 0x06001397u;
|
private const uint RowHighlightSprite = 0x06001397u;
|
||||||
|
|
||||||
// Scrollbar chrome from base layout 0x2100003E, shared with chat/inventory.
|
// Scrollbar chrome from base layout 0x2100003E, shared with chat/
|
||||||
private const uint ScrollTrackSprite = 0x06004C5Fu;
|
// inventory — sprite set + retail button seating live in
|
||||||
private const uint ScrollThumbSprite = 0x06004C63u;
|
// RetailScrollbarChrome (2026-08-24: the previous local constants seated
|
||||||
private const uint ScrollThumbTop = 0x06004C60u;
|
// the DOWN-arrow art on the top button).
|
||||||
private const uint ScrollThumbBot = 0x06004C66u;
|
|
||||||
private const uint ScrollUpSprite = 0x06004C69u;
|
|
||||||
private const uint ScrollDownSprite = 0x06004C6Cu;
|
|
||||||
|
|
||||||
private enum CharacterStatTab
|
private enum CharacterStatTab
|
||||||
{
|
{
|
||||||
|
|
@ -681,12 +678,7 @@ public static class CharacterStatController
|
||||||
Func<uint, (uint handle, int w, int h)> spriteResolve)
|
Func<uint, (uint handle, int w, int h)> spriteResolve)
|
||||||
{
|
{
|
||||||
bar.SpriteResolve = id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); };
|
bar.SpriteResolve = id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); };
|
||||||
bar.TrackSprite = ScrollTrackSprite;
|
RetailScrollbarChrome.ApplyVertical(bar);
|
||||||
bar.ThumbSprite = ScrollThumbSprite;
|
|
||||||
bar.ThumbTopSprite = ScrollThumbTop;
|
|
||||||
bar.ThumbBotSprite = ScrollThumbBot;
|
|
||||||
bar.UpSprite = ScrollUpSprite;
|
|
||||||
bar.DownSprite = ScrollDownSprite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static float SkillViewportWidth(UiElement statList, UiScrollbar? bar)
|
private static float SkillViewportWidth(UiElement statList, UiScrollbar? bar)
|
||||||
|
|
|
||||||
|
|
@ -272,8 +272,11 @@ public static class ConfigOptionsPageController
|
||||||
public const uint ScrollThumbTop = 0x06004C60u;
|
public const uint ScrollThumbTop = 0x06004C60u;
|
||||||
public const uint ScrollThumb = 0x06004C63u;
|
public const uint ScrollThumb = 0x06004C63u;
|
||||||
public const uint ScrollThumbBottom = 0x06004C66u;
|
public const uint ScrollThumbBottom = 0x06004C66u;
|
||||||
public const uint ScrollUp = 0x06004C69u;
|
// Retail seating (UpdateScrollingArea @0x00470AA0): the top slot
|
||||||
public const uint ScrollDown = 0x06004C6Cu;
|
// 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
|
/// <summary>Applies <see cref="MenuChromeSprites"/> + geometry to a
|
||||||
|
|
|
||||||
|
|
@ -232,16 +232,28 @@ public static class DatWidgetFactory
|
||||||
uint decrementId = ReferencedElementId(info, 0x78u);
|
uint decrementId = ReferencedElementId(info, 0x78u);
|
||||||
ElementInfo? increment = info.Children.FirstOrDefault(child => child.Id == incrementId);
|
ElementInfo? increment = info.Children.FirstOrDefault(child => child.Id == incrementId);
|
||||||
ElementInfo? decrement = info.Children.FirstOrDefault(child => child.Id == decrementId);
|
ElementInfo? decrement = info.Children.FirstOrDefault(child => child.Id == decrementId);
|
||||||
ElementInfo? leadingButton = new[] { increment, decrement }
|
|
||||||
.Where(child => child is not null)
|
// Seat by DESIGNATION, not authored position: retail's
|
||||||
.OrderBy(child => bar.Horizontal ? child!.X : child!.Y)
|
// UpdateScrollingArea @0x00470AA0 moves the increment designee to the
|
||||||
.ThenBy(child => child!.ReadOrder)
|
// top/left corner (MoveTo(0,0)) and the decrement designee to the
|
||||||
.FirstOrDefault();
|
// bottom/right corner regardless of where the layout drew them. The
|
||||||
ElementInfo? trailingButton = new[] { increment, decrement }
|
// vertical base skin authors the DOWN-arrow (0x10000071) at Y=0 and
|
||||||
.Where(child => child is not null)
|
// the UP-arrow (0x10000072) at Y=32, so the previous authored-Y
|
||||||
.OrderByDescending(child => bar.Horizontal ? child!.X : child!.Y)
|
// ordering rendered both arrows upside down (2026-08-24 owner
|
||||||
.ThenByDescending(child => child!.ReadOrder)
|
// report). Fall back to authored order only when the designations
|
||||||
.FirstOrDefault();
|
// 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.UpSprite = ButtonStateImage(leadingButton, "Normal");
|
||||||
bar.UpRolloverSprite = ButtonStateImage(leadingButton, "Normal_rollover");
|
bar.UpRolloverSprite = ButtonStateImage(leadingButton, "Normal_rollover");
|
||||||
bar.UpPressedSprite = ButtonStateImage(leadingButton, "Normal_pressed");
|
bar.UpPressedSprite = ButtonStateImage(leadingButton, "Normal_pressed");
|
||||||
|
|
@ -323,9 +335,24 @@ public static class DatWidgetFactory
|
||||||
.OrderBy(child => child.Y)
|
.OrderBy(child => child.Y)
|
||||||
.ThenBy(child => child.ReadOrder)
|
.ThenBy(child => child.ReadOrder)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
if (slices.Length > 0) bar.ThumbTopSprite = DefaultImage(slices[0]);
|
if (slices.Length > 0)
|
||||||
if (slices.Length > 1) bar.ThumbSprite = DefaultImage(slices[1]);
|
{
|
||||||
if (slices.Length > 2) bar.ThumbBotSprite = DefaultImage(slices[^1]);
|
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
|
// R3-4/R3-7 (Campaign CC gate round 1 re-test 2): retail authors
|
||||||
// TWO distinct thumb shapes for UIElement_Scrollbar (Type 11) —
|
// 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`
|
// slice children (chat) is unaffected since `slices.Length == 0`
|
||||||
// is false for that shape.
|
// is false for that shape.
|
||||||
if (slices.Length == 0)
|
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;
|
return bar;
|
||||||
|
|
|
||||||
|
|
@ -98,13 +98,10 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
|
||||||
scrollbar.SpriteResolve ??= _contentsList.SpriteResolve;
|
scrollbar.SpriteResolve ??= _contentsList.SpriteResolve;
|
||||||
// Horizontal base LayoutDesc 0x2100003E media. The compatibility
|
// Horizontal base LayoutDesc 0x2100003E media. The compatibility
|
||||||
// factory treats all horizontal bars as scalar controls; this panel
|
// factory treats all horizontal bars as scalar controls; this panel
|
||||||
// binds the authored model sprites explicitly.
|
// binds the authored model sprites explicitly — full state set +
|
||||||
scrollbar.TrackSprite = 0x06004C7Fu;
|
// retail seating from RetailScrollbarChrome (increment/left =
|
||||||
scrollbar.ThumbTopSprite = 0x06004C80u;
|
// 0x06004C8C, decrement/right = 0x06004C89).
|
||||||
scrollbar.ThumbSprite = 0x06004C83u;
|
RetailScrollbarChrome.ApplyHorizontal(scrollbar);
|
||||||
scrollbar.ThumbBotSprite = 0x06004C86u;
|
|
||||||
scrollbar.DownSprite = 0x06004C89u;
|
|
||||||
scrollbar.UpSprite = 0x06004C8Cu;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BindClose(layout, RequestClose);
|
BindClose(layout, RequestClose);
|
||||||
|
|
|
||||||
|
|
@ -181,8 +181,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
/// list, with a real thumb/up/down-button subtree matching
|
/// list, with a real thumb/up/down-button subtree matching
|
||||||
/// <see cref="UiScrollbar"/>'s own shape exactly: thumb caps
|
/// <see cref="UiScrollbar"/>'s own shape exactly: thumb caps
|
||||||
/// <c>0x06004C60</c>/<c>63</c>/<c>66</c>, up button (element
|
/// <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
|
/// <c>0x10000072</c>, retail-seated on top) <c>0x06004C6C</c>/<c>6D</c>/<c>6E</c>, down button
|
||||||
/// (element <c>0x10000072</c>) <c>0x06004C6C</c>/<c>6D</c>/<c>6E</c>,
|
/// (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
|
/// track <c>0x06004C5F</c>). With 18 authored categories and only 6
|
||||||
/// visible rows, retail's actual rendering is a single scrolling column
|
/// visible rows, retail's actual rendering is a single scrolling column
|
||||||
/// (matching the user's reference screenshot: ~visible rows + scrollbar +
|
/// (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 TypeMenuScrollThumbTopSprite = 0x06004C60u;
|
||||||
private const uint TypeMenuScrollThumbSprite = 0x06004C63u;
|
private const uint TypeMenuScrollThumbSprite = 0x06004C63u;
|
||||||
private const uint TypeMenuScrollThumbBottomSprite = 0x06004C66u;
|
private const uint TypeMenuScrollThumbBottomSprite = 0x06004C66u;
|
||||||
private const uint TypeMenuScrollUpSprite = 0x06004C69u;
|
// Retail seating (UpdateScrollingArea @0x00470AA0): top = the INCREMENT
|
||||||
private const uint TypeMenuScrollDownSprite = 0x06004C6Cu;
|
// 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>
|
/// <summary>
|
||||||
/// Retail's ordered category table, transcribed verbatim from
|
/// Retail's ordered category table, transcribed verbatim from
|
||||||
|
|
|
||||||
108
src/AcDream.App/UI/RetailScrollbarChrome.cs
Normal file
108
src/AcDream.App/UI/RetailScrollbarChrome.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,9 +12,13 @@ namespace AcDream.App.UI;
|
||||||
/// Dat element ids (chat LayoutDesc 0x2100006F, Campaign CH slice CH6a — retired the
|
/// 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
|
/// 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
|
/// transcript panel). The track is instanced from base layout 0x2100003E which contains
|
||||||
/// the full scrollbar widget with distinct up/down button children:
|
/// the full scrollbar widget with distinct button children. IMPORTANT — retail seats
|
||||||
/// Up button element 0x10000071 — Y=0, 16×16, Normal sprite 0x06004C69.
|
/// the buttons by DESIGNATION, not authored position: <c>UpdateScrollingArea
|
||||||
/// Down button element 0x10000072 — Y=32, 16×16, Normal sprite 0x06004C6C.
|
/// @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).
|
/// 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.
|
/// Thumb is a 3-slice: top cap 0x06004C60, middle 0x06004C63, bottom cap 0x06004C66.
|
||||||
/// The widget reproduces referenced button children procedurally and uses their
|
/// 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>
|
/// <summary>Thumb 3-slice BOTTOM cap sprite id (0x06004C66, 3px tall).</summary>
|
||||||
public uint ThumbBotSprite { get; set; }
|
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; }
|
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; }
|
public uint DownSprite { get; set; }
|
||||||
|
|
||||||
/// <summary>Rollover and pressed media for the start/decrement button.</summary>
|
/// <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 const float CapH = 3f;
|
||||||
|
|
||||||
private bool _draggingThumb;
|
private bool _draggingThumb;
|
||||||
|
private bool _hoveredThumb;
|
||||||
private float _dragOffsetY;
|
private float _dragOffsetY;
|
||||||
private float _dragOffsetX;
|
private float _dragOffsetX;
|
||||||
private EndButton _hoveredButton;
|
private EndButton _hoveredButton;
|
||||||
|
|
@ -245,7 +271,7 @@ public sealed class UiScrollbar : UiElement
|
||||||
}
|
}
|
||||||
float travel = MathF.Max(0f, Width - thumbWidth);
|
float travel = MathF.Max(0f, Width - thumbWidth);
|
||||||
float x = travel * ScalarPosition;
|
float x = travel * ScalarPosition;
|
||||||
DrawSprite(ctx, resolve, ThumbSprite, x, 0f, thumbWidth, Height);
|
DrawSprite(ctx, resolve, ActiveThumbSprite, x, 0f, thumbWidth, Height);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -269,20 +295,24 @@ public sealed class UiScrollbar : UiElement
|
||||||
DrawSprite(ctx, resolve, ActiveEndSprite,
|
DrawSprite(ctx, resolve, ActiveEndSprite,
|
||||||
0f, Height - incrementExtent, Width, incrementExtent);
|
0f, Height - incrementExtent, Width, incrementExtent);
|
||||||
|
|
||||||
// Thumb — only when content overflows the view. Retail 3-slice: top cap +
|
// Thumb — drawn even with nothing to scroll: retail's proportion
|
||||||
// tiled middle + bottom cap (base layout 0x2100003E thumb sub-elements
|
// attribute 0x88 defaults to 1.0 (UpdateLayout @0x004710d0), so a
|
||||||
// 0x10000364/65/66). Falls back to a single tiled middle if the caps are unset
|
// 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.
|
// or the thumb is too short to hold both caps.
|
||||||
if (m.HasOverflow)
|
|
||||||
{
|
{
|
||||||
float trackTop = decrementExtent;
|
float trackTop = decrementExtent;
|
||||||
float trackLen = MathF.Max(0f, Height - decrementExtent - incrementExtent);
|
float trackLen = MathF.Max(0f, Height - decrementExtent - incrementExtent);
|
||||||
var (ty, th) = ThumbRect(m, trackTop, trackLen);
|
var (ty, th) = ThumbRect(m, trackTop, trackLen);
|
||||||
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && th >= 2f * CapH)
|
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && th >= 2f * CapH)
|
||||||
{
|
{
|
||||||
DrawSprite(ctx, resolve, ThumbTopSprite, 0f, ty, Width, CapH);
|
DrawSprite(ctx, resolve, ActiveThumbTopSprite, 0f, ty, Width, CapH);
|
||||||
DrawTiled(ctx, resolve, ThumbSprite, 0f, ty + CapH, Width, th - 2f * CapH);
|
DrawTiled(ctx, resolve, ActiveThumbSprite, 0f, ty + CapH, Width, th - 2f * CapH);
|
||||||
DrawSprite(ctx, resolve, ThumbBotSprite, 0f, ty + th - CapH, Width, CapH);
|
DrawSprite(ctx, resolve, ActiveThumbBotSprite, 0f, ty + th - CapH, Width, CapH);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -296,11 +326,29 @@ public sealed class UiScrollbar : UiElement
|
||||||
// (~9 repeats on Summary's overview bar, ~2 on Skills, per
|
// (~9 repeats on Summary's overview bar, ~2 on Skills, per
|
||||||
// the live capture). DrawThumbMarker draws exactly ONE
|
// the live capture). DrawThumbMarker draws exactly ONE
|
||||||
// instance at its own native size.
|
// 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>
|
/// <summary>
|
||||||
/// R4-2 (Campaign CC gate round 1 re-test 3): draws ONE instance of a
|
/// 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
|
/// 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, ActiveStartSprite, 0f, 0f, decrementExtent, Height);
|
||||||
DrawSprite(ctx, resolve, ActiveEndSprite,
|
DrawSprite(ctx, resolve, ActiveEndSprite,
|
||||||
Width - incrementExtent, 0f, incrementExtent, Height);
|
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 trackLeft = decrementExtent;
|
||||||
float trackLength = MathF.Max(0f, Width - decrementExtent - incrementExtent);
|
float trackLength = MathF.Max(0f, Width - decrementExtent - incrementExtent);
|
||||||
var (tx, tw) = ThumbRect(model, trackLeft, trackLength);
|
var (tx, tw) = ThumbRect(model, trackLeft, trackLength);
|
||||||
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && tw >= 2f * CapH)
|
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && tw >= 2f * CapH)
|
||||||
{
|
{
|
||||||
DrawSprite(ctx, resolve, ThumbTopSprite, tx, 0f, CapH, Height);
|
DrawSprite(ctx, resolve, ActiveThumbTopSprite, tx, 0f, CapH, Height);
|
||||||
DrawTiled(ctx, resolve, ThumbSprite, tx + CapH, 0f, tw - 2f * CapH, Height);
|
DrawTiled(ctx, resolve, ActiveThumbSprite, tx + CapH, 0f, tw - 2f * CapH, Height);
|
||||||
DrawSprite(ctx, resolve, ThumbBotSprite, tx + tw - CapH, 0f, CapH, Height);
|
DrawSprite(ctx, resolve, ActiveThumbBotSprite, tx + tw - CapH, 0f, CapH, Height);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// R4-2: horizontal counterpart of the vertical fallback above.
|
// 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 thumbHeight = ScalarThumbExtent(resolve, Height);
|
||||||
float travel = MathF.Max(0f, Height - thumbHeight);
|
float travel = MathF.Max(0f, Height - thumbHeight);
|
||||||
float y = travel * ScalarPosition;
|
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>
|
/// <summary>Draw a sprite stretched 1:1 to the dest rect.</summary>
|
||||||
|
|
@ -474,6 +523,7 @@ public sealed class UiScrollbar : UiElement
|
||||||
if (IsModelDisabled)
|
if (IsModelDisabled)
|
||||||
{
|
{
|
||||||
_draggingThumb = false;
|
_draggingThumb = false;
|
||||||
|
_hoveredThumb = false;
|
||||||
_hoveredButton = EndButton.None;
|
_hoveredButton = EndButton.None;
|
||||||
_pressedButton = EndButton.None;
|
_pressedButton = EndButton.None;
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -482,15 +532,20 @@ public sealed class UiScrollbar : UiElement
|
||||||
if (e.Type == UiEventType.HoverEnter)
|
if (e.Type == UiEventType.HoverEnter)
|
||||||
{
|
{
|
||||||
_hoveredButton = ButtonAt(e.Data1, e.Data2);
|
_hoveredButton = ButtonAt(e.Data1, e.Data2);
|
||||||
|
_hoveredThumb = ThumbAt(e.Data1, e.Data2);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (e.Type == UiEventType.HoverLeave)
|
if (e.Type == UiEventType.HoverLeave)
|
||||||
{
|
{
|
||||||
_hoveredButton = EndButton.None;
|
_hoveredButton = EndButton.None;
|
||||||
|
_hoveredThumb = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (e.Type == UiEventType.MouseMove)
|
if (e.Type == UiEventType.MouseMove)
|
||||||
|
{
|
||||||
_hoveredButton = ButtonAt(e.Data1, e.Data2);
|
_hoveredButton = ButtonAt(e.Data1, e.Data2);
|
||||||
|
_hoveredThumb = ThumbAt(e.Data1, e.Data2);
|
||||||
|
}
|
||||||
|
|
||||||
// Fix round F11: retail's chargen shade scrollbar (0x10000321) is
|
// Fix round F11: retail's chargen shade scrollbar (0x10000321) is
|
||||||
// authored VERTICAL (measured against the installed dat), but a
|
// authored VERTICAL (measured against the installed dat), but a
|
||||||
|
|
@ -758,6 +813,53 @@ public sealed class UiScrollbar : UiElement
|
||||||
ScalarChanged?.Invoke(ScalarPosition);
|
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)
|
private EndButton ButtonAt(float x, float y)
|
||||||
{
|
{
|
||||||
if (x < 0f || x >= Width || y < 0f || y >= Height)
|
if (x < 0f || x >= Width || y < 0f || y >= Height)
|
||||||
|
|
@ -799,6 +901,9 @@ public sealed class UiScrollbar : UiElement
|
||||||
|
|
||||||
internal uint ActiveStartSpriteForTest => ActiveStartSprite;
|
internal uint ActiveStartSpriteForTest => ActiveStartSprite;
|
||||||
internal uint ActiveEndSpriteForTest => ActiveEndSprite;
|
internal uint ActiveEndSpriteForTest => ActiveEndSprite;
|
||||||
|
internal uint ActiveThumbSpriteForTest => ActiveThumbSprite;
|
||||||
|
internal uint ActiveThumbTopSpriteForTest => ActiveThumbTopSprite;
|
||||||
|
internal uint ActiveThumbBotSpriteForTest => ActiveThumbBotSprite;
|
||||||
|
|
||||||
private static float AxisExtent(float authoredExtent, float axisLength)
|
private static float AxisExtent(float authoredExtent, float axisLength)
|
||||||
=> Math.Clamp(authoredExtent, 0f, MathF.Max(0f, axisLength));
|
=> Math.Clamp(authoredExtent, 0f, MathF.Max(0f, axisLength));
|
||||||
|
|
|
||||||
|
|
@ -1945,8 +1945,12 @@ public class CharacterStatControllerTests
|
||||||
Assert.NotNull(scrollbar.Model);
|
Assert.NotNull(scrollbar.Model);
|
||||||
Assert.NotNull(scrollbar.SpriteResolve);
|
Assert.NotNull(scrollbar.SpriteResolve);
|
||||||
Assert.Equal(0x06004C5Fu, scrollbar.TrackSprite);
|
Assert.Equal(0x06004C5Fu, scrollbar.TrackSprite);
|
||||||
Assert.Equal(0x06004C69u, scrollbar.UpSprite);
|
// Retail seating (2026-08-24): top = the INCREMENT designee's
|
||||||
Assert.Equal(0x06004C6Cu, scrollbar.DownSprite);
|
// 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 ──────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -287,8 +287,14 @@ public class ChatLayoutConformanceTests
|
||||||
Assert.Equal(0x06004C60u, scrollbar.ThumbTopSprite);
|
Assert.Equal(0x06004C60u, scrollbar.ThumbTopSprite);
|
||||||
Assert.Equal(0x06004C63u, scrollbar.ThumbSprite);
|
Assert.Equal(0x06004C63u, scrollbar.ThumbSprite);
|
||||||
Assert.Equal(0x06004C66u, scrollbar.ThumbBotSprite);
|
Assert.Equal(0x06004C66u, scrollbar.ThumbBotSprite);
|
||||||
Assert.Equal(0x06004C69u, scrollbar.UpSprite);
|
// Retail seating (2026-08-24): UpdateScrollingArea @0x00470AA0 puts
|
||||||
Assert.Equal(0x06004C6Cu, scrollbar.DownSprite);
|
// 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]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -786,6 +786,61 @@ public class DatWidgetFactoryTests
|
||||||
Assert.Equal(Thumb, bar.ThumbSprite);
|
Assert.Equal(Thumb, bar.ThumbSprite);
|
||||||
Assert.Equal(0u, bar.ThumbTopSprite);
|
Assert.Equal(0u, bar.ThumbTopSprite);
|
||||||
Assert.Equal(0u, bar.ThumbBotSprite);
|
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>
|
/// <summary>
|
||||||
|
|
@ -810,6 +865,8 @@ public class DatWidgetFactoryTests
|
||||||
topCap.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", Image = new UiImageMedia(Top, 1) };
|
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 };
|
var mid = new ElementInfo { Id = 0x10000365u, Type = 3u, Y = 3f, Width = 16f, Height = 10f };
|
||||||
mid.StateMedia["Normal"] = (Mid, 1);
|
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) };
|
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 };
|
var botCap = new ElementInfo { Id = 0x10000366u, Type = 3u, Y = 13f, Width = 16f, Height = 3f };
|
||||||
botCap.StateMedia["Normal"] = (Bot, 1);
|
botCap.StateMedia["Normal"] = (Bot, 1);
|
||||||
|
|
@ -838,6 +895,9 @@ public class DatWidgetFactoryTests
|
||||||
Assert.Equal(Top, bar.ThumbTopSprite);
|
Assert.Equal(Top, bar.ThumbTopSprite);
|
||||||
Assert.Equal(Mid, bar.ThumbSprite);
|
Assert.Equal(Mid, bar.ThumbSprite);
|
||||||
Assert.Equal(Bot, bar.ThumbBotSprite);
|
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]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -471,6 +471,108 @@ public class UiScrollbarTests
|
||||||
Assert.Equal(expectedWidth, width, 3);
|
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
|
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
|
||||||
{
|
{
|
||||||
public IGpuFrame? CurrentFrame => null;
|
public IGpuFrame? CurrentFrame => null;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue