feat(D.5.1): faithful item-icon type-default underlay (EnumIDMap 0x10000004) — opaque icon backing

Retail IconData::RenderIcons (decomp 407524) builds the icon layer stack bottom→top:
type-default underlay (OPAQUE, Blit_Normal) first, then custom underlay, base icon,
custom overlay.  acdream's IconComposer omitted the type-default underlay, leaving
filled toolbar slots with a transparent background.

Resolution via the two-level EnumIDMap chain that retail uses (DBCache::GetDIDFromEnum
0x413940): Portal.Header.MasterMapId (0x25000000) → master[0x10000004] → submap DID
(0x25000008) → submap[LSB(itemType)+1] → 0x06 RenderSurface underlay DID.  Golden
values confirmed against the live dats: MeleeWeapon→0x060011CB, Armor→0x060011CF,
Clothing→0x060011F3, Jewelry→0x060011D5, None(fallback 0x21)→0x060011D4.

Changes:
- IconComposer: add ResolveUnderlayDid(ItemType)/EnsureUnderlaySubMap (memoised);
  widen cache key from (uint,uint,uint)→(uint,uint,uint,uint); GetIcon gains ItemType
  param and prepends the opaque underlay as layer 0 (Compose sizes to it → fully opaque)
- ToolbarController: widen _iconIds Func from 3-arg to 4-arg; Populate passes item.Type
- GameWindow: update toolbar mount lambda to 4-arg form
- Tests: update ToolbarController test stubs to (_,_,_,_); add
  Compose_opaqueUnderlayFirst_resultIsFullyOpaque (dat-free) and
  ResolveUnderlayDid_goldenValues_matchDat (dat-gated, skip when dats absent)

No divergence-register row existed for this omission; none added (fully ported now).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-17 13:37:53 +02:00
parent bfc452d610
commit f21dbfad80
5 changed files with 156 additions and 23 deletions

View file

@ -1,6 +1,8 @@
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;
@ -9,18 +11,37 @@ 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). 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.
/// 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> _byTuple = new();
private readonly Dictionary<(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 0058d2140058d22c; DBCache::GetDIDFromEnum 0x413940.
private EnumIDMap? _underlaySubMap;
private bool _underlayResolveTried;
private readonly Dictionary<uint, uint> _underlayDidByIndex = new();
public IconComposer(DatCollection dats, TextureCache cache)
{
@ -28,6 +49,41 @@ public sealed class IconComposer
_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>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>
@ -56,15 +112,24 @@ public sealed class IconComposer
return (outp, w, h);
}
/// <summary>Resolve (and cache) the composited GL texture for an item's icon
/// layers. Returns 0 if no base icon is available.</summary>
public uint GetIcon(uint iconId, uint underlayId, uint overlayId)
/// <summary>
/// Resolve (and cache) the composited GL texture for an item's icon layers.
/// Returns 0 if no base icon is available.
///
/// <para>Layer order mirrors retail IconData::RenderIcons (decomp 407524):
/// type-default underlay (OPAQUE) → custom underlay → base icon → custom overlay.
/// The type-default underlay is resolved via the EnumIDMap 0x10000004 chain;
/// its presence ensures filled slots are never transparent.</para>
/// </summary>
public uint GetIcon(ItemType itemType, uint iconId, uint underlayId, uint overlayId)
{
if (iconId == 0) return 0;
var key = (iconId, underlayId, overlayId);
uint typeUnderlayDid = ResolveUnderlayDid(itemType);
var key = (typeUnderlayDid, iconId, underlayId, overlayId);
if (_byTuple.TryGetValue(key, out var tex)) return tex;
var layers = new List<(byte[] rgba, int w, int h)>();
AddLayer(layers, typeUnderlayDid); // OPAQUE bottom; sizes the 32x32 output
AddLayer(layers, underlayId);
AddLayer(layers, iconId);
AddLayer(layers, overlayId);

View file

@ -51,14 +51,14 @@ public sealed class ToolbarController
private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length];
private readonly ItemRepository _repo;
private readonly Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> _shortcuts;
private readonly Func<uint, uint, uint, uint> _iconIds; // (iconId, underlayId, overlayId) → GL tex
private readonly Func<ItemType, uint, uint, uint, uint> _iconIds; // (itemType, iconId, underlayId, overlayId) → GL tex
private readonly Action<uint> _useItem; // guid → fire UseObject
private ToolbarController(
ImportedLayout layout,
ItemRepository repo,
Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> shortcuts,
Func<uint, uint, uint, uint> iconIds,
Func<ItemType, uint, uint, uint, uint> iconIds,
Action<uint> useItem,
CombatState? combatState)
{
@ -105,7 +105,7 @@ public sealed class ToolbarController
/// <param name="layout">Imported toolbar layout (LayoutDesc 0x21000016).</param>
/// <param name="repo">Live item repository — must stay alive for the controller's lifetime.</param>
/// <param name="shortcuts">Provider for the current shortcut bar list.</param>
/// <param name="iconIds">Resolves (iconId, underlayId, overlayId) → GL texture handle.</param>
/// <param name="iconIds">Resolves (itemType, iconId, underlayId, overlayId) → GL texture handle.</param>
/// <param name="useItem">Callback fired when a bound slot is clicked; receives the item guid.</param>
/// <param name="combatState">
/// Optional live combat state — when provided, the toolbar subscribes to
@ -117,7 +117,7 @@ public sealed class ToolbarController
ImportedLayout layout,
ItemRepository repo,
Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> shortcuts,
Func<uint, uint, uint, uint> iconIds,
Func<ItemType, uint, uint, uint, uint> iconIds,
Action<uint> useItem,
CombatState? combatState = null)
{
@ -148,7 +148,7 @@ public sealed class ToolbarController
var item = _repo.GetItem(sc.ObjectGuid);
if (item is null) continue; // deferred: ItemAdded will re-call Populate
uint tex = _iconIds(item.IconId, item.IconUnderlayId, item.IconOverlayId);
uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId);
list.Cell.SetItem(sc.ObjectGuid, tex);
}
}