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
|
|
@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue