diff --git a/AGENTS.md b/AGENTS.md
index 9525771c..978805b3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -116,7 +116,7 @@ user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; the structural closeout plus interaction-completion Slice 1
-pass 293 focused tests, 3,466 App tests / 3 skips, and 7,839 complete-solution
+pass 293 focused tests, 3,472 App tests / 3 skips, and 7,845 complete-solution
tests / 5 skips.
**Current work order:** the six-slice pre-M4 world-interaction completion
diff --git a/CLAUDE.md b/CLAUDE.md
index 5fe42459..0677e9cc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -114,7 +114,7 @@ user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; the structural closeout plus interaction-completion Slice 1
-pass 293 focused tests, 3,466 App tests / 3 skips, and 7,839 complete-solution
+pass 293 focused tests, 3,472 App tests / 3 skips, and 7,845 complete-solution
tests / 5 skips.
**Current work order:** the six-slice pre-M4 world-interaction completion
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index e041cc06..b0fd0a7c 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -14,8 +14,12 @@ bodies begin: spell-bar overflow controls, status-bar Use/Assess commands,
assessment information, equipped-child picking, vendor browsing, and
server-authoritative vendor transactions. Slice 1 is active and ports the
DAT-authored arrow-only favorite-spell scrollbar through the shared retained
-scrollbar/list model. Its implementation and complete Release suite pass;
-connected overflow-arrow visual acceptance is pending.
+scrollbar/list model. The corrected import preserves authored arrow sides and
+rollover/pressed media, passive refresh preserves manual offsets, and the
+mixed-parent DAT anchor chain produces the fixed retail capacity of 18 visible
+favorite cells (nine numbered plus nine unnumbered). Its
+implementation and complete Release suite pass; connected overflow-arrow
+visual acceptance is pending.
---
diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md
index 6178bcd1..2c771bf2 100644
--- a/docs/plans/2026-07-23-world-interaction-completion.md
+++ b/docs/plans/2026-07-23-world-interaction-completion.md
@@ -43,12 +43,18 @@ Each slice begins with named-retail research, produces pseudocode and
conformance tests, updates the divergence register if required, and lands as a
separate bisectable commit. A visual gate follows each UI-bearing slice.
-Slice 1 now imports both 23-pixel arrow buttons and HideDisabled from the real
-combat fixture, shares the list's single pixel-scroll model, advances one
-32-pixel cell per press, and exposes programmatically selected spells. The
-focused 57-test set, all 3,466 App tests / 3 skips, the Release solution build,
-and all 7,839 solution tests / 5 skips pass. Only the connected visual gate
-remains before Slice 2 starts.
+Slice 1 now imports both 23-pixel arrow buttons, their rollover/pressed media,
+and HideDisabled from the real combat fixture; places their artwork by the
+authored leading/trailing positions; shares the list's single pixel-scroll
+model; advances one 32-pixel cell per press; and exposes only actively selected
+spells. Passive object/endowment refresh preserves a manual offset. The mixed
+610/800-pixel DAT anchor chain is solved to a fixed 747-pixel combat root,
+producing exactly 18 visible 32-pixel favorite cells (nine numbered plus nine
+unnumbered) without consuming the overflow. The initial 57-test/full-suite gate passed;
+the corrective 104-test focus set, 3,472 App tests / 3 skips, Release solution
+build, and 7,845 complete-solution tests / 5 skips pass. The connected gate is
+pending after user feedback on arrow sides, rollover, width, and offset
+persistence.
## Slice 1 — spell-bar overflow arrows
@@ -74,7 +80,8 @@ Named retail references and executable pseudocode are recorded in
1. Generalize `DatWidgetFactory.BuildScrollbar` so horizontal and vertical
scrollbars both import the referenced decrement/increment children,
- including their authored dimensions and HideDisabled property.
+ including their authored positions, dimensions, Normal/rollover/pressed
+ media, and HideDisabled property.
2. Generalize `UiScrollbar` to use distinct authored decrement/increment
extents for rendering, hit-testing, track geometry, and dragging. Preserve
the existing 16-pixel default for layouts whose button children are absent.
@@ -85,10 +92,14 @@ Named retail references and executable pseudocode are recorded in
`UiItemList.Scroll`, enable horizontal scrolling, and retain one independent
pixel offset per favorite tab.
5. Match `SpellCastSubMenu::SetSelected`: selecting a spell through keyboard,
- shortcut, or code scrolls that item into view.
-6. Pin the importer, arrow hit extents, 32-pixel step, no-overflow behavior,
+ shortcut, or code scrolls that item into view; passive state refresh does
+ not re-expose it.
+6. Carry the combat root's mixed-parent raw-edge policies through the complete
+ imported tree and solve for the retail 18-cell favorite viewport; keep that
+ HUD capacity fixed across desktop resizes.
+7. Pin the importer, arrow hit extents, 32-pixel step, no-overflow behavior,
controller binding, and selection exposure with focused App tests.
-7. Run the App Release suite, solution Release build, and complete Release
+8. Run the App Release suite, solution Release build, and complete Release
suite. Then update the roadmap/memory and request the connected visual gate:
place more favorites than fit, scroll both directions, change tabs, and
verify arrows disappear on a non-overflowing tab.
diff --git a/docs/research/2026-07-23-retail-spellbar-overflow-pseudocode.md b/docs/research/2026-07-23-retail-spellbar-overflow-pseudocode.md
index a46b6a20..93527100 100644
--- a/docs/research/2026-07-23-retail-spellbar-overflow-pseudocode.md
+++ b/docs/research/2026-07-23-retail-spellbar-overflow-pseudocode.md
@@ -13,12 +13,12 @@ Each of the eight `SpellCastSubMenu` groups contains:
```text
favorite group
scrollbar 0x100000B5, 685x36
- decrement button 0x10000071, 23x36
+ increment reference 0x10000072, authored x=0, 23x36
+ Normal 0x06004CDE (left-facing arrow)
+ Rollover 0x06004CDF
+ decrement reference 0x10000071, authored x=63, 23x36
Normal 0x06004CDC
Rollover 0x06004CDD
- increment button 0x10000072, 23x36
- Normal 0x06004CDE
- Rollover 0x06004CDF
item list 0x100000B6, x=23, y=2, 639x32
scrollbar property 0x77 = increment button 0x10000072
@@ -28,7 +28,12 @@ item-list property 0x71 = horizontal scrollbar 0x100000B5
```
The spell-bar scrollbar has no authored track or thumb. Its 23-pixel buttons
-occupy the space immediately outside the 639-pixel item-list viewport.
+occupy the space immediately outside the 639-pixel item-list viewport. The
+property names do not determine their drawn side:
+`UIElement_Scrollbar::UpdateScrollingArea @ 0x00470AA0` places the 0x77
+increment reference at the leading/top-left edge and the 0x78 decrement
+reference at the trailing/bottom-right edge. For this horizontal fixture that
+means `0x06004CDE` on the left and `0x06004CDC` on the right.
## Initialization and ownership
@@ -85,8 +90,8 @@ UpdateScrollbarSize(horizontal):
Scrollbar.UpdateLayout():
scrollingArea = own rectangle
- position decrement button at the start and subtract its authored width
- position increment button at the end and subtract its authored width
+ position property-0x77 button at the start and subtract its authored width
+ position property-0x78 button at the end and subtract its authored width
update thumb/track geometry, when those children exist
Visible = NOT (Disabled AND HideDisabled)
```
@@ -95,6 +100,31 @@ For the favorite bar, disabled plus HideDisabled removes both arrows when every
favorite/empty cell fits. With overflow, the arrows remain visible and the
item list clips the horizontally translated cells.
+The combat LayoutDesc deliberately mixes design extents: its outer combat root
+is 610 pixels wide, while the magic page's children are authored against an
+800-pixel parent. Applying retail
+`UIElement::UpdateForParentSizeChange @ 0x00462640` through that complete
+raw-edge chain gives a 439-pixel favorite viewport at the 610-pixel root—the
+13-slot short bar seen before this slice.
+
+The retail presentation exposes 18 favorite cells: nine shortcut-numbered
+cells plus nine unnumbered cells. At 32 pixels per cell the target viewport is
+576 pixels. Because every horizontal raw-edge stage carries the parent delta
+one-for-one, solving the imported tree for that viewport yields:
+
+```text
+outer combat root = 747 px
+magic page = 737 px (5 px inset on each side)
+favorite group = 622 px (x=40, followed by Cast)
+scrollbar = 622 px (23 + 576 + 23)
+favorite list = 576 px (18 * 32)
+Cast = x=662, width=75 inside the magic page
+```
+
+This is a fixed visible-capacity HUD, not a desktop-wide strip. Stretching it
+to the display width makes the favorite content fit, which legitimately
+disables and hides the `HideDisabled` overflow arrows.
+
## Scroll delta and selection exposure
Retail:
@@ -127,15 +157,23 @@ SpellCastSubMenu.SetSelected(spellId):
```
The favorite cells are 32 pixels wide, so a line arrow moves exactly 32
-pixels. Programmatic or keyboard selection exposes the selected cell even when
-it was outside the prior viewport.
+pixels. An actual programmatic, keyboard, or pointer selection exposes the
+selected cell even when it was outside the prior viewport. Passive endowment
+or object-table refresh does not re-run `SetSelected`; doing so would undo a
+manual scrollbar offset.
## Port constraints
- Preserve the imported `0x71`/`0x77`/`0x78` relationships.
- Preserve each authored button's dimension; do not assume the 16-pixel chat
scrollbar button size.
+- Preserve each authored button's Normal, Normal_rollover, and Normal_pressed
+ media through the shared scrollbar presentation path.
- Do not fabricate a track/thumb for this arrow-only control.
- Derive disabled state from the shared model's content and viewport extents.
- Hide disabled controls from both drawing and hit-testing.
- Keep every favorite tab's scroll offset independent.
+- Carry the DAT raw-edge policies through the full imported combat tree and
+ solve the root width for an 18-cell (576-pixel) favorite viewport.
+- Keep the resulting 747-pixel HUD width fixed across desktop resizes; do not
+ turn the favorite bar into a desktop-wide strip.
diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
index 2e38ef31..ea25621e 100644
--- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
+++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
@@ -126,8 +126,11 @@ public static class DatWidgetFactory
///
/// Bind inherited scrollbar media structurally. Property 0x77 names the
- /// increment button and 0x78 the decrement button; the remaining Type-1
- /// child is the thumb with ordered top/middle/bottom image slices.
+ /// increment button and 0x78 the decrement button; retail
+ /// UIElement_Scrollbar::UpdateScrollingArea @ 0x00470AA0 then places
+ /// those referenced child buttons by their authored leading/trailing
+ /// positions. The remaining Type-1 child is the thumb with ordered
+ /// top/middle/bottom image slices.
///
private static UiScrollbar BuildScrollbar(
ElementInfo info,
@@ -144,17 +147,31 @@ 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);
- bar.UpSprite = decrement is null ? 0u : DefaultImage(decrement);
- bar.DownSprite = increment is null ? 0u : DefaultImage(increment);
+ 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();
+ bar.UpSprite = ButtonStateImage(leadingButton, "Normal");
+ bar.UpRolloverSprite = ButtonStateImage(leadingButton, "Normal_rollover");
+ bar.UpPressedSprite = ButtonStateImage(leadingButton, "Normal_pressed");
+ bar.DownSprite = ButtonStateImage(trailingButton, "Normal");
+ bar.DownRolloverSprite = ButtonStateImage(trailingButton, "Normal_rollover");
+ bar.DownPressedSprite = ButtonStateImage(trailingButton, "Normal_pressed");
if (info.TryGetEffectiveBool(0x79u, out bool hideDisabled))
bar.HideWhenDisabled = hideDisabled;
if (bar.Horizontal)
{
- if (decrement is { Width: > 0f })
- bar.DecrementButtonExtent = decrement.Width;
- if (increment is { Width: > 0f })
- bar.IncrementButtonExtent = increment.Width;
+ if (leadingButton is { Width: > 0f })
+ bar.DecrementButtonExtent = leadingButton.Width;
+ if (trailingButton is { Width: > 0f })
+ bar.IncrementButtonExtent = trailingButton.Width;
// Retail horizontal scrollbars use structural child ids: element 1 is
// the thumb and element 4 is the optional child-authored track.
@@ -207,10 +224,10 @@ public static class DatWidgetFactory
return bar;
}
- if (decrement is { Height: > 0f })
- bar.DecrementButtonExtent = decrement.Height;
- if (increment is { Height: > 0f })
- bar.IncrementButtonExtent = increment.Height;
+ if (leadingButton is { Height: > 0f })
+ bar.DecrementButtonExtent = leadingButton.Height;
+ if (trailingButton is { Height: > 0f })
+ bar.IncrementButtonExtent = trailingButton.Height;
ElementInfo? thumb = info.Children.FirstOrDefault(child =>
child.Type == 1u && child.Id != incrementId && child.Id != decrementId);
@@ -272,6 +289,19 @@ public static class DatWidgetFactory
return 0u;
}
+ private static uint ButtonStateImage(ElementInfo? info, string stateName)
+ {
+ if (info is null)
+ return 0u;
+ if (info.StateMedia.TryGetValue(stateName, out var media))
+ return media.File;
+ UiStateInfo? state = info.States.Values.FirstOrDefault(
+ candidate => string.Equals(candidate.Name, stateName, StringComparison.Ordinal));
+ if (state?.Image is { } image)
+ return image.File;
+ return stateName == "Normal" ? DefaultImage(info) : 0u;
+ }
+
// ── Meter ────────────────────────────────────────────────────────────────
///
diff --git a/src/AcDream.App/UI/Layout/RetailCombatLayout.cs b/src/AcDream.App/UI/Layout/RetailCombatLayout.cs
new file mode 100644
index 00000000..55ee92df
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/RetailCombatLayout.cs
@@ -0,0 +1,49 @@
+namespace AcDream.App.UI.Layout;
+
+/// Retail geometry policy for the shared melee/missile/magic combat root.
+internal static class RetailCombatLayout
+{
+ internal const int VisibleFavoriteSlots = 18;
+ internal const float FavoriteCellWidth = 32f;
+
+ ///
+ /// Resize the imported combat tree so its favorite list exposes exactly the
+ /// requested number of cells. The DAT deliberately authors the outer combat
+ /// root at 610 pixels while the magic page's descendants use an 800-pixel
+ /// design parent. Retail UIElement::UpdateForParentSizeChange @
+ /// 0x00462640 carries that width delta through every raw-edge policy.
+ /// Applying the same chain and solving for the list viewport keeps the
+ /// endowment, arrows, list, Cast button, and outer frame in one coherent
+ /// retained layout instead of resizing individual controls ad hoc.
+ ///
+ internal static float FitFavoriteSlots(
+ ImportedLayout layout,
+ int visibleSlots = VisibleFavoriteSlots,
+ float cellWidth = FavoriteCellWidth)
+ {
+ ArgumentNullException.ThrowIfNull(layout);
+ if (visibleSlots < 1)
+ throw new ArgumentOutOfRangeException(nameof(visibleSlots));
+ if (!(cellWidth > 0f) || !float.IsFinite(cellWidth))
+ throw new ArgumentOutOfRangeException(nameof(cellWidth));
+
+ UiElement root = layout.Root;
+ ApplyDescendantLayout(root);
+ if (layout.FindElement(SpellcastingUiController.FavoriteListId) is not UiItemList list)
+ throw new InvalidOperationException("Retail combat layout has no favorite spell list.");
+
+ float targetViewport = visibleSlots * cellWidth;
+ root.Width = MathF.Max(0f, root.Width + targetViewport - list.Width);
+ ApplyDescendantLayout(root);
+ return root.Width;
+ }
+
+ private static void ApplyDescendantLayout(UiElement parent)
+ {
+ foreach (UiElement child in parent.Children)
+ {
+ child.ApplyAnchor(parent.Width, parent.Height);
+ ApplyDescendantLayout(child);
+ }
+ }
+}
diff --git a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs
index 15b40179..6c505e9f 100644
--- a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs
+++ b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs
@@ -64,6 +64,12 @@ public static class RetailWindowFrame
public bool DrawChromeCenter { get; init; } = true;
public bool Visible { get; init; } = true;
+ ///
+ /// Top-level desktop anchors. Most movable windows are unanchored; fixed
+ /// retail HUD roots can preserve their authored screen-edge margins.
+ ///
+ public AnchorEdges OuterAnchors { get; init; } = AnchorEdges.None;
+
public AnchorEdges ContentAnchors { get; init; } =
AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
public bool? ContentClickThrough { get; init; }
@@ -133,7 +139,7 @@ public static class RetailWindowFrame
outerFrame.Top = options.Top;
outerFrame.Width = outerWidth;
outerFrame.Height = outerHeight;
- outerFrame.Anchors = AnchorEdges.None;
+ outerFrame.Anchors = options.OuterAnchors;
outerFrame.Draggable = options.Draggable;
outerFrame.Resizable = options.Resizable;
outerFrame.ResizeX = options.ResizeX;
diff --git a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs
index e06fef3b..c7b1206c 100644
--- a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs
+++ b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs
@@ -251,8 +251,6 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_endowmentSelected[_activeTab] = favorites.Count == 0 && _endowmentItemId != 0u;
}
SyncSelection();
- if (_selected[_activeTab] is uint visibleSpell)
- ScrollSelectedSpellIntoView(_activeTab, visibleSpell);
UpdateCastAvailability();
}
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index 17b46f33..f57249bb 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -764,11 +764,12 @@ public sealed class RetailUiRuntime : IDisposable
strings.Resolve);
labels = info is null ? null : CombatUiLabels.Resolve(info, strings);
}
- if (layout is null || labels is null)
+ if (info is null || layout is null || labels is null)
{
Console.WriteLine("[M2] combat: LayoutDesc 0x21000073 not found.");
return;
}
+ float combatWidth = RetailCombatLayout.FitFavoriteSlots(layout);
CombatUiController? controller = Layout.CombatUiController.Bind(
layout,
@@ -821,9 +822,13 @@ public sealed class RetailUiRuntime : IDisposable
{
WindowName = WindowNames.Combat,
Chrome = RetailWindowChrome.Imported,
- Left = Math.Max(0f, (Host.Root.Width - root.Width) * 0.5f),
+ Left = 0f,
Top = Math.Max(0f, Host.Root.Height - root.Height - 10f),
+ ContentWidth = combatWidth,
+ DatConstraintSource = info,
+ OuterAnchors = AnchorEdges.Left | AnchorEdges.Bottom,
Visible = false,
+ Draggable = false,
Resizable = false,
ResizeX = false,
ResizeY = false,
diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs
index b59b083d..ea484b1e 100644
--- a/src/AcDream.App/UI/UiElement.cs
+++ b/src/AcDream.App/UI/UiElement.cs
@@ -233,6 +233,14 @@ public abstract class UiElement
/// self-driven interior drag such as text selection). Default false.
public virtual bool HandlesClick => false;
+ ///
+ /// Whether pointer movement should continue reaching this element while it remains
+ /// hovered and no mouse capture is active. Composite controls use this to update
+ /// procedural sub-region hover state without broadening mouse-move delivery to every
+ /// retained widget.
+ ///
+ public virtual bool ReceivesHoverMouseMove => false;
+
/// Minimum size enforced while resizing.
public float MinWidth { get; set; } = 40f;
public float MinHeight { get; set; } = 40f;
diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs
index 03b0b726..7b2aa7d4 100644
--- a/src/AcDream.App/UI/UiRoot.cs
+++ b/src/AcDream.App/UI/UiRoot.cs
@@ -851,7 +851,12 @@ public sealed class UiRoot : UiElement
private void UpdateHover(int x, int y)
{
var (w, _, _) = HitTestTopDown(x, y);
- if (ReferenceEquals(w, _hoverWidget)) return;
+ if (ReferenceEquals(w, _hoverWidget))
+ {
+ if (w?.ReceivesHoverMouseMove == true)
+ DispatchMouseMove(w, x, y);
+ return;
+ }
if (_hoverWidget is not null)
{
@@ -863,7 +868,13 @@ public sealed class UiRoot : UiElement
_tooltipFired = false;
if (w is not null)
{
- var enter = new UiEvent(w.EventId, w, UiEventType.HoverEnter);
+ var screen = w.ScreenPosition;
+ var enter = new UiEvent(
+ w.EventId,
+ w,
+ UiEventType.HoverEnter,
+ Data1: (int)(x - screen.X),
+ Data2: (int)(y - screen.Y));
w.OnEvent(in enter);
}
}
diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs
index 49a2f0d1..25c04acc 100644
--- a/src/AcDream.App/UI/UiScrollbar.cs
+++ b/src/AcDream.App/UI/UiScrollbar.cs
@@ -21,6 +21,8 @@ namespace AcDream.App.UI;
///
public sealed class UiScrollbar : UiElement
{
+ public override bool ReceivesHoverMouseMove => true;
+
/// The scroll model this bar reflects + drives (shared with the transcript).
public UiScrollable? Model { get; set; }
@@ -83,6 +85,14 @@ public sealed class UiScrollbar : UiElement
/// Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072).
public uint DownSprite { get; set; }
+ /// Rollover and pressed media for the start/decrement button.
+ public uint UpRolloverSprite { get; set; }
+ public uint UpPressedSprite { get; set; }
+
+ /// Rollover and pressed media for the end/increment button.
+ public uint DownRolloverSprite { get; set; }
+ public uint DownPressedSprite { get; set; }
+
///
/// Authored extent of the decrement button along the scrollbar axis. Retail
/// positions and subtracts the referenced button's real size; chat uses
@@ -108,6 +118,15 @@ public sealed class UiScrollbar : UiElement
private bool _draggingThumb;
private float _dragOffsetY;
private float _dragOffsetX;
+ private EndButton _hoveredButton;
+ private EndButton _pressedButton;
+
+ private enum EndButton
+ {
+ None,
+ Decrement,
+ Increment,
+ }
public UiScrollbar() { CapturesPointerDrag = true; }
@@ -201,8 +220,8 @@ public sealed class UiScrollbar : UiElement
float incrementExtent = AxisExtent(IncrementButtonExtent, Height);
// Decrement/up and increment/down use their authored button heights.
- DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, decrementExtent);
- DrawSprite(ctx, resolve, DownSprite,
+ DrawSprite(ctx, resolve, ActiveStartSprite, 0f, 0f, Width, decrementExtent);
+ DrawSprite(ctx, resolve, ActiveEndSprite,
0f, Height - incrementExtent, Width, incrementExtent);
// Thumb — only when content overflows the view. Retail 3-slice: top cap +
@@ -235,8 +254,8 @@ public sealed class UiScrollbar : UiElement
float decrementExtent = AxisExtent(DecrementButtonExtent, Width);
float incrementExtent = AxisExtent(IncrementButtonExtent, Width);
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
- DrawSprite(ctx, resolve, UpSprite, 0f, 0f, decrementExtent, Height);
- DrawSprite(ctx, resolve, DownSprite,
+ DrawSprite(ctx, resolve, ActiveStartSprite, 0f, 0f, decrementExtent, Height);
+ DrawSprite(ctx, resolve, ActiveEndSprite,
Width - incrementExtent, 0f, incrementExtent, Height);
if (!model.HasOverflow) return;
@@ -322,9 +341,24 @@ public sealed class UiScrollbar : UiElement
if (IsModelDisabled)
{
_draggingThumb = false;
+ _hoveredButton = EndButton.None;
+ _pressedButton = EndButton.None;
return false;
}
+ if (e.Type == UiEventType.HoverEnter)
+ {
+ _hoveredButton = ButtonAt(e.Data1, e.Data2);
+ return true;
+ }
+ if (e.Type == UiEventType.HoverLeave)
+ {
+ _hoveredButton = EndButton.None;
+ return true;
+ }
+ if (e.Type == UiEventType.MouseMove)
+ _hoveredButton = ButtonAt(e.Data1, e.Data2);
+
if (Horizontal && ScalarChanged is not null)
return OnScalarEvent(e);
@@ -339,6 +373,7 @@ public sealed class UiScrollbar : UiElement
{
// e.Data1 = local X, e.Data2 = local Y (int pixel coords, see UiRoot hit dispatch).
float ly = e.Data2;
+ _pressedButton = ButtonAt(e.Data1, e.Data2);
float decrementExtent = AxisExtent(DecrementButtonExtent, Height);
float incrementExtent = AxisExtent(IncrementButtonExtent, Height);
@@ -386,6 +421,7 @@ public sealed class UiScrollbar : UiElement
case UiEventType.MouseUp:
_draggingThumb = false;
+ _pressedButton = EndButton.None;
return true;
}
@@ -400,6 +436,7 @@ public sealed class UiScrollbar : UiElement
case UiEventType.MouseDown:
{
float x = e.Data1;
+ _pressedButton = ButtonAt(e.Data1, e.Data2);
float decrementExtent = AxisExtent(DecrementButtonExtent, Width);
float incrementExtent = AxisExtent(IncrementButtonExtent, Width);
if (x < decrementExtent) { m.ScrollByLines(-1); return true; }
@@ -437,6 +474,7 @@ public sealed class UiScrollbar : UiElement
case UiEventType.MouseUp:
_draggingThumb = false;
+ _pressedButton = EndButton.None;
return true;
}
return false;
@@ -475,6 +513,7 @@ public sealed class UiScrollbar : UiElement
case UiEventType.MouseUp:
_draggingThumb = false;
+ _pressedButton = EndButton.None;
return true;
}
@@ -497,6 +536,48 @@ public sealed class UiScrollbar : UiElement
ScalarChanged?.Invoke(ScalarPosition);
}
+ private EndButton ButtonAt(float x, float y)
+ {
+ if (x < 0f || x >= Width || y < 0f || y >= Height)
+ return EndButton.None;
+
+ if (Horizontal)
+ {
+ if (x < AxisExtent(DecrementButtonExtent, Width))
+ return EndButton.Decrement;
+ if (x >= Width - AxisExtent(IncrementButtonExtent, Width))
+ return EndButton.Increment;
+ return EndButton.None;
+ }
+
+ if (y < AxisExtent(DecrementButtonExtent, Height))
+ return EndButton.Decrement;
+ if (y >= Height - AxisExtent(IncrementButtonExtent, Height))
+ return EndButton.Increment;
+ return EndButton.None;
+ }
+
+ private uint ActiveStartSprite
+ => _pressedButton == EndButton.Decrement
+ && _hoveredButton == EndButton.Decrement
+ && UpPressedSprite != 0u
+ ? UpPressedSprite
+ : _hoveredButton == EndButton.Decrement && UpRolloverSprite != 0u
+ ? UpRolloverSprite
+ : UpSprite;
+
+ private uint ActiveEndSprite
+ => _pressedButton == EndButton.Increment
+ && _hoveredButton == EndButton.Increment
+ && DownPressedSprite != 0u
+ ? DownPressedSprite
+ : _hoveredButton == EndButton.Increment && DownRolloverSprite != 0u
+ ? DownRolloverSprite
+ : DownSprite;
+
+ internal uint ActiveStartSpriteForTest => ActiveStartSprite;
+ internal uint ActiveEndSpriteForTest => ActiveEndSprite;
+
private static float AxisExtent(float authoredExtent, float axisLength)
=> Math.Clamp(authoredExtent, 0f, MathF.Max(0f, axisLength));
}
diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
index b568c400..f4c3b449 100644
--- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
@@ -230,24 +230,32 @@ public class DatWidgetFactoryTests
const uint IncrementId = 0x10000072u;
const uint DecrementSprite = 0x06004CDCu;
const uint IncrementSprite = 0x06004CDEu;
+ const uint DecrementRollover = 0x06004CDDu;
+ const uint IncrementRollover = 0x06004CDFu;
var decrement = new ElementInfo
{
- Id = DecrementId, Type = 1u, Width = 23f, Height = 36f,
+ Id = DecrementId, Type = 1u, X = 63f, Width = 23f, Height = 36f,
};
decrement.States[UiStateInfo.DirectStateId] = new UiStateInfo
{
Id = UiStateInfo.DirectStateId,
Image = new UiImageMedia(DecrementSprite, 1),
};
+ decrement.StateMedia["Normal"] = (DecrementSprite, 1);
+ decrement.StateMedia["Normal_rollover"] = (DecrementRollover, 1);
+ decrement.StateMedia["Normal_pressed"] = (DecrementSprite, 1);
var increment = new ElementInfo
{
- Id = IncrementId, Type = 1u, Width = 23f, Height = 36f,
+ Id = IncrementId, Type = 1u, X = 0f, Width = 23f, Height = 36f,
};
increment.States[UiStateInfo.DirectStateId] = new UiStateInfo
{
Id = UiStateInfo.DirectStateId,
Image = new UiImageMedia(IncrementSprite, 1),
};
+ increment.StateMedia["Normal"] = (IncrementSprite, 1);
+ increment.StateMedia["Normal_rollover"] = (IncrementRollover, 1);
+ increment.StateMedia["Normal_pressed"] = (IncrementSprite, 1);
var info = new ElementInfo
{
Type = 11u,
@@ -271,8 +279,12 @@ public class DatWidgetFactoryTests
Assert.True(bar.HideWhenDisabled);
Assert.Equal(23f, bar.DecrementButtonExtent);
Assert.Equal(23f, bar.IncrementButtonExtent);
- Assert.Equal(DecrementSprite, bar.UpSprite);
- Assert.Equal(IncrementSprite, bar.DownSprite);
+ Assert.Equal(IncrementSprite, bar.UpSprite);
+ Assert.Equal(IncrementRollover, bar.UpRolloverSprite);
+ Assert.Equal(IncrementSprite, bar.UpPressedSprite);
+ Assert.Equal(DecrementSprite, bar.DownSprite);
+ Assert.Equal(DecrementRollover, bar.DownRolloverSprite);
+ Assert.Equal(DecrementSprite, bar.DownPressedSprite);
Assert.Equal(0u, bar.TrackSprite);
Assert.Equal(0u, bar.ThumbSprite);
}
diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs
index c48167aa..3d670338 100644
--- a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs
@@ -7,6 +7,43 @@ public sealed class RetailWindowFrameTests
{
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
+ [Theory]
+ [InlineData(9, 459f, 288f)]
+ [InlineData(13, 587f, 416f)]
+ [InlineData(18, 747f, 576f)]
+ public void CombatRoot_FitsRequestedFavoriteSlotCapacity(
+ int visibleSlots,
+ float expectedRootWidth,
+ float expectedListWidth)
+ {
+ ImportedLayout combat = LayoutImporter.Build(
+ FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
+
+ float width = RetailCombatLayout.FitFavoriteSlots(combat, visibleSlots);
+
+ Assert.Equal(expectedRootWidth, width);
+ Assert.Equal(expectedListWidth, combat.FindElement(
+ SpellcastingUiController.FavoriteListId)!.Width);
+ }
+
+ [Fact]
+ public void CombatRoot_EighteenSlotsKeepArrowsAndCastInsideFrame()
+ {
+ ImportedLayout combat = LayoutImporter.Build(
+ FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
+
+ RetailCombatLayout.FitFavoriteSlots(combat);
+
+ UiElement group = combat.FindElement(0x100000AAu)!;
+ UiElement scrollbar = combat.FindElement(SpellcastingUiController.FavoriteScrollbarId)!;
+ UiElement cast = combat.FindElement(SpellcastingUiController.CastButtonId)!;
+ Assert.Equal(747f, combat.Root.Width);
+ Assert.Equal((40f, 622f), (group.Left, group.Width));
+ Assert.Equal(622f, scrollbar.Width);
+ Assert.Equal((662f, 75f), (cast.Left, cast.Width));
+ Assert.Equal(737f, cast.Left + cast.Width);
+ }
+
[Fact]
public void ImportedChrome_MountsRootWithoutASecondFrame()
{
@@ -41,6 +78,30 @@ public sealed class RetailWindowFrameTests
Assert.Equal(40f, content.MinWidth);
}
+ [Fact]
+ public void ImportedChrome_CanPreserveFixedHudDesktopAnchors()
+ {
+ var root = new UiRoot { Width = 1920, Height = 1080 };
+ var content = new UiPanel { Width = 1730, Height = 90 };
+
+ RetailWindowHandle handle = RetailWindowFrame.Mount(
+ root,
+ content,
+ NoTex,
+ new RetailWindowFrame.Options
+ {
+ WindowName = "combat",
+ Chrome = RetailWindowChrome.Imported,
+ Left = 0f,
+ Top = 980f,
+ OuterAnchors = AnchorEdges.Left | AnchorEdges.Bottom,
+ });
+
+ Assert.Equal(
+ AnchorEdges.Left | AnchorEdges.Bottom,
+ handle.OuterFrame.Anchors);
+ }
+
[Fact]
public void NineSlice_UsesCroppedContentAndDatHeightConstraints()
{
diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs
index 0329169f..04c454c2 100644
--- a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs
@@ -16,7 +16,7 @@ public sealed class SpellcastingUiControllerTests
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
- ApplyAnchors(layout.Root);
+ RetailCombatLayout.FitFavoriteSlots(layout);
var spellbook = new Spellbook();
spellbook.OnSpellLearned(42u, 1f);
spellbook.SetFavorite(0, 0, 42u);
@@ -44,11 +44,13 @@ public sealed class SpellcastingUiControllerTests
Assert.True(scrollbar.HideWhenDisabled);
Assert.Equal(23f, scrollbar.DecrementButtonExtent);
Assert.Equal(23f, scrollbar.IncrementButtonExtent);
- Assert.Equal(0x06004CDCu, scrollbar.UpSprite);
- Assert.Equal(0x06004CDEu, scrollbar.DownSprite);
+ Assert.Equal(0x06004CDEu, scrollbar.UpSprite);
+ Assert.Equal(0x06004CDFu, scrollbar.UpRolloverSprite);
+ Assert.Equal(0x06004CDCu, scrollbar.DownSprite);
+ Assert.Equal(0x06004CDDu, scrollbar.DownRolloverSprite);
Assert.False(scrollbar.IsPresentationVisible);
- Assert.Equal(439f, list.Width);
- Assert.Equal(13, list.GetNumUIItems());
+ Assert.Equal(576f, list.Width);
+ Assert.Equal(18, list.GetNumUIItems());
UiCatalogSlot favorite = Assert.IsType(list.GetItem(0));
Assert.Equal(42u, favorite.EntryId);
@@ -63,7 +65,7 @@ public sealed class SpellcastingUiControllerTests
Assert.Equal(i, slot.ShortcutNum);
Assert.Same(empty, slot.ActiveDigitArray());
}
- for (int i = 9; i < 13; i++)
+ for (int i = 9; i < 18; i++)
{
Assert.Equal(0x06001A97u, list.GetItem(i)!.EmptySprite);
Assert.Equal(-1, list.GetItem(i)!.ShortcutNum);
@@ -71,7 +73,7 @@ public sealed class SpellcastingUiControllerTests
var cast = Assert.IsType(
layout.FindElement(SpellcastingUiController.CastButtonId));
- Assert.Equal((525f, 75f), (cast.Left, cast.Width));
+ Assert.Equal((662f, 75f), (cast.Left, cast.Width));
Assert.Equal(3, cast.FaceSegmentCount);
Assert.Equal(
[
@@ -87,7 +89,7 @@ public sealed class SpellcastingUiControllerTests
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
- ApplyAnchors(layout.Root);
+ RetailCombatLayout.FitFavoriteSlots(layout);
var spellbook = new Spellbook();
for (uint spellId = 1u; spellId <= 20u; spellId++)
{
@@ -105,7 +107,7 @@ public sealed class SpellcastingUiControllerTests
UiScrollbar scrollbar = Descendants(group).OfType().Single(
candidate => candidate.DatElementId == SpellcastingUiController.FavoriteScrollbarId);
Assert.True(scrollbar.IsPresentationVisible);
- Assert.Equal(201, list.Scroll.MaxScroll);
+ Assert.Equal(64, list.Scroll.MaxScroll);
Assert.True(scrollbar.OnEvent(new UiEvent(
0u, scrollbar, UiEventType.MouseDown, Data1: (int)scrollbar.Width - 1)));
@@ -117,6 +119,14 @@ public sealed class SpellcastingUiControllerTests
controller.AddFavorite(20u);
Assert.Equal(list.Scroll.MaxScroll, list.Scroll.ScrollY);
+
+ Assert.True(scrollbar.OnEvent(new UiEvent(
+ 0u, scrollbar, UiEventType.MouseDown, Data1: 0)));
+ int manualOffset = list.Scroll.ScrollY;
+ objects.AddOrUpdate(new ClientObject { ObjectId = 99u, Name = "Unrelated" });
+ controller.Tick();
+
+ Assert.Equal(manualOffset, list.Scroll.ScrollY);
}
[Fact]
diff --git a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs
index 6d02b452..d2d26257 100644
--- a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs
@@ -157,6 +157,47 @@ public class UiScrollbarTests
Assert.Equal(64, model.ScrollY);
}
+ [Fact]
+ public void HorizontalModel_UsesIndependentRolloverAndPressedArrowMedia()
+ {
+ var root = new UiRoot { Width = 300f, Height = 100f };
+ var model = new UiScrollable
+ {
+ ContentHeight = 640,
+ ViewHeight = 320,
+ LineHeight = 32,
+ };
+ var bar = new UiScrollbar
+ {
+ Width = 160f,
+ Height = 36f,
+ Horizontal = true,
+ Model = model,
+ UpSprite = 1u,
+ UpRolloverSprite = 2u,
+ UpPressedSprite = 3u,
+ DownSprite = 4u,
+ DownRolloverSprite = 5u,
+ DownPressedSprite = 6u,
+ };
+ root.AddChild(bar);
+
+ root.OnMouseMove(5, 10);
+ Assert.Equal(2u, bar.ActiveStartSpriteForTest);
+ Assert.Equal(4u, bar.ActiveEndSpriteForTest);
+
+ // Moving between two regions of the same procedural scrollbar must
+ // refresh its sub-control hover, not wait for a whole-widget leave.
+ root.OnMouseMove(155, 10);
+ Assert.Equal(1u, bar.ActiveStartSpriteForTest);
+ Assert.Equal(5u, bar.ActiveEndSpriteForTest);
+
+ root.OnMouseDown(UiMouseButton.Left, 155, 10);
+ Assert.Equal(6u, bar.ActiveEndSpriteForTest);
+ root.OnMouseUp(UiMouseButton.Left, 155, 10);
+ Assert.Equal(5u, bar.ActiveEndSpriteForTest);
+ }
+
[Fact]
public void ModelWithoutOverflow_IsDisabledAndHideDisabledSuppressesPresentation()
{