diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 709822ba..385e1cb1 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2002,7 +2002,7 @@ public sealed class GameWindow : IDisposable _toolbarController = AcDream.App.UI.Layout.ToolbarController.Bind( toolbarLayout, Items, () => Shortcuts, - iconIds: (type, icon, under, over) => iconComposer.GetIcon(type, icon, under, over), + iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects), useItem: guid => UseItemByGuid(guid), combatState: Combat, peaceDigits: toolbarPeaceDigits, @@ -2646,7 +2646,7 @@ public sealed class GameWindow : IDisposable // WeenieHeader tail so IconComposer composites all icon layers. Items.EnrichItem(spawn.Guid, spawn.IconId, spawn.Name ?? string.Empty, (AcDream.Core.Items.ItemType)(spawn.ItemType ?? 0), - spawn.IconOverlayId, spawn.IconUnderlayId); + spawn.IconOverlayId, spawn.IconUnderlayId, spawn.UiEffects); // Phase A.1 hotfix: live CreateObject handler reads dats extensively // (Setup, GfxObj, Surface, SurfaceTexture) to hydrate the spawned diff --git a/src/AcDream.App/UI/IconComposer.cs b/src/AcDream.App/UI/IconComposer.cs index 68b59ffa..a0182b1f 100644 --- a/src/AcDream.App/UI/IconComposer.cs +++ b/src/AcDream.App/UI/IconComposer.cs @@ -33,7 +33,7 @@ public sealed class IconComposer { private readonly DatCollection _dats; private readonly TextureCache _cache; - private readonly Dictionary<(uint, uint, uint, uint), uint> _byTuple = new(); + 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). @@ -210,26 +210,42 @@ public sealed class IconComposer } /// - /// Resolve (and cache) the composited GL texture for an item's icon layers. - /// Returns 0 if no base icon is available. - /// - /// 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. + /// 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 2-stage form is + /// associative-equivalent to a single Compose when effects==0, so D.5.1 visuals are + /// unchanged for non-effect items. /// - public uint GetIcon(ItemType itemType, uint iconId, uint underlayId, uint overlayId) + 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); + 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 only when an effect bit is set. Retail nominally also runs the + // effects==0 black-fallback recolor; we skip it (DR-3: white→black on every item + // is a likely no-op but a regression risk, pending visual/cdb confirmation). + if (effects != 0 && TryGetEffectColor(effects, out var ec)) + ReplaceColorWhite(composed.rgba, composed.w, composed.h, ec); + 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); // OPAQUE bottom; sizes the 32x32 output + AddLayer(layers, typeUnderlayDid); AddLayer(layers, underlayId); - AddLayer(layers, iconId); - AddLayer(layers, overlayId); + if (drag is { } d) layers.Add(d); if (layers.Count == 0) return 0; var (rgba, w, h) = Compose(layers); diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index 5ebd61da..f33ddfe2 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -51,7 +51,7 @@ public sealed class ToolbarController private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length]; private readonly ItemRepository _repo; private readonly Func> _shortcuts; - private readonly Func _iconIds; // (itemType, iconId, underlayId, overlayId) → GL tex + private readonly Func _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex private readonly Action _useItem; // guid → fire UseObject // Digit sprite DID arrays for slot labels (top row, numbers 1-9). @@ -70,7 +70,7 @@ public sealed class ToolbarController ImportedLayout layout, ItemRepository repo, Func> shortcuts, - Func iconIds, + Func iconIds, Action useItem, CombatState? combatState, uint[]? peaceDigits, @@ -123,7 +123,7 @@ public sealed class ToolbarController /// Imported toolbar layout (LayoutDesc 0x21000016). /// Live item repository — must stay alive for the controller's lifetime. /// Provider for the current shortcut bar list. - /// Resolves (itemType, iconId, underlayId, overlayId) → GL texture handle. + /// Resolves (itemType, iconId, underlayId, overlayId, effects) → GL texture handle. /// Callback fired when a bound slot is clicked; receives the item guid. /// /// Optional live combat state — when provided, the toolbar subscribes to @@ -148,7 +148,7 @@ public sealed class ToolbarController ImportedLayout layout, ItemRepository repo, Func> shortcuts, - Func iconIds, + Func iconIds, Action useItem, CombatState? combatState = null, uint[]? peaceDigits = null, @@ -183,7 +183,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.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId); + uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects); list.Cell.SetItem(sc.ObjectGuid, tex); } diff --git a/tests/AcDream.App.Tests/UI/IconComposerTests.cs b/tests/AcDream.App.Tests/UI/IconComposerTests.cs index 2e4ad458..a953e843 100644 --- a/tests/AcDream.App.Tests/UI/IconComposerTests.cs +++ b/tests/AcDream.App.Tests/UI/IconComposerTests.cs @@ -144,4 +144,17 @@ public class IconComposerTests Assert.Equal(new byte[] { 255, 255, 255, 0 }, px[8..12]); // untouched Assert.Equal(new byte[] { 10, 20, 30, 255 }, px[12..16]); // replaced } + + [Fact] + public void TwoStageWithEffect_recolorsWhiteBeforeUnderlay() + { + // drag = base (white pixel) over overlay (none); recolor white→blue; then over + // an opaque tawny underlay. The white pixel must become blue in the final. + var baseIcon = (new byte[] { 255,255,255,255 }, 1, 1); // 1x1 white opaque + var drag = IconComposer.Compose(new[] { baseIcon }); + IconComposer.ReplaceColorWhite(drag.rgba, drag.w, drag.h, (0, 0, 255, 255)); // blue + var underlay = (new byte[] { 105, 70, 50, 255 }, 1, 1); // tawny opaque + var final = IconComposer.Compose(new[] { underlay, (drag.rgba, drag.w, drag.h) }); + Assert.Equal(new byte[] { 0, 0, 255, 255 }, final.rgba); // blue on top + } }