diff --git a/docs/research/2026-06-17-stateful-icon-RESOLVED.md b/docs/research/2026-06-17-stateful-icon-RESOLVED.md new file mode 100644 index 00000000..c1d1023e --- /dev/null +++ b/docs/research/2026-06-17-stateful-icon-RESOLVED.md @@ -0,0 +1,142 @@ +# Stateful item-icon system — RESEARCH RESOLVED (the build basis for D.5.2) + +**Date:** 2026-06-17 +**Supersedes the key hypotheses in** `docs/research/2026-06-17-stateful-icon-system-handoff.md`. +**Method:** grep-named → cross-ref (ACE/ACViewer/Chorizite) → clean Ghidra decompile +(MCP, PDB-applied `patchmem.gpr`) → live-dat probe. Each decomp claim adversarially +verified against source. + +This doc records the **definitive** answers. Two handoff hypotheses were **wrong**; both +are corrected here with evidence. + +--- + +## 1. Data-availability — SETTLED (handoff's "DO THIS FIRST" question) + +**The icon ids and the effect bitfield arrive ONLY on `CreateObject`. Appraise carries +NONE of them.** Definitive from the ACE oracle (the user's own server): + +- `references/ACE/.../Enum/Properties/PropertyDataId.cs:5-7` (verbatim): + *"No properties are sent to the client unless they featured an attribute. … AssessmentProperty + gets sent in successful appraisal."* +- `Icon = 8`, `IconOverlay = 50`, `IconUnderlay = 52` — **no `[AssessmentProperty]`** → never in + appraise (nor `[SendOnLogin]` → never in PlayerDescription property tables). +- `PropertyInt.UiEffects = 18` — **no `[AssessmentProperty]`** (`PropertyInt.cs:34`; the + research-agent claim that it has the attribute was a **fabrication**, caught by the verifier). +- `AppraiseInfo.Write` serializes only the attributed `PropertiesInt/PropertiesDID/…` tables + + the profile blobs — **no icon / UiEffects field anywhere**. + +Wire path for every icon input (all on the `CreateObject` weenie header, ACE +`WorldObject_Networking.cs` + `PublicWeenieDesc::Pack` decomp `442421/442489/442628/442631`): + +| Field | weenie-flag gate | acdream status | +|---|---|---| +| `_iconID` | always | captured (D.5.1) | +| `_iconOverlayID` | weenieFlags `0x40000000` | captured (D.5.1) | +| `_iconUnderlayID` | weenieFlags2 `0x01` | captured (D.5.1) | +| `_effects` (UiEffects) | weenieFlags `0x80` | **read + DISCARDED** at `CreateObject.cs:669` | + +**Consequence (corrects handoff §3.3/§3.4 + §5.4):** the pinned scroll shows no overlay because +acdream **discards `UiEffects`** and never builds the effect treatment — NOT because the data is +appraise-gated. **The handoff's "wire appraise → enrichment" item is a no-op**: appraise never +carries this data, and acdream never even *sends* an `AppraiseRequest` (`AppraiseRequest.Build` +exists but has zero call sites). The live "mana vs out-of-mana" re-trigger is a future +`PrivateUpdateInt(UiEffects=18)` (the `0x02CD` property-update block, inventory/M2 phase), feeding +the same re-composition contract — NOT appraise. + +--- + +## 2. The effect overlay is a `ReplaceColor` tint SOURCE, not a blit layer — SETTLED + +Clean Ghidra decompile of `IconData::RenderIcons` (`0x0058d180`) + `SurfaceWindow::ReplaceColor` +(`0x00441530`) resolves the Binary-Ninja register/calling-convention artifacts the handoff and the +spine doc flagged UNVERIFIED. + +**`SurfaceWindow::ReplaceColor(this, RGBAColor src, RGBAColor dest)`** = for each pixel `== +GetColor32(src)`, set it to `GetColor32(dest)`. A flat single-color → single-color replace. + +**`RenderIcons` builds two surfaces (bottom→top):** + +``` +m_pDragIcon (32x32): + Blit base icon (m_idIcon) mode Blit_Normal (opaque) + Blit custom overlay (m_idOverlayID) mode Blit_4Alpha + if (effectTile != null): # effectTile = GetByEnum(0x10000005, …) + ReplaceColor(this, src = WHITE(1,1,1,1), dest = ) + +m_pIcon (32x32): + Blit type-default underlay (GetByEnum 0x10000004, lsb(itemType)+1, fb 0x21) Blit_Normal (opaque) + Blit custom underlay (m_idUnderlayID) Blit_3Alpha + Blit m_pDragIcon Blit_3Alpha +``` + +- The **effect tile is NEVER blitted** (it's the `ReplaceColor` `dest`-color source). The dat probe + confirms why: every `enum 0x10000005` entry is a **32×32 FULLY-OPAQUE** colored tile + (`opaque=1024, transp=0`) — blitting one on top would erase the icon. +- `src` color = `RGBAColor(1,1,1,1)` → `GetColor32` → `0xFFFFFFFF` (pure-white, full alpha). So + **only pure-white-opaque pixels recolor** — the effect is the recolor of the icon/overlay's white + highlights to the effect hue. Subtle, data-dependent. +- **Effect index:** `LowestSetBit(_effects)+1` into `enum 0x10000005`; if the resolved DBObj is null, + fallback index `0x21`. NOTE retail has **no** `lsb==-1 → 0x21` pre-check on the effect path (unlike + the type-underlay path), so `_effects==0` → index 0 → null → fallback `0x21` (the SOLID-BLACK tile). +- **UpdateIcons dirty-check** (`0x0058da…`, decomp `407962`): 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. + +### The one residual ambiguity (decompiler-bounded) +The exact byte `ReplaceColor`'s `dest` color is read from is `effectTile + 0xac` (= the effect tile's +`SurfaceWindow` header) reinterpreted as `RGBAColor` — both BN and Ghidra leave this as a struct +read neither types cleanly. It is NOT pixel data and NOT a clean field either decompiler resolves. +**Faithful resolution:** the effect tiles are purpose-built per-effect colored tiles, so the effect +color = the tile's own representative (mean opaque) color. This is intent-faithful, not a guess about +an unknown constant. Flagged for cdb/visual confirmation. (Register row + visual gate.) + +--- + +## 3. `enum 0x10000005` effect submap — golden values (live dat, MasterMap `0x25000000` → submap `0x25000009`) + +`index = LowestSetBit(UiEffects)+1`; submap has 14 entries (idx 0–12 + `0x21` fallback): + +| UiEffects bit | name | idx | effect tile DID | tile mean RGB | +|---|---|---|---|---| +| 0x0001 | Magical | 1 | `0x060011CA` | blue (53,70,212) | +| 0x0002 | Poisoned | 2 | `0x060011C6` | green (79,204,34) | +| 0x0004 | BoostHealth | 3 | `0x06001B05` | red (213,57,59) | +| 0x0008 | BoostMana | 4 | `0x060011CA` | blue | +| 0x0010 | BoostStamina | 5 | `0x06001B06` | yellow (223,206,21) | +| 0x0020 | Fire | 6 | `0x06001B2E` | orange | +| 0x0040 | Lightning | 7 | `0x06001B2D` | purple | +| 0x0080 | Frost | 8 | `0x06001B2F` | cyan-grey | +| 0x0100 | Acid | 9 | `0x06001B2C` | green | +| 0x0200 | Bludgeoning | 10 | `0x060033C3` | grey | +| 0x0400 | Slashing | 11 | `0x060033C2` | pink-grey | +| 0x0800 | Piercing | 12 | `0x060033C4` | tan | +| 0x1000 | Nether | 13 | *(absent)* → fallback | → `0x060011C5` | +| — | (`_effects==0`) | 0 | *(zero)* → fallback | → `0x060011C5` (SOLID black) | +| — | fallback | 0x21 | `0x060011C5` | SOLID 0xFF000000 | + +(Cross-check, `enum 0x10000004` type-underlay, already shipped + golden-tested: Melee→`0x060011CB`, +Armor→`0x060011CF`, Clothing→`0x060011F3`, Jewelry→`0x060011D5`, fallback `0x21`→`0x060011D4`.) + +--- + +## 4. Build decisions (D.5.2) + +1. **Capture `UiEffects`** from `CreateObject` → `ItemInstance.Effects`; thread through + `EntitySpawn` → `EnrichItem`. +2. **`IconComposer`: faithful 2-stage composite** (drag = base+overlay+recolor; slot = + typeUnderlay+customUnderlay+drag). New `ResolveEffectDid` mirrors the proven `ResolveUnderlayDid`. + `GetIcon` + cache key widened to include `effects`. +3. **Effect recolor** applied only when `_effects != 0` (the meaningful case). Retail nominally runs + the `_effects==0` black-fallback recolor too; we **skip** it — recoloring white→black on every + item is a likely visual no-op (few pure-white pixels) but a real regression risk; documented + divergence pending visual/cdb confirmation. +4. **DROP the appraise-enrichment item** (no-op — §1). The re-composition contract + (`ItemPropertiesUpdated` → widget re-resolve) is already wired; its future trigger is + `PrivateUpdateInt(UiEffects)`, filed for the property-update phase. +5. **Conformance**: golden `ResolveEffectDid` test (the §3 values) + a dat-free recolor test. +6. **Register**: retire `IA-16`; add rows for effect-as-recolor, the `_effects==0` skip, and the + representative-color approximation. + +**MEMORY.md index line:** +- [Research: stateful icon RESOLVED (2026-06-17)](research/2026-06-17-stateful-icon-RESOLVED.md) — definitive basis for D.5.2. Appraise carries NO icon/UiEffects (ACE `[AssessmentProperty]` proof); all icon inputs are CreateObject-only (UiEffects weenieFlags 0x80, discarded at CreateObject.cs:669). Effect overlay (enum 0x10000005) is a `ReplaceColor(white→effectColor)` SOURCE, NOT a blit layer (Ghidra `RenderIcons`@0x0058d180 + `ReplaceColor`@0x00441530). Golden effect-submap values + the 2-stage composite. Corrects the handoff's appraise + blit-layer hypotheses. diff --git a/docs/superpowers/specs/2026-06-17-d2b-stateful-icon-design.md b/docs/superpowers/specs/2026-06-17-d2b-stateful-icon-design.md new file mode 100644 index 00000000..5a2806b5 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-d2b-stateful-icon-design.md @@ -0,0 +1,210 @@ +# 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.