fix(ui): restore retail magic shortcut bar

Port the retail horizontal ItemList empty-slot padding and share UIItem shortcut-number graphics with the status toolbar. Preserve all authored face children on compound DAT buttons so the three-piece Cast control reflows and renders as one complete button.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-15 19:11:07 +02:00
parent 0527325b25
commit e3605672bb
12 changed files with 466 additions and 62 deletions

View file

@ -552,8 +552,12 @@ public static class DatWidgetFactory
// Spellbook school/level filters are Type-1 buttons with no parent media:
// their 13x13 child carries Normal/Highlight art. Keep that child as the
// face of this retained leaf instead of consuming and losing it.
ElementInfo? face = info.StateMedia.Count == 0
? FindStatefulFaceChild(info)
ElementInfo[] authoredFaces = info.StateMedia.Count == 0
? FindStatefulFaceChildren(info)
: [];
ElementInfo? face = authoredFaces.Length == 1 ? authoredFaces[0] : null;
IReadOnlyList<ElementInfo>? faceSegments = authoredFaces.Length > 1
? authoredFaces
: null;
string? label = ResolveAuthoredString(info, stringResolve);
@ -576,7 +580,7 @@ public static class DatWidgetFactory
if (labelInfo.FontDid != 0u && fontResolve is not null)
labelFont = fontResolve(labelInfo.FontDid) ?? elementFont;
var button = new UiButton(info, resolve, face)
var button = new UiButton(info, resolve, face, faceSegments)
{
Label = label,
LabelFont = labelFont,
@ -638,11 +642,16 @@ public static class DatWidgetFactory
}
private static ElementInfo? FindStatefulFaceChild(ElementInfo info)
=> info.Children.FirstOrDefault(child =>
=> FindStatefulFaceChildren(info).FirstOrDefault();
private static ElementInfo[] FindStatefulFaceChildren(ElementInfo info)
=> info.Children.Where(child =>
child.StateMedia.Count != 0
&& child.StateMedia.Keys.Any(childState =>
info.States.Values.Any(parentState =>
string.Equals(parentState.Name, childState, StringComparison.Ordinal))));
string.Equals(parentState.Name, childState, StringComparison.Ordinal))))
.OrderBy(child => child.ReadOrder)
.ToArray();
private static string? ResolveAuthoredString(
ElementInfo info,

View file

@ -45,6 +45,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
private readonly Action<uint> _useItem;
private readonly Action<int, int, uint>? _addFavorite;
private readonly Action<int, uint>? _removeFavorite;
private readonly UiShortcutSlotGraphics? _shortcutGraphics;
private readonly UiElement[] _tabs;
private readonly UiElement[] _groups;
private readonly UiItemList?[] _lists;
@ -77,7 +78,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
UiElement[] groups,
UiItemList?[] lists,
UiButton cast,
UiElement endowmentHost)
UiElement endowmentHost,
UiShortcutSlotGraphics? shortcutGraphics)
{
_spellbook = spellbook;
_casting = casting;
@ -89,6 +91,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_useItem = useItem;
_addFavorite = addFavorite;
_removeFavorite = removeFavorite;
_shortcutGraphics = shortcutGraphics;
_tabs = tabs;
_groups = groups;
_lists = lists;
@ -139,7 +142,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
Action<uint> useItem,
SelectionState selection,
Action<int, int, uint>? addFavorite,
Action<int, uint>? removeFavorite)
Action<int, uint>? removeFavorite,
UiShortcutSlotGraphics? shortcutGraphics = null)
{
if (layout.FindElement(CastButtonId) is not UiButton cast
|| layout.FindElement(EndowmentId) is not { } endowmentHost)
@ -162,7 +166,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
layout, spellbook, casting, objects, playerGuid, resolveSpellIcon,
resolveItemDragIcon, useItem, selection,
addFavorite, removeFavorite,
tabs, groups, lists, cast, endowmentHost);
tabs, groups, lists, cast, endowmentHost, shortcutGraphics);
}
public void AddFavorite(uint spellId)
@ -278,14 +282,17 @@ public sealed class SpellcastingUiController : IRetainedPanelController
list.CatalogDropped = (payload, x, _) =>
{
if (payload is SpellbookShortcutDragPayload shortcut)
DropSpellbookShortcut(shortcut, targetTab, DropPosition(list, x));
DropSpellbookShortcut(
shortcut, targetTab, DropPosition(list, x, favorites.Count));
};
using (list.DeferLayout())
{
list.Flush();
list.Columns = 9;
list.SingleRow = true;
list.CellWidth = 32f;
list.CellHeight = 32f;
list.EmptySlotFactory = () => CreateEmptyFavoriteSlot(list, targetTab);
list.FillVisibleEmptySlots = true;
foreach (uint spellId in favorites)
{
uint id = spellId;
@ -313,10 +320,59 @@ public sealed class SpellcastingUiController : IRetainedPanelController
list.AddItem(slot);
}
}
StampShortcutOverlays(list);
}
SelectTab(_activeTab);
}
private UiCatalogSlot CreateEmptyFavoriteSlot(UiItemList list, int targetTab)
{
int shortcutIndex = list.GetNumUIItems();
UiCatalogSlot? slot = null;
slot = new UiCatalogSlot
{
SpriteResolve = list.SpriteResolve,
EmptySprite = _shortcutGraphics is { EmptySprite: > 0u } graphics
? graphics.EmptySprite
: 0x060074CFu,
Dropped = payload =>
{
int position = Math.Max(0, list.IndexOf(slot!));
int favoriteCount = _spellbook.GetFavorites(targetTab).Count;
position = Math.Min(position, favoriteCount);
if (payload is SpellFavoriteDragPayload favorite)
DropFavorite(favorite, targetTab, position);
else if (payload is SpellbookShortcutDragPayload shortcut)
DropSpellbookShortcut(shortcut, targetTab, position);
},
};
ConfigureShortcutOverlay(slot, shortcutIndex);
return slot;
}
private void StampShortcutOverlays(UiItemList list)
{
for (int i = 0; i < list.GetNumUIItems(); i++)
{
UiItemSlot? slot = list.GetItem(i);
if (slot is null) continue;
ConfigureShortcutOverlay(slot, i);
}
}
private void ConfigureShortcutOverlay(UiItemSlot slot, int index)
{
slot.RegularDigits = _shortcutGraphics?.RegularDigits;
slot.GhostedDigits = _shortcutGraphics?.GhostedDigits;
slot.EmptyDigits = _shortcutGraphics?.EmptyDigits;
if (_shortcutGraphics is { EmptySprite: > 0u } graphics)
slot.EmptySprite = graphics.EmptySprite;
if (index < 9)
slot.SetShortcutNum(index, ghosted: false);
else
slot.ClearShortcutNum();
}
private void BeginFavoriteDrag(SpellFavoriteDragPayload payload)
=> _removeFavorite?.Invoke(payload.SourceTab, payload.SpellId);
@ -343,12 +399,12 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_selected[targetTab] = payload.SpellId;
}
private static int DropPosition(UiItemList list, int localX)
private static int DropPosition(UiItemList list, int localX, int favoriteCount)
{
int position = list.CellWidth <= 0f
? list.GetNumUIItems()
? favoriteCount
: (int)MathF.Floor(MathF.Max(0f, localX) / list.CellWidth);
return Math.Clamp(position, 0, list.GetNumUIItems());
return Math.Clamp(position, 0, favoriteCount);
}
private void OnSpellbookChanged() => _favoritesDirty = true;
@ -381,9 +437,9 @@ public sealed class SpellcastingUiController : IRetainedPanelController
for (int i = 0; i < list.GetNumUIItems(); i++)
if (list.GetItem(i) is UiCatalogSlot slot)
slot.Selected = slot.EntryId == _selected[tab];
}
_endowmentSlot.Selected = _endowmentItemId != 0u
&& _endowmentSelected[_activeTab];
}
}
private void OnSelectionChanged(SelectionTransition _) => UpdateCastAvailability();

