fix(ui): resolve retail spell slot background

Keep shared shortcut digit overlays separate from the per-ItemList background. Resolve the magic favorite list through its cross-layout inherited cell prototype and use the pinned brown/gold ItemSlot_Empty surface instead of the blue toolbar slot.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-15 19:23:46 +02:00
parent e3605672bb
commit 09612f9981
9 changed files with 128 additions and 53 deletions

View file

@ -21,8 +21,8 @@ public static class ItemListCellTemplate
public const uint CatalogLayoutId = 0x21000037u;
private const uint CellTemplateAttr = 0x1000000eu; // UIElement attribute: cell-template element id
private const uint IconChildId = 0x1000033Bu; // m_elem_Icon sub-element (carries ItemSlot_Empty)
private const string ItemSlotEmpty = "ItemSlot_Empty"; // UIStateId.ToString() for state 0x1000001c
private const uint IconChildId = 0x1000033Bu; // m_elem_Icon sub-element (carries ItemSlot_Empty)
private const string ItemSlotEmpty = "ItemSlot_Empty"; // UIStateId.ToString() for state 0x1000001c
/// <summary>
/// Resolve the empty-slot sprite (a 0x06xxxxxx RenderSurface id) for the cells of item-list
@ -43,6 +43,29 @@ public static class ItemListCellTemplate
return ResolvePrototypeEmptySprite(dats, protoId);
}
/// <summary>
/// Resolve from an already inherited <see cref="ElementInfo"/> tree. Use this
/// overload when the ItemList is contributed by a cross-layout BaseElement:
/// the raw outer LayoutDesc does not physically contain that descendant, but
/// <see cref="LayoutImporter.ImportInfos(DatCollection,uint)"/> has applied the
/// same inheritance that retail uses before <c>InternalCreateItem</c> queries
/// attribute <c>0x1000000E</c>.
/// </summary>
public static uint ResolveEmptySprite(
DatCollection dats,
ElementInfo resolvedRoot,
uint listElementId)
{
ElementInfo? list = FindInfo(resolvedRoot, listElementId);
if (list is null
|| !list.TryGetEffectiveProperty(CellTemplateAttr, out UiPropertyValue property)
|| property.Kind is not (UiPropertyKind.Enum or UiPropertyKind.DataId)
|| property.UnsignedValue is 0 or > uint.MaxValue)
return 0;
return ResolvePrototypeEmptySprite(dats, (uint)property.UnsignedValue);
}
/// <summary>
/// Resolves <c>ItemSlot_Empty</c> directly from an authored UIItem prototype in
/// <see cref="CatalogLayoutId"/>. Paperdoll lists select distinct prototypes for their
@ -168,4 +191,15 @@ public static class ItemListCellTemplate
}
return null;
}
private static ElementInfo? FindInfo(ElementInfo element, uint id)
{
if (element.Id == id) return element;
foreach (ElementInfo child in element.Children)
{
ElementInfo? found = FindInfo(child, id);
if (found is not null) return found;
}
return null;
}
}

View file

@ -45,7 +45,8 @@ 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 UiShortcutDigitGraphics? _shortcutDigits;
private readonly uint _emptySlotSprite;
private readonly UiElement[] _tabs;
private readonly UiElement[] _groups;
private readonly UiItemList?[] _lists;
@ -79,7 +80,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
UiItemList?[] lists,
UiButton cast,
UiElement endowmentHost,
UiShortcutSlotGraphics? shortcutGraphics)
UiShortcutDigitGraphics? shortcutDigits,
uint emptySlotSprite)
{
_spellbook = spellbook;
_casting = casting;
@ -91,7 +93,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_useItem = useItem;
_addFavorite = addFavorite;
_removeFavorite = removeFavorite;
_shortcutGraphics = shortcutGraphics;
_shortcutDigits = shortcutDigits;
_emptySlotSprite = emptySlotSprite;
_tabs = tabs;
_groups = groups;
_lists = lists;
@ -143,7 +146,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
SelectionState selection,
Action<int, int, uint>? addFavorite,
Action<int, uint>? removeFavorite,
UiShortcutSlotGraphics? shortcutGraphics = null)
UiShortcutDigitGraphics? shortcutDigits = null,
uint emptySlotSprite = 0u)
{
if (layout.FindElement(CastButtonId) is not UiButton cast
|| layout.FindElement(EndowmentId) is not { } endowmentHost)
@ -166,7 +170,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
layout, spellbook, casting, objects, playerGuid, resolveSpellIcon,
resolveItemDragIcon, useItem, selection,
addFavorite, removeFavorite,
tabs, groups, lists, cast, endowmentHost, shortcutGraphics);
tabs, groups, lists, cast, endowmentHost,
shortcutDigits, emptySlotSprite);
}
public void AddFavorite(uint spellId)
@ -291,6 +296,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
list.SingleRow = true;
list.CellWidth = 32f;
list.CellHeight = 32f;
list.CellEmptySprite = _emptySlotSprite;
list.EmptySlotFactory = () => CreateEmptyFavoriteSlot(list, targetTab);
list.FillVisibleEmptySlots = true;
foreach (uint spellId in favorites)
@ -332,9 +338,6 @@ public sealed class SpellcastingUiController : IRetainedPanelController
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!));
@ -362,11 +365,9 @@ public sealed class SpellcastingUiController : IRetainedPanelController
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;
slot.RegularDigits = _shortcutDigits?.RegularDigits;
slot.GhostedDigits = _shortcutDigits?.GhostedDigits;
slot.EmptyDigits = _shortcutDigits?.EmptyDigits;
if (index < 9)
slot.SetShortcutNum(index, ghosted: false);
else

