From b8626401add6aa1e0b74d570f8dd1e4732f1aa3c Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 11:00:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20D.2b=20empty-slot=20art=20=E2=80=94?= =?UTF-8?q?=20ItemListCellTemplate=20resolver=20(0x1000000e=20->=200x21000?= =?UTF-8?q?037)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports UIElement_ItemList::InternalCreateItem (0x004e3570) attribute lookup to a testable static helper. The 0x1000000e property is an EnumBaseProperty (not DataIdBaseProperty as the spec anticipated) — discovered by runtime reflection and confirmed against the live dat. Pinned sprite ids from real-dat test: contents (0x100001C6): 0x06004D20 side-bag (0x100001CA): 0x06005D9C main-pack (0x100001C9): 0x06005D9C Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UI/Layout/ItemListCellTemplate.cs | 138 ++++++++++++++++++ .../UI/Layout/ItemListCellTemplateTests.cs | 59 ++++++++ 2 files changed, 197 insertions(+) create mode 100644 src/AcDream.App/UI/Layout/ItemListCellTemplate.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs diff --git a/src/AcDream.App/UI/Layout/ItemListCellTemplate.cs b/src/AcDream.App/UI/Layout/ItemListCellTemplate.cs new file mode 100644 index 00000000..2e03db1f --- /dev/null +++ b/src/AcDream.App/UI/Layout/ItemListCellTemplate.cs @@ -0,0 +1,138 @@ +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.UI.Layout; + +/// +/// Resolves the empty-slot background sprite for a UIElement_ItemList's cells the way retail +/// does. Retail ref: UIElement_ItemList::InternalCreateItem (acclient_2013_pseudo_c.txt:231486 / +/// 0x004e3570): GetAttribute_Enum(this, 0x1000000e, &id) -> GetByEnum(0x10000038,5,0x23) [the +/// shared UIItem catalog LayoutDesc, = 0x21000037] -> CreateChildElement(catalog, id); the cloned +/// prototype's ItemSlot_Empty (state 0x1000001c) media is the empty-cell background. +/// +public static class ItemListCellTemplate +{ + /// The shared UIItem cell-template catalog. Hardcoded: retail resolves it via + /// GetByEnum(0x10000038,5,0x23) through a master enum-map DAT object (no code literal); + /// 0x21000037 is the only LayoutDesc that is a catalog of standalone UIItem (0x10000032) + /// prototypes. Validated by the real-dat test (the resolved sprite must be a real 0x06 surface). + public const uint CatalogLayoutId = 0x21000037u; + + private const uint CellTemplateAttr = 0x1000000eu; // UIElement attribute: cell-template element id + private const uint IconChildId = 0x1000033Bu; // m_elem_Icon sub-element (carries ItemSlot_Empty) + private const string ItemSlotEmpty = "ItemSlot_Empty"; // UIStateId.ToString() for state 0x1000001c + + /// + /// Resolve the empty-slot sprite (a 0x06xxxxxx RenderSurface id) for the cells of item-list + /// element in LayoutDesc . + /// Returns 0 when the layout/element/attribute/prototype/media is absent (the caller keeps + /// the default). + /// + public static uint ResolveEmptySprite(DatCollection dats, uint listLayoutId, uint listElementId) + { + var listLd = dats.Get(listLayoutId); + if (listLd is null) return 0; + var listElem = FindDesc(listLd, listElementId); + if (listElem is null) return 0; + + uint protoId = ReadCellTemplateId(listElem); // attr 0x1000000e + if (protoId == 0) return 0; + + var catalog = dats.Get(CatalogLayoutId); + if (catalog is null) return 0; + var proto = FindDesc(catalog, protoId); + if (proto is null) return 0; + + // Priority: a child's DirectState background frame (the 36x36 backpack cell's recessed + // border, e.g. 0x06005D9C); else the m_elem_Icon child's ItemSlot_Empty media (the 32x32 + // contents cell, e.g. 0x06004D20). See the spec's explicit single-sprite contract. + uint frame = FirstChildDirectStateImage(proto); + return frame != 0 ? frame : IconChildEmptyImage(proto); + } + + // ── attribute 0x1000000e: stored as EnumBaseProperty in the dat (Value is the prototype id) ── + // Note: the spec anticipated DataIdBaseProperty/ArrayBaseProperty based on the font-DID pattern, + // but the live dat uses EnumBaseProperty.Value (uint) — confirmed by runtime reflection. + private static uint ReadCellTemplateId(ElementDesc elem) + { + uint id = ReadIdFromState(elem.StateDesc); + if (id != 0) return id; + foreach (var s in elem.States) + { + id = ReadIdFromState(s.Value); + if (id != 0) return id; + } + return 0; + } + + private static uint ReadIdFromState(StateDesc? sd) + { + if (sd?.Properties is null) return 0; + return sd.Properties.TryGetValue(CellTemplateAttr, out var raw) ? ReadId(raw) : 0; + } + + private static uint ReadId(BaseProperty raw) + { + if (raw is ArrayBaseProperty arr && arr.Value.Count > 0) return ReadId(arr.Value[0]); + if (raw is DataIdBaseProperty did) return did.Value; + if (raw is EnumBaseProperty ep) return ep.Value; // 0x1000000e is EnumBaseProperty in the live dat + return 0; + } + + // ── prototype media ── + private static uint FirstChildDirectStateImage(ElementDesc proto) + { + foreach (var kv in proto.Children) + { + uint f = DirectStateImage(kv.Value); + if (f != 0) return f; + } + return 0; + } + + private static uint IconChildEmptyImage(ElementDesc proto) + { + var icon = FindDescIn(proto, IconChildId); // recursive: handles inner wrappers (e.g. 0x10000340) + if (icon is null) return 0; + foreach (var s in icon.States) + if (s.Key.ToString() == ItemSlotEmpty) + { + uint f = StateImage(s.Value); + if (f != 0) return f; + } + return DirectStateImage(icon); // some prototypes carry empty media on the DirectState + } + + private static uint DirectStateImage(ElementDesc d) + => d.StateDesc is null ? 0u : StateImage(d.StateDesc); + + private static uint StateImage(StateDesc sd) + { + foreach (var m in sd.Media) + if (m is MediaDescImage img && img.File != 0) return img.File; + return 0; + } + + // ── depth-first element search by id (LayoutImporter.FindDesc is private there) ── + private static ElementDesc? FindDesc(LayoutDesc ld, uint id) + { + foreach (var kv in ld.Elements) + { + var f = FindDescIn(kv.Value, id); + if (f is not null) return f; + } + return null; + } + + private static ElementDesc? FindDescIn(ElementDesc d, uint id) + { + if (d.ElementId == id) return d; + foreach (var kv in d.Children) + { + var f = FindDescIn(kv.Value, id); + if (f is not null) return f; + } + return null; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs b/tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs new file mode 100644 index 00000000..898f9aac --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs @@ -0,0 +1,59 @@ +using System; +using System.IO; +using AcDream.App.UI.Layout; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Real-dat test for : the inventory +/// item-lists must resolve their empty-slot sprite from the dat cell template (attribute +/// 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty), NOT the generic toolbar +/// square 0x060074CF. Also PRINTS the resolved ids — the "pin the exact asset" record. +/// Skips when the live dat directory is absent (CI). +/// +public class ItemListCellTemplateTests +{ + private const uint Generic = 0x060074CFu; // generic toolbar-shortcut empty square (the bug) + + private const uint ContentsLayout = 0x21000021u; private const uint ContentsGrid = 0x100001C6u; + private const uint BackpackLayout = 0x21000022u; private const uint SideBag = 0x100001CAu; + private const uint MainPack = 0x100001C9u; + + private readonly ITestOutputHelper _out; + public ItemListCellTemplateTests(ITestOutputHelper o) => _out = o; + + private static string? DatDir() + { + var d = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + return Directory.Exists(d) ? d : null; + } + + [Fact] + public void Inventory_lists_resolve_a_real_nongeneric_empty_sprite() + { + var datDir = DatDir(); + if (datDir is null) return; // CI: no live dat — skip (smoke test) + + using var dats = new DatCollection(datDir, DatAccessType.Read); + + foreach (var (name, layout, elem) in new[] + { + ("contents", ContentsLayout, ContentsGrid), + ("side-bag", BackpackLayout, SideBag), + ("main-pack", BackpackLayout, MainPack), + }) + { + uint sprite = ItemListCellTemplate.ResolveEmptySprite(dats, layout, elem); + _out.WriteLine($"{name}: list 0x{elem:X8} (layout 0x{layout:X8}) -> empty sprite 0x{sprite:X8}"); + Assert.True(sprite != 0u, $"{name}: resolved 0 (no 0x1000000e attr / prototype / media)"); + Assert.True(sprite != Generic, $"{name}: resolved the generic 0x060074CF — the bug, not the fix"); + Assert.True((sprite & 0xFF000000u) == 0x06000000u, $"{name}: 0x{sprite:X8} is not a 0x06 RenderSurface id"); + } + } +}