diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs
new file mode 100644
index 00000000..aefa6502
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/ToolbarController.cs
@@ -0,0 +1,139 @@
+using System;
+using System.Collections.Generic;
+using AcDream.Core.Items;
+using AcDream.Core.Net.Messages;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// Binds the imported gmToolbarUI window (LayoutDesc 0x21000016) to live data —
+/// the gm*UI::PostInit analogue. Finds the 18 shortcut slots (UiItemList) by id,
+/// populates them from the persisted PlayerDescription shortcuts
+/// (UpdateFromPlayerDesc), re-binds deferred slots when an item's CreateObject
+/// arrives (SetDelayedShortcutNum), and on click uses the bound item
+/// (UseShortcut -> ItemHolder::UseObject -> use-item callback).
+///
+///
+/// Retail reference: gmToolbarUI::PostInit grabs each slot widget by its
+/// id, calls UpdateFromPlayerDesc to flush-and-bind shortcuts from the
+/// PlayerDescription trailer, and hooks OnEvent for the Click case to fire
+/// UseShortcut. The deferred-rebind path matches
+/// gmToolbarUI::SetDelayedShortcutNum which re-tries binding after
+/// CreateObject resolves a formerly-unknown guid.
+///
+///
+public sealed class ToolbarController
+{
+ // Slot element ids in slot-index order (toolbar LayoutDesc 0x21000016, pre-dump).
+ // Row 1 = slots 0-8 (0x100001A7..0x100001AF), Row 2 = slots 9-17 (0x100006B7..0x100006BF).
+ private static readonly uint[] SlotIds =
+ {
+ 0x100001A7, 0x100001A8, 0x100001A9, 0x100001AA, 0x100001AB,
+ 0x100001AC, 0x100001AD, 0x100001AE, 0x100001AF,
+ 0x100006B7, 0x100006B8, 0x100006B9, 0x100006BA, 0x100006BB,
+ 0x100006BC, 0x100006BD, 0x100006BE, 0x100006BF,
+ };
+
+ // Elements hidden by default in retail gmToolbarUI::PostInit: the selected-object
+ // vitals meters (health/stamina/mana bars that track your target) and the stack slider.
+ // Ids confirmed from the toolbar LayoutDesc dump.
+ private static readonly uint[] HiddenIds = { 0x100001A1, 0x100001A2, 0x100001A4 };
+
+ private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length];
+ private readonly ItemRepository _repo;
+ private readonly Func> _shortcuts;
+ private readonly Func _iconIds; // (iconId, underlayId, overlayId) → GL tex
+ private readonly Action _useItem; // guid → fire UseObject
+
+ private ToolbarController(
+ ImportedLayout layout,
+ ItemRepository repo,
+ Func> shortcuts,
+ Func iconIds,
+ Action useItem)
+ {
+ _repo = repo;
+ _shortcuts = shortcuts;
+ _iconIds = iconIds;
+ _useItem = useItem;
+
+ for (int i = 0; i < SlotIds.Length; i++)
+ {
+ _slots[i] = layout.FindElement(SlotIds[i]) as UiItemList;
+ if (_slots[i] is { } list)
+ WireClick(list);
+ }
+
+ // Hide target-object meters + stack slider (gmToolbarUI::PostInit).
+ foreach (var id in HiddenIds)
+ if (layout.FindElement(id) is { } e) e.Visible = false;
+
+ // Re-bind any deferred slot whenever the repo learns about a new/updated item.
+ repo.ItemAdded += _ => Populate();
+ repo.ItemPropertiesUpdated += _ => Populate();
+ }
+
+ ///
+ /// Create and bind a to .
+ /// Calls immediately (binds whatever items are in the repo now).
+ /// Returns the controller so the caller can call again
+ /// if the shortcut list is refreshed outside the repo-event path.
+ ///
+ /// Imported toolbar layout (LayoutDesc 0x21000016).
+ /// Live item repository — must stay alive for the controller's lifetime.
+ /// Provider for the current shortcut bar list.
+ /// Resolves (iconId, underlayId, overlayId) → GL texture handle.
+ /// Callback fired when a bound slot is clicked; receives the item guid.
+ public static ToolbarController Bind(
+ ImportedLayout layout,
+ ItemRepository repo,
+ Func> shortcuts,
+ Func iconIds,
+ Action useItem)
+ {
+ var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem);
+ c.Populate();
+ return c;
+ }
+
+ ///
+ /// Port of gmToolbarUI::UpdateFromPlayerDesc: clear all slots, then bind
+ /// each shortcut entry that has a resolved item in the repository.
+ /// Entries whose item is not yet in the repo are silently skipped here; the
+ /// ItemAdded event re-fires this method when the item arrives
+ /// (matching retail's SetDelayedShortcutNum deferred-rebind path).
+ ///
+ public void Populate()
+ {
+ // Clear all slot cells first (flush).
+ foreach (var list in _slots) list?.Cell.Clear();
+
+ foreach (var sc in _shortcuts())
+ {
+ if (sc.ObjectGuid == 0) continue; // spell-only shortcut — inventory phase
+ if (sc.Index >= (uint)_slots.Length) continue;
+ var list = _slots[(int)sc.Index];
+ if (list is null) continue;
+
+ var item = _repo.GetItem(sc.ObjectGuid);
+ if (item is null) continue; // deferred: ItemAdded will re-call Populate
+
+ uint tex = _iconIds(item.IconId, item.IconUnderlayId, item.IconOverlayId);
+ list.Cell.SetItem(sc.ObjectGuid, tex);
+ }
+ }
+
+ ///
+ /// Wire the callback on a slot cell so that
+ /// clicking a bound item fires with the slot's current guid.
+ /// Mirrors retail's gmToolbarUI click → UseShortcut dispatch.
+ ///
+ private void WireClick(UiItemList list)
+ {
+ list.Cell.Clicked = () =>
+ {
+ if (list.Cell.ItemId != 0)
+ _useItem(list.Cell.ItemId);
+ };
+ }
+}
diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs
index ebd1f33e..f6dac45d 100644
--- a/src/AcDream.App/UI/UiItemSlot.cs
+++ b/src/AcDream.App/UI/UiItemSlot.cs
@@ -37,6 +37,17 @@ public sealed class UiItemSlot : UiElement
public void Clear() { ItemId = 0; IconTexture = 0; }
+ /// Invoked by when a left-button-down lands on
+ /// a bound slot. Wired by ToolbarController to the use-item callback.
+ public Action? Clicked { get; set; }
+
+ ///
+ public override bool OnEvent(in UiEvent e)
+ {
+ if (e.Type == UiEventType.MouseDown) { Clicked?.Invoke(); return true; }
+ return false;
+ }
+
protected override void OnDraw(UiRenderContext ctx)
{
if (ItemId != 0 && IconTexture != 0)
diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
new file mode 100644
index 00000000..44bd3416
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Collections.Generic;
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+using AcDream.Core.Items;
+using AcDream.Core.Net.Messages;
+using Xunit;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+public class ToolbarControllerTests
+{
+ private static readonly uint[] Row1 =
+ { 0x100001A7,0x100001A8,0x100001A9,0x100001AA,0x100001AB,0x100001AC,0x100001AD,0x100001AE,0x100001AF };
+ private static readonly uint[] Row2 =
+ { 0x100006B7,0x100006B8,0x100006B9,0x100006BA,0x100006BB,0x100006BC,0x100006BD,0x100006BE,0x100006BF };
+
+ private static (ImportedLayout layout, Dictionary slots) FakeToolbar()
+ {
+ var dict = new Dictionary();
+ var slots = new Dictionary();
+ var root = new UiPanel();
+ foreach (var id in Row1) AddSlot(id);
+ foreach (var id in Row2) AddSlot(id);
+ return (new ImportedLayout(root, dict), slots);
+
+ void AddSlot(uint id)
+ {
+ var list = new UiItemList(_ => (0u, 0, 0)) { Width = 32, Height = 32 };
+ dict[id] = list; slots[id] = list; root.AddChild(list);
+ }
+ }
+
+ [Fact]
+ public void Populate_bindsShortcutToCorrectSlot()
+ {
+ var (layout, slots) = FakeToolbar();
+ var repo = new ItemRepository();
+ repo.AddOrUpdate(new ItemInstance { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
+ var shortcuts = new List
+ { new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
+
+ ToolbarController.Bind(layout, repo, () => shortcuts,
+ iconIds: (_,_,_) => 0x77u, useItem: _ => { });
+
+ Assert.Equal(0x5001u, slots[Row1[0]].Cell.ItemId);
+ Assert.Equal(0x77u, slots[Row1[0]].Cell.IconTexture);
+ Assert.Equal(0u, slots[Row1[1]].Cell.ItemId); // others empty
+ }
+
+ [Fact]
+ public void DeferredRebind_whenItemArrivesLate()
+ {
+ var (layout, slots) = FakeToolbar();
+ var repo = new ItemRepository(); // item NOT present yet
+ var shortcuts = new List
+ { new(Index: 2, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) };
+
+ ToolbarController.Bind(layout, repo, () => shortcuts,
+ iconIds: (_,_,_) => 0x88u, useItem: _ => { });
+ Assert.Equal(0u, slots[Row1[2]].Cell.ItemId); // not bound yet
+
+ repo.AddOrUpdate(new ItemInstance { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u });
+
+ Assert.Equal(0x5002u, slots[Row1[2]].Cell.ItemId); // rebound on ItemAdded
+ }
+
+ [Fact]
+ public void Click_emitsUseForBoundItem()
+ {
+ var (layout, slots) = FakeToolbar();
+ var repo = new ItemRepository();
+ repo.AddOrUpdate(new ItemInstance { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
+ var shortcuts = new List
+ { new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
+ uint used = 0;
+
+ ToolbarController.Bind(layout, repo, () => shortcuts,
+ iconIds: (_,_,_) => 0x77u, useItem: g => used = g);
+ // UiEvent is a positional record struct: (SourceId, Target, Type, Data0..3, Payload)
+ slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.MouseDown));
+
+ Assert.Equal(0x5001u, used);
+ }
+}