From 3e019e408a3ad5d21209c4f054b399c716eee524 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 17 Jun 2026 18:38:51 +0200 Subject: [PATCH] feat(D.5.2): IconComposer effect-color + ReplaceColorWhite helpers ReplaceColorWhite (retail SurfaceWindow::ReplaceColor 0x00441530): replaces only pure-white-opaque (RGBA 255,255,255,255) pixels in place. TryGetEffectColor: resolves the effect tile DID via ResolveEffectDid, decodes the RenderSurface, and returns the mean-opaque RGB as the tint color (divergence DR-2: exact retail color byte is decompiler-ambiguous). TryDecode: shared RenderSurface decode helper for the effect path. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/IconComposer.cs | 56 +++++++++++++++++++ .../AcDream.App.Tests/UI/IconComposerTests.cs | 18 ++++++ 2 files changed, 74 insertions(+) diff --git a/src/AcDream.App/UI/IconComposer.cs b/src/AcDream.App/UI/IconComposer.cs index 516b8e9b..68b59ffa 100644 --- a/src/AcDream.App/UI/IconComposer.cs +++ b/src/AcDream.App/UI/IconComposer.cs @@ -51,6 +51,7 @@ public sealed class IconComposer private EnumIDMap? _effectSubMap; private bool _effectResolveTried; private readonly Dictionary _effectDidByIndex = new(); + private readonly Dictionary _effectColorByDid = new(); public IconComposer(DatCollection dats, TextureCache cache) { @@ -125,6 +126,61 @@ public sealed class IconComposer if (_dats.Portal.TryGet(subDid, out var sub)) _effectSubMap = sub; } + /// + /// Retail SurfaceWindow::ReplaceColor (0x00441530) with the icon-composite's + /// fixed source color: replace pixels exactly equal to pure-white-opaque + /// (RGBAColor(1,1,1,1) → 0xFFFFFFFF) with . Mutates in place. + /// + internal static void ReplaceColorWhite(byte[] rgba, int w, int h, (byte r, byte g, byte b, byte a) dest) + { + for (int i = 0; i < w * h; i++) + { + if (rgba[i * 4] == 255 && rgba[i * 4 + 1] == 255 && + rgba[i * 4 + 2] == 255 && rgba[i * 4 + 3] == 255) + { + rgba[i * 4] = dest.r; rgba[i * 4 + 1] = dest.g; + rgba[i * 4 + 2] = dest.b; rgba[i * 4 + 3] = dest.a; + } + } + } + + /// + /// The effect tint color for : the effect tile's mean-opaque + /// color (blue=Magical, green=Poisoned, …). The exact retail color byte is a + /// 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) + { + color = default; + uint did = ResolveEffectDid(effects); + if (did == 0) return false; + if (_effectColorByDid.TryGetValue(did, out var cached)) { color = cached; return true; } + if (!TryDecode(did, out var d)) return false; + long sr = 0, sg = 0, sb = 0; int n = 0; + for (int i = 0; i < d.Width * d.Height; i++) + { + if (d.Rgba8[i * 4 + 3] == 0) continue; + sr += d.Rgba8[i * 4]; sg += d.Rgba8[i * 4 + 1]; sb += d.Rgba8[i * 4 + 2]; n++; + } + if (n == 0) return false; + var rep = ((byte)(sr / n), (byte)(sg / n), (byte)(sb / n), (byte)255); + _effectColorByDid[did] = rep; + color = rep; + return true; + } + + private bool TryDecode(uint renderSurfaceId, out DecodedTexture decoded) + { + decoded = null!; + if (renderSurfaceId == 0) return false; + if (!_dats.Portal.TryGet(renderSurfaceId, out var rs) && + !_dats.HighRes.TryGet(renderSurfaceId, out rs)) + return false; + decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null); + return true; + } + /// Pure alpha-over composite, bottom->top. Layers may differ in size; /// the result is sized to the FIRST (bottom) layer and upper layers are sampled /// top-left aligned (all icon layers are 32x32 in practice). diff --git a/tests/AcDream.App.Tests/UI/IconComposerTests.cs b/tests/AcDream.App.Tests/UI/IconComposerTests.cs index 4a19ed83..2e4ad458 100644 --- a/tests/AcDream.App.Tests/UI/IconComposerTests.cs +++ b/tests/AcDream.App.Tests/UI/IconComposerTests.cs @@ -126,4 +126,22 @@ public class IconComposerTests Assert.Equal(0x060011C5u, composer.ResolveEffectDid(0x1000u)); Assert.Equal(0x060011C5u, composer.ResolveEffectDid(0x0000u)); } + + [Fact] + public void ReplaceColorWhite_replacesOnlyPureWhiteOpaque() + { + // 2x2: [white-opaque, red-opaque, white-transparent, white-opaque] + var px = new byte[] + { + 255,255,255,255, // pure white opaque → replaced + 255, 0, 0,255, // red → untouched + 255,255,255, 0, // white but alpha 0 → untouched (not 0xFFFFFFFF) + 255,255,255,255, // pure white opaque → replaced + }; + IconComposer.ReplaceColorWhite(px, 2, 2, (10, 20, 30, 255)); + Assert.Equal(new byte[] { 10, 20, 30, 255 }, px[0..4]); // replaced + Assert.Equal(new byte[] { 255, 0, 0, 255 }, px[4..8]); // untouched + Assert.Equal(new byte[] { 255, 255, 255, 0 }, px[8..12]); // untouched + Assert.Equal(new byte[] { 10, 20, 30, 255 }, px[12..16]); // replaced + } }