# 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=) 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. ## 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` (+effects); `ToolbarController.Populate` passes `item.Effects`; `GameWindow`'s closure + `OnLiveEntitySpawned` pass `spawn.UiEffects`. - **Depends on:** §5.1, §5.6. ### 5.8 `GameEventWiring` (`AcDream.Core.Net/GameEventWiring.cs`) - **What:** subscribe to `WorldSession.ObjectIntPropertyUpdated`; route `property == 18 (UiEffects)` to `items.UpdateIntProperty(guid, 18, value)`. - **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.