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>
19 KiB
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.
Research evidence base (the anchors live here — this spec cites, does not re-derive):
docs/research/2026-06-16-ui-panels-synthesis.md— the build plan + consolidated widget list + cross-panel wire tabledocs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md—UIElement_UIItem/UIElement_ItemListport spec, the icon composite, drag-drop spinedocs/research/2026-06-16-action-bar-toolbar-deep-dive.md—gmToolbarUIshortcut 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):
UiItemSlotwidget (port ofUIElement_UIItem, class0x10000032) — empty-slot + icon render.UiItemListwidget (port ofUIElement_ItemList, class0x10000031) — single-cell instances.- Icon composite pipeline (faithful CPU pre-composite — Approach A, §4.3).
CreateObject.TryParseextension to captureIconIdontoItemInstance.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/RemoveShortCutmutate 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) — staySetVisible(0), matchinggmToolbarUI::PostInit. - Spell shortcuts (
ItemList_InsertSpellShortcut,CM_Magicpath). - 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:
gmToolbarUIelement class0x10000007→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 ingmToolbarUI::InitShortcutArray(decomp 197051); each is aDynamicCast(0x10000031)=UIElement_ItemList, pushed intom_shortcutSlotsin slot-index order. - Slot content: each slot list holds one
UIElement_UIItem(item-cell, class0x10000032). The cell's bound weenie guid isUIElement_UIItem::itemID(offset+0x5FC), read inUIItem_Update(decomp 230230:uint32_t itemID = this->itemID; … GetWeenieObject(itemID)). - Persisted model:
ShortCutManager::shortCuts_[18](acclient.h:36492); the struct isShortCutData { int index_; uint objectID_; uint spellID_; }(acclient.h:36484). Delivered at login in thePlayerDescriptionSHORTCUTblock (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) —FlushShortcutsthen for i in 0..0x12 readshortCuts_[i]->objectID_andAddShortcut(this, objId, i, send=0). - Deferred bind:
UIElement_UIItem::SetDelayedShortcutNum/AddShortcut(decomp 196867) re-binds a slot whose weenie hasn't loaded yet onceCreateObjectfor that guid arrives. - Activation (click-to-use):
gmToolbarUI::UseShortcut(decomp 196395) →ItemHolder::UseObject(decomp 402923, 0.2s throttlem_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 underlayDBObj::GetByEnum(0x10000004, lsb(itemType)+1); custom underlay_iconUnderlayID; base_iconID; custom overlay_iconOverlayID+SurfaceWindow::ReplaceColortint; effect overlayDBObj::GetByEnum(0x10000005, lsb(effects)+1). Every layer is DBObj type0xc= RenderSurface, id range0x06000000-0x07FFFFFF— decoded DIRECTLY viaTextureCache.GetOrUploadRenderSurface(the D.2b RenderSurface-vs-Surface gotcha: feeding a0x06id toGetOrUploadreturns 1×1 magenta). Icon is NOT appraise-gated (no appraise branch in the icon path; appraise gatesUpdateTooltiponly). - acdream gap:
CreateObject.TryParsecurrently DISCARDSIconId(CreateObject.cs:516:_ = ReadPackedDwordOfKnownType(..., IconTypePrefix)).ItemInstancealready has theIconId/IconUnderlayId/IconOverlayId/StackSize/ContainerIdfields.
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:
DatWidgetFactorydispatches it on the resolved element class id0x10000032. NOTE: the shipped factory keys off the small numeric Types (1–0x12); the item-slot/item-list areUIElementsubclasses identified by a high class id, so the plan must add a class-id dispatch branch (the class id is already surfaced —ElementReader.Mergeresolves it through theBaseElementchain, andUIElement_UIItemderives fromUIElement_Field/Type 3, so do NOT register numeric Type 3 — that stays chromeUiDatElement, per the shipped toolkit's deliberate Type-3 rule). Behavioral leaf — overridesConsumesDatChildren => trueso 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 stateItemSlot_Empty→0x060074CF, read from the element's states like every otherUiDatElementsprite). 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:
DatWidgetFactorykeyed off class id0x10000031. Behavioral leaf (ConsumesDatChildren => true) — manages itsUiItemSlotchildren procedurally. - Phase-1 API subset:
AddItem(UiItemSlot)/Flush()/GetNumUIItems()/GetItem(int). The toolbar uses 18 single-cell instances (oneUiItemSloteach), 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 alongsideTextureCache. - 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 theReplaceColorpalette 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
0x06RenderSurface decoded DIRECTLY (Portal/HighResTryGet<RenderSurface>→SurfaceDecoder.DecodeRenderSurface(palette:null)), the same pathTextureCache.GetOrUploadRenderSurfacealready 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
ReplaceColortint is a per-pixel palette operation, not a simple alpha-blend — it cannot be reproduced by a tintedDrawSprite. 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 theIconId(currently discarded atCreateObject.cs:516) — and the underlay/overlay/effect ids if present in the same block — onto the parsed object soItemRepositorystores them onItemInstance(fields already exist). - Planning delta (see the plan): fact-gathering found this is wider than "just capture IconId."
acdream has NO
CreateObject→ItemRepositorywiring at all (the repo is populated only fromPlayerDescriptionwith stubItemInstances), andParsed.Shortcutsis parsed then discarded inGameEventWiring. So the plan adds three small wiring pieces: capture IconId (Task 1), enrich the repo from theWorldSession.EntitySpawnedevent (Tasks 2–3,ItemRepository.EnrichItem), and persist the shortcut list (Task 4). The icon source is CONFIRMED to beCreateObjectfor contained pack items (ACEWorldObject_Networking.cs:79writes IconId unconditionally). - Step 0 verification: confirm against ACE source (
WorldObject.SerializeCreateObject/ the weenie property serialization) that a contained pack item'sCreateObjectactually carriesIconId(synthesis risk #3 — LIKELY, not yet byte-traced). Reading ACE is sufficient; no live capture needed. If ACE only sendsIconIdfor world-visible objects and relies onPlayerDescriptionfor 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(alongsideVitalsController,ChatWindowController). - Bind:
Bind(LayoutDesc 0x21000016, …)— find the 18 slotUiItemLists by id (0x100001A7-AF+0x100006B7-BF) into an ordered_slots[18]. Force the 2 meters (0x100001A1/A2) + slider (0x100001A4) hidden (matchesgmToolbarUI::PostInit). - Populate (port
UpdateFromPlayerDesc): on thePlayerDescriptionarriving,Flushall slots, then for eachParsed.Shortcutsentry resolveObjectGuid→ItemRepositoryitem → set_slots[Index]'s cellItemId. The cell renders the composited icon from the item'sIconId. - Deferred re-bind (port
SetDelayedShortcutNum): if a shortcut's guid is not yet inItemRepository, record it pending; whenItemRepositoryraises item-added for that guid, bind the waiting slot. (ReuseItemRepository's existing item-change events.) - Click-to-use (port
UseShortcut): a slot click → controller → existingInteractRequests.BuildUse(0x0036) for the cell'sItemId, 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
LayoutImporterfrom0x21000016and mounted inUiRootunderACDREAM_RETAIL_UI=1, like vitals/chat. Always-on this phase. Root isAnchors=NoneDraggable(whole-window-drag, IA-12 approximation) — NOTResizable(faithful resize is the deferred window manager).
GameWindowwiring 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)
- Login →
PlayerDescriptionarrives →PlayerDescriptionParserfillsParsed.Shortcuts. - In parallel, the player's pack items arrive as
CreateObjectmessages →ItemRepositorystoresItemInstances includingIconId(the §4.4 extension). ToolbarController(bound to the imported0x21000016window) runs its populate pass: for each shortcut, resolve guid → item → set slotItemId. Missing items → pending, re-bound on item-added.- Each filled
UiItemSlotasksIconComposerfor the composited 32×32 texture (cached by icon-id tuple) and draws it; empty slots draw0x060074CF. - 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).
CreateObjectIconId (tests/AcDream.Core.Net.Tests): a goldenCreateObjectbyte buffer parses with the expectedIconId(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 theRenderIconsorder; assert the cache returns the same texture for the same tuple). TheReplaceColortint math gets a small unit test against a known palette index.UiItemSlot(tests/AcDream.App.Tests):ItemId==0selects the empty sprite;ItemId!=0requests the composite.ConsumesDatChildren==true.UiItemList:AddItem/Flush/GetNumUIItems/GetItemover 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 testgreen.- 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 —
CreateObjectIconId for contained items (synthesis risk #3): read ACE source to confirm pack-itemCreateObjectcarriesIconId; if not, use the PD inventory block. Gates §4.3/§4.4. - Use-item opcode (synthesis risk #4):
ItemHolder::UseObjectdispatch is confirmed; the precise0x0035vs0x0036branch was not traced to the send. acdream has both inInteractRequests; the toolbar uses single-item use (0x0036). Reconcile when wiring §4.5. - The empty-slot baseline is itself a valid visual verification even if
+Acdreamhas 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.