fix(D.5.2): always run effect recolor (effects==0 -> black) to match retail

Visual verification caught it: a no-mana scroll's icon edges are BLACK in retail
but rendered WHITE in acdream. Cause = the effects!=0 gate (registered AP-44) that
skipped retail's effects==0 recolor. Retail's effect tile is non-null even for
effects==0 (the 0x21 SOLID-BLACK fallback 0x060011C5), so RenderIcons recolors
pure-white pixels to black on mundane items and to the effect hue on magical ones.
Remove the gate (always recolor); retire AP-44 (now faithful). TryGetEffectColor
made internal + a golden test pins effects==0 -> ~black.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-17 22:54:15 +02:00
parent 702d6e1e90
commit 40c97a53ac
3 changed files with 30 additions and 10 deletions

View file

@ -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) |
---

View file

@ -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.
/// </summary>
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.
/// </summary>
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;
}

View file

@ -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()
{