acdream/docs/superpowers/specs/2026-06-17-d2b-stateful-icon-design.md
Erik 52306d9268 docs(D.5.2): implementation plan (9 TDD tasks) + spec wiring fix
Bite-sized TDD plan for the stateful item-icon system. Corrects spec 5.8:
the live 0x02CE event binds in GameWindow (next to VitalUpdated), not
GameEventWiring (which only handles the 0xF7B0 GameEvent dispatcher).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:19:26 +02:00

215 lines
13 KiB
Markdown
Raw Permalink 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.2b — Stateful item-icon system (D.5.2) — design
**Date:** 2026-06-17
**Phase:** D.2b retail-UI engine → D.5.2 (the shared icon infrastructure before the
inventory / equipment / vendor / trade panels).
**Research basis (READ FIRST):** [`docs/research/2026-06-17-stateful-icon-RESOLVED.md`](../../research/2026-06-17-stateful-icon-RESOLVED.md)
— the definitive, source-verified answers (clean Ghidra decompile + live-dat probe + ACE
oracle). It **supersedes** the hypotheses in `docs/research/2026-06-17-stateful-icon-system-handoff.md`.
## 1. Goal
The displayed item icon must **always be a function of the item's current state** — the
shared compositor every item panel reuses. Two concrete gaps remain after D.5.1:
1. The **effect treatment** (retail's `UiEffects`-driven recolor) is unbuilt, and acdream
**discards** the `UiEffects` bitfield at `CreateObject.cs` (the UiEffects skip).
2. There is no **live** re-trigger: when an item's state changes (the user's "item with
mana vs out of mana"), the icon must re-composite.
User decisions (2026-06-17): **(a)** port the effect treatment **faithfully** (retail's
subtle white-pixel recolor, not a bold overlay); **(b)** D.5.2 **includes** the live
`PublicUpdatePropertyInt(0x02CE)` wire-up so the icon updates in real time.
## 2. Scope
**In scope**
- Capture `UiEffects` (weenieFlags `0x80`) from `CreateObject` onto the item.
- The faithful 2-stage effect composite in `IconComposer`.
- The live `PublicUpdatePropertyInt(0x02CE)` parser → `UiEffects` → re-composition.
- Conformance tests + divergence-register bookkeeping.
**Out of scope (with reasons)**
- **Appraise-driven icon enrichment** — DROPPED. ACE proves appraise carries no icon /
UiEffects data (`Icon`/`IconOverlay`/`IconUnderlay` and `PropertyInt.UiEffects` all lack
`[AssessmentProperty]`). It is a no-op, and acdream never sends an appraise anyway.
- `IsThePlayer` paperdoll container icon (`GetDIDByEnum(0x10000004, 7)`) — paperdoll phase.
- `PrivateUpdatePropertyInt(0x02CD)` (player's own object, no guid) — not an item path.
## 3. Background — the corrected retail facts (from the RESOLVED doc)
- **All icon inputs are CreateObject-only.** `_iconID` (always), `_iconOverlayID`
(weenieFlags `0x40000000`), `_iconUnderlayID` (weenieFlags2 `0x01`), `_effects`/UiEffects
(weenieFlags `0x80`). D.5.1 already captures the first three; `_effects` is discarded.
- **The effect overlay is a `ReplaceColor` tint SOURCE, not a blit layer.** Clean decompile
of `IconData::RenderIcons` (`0x0058d180`) + `SurfaceWindow::ReplaceColor` (`0x00441530`):
```
drag surface = Blit base (Blit_Normal) + Blit custom overlay (Blit_4Alpha)
+ if effect: ReplaceColor(this=drag, src=WHITE(1,1,1,1), dest=<effect color>)
slot icon = Blit type-default underlay (Blit_Normal, opaque)
+ Blit custom underlay (Blit_3Alpha)
+ Blit drag surface (Blit_3Alpha)
```
`ReplaceColor` replaces pixels exactly equal to `0xFFFFFFFF` with the dest color. The
effect tiles (`enum 0x10000005`) are 32×32 **fully-opaque** colored squares — they cannot
be blitted on top (would erase the icon); they source the recolor.
- **Effect index** = `LowestSetBit(_effects)+1` into `enum 0x10000005`; if the resolved DBObj
is null → fallback index `0x21`. (No `lsb==-1 → 0x21` pre-check on the effect path, unlike
the type-underlay path.)
- **Dirty-check** (`UpdateIcons`): re-render on change of `iconID / overlayID / underlayID /
itemType / _effects`. acdream's per-tuple icon cache keyed on exactly these IS the
re-composition contract.
### Golden effect-submap values (live dat — MasterMap `0x25000000` → submap `0x25000009`)
| UiEffects | bit | index | effect DID | tile mean RGB |
|---|---|---|---|---|
| Magical | 0x0001 | 1 | `0x060011CA` | blue |
| Poisoned | 0x0002 | 2 | `0x060011C6` | green |
| BoostHealth | 0x0004 | 3 | `0x06001B05` | red |
| BoostStamina | 0x0010 | 5 | `0x06001B06` | yellow |
| Nether | 0x1000 | 13 (absent) | → fallback `0x060011C5` | black |
| (none, `_effects==0`) | — | 0 (zero) | → fallback `0x060011C5` | black |
Full table + the type-underlay (`0x10000004`) cross-check are in the RESOLVED doc.
## 4. Architecture & data flow
```
CreateObject (0xF745) ──UiEffects(0x80)──┐
├──► ItemInstance.Effects ──► ItemRepository.ItemPropertiesUpdated
PublicUpdatePropertyInt(0x02CE) ──────────┤ │
prop==UiEffects(18), guid==item │ ▼
└──────────► UiItemSlot re-calls IconComposer.GetIcon(…, effects)
(new cache key ⇒ fresh composite)
```
The re-composition contract (`ItemPropertiesUpdated` → widget re-resolve via the
toolbar's `Populate`) already exists; D.5.2 feeds it the effect state from two sources.
The live `0x02CE` event is bound in `GameWindow`'s session-event binding (next to the
existing `VitalUpdated` subscription) — NOT `GameEventWiring`, which only handles the
`0xF7B0` GameEvent sub-opcode dispatcher.
## 5. Components
Each component below states **what it does / how it's used / what it depends on.**
### 5.1 `ItemInstance.Effects` (`AcDream.Core/Items/ItemInstance.cs`)
- **What:** a `uint Effects` field — the live UiEffects bitfield (0 = no effect).
- **Use:** read by the icon-id resolver; written by `EnrichItem` (CreateObject) and
`UpdateIntProperty` (live update).
- **Depends on:** nothing (pure data).
### 5.2 `CreateObject.Parsed.UiEffects` (`AcDream.Core.Net/Messages/CreateObject.cs`)
- **What:** capture the `UiEffects` u32 (weenieFlags `0x80`) currently read-and-discarded;
add `uint UiEffects = 0` to the `Parsed` record.
- **Use:** threaded into `EntitySpawn`.
- **Depends on:** the existing weenie-tail walk (no order change — UiEffects already sits at
its correct position in the walk).
### 5.3 `WorldSession.EntitySpawn.UiEffects` + the `0x02CE` route (`AcDream.Core.Net/WorldSession.cs`)
- **What:** add `uint UiEffects = 0` to `EntitySpawn`, thread `parsed.Value.UiEffects`; add a
message-loop branch for `PublicUpdatePropertyInt.Opcode (0x02CE)` that parses the body and
fires a new `ObjectIntPropertyUpdated(guid, property, value)` event.
- **Use:** `GameWindow` consumes `EntitySpawn`; `GameEventWiring` consumes the new event.
- **Depends on:** `CreateObject.Parsed.UiEffects`, `PublicUpdatePropertyInt` parser.
### 5.4 `PublicUpdatePropertyInt` parser (`AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs`, NEW)
- **What:** a static parser mirroring `PrivateUpdateVital.cs`. Wire layout (ACE
`GameMessagePublicUpdatePropertyInt`, size hint 17):
```
u32 opcode = 0x02CE
u8 sequence (single byte, per the PrivateUpdateVital note)
u32 guid
u32 property (PropertyInt enum; UiEffects = 18)
i32 value
```
`TryParse(body) -> (uint Guid, uint Property, int Value)?` — null on opcode mismatch /
truncation. (Sequence parsed-past, not honored — latest-wins; see divergence DR-4.)
- **Use:** called from the `WorldSession` `0x02CE` branch.
- **Depends on:** nothing.
### 5.5 `ItemRepository` (`AcDream.Core/Items/ItemRepository.cs`)
- **What:**
- `EnrichItem(..., uint effects = 0)` — assign `item.Effects = effects` (unconditional; 0
is a meaningful "no effect" state).
- `UpdateIntProperty(uint itemId, uint propertyId, int value)` — NEW extensible hook:
stores into `Properties.Ints[propertyId]`, and for known typed ints maps to the typed
field (`propertyId == 18 (UiEffects) → item.Effects = (uint)value`), then fires
`ItemPropertiesUpdated`. Returns false if the item is unknown.
- **Use:** `EnrichItem` from `GameWindow.OnLiveEntitySpawned`; `UpdateIntProperty` from
`GameEventWiring` on `ObjectIntPropertyUpdated`.
- **Depends on:** `ItemInstance.Effects`.
### 5.6 `IconComposer` (`AcDream.App/UI/IconComposer.cs`) — the compositor
- **What:** `GetIcon(ItemType, iconId, underlayId, overlayId, effects)` — 5-arg, cache key
widened to include `effects`. Implements the faithful 2-stage composite (§3):
- **Stage 1 (drag):** `Compose([base, customOverlay])`; if `effects != 0` and the effect
color resolves, `ReplaceColor(white → effectColor)` on the drag buffer.
- **Stage 2 (slot):** `Compose([typeUnderlay, customUnderlay, drag])`.
- `ResolveEffectDid(effects)` mirrors `ResolveUnderlayDid` but via `enum 0x10000005`
(`EnsureEffectSubMap`), index `LowestSetBit(effects)+1`, fallback `0x21`.
- `TryGetEffectColor(effects)` decodes the effect tile and returns its **mean-opaque**
color (the faithful representative; the exact retail byte is a decompiler-ambiguous
`SurfaceWindow`-header read — see DR-2).
- `ReplaceColorWhite(rgba, w, h, dest)` — retail `ReplaceColor` (`0x00441530`): replace
pixels `== (255,255,255,255)` with `dest`.
- **Effect recolor applies only when `effects != 0`** (DR-3: retail nominally runs the
`effects==0` black-fallback recolor; we skip it — likely a no-op but a regression risk).
- **Use:** called by the toolbar's `iconIds` delegate (and future item panels).
- **Depends on:** `DatCollection`, `TextureCache`, `SurfaceDecoder`, `EnumIDMap`.
- **Note:** the 2-stage form is associative-equivalent to D.5.1's single Compose for the
non-effect case (Porter-Duff "over" is associative), so shipped D.5.1 visuals are
unchanged when `effects == 0`.
### 5.7 Delegate widening (`ToolbarController.cs` + `GameWindow.cs`)
- **What:** the `iconIds` delegate becomes `Func<ItemType, uint, uint, uint, uint, uint>`
(+effects); `ToolbarController.Populate` passes `item.Effects`; `GameWindow`'s closure +
`OnLiveEntitySpawned` pass `spawn.UiEffects`.
- **Depends on:** §5.1, §5.6.
### 5.8 `GameWindow` session-event binding (`AcDream.App/Rendering/GameWindow.cs`)
- **What:** subscribe to `WorldSession.ObjectIntPropertyUpdated` (alongside the existing
`VitalUpdated` subscription, ~line 2630); route `Property == 18 (UiEffects)` to
`Items.UpdateIntProperty(guid, 18, value)`. (Top-level session events bind here, not in
`GameEventWiring` — that class only handles the `0xF7B0` GameEvent dispatcher.)
- **Depends on:** §5.3, §5.5.
## 6. Divergence-register changes
- **Retire `IA-16`** (item-icon composite PARTIAL) — the composite is now complete.
- **Add DR-1** — effect overlay is a `ReplaceColor` recolor SOURCE, not a blit layer (this
IS the faithful retail behavior; row documents the model so future readers don't "fix" it
back to a blit). Anchor: `RenderIcons` `0x0058d180`, `ReplaceColor` `0x00441530`.
- **Add DR-2** — the effect tint color uses the effect tile's mean-opaque color; the exact
retail color byte (`effectTile + 0xac` reinterpreted as `RGBAColor`) is decompiler-
ambiguous. Approximation; visual/cdb confirmation pending.
- **Add DR-3** — we skip the `_effects==0` black-fallback recolor that retail nominally runs.
- **Add DR-4** — `PublicUpdatePropertyInt(0x02CE)` sequence not honored (latest-wins).
## 7. Tests (conformance + acceptance)
- **Resolve (dat-gated golden):** `ResolveEffectDid` → Magical `0x060011CA`, Poisoned
`0x060011C6`, BoostHealth `0x06001B05`, None & Nether → fallback `0x060011C5`.
- **Recolor (dat-free):** `ReplaceColorWhite` turns `0xFFFFFFFF` pixels into the dest color
and leaves non-white pixels untouched; a 2-layer compose + recolor yields the expected
pixels.
- **Parse:** `CreateObject.TryParse` captures `UiEffects` from a synthetic body with the
`0x80` flag; `PublicUpdatePropertyInt.TryParse` returns `(guid, prop, value)` from golden
bytes and rejects a wrong opcode / truncation.
- **Repository:** `EnrichItem(effects:…)` sets `Effects`; `UpdateIntProperty(guid, 18, v)`
sets `Effects` and fires `ItemPropertiesUpdated`; returns false for an unknown guid.
- **Acceptance (visual):** build + `dotnet test` green, then the user confirms in the live
client — a magical item shows the effect tint, and an item draining mana updates live.
## 8. Acceptance criteria checklist
- [ ] `UiEffects` captured on `CreateObject`, threaded to `ItemInstance.Effects`.
- [ ] `IconComposer.GetIcon` 5-arg with the faithful 2-stage composite + effect recolor.
- [ ] `ResolveEffectDid` golden test passes against the live dat.
- [ ] `PublicUpdatePropertyInt(0x02CE)` parsed; `UiEffects` updates re-composite live.
- [ ] Appraise path left as-is (no speculative icon enrichment added).
- [ ] Register: `IA-16` retired; `DR-1..DR-4` added (same commits as the code they describe).
- [ ] `dotnet build` + `dotnet test` green; roadmap + memory digest updated.
- [ ] Visual verification by the user.