feat(ui): port retail component book rows

Instantiate gmSpellComponentUI category and component entries from LayoutDesc 0x21000033 instead of synthesizing catalog rows. Preserve localized category titles, authored textures and geometry, live icon/count/desired bindings, selected-object synchronization, and retail's inclusive 0..5000 desired-level contract.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-15 17:38:24 +02:00
parent 15bb61e05d
commit a1175e6aac
13 changed files with 2395 additions and 60 deletions

View file

@ -0,0 +1,188 @@
using DatReaderWriter;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Instantiates retail's category and component listbox templates from LayoutDesc
/// 0x21000033. This is the retained equivalent of
/// <c>UIElement_ListBox::AddItemFromTemplateList</c> used by
/// <c>gmSpellComponentUI::UpdateComponents @ 0x0048A910</c>.
/// </summary>
public sealed class ComponentBookTemplateFactory
{
public const uint LayoutId = 0x21000033u;
public const uint CategoryTemplateId = 0x10000466u;
public const uint ComponentTemplateId = 0x10000467u;
public const uint IconId = 0x10000468u;
public const uint NameId = 0x10000469u;
public const uint OwnedCountId = 0x1000046Au;
public const uint DesiredCountId = 0x1000046Bu;
private const uint LocalStringTableId = 0x23000001u;
private const uint HighlightState = UiButtonStateMachine.Highlight;
private static readonly (string Key, string Fallback)[] CategoryStrings =
[
("ID_SpellComp_Category_Scarabs", "SCARABS"),
("ID_SpellComp_Category_Herbs", "HERBS"),
("ID_SpellComp_Category_Gems", "POWDERED GEMS"),
("ID_SpellComp_Category_Alchemical", "ALCHEMICAL SUBSTANCES"),
("ID_SpellComp_Category_Talismans", "TALISMANS"),
("ID_SpellComp_Category_Tapers", "TAPERS"),
("ID_SpellComp_Category_Peas", "PEAS"),
];
private readonly ElementInfo _categoryTemplate;
private readonly ElementInfo _componentTemplate;
private readonly Func<uint, (uint tex, int w, int h)> _resolveSprite;
private readonly UiDatFont? _defaultFont;
private readonly IReadOnlyDictionary<uint, UiDatFont?> _fonts;
private readonly string[] _categoryNames;
public ComponentBookTemplateFactory(
ElementInfo categoryTemplate,
ElementInfo componentTemplate,
Func<uint, (uint tex, int w, int h)> resolveSprite,
UiDatFont? defaultFont,
IReadOnlyDictionary<uint, UiDatFont?>? fonts = null,
IReadOnlyList<string>? categoryNames = null)
{
_categoryTemplate = categoryTemplate;
_componentTemplate = componentTemplate;
_resolveSprite = resolveSprite;
_defaultFont = defaultFont;
_fonts = fonts ?? new Dictionary<uint, UiDatFont?>();
_categoryNames = categoryNames?.ToArray()
?? CategoryStrings.Select(value => value.Fallback).ToArray();
}
/// <summary>
/// Loads and resolves the two template definitions while the caller holds the
/// shared DAT lock. Fonts and localized category strings are captured eagerly,
/// so later inventory refreshes instantiate rows without reading DAT files.
/// </summary>
public static ComponentBookTemplateFactory? TryLoad(
DatCollection dats,
Func<uint, (uint tex, int w, int h)> resolveSprite,
UiDatFont? defaultFont,
Func<uint, UiDatFont?>? resolveFont)
{
ElementInfo? category = LayoutImporter.ImportInfos(
dats, LayoutId, CategoryTemplateId);
ElementInfo? component = LayoutImporter.ImportInfos(
dats, LayoutId, ComponentTemplateId);
if (category is null || component is null)
return null;
var fonts = new Dictionary<uint, UiDatFont?>();
CaptureFonts(category, resolveFont, fonts);
CaptureFonts(component, resolveFont, fonts);
var strings = new DatStringResolver(dats);
string[] categoryNames = CategoryStrings
.Select(value => strings.Resolve(
LocalStringTableId,
DatStringResolver.ComputeHash(value.Key))
?? value.Fallback)
.ToArray();
return new ComponentBookTemplateFactory(
category, component, resolveSprite, defaultFont, fonts, categoryNames);
}
public UiTemplateListSlot CreateCategoryRow(uint category)
{
ImportedLayout content = Build(_categoryTemplate);
if (content.Root is not UiText title)
throw new InvalidOperationException(
"Retail component category template did not resolve to UIElement_Text.");
string label = category < _categoryNames.Length
? _categoryNames[category]
: "OTHER COMPONENTS";
title.LinesProvider = () => [new UiText.Line(label, title.DefaultColor)];
return new UiTemplateListSlot(
content, uint.MaxValue, content.Root is IUiDatStateful stateful
? stateful.ActiveRetailStateId
: UiStateInfo.DirectStateId, selectedState: null);
}
public ComponentRow CreateComponentRow(
uint componentId,
uint iconTexture,
string name,
int ownedCount,
uint desiredCount)
{
ImportedLayout content = Build(_componentTemplate);
UiElement iconHost = Required(content, IconId);
UiText nameText = Required<UiText>(content, NameId);
UiText ownedText = Required<UiText>(content, OwnedCountId);
UiField desiredField = Required<UiField>(content, DesiredCountId);
var icon = new UiTextureElement
{
Width = iconHost.Width,
Height = iconHost.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom,
Texture = iconTexture,
};
iconHost.AddChild(icon);
nameText.LinesProvider = () => [new UiText.Line(name, nameText.DefaultColor)];
ownedText.LinesProvider = () =>
[new UiText.Line(ownedCount.ToString(System.Globalization.CultureInfo.InvariantCulture),
ownedText.DefaultColor)];
// NumberInputFilter + the 0..5000 validation are installed by the controller,
// matching gmSpellComponentUI::ListenToElementMessage @ 0x0048A420.
desiredField.ClearOnSubmit = false;
desiredField.RecordHistory = false;
desiredField.SelectAllOnFocus = true;
desiredField.CharacterFilter = char.IsAsciiDigit;
desiredField.SetText(desiredCount.ToString(
System.Globalization.CultureInfo.InvariantCulture));
uint normalState = content.Root is IUiDatStateful stateful
? stateful.ActiveRetailStateId
: UiStateInfo.DirectStateId;
var slot = new UiTemplateListSlot(
content, componentId, normalState, HighlightState);
return new ComponentRow(slot, desiredField);
}
private ImportedLayout Build(ElementInfo template)
=> LayoutImporter.Build(
template,
_resolveSprite,
_defaultFont,
did => _fonts.TryGetValue(did, out UiDatFont? font) ? font : _defaultFont);
private static T Required<T>(ImportedLayout content, uint id)
where T : UiElement
=> content.FindElement(id) as T
?? throw new InvalidOperationException(
$"Retail component template element 0x{id:X8} did not resolve to {typeof(T).Name}.");
private static UiElement Required(ImportedLayout content, uint id)
=> content.FindElement(id)
?? throw new InvalidOperationException(
$"Retail component template element 0x{id:X8} is missing.");
private static void CaptureFonts(
ElementInfo info,
Func<uint, UiDatFont?>? resolveFont,
Dictionary<uint, UiDatFont?> fonts)
{
if (info.FontDid != 0u && !fonts.ContainsKey(info.FontDid))
fonts[info.FontDid] = resolveFont?.Invoke(info.FontDid);
foreach (ElementInfo child in info.Children)
CaptureFonts(child, resolveFont, fonts);
}
public readonly record struct ComponentRow(
UiTemplateListSlot Slot,
UiField DesiredField);
}

