diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs
index 7d1c0b25..0e7ebcea 100644
--- a/src/AcDream.App/Rendering/TextureCache.cs
+++ b/src/AcDream.App/Rendering/TextureCache.cs
@@ -542,6 +542,11 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
return composed;
}
+ /// Uploads a raw RGBA8 byte array as a Texture2D. Used by
+ /// to upload CPU-composited icon layers.
+ public uint UploadRgba8(byte[] rgba, int width, int height, bool nearest = false)
+ => UploadRgba8(new DecodedTexture(rgba, width, height), nearest);
+
private uint UploadRgba8(DecodedTexture decoded, bool nearest = false)
{
uint tex = _gl.GenTexture();
diff --git a/src/AcDream.App/UI/IconComposer.cs b/src/AcDream.App/UI/IconComposer.cs
new file mode 100644
index 00000000..09b97def
--- /dev/null
+++ b/src/AcDream.App/UI/IconComposer.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Collections.Generic;
+using AcDream.App.Rendering;
+using AcDream.Core.Textures;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+
+namespace AcDream.App.UI;
+
+///
+/// Builds an item icon by alpha-compositing its RenderSurface layers into one 32x32
+/// texture, mirroring retail IconData::RenderIcons (decomp 407524). Each layer is a
+/// 0x06 RenderSurface decoded DIRECTLY (the D.2b RenderSurface-vs-Surface rule).
+/// Phase 1 composites the layers ItemInstance exposes (custom underlay + base +
+/// custom overlay); the GetByEnum type-default underlay, the overlay ReplaceColor
+/// tint, and the effect overlay are deferred (see plan Task 12 / divergence rows).
+/// Composited textures are cached by their layer-id tuple.
+///
+public sealed class IconComposer
+{
+ private readonly DatCollection _dats;
+ private readonly TextureCache _cache;
+ private readonly Dictionary<(uint, uint, uint), uint> _byTuple = new();
+
+ public IconComposer(DatCollection dats, TextureCache cache)
+ {
+ _dats = dats;
+ _cache = cache;
+ }
+
+ /// 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).
+ public static (byte[] rgba, int w, int h) Compose(IReadOnlyList<(byte[] rgba, int w, int h)> layers)
+ {
+ if (layers.Count == 0) return (Array.Empty(), 0, 0);
+ var (baseRgba, w, h) = layers[0];
+ var outp = (byte[])baseRgba.Clone();
+ for (int li = 1; li < layers.Count; li++)
+ {
+ var (src, sw, sh) = layers[li];
+ int cw = Math.Min(w, sw), ch = Math.Min(h, sh);
+ for (int y = 0; y < ch; y++)
+ for (int x = 0; x < cw; x++)
+ {
+ int di = (y * w + x) * 4, si = (y * sw + x) * 4;
+ float sa = src[si + 3] / 255f;
+ if (sa <= 0f) continue;
+ float da = 1f - sa;
+ outp[di] = (byte)(src[si] * sa + outp[di] * da);
+ outp[di + 1] = (byte)(src[si + 1] * sa + outp[di + 1] * da);
+ outp[di + 2] = (byte)(src[si + 2] * sa + outp[di + 2] * da);
+ outp[di + 3] = (byte)Math.Min(255f, src[si + 3] + outp[di + 3] * da);
+ }
+ }
+ return (outp, w, h);
+ }
+
+ /// Resolve (and cache) the composited GL texture for an item's icon
+ /// layers. Returns 0 if no base icon is available.
+ public uint GetIcon(uint iconId, uint underlayId, uint overlayId)
+ {
+ if (iconId == 0) return 0;
+ var key = (iconId, underlayId, overlayId);
+ if (_byTuple.TryGetValue(key, out var tex)) return tex;
+
+ var layers = new List<(byte[] rgba, int w, int h)>();
+ AddLayer(layers, underlayId);
+ AddLayer(layers, iconId);
+ AddLayer(layers, overlayId);
+ if (layers.Count == 0) return 0;
+
+ var (rgba, w, h) = Compose(layers);
+ uint handle = _cache.UploadRgba8(rgba, w, h, nearest: true);
+ _byTuple[key] = handle;
+ return handle;
+ }
+
+ private void AddLayer(List<(byte[], int, int)> layers, uint renderSurfaceId)
+ {
+ if (renderSurfaceId == 0) return;
+ if (!_dats.Portal.TryGet(renderSurfaceId, out var rs) &&
+ !_dats.HighRes.TryGet(renderSurfaceId, out rs))
+ return;
+ var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
+ layers.Add((decoded.Rgba8, decoded.Width, decoded.Height));
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/IconComposerTests.cs b/tests/AcDream.App.Tests/UI/IconComposerTests.cs
new file mode 100644
index 00000000..09ec721f
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/IconComposerTests.cs
@@ -0,0 +1,36 @@
+using AcDream.App.UI;
+
+namespace AcDream.App.Tests.UI;
+
+public class IconComposerTests
+{
+ private static byte[] Solid(int w, int h, byte r, byte g, byte b, byte a)
+ {
+ var px = new byte[w * h * 4];
+ for (int i = 0; i < w * h; i++) { px[i*4]=r; px[i*4+1]=g; px[i*4+2]=b; px[i*4+3]=a; }
+ return px;
+ }
+
+ [Fact]
+ public void Compose_alphaOver_topOpaqueLayerWins()
+ {
+ var bottom = (Solid(2, 2, 255, 0, 0, 255), 2, 2); // red, opaque
+ var top = (Solid(2, 2, 0, 0, 255, 255), 2, 2); // blue, opaque
+ var (rgba, w, h) = IconComposer.Compose(new[] { bottom, top });
+ Assert.Equal(2, w); Assert.Equal(2, h);
+ Assert.Equal(0, rgba[0]); // R
+ Assert.Equal(0, rgba[1]); // G
+ Assert.Equal(255, rgba[2]); // B — top layer won
+ Assert.Equal(255, rgba[3]); // A
+ }
+
+ [Fact]
+ public void Compose_alphaOver_transparentTopKeepsBottom()
+ {
+ var bottom = (Solid(1, 1, 255, 0, 0, 255), 1, 1);
+ var top = (Solid(1, 1, 0, 0, 255, 0), 1, 1); // fully transparent blue
+ var (rgba, _, _) = IconComposer.Compose(new[] { bottom, top });
+ Assert.Equal(255, rgba[0]); // bottom red preserved
+ Assert.Equal(0, rgba[2]);
+ }
+}