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>
13 KiB
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:
- The effect treatment (retail's
UiEffects-driven recolor) is unbuilt, and acdream discards theUiEffectsbitfield atCreateObject.cs(the UiEffects skip). - 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(weenieFlags0x80) fromCreateObjectonto 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/IconUnderlayandPropertyInt.UiEffectsall lack[AssessmentProperty]). It is a no-op, and acdream never sends an appraise anyway. IsThePlayerpaperdoll 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(weenieFlags0x40000000),_iconUnderlayID(weenieFlags20x01),_effects/UiEffects (weenieFlags0x80). D.5.1 already captures the first three;_effectsis discarded. -
The effect overlay is a
ReplaceColortint SOURCE, not a blit layer. Clean decompile ofIconData::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)ReplaceColorreplaces pixels exactly equal to0xFFFFFFFFwith 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)+1intoenum 0x10000005; if the resolved DBObj is null → fallback index0x21. (Nolsb==-1 → 0x21pre-check on the effect path, unlike the type-underlay path.) -
Dirty-check (
UpdateIcons): re-render on change oficonID / 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 Effectsfield — the live UiEffects bitfield (0 = no effect). - Use: read by the icon-id resolver; written by
EnrichItem(CreateObject) andUpdateIntProperty(live update). - Depends on: nothing (pure data).
5.2 CreateObject.Parsed.UiEffects (AcDream.Core.Net/Messages/CreateObject.cs)
- What: capture the
UiEffectsu32 (weenieFlags0x80) currently read-and-discarded; adduint UiEffects = 0to theParsedrecord. - 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 = 0toEntitySpawn, threadparsed.Value.UiEffects; add a message-loop branch forPublicUpdatePropertyInt.Opcode (0x02CE)that parses the body and fires a newObjectIntPropertyUpdated(guid, property, value)event. - Use:
GameWindowconsumesEntitySpawn;GameEventWiringconsumes the new event. - Depends on:
CreateObject.Parsed.UiEffects,PublicUpdatePropertyIntparser.
5.4 PublicUpdatePropertyInt parser (AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs, NEW)
- What: a static parser mirroring
PrivateUpdateVital.cs. Wire layout (ACEGameMessagePublicUpdatePropertyInt, size hint 17):u32 opcode = 0x02CE u8 sequence (single byte, per the PrivateUpdateVital note) u32 guid u32 property (PropertyInt enum; UiEffects = 18) i32 valueTryParse(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
WorldSession0x02CEbranch. - Depends on: nothing.
5.5 ItemRepository (AcDream.Core/Items/ItemRepository.cs)
- What:
EnrichItem(..., uint effects = 0)— assignitem.Effects = effects(unconditional; 0 is a meaningful "no effect" state).UpdateIntProperty(uint itemId, uint propertyId, int value)— NEW extensible hook: stores intoProperties.Ints[propertyId], and for known typed ints maps to the typed field (propertyId == 18 (UiEffects) → item.Effects = (uint)value), then firesItemPropertiesUpdated. Returns false if the item is unknown.
- Use:
EnrichItemfromGameWindow.OnLiveEntitySpawned;UpdateIntPropertyfromGameEventWiringonObjectIntPropertyUpdated. - 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 includeeffects. Implements the faithful 2-stage composite (§3):- Stage 1 (drag):
Compose([base, customOverlay]); ifeffects != 0and the effect color resolves,ReplaceColor(white → effectColor)on the drag buffer. - Stage 2 (slot):
Compose([typeUnderlay, customUnderlay, drag]). ResolveEffectDid(effects)mirrorsResolveUnderlayDidbut viaenum 0x10000005(EnsureEffectSubMap), indexLowestSetBit(effects)+1, fallback0x21.TryGetEffectColor(effects)decodes the effect tile and returns its mean-opaque color (the faithful representative; the exact retail byte is a decompiler-ambiguousSurfaceWindow-header read — see DR-2).ReplaceColorWhite(rgba, w, h, dest)— retailReplaceColor(0x00441530): replace pixels== (255,255,255,255)withdest.- Effect recolor applies only when
effects != 0(DR-3: retail nominally runs theeffects==0black-fallback recolor; we skip it — likely a no-op but a regression risk).
- Stage 1 (drag):
- Use: called by the toolbar's
iconIdsdelegate (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
iconIdsdelegate becomesFunc<ItemType, uint, uint, uint, uint, uint>(+effects);ToolbarController.Populatepassesitem.Effects;GameWindow's closure +OnLiveEntitySpawnedpassspawn.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 existingVitalUpdatedsubscription, ~line 2630); routeProperty == 18 (UiEffects)toItems.UpdateIntProperty(guid, 18, value). (Top-level session events bind here, not inGameEventWiring— that class only handles the0xF7B0GameEvent 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
ReplaceColorrecolor 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:RenderIcons0x0058d180,ReplaceColor0x00441530. - Add DR-2 — the effect tint color uses the effect tile's mean-opaque color; the exact
retail color byte (
effectTile + 0xacreinterpreted asRGBAColor) is decompiler- ambiguous. Approximation; visual/cdb confirmation pending. - Add DR-3 — we skip the
_effects==0black-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→ Magical0x060011CA, Poisoned0x060011C6, BoostHealth0x06001B05, None & Nether → fallback0x060011C5. - Recolor (dat-free):
ReplaceColorWhiteturns0xFFFFFFFFpixels into the dest color and leaves non-white pixels untouched; a 2-layer compose + recolor yields the expected pixels. - Parse:
CreateObject.TryParsecapturesUiEffectsfrom a synthetic body with the0x80flag;PublicUpdatePropertyInt.TryParsereturns(guid, prop, value)from golden bytes and rejects a wrong opcode / truncation. - Repository:
EnrichItem(effects:…)setsEffects;UpdateIntProperty(guid, 18, v)setsEffectsand firesItemPropertiesUpdated; returns false for an unknown guid. - Acceptance (visual): build +
dotnet testgreen, 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
UiEffectscaptured onCreateObject, threaded toItemInstance.Effects.IconComposer.GetIcon5-arg with the faithful 2-stage composite + effect recolor.ResolveEffectDidgolden test passes against the live dat.PublicUpdatePropertyInt(0x02CE)parsed;UiEffectsupdates re-composite live.- Appraise path left as-is (no speculative icon enrichment added).
- Register:
IA-16retired;DR-1..DR-4added (same commits as the code they describe). dotnet build+dotnet testgreen; roadmap + memory digest updated.- Visual verification by the user.