View file

@ -2,9 +2,9 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Numerics;
using AcDream.App.Spells;
using AcDream.Core.Items;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
@ -48,6 +48,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly IReadOnlyDictionary<uint, SpellComponentDescriptor> _components;
private readonly SelectionState _selection;
private readonly Func<uint, uint> _resolveSpellIcon;
private readonly Func<uint, uint> _resolveComponentIcon;
private readonly Func<uint, int> _spellLevel;
@ -66,10 +67,12 @@ public sealed class SpellbookWindowController : IRetainedPanelController
private readonly UiButton _deleteButton;
private readonly UiItemList _spellList;
private readonly UiItemList _componentList;
private readonly ComponentBookTemplateFactory _componentTemplates;
private readonly List<(UiButton Button, uint Mask)> _filters = new();
private readonly SpellbookRowStyle _rowStyle;
private readonly UiDatFont? _rowFont;
private uint? _selectedSpell;
private uint? _selectedComponent;
private bool _spellsDirty;
private bool _componentsDirty;
private bool _componentEditActive;
@ -81,6 +84,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
ClientObjectTable objects,
Func<uint> playerGuid,
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
SelectionState selection,
Func<uint, uint> resolveSpellIcon,
Func<uint, uint> resolveComponentIcon,
Func<uint, int> spellLevel,
@ -99,6 +103,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
UiButton deleteButton,
UiItemList spellList,
UiItemList componentList,
ComponentBookTemplateFactory componentTemplates,
SpellbookRowStyle rowStyle,
UiDatFont? rowFont)
{
@ -106,6 +111,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
_objects = objects;
_playerGuid = playerGuid;
_components = components;
_selection = selection;
_resolveSpellIcon = resolveSpellIcon;
_resolveComponentIcon = resolveComponentIcon;
_spellLevel = spellLevel;
@ -124,6 +130,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
_deleteButton = deleteButton;
_spellList = spellList;
_componentList = componentList;
_componentTemplates = componentTemplates;
_rowStyle = rowStyle;
_rowFont = rowFont;
@ -148,6 +155,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
_objects.ObjectRemoved += OnObjectRemoved;
_objects.ObjectMoved += OnObjectMoved;
_objects.ContainerContentsReplaced += OnContainerContentsReplaced;
_selection.Changed += OnSelectionChanged;
ShowPage(SpellbookWindowPage.Spells);
RebuildAll();
}
@ -160,6 +168,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
ClientObjectTable objects,
Func<uint> playerGuid,
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
SelectionState selection,
Func<uint, uint> resolveSpellIcon,
Func<uint, uint> resolveComponentIcon,
Func<uint, int> spellLevel,
@ -170,6 +179,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
Action<string, Action<bool>> showConfirmation,
Action<uint, uint> setDesiredComponent,
Action close,
ComponentBookTemplateFactory componentTemplates,
SpellbookRowStyle rowStyle,
UiDatFont? rowFont)
{
@ -199,13 +209,14 @@ public sealed class SpellbookWindowController : IRetainedPanelController
}
return new SpellbookWindowController(
layout, spellbook, objects, playerGuid, components,
layout, spellbook, objects, playerGuid, components, selection,
resolveSpellIcon, resolveComponentIcon,
spellLevel, selectObject,
addFavorite, sendFilter, removeSpell, showConfirmation,
setDesiredComponent, close,
spellPage, componentPage, spellTab, componentTab, closeButton,
deleteButton, spellList, componentList, rowStyle, rowFont);
deleteButton, spellList, componentList, componentTemplates,
rowStyle, rowFont);
}
public void ShowPage(SpellbookWindowPage page)
@ -337,40 +348,29 @@ public sealed class SpellbookWindowController : IRetainedPanelController
if (category != nextCategory)
{
category = nextCategory;
_componentList.AddItem(new UiCatalogSlot
UiTemplateListSlot categoryRow =
_componentTemplates.CreateCategoryRow(nextCategory);
categoryRow.Clicked = () =>
{
EntryId = uint.MaxValue,
Label = ComponentCategoryName(nextCategory),
ShowLabel = true,
SpriteResolve = _componentList.SpriteResolve,
});
_selectedComponent = null;
SyncComponentSelection();
_selectObject(0u);
};
_componentList.AddItem(categoryRow);
}
uint desired = _spellbook.DesiredComponents.TryGetValue(componentId, out uint amount) ? amount : 0u;
var row = new UiCatalogSlot
{
EntryId = componentId,
CatalogIconTexture = _resolveComponentIcon(
pack.IconId != 0 ? pack.IconId : descriptor?.IconId ?? 0u),
Label = string.IsNullOrWhiteSpace(pack.Name) ? descriptor?.Name ?? $"Component {componentId}" : pack.Name,
Detail = packGroup.Quantity.ToString(CultureInfo.InvariantCulture),
ShowLabel = true,
SpriteResolve = _componentList.SpriteResolve,
};
var desiredField = new UiField
{
Left = width - 47f,
Top = 4f,
Width = 43f,
Height = 24f,
MaxCharacters = 4,
ClearOnSubmit = false,
RecordHistory = false,
SelectAllOnFocus = true,
RightAligned = true,
CharacterFilter = char.IsAsciiDigit,
BackgroundColor = new Vector4(0f, 0f, 0f, 0.65f),
};
desiredField.SetText(desired.ToString(CultureInfo.InvariantCulture));
string componentName = string.IsNullOrWhiteSpace(pack.Name)
? descriptor?.Name ?? $"Component {componentId}"
: pack.Name;
ComponentBookTemplateFactory.ComponentRow row =
_componentTemplates.CreateComponentRow(
componentId,
_resolveComponentIcon(
pack.IconId != 0 ? pack.IconId : descriptor?.IconId ?? 0u),
componentName,
packGroup.Quantity,
desired);
UiField desiredField = row.DesiredField;
desiredField.OnFocusGained = () => _componentEditActive = true;
uint committed = desired;
void Commit(string value)
@ -392,25 +392,18 @@ public sealed class SpellbookWindowController : IRetainedPanelController
Commit(value);
_componentEditActive = false;
};
row.AddChild(desiredField);
row.Clicked = () => _selectObject(pack.ObjectId);
_componentList.AddItem(row);
row.Slot.Clicked = () =>
{
_selectedComponent = componentId;
SyncComponentSelection();
_selectObject(pack.ObjectId);
};
_componentList.AddItem(row.Slot);
}
}
SyncComponentSelectionFromWorld();
}
private static string ComponentCategoryName(uint category) => category switch
{
0u => "Scarabs",
1u => "Herbs",
2u => "Powdered Gems",
3u => "Alchemical Substances",
4u => "Talismans",
5u => "Tapers",
6u => "Prismatic Components",
_ => "Other Components",
};
private bool IsOwnedByPlayer(ClientObject item, uint playerGuid)
{
if (playerGuid == 0) return item.ContainerId != 0 || item.WielderId != 0;
@ -479,6 +472,23 @@ public sealed class SpellbookWindowController : IRetainedPanelController
slot.Selected = slot.EntryId == _selectedSpell;
}
private void SyncComponentSelection()
{
for (int i = 0; i < _componentList.GetNumUIItems(); i++)
if (_componentList.GetItem(i) is UiTemplateListSlot slot)
slot.SetSelected(slot.EntryId == _selectedComponent);
}
private void SyncComponentSelectionFromWorld()
{
_selectedComponent = _selection.SelectedObjectId is uint selected
&& _objects.Get(selected) is { } item
&& IsComponent(item)
? item.WeenieClassId
: null;
SyncComponentSelection();
}
public void Tick()
{
if (_spellsDirty)
@ -505,6 +515,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
}
private void OnObjectMoved(ClientObjectMove _) => _componentsDirty = true;
private void OnContainerContentsReplaced(uint _) => _componentsDirty = true;
private void OnSelectionChanged(SelectionTransition _) => SyncComponentSelectionFromWorld();
private bool IsComponent(ClientObject item)
=> item.IsComponentPack || _components.ContainsKey(item.WeenieClassId);
public void OnShown() => RebuildAll();
@ -520,6 +531,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
_objects.ObjectRemoved -= OnObjectRemoved;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
_selection.Changed -= OnSelectionChanged;
RetailTabBinding.SetClick(_spellTab, null);
RetailTabBinding.SetClick(_componentTab, null);
_closeButton.OnClick = null;

