acdream/docs/superpowers/plans/2026-06-16-d2b-toolbar-phase1.md
Erik 44fabd350e docs(D.5.1): toolbar phase-1 implementation plan (+ spec wiring-delta note)
12-task TDD plan: register D.5.1 -> CreateObject IconId capture -> ItemRepository.EnrichItem -> spawn-event icon wiring -> persist shortcuts -> IconComposer (CPU composite) -> UiItemSlot -> UiItemList + factory branch -> ToolbarController -> GameWindow mount -> visual gate -> bookkeeping. Concrete call sites pinned (WorldSession.cs:701 EntitySpawned, GameEventWiring.WireAll, GameWindow Items@598, BuildUse 0x0036). Synced the spec's CreateObject section with the wider-than-expected wiring found during planning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:27:49 +02:00

1104 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# D.5.1 Toolbar (action bar) — Phase 1 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship the retail action bar (`gmToolbarUI`, `LayoutDesc 0x21000016`) as acdream's first data-driven game panel: 18 shortcut slots populated from the persisted `PlayerDescription` shortcut block, each pinned item rendering its real composited icon, with click-to-use.
**Architecture:** Reuse the shipped D.2b assembly pattern (dat `LayoutDesc``LayoutImporter``DatWidgetFactory` → thin find-by-id controller). Two new shared widgets (`UiItemSlot`, `UiItemList`) + a CPU icon-composite pipeline (`IconComposer`) + the wire plumbing to carry `IconId` from `CreateObject` into `ItemRepository` and to persist the shortcut list. The 18 toolbar slots already resolve to `UIElement_ItemList` (class `0x10000031`) through the dat `BaseElement`/`BaseLayoutId` chain (slot → `0x100001B2``0x10000339`@`0x2100003D`, Type `0x10000031`), so one `DatWidgetFactory` branch makes them `UiItemList`s automatically; the item cell is created procedurally by the list.
**Tech Stack:** C# .NET 10, Silk.NET OpenGL, the in-tree `AcDream.App/UI` retained-mode toolkit, `DatCollection` for RenderSurface decode, xUnit.
**Spec:** [`docs/superpowers/specs/2026-06-16-d2b-toolbar-phase1-design.md`](../specs/2026-06-16-d2b-toolbar-phase1-design.md).
**Research anchors:** [`docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](../../research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md), [`docs/research/2026-06-16-action-bar-toolbar-deep-dive.md`](../../research/2026-06-16-action-bar-toolbar-deep-dive.md).
**Spec deltas discovered during planning (elaboration, not contradiction):**
- Spec §4.4 assumed "just capture IconId." Reality: acdream's `CreateObject.TryParse` discards IconId (`CreateObject.cs:516`) AND there is **no** `CreateObject``ItemRepository` wiring at all — the repo is populated only from `PlayerDescription` with stub `ItemInstance`s (`ObjectId`+`WeenieClassId`). So Tasks 24 add: capture IconId, enrich the repo from the spawn event, and persist `Parsed.Shortcuts` (currently parsed then discarded in `GameEventWiring`).
- IconId source is CONFIRMED to be `CreateObject` for contained pack items (ACE `WorldObject_Networking.cs:79` writes `WritePackedDwordOfKnownType(IconId, 0x6000000)` unconditionally; Chorizite `PublicWeenieDesc` reads `Icon` with no flag gate). No fallback needed.
- Phase-1 `IconComposer` scope: CPU-composite the layers whose source data `ItemInstance` already exposes (custom underlay `IconUnderlayId` + base `IconId` + custom overlay `IconOverlayId`, alpha-over). The retail `IconData::RenderIcons` (decomp 407524) `GetByEnum` type-default-underlay, the overlay `ReplaceColor` tint, and the effect overlay need wire data not yet parsed (overlay tint color, `IconEffects`) — DEFERRED with divergence rows (Task 12). This keeps Approach A (faithful CPU pre-composite) while scoping to available data.
---
## Task 0: Register D.5.1 in the roadmap
**Files:**
- Modify: `docs/plans/2026-04-11-roadmap.md` (the D.5 entry, ~line 433)
- [ ] **Step 1: Add the D.5.1 sub-phase entry under D.5**
In `docs/plans/2026-04-11-roadmap.md`, immediately after the `D.5 — Core panels` bullet (the one at ~line 433), add:
```markdown
- **D.5.1 — Toolbar (action bar) [IN PROGRESS].** First D.5 sub-phase. `gmToolbarUI` (`LayoutDesc 0x21000016`) as the first data-driven game panel: 18 shortcut slots from the persisted `PlayerDescription` SHORTCUT block, real composited icons, click-to-use. New shared widgets `UiItemSlot` (`UIElement_UIItem` 0x10000032, procedural) + `UiItemList` (`UIElement_ItemList` 0x10000031, factory-registered) + `IconComposer` (CPU 5-layer composite, `IconData::RenderIcons` @407524) + the `CreateObject``ItemRepository` IconId wiring. Spec/plan: `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`. Deferred to later D.5 sub-phases: drag/reorder, the AddShortcut/RemoveShortcut mutate wire, meters/slider, spell shortcuts, faithful window manager, inventory, paperdoll.
```
- [ ] **Step 2: Commit**
```bash
git add docs/plans/2026-04-11-roadmap.md
git commit -m "docs(D.5.1): register toolbar phase-1 in the roadmap"
```
---
## Task 1: Capture `IconId` in `CreateObject.Parsed`
**Files:**
- Modify: `src/AcDream.Core.Net/Messages/CreateObject.cs` (the `Parsed` struct ~lines 105-142; the parse at lines 515-516)
- Test: `tests/AcDream.Core.Net.Tests/CreateObjectTests.cs` (add a test; create the file if no CreateObject test exists — verify with `Glob tests/AcDream.Core.Net.Tests/*reate*bject*`)
- [ ] **Step 1: Write the failing test**
Add to `tests/AcDream.Core.Net.Tests/CreateObjectTests.cs` (mirror an existing CreateObject test's byte-buffer construction; if none exists, build a minimal body using the same field order as `TryParse`). The assertion that matters:
```csharp
[Fact]
public void TryParse_capturesIconId()
{
// A CreateObject body for a simple contained item. Build the bytes with the
// exact field order TryParse reads (guid, ... name, packed WeenieClassId,
// packed-of-known-type IconId 0x06xxxxxx, u32 itemType, ...). Reuse the helper
// that an existing CreateObject test uses to assemble a body; the new assertion:
var parsed = CreateObject.TryParse(BuildContainedItemBody(iconId: 0x06001234u));
Assert.NotNull(parsed);
Assert.Equal(0x06001234u, parsed!.Value.IconId);
}
```
- [ ] **Step 2: Run the test, verify it fails**
Run: `dotnet test tests/AcDream.Core.Net.Tests --filter TryParse_capturesIconId`
Expected: FAIL — `Parsed` has no member `IconId` (compile error), or `IconId` is 0.
- [ ] **Step 3: Add `IconId` to the `Parsed` struct and capture it**
In `src/AcDream.Core.Net/Messages/CreateObject.cs`, add a field to the `Parsed` struct (the readonly struct around lines 105-142):
```csharp
public uint IconId; // 0x06xxxxxx RenderSurface id of the item icon (0 = none)
```
In `TryParse`, change the discard at line 516 to capture, and assign it into the returned `Parsed`. Replace:
```csharp
_ = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
```
with:
```csharp
uint iconId = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
```
Then add `IconId = iconId,` to the object/struct initializer where `Parsed` is constructed (the `return new Parsed { ... }` near the end of `TryParse`). Leave the `WeenieClassId` discard at line 515 as-is for now (the spawn event already carries it separately; capturing it is out of phase-1 scope).
- [ ] **Step 4: Run the test, verify it passes**
Run: `dotnet test tests/AcDream.Core.Net.Tests --filter TryParse_capturesIconId`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.Core.Net/Messages/CreateObject.cs tests/AcDream.Core.Net.Tests/CreateObjectTests.cs
git commit -m "feat(D.5.1): capture IconId in CreateObject.Parsed (was discarded at cs:516)"
```
---
## Task 2: `ItemRepository.EnrichItem` (icon enrichment, enrich-existing)
**Files:**
- Modify: `src/AcDream.Core/Items/ItemRepository.cs` (add a method; events already exist at lines 49-59)
- Test: `tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs` (verify the dir with `Glob tests/AcDream.Core.Tests/**/*ItemRepository*`; if absent, create it)
- [ ] **Step 1: Write the failing test**
```csharp
[Fact]
public void EnrichItem_updatesIconOnExistingStub_andRaisesUpdated()
{
var repo = new ItemRepository();
repo.AddOrUpdate(new ItemInstance { ObjectId = 0x5001u, WeenieClassId = 42u }); // stub from PlayerDescription
ItemInstance? updated = null;
repo.ItemPropertiesUpdated += i => updated = i;
bool hit = repo.EnrichItem(0x5001u, iconId: 0x06001234u, name: "Mana Stone", type: ItemType.Misc);
Assert.True(hit);
Assert.Equal(0x06001234u, repo.GetItem(0x5001u)!.IconId);
Assert.Equal("Mana Stone", repo.GetItem(0x5001u)!.Name);
Assert.NotNull(updated);
}
[Fact]
public void EnrichItem_returnsFalse_whenItemUnknown()
{
var repo = new ItemRepository();
Assert.False(repo.EnrichItem(0x9999u, 0x06001234u, "x", ItemType.Misc));
}
```
- [ ] **Step 2: Run the test, verify it fails**
Run: `dotnet test tests/AcDream.Core.Tests --filter EnrichItem`
Expected: FAIL — `EnrichItem` not defined.
- [ ] **Step 3: Implement `EnrichItem`**
Add to `src/AcDream.Core/Items/ItemRepository.cs` (near `AddOrUpdate`):
```csharp
/// <summary>
/// Enrich an already-known item (a stub created from PlayerDescription) with the
/// fuller data carried by its CreateObject (icon, name, type). Returns false if the
/// item isn't tracked yet — phase 1 enriches existing items only; full
/// CreateObject ingestion of newly-acquired items is the inventory phase.
/// Raises ItemPropertiesUpdated on success so bound widgets (the toolbar) re-render.
/// </summary>
public bool EnrichItem(uint objectId, uint iconId, string name, ItemType type)
{
if (!_items.TryGetValue(objectId, out var item)) return false;
if (iconId != 0) item.IconId = iconId;
if (!string.IsNullOrEmpty(name)) item.Name = name;
if (type != default) item.Type = type;
ItemPropertiesUpdated?.Invoke(item);
return true;
}
```
- [ ] **Step 4: Run the test, verify it passes**
Run: `dotnet test tests/AcDream.Core.Tests --filter EnrichItem`
Expected: PASS (both).
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.Core/Items/ItemRepository.cs tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs
git commit -m "feat(D.5.1): ItemRepository.EnrichItem (icon/name/type from CreateObject)"
```
---
## Task 3: Thread `IconId` through the spawn event into `ItemRepository`
This is integration wiring (no new pure unit; covered by Task 1/2 units + the visual gate). Three edits.
**Files:**
- Modify: the `EntitySpawn` record (locate: `Grep "record EntitySpawn" src/AcDream.Core.Net` — likely `src/AcDream.Core.Net/WorldSession.cs` or a sibling)
- Modify: `src/AcDream.Core.Net/WorldSession.cs:701-719` (the `EntitySpawned?.Invoke(new EntitySpawn(...))`)
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the `OnLiveEntitySpawned` handler — subscribed at line 2216)
- [ ] **Step 1: Add `IconId` to the `EntitySpawn` record**
Run: `Grep "record EntitySpawn" src/AcDream.Core.Net -n`. Add a `uint IconId` parameter to the record's positional parameter list (append it at the end to minimize call-site churn; note the one constructor call in WorldSession is updated next).
- [ ] **Step 2: Pass `parsed.Value.IconId` at the invoke site**
In `src/AcDream.Core.Net/WorldSession.cs`, in the `EntitySpawned?.Invoke(new EntitySpawn(...))` block (lines 701-719), add `parsed.Value.IconId` as the final constructor argument (matching the new record parameter position).
- [ ] **Step 3: Enrich the repo in the spawn handler**
In `src/AcDream.App/Rendering/GameWindow.cs`, find `OnLiveEntitySpawned` (the handler subscribed at line 2216). Add, near the top of the handler body (after the `EntitySpawn` arg is in scope, call it `e`):
```csharp
// D.5.1: enrich a known inventory/equipped item (stubbed from PlayerDescription)
// with the icon/name/type its CreateObject carries, so the toolbar can render it.
Items.EnrichItem(e.Guid, e.IconId, e.Name, e.ItemType);
```
(`Items` is the `ItemRepository` field at `GameWindow.cs:598`. `EnrichItem` is a no-op returning false for non-item spawns — players, NPCs, furniture — because they aren't in the repo, so this is safe to call unconditionally.)
- [ ] **Step 4: Build + run the full suite**
Run: `dotnet build` then `dotnet test`
Expected: green (no behavior regression; the new arg threads through).
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.Core.Net/WorldSession.cs src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(D.5.1): thread CreateObject IconId into ItemRepository via spawn event"
```
---
## Task 4: Persist `Parsed.Shortcuts` (the durable holder)
`Parsed.Shortcuts` is parsed in `GameEventWiring.WireAll`'s PlayerDescription handler then discarded. Surface it to a durable holder the toolbar reads.
**Files:**
- Modify: `src/AcDream.Core.Net/GameEventWiring.cs` (the `WireAll` signature + the PlayerDescription lambda)
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (add a `Shortcuts` field; pass a callback at the `WireAll(...)` call ~line 2269)
- Test: `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs`
- [ ] **Step 1: Write the failing test**
In `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs`, add a test that feeds a `PlayerDescription` message carrying a SHORTCUT block through `WireAll` and asserts the new `onShortcuts` callback receives the parsed list. Mirror an existing `GameEventWiringTests` PlayerDescription test for the message-construction + dispatch harness:
```csharp
[Fact]
public void WireAll_PlayerDescription_invokesOnShortcuts()
{
IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>? got = null;
// ... build the same harness an existing PD test uses, but pass the new
// onShortcuts callback into WireAll: onShortcuts: list => got = list
// then dispatch a PD message whose SHORTCUT block has one entry (idx=0, guid=0x5001, spell=0, layer=0).
Assert.NotNull(got);
Assert.Single(got!);
Assert.Equal(0x5001u, got![0].ObjectGuid);
}
```
- [ ] **Step 2: Run the test, verify it fails**
Run: `dotnet test tests/AcDream.Core.Net.Tests --filter WireAll_PlayerDescription_invokesOnShortcuts`
Expected: FAIL — `WireAll` has no `onShortcuts` parameter.
- [ ] **Step 3: Add the callback to `WireAll` and invoke it**
In `src/AcDream.Core.Net/GameEventWiring.cs`:
- Add a parameter to `WireAll`: `Action<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>>? onShortcuts = null` (optional, so existing callers/tests compile unchanged).
- In the PlayerDescription handler lambda (where `Parsed` is in scope, ~lines 281-433), after the existing inventory population, add:
```csharp
onShortcuts?.Invoke(parsed.Shortcuts);
```
- [ ] **Step 4: Run the test, verify it passes**
Run: `dotnet test tests/AcDream.Core.Net.Tests --filter WireAll_PlayerDescription_invokesOnShortcuts`
Expected: PASS.
- [ ] **Step 5: Store the shortcuts in GameWindow**
In `src/AcDream.App/Rendering/GameWindow.cs`:
- Add a field near `Items` (line 598):
```csharp
/// <summary>Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source).</summary>
public IReadOnlyList<AcDream.Core.Net.Messages.PlayerDescriptionParser.ShortcutEntry> Shortcuts { get; private set; }
= System.Array.Empty<AcDream.Core.Net.Messages.PlayerDescriptionParser.ShortcutEntry>();
```
- At the `GameEventWiring.WireAll(...)` call (~line 2269), pass `onShortcuts: list => Shortcuts = list`.
- [ ] **Step 6: Build + commit**
Run: `dotnet build` then `dotnet test`
Expected: green.
```bash
git add src/AcDream.Core.Net/GameEventWiring.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
git commit -m "feat(D.5.1): persist PlayerDescription shortcuts (were parsed then discarded)"
```
---
## Task 5: `IconComposer` — CPU icon composite + cache
**Files:**
- Create: `src/AcDream.App/UI/IconComposer.cs`
- Modify: `src/AcDream.App/Rendering/TextureCache.cs` (add a public `UploadRgba8` wrapper — it's currently private)
- Test: `tests/AcDream.App.Tests/UI/IconComposerTests.cs`
The pure compositing core is testable; the dat-decode + GL-upload is a thin shell exercised by the visual gate.
- [ ] **Step 1: Write the failing test (pure composite)**
`tests/AcDream.App.Tests/UI/IconComposerTests.cs`:
```csharp
using AcDream.App.UI;
using Xunit;
public class IconComposerTests
{
private static byte[] Solid(int w, int h, byte r, byte g, byte b, byte a)
{
var px = new byte[w * h * 4];
for (int i = 0; i < w * h; i++) { px[i*4]=r; px[i*4+1]=g; px[i*4+2]=b; px[i*4+3]=a; }
return px;
}
[Fact]
public void Compose_alphaOver_topOpaqueLayerWins()
{
var bottom = (Solid(2, 2, 255, 0, 0, 255), 2, 2); // red, opaque
var top = (Solid(2, 2, 0, 0, 255, 255), 2, 2); // blue, opaque
var (rgba, w, h) = IconComposer.Compose(new[] { bottom, top });
Assert.Equal(2, w); Assert.Equal(2, h);
Assert.Equal(0, rgba[0]); // R
Assert.Equal(0, rgba[1]); // G
Assert.Equal(255, rgba[2]); // B — top layer won
Assert.Equal(255, rgba[3]); // A
}
[Fact]
public void Compose_alphaOver_transparentTopKeepsBottom()
{
var bottom = (Solid(1, 1, 255, 0, 0, 255), 1, 1);
var top = (Solid(1, 1, 0, 0, 255, 0), 1, 1); // fully transparent blue
var (rgba, _, _) = IconComposer.Compose(new[] { bottom, top });
Assert.Equal(255, rgba[0]); // bottom red preserved
Assert.Equal(0, rgba[2]);
}
}
```
- [ ] **Step 2: Run the test, verify it fails**
Run: `dotnet test tests/AcDream.App.Tests --filter IconComposer`
Expected: FAIL — `IconComposer` not defined.
- [ ] **Step 3: Implement `IconComposer`**
`src/AcDream.App/UI/IconComposer.cs`:
```csharp
using System;
using System.Collections.Generic;
using AcDream.App.Rendering;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
namespace AcDream.App.UI;
/// <summary>
/// Builds an item icon by alpha-compositing its RenderSurface layers into one 32×32
/// texture, mirroring retail IconData::RenderIcons (decomp 407524). Each layer is a
/// 0x06 RenderSurface decoded DIRECTLY (the D.2b RenderSurface-vs-Surface rule).
/// Phase 1 composites the layers ItemInstance exposes (custom underlay + base +
/// custom overlay); the GetByEnum type-default underlay, the overlay ReplaceColor
/// tint, and the effect overlay are deferred (see plan Task 12 / divergence rows).
/// Composited textures are cached by their layer-id tuple.
/// </summary>
public sealed class IconComposer
{
private readonly DatCollection _dats;
private readonly TextureCache _cache;
private readonly Dictionary<(uint, uint, uint), uint> _byTuple = new();
public IconComposer(DatCollection dats, TextureCache cache)
{
_dats = dats;
_cache = cache;
}
/// <summary>Pure alpha-over composite, bottom→top. Layers may differ in size;
/// the result is sized to the FIRST (bottom) layer and upper layers are sampled
/// top-left aligned (all icon layers are 32×32 in practice).</summary>
public static (byte[] rgba, int w, int h) Compose(IReadOnlyList<(byte[] rgba, int w, int h)> layers)
{
if (layers.Count == 0) return (Array.Empty<byte>(), 0, 0);
var (baseRgba, w, h) = layers[0];
var outp = (byte[])baseRgba.Clone();
for (int li = 1; li < layers.Count; li++)
{
var (src, sw, sh) = layers[li];
int cw = Math.Min(w, sw), ch = Math.Min(h, sh);
for (int y = 0; y < ch; y++)
for (int x = 0; x < cw; x++)
{
int di = (y * w + x) * 4, si = (y * sw + x) * 4;
float sa = src[si + 3] / 255f;
if (sa <= 0f) continue;
float da = 1f - sa;
outp[di] = (byte)(src[si] * sa + outp[di] * da);
outp[di + 1] = (byte)(src[si + 1] * sa + outp[di + 1] * da);
outp[di + 2] = (byte)(src[si + 2] * sa + outp[di + 2] * da);
outp[di + 3] = (byte)Math.Min(255f, src[si + 3] + outp[di + 3] * da);
}
}
return (outp, w, h);
}
/// <summary>Resolve (and cache) the composited GL texture for an item's icon
/// layers. Returns 0 if no base icon is available.</summary>
public uint GetIcon(uint iconId, uint underlayId, uint overlayId)
{
if (iconId == 0) return 0;
var key = (iconId, underlayId, overlayId);
if (_byTuple.TryGetValue(key, out var tex)) return tex;
var layers = new List<(byte[] rgba, int w, int h)>();
AddLayer(layers, underlayId);
AddLayer(layers, iconId);
AddLayer(layers, overlayId);
if (layers.Count == 0) return 0;
var (rgba, w, h) = Compose(layers);
uint handle = _cache.UploadRgba8(rgba, w, h, nearest: true);
_byTuple[key] = handle;
return handle;
}
private void AddLayer(List<(byte[], int, int)> layers, uint renderSurfaceId)
{
if (renderSurfaceId == 0) return;
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs) &&
!_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
return;
var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
layers.Add((decoded.Rgba8, decoded.Width, decoded.Height));
}
}
```
- [ ] **Step 4: Add the public `UploadRgba8` wrapper to `TextureCache`**
In `src/AcDream.App/Rendering/TextureCache.cs`, expose the existing private upload (the one `GetOrUploadRenderSurface` calls). Add:
```csharp
/// <summary>Upload raw RGBA8 bytes as a GL texture (used by IconComposer for
/// CPU-composited icons). Returns the GL handle.</summary>
public uint UploadRgba8(byte[] rgba, int width, int height, bool nearest)
=> UploadRgba8Internal(rgba, width, height, nearest); // rename the existing private method to *Internal if needed, or call it directly if it already has this shape
```
(Verify the existing private upload's name/signature with `Grep "UploadRgba8" src/AcDream.App/Rendering/TextureCache.cs`; if it already takes `(byte[], int, int, bool)`, just change its accessibility to `public` instead of adding a wrapper.)
- [ ] **Step 5: Run the tests, verify they pass; build**
Run: `dotnet test tests/AcDream.App.Tests --filter IconComposer` then `dotnet build`
Expected: PASS + green build.
- [ ] **Step 6: Commit**
```bash
git add src/AcDream.App/UI/IconComposer.cs src/AcDream.App/Rendering/TextureCache.cs tests/AcDream.App.Tests/UI/IconComposerTests.cs
git commit -m "feat(D.5.1): IconComposer — CPU alpha-over icon composite + cache"
```
---
## Task 6: `UiItemSlot` widget (the item cell)
**Files:**
- Create: `src/AcDream.App/UI/UiItemSlot.cs`
- Test: `tests/AcDream.App.Tests/UI/UiItemSlotTests.cs`
- [ ] **Step 1: Write the failing test**
```csharp
using AcDream.App.UI;
using Xunit;
public class UiItemSlotTests
{
[Fact]
public void IsLeafWidget()
=> Assert.True(new UiItemSlot().ConsumesDatChildren);
[Fact]
public void DefaultEmptySprite_isToolbarBorder()
=> Assert.Equal(0x060074CFu, new UiItemSlot().EmptySprite);
[Fact]
public void Empty_whenNoItem()
{
var s = new UiItemSlot();
Assert.Equal(0u, s.ItemId);
Assert.Equal(0u, s.IconTexture);
}
[Fact]
public void SetItem_setsIdAndTexture()
{
var s = new UiItemSlot();
s.SetItem(0x5001u, 0x99u);
Assert.Equal(0x5001u, s.ItemId);
Assert.Equal(0x99u, s.IconTexture);
}
}
```
- [ ] **Step 2: Run the test, verify it fails**
Run: `dotnet test tests/AcDream.App.Tests --filter UiItemSlot`
Expected: FAIL — `UiItemSlot` not defined.
- [ ] **Step 3: Implement `UiItemSlot`**
`src/AcDream.App/UI/UiItemSlot.cs`:
```csharp
using System;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// One item-in-a-slot cell (port of retail UIElement_UIItem, class 0x10000032).
/// A behavioral LEAF: it draws the empty-slot sprite when unbound, else a
/// pre-composited icon texture (set by the controller). Holds the bound weenie
/// guid (retail UIElement_UIItem::itemID, +0x5FC).
/// </summary>
public sealed class UiItemSlot : UiElement
{
public UiItemSlot() { ClickThrough = false; }
public override bool ConsumesDatChildren => true;
/// <summary>Bound weenie guid (0 = empty). Retail UIElement_UIItem::itemID.</summary>
public uint ItemId { get; private set; }
/// <summary>Pre-composited icon GL texture for the bound item (0 = none).</summary>
public uint IconTexture { get; private set; }
/// <summary>Empty-slot sprite. Default = the generic toolbar empty-slot border
/// 0x060074CF (uiitem template 0x21000037, state ItemSlot_Empty). Configurable so
/// paperdoll equip slots can use their per-slot silhouettes later.</summary>
public uint EmptySprite { get; set; } = 0x060074CFu;
/// <summary>RenderSurface id → (GL texture, w, h). Set by the factory/controller.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
public void SetItem(uint itemId, uint iconTexture)
{
ItemId = itemId;
IconTexture = iconTexture;
}
public void Clear() { ItemId = 0; IconTexture = 0; }
protected override void OnDraw(UiRenderContext ctx)
{
if (ItemId != 0 && IconTexture != 0)
{
ctx.DrawSprite(IconTexture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
return;
}
if (SpriteResolve is not null && EmptySprite != 0)
{
var (tex, _, _) = SpriteResolve(EmptySprite);
if (tex != 0)
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
}
}
```
- [ ] **Step 4: Run the tests, verify they pass**
Run: `dotnet test tests/AcDream.App.Tests --filter UiItemSlot`
Expected: PASS (all four).
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/UI/UiItemSlot.cs tests/AcDream.App.Tests/UI/UiItemSlotTests.cs
git commit -m "feat(D.5.1): UiItemSlot widget (UIElement_UIItem cell port)"
```
---
## Task 7: `UiItemList` widget + `DatWidgetFactory` branch
**Files:**
- Create: `src/AcDream.App/UI/UiItemList.cs`
- Modify: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (the `Create` switch, lines 63-71)
- Test: `tests/AcDream.App.Tests/UI/UiItemListTests.cs` + `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs` (verify the factory-test file exists with `Glob tests/AcDream.App.Tests/**/*WidgetFactory*`; if absent, create it)
- [ ] **Step 1: Write the failing tests**
`tests/AcDream.App.Tests/UI/UiItemListTests.cs`:
```csharp
using AcDream.App.UI;
using Xunit;
public class UiItemListTests
{
[Fact]
public void IsLeafWidget() => Assert.True(new UiItemList().ConsumesDatChildren);
[Fact]
public void StartsWithOneCell_forSingleCellSlot()
{
var list = new UiItemList();
Assert.Equal(1, list.GetNumUIItems());
Assert.NotNull(list.GetItem(0));
}
[Fact]
public void Cell_returnsTheFirstSlot()
{
var list = new UiItemList();
Assert.Same(list.GetItem(0), list.Cell);
}
}
```
Add to the factory test file:
```csharp
[Fact]
public void Create_buildsUiItemList_forItemListClassId()
{
var info = new AcDream.App.UI.Layout.ElementInfo { Id = 0x100001A7u, Type = 0x10000031u, Width = 32, Height = 32 };
var w = AcDream.App.UI.Layout.DatWidgetFactory.Create(info, _ => (0u, 0, 0), null);
Assert.IsType<AcDream.App.UI.UiItemList>(w);
}
```
- [ ] **Step 2: Run the tests, verify they fail**
Run: `dotnet test tests/AcDream.App.Tests --filter "UiItemList|UiItemList_forItemListClassId"`
Expected: FAIL — `UiItemList` not defined / factory returns `UiDatElement`.
- [ ] **Step 3: Implement `UiItemList`**
`src/AcDream.App/UI/UiItemList.cs`:
```csharp
using System;
using System.Collections.Generic;
namespace AcDream.App.UI;
/// <summary>
/// A container of item cells (port of retail UIElement_ItemList, class 0x10000031).
/// Behavioral LEAF: it creates/owns its UiItemSlot children procedurally, so the
/// LayoutImporter must NOT build dat children. The toolbar uses single-cell
/// instances (one slot); the inventory phase will grow this to an N-cell grid.
/// </summary>
public sealed class UiItemList : UiElement
{
private readonly List<UiItemSlot> _cells = new();
public UiItemList(Func<uint, (uint tex, int w, int h)>? spriteResolve = null)
{
SpriteResolve = spriteResolve;
// Single-cell default: every toolbar slot always shows one cell (empty or filled).
AddItem(new UiItemSlot { SpriteResolve = spriteResolve });
}
public override bool ConsumesDatChildren => true;
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>Convenience for single-cell slots (the toolbar): the first cell.</summary>
public UiItemSlot Cell => _cells[0];
public int GetNumUIItems() => _cells.Count;
public UiItemSlot? GetItem(int index)
=> index >= 0 && index < _cells.Count ? _cells[index] : null;
public void AddItem(UiItemSlot cell)
{
cell.SpriteResolve ??= SpriteResolve;
cell.Left = 0; cell.Top = 0; cell.Width = Width; cell.Height = Height;
_cells.Add(cell);
AddChild(cell);
}
public void Flush()
{
foreach (var c in _cells) RemoveChild(c);
_cells.Clear();
}
protected override void OnDraw(UiRenderContext ctx)
{
// The factory sets THIS list's Width/Height AFTER construction, so the cell
// (added in the ctor) starts 0x0. For the single-cell toolbar slot, keep the
// cell sized to the list each frame; the cell paints itself in the children
// pass that follows. (N-cell grid layout is the inventory phase.)
if (_cells.Count > 0)
{
var cell = _cells[0];
cell.Left = 0; cell.Top = 0; cell.Width = Width; cell.Height = Height;
}
}
}
```
Note: `ConsumesDatChildren` stops the IMPORTER from adding dat children, but the list still draws its own `UiItemSlot` children (added via `AddChild`) through the normal `DrawSelfAndChildren` traversal — `ConsumesDatChildren` only gates the importer, not runtime children. The cell's `Width`/`Height` are synced in the list's `OnDraw` (which runs before the children pass), so the cell is correctly sized + hit-testable from the first rendered frame.
- [ ] **Step 4: Add the factory branch**
In `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`, add to the `Create` switch (lines 63-71), before the `_` fallback:
```csharp
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
```
(The item *cell* class `0x10000032` is created procedurally by `UiItemList`, not via a static dat element in the toolbar, so it needs no factory branch this phase.)
- [ ] **Step 5: Run the tests, verify they pass; build**
Run: `dotnet test tests/AcDream.App.Tests --filter "UiItemList|ItemListClassId"` then `dotnet build`
Expected: PASS + green.
- [ ] **Step 6: Commit**
```bash
git add src/AcDream.App/UI/UiItemList.cs src/AcDream.App/UI/Layout/DatWidgetFactory.cs tests/AcDream.App.Tests/UI/UiItemListTests.cs tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
git commit -m "feat(D.5.1): UiItemList widget + factory branch for class 0x10000031"
```
---
## Task 8: `ToolbarController` (the `gmToolbarUI::PostInit` analogue)
**Files:**
- Create: `src/AcDream.App/UI/Layout/ToolbarController.cs`
- Test: `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs`
- [ ] **Step 1: Write the failing test**
```csharp
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;
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<uint, UiItemList> slots) FakeToolbar()
{
var dict = new Dictionary<uint, UiElement>();
var slots = new Dictionary<uint, UiItemList>();
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<PlayerDescriptionParser.ShortcutEntry>
{ 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<PlayerDescriptionParser.ShortcutEntry>
{ 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<PlayerDescriptionParser.ShortcutEntry>
{ new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
uint used = 0;
ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_) => 0x77u, useItem: g => used = g);
slots[Row1[0]].Cell.OnEvent(new UiEvent { Type = UiEventType.MouseDown });
Assert.Equal(0x5001u, used);
}
}
```
(Adapt `UiEvent` construction + the click-emit seam to the toolkit's actual event shape — see Step 3; if `UiItemSlot` needs a `Clicked` callback rather than handling `OnEvent`, wire that in Step 3 and update this assertion to invoke it.)
- [ ] **Step 2: Run the tests, verify they fail**
Run: `dotnet test tests/AcDream.App.Tests --filter ToolbarController`
Expected: FAIL — `ToolbarController` not defined.
- [ ] **Step 3: Implement `ToolbarController`**
`src/AcDream.App/UI/Layout/ToolbarController.cs`:
```csharp
using System;
using System.Collections.Generic;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
namespace AcDream.App.UI.Layout;
/// <summary>
/// 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).
/// </summary>
public sealed class ToolbarController
{
// Slot element ids, in slot-index order (toolbar pre-dump 0x21000016).
private static readonly uint[] SlotIds =
{
0x100001A7,0x100001A8,0x100001A9,0x100001AA,0x100001AB,0x100001AC,0x100001AD,0x100001AE,0x100001AF,
0x100006B7,0x100006B8,0x100006B9,0x100006BA,0x100006BB,0x100006BC,0x100006BD,0x100006BE,0x100006BF,
};
// Hidden-by-default elements (gmToolbarUI::PostInit): selected-object meters + stack slider.
private static readonly uint[] HiddenIds = { 0x100001A1, 0x100001A2, 0x100001A4 };
private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length];
private readonly ItemRepository _repo;
private readonly Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> _shortcuts;
private readonly Func<uint, uint, uint, uint> _iconIds; // (iconId, underlay, overlay) → GL texture
private readonly Action<uint> _useItem;
private ToolbarController(ImportedLayout layout, ItemRepository repo,
Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> shortcuts,
Func<uint, uint, uint, uint> iconIds, Action<uint> 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);
}
foreach (var id in HiddenIds)
if (layout.FindElement(id) is { } e) e.Visible = false;
repo.ItemAdded += _ => Populate();
repo.ItemPropertiesUpdated += _ => Populate();
}
public static ToolbarController Bind(ImportedLayout layout, ItemRepository repo,
Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> shortcuts,
Func<uint, uint, uint, uint> iconIds, Action<uint> useItem)
{
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem);
c.Populate();
return c;
}
/// <summary>Port of gmToolbarUI::UpdateFromPlayerDesc — flush then bind each shortcut.</summary>
public void Populate()
{
foreach (var list in _slots) list?.Cell.Clear();
foreach (var sc in _shortcuts())
{
if (sc.ObjectGuid == 0) continue; // spell shortcuts — deferred phase
if (sc.Index >= _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; // SetDelayedShortcutNum: re-bound on ItemAdded
uint tex = _iconIds(item.IconId, item.IconUnderlayId, item.IconOverlayId);
list.Cell.SetItem(sc.ObjectGuid, tex);
}
}
private void WireClick(UiItemList list)
{
list.Cell.Clicked = () =>
{
if (list.Cell.ItemId != 0) _useItem(list.Cell.ItemId);
};
}
}
```
This requires a `Clicked` callback on `UiItemSlot`. Add to `UiItemSlot` (Task 6 file) and have `OnEvent` invoke it on mouse-down:
```csharp
public Action? Clicked { get; set; }
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.MouseDown) { Clicked?.Invoke(); return true; }
return false;
}
```
(If `UiEvent`/`UiEventType` member names differ, match the toolkit's actual definitions — `Grep "enum UiEventType" src/AcDream.App/UI`.)
- [ ] **Step 4: Run the tests, verify they pass; build**
Run: `dotnet test tests/AcDream.App.Tests --filter ToolbarController` then `dotnet build`
Expected: PASS (all three) + green.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/UI/Layout/ToolbarController.cs src/AcDream.App/UI/UiItemSlot.cs tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
git commit -m "feat(D.5.1): ToolbarController — bind 18 slots, populate, deferred rebind, click-to-use"
```
---
## Task 9: Wire the toolbar into `GameWindow`
Integration (covered by the visual gate). Mirror the vitals import + mount.
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the `if (_options.RetailUi)` block, ~lines 1761-1898, after the chat block)
- [ ] **Step 1: Construct the IconComposer once**
In the `if (_options.RetailUi)` block (after `_uiHost` + the sprite resolver `ResolveChrome` exist, ~line 1778), add:
```csharp
var iconComposer = new AcDream.App.UI.IconComposer(_dats!, cache);
```
- [ ] **Step 2: Import the toolbar layout + bind the controller**
After the chat block (~line 1898), add (mirroring the vitals import at 1800-1828):
```csharp
AcDream.App.UI.Layout.ImportedLayout? toolbarLayout;
lock (_datLock)
toolbarLayout = AcDream.App.UI.Layout.LayoutImporter.Import(
_dats!, 0x21000016u, ResolveChrome, vitalsDatFont);
if (toolbarLayout is not null)
{
AcDream.App.UI.Layout.ToolbarController.Bind(
toolbarLayout, Items,
() => Shortcuts,
iconIds: (icon, under, over) => iconComposer.GetIcon(icon, under, over),
useItem: guid => UseItemByGuid(guid)); // existing use-item path (see Step 3)
var toolbarRoot = toolbarLayout.Root;
toolbarRoot.Left = 10; toolbarRoot.Top = 300; // initial position; user-draggable
toolbarRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top;
toolbarRoot.Draggable = true;
_uiHost.Root.AddChild(toolbarRoot);
}
```
- [ ] **Step 3: Provide `UseItemByGuid`**
acdream already builds + sends use-item at `GameWindow.cs:11577-11579` (`InteractRequests.BuildUse(seq, guid)``_liveSession.SendGameAction`). Extract that into a small helper if it isn't already callable by guid:
```csharp
private void UseItemByGuid(uint guid)
{
if (_liveSession is null) return;
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
_liveSession.SendGameAction(body);
}
```
(If a guid-based use helper already exists near line 11577, call it instead of duplicating.)
- [ ] **Step 4: Build**
Run: `dotnet build`
Expected: green.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(D.5.1): mount the toolbar window under ACDREAM_RETAIL_UI"
```
---
## Task 10: Full suite + manual smoke gate
- [ ] **Step 1: Build + full test suite**
Run: `dotnet build` then `dotnet test`
Expected: all green.
- [ ] **Step 2: Launch + visual verification (the user's gate)**
Launch per CLAUDE.md (PowerShell, background, Tee to `launch.log`):
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_RETAIL_UI = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"; $env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"; $env:ACDREAM_TEST_PASS = "testpassword"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log"
```
Acceptance (user confirms by looking):
- An 18-slot action bar (2 rows of 9) renders with the dat chrome + empty-slot sprites.
- Any persisted `+Acdream` shortcuts show their real composited item icons.
- Clicking a pinned item uses it (observe server-side / in-world effect).
- The bar drags as a whole window.
(If `+Acdream` has no persisted shortcuts, the empty-slot render is still a valid gate; pinning real items to test icons may need the inventory phase or a server-side pre-pin — note this to the user.)
---
## Task 11: Bookkeeping — divergence register, roadmap shipped, memory
**Files:**
- Modify: `docs/architecture/retail-divergence-register.md`
- Modify: `docs/plans/2026-04-11-roadmap.md`
- Modify: `claude-memory/project_d2b_retail_ui.md` (durable lesson, if any)
- [ ] **Step 1: Add divergence rows**
Add rows to `docs/architecture/retail-divergence-register.md` for the phase-1 icon deferrals + the empty-sprite constant:
- Icon composite omits the retail `GetByEnum` type-default underlay (`IconData::RenderIcons` 407524, enum 0x10000004), the overlay `ReplaceColor` tint, and the effect overlay (enum 0x10000005) — their source data (overlay tint color, `IconEffects`) isn't parsed yet. Risk: items with a material/effect overlay render without it. Retire when the inventory phase parses the full `PublicWeenieDesc`.
- `UiItemSlot.EmptySprite` defaults to the constant `0x060074CF` instead of importing the empty-slot state from the uiitem template `0x21000037`. Risk: paperdoll equip-slot silhouettes need per-slot empty sprites (already configurable). Retire when the cell imports its template states.
- (Reuse the existing IA-12 row for whole-window-drag — no new row.)
- [ ] **Step 2: Flip the roadmap entry to shipped**
In `docs/plans/2026-04-11-roadmap.md`, change the D.5.1 entry from `[IN PROGRESS]` to `✓ SHIPPED` with the commit range, mirroring the other D.2b shipped entries.
- [ ] **Step 3: Commit**
```bash
git add docs/architecture/retail-divergence-register.md docs/plans/2026-04-11-roadmap.md claude-memory/project_d2b_retail_ui.md
git commit -m "docs(D.5.1): divergence rows + roadmap shipped + memory for the toolbar"
```
---
## Task 12 (FOLLOW-UP, optional within phase): faithful icon layers
Deferred from Task 5 to keep phase 1 shippable; do only if the toolbar icons visibly lack the standard background. NOT required for the phase-1 acceptance gate.
- Port `DBObj::GetByEnum(0x10000004, lsb(itemType)+1)` (the type-default underlay) — first confirm what `0x10000004` maps to (dump an EnumMapper DBObj) to decide whether it's the universal icon background. Add it as the bottom layer in `IconComposer.GetIcon`.
- Parse `IconEffects`/`IconOverlay` tint from `CreateObject` (extend `CreateObject.Parsed` + `ItemInstance`), then add the `ReplaceColor` overlay tint + the effect overlay (`GetByEnum 0x10000005`).
- Delete the corresponding divergence rows from Task 11 Step 1 as each layer lands.
---
## Self-review notes (author)
- **Spec coverage:** spec §2 widgets → Tasks 6,7; §4.3 icon → Task 5 (+12); §4.4 CreateObject/ItemInstance → Tasks 1,2,3; §4.5 ToolbarController → Task 8; §4.6 wiring/gating → Task 9; §5 testing → per-task TDD + Task 10; §6 acceptance → Task 10; §8 bookkeeping → Tasks 0,11. The shortcut-holder (Task 4) was implicit in §4.5's "reads Parsed.Shortcuts" and is made explicit here.
- **Type consistency:** `UiItemSlot.SetItem(uint,uint)` / `.Clear()` / `.ItemId` / `.IconTexture` / `.EmptySprite` / `.Clicked`; `UiItemList.Cell` / `.GetItem(int)` / `.GetNumUIItems()` / `.AddItem(UiItemSlot)` / `.Flush()`; `IconComposer.Compose(IReadOnlyList<(byte[],int,int)>)` / `.GetIcon(uint,uint,uint)`; `ItemRepository.EnrichItem(uint,uint,string,ItemType)`; `ToolbarController.Bind(ImportedLayout, ItemRepository, Func<IReadOnlyList<ShortcutEntry>>, Func<uint,uint,uint,uint>, Action<uint>)`. These match across Tasks 5-9.
- **Known executor confirmations (grep-to-confirm, not placeholders):** the exact name/signature of `TextureCache`'s private RGBA upload (Task 5 Step 4); the `EntitySpawn` record location (Task 3 Step 1); the `UiEvent`/`UiEventType` member shape (Tasks 6/8); whether a guid-based use helper already exists near `GameWindow.cs:11577` (Task 9 Step 3). Each step names the grep + the change.