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

13 KiB
Raw Permalink Blame History

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 — 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-4PublicUpdatePropertyInt(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.