View file

@ -0,0 +1,72 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// One runtime listbox entry instantiated from an authored LayoutDesc template.
/// Retail <c>UIElement_ListBox::AddItemFromTemplateList</c> creates exactly this
/// ownership shape: the list owns the entry while the template owns its visuals.
/// </summary>
public sealed class UiTemplateListSlot : UiItemSlot
{
private readonly IUiDatStateful? _statefulRoot;
private readonly uint _normalState;
private readonly uint? _selectedState;
private bool? _stateWasSelected;
public UiTemplateListSlot(
ImportedLayout content,
uint entryId,
uint normalState,
uint? selectedState)
{
Content = content;
EntryId = entryId;
_normalState = normalState;
_selectedState = selectedState;
_statefulRoot = content.Root as IUiDatStateful;
Width = content.Root.Width;
Height = content.Root.Height;
content.Root.Left = 0f;
content.Root.Top = 0f;
content.Root.Anchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom;
AddChild(content.Root);
}
public ImportedLayout Content { get; }
public uint EntryId { get; }
public new Action? Clicked { get; set; }
public override bool HandlesClick => Clicked is not null;
public void SetSelected(bool selected)
{
Selected = selected;
ApplySelectionState();
}
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.MouseDown)
return true;
if (e.Type == UiEventType.Click && Clicked is not null)
{
Clicked();
return true;
}
return false;
}
protected override void OnDraw(UiRenderContext ctx)
=> ApplySelectionState();
private void ApplySelectionState()
{
if (_stateWasSelected == Selected)
return;
_stateWasSelected = Selected;
_statefulRoot?.TrySetRetailState(
Selected && _selectedState.HasValue ? _selectedState.Value : _normalState);
}
}

