acdream/docs/superpowers/specs/2026-06-16-d2b-toolbar-phase1-design.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

279 lines
19 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 design
**Date:** 2026-06-16
**Status:** design approved (brainstorm), spec under review → writing-plans next
**Phase:** D.5.1 — first sub-phase of D.5 "Core panels" (D.2b retail-look track). NEW
sub-phase; roadmap registration is plan step 0 (roadmap discipline rule 4).
**Builds on:** the shipped D.2b widget toolkit (`b7f7e2b``89626cd`) — generic
Type-registered widgets built by `DatWidgetFactory`, assembled by `LayoutImporter`,
bound by thin `gm*UI::PostInit`-style controllers. See
[`claude-memory/project_d2b_retail_ui.md`](../../../claude-memory/project_d2b_retail_ui.md).
**Research evidence base (the anchors live here — this spec cites, does not re-derive):**
- [`docs/research/2026-06-16-ui-panels-synthesis.md`](../../research/2026-06-16-ui-panels-synthesis.md) — the build plan + consolidated widget list + cross-panel wire table
- [`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) — `UIElement_UIItem`/`UIElement_ItemList` port spec, the icon composite, drag-drop spine
- [`docs/research/2026-06-16-action-bar-toolbar-deep-dive.md`](../../research/2026-06-16-action-bar-toolbar-deep-dive.md) — `gmToolbarUI` shortcut model + wire + element map
---
## 1. Goal
Ship the **action bar (`gmToolbarUI`)** as the first data-driven *game* panel (vitals
and chat were HUD). 18 shortcut slots built from `LayoutDesc 0x21000016` via the existing
`LayoutImporter`, populated from the persisted `PlayerDescription` shortcut block, each
pinned item rendering its **real composited icon**, with **click-to-use**. Gated
`ACDREAM_RETAIL_UI=1`, whole-window-drag.
The point of doing the toolbar first is that it is the **thinnest end-to-end slice that
exercises the entire shared item spine** — the `UiItemSlot` widget, the icon composite
pipeline, the `UiItemList` widget, a find-by-id controller, and the `CreateObject` icon
extension — on the simplest of the three panels (no nested sub-windows, no 3D viewport,
no multi-column grid). Everything built here is reused verbatim by the inventory and
paperdoll phases.
## 2. Scope
**In scope (Phase 1):**
- `UiItemSlot` widget (port of `UIElement_UIItem`, class `0x10000032`) — empty-slot + icon render.
- `UiItemList` widget (port of `UIElement_ItemList`, class `0x10000031`) — single-cell instances.
- Icon composite pipeline (faithful CPU pre-composite — Approach A, §4.3).
- `CreateObject.TryParse` extension to capture `IconId` onto `ItemInstance`.
- `ToolbarController` — find-by-id bind, populate-from-shortcuts, deferred re-bind, click-to-use.
- Toolbar window mounted under `ACDREAM_RETAIL_UI=1`, whole-window-drag.
**Out of scope (later D.5 sub-phases):**
- Drag/reorder within the bar; drag-to-add from inventory (needs inventory as a drag source).
- The `AddShortCut`/`RemoveShortCut` mutate wire (`0x019C`/`0x019D`) — builders already exist; wiring them is deferred to the drag phase.
- The hidden selected-object Health/Mana meters (`0x100001A1`/`A2`) + the stack-split slider (`0x100001A4`) — stay `SetVisible(0)`, matching `gmToolbarUI::PostInit`.
- Spell shortcuts (`ItemList_InsertSpellShortcut`, `CM_Magic` path).
- Faithful window manager (Dragbar/Resizebar drag-resize) — uses the accepted IA-12 whole-window-drag approximation.
- Inventory and paperdoll panels.
## 3. Retail anchors (the load-bearing facts, verified)
All confirmed against the named decomp during the research phase and re-verified for this
spec. Lines are `acclient_2013_pseudo_c.txt`.
- **Window:** `gmToolbarUI` element class `0x10000007``LayoutDesc 0x21000016` (300×122).
`gmToolbarUI::Register` (decomp 196897), `GetUIElementType``0x10000007` (196707).
- **18 slots, two rows of 9:** element ids `0x100001A7-AF` (top) + `0x100006B7-BF` (bottom),
wired in `gmToolbarUI::InitShortcutArray` (decomp 197051); each is a `DynamicCast(0x10000031)`
= `UIElement_ItemList`, pushed into `m_shortcutSlots` in slot-index order.
- **Slot content:** each slot list holds one `UIElement_UIItem` (item-cell, class
`0x10000032`). The cell's bound weenie guid is `UIElement_UIItem::itemID` (offset `+0x5FC`),
read in `UIItem_Update` (decomp 230230: `uint32_t itemID = this->itemID; … GetWeenieObject(itemID)`).
- **Persisted model:** `ShortCutManager::shortCuts_[18]` (`acclient.h:36492`); the struct is
`ShortCutData { int index_; uint objectID_; uint spellID_; }` (`acclient.h:36484`). Delivered
at login in the `PlayerDescription` `SHORTCUT` block (`CharacterOptionDataFlag.SHORTCUT 0x1`).
acdream already parses it → `PlayerDescriptionParser.cs:345-356``Parsed.Shortcuts`
(`ShortcutEntry{Index, ObjectGuid, SpellId, Layer}`).
- **Populate at login:** `gmToolbarUI::UpdateFromPlayerDesc` (decomp 198838) — `FlushShortcuts`
then for i in 0..0x12 read `shortCuts_[i]->objectID_` and `AddShortcut(this, objId, i, send=0)`.
- **Deferred bind:** `UIElement_UIItem::SetDelayedShortcutNum` / `AddShortcut` (decomp 196867)
re-binds a slot whose weenie hasn't loaded yet once `CreateObject` for that guid arrives.
- **Activation (click-to-use):** `gmToolbarUI::UseShortcut` (decomp 196395) → `ItemHolder::UseObject`
(decomp 402923, 0.2s throttle `m_timeLastUsed + 0.2`) → ordinary use-item dispatch (NOT a
shortcut-specific wire message). acdream's use-item path = `InteractRequests.BuildUse` (`0x0036`).
- **Icon composite:** `UIElement_UIItem::UIItem_SetIcon` (230143) → `ACCWeenieObject::GetIconData`
(408224) → `IconData::RenderIcons` (407524). Five layers, bottom→top: item-type default
underlay `DBObj::GetByEnum(0x10000004, lsb(itemType)+1)`; custom underlay `_iconUnderlayID`;
base `_iconID`; custom overlay `_iconOverlayID` + `SurfaceWindow::ReplaceColor` tint; effect
overlay `DBObj::GetByEnum(0x10000005, lsb(effects)+1)`. **Every layer is DBObj type `0xc`
= RenderSurface, id range `0x06000000-0x07FFFFFF`** — decoded DIRECTLY via
`TextureCache.GetOrUploadRenderSurface` (the D.2b RenderSurface-vs-Surface gotcha: feeding
a `0x06` id to `GetOrUpload` returns 1×1 magenta). Icon is NOT appraise-gated (no appraise
branch in the icon path; appraise gates `UpdateTooltip` only).
- **acdream gap:** `CreateObject.TryParse` currently DISCARDS `IconId` (`CreateObject.cs:516`:
`_ = ReadPackedDwordOfKnownType(..., IconTypePrefix)`). `ItemInstance` already has the
`IconId`/`IconUnderlayId`/`IconOverlayId`/`StackSize`/`ContainerId` fields.
## 4. Architecture & components
Five new/extended units, each with one purpose and a defined interface. The pattern
mirrors the shipped vitals/chat re-drive exactly: dat `LayoutDesc``LayoutImporter`
`DatWidgetFactory` builds widgets generically → a thin controller binds by id.
### 4.1 `UiItemSlot` (new behavioral widget) — port of `UIElement_UIItem` (`0x10000032`)
- **Location:** `src/AcDream.App/UI/UiItemSlot.cs`.
- **Registration:** `DatWidgetFactory` dispatches it on the resolved element **class id**
`0x10000032`. NOTE: the shipped factory keys off the small *numeric* Types (10x12); the
item-slot/item-list are `UIElement` subclasses identified by a high class id, so the plan
must add a class-id dispatch branch (the class id is already surfaced — `ElementReader.Merge`
resolves it through the `BaseElement` chain, and `UIElement_UIItem` derives from
`UIElement_Field`/Type 3, so do NOT register numeric Type 3 — that stays chrome `UiDatElement`,
per the shipped toolkit's deliberate Type-3 rule). Behavioral **leaf** — overrides
`ConsumesDatChildren => true` so the importer does NOT build its dat sub-elements (it
reproduces them procedurally).
- **State:** `uint ItemId` (the bound weenie guid, retail `+0x5FC`). Phase 1 needs only this.
Quantity / selection / drag-accept / ghost / open-container overlay states are *structurally
reserved* (documented as later-phase hooks) but inert.
- **Render:** if `ItemId == 0` → draw the empty-slot sprite (the dat state `ItemSlot_Empty`
`0x060074CF`, read from the element's states like every other `UiDatElement` sprite). Else
→ draw the composited icon (§4.3) into the 32×32 cell. Phase 1 draws no quantity text / no
overlays.
- **Depends on:** the icon pipeline (§4.3), `UiRenderContext.DrawSprite`.
### 4.2 `UiItemList` (new behavioral widget) — port of `UIElement_ItemList` (`0x10000031`)
- **Location:** `src/AcDream.App/UI/UiItemList.cs`.
- **Registration:** `DatWidgetFactory` keyed off class id `0x10000031`. Behavioral leaf
(`ConsumesDatChildren => true`) — manages its `UiItemSlot` children procedurally.
- **Phase-1 API subset:** `AddItem(UiItemSlot)` / `Flush()` / `GetNumUIItems()` /
`GetItem(int)`. The toolbar uses 18 **single-cell** instances (one `UiItemSlot` each), so
the N-cell grid layout (column wrap, cell pitch) is NOT needed yet — deferred to the
inventory phase. A single-cell list just hosts at most one slot.
- **Depends on:** `UiItemSlot`.
### 4.3 Icon pipeline (Approach A — faithful CPU pre-composite)
- **Location:** `src/AcDream.App/UI/IconComposer.cs` (App layer — it touches GL texture
upload). Pure-decode helpers may live alongside `TextureCache`.
- **Behaviour:** port `IconData::RenderIcons` (407524). For a given item's icon ids, build a
single 32×32 BGRA composite on the CPU by alpha-compositing the layers bottom→top
(§3 list), apply the `ReplaceColor` palette tint to the custom-overlay layer, then upload
the result once as a GL texture and **cache it keyed by the icon-id tuple** (so identical
items share one composite). The slot draws one sprite.
- **Layer decode:** each layer id is a `0x06` RenderSurface decoded DIRECTLY (Portal/HighRes
`TryGet<RenderSurface>``SurfaceDecoder.DecodeRenderSurface(palette:null)`), the same path
`TextureCache.GetOrUploadRenderSurface` already uses — but composited on the CPU rather than
drawn as separate sprites.
- **Enum-mapper layers:** the type-default underlay (`GetByEnum(0x10000004, …)`) and effect
overlay (`GetByEnum(0x10000005, …)`) require reading the two DBObj enum-mapper tables. These
are bounded lookups (index → RenderSurface id); port them as part of this unit. If a mapper
proves more involved than the research suggests, the base + custom underlay/overlay layers
still composite correctly and the enum layers can land as a tight follow-up within the phase
(documented, not silently dropped).
- **Why pre-composite, not stacked draws:** the custom-overlay `ReplaceColor` tint is a
per-pixel palette operation, not a simple alpha-blend — it cannot be reproduced by a tinted
`DrawSprite`. CPU compositing is therefore the faithful path, and it's the shared spine for
all three panels, so it's built correctly once.
- **Depends on:** `DatCollection` (RenderSurface decode), GL texture upload.
### 4.4 `CreateObject` icon extension + `ItemInstance`
- **Location:** `src/AcDream.Core.Net/Messages/CreateObject.cs`, `src/AcDream.Core/Items/ItemInstance.cs`.
- **Change:** in `CreateObject.TryParse`, capture the `IconId` (currently discarded at
`CreateObject.cs:516`) — and the underlay/overlay/effect ids if present in the same block —
onto the parsed object so `ItemRepository` stores them on `ItemInstance` (fields already exist).
- **Planning delta (see the plan):** fact-gathering found this is wider than "just capture IconId."
acdream has NO `CreateObject``ItemRepository` wiring at all (the repo is populated only from
`PlayerDescription` with stub `ItemInstance`s), and `Parsed.Shortcuts` is parsed then discarded
in `GameEventWiring`. So the plan adds three small wiring pieces: capture IconId (Task 1), enrich
the repo from the `WorldSession.EntitySpawned` event (Tasks 23, `ItemRepository.EnrichItem`),
and persist the shortcut list (Task 4). The icon source is CONFIRMED to be `CreateObject` for
contained pack items (ACE `WorldObject_Networking.cs:79` writes IconId unconditionally).
- **Step 0 verification:** confirm against **ACE source** (`WorldObject.SerializeCreateObject`
/ the weenie property serialization) that a *contained* pack item's `CreateObject` actually
carries `IconId` (synthesis risk #3 — LIKELY, not yet byte-traced). Reading ACE is sufficient;
no live capture needed. If ACE only sends `IconId` for world-visible objects and relies on
`PlayerDescription` for pack items, fall back to the PD inventory block as the icon source —
this is a branch the plan must resolve before the icon pipeline is wired.
### 4.5 `ToolbarController` (new) — the `gmToolbarUI::PostInit` analogue
- **Location:** `src/AcDream.App/UI/ToolbarController.cs` (alongside `VitalsController`,
`ChatWindowController`).
- **Bind:** `Bind(LayoutDesc 0x21000016, …)` — find the 18 slot `UiItemList`s by id
(`0x100001A7-AF` + `0x100006B7-BF`) into an ordered `_slots[18]`. Force the 2 meters
(`0x100001A1`/`A2`) + slider (`0x100001A4`) hidden (matches `gmToolbarUI::PostInit`).
- **Populate (port `UpdateFromPlayerDesc`):** on the `PlayerDescription` arriving, `Flush` all
slots, then for each `Parsed.Shortcuts` entry resolve `ObjectGuid``ItemRepository` item →
set `_slots[Index]`'s cell `ItemId`. The cell renders the composited icon from the item's
`IconId`.
- **Deferred re-bind (port `SetDelayedShortcutNum`):** if a shortcut's guid is not yet in
`ItemRepository`, record it pending; when `ItemRepository` raises item-added for that guid,
bind the waiting slot. (Reuse `ItemRepository`'s existing item-change events.)
- **Click-to-use (port `UseShortcut`):** a slot click → controller → existing
`InteractRequests.BuildUse` (`0x0036`) for the cell's `ItemId`, gated by the 0.2s
use-throttle (`ItemHolder::UseObject`). No special shortcut wire.
- **Depends on:** `PlayerDescriptionParser.Parsed.Shortcuts`, `ItemRepository`, the slot
widgets, the command/interact send path.
### 4.6 Wiring & gating
- The toolbar window is built by `LayoutImporter` from `0x21000016` and mounted in `UiRoot`
under `ACDREAM_RETAIL_UI=1`, like vitals/chat. Always-on this phase. Root is `Anchors=None`
+ `Draggable` (whole-window-drag, IA-12 approximation) — NOT `Resizable` (faithful resize is
the deferred window manager).
- `GameWindow` wiring follows the existing vitals/chat drain pattern (one controller
constructed + bound; per-panel try/catch fault isolation already exists).
## 5. Data flow (login → visible toolbar)
1. Login → `PlayerDescription` arrives → `PlayerDescriptionParser` fills `Parsed.Shortcuts`.
2. In parallel, the player's pack items arrive as `CreateObject` messages → `ItemRepository`
stores `ItemInstance`s **including `IconId`** (the §4.4 extension).
3. `ToolbarController` (bound to the imported `0x21000016` window) runs its populate pass:
for each shortcut, resolve guid → item → set slot `ItemId`. Missing items → pending,
re-bound on item-added.
4. Each filled `UiItemSlot` asks `IconComposer` for the composited 32×32 texture (cached by
icon-id tuple) and draws it; empty slots draw `0x060074CF`.
5. Click a filled slot → use-item (`0x0036`) with throttle.
## 6. Testing strategy
Conformance tests in the layer matching each unit; dat-free fixtures where possible (mirror
the vitals `0x2100006C` golden-fixture approach).
- **`CreateObject` IconId** (`tests/AcDream.Core.Net.Tests`): a golden `CreateObject` byte
buffer parses with the expected `IconId` (and the previously-discarded fields).
- **`IconComposer`** (`tests/AcDream.App.Tests`): layer ORDER + presence given a synthetic
icon-id tuple (assert the composite requests layers bottom→top in the `RenderIcons` order;
assert the cache returns the same texture for the same tuple). The `ReplaceColor` tint math
gets a small unit test against a known palette index.
- **`UiItemSlot`** (`tests/AcDream.App.Tests`): `ItemId==0` selects the empty sprite;
`ItemId!=0` requests the composite. `ConsumesDatChildren==true`.
- **`UiItemList`**: `AddItem`/`Flush`/`GetNumUIItems`/`GetItem` over single-cell instances.
- **`ToolbarController`**: find-by-id binds 18 slots from a fixture tree; shortcut→item
resolution sets the right slot; an item arriving late triggers the deferred re-bind; a slot
click emits a use-item for the bound guid with the throttle respected. Meters/slider hidden.
- **Build + full suite green** before the visual gate.
## 7. Acceptance criteria
- `dotnet build` + `dotnet test` green.
- **Visual (the user's gate):** launch, log in `+Acdream` → an 18-slot action bar renders with
the correct dat chrome + empty-slot sprites; any persisted shortcuts show their **real
composited item icons**; clicking a pinned item **uses** it (observable server-side /
in-world). Whole-window drag works.
- Every AC-specific algorithm cites its named-decomp anchor in a comment (per the phase
checklist).
- Divergence rows added (§8); D.5.1 registered in the roadmap; memory updated if a durable
lesson emerges.
## 8. Divergence register + roadmap (bookkeeping)
- **Whole-window-drag** instead of faithful Dragbar-driven drag — already covered by the
existing **IA-12** row (reuse, no new row).
- **Icon enum-mapper layers**: if the type-default-underlay / effect-overlay layers land as a
follow-up rather than in the first commit, add a register row noting the temporarily-absent
layers (and delete it when they land). The base + custom underlay/overlay layers are faithful
from the first commit.
- **Roadmap:** register **D.5.1 — Toolbar** under D.5 "Core panels" as plan step 0 (avoids the
retroactive-registration deviation that the D.2b importer hit at roadmap line 428).
## 9. Open items carried from research (resolve in the plan, before the dependent step)
- **Step 0 — `CreateObject` IconId for contained items** (synthesis risk #3): read ACE source
to confirm pack-item `CreateObject` carries `IconId`; if not, use the PD inventory block.
Gates §4.3/§4.4.
- **Use-item opcode** (synthesis risk #4): `ItemHolder::UseObject` dispatch is confirmed; the
precise `0x0035` vs `0x0036` branch was not traced to the send. acdream has both in
`InteractRequests`; the toolbar uses single-item use (`0x0036`). Reconcile when wiring §4.5.
- The empty-slot baseline is itself a valid visual verification even if `+Acdream` has no
persisted shortcuts; pinning real items to verify icons may require the inventory phase
(drag-to-add) or a server-side pre-pin.
## 10. Component boundary summary (isolation check)
| Unit | One purpose | Interface | Depends on |
|---|---|---|---|
| `UiItemSlot` | render one item-in-a-slot | `ItemId` setter; standard `UiElement` draw/hit | `IconComposer`, render context |
| `UiItemList` | hold N item slots | `AddItem`/`Flush`/`GetNumUIItems`/`GetItem` | `UiItemSlot` |
| `IconComposer` | icon-id tuple → composited 32×32 texture | `GetIcon(iconIds) → texture` (cached) | `DatCollection`, GL upload |
| `CreateObject`/`ItemInstance` | carry `IconId` from wire to model | existing parse + fields | — |
| `ToolbarController` | bind + populate + use | `Bind(layout, deps)` | shortcuts, `ItemRepository`, slots, send path |
Each can be understood and tested without reading the others' internals; the controller is
the only unit that knows about wire + model, keeping the widgets pure-presentation.