View file

@ -167,6 +167,7 @@ public sealed class RetailUiRuntime : IDisposable
private GameplayConfirmationController? _gameplayConfirmationController;
private RetailItemConfirmationController? _itemConfirmationController;
private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController;
private UiShortcutSlotGraphics? _shortcutSlotGraphics;
private bool _disposed;
private RetailUiRuntime(RetailUiRuntimeBindings bindings)
@ -558,11 +559,13 @@ public sealed class RetailUiRuntime : IDisposable
return;
}
var (regular, ghosted, empty) = LoadToolbarDigits();
UiShortcutSlotGraphics shortcutGraphics = LoadShortcutSlotGraphics();
ToolbarRuntimeBindings b = _bindings.Toolbar;
ToolbarController = Layout.ToolbarController.Bind(
layout, b.Objects, b.Shortcuts, b.ResolveIcon, b.UseItem, b.Combat,
regular, ghosted, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut,
shortcutGraphics.RegularDigits, shortcutGraphics.GhostedDigits,
shortcutGraphics.EmptyDigits, b.ItemInteraction,
b.SendAddShortcut, b.SendRemoveShortcut,
toggleCombat: b.ToggleCombat,
selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar),
selectedObjectId: () => b.Selection.SelectedObjectId ?? 0u,
@ -671,7 +674,8 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Magic.UseItem,
_bindings.Magic.Selection,
_bindings.Magic.AddFavorite,
_bindings.Magic.RemoveFavorite);
_bindings.Magic.RemoveFavorite,
LoadShortcutSlotGraphics());
if (spellcasting is null)
Console.WriteLine("[M3] spellcasting: required controls missing in LayoutDesc 0x21000073.");
@ -1012,9 +1016,13 @@ public sealed class RetailUiRuntime : IDisposable
$"[UI] retail DialogFactory from LayoutDesc 0x{layoutId:X8}; confirmation root 0x15.");
}
private (uint[] Regular, uint[] Ghosted, uint[]? Empty) LoadToolbarDigits()
private UiShortcutSlotGraphics LoadShortcutSlotGraphics()
{
if (_shortcutSlotGraphics is not null)
return _shortcutSlotGraphics;
uint[]? regular = null, ghosted = null, empty = null;
uint emptySprite = 0u;
lock (_bindings.Assets.DatLock)
{
var layout = _bindings.Assets.Dats.Get<DatReaderWriter.DBObjs.LayoutDesc>(0x21000037u);
@ -1031,6 +1039,10 @@ public sealed class RetailUiRuntime : IDisposable
&& emptyComposite.Children.TryGetValue(0x1000034Au, out var emptyNumber)
&& emptyNumber.StateDesc?.Properties is { } emptyProps)
empty = ReadDataIds(emptyProps, 0x1000005Eu);
emptySprite = ItemListCellTemplate.ResolveEmptySprite(
_bindings.Assets.Dats,
CombatUiController.LayoutId,
SpellcastingUiController.FavoriteListId);
}
regular ??=
@ -1043,8 +1055,13 @@ public sealed class RetailUiRuntime : IDisposable
0x06001ACCu, 0x06001ACDu, 0x06001ACEu, 0x06001ACFu, 0x06001AD0u,
0x06001AD1u, 0x06001AD2u, 0x06001AD3u, 0x06001AD4u,
];
Console.WriteLine($"[D.5.1] toolbar digit arrays ready: regular={regular.Length}, ghosted={ghosted.Length}, empty={empty?.Length ?? 0} entries.");
return (regular, ghosted, empty);
_shortcutSlotGraphics = new UiShortcutSlotGraphics(
regular, ghosted, empty, emptySprite);
Console.WriteLine(
$"[D.5.1] shared shortcut graphics ready: regular={regular.Length}, " +
$"ghosted={ghosted.Length}, empty={empty?.Length ?? 0}, " +
$"slot=0x{emptySprite:X8}.");
return _shortcutSlotGraphics;
}
private static uint[]? ReadDataIds(

View file

@ -34,6 +34,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
{
private readonly ElementInfo _info;
private readonly ElementInfo _mediaInfo;
private readonly FaceSegment[] _faceSegments;
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
private readonly HashSet<uint> _availableStates = new();
private bool _pressed;
@ -175,12 +176,12 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
if (stateId == UiStateInfo.DirectStateId)
{
if (!_mediaInfo.States.ContainsKey(stateId) && !_mediaInfo.StateMedia.ContainsKey(""))
if (!TryFindState(stateId, out _) && !HasStateMedia(""))
return false;
ActiveState = "";
return true;
}
if (_mediaInfo.States.TryGetValue(stateId, out var state))
if (TryFindState(stateId, out var state))
{
ActiveState = state.Name;
return true;
@ -188,7 +189,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
string stateName = UiButtonStateMachine.StateName(stateId);
if (string.IsNullOrEmpty(stateName))
stateName = RetailUiStateIds.StateName(stateId);
if (!string.IsNullOrEmpty(stateName) && _mediaInfo.StateMedia.ContainsKey(stateName))
if (!string.IsNullOrEmpty(stateName) && HasStateMedia(stateName))
{
ActiveState = stateName;
return true;
@ -202,10 +203,14 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
public UiButton(
ElementInfo info,
Func<uint, (uint tex, int w, int h)> resolve,
ElementInfo? mediaInfo = null)
ElementInfo? mediaInfo = null,
IReadOnlyList<ElementInfo>? faceSegments = null)
{
_info = info;
_info = info;
_mediaInfo = mediaInfo ?? info;
_faceSegments = faceSegments is null
? []
: faceSegments.Select(static segment => new FaceSegment(segment)).ToArray();
_resolve = resolve;
ClickThrough = false; // buttons are interactive — opt OUT of click-through
@ -213,9 +218,11 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
// Retail layouts commonly declare an empty Normal_pressed descriptor while
// supplying art only for Normal/Highlight. Treating that property-only state
// as drawable briefly blanks the button during mouse-down.
foreach (string stateName in _mediaInfo.StateMedia.Keys)
if (UiButtonStateMachine.TryStateId(stateName, out uint stateId))
_availableStates.Add(stateId);
if (_faceSegments.Length == 0)
AddAvailableStates(_mediaInfo);
else
foreach (FaceSegment segment in _faceSegments)
AddAvailableStates(segment.Info);
ToggleBehavior = info.TryGetEffectiveBool(0x0Bu, out bool toggle) && toggle;
RolloverEnabled = info.TryGetEffectiveBool(0x13u, out bool rollover) && rollover;
@ -233,7 +240,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
// DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState ("").
if (!string.IsNullOrEmpty(info.DefaultStateName))
ActiveState = info.DefaultStateName;
else if (_mediaInfo.StateMedia.ContainsKey("Normal"))
else if (HasStateMedia("Normal"))
ActiveState = "Normal";
// else ActiveState stays "" (DirectState)
@ -258,24 +265,32 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// Returns 0 if neither exists.
/// Mirrors <see cref="UiDatElement.ActiveMedia()"/>.
/// </summary>
private uint ActiveFile()
=> _mediaInfo.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
: _mediaInfo.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
private uint ActiveFile(ElementInfo mediaInfo)
=> mediaInfo.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
: mediaInfo.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
protected override void OnDraw(UiRenderContext ctx)
{
uint file = ActiveFile();
if (file != 0)
if (_faceSegments.Length != 0)
{
var (tex, tw, th) = _resolve(file);
if (tex != 0 && tw != 0 && th != 0)
foreach (FaceSegment segment in _faceSegments)
DrawFace(ctx, ActiveFile(segment.Info), segment.Rect(Width, Height));
}
else
{
uint file = ActiveFile(_mediaInfo);
if (file != 0)
{
// Tiled draw — same call shape as UiDatElement.OnDraw (UV-repeat; GL_REPEAT-wrapped
// UI texture). Matches ImgTex::TileCSI; no Stretch mode exists.
float faceWidth = FaceWidth > 0f ? FaceWidth : Width;
float faceHeight = FaceHeight > 0f ? FaceHeight : Height;
ctx.DrawSprite(tex, FaceLeft, FaceTop, faceWidth, faceHeight,
0, 0, faceWidth / tw, faceHeight / th, Vector4.One);
var (tex, tw, th) = _resolve(file);
if (tex != 0 && tw != 0 && th != 0)
{
// Tiled draw — same call shape as UiDatElement.OnDraw (UV-repeat; GL_REPEAT-wrapped
// UI texture). Matches ImgTex::TileCSI; no Stretch mode exists.
float faceWidth = FaceWidth > 0f ? FaceWidth : Width;
float faceHeight = FaceHeight > 0f ? FaceHeight : Height;
ctx.DrawSprite(tex, FaceLeft, FaceTop, faceWidth, faceHeight,
0, 0, faceWidth / tw, faceHeight / th, Vector4.One);
}
}
}
@ -302,6 +317,79 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
}
}
private void DrawFace(UiRenderContext ctx, uint file, UiPixelRect rect)
{
if (file == 0 || rect.Width <= 0 || rect.Height <= 0)
return;
var (texture, textureWidth, textureHeight) = _resolve(file);
if (texture == 0 || textureWidth == 0 || textureHeight == 0)
return;
// Same tiled/cropped media path as UiDatElement. Segment geometry is
// first reflowed by its own four-edge retail layout policy.
ctx.DrawSprite(texture, rect.X0, rect.Y0, rect.Width, rect.Height,
0f, 0f, (float)rect.Width / textureWidth, (float)rect.Height / textureHeight,
Vector4.One);
}
private void AddAvailableStates(ElementInfo mediaInfo)
{
foreach (string stateName in mediaInfo.StateMedia.Keys)
if (UiButtonStateMachine.TryStateId(stateName, out uint stateId))
_availableStates.Add(stateId);
}
private bool HasStateMedia(string stateName)
{
if (_faceSegments.Length == 0)
return _mediaInfo.StateMedia.ContainsKey(stateName);
foreach (FaceSegment segment in _faceSegments)
if (segment.Info.StateMedia.ContainsKey(stateName))
return true;
return false;
}
private bool TryFindState(uint stateId, out UiStateInfo state)
{
if (_faceSegments.Length == 0)
return _mediaInfo.States.TryGetValue(stateId, out state!);
foreach (FaceSegment segment in _faceSegments)
if (segment.Info.States.TryGetValue(stateId, out state!))
return true;
state = null!;
return false;
}
private sealed class FaceSegment
{
private readonly UiPixelRect _original;
private readonly UiLayoutPolicy? _layout;
public FaceSegment(ElementInfo info)
{
Info = info;
_original = UiPixelRect.FromPositionAndSize(
(int)info.X, (int)info.Y, (int)info.Width, (int)info.Height);
if (info.HasOriginalParentSize)
_layout = new UiLayoutPolicy(
info.Left, info.Top, info.Right, info.Bottom,
_original,
UiPixelRect.FromPositionAndSize(
0, 0, (int)info.OriginalParentWidth, (int)info.OriginalParentHeight));
}
public ElementInfo Info { get; }
public UiPixelRect Rect(float parentWidth, float parentHeight)
{
if (_layout is null)
return _original;
return _layout.Apply(
_original,
UiPixelRect.FromPositionAndSize(0, 0, (int)parentWidth, (int)parentHeight));
}
}
public override bool OnEvent(in UiEvent e)
{
switch (e.Type)
@ -393,6 +481,11 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
UpdateVisualState();
}
internal int FaceSegmentCount => _faceSegments.Length;
internal IReadOnlyList<UiPixelRect> FaceSegmentRectsForTest()
=> _faceSegments.Select(segment => segment.Rect(Width, Height)).ToArray();
public void OnGlobalUiTime(double nowSeconds)
{
if (!_hotClicking || !HotClickEnabled)

View file

@ -36,6 +36,9 @@ public sealed class UiCatalogSlot : UiItemSlot
public Action<object>? DragEnded { get; init; }
public Action<object>? Dropped { get; init; }
protected override bool IsShortcutOccupied => EntryId != 0u;
public override bool IsEmptySlot => EntryId == 0u;
public override string? GetTooltipText() => string.IsNullOrWhiteSpace(Label) ? null : Label;
public override bool IsDragSource => CatalogDragPayload is not null;
@ -102,6 +105,11 @@ public sealed class UiCatalogSlot : UiItemSlot
if (CatalogOverlayTexture != 0)
ctx.DrawSprite(CatalogOverlayTexture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One);
// Spell favorites are UIElement_UIItems too. Retail's
// SpellCastSubMenu::UpdateShortcutOverlays @ 0x004C5BE0 calls the same
// SetShortcutNum path used by physical toolbar shortcuts.
DrawShortcutOverlay(ctx);
if (!SelectionBehindContent)
DrawSelection(ctx);

View file

@ -82,6 +82,10 @@ public sealed class UiItemList : UiElement
public int GetNumUIItems() => _cells.Count;
/// <summary>Returns this cell's current retained-list position, matching
/// UIElement_ListBox::WhatNum. -1 means the cell is not owned by this list.</summary>
public int IndexOf(UiItemSlot cell) => _cells.IndexOf(cell);
public UiItemSlot? GetItem(int index)
=> index >= 0 && index < _cells.Count ? _cells[index] : null;
@ -128,6 +132,24 @@ public sealed class UiItemList : UiElement
/// <summary>Fixed cell height in grid mode (pairs with CellWidth).</summary>
public float CellHeight { get; set; }
/// <summary>
/// Lay cells left-to-right in one row. Retail favorite spell lists use the
/// horizontal UpdateEmptySlots branch (row-count attribute 0x5F is -1), so
/// the number of visible cells is determined by width rather than the nine
/// shortcut-number overlays.
/// </summary>
public bool SingleRow { get; set; }
/// <summary>
/// Maintain a tail of empty UIItems that fills the visible horizontal extent.
/// Port of UIElement_ItemList::UpdateEmptySlots @ 0x004E3700.
/// </summary>
public bool FillVisibleEmptySlots { get; set; }
/// <summary>Creates an empty cell for <see cref="FillVisibleEmptySlots"/>.
/// Null uses a plain physical UiItemSlot.</summary>
public Func<UiItemSlot>? EmptySlotFactory { get; set; }
/// <summary>Whole rows needed for <paramref name="cellCount"/> cells in a grid of
/// <paramref name="columns"/> columns.</summary>
public static int RowCount(int cellCount, int columns)
@ -177,7 +199,11 @@ public sealed class UiItemList : UiElement
return;
}
int cols = Columns < 1 ? 1 : Columns;
UpdateEmptySlots();
int cols = SingleRow
? Math.Max(1, _cells.Count)
: Columns < 1 ? 1 : Columns;
int cellH = (int)MathF.Round(CellHeight);
// Drive the shared scroll model from the current geometry, then re-clamp the offset to
@ -201,6 +227,35 @@ public sealed class UiItemList : UiElement
}
}
/// <summary>
/// Retail's horizontal branch pads to floor(listWidth / cellWidth), and when
/// the viewport shrinks removes only empty cells from the tail. Occupied cells
/// are never discarded merely because they extend beyond the viewport.
/// </summary>
private void UpdateEmptySlots()
{
if (!FillVisibleEmptySlots || !SingleRow || CellWidth <= 0f)
return;
int visibleCellCount = Math.Max(0, (int)MathF.Floor(Width / CellWidth));
while (_cells.Count < visibleCellCount)
{
UiItemSlot cell = EmptySlotFactory?.Invoke() ?? new UiItemSlot();
cell.SpriteResolve ??= SpriteResolve;
if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite;
cell.Anchors = AnchorEdges.None;
_cells.Add(cell);
AddChild(cell);
}
while (_cells.Count > visibleCellCount && _cells[^1].IsEmptySlot)
{
UiItemSlot cell = _cells[^1];
_cells.RemoveAt(_cells.Count - 1);
RemoveChild(cell);
}
}
protected override bool ClipsChildren => CellWidth > 0f;
public void Flush()

View file

@ -192,6 +192,17 @@ public class UiItemSlot : UiElement
/// (decomp 229481) — else branch when m_elem_Icon->m_state == 0x1000001c (empty).</summary>
public uint[]? EmptyDigits { get; set; }
/// <summary>
/// Whether this retained cell represents an occupied UIItem for shortcut-number
/// presentation. Physical item cells use their weenie id; non-weenie catalog
/// cells override this with their own identity instead of forging an item GUID.
/// </summary>
protected virtual bool IsShortcutOccupied => ItemId != 0;
/// <summary>True when this cell is an empty slot that a retail item list may
/// add/remove while maintaining its visible empty-cell tail.</summary>
public virtual bool IsEmptySlot => ItemId == 0;
/// <summary>Set the slot's shortcut position and ghosted state so the correct digit
/// is drawn. Call with index 0..8 for the top row.</summary>
public void SetShortcutNum(int index, bool ghosted)
@ -213,8 +224,9 @@ public class UiItemSlot : UiElement
/// </summary>
internal uint[]? ActiveDigitArray()
{
bool occupied = ItemId != 0;
return occupied ? (ShortcutGhosted ? GhostedDigits : RegularDigits) : EmptyDigits;
return IsShortcutOccupied
? (ShortcutGhosted ? GhostedDigits : RegularDigits)
: EmptyDigits;
}
// ── Events / draw ─────────────────────────────────────────────────────────
@ -296,20 +308,7 @@ public class UiItemSlot : UiElement
// empty (ItemId == 0) → background digit set 0x1000005e, stance-independent
// Each digit image is corner-baked (glyph in top-left, rest alpha=0); drawn
// full-cell Alphablend so the transparent region is invisible.
if (ShortcutNum >= 0 && SpriteResolve is not null)
{
var arr = ActiveDigitArray();
if (arr is not null && ShortcutNum < arr.Length)
{
uint did = arr[ShortcutNum];
if (did != 0)
{
var (tex, _, _) = SpriteResolve(did);
if (tex != 0)
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
}
}
DrawShortcutOverlay(ctx);
// Container capacity bar — UIElement_UIItem::UpdateCapacityDisplay (0x004e16e0): a vertical
// UIElement_Meter (element 0x10000347, 5×30 at x=26,y=1) shown only for container cells
@ -381,4 +380,25 @@ public class UiItemSlot : UiElement
}
}
}
/// <summary>Draws the shared retail shortcut-number layer. Catalog spell cells
/// call this after their icon layer so magic favorites and physical shortcuts
/// use the same UIItem presentation path.</summary>
protected void DrawShortcutOverlay(UiRenderContext ctx)
{
if (ShortcutNum < 0 || SpriteResolve is null)
return;
uint[]? digits = ActiveDigitArray();
if (digits is null || ShortcutNum >= digits.Length)
return;
uint did = digits[ShortcutNum];
if (did == 0)
return;
var (texture, _, _) = SpriteResolve(did);
if (texture != 0)
ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
}

View file

@ -0,0 +1,12 @@
namespace AcDream.App.UI;
/// <summary>
/// Shared DAT presentation assets for UIItem shortcut cells. Both gmToolbarUI and
/// gmSpellcastingUI use UIElement_UIItem::SetShortcutNum @ 0x004E1590, so their
/// digit arrays and empty-cell prototype must come from one resolved asset set.
/// </summary>
public sealed record UiShortcutSlotGraphics(
uint[]? RegularDigits,
uint[]? GhostedDigits,
uint[]? EmptyDigits,
uint EmptySprite);