View file

@ -709,6 +709,7 @@ public sealed class RetailUiRuntime : IDisposable
{
ImportedLayout? layout;
SpellbookRowStyle? rowStyle;
ComponentBookTemplateFactory? componentTemplates;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Import(
@ -719,10 +720,16 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
rowStyle = SpellbookRowStyle.TryLoad(_bindings.Assets.Dats);
componentTemplates = ComponentBookTemplateFactory.TryLoad(
_bindings.Assets.Dats,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (layout is null || rowStyle is null)
if (layout is null || rowStyle is null || componentTemplates is null)
{
Console.WriteLine("[M3] spellbook: layout or authored UIItem row prototype not found.");
Console.WriteLine(
"[M3] spellbook: layout, spell row, or component list templates not found.");
return;
}
@ -737,6 +744,7 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Magic.Objects,
_bindings.Magic.PlayerGuid,
_bindings.Magic.Components,
_bindings.Magic.Selection,
_bindings.Magic.ResolveSpellIcon,
_bindings.Magic.ResolveComponentIcon,
_bindings.Magic.SpellLevel,
@ -747,6 +755,7 @@ public sealed class RetailUiRuntime : IDisposable
(message, completed) => ShowConfirmation(message, completed),
_bindings.Magic.SetDesiredComponent,
() => CloseWindow(WindowNames.Spellbook),
componentTemplates,
rowStyle.Value,
rowFont);
if (controller is null)

View file

@ -0,0 +1,23 @@
using System.Numerics;
using AcDream.App.Rendering;
namespace AcDream.App.UI;
/// <summary>
/// Runtime-owned texture content inside an authored retained-UI region. LayoutDesc
/// still owns the region's geometry and chrome; controllers supply only the live
/// texture handle (for example a composed item icon).
/// </summary>
public sealed class UiTextureElement : UiElement
{
public UiTextureElement() => ClickThrough = true;
public uint Texture { get; set; }
public Vector4 Tint { get; set; } = Vector4.One;
protected override void OnDraw(UiRenderContext ctx)
{
if (Texture != 0u)
ctx.DrawSprite(Texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Tint);
}
}