diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 6b6a9b20..9527df41 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -96,7 +96,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 44 rows +## 3. Documented approximation (AP) — 43 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -143,7 +143,6 @@ accepted-divergence entries (#96, #49, #50). | AP-41 | Scrollbar thumb 3-slice cap fallback only: single-tile draw (`0x06004C63`) used only when `ThumbTopSprite`/`ThumbBotSprite` are unset; the chat controller passes all three cap ids so the 3-slice path is drawn in practice | `src/AcDream.App/UI/UiScrollbar.cs:35` | The fallback single-tile path is unreachable when caps are bound (chat controller always sets them); the 3-slice path is the active code path | Only if a future caller omits the cap ids will the fallback fire — no visual regression in the chat window | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` | | AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 | | AP-43 | Effect tint color = the effect tile's mean-opaque RGB (average of non-transparent pixels); the exact retail color is an `effectTile + 0xac` pointer reinterpreted as `RGBAColor` — decompiler-ambiguous in the BN pseudo-C (field offset vs pointer). Visual/cdb confirmation pending (D.5.2 lever: DR-2) | `src/AcDream.App/UI/IconComposer.cs` (`TryGetEffectColor`) | The tile IS the per-effect coloring authority (Magical=blue, Poisoned=green, …); its mean-opaque color is the representative color. The ambiguity only affects hue saturation slightly. | Effect tints could be subtly wrong vs retail (too washed-out or oversaturated) if the header field is a precomputed key color rather than the pixel mean — visible on items with distinctive effect glows | `IconData::RenderIcons` 0x0058d180 (`effectTile+0xac` usage); `docs/research/2026-06-17-stateful-icon-RESOLVED.md` | -| AP-44 | The `effects==0` black-fallback recolor retail nominally runs (white→black on every item when no effect bit is set) is SKIPPED; we gate on `effects != 0` | `src/AcDream.App/UI/IconComposer.cs` (`GetIcon` `effects != 0` gate) | The white→black recolor on a no-effect item is presumed a no-op in practice (items without magic are unlikely to have white-opaque pixels in the base icon); skipping avoids a regression risk pending visual/cdb confirmation (DR-3). Filed as a known deviation — not an oversight. | If retail does darken non-effect items' white highlights, those highlights will appear brighter than retail until a cdb session confirms or refutes | `IconData::RenderIcons` 0x0058d180 (fallback-0x21 path leads to ReplaceColor with a near-black tile) | | AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; latest-wins matches `PrivateUpdateVital`/`UpdatePosition`'s existing non-sequence behavior. Sequence tracking added when needed alongside TS-26. | A reordered 0x02CE on a real network could apply a stale UiEffects value — item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) | --- diff --git a/src/AcDream.App/UI/IconComposer.cs b/src/AcDream.App/UI/IconComposer.cs index a0182b1f..12725e6e 100644 --- a/src/AcDream.App/UI/IconComposer.cs +++ b/src/AcDream.App/UI/IconComposer.cs @@ -150,7 +150,7 @@ public sealed class IconComposer /// decompiler-ambiguous SurfaceWindow-header read; the tile IS the per-effect color, so /// its representative color is the faithful equivalent (divergence DR-2). Cached per DID. /// - private bool TryGetEffectColor(uint effects, out (byte r, byte g, byte b, byte a) color) + internal bool TryGetEffectColor(uint effects, out (byte r, byte g, byte b, byte a) color) { color = default; uint did = ResolveEffectDid(effects); @@ -214,9 +214,9 @@ public sealed class IconComposer /// Returns 0 if no base icon. Mirrors retail IconData::RenderIcons (0x0058d180): /// a DRAG composite (base + custom overlay + effect recolor) blitted over the /// type-default underlay + custom underlay. The effect tile (enum 0x10000005) is a - /// ReplaceColor tint SOURCE, not a blit layer (DR-1). The 2-stage form is - /// associative-equivalent to a single Compose when effects==0, so D.5.1 visuals are - /// unchanged for non-effect items. + /// ReplaceColor tint SOURCE, not a blit layer (DR-1). The recolor runs for ALL items: + /// effects==0 resolves to the 0x21 solid-black fallback tile, so pure-white pixels become + /// black (matching retail); magical items take the per-effect hue instead. /// public uint GetIcon(ItemType itemType, uint iconId, uint underlayId, uint overlayId, uint effects) { @@ -233,10 +233,13 @@ public sealed class IconComposer if (dragLayers.Count > 0) { var composed = Compose(dragLayers); - // Effect recolor only when an effect bit is set. Retail nominally also runs the - // effects==0 black-fallback recolor; we skip it (DR-3: white→black on every item - // is a likely no-op but a regression risk, pending visual/cdb confirmation). - if (effects != 0 && TryGetEffectColor(effects, out var ec)) + // Effect recolor — ALWAYS, matching retail IconData::RenderIcons (0x0058d180): + // the effect tile (enum 0x10000005, lsb(effects)+1, fallback 0x21) is non-null + // even for effects==0 (the 0x21 SOLID-BLACK tile 0x060011C5), so retail recolors + // pure-white pixels to BLACK on mundane items and to the effect hue on magical + // ones. Visually confirmed against retail 2026-06-17: the no-mana scroll's edges + // are BLACK, not white — the earlier `effects != 0` gate (AP-44) was wrong. + if (TryGetEffectColor(effects, out var ec)) ReplaceColorWhite(composed.rgba, composed.w, composed.h, ec); drag = composed; } diff --git a/tests/AcDream.App.Tests/UI/IconComposerTests.cs b/tests/AcDream.App.Tests/UI/IconComposerTests.cs index a953e843..003dec5a 100644 --- a/tests/AcDream.App.Tests/UI/IconComposerTests.cs +++ b/tests/AcDream.App.Tests/UI/IconComposerTests.cs @@ -127,6 +127,24 @@ public class IconComposerTests Assert.Equal(0x060011C5u, composer.ResolveEffectDid(0x0000u)); } + [Fact] + public void TryGetEffectColor_noEffect_resolvesToBlackFallback() + { + var datDir = ResolveDatDir(); + if (datDir is null) return; // dats absent (CI) — skip cleanly + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var composer = new IconComposer(dats, null!); + + // effects==0 resolves to the 0x21 solid-black fallback tile (0x060011C5), so the + // ALWAYS-on recolor blackens an icon's pure-white edge pixels on mundane items — + // retail-faithful (the no-mana scroll's edges are BLACK, not white). Confirmed + // visually against retail 2026-06-17. + Assert.True(composer.TryGetEffectColor(0u, out var c)); + Assert.True(c.r <= 8 && c.g <= 8 && c.b <= 8, $"expected ~black, got ({c.r},{c.g},{c.b})"); + Assert.Equal(255, c.a); + } + [Fact] public void ReplaceColorWhite_replacesOnlyPureWhiteOpaque() {