View file

@ -167,7 +167,7 @@ public sealed class RetailUiRuntime : IDisposable
private GameplayConfirmationController? _gameplayConfirmationController;
private RetailItemConfirmationController? _itemConfirmationController;
private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController;
private UiShortcutSlotGraphics? _shortcutSlotGraphics;
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
private bool _disposed;
private RetailUiRuntime(RetailUiRuntimeBindings bindings)
@ -559,12 +559,12 @@ public sealed class RetailUiRuntime : IDisposable
return;
}
UiShortcutSlotGraphics shortcutGraphics = LoadShortcutSlotGraphics();
UiShortcutDigitGraphics shortcutDigits = LoadShortcutDigitGraphics();
ToolbarRuntimeBindings b = _bindings.Toolbar;
ToolbarController = Layout.ToolbarController.Bind(
layout, b.Objects, b.Shortcuts, b.ResolveIcon, b.UseItem, b.Combat,
shortcutGraphics.RegularDigits, shortcutGraphics.GhostedDigits,
shortcutGraphics.EmptyDigits, b.ItemInteraction,
shortcutDigits.RegularDigits, shortcutDigits.GhostedDigits,
shortcutDigits.EmptyDigits, b.ItemInteraction,
b.SendAddShortcut, b.SendRemoveShortcut,
toggleCombat: b.ToggleCombat,
selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar),
@ -625,9 +625,16 @@ public sealed class RetailUiRuntime : IDisposable
ElementInfo? info;
ImportedLayout? layout;
CombatUiLabels? labels;
uint favoriteEmptySprite;
lock (_bindings.Assets.DatLock)
{
info = LayoutImporter.ImportInfos(_bindings.Assets.Dats, CombatUiController.LayoutId);
favoriteEmptySprite = info is null
? 0u
: ItemListCellTemplate.ResolveEmptySprite(
_bindings.Assets.Dats,
info,
SpellcastingUiController.FavoriteListId);
var strings = new DatStringResolver(_bindings.Assets.Dats);
layout = info is null ? null : LayoutImporter.Build(
info,
@ -675,7 +682,8 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Magic.Selection,
_bindings.Magic.AddFavorite,
_bindings.Magic.RemoveFavorite,
LoadShortcutSlotGraphics());
LoadShortcutDigitGraphics(),
favoriteEmptySprite);
if (spellcasting is null)
Console.WriteLine("[M3] spellcasting: required controls missing in LayoutDesc 0x21000073.");
@ -706,7 +714,8 @@ public sealed class RetailUiRuntime : IDisposable
controller.SyncVisibility();
Console.WriteLine(spellcasting is null
? "[M2] retail combat from LayoutDesc 0x21000073; magic binding unavailable."
: "[M3] retail combat + spell bar from LayoutDesc 0x21000073.");
: $"[M3] retail combat + spell bar from LayoutDesc 0x21000073; " +
$"favorite empty=0x{favoriteEmptySprite:X8}.");
}
private void MountSpellbook()
@ -1016,13 +1025,12 @@ public sealed class RetailUiRuntime : IDisposable
$"[UI] retail DialogFactory from LayoutDesc 0x{layoutId:X8}; confirmation root 0x15.");
}
private UiShortcutSlotGraphics LoadShortcutSlotGraphics()
private UiShortcutDigitGraphics LoadShortcutDigitGraphics()
{
if (_shortcutSlotGraphics is not null)
return _shortcutSlotGraphics;
if (_shortcutDigitGraphics is not null)
return _shortcutDigitGraphics;
uint[]? regular = null, ghosted = null, empty = null;
uint emptySprite = 0u;
lock (_bindings.Assets.DatLock)
{
var layout = _bindings.Assets.Dats.Get<DatReaderWriter.DBObjs.LayoutDesc>(0x21000037u);
@ -1039,10 +1047,6 @@ 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 ??=
@ -1055,13 +1059,12 @@ public sealed class RetailUiRuntime : IDisposable
0x06001ACCu, 0x06001ACDu, 0x06001ACEu, 0x06001ACFu, 0x06001AD0u,
0x06001AD1u, 0x06001AD2u, 0x06001AD3u, 0x06001AD4u,
];
_shortcutSlotGraphics = new UiShortcutSlotGraphics(
regular, ghosted, empty, emptySprite);
_shortcutDigitGraphics = new UiShortcutDigitGraphics(
regular, ghosted, empty);
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;
$"[D.5.1] shared shortcut digits ready: regular={regular.Length}, " +
$"ghosted={ghosted.Length}, empty={empty?.Length ?? 0}.");
return _shortcutDigitGraphics;
}
private static uint[]? ReadDataIds(

View file

@ -0,0 +1,12 @@
namespace AcDream.App.UI;
/// <summary>
/// Shared DAT digit overlays used by UIItem shortcut cells. Both gmToolbarUI and
/// gmSpellcastingUI call UIElement_UIItem::SetShortcutNum @ 0x004E1590, so they
/// share these number arrays. The underlying ItemSlot_Empty surface remains a
/// per-ItemList asset selected by that list's cell prototype.
/// </summary>
public sealed record UiShortcutDigitGraphics(
uint[]? RegularDigits,
uint[]? GhostedDigits,
uint[]? EmptyDigits);

View file

@ -1,12 +0,0 @@
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);