fix(ui): match retail spell bar controls

Place favorite-bar arrows by their authored sides, import rollover and pressed media through the shared scrollbar, and preserve manual offsets across passive refreshes. Carry the mixed-parent DAT anchor chain to a fixed 18-cell favorite viewport so overflow controls and the Cast button remain inside the retail-sized combat frame.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 07:01:01 +02:00
parent 02c29e67c8
commit 0134122c28
17 changed files with 424 additions and 59 deletions

View file

@ -126,8 +126,11 @@ public static class DatWidgetFactory
/// <summary>
/// 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
/// <c>UIElement_Scrollbar::UpdateScrollingArea @ 0x00470AA0</c> 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.
/// </summary>
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 ────────────────────────────────────────────────────────────────
/// <summary>

View file

@ -0,0 +1,49 @@
namespace AcDream.App.UI.Layout;
/// <summary>Retail geometry policy for the shared melee/missile/magic combat root.</summary>
internal static class RetailCombatLayout
{
internal const int VisibleFavoriteSlots = 18;
internal const float FavoriteCellWidth = 32f;
/// <summary>
/// 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 <c>UIElement::UpdateForParentSizeChange @
/// 0x00462640</c> 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.
/// </summary>
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);
}
}
}

View file

@ -64,6 +64,12 @@ public static class RetailWindowFrame
public bool DrawChromeCenter { get; init; } = true;
public bool Visible { get; init; } = true;
/// <summary>
/// Top-level desktop anchors. Most movable windows are unanchored; fixed
/// retail HUD roots can preserve their authored screen-edge margins.
/// </summary>
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;

View file

@ -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();
}