diff --git a/docs/plugin-ui-markup.md b/docs/plugin-ui-markup.md index 77030479..cf711a5f 100644 --- a/docs/plugin-ui-markup.md +++ b/docs/plugin-ui-markup.md @@ -201,6 +201,13 @@ and `item` are almost always bindings (`spell="{SpellId}"`, resolving to 0, or the resolver returning no texture, draws nothing — never a placeholder, never a throw. +A `did` icon is **not blitted raw**. It is drawn the way retail draws every +icon it composites (`IconData::RenderIcons` with no overlay and no effects): +the art's pure-white pixels are the DAT's "recolor me" key and are replaced +with the solid-black fallback tile, exactly as a mundane item in the inventory. +Raw art shows a white ring around the icon (Decal's `HudPictureBox` draws it +that way); acdream does not. Art without any pure-white pixel is unaffected. + ### `` ```xml diff --git a/src/AcDream.App/UI/IMarkupIconResolver.cs b/src/AcDream.App/UI/IMarkupIconResolver.cs index c1475992..12e86b37 100644 --- a/src/AcDream.App/UI/IMarkupIconResolver.cs +++ b/src/AcDream.App/UI/IMarkupIconResolver.cs @@ -66,7 +66,6 @@ public interface IMarkupIconResolver public sealed class RetailMarkupIconResolver : IMarkupIconResolver { private readonly IDatReaderWriter _dats; - private readonly TextureCache _textureCache; private readonly IconComposer _icons; private readonly ClientObjectTable _objects; @@ -114,12 +113,10 @@ public sealed class RetailMarkupIconResolver : IMarkupIconResolver public RetailMarkupIconResolver( IDatReaderWriter dats, - TextureCache textureCache, IconComposer icons, ClientObjectTable objects) { _dats = dats ?? throw new ArgumentNullException(nameof(dats)); - _textureCache = textureCache ?? throw new ArgumentNullException(nameof(textureCache)); _icons = icons ?? throw new ArgumentNullException(nameof(icons)); _objects = objects ?? throw new ArgumentNullException(nameof(objects)); } @@ -137,12 +134,14 @@ public sealed class RetailMarkupIconResolver : IMarkupIconResolver /// (DatDatabaseWrapper.TryGet's _databaseLock) — the same /// synchronization relies on for /// every one of its own DAT reads — so no additional lock is taken here. - /// Requests the NEAREST sampler (Slice B finding 6): plugin icon art is - /// pixel-exact 32x32 DAT art, the same convention every other icon in the - /// client draws with (dat-font glyphs, composited spell/item icons), and - /// keys its render-surface cache by - /// (id, nearest) so this never collides with a chrome sprite's - /// linear sampling of the same id. + /// A resolvable id is NOT blitted raw: it goes through + /// — retail's RenderIcons + /// composite with no overlay and no effects, which replaces the art's + /// pure-white keyed pixels with the effects==0 solid-black tile. Item, + /// spell and component art reserve pure white as that key, so a raw blit + /// shows a white ring (owner report 2026-09-06, the MossTank shelf icon); + /// the composed texture is uploaded nearest-sampled by the composer, the + /// same convention every other icon in the client draws with. /// public (uint tex, int w, int h) ResolveDid(uint did) { @@ -161,9 +160,8 @@ public sealed class RetailMarkupIconResolver : IMarkupIconResolver } else { - uint tex = _textureCache.GetOrUploadRenderSurface(did, out int w, out int h, nearest: true); - result = (tex, w, h); - isMiss = false; + result = _icons.GetKeyedIcon(did); + isMiss = result.tex == 0u; } _resolvedDidCache[did] = result; diff --git a/src/AcDream.App/UI/IconComposer.cs b/src/AcDream.App/UI/IconComposer.cs index 77dacf2b..d767409b 100644 --- a/src/AcDream.App/UI/IconComposer.cs +++ b/src/AcDream.App/UI/IconComposer.cs @@ -264,6 +264,46 @@ public sealed class IconComposer return iconId == 0 ? 0u : GetOrCreateDragIcon(iconId, overlayId, effects)?.Texture ?? 0u; } + /// + /// A bare RenderSurface icon drawn the way retail draws EVERY icon it composites + /// (IconData::RenderIcons 0x0058d180 with no custom overlay and no effects): + /// the art, then SurfaceWindow::ReplaceColor of its pure-white keyed pixels + /// from the effects==0 fallback tile (the solid-black 0x21 tile 0x060011C5). Item, + /// spell and component art in the DAT reserve pure white as the "recolor me" key — + /// blitting such art raw shows a white ring around the icon (owner report + /// 2026-09-06 on the plugin shelf's MossTank icon; the same defect the inventory + /// had before the effect recolor landed). This is exactly the drag-icon composite + /// () with neither overlay nor effects, so it shares that + /// cache. Used by the plugin markup did sink and the plugin shelf. + /// Returns (0, 0, 0) when the surface does not exist. + /// + public (uint tex, int w, int h) GetKeyedIcon(uint iconId) + { + if (iconId == 0u) return (0u, 0, 0); + ComposedIcon? icon = GetOrCreateDragIcon(iconId, overlayId: 0u, effects: 0u); + return icon is null ? (0u, 0, 0) : (icon.Texture, icon.Width, icon.Height); + } + + /// Test seam for : the composed RGBA8 pixels. + internal bool TryGetKeyedIconRgba(uint iconId, out byte[] rgba, out int w, out int h) + { + rgba = Array.Empty(); w = 0; h = 0; + if (iconId == 0u) return false; + ComposedIcon? icon = GetOrCreateDragIcon(iconId, overlayId: 0u, effects: 0u); + if (icon is null) return false; + rgba = icon.Rgba; w = icon.Width; h = icon.Height; + return true; + } + + /// Test seam: the raw decoded RGBA8 of one RenderSurface, no compositing. + internal bool TryDecodeRaw(uint renderSurfaceId, out byte[] rgba, out int w, out int h) + { + rgba = Array.Empty(); w = 0; h = 0; + if (!TryDecode(renderSurfaceId, out DecodedTexture decoded)) return false; + rgba = decoded.Rgba8; w = decoded.Width; h = decoded.Height; + return true; + } + private ComposedIcon? GetOrCreateDragIcon(uint iconId, uint overlayId, uint effects) { var key = (iconId, overlayId, effects); diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 46c8ea1a..02600df3 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -4726,7 +4726,6 @@ public sealed class RetailUiRuntime : IDisposable // existing bindings field supplies the object table. IMarkupIconResolver iconResolver = new RetailMarkupIconResolver( _bindings.Assets.Dats, - _bindings.Assets.TextureCache, _bindings.Assets.Icons, _bindings.Toolbar.Objects); diff --git a/tests/AcDream.App.Tests/UI/KeyedIconInstalledDatTests.cs b/tests/AcDream.App.Tests/UI/KeyedIconInstalledDatTests.cs new file mode 100644 index 00000000..ea4a4712 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/KeyedIconInstalledDatTests.cs @@ -0,0 +1,74 @@ +using AcDream.App.Rendering; +using AcDream.App.Tests.Rendering; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.App.UI; +using AcDream.Content; +using AcDream.Core.Items; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; + +namespace AcDream.App.Tests.UI; + +/// +/// Owner report 2026-09-06: the MossTank shelf icon (0x06002C41) drew +/// with a white ring. DAT icon art reserves pure-white-opaque pixels as the +/// "recolor me" key that retail IconData::RenderIcons replaces per pixel +/// through SurfaceWindow::ReplaceColor (effects==0 → the solid-black +/// 0x21 tile); blitting the art raw leaves the key visible. The plugin +/// did sink (, also the +/// shelf button's path) therefore goes through +/// , the drag-icon composite with no +/// overlay and no effects. This pins both halves: the raw art really does +/// carry the key (so the test is not vacuous), and the composed result has +/// none of it left. +/// +[Trait("Lane", "InstalledDat")] +public sealed class KeyedIconInstalledDatTests +{ + private const uint MossTankShelfIconId = 0x06002C41u; + + [Fact] + public void KeyedIcon_ReplacesTheArtsPureWhiteKey_AndResolveDidUsesIt() + { + string? datDir = InstalledDatTestPath.Resolve(); + if (datDir is null) + { + Assert.Fail( + "Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + var device = new RecordingGpuDevice(); + using var cache = new TextureCache(device, adapter); + var icons = new IconComposer(adapter, cache); + + Assert.True(icons.TryDecodeRaw(MossTankShelfIconId, out byte[] raw, out int rw, out int rh)); + Assert.True( + CountPureWhite(raw) > 0, + "the raw art must carry pure-white keyed pixels, or this pin proves nothing"); + + Assert.True(icons.TryGetKeyedIconRgba(MossTankShelfIconId, out byte[] keyed, out int kw, out int kh)); + Assert.Equal((rw, rh), (kw, kh)); + Assert.Equal(0, CountPureWhite(keyed)); + + // The markup/shelf did sink must hand out exactly the keyed composite. + var resolver = new RetailMarkupIconResolver(adapter, icons, new ClientObjectTable()); + (uint tex, int w, int h) = resolver.ResolveDid(MossTankShelfIconId); + Assert.Equal(icons.GetKeyedIcon(MossTankShelfIconId), (tex, w, h)); + Assert.NotEqual(0u, tex); + } + + private static int CountPureWhite(byte[] rgba) + { + int count = 0; + 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) + count++; + } + return count; + } +} diff --git a/tests/AcDream.App.Tests/UI/MossTankIconInstalledDatTests.cs b/tests/AcDream.App.Tests/UI/MossTankIconInstalledDatTests.cs index fa6e6fdb..ba5d3331 100644 --- a/tests/AcDream.App.Tests/UI/MossTankIconInstalledDatTests.cs +++ b/tests/AcDream.App.Tests/UI/MossTankIconInstalledDatTests.cs @@ -56,7 +56,7 @@ public sealed class MossTankIconInstalledDatTests using var cache = new TextureCache(device, adapter); var icons = new IconComposer(adapter, cache); var objects = new ClientObjectTable(); - var resolver = new RetailMarkupIconResolver(adapter, cache, icons, objects); + var resolver = new RetailMarkupIconResolver(adapter, icons, objects); (uint tex, int w, int h) = resolver.ResolveDid(MossTankShelfIconId); Assert.NotEqual(0u, tex); diff --git a/tests/AcDream.App.Tests/UI/RetailMarkupIconResolverInstalledDatTests.cs b/tests/AcDream.App.Tests/UI/RetailMarkupIconResolverInstalledDatTests.cs index 2079db28..9603d0a6 100644 --- a/tests/AcDream.App.Tests/UI/RetailMarkupIconResolverInstalledDatTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailMarkupIconResolverInstalledDatTests.cs @@ -57,7 +57,7 @@ public sealed class RetailMarkupIconResolverInstalledDatTests using var cache = new TextureCache(device, adapter); var icons = new IconComposer(adapter, cache); var objects = new ClientObjectTable(); - var resolver = new RetailMarkupIconResolver(adapter, cache, icons, objects); + var resolver = new RetailMarkupIconResolver(adapter, icons, objects); (uint missTex, int missW, int missH) = resolver.ResolveDid(DecalHabitDoubleNormalizedId); Assert.Equal((0u, 0, 0), (missTex, missW, missH)); @@ -99,7 +99,7 @@ public sealed class RetailMarkupIconResolverInstalledDatTests using var cache = new TextureCache(device, adapter); var icons = new IconComposer(adapter, cache); var objects = new ClientObjectTable(); - var resolver = new RetailMarkupIconResolver(adapter, cache, icons, objects); + var resolver = new RetailMarkupIconResolver(adapter, icons, objects); var root = new UiRoot { Width = 800f, Height = 600f }; using var shelf = new PluginSidePanel( diff --git a/tests/AcDream.App.Tests/UI/RetailMarkupIconResolverMemoizationTests.cs b/tests/AcDream.App.Tests/UI/RetailMarkupIconResolverMemoizationTests.cs index 5efda26b..47d4394c 100644 --- a/tests/AcDream.App.Tests/UI/RetailMarkupIconResolverMemoizationTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailMarkupIconResolverMemoizationTests.cs @@ -54,7 +54,7 @@ public sealed class RetailMarkupIconResolverMemoizationTests using var cache = new TextureCache(device, dats); var icons = new IconComposer(dats, cache); var objects = new ClientObjectTable(); - var resolver = new RetailMarkupIconResolver(dats, cache, icons, objects); + var resolver = new RetailMarkupIconResolver(dats, icons, objects); (uint tex1, int w1, int h1) = resolver.ResolveDid(DecalHabitDoubleNormalizedId); (uint tex2, int w2, int h2) = resolver.ResolveDid(DecalHabitDoubleNormalizedId); @@ -79,7 +79,7 @@ public sealed class RetailMarkupIconResolverMemoizationTests using var cache = new TextureCache(device, dats); var icons = new IconComposer(dats, cache); var objects = new ClientObjectTable(); - var resolver = new RetailMarkupIconResolver(dats, cache, icons, objects); + var resolver = new RetailMarkupIconResolver(dats, icons, objects); resolver.ResolveDid(0x06000001u); resolver.ResolveDid(0x06000002u); @@ -108,7 +108,7 @@ public sealed class RetailMarkupIconResolverMemoizationTests using var cache = new TextureCache(device, dats); var icons = new IconComposer(dats, cache); var objects = new ClientObjectTable(); - var resolver = new RetailMarkupIconResolver(dats, cache, icons, objects); + var resolver = new RetailMarkupIconResolver(dats, icons, objects); // Fill the 256-entry miss cache with distinct ids (starting at 1; // did == 0 short-circuits before ever touching the DAT).