Visual gate (2026-06-22) showed the side-bag/main-pack empty cells drawing yellow triangles. Root cause: the frame-first heuristic grabbed the 36x36 container prototype's DirectState child 0x06005D9C — which is the open/SELECTED-container triangle indicator, NOT a background frame — and stamped it onto every empty cell. Fix: drop frame-first; FindIconEmpty resolves the inner m_elem_Icon ItemSlot_Empty THROUGH BaseElement inheritance (0x1000033F -> 0x10000340 -> base 0x1000033E -> 0x1000033B -> 0x06000F6E), so containers get the dark slot background matching the inventory. Pin test now asserts exact ids (0x06004D20 contents / 0x06000F6E containers) — the old structural-only assertion let the wrong-but-valid 0x06005D9C pass (Opus review Minor 2, now vindicated). The triangle + green/yellow selection square is deferred to container-switching (AP-56 reworded). Full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
6.9 KiB
C#
159 lines
6.9 KiB
C#
using System.Collections.Generic;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Types;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static class ItemListCellTemplate
|
|
{
|
|
/// <summary>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).</summary>
|
|
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
|
|
|
|
/// <summary>
|
|
/// Resolve the empty-slot sprite (a 0x06xxxxxx RenderSurface id) for the cells of item-list
|
|
/// element <paramref name="listElementId"/> in LayoutDesc <paramref name="listLayoutId"/>.
|
|
/// Returns 0 when the layout/element/attribute/prototype/media is absent (the caller keeps
|
|
/// the <see cref="UiItemSlot"/> default).
|
|
/// </summary>
|
|
public static uint ResolveEmptySprite(DatCollection dats, uint listLayoutId, uint listElementId)
|
|
{
|
|
var listLd = dats.Get<LayoutDesc>(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<LayoutDesc>(CatalogLayoutId);
|
|
if (catalog is null) return 0;
|
|
var proto = FindDesc(catalog, protoId);
|
|
if (proto is null) return 0;
|
|
|
|
// The empty-slot BACKGROUND is the m_elem_Icon's ItemSlot_Empty media, resolved THROUGH
|
|
// BaseElement inheritance: the 36x36 container prototype (0x1000033F) nests its icon behind
|
|
// an inner wrapper (0x10000340 -> base 0x1000033E -> 0x1000033B -> 0x06000F6E), so a raw
|
|
// child search alone returns nothing for containers. We deliberately do NOT use the
|
|
// prototype's DirectState child overlay: on the container prototype that child is the
|
|
// open/selected-container TRIANGLE indicator (0x06005D9C), which retail draws ONLY on the
|
|
// selected container (a deferred container-selection feature) — never as empty-cell art.
|
|
// (Live visual gate 2026-06-22: frame-first stamped the triangle onto every empty cell.)
|
|
return FindIconEmpty(catalog, proto, new HashSet<uint>());
|
|
}
|
|
|
|
// ── 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: the m_elem_Icon (0x1000033B) ItemSlot_Empty, resolved through inheritance ──
|
|
// Find the icon child's empty media within `element`'s subtree, following BaseElement edges
|
|
// within the same catalog (cycle-guarded by `baseSeen`). The 32x32 contents prototype carries
|
|
// 0x1000033B as a direct child; the 36x36 container prototype reaches it only via an inherited
|
|
// base (0x10000340 -> 0x1000033E), so a raw child search alone returns nothing for containers.
|
|
private static uint FindIconEmpty(LayoutDesc catalog, ElementDesc element, HashSet<uint> baseSeen)
|
|
{
|
|
if (element.ElementId == IconChildId)
|
|
{
|
|
uint e = IconEmptyState(element);
|
|
if (e != 0) return e;
|
|
}
|
|
foreach (var kv in element.Children)
|
|
{
|
|
uint e = FindIconEmpty(catalog, kv.Value, baseSeen);
|
|
if (e != 0) return e;
|
|
}
|
|
if (element.BaseElement != 0 && element.BaseLayoutId == CatalogLayoutId && baseSeen.Add(element.BaseElement))
|
|
{
|
|
var baseDesc = FindDesc(catalog, element.BaseElement);
|
|
if (baseDesc is not null)
|
|
{
|
|
uint e = FindIconEmpty(catalog, baseDesc, baseSeen);
|
|
if (e != 0) return e;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
private static uint IconEmptyState(ElementDesc icon)
|
|
{
|
|
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 icons 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;
|
|
}
|
|
}
|