Visual verification (Coldeve, Energy Crystal) showed acdream's Magical blue as a flat tint vs retail's gradient. Root cause: RenderIcons calls the SURFACE overload of SurfaceWindow::ReplaceColor (0x004415b0), which copies the textured effect tile pixel-by-pixel into the icon's pure-white pixels — not the flat color->color overload (0x00441530) I'd approximated with the tile's mean color. Port the surface overload exactly (dst[x,y]=src[x,y] where dst==white); confirmed via clean Ghidra decompile + named decomp. Retires AP-43 (mean-color approximation); IA-18 updated to the surface op. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
271 lines
13 KiB
C#
271 lines
13 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using AcDream.App.Rendering;
|
||
using AcDream.Core.Items;
|
||
using AcDream.Core.Textures;
|
||
using DatReaderWriter;
|
||
using DatReaderWriter.DBObjs;
|
||
|
||
namespace AcDream.App.UI;
|
||
|
||
/// <summary>
|
||
/// Builds an item icon by alpha-compositing its RenderSurface layers into one 32x32
|
||
/// texture, mirroring retail IconData::RenderIcons (decomp 407524) and
|
||
/// DBCache::GetDIDFromEnum (0x413940). Each layer is a 0x06 RenderSurface decoded
|
||
/// DIRECTLY (the D.2b RenderSurface-vs-Surface rule).
|
||
///
|
||
/// Layer order (bottom → top), matching retail:
|
||
/// 1. type-default underlay (OPAQUE backing; resolved via EnumIDMap 0x10000004 from
|
||
/// the portal MasterMap) — <see cref="ResolveUnderlayDid"/>
|
||
/// 2. item custom underlay (e.g. "magic" tint strip)
|
||
/// 3. base icon
|
||
/// 4. item custom overlay (e.g. "enchanted" sparkle)
|
||
///
|
||
/// The type-default underlay is the key to non-transparent filled slots: because it
|
||
/// is fully opaque and is layer 0, <see cref="Compose"/> sizes the output to it and
|
||
/// the alpha-over pass fills every pixel. The overlay ReplaceColor tint and the effect
|
||
/// overlay (RenderIcons 407546) remain out of scope (paperdoll phase).
|
||
///
|
||
/// Composited textures are cached by their (typeUnderlay, underlay, base, overlay) tuple.
|
||
/// </summary>
|
||
public sealed class IconComposer
|
||
{
|
||
private readonly DatCollection _dats;
|
||
private readonly TextureCache _cache;
|
||
private readonly Dictionary<(uint, uint, uint, uint, uint), uint> _byTuple = new();
|
||
|
||
// ── type-default underlay resolve (EnumIDMap 0x10000004) ─────────────────
|
||
// Portal MasterMap (0x25000000) maps enum 0x10000004 → submap DID (0x25000008).
|
||
// Submap maps index → 0x06 RenderSurface DID. index = LSB(itemType)+1, or 0x21.
|
||
// Refs: IconData::RenderIcons 0058d214–0058d22c; DBCache::GetDIDFromEnum 0x413940.
|
||
private EnumIDMap? _underlaySubMap;
|
||
private bool _underlayResolveTried;
|
||
private readonly Dictionary<uint, uint> _underlayDidByIndex = new();
|
||
|
||
// ── effect overlay resolve (EnumIDMap 0x10000005) ────────────────────────
|
||
// Portal MasterMap (0x25000000) maps enum 0x10000005 → submap DID (0x25000009).
|
||
// Submap maps index → 0x06 RenderSurface DID. index = LSB(effects)+1, fallback 0x21.
|
||
// Refs: IconData::RenderIcons 0x0058d180 (effect path); the effect tile is a
|
||
// ReplaceColor tint SOURCE, not a blit layer (see RESOLVED doc, divergence DR-1).
|
||
private EnumIDMap? _effectSubMap;
|
||
private bool _effectResolveTried;
|
||
private readonly Dictionary<uint, uint> _effectDidByIndex = new();
|
||
private readonly Dictionary<uint, DecodedTexture> _effectTileByDid = new();
|
||
|
||
public IconComposer(DatCollection dats, TextureCache cache)
|
||
{
|
||
_dats = dats;
|
||
_cache = cache;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resolve the type-default underlay DID for <paramref name="itemType"/> via the
|
||
/// two-level EnumIDMap chain (retail: IconData::RenderIcons 0058d214–0058d22c +
|
||
/// DBCache::GetDIDFromEnum 0x413940).
|
||
///
|
||
/// <para>index = LowestSetBit(itemType) + 1, or 0x21 when itemType has no bits set.</para>
|
||
///
|
||
/// <para>NOTE: retail RenderIcons (407546) has a special paperdoll IsThePlayer case
|
||
/// that uses GetDIDByEnum(0x10000004, 7) + TYPE_CONTAINER for the player doll — that
|
||
/// path is out of scope here (paperdoll phase).</para>
|
||
/// </summary>
|
||
internal uint ResolveUnderlayDid(ItemType itemType)
|
||
{
|
||
uint raw = (uint)itemType;
|
||
int lsb = raw == 0 ? -1 : BitOperations.TrailingZeroCount(raw);
|
||
uint index = lsb < 0 ? 0x21u : (uint)(lsb + 1);
|
||
if (_underlayDidByIndex.TryGetValue(index, out var cached)) return cached;
|
||
EnsureUnderlaySubMap();
|
||
uint did = 0;
|
||
if (_underlaySubMap is { } sub && sub.ClientEnumToID.TryGetValue(index, out var d)) did = d;
|
||
_underlayDidByIndex[index] = did;
|
||
return did;
|
||
}
|
||
|
||
private void EnsureUnderlaySubMap()
|
||
{
|
||
if (_underlayResolveTried) return;
|
||
_underlayResolveTried = true;
|
||
uint masterDid = (uint)_dats.Portal.Header.MasterMapId; // = 0x25000000
|
||
if (masterDid == 0) return;
|
||
if (!_dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)) return;
|
||
if (!master.ClientEnumToID.TryGetValue(0x10000004u, out var subDid)) return; // → 0x25000008
|
||
if (_dats.Portal.TryGet<EnumIDMap>(subDid, out var sub)) _underlaySubMap = sub;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resolve the effect-overlay DID for <paramref name="effects"/> via the EnumIDMap
|
||
/// 0x10000005 chain. index = LowestSetBit(effects)+1; if the entry is missing/zero,
|
||
/// retail falls back to index 0x21 (the solid-black tile). NOTE: the effect path has
|
||
/// NO lsb==-1 pre-check (unlike the type underlay), so effects==0 → index 0 → miss →
|
||
/// fallback. (Retail IconData::RenderIcons 0x0058d180.)
|
||
/// </summary>
|
||
internal uint ResolveEffectDid(uint effects)
|
||
{
|
||
int lsb = effects == 0 ? -1 : BitOperations.TrailingZeroCount(effects);
|
||
uint index = (uint)(lsb + 1);
|
||
if (_effectDidByIndex.TryGetValue(index, out var cached)) return cached;
|
||
EnsureEffectSubMap();
|
||
uint did = 0;
|
||
if (_effectSubMap is { } sub && sub.ClientEnumToID.TryGetValue(index, out var d)) did = d;
|
||
if (did == 0 && _effectSubMap is { } sub2 && sub2.ClientEnumToID.TryGetValue(0x21u, out var fb))
|
||
did = fb;
|
||
_effectDidByIndex[index] = did;
|
||
return did;
|
||
}
|
||
|
||
private void EnsureEffectSubMap()
|
||
{
|
||
if (_effectResolveTried) return;
|
||
_effectResolveTried = true;
|
||
uint masterDid = (uint)_dats.Portal.Header.MasterMapId; // = 0x25000000
|
||
if (masterDid == 0) return;
|
||
if (!_dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)) return;
|
||
if (!master.ClientEnumToID.TryGetValue(0x10000005u, out var subDid)) return; // → 0x25000009
|
||
if (_dats.Portal.TryGet<EnumIDMap>(subDid, out var sub)) _effectSubMap = sub;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail <c>SurfaceWindow::ReplaceColor</c> SURFACE overload (0x004415b0): for every
|
||
/// pixel in <paramref name="dst"/> that equals pure-white-opaque (RGBAColor(1,1,1,1) →
|
||
/// 0xFFFFFFFF), copy the SAME (x,y) pixel from the source effect tile. This preserves
|
||
/// the effect tile's texture/gradient (NOT a flat color). Retail requires the source to
|
||
/// cover the dest (it does — both are 32x32); out-of-range pixels are left unchanged.
|
||
/// Mutates <paramref name="dst"/> in place.
|
||
/// </summary>
|
||
internal static void ReplaceWhiteFromSurface(byte[] dst, int dw, int dh, byte[] src, int sw, int sh)
|
||
{
|
||
for (int y = 0; y < dh; y++)
|
||
for (int x = 0; x < dw; x++)
|
||
{
|
||
int di = (y * dw + x) * 4;
|
||
if (dst[di] == 255 && dst[di + 1] == 255 && dst[di + 2] == 255 && dst[di + 3] == 255
|
||
&& x < sw && y < sh)
|
||
{
|
||
int si = (y * sw + x) * 4;
|
||
dst[di] = src[si]; dst[di + 1] = src[si + 1];
|
||
dst[di + 2] = src[si + 2]; dst[di + 3] = src[si + 3];
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// The decoded effect tile for <paramref name="effects"/> (enum 0x10000005). The tile is
|
||
/// a 32x32 textured RenderSurface whose pixels ARE the per-effect coloring (blue=Magical,
|
||
/// green=Poisoned, …; the 0x21 fallback is solid black). Retail copies it per-pixel into
|
||
/// the icon's white pixels (gradient), so we need the whole tile, not a representative
|
||
/// color. Cached per DID.
|
||
/// </summary>
|
||
internal bool TryGetEffectTile(uint effects, out DecodedTexture tile)
|
||
{
|
||
tile = null!;
|
||
uint did = ResolveEffectDid(effects);
|
||
if (did == 0) return false;
|
||
if (_effectTileByDid.TryGetValue(did, out var cached)) { tile = cached; return true; }
|
||
if (!TryDecode(did, out var d)) return false;
|
||
_effectTileByDid[did] = d;
|
||
tile = d;
|
||
return true;
|
||
}
|
||
|
||
private bool TryDecode(uint renderSurfaceId, out DecodedTexture decoded)
|
||
{
|
||
decoded = null!;
|
||
if (renderSurfaceId == 0) return false;
|
||
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs) &&
|
||
!_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
|
||
return false;
|
||
decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>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).</summary>
|
||
public static (byte[] rgba, int w, int h) Compose(IReadOnlyList<(byte[] rgba, int w, int h)> layers)
|
||
{
|
||
if (layers.Count == 0) return (Array.Empty<byte>(), 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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resolve (and cache) the composited GL texture for an item's icon state.
|
||
/// 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 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)
|
||
{
|
||
if (iconId == 0) return 0;
|
||
uint typeUnderlayDid = ResolveUnderlayDid(itemType);
|
||
var key = (typeUnderlayDid, iconId, underlayId, overlayId, effects);
|
||
if (_byTuple.TryGetValue(key, out var tex)) return tex;
|
||
|
||
// Stage 1 — retail m_pDragIcon: base + custom overlay, then the effect recolor.
|
||
var dragLayers = new List<(byte[] rgba, int w, int h)>();
|
||
AddLayer(dragLayers, iconId);
|
||
AddLayer(dragLayers, overlayId);
|
||
(byte[] rgba, int w, int h)? drag = null;
|
||
if (dragLayers.Count > 0)
|
||
{
|
||
var composed = Compose(dragLayers);
|
||
// 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). Retail's RenderIcons
|
||
// calls the SURFACE overload of SurfaceWindow::ReplaceColor (0x004415b0), copying
|
||
// the textured effect tile per-pixel into the icon's pure-white pixels — so
|
||
// magical items take the tile's GRADIENT hue and mundane items go solid black.
|
||
// (Visually confirmed against retail 2026-06-17: the Energy Crystal's blue is a
|
||
// gradient, not a flat tint, and the no-mana scroll's edges are black.)
|
||
if (TryGetEffectTile(effects, out var tile))
|
||
ReplaceWhiteFromSurface(composed.rgba, composed.w, composed.h,
|
||
tile.Rgba8, tile.Width, tile.Height);
|
||
drag = composed;
|
||
}
|
||
|
||
// Stage 2 — retail m_pIcon: type-default underlay (opaque) + custom underlay + drag.
|
||
var layers = new List<(byte[] rgba, int w, int h)>();
|
||
AddLayer(layers, typeUnderlayDid);
|
||
AddLayer(layers, underlayId);
|
||
if (drag is { } d) layers.Add(d);
|
||
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<RenderSurface>(renderSurfaceId, out var rs) &&
|
||
!_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
|
||
return;
|
||
var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
|
||
layers.Add((decoded.Rgba8, decoded.Width, decoded.Height));
|
||
}
|
||
}
|