acdream/src/AcDream.App/UI/IconComposer.cs
Erik 07be994d97 feat: port retail magic lifecycle and retained spell UI
Complete the retail cast-intent, target, component, enchantment, and busy-state paths; mount the DAT-authored spell bar, spellbook, component book, effects panels, and shared panel lifecycle; and add scoped input plus conformance coverage.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-15 10:55:22 +02:00

371 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.Items;
using AcDream.Core.Textures;
using AcDream.Core.Spells;
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();
private readonly Dictionary<(uint, uint, uint), ComposedIcon> _dragByTuple = new();
private readonly Dictionary<uint, uint> _spellIcons = new();
private readonly Dictionary<uint, uint> _componentIcons = new();
private sealed record ComposedIcon(byte[] Rgba, int Width, int Height, uint Texture);
// ── 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 0058d2140058d22c; 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 0058d2140058d22c +
/// 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-&gt;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.
// RenderIcons retains this as a distinct Graphic because the cursor ghost must not
// carry the type/custom underlay that fills an inventory cell.
ComposedIcon? drag = GetOrCreateDragIcon(iconId, overlayId, effects);
// 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 not null) layers.Add((drag.Rgba, drag.Width, drag.Height));
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;
}
/// <summary>
/// Resolve retail's dedicated cursor-drag graphic (<c>IconData::m_pDragIcon</c>): base icon
/// + custom overlay + effect recolor, with neither the type-default nor custom underlay.
/// <c>UIElement_ItemList::PrepareDragIcon</c> obtains exactly this graphic through
/// <c>ACCWeenieObject::GetDragIcon</c> (0x004e2a50 / 0x0058d180).
/// </summary>
public uint GetDragIcon(ItemType itemType, uint iconId, uint underlayId, uint overlayId, uint effects)
{
// itemType/underlayId are deliberately unused: keeping the resolver signature identical
// to GetIcon lets every item-panel binding request the two retail siblings from one model.
_ = itemType;
_ = underlayId;
return iconId == 0 ? 0u : GetOrCreateDragIcon(iconId, overlayId, effects)?.Texture ?? 0u;
}
private ComposedIcon? GetOrCreateDragIcon(uint iconId, uint overlayId, uint effects)
{
var key = (iconId, overlayId, effects);
if (_dragByTuple.TryGetValue(key, out var cached)) return cached;
var dragLayers = new List<(byte[] rgba, int w, int h)>();
AddLayer(dragLayers, iconId);
AddLayer(dragLayers, overlayId);
if (dragLayers.Count == 0) return null;
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);
uint texture = _cache.UploadRgba8(composed.rgba, composed.w, composed.h, nearest: true);
var created = new ComposedIcon(composed.rgba, composed.w, composed.h, texture);
_dragByTuple[key] = created;
return created;
}
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));
}
/// <summary>
/// Retail ClientMagicSystem::CompositeSpellIcon (0x00567550): power-level
/// backing, spell art, reversed/normal recolor, then self/fellow overlay.
/// </summary>
public uint GetSpellIcon(uint spellId)
{
if (_spellIcons.TryGetValue(spellId, out uint cached)) return cached;
DatReaderWriter.DBObjs.SpellTable? table =
_dats.Get<DatReaderWriter.DBObjs.SpellTable>(0x0E00000Eu);
if (table is null || !table.Spells.TryGetValue(spellId, out var spell)) return 0u;
uint power = spell.Components.Count == 0
? 0u
: RetailSpellFormula.DeterminePowerLevelOfComponent(spell.Components[0]);
uint powerBacking = RetailDataIdResolver.Resolve(_dats, power, 0x10000006u);
var layers = new List<(byte[] rgba, int w, int h)>();
AddLayer(layers, powerBacking);
AddLayer(layers, spell.Icon);
if (layers.Count == 0) return 0u;
var composed = Compose(layers);
uint tintIndex = (spell.Bitfield & DatReaderWriter.Enums.SpellIndex.Reversed) != 0
? 1u : 2u;
uint tintDid = RetailDataIdResolver.Resolve(_dats, tintIndex, 0x10000007u);
if (TryDecode(tintDid, out DecodedTexture tint))
ReplaceWhiteFromSurface(composed.rgba, composed.w, composed.h,
tint.Rgba8, tint.Width, tint.Height);
uint overlayIndex = (spell.Bitfield & DatReaderWriter.Enums.SpellIndex.FellowshipSpell) != 0
? 4u
: (spell.Bitfield & DatReaderWriter.Enums.SpellIndex.SelfTargeted) != 0
? 3u : 0u;
if (overlayIndex != 0u)
{
uint overlayDid = RetailDataIdResolver.Resolve(_dats, overlayIndex, 0x10000007u);
if (TryDecode(overlayDid, out DecodedTexture overlay))
composed = Compose([
(composed.rgba, composed.w, composed.h),
(overlay.Rgba8, overlay.Width, overlay.Height)]);
}
uint texture = _cache.UploadRgba8(composed.rgba, composed.w, composed.h, nearest: true);
_spellIcons[spellId] = texture;
return texture;
}
/// <summary>
/// Retail ClientMagicSystem::CompositeSpellComponentIcon (0x00567720).
/// Components use their raw DAT art with pure white replaced by black.
/// </summary>
public uint GetSpellComponentIcon(uint iconId)
{
if (iconId == 0u) return 0u;
if (_componentIcons.TryGetValue(iconId, out uint cached)) return cached;
if (!TryDecode(iconId, out DecodedTexture icon)) return 0u;
byte[] rgba = (byte[])icon.Rgba8.Clone();
for (int i = 0; i + 3 < rgba.Length; i += 4)
{
if (rgba[i] != 255 || rgba[i + 1] != 255 || rgba[i + 2] != 255 || rgba[i + 3] != 255)
continue;
rgba[i] = rgba[i + 1] = rgba[i + 2] = 0;
}
uint texture = _cache.UploadRgba8(rgba, icon.Width, icon.Height, nearest: true);
_componentIcons[iconId] = texture;
return texture;
}
}