fix(ui): D.2b empty-slot art — container cells use inner slot bg, not the selected-container triangle
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>
This commit is contained in:
parent
499485a672
commit
22d92315e5
4 changed files with 53 additions and 16 deletions
|
|
@ -5397,7 +5397,7 @@ The burden bar now reads the server's wire `EncumbranceVal` (PropertyInt 5) inst
|
||||||
|
|
||||||
**Root cause / status:** `UiItemSlot.EmptySprite` is a single hardcoded default (`0x060074CF`) shared by all empty cells. The inventory/side-bag pack slots need the gm3DItemsUI/gmBackpackUI cell-template (`0x21000037`) empty-state background instead. The equip slots need the `0x10000032` `UIElement_UIItem` registration + per-slot silhouette art (Sub-phase C).
|
**Root cause / status:** `UiItemSlot.EmptySprite` is a single hardcoded default (`0x060074CF`) shared by all empty cells. The inventory/side-bag pack slots need the gm3DItemsUI/gmBackpackUI cell-template (`0x21000037`) empty-state background instead. The equip slots need the `0x10000032` `UIElement_UIItem` registration + per-slot silhouette art (Sub-phase C).
|
||||||
|
|
||||||
**Resolution (inventory portion, 2026-06-22):** Ported retail's `UIElement_ItemList::InternalCreateItem` (`0x004e3570`) resolver into `ItemListCellTemplate.ResolveEmptySprite` — reads attribute `0x1000000e` off each list element → catalog `0x21000037` prototype's `ItemSlot_Empty`. **Correction to the original diagnosis:** `0x060074CF` is the *generic shared* item-slot empty (the `ItemSlot_Empty` of many catalog prototypes), not "the toolbar's"; `0x21000037` is a catalog of ~50 per-slot-kind empties — so it was never a one-constant swap. Pinned per-list (real-dat test): contents `0x100001C6` → `0x06004D20`; side-bag `0x100001CA` + main-pack `0x100001C9` → `0x06005D9C` (the 36×36 frame). `UiItemList.CellEmptySprite` carries it; `InventoryController` + `GameWindow` wire it. Divergence AP-55 (toolbar still hardcoded) + AP-56 (flat single-sprite cell vs retail's layered prototype). Spec/plan `docs/superpowers/{specs,plans}/2026-06-22-d2b-empty-slot-art*.md`.
|
**Resolution (inventory portion, 2026-06-22):** Ported retail's `UIElement_ItemList::InternalCreateItem` (`0x004e3570`) resolver into `ItemListCellTemplate.ResolveEmptySprite` — reads attribute `0x1000000e` off each list element → catalog `0x21000037` prototype's `ItemSlot_Empty`. **Correction to the original diagnosis:** `0x060074CF` is the *generic shared* item-slot empty (the `ItemSlot_Empty` of many catalog prototypes), not "the toolbar's"; `0x21000037` is a catalog of ~50 per-slot-kind empties — so it was never a one-constant swap. Pinned per-list (real-dat test): contents `0x100001C6` → `0x06004D20`; side-bag `0x100001CA` + main-pack `0x100001C9` → `0x06000F6E` (the inner dark slot, resolved through `BaseElement` inheritance). **Visual-gate correction (2026-06-22):** the first cut's frame-first heuristic grabbed the container prototype's DirectState child `0x06005D9C` — which is the open/**selected**-container TRIANGLE indicator, not a background — and stamped it onto every empty container cell. Fixed: resolve the inner `ItemSlot_Empty` through inheritance (`FindIconEmpty`); the triangle + green/yellow selection square is deferred to the container-switching sub-phase (AP-56). `UiItemList.CellEmptySprite` carries it; `InventoryController` + `GameWindow` wire it. Divergence AP-55 (toolbar still hardcoded) + AP-56 (selected-container indicator deferred). Spec/plan `docs/superpowers/{specs,plans}/2026-06-22-d2b-empty-slot-art*.md`.
|
||||||
|
|
||||||
**Files:** `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite`); `src/AcDream.App/UI/Layout/InventoryController.cs` (set the inventory pack-slot empty sprite on the cells it creates); paperdoll equip slots → Sub-phase C.
|
**Files:** `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite`); `src/AcDream.App/UI/Layout/InventoryController.cs` (set the inventory pack-slot empty sprite on the cells it creates); paperdoll equip slots → Sub-phase C.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| AP-53 | Contents grid slot count defaults to 102 when the player's `ItemsCapacity` is absent/0; the grid always shows the main pack (container-switch to a side pack's 24 not wired). | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ItemsCapacity` is not reliably on the player ClientObject yet; 102 is the retail main-pack standard. ViewContents-driven container switching is a later sub-phase. | A non-102 pack shows the wrong empty-slot count; selecting a side pack doesn't show its 24-slot contents yet. | retail main-pack capacity 102; ACE `ItemsCapacity`; `ViewContents 0x0196` (deferred) |
|
| AP-53 | Contents grid slot count defaults to 102 when the player's `ItemsCapacity` is absent/0; the grid always shows the main pack (container-switch to a side pack's 24 not wired). | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ItemsCapacity` is not reliably on the player ClientObject yet; 102 is the retail main-pack standard. ViewContents-driven container switching is a later sub-phase. | A non-102 pack shows the wrong empty-slot count; selecting a side pack doesn't show its 24-slot contents yet. | retail main-pack capacity 102; ACE `ItemsCapacity`; `ViewContents 0x0196` (deferred) |
|
||||||
| AP-54 | Inventory window vertical resize is a toolkit approximation: bottom-edge drag, expand-only (Min = dat default 372 px, Max = 560 px constant), contents grid/sub-window/scrollbar/backdrop stretched via overridden `Left\|Top\|Bottom` anchors. | `src/AcDream.App/Rendering/GameWindow.cs` (inventory frame setup) | retail's gmInventoryUI resize lives in keystone.dll (no decomp); the anchor-stretch + 372..560 range matches the observed retail behavior for the dev loop. | The expand range / which elements reflow could differ from retail (e.g. retail may allow shrink-below-default); shrink is intentionally disabled for now. | retail gmInventoryUI resize (keystone.dll, no decomp); `UiNineSlicePanel` resize |
|
| AP-54 | Inventory window vertical resize is a toolkit approximation: bottom-edge drag, expand-only (Min = dat default 372 px, Max = 560 px constant), contents grid/sub-window/scrollbar/backdrop stretched via overridden `Left\|Top\|Bottom` anchors. | `src/AcDream.App/Rendering/GameWindow.cs` (inventory frame setup) | retail's gmInventoryUI resize lives in keystone.dll (no decomp); the anchor-stretch + 372..560 range matches the observed retail behavior for the dev loop. | The expand range / which elements reflow could differ from retail (e.g. retail may allow shrink-below-default); shrink is intentionally disabled for now. | retail gmInventoryUI resize (keystone.dll, no decomp); `UiNineSlicePanel` resize |
|
||||||
| AP-55 | The toolbar's item slots keep the hardcoded `UiItemSlot.EmptySprite = 0x060074CF` rather than resolving their cell template via attribute `0x1000000e`. Correct in outcome (the toolbar's `0x1000000e` resolves to a generic prototype whose empty media is `0x060074CF`) but not dat-resolved. | `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite` default); `src/AcDream.App/UI/Layout/ToolbarController.cs` | The inventory lists now port the retail resolver (`ItemListCellTemplate`); the toolbar was left on its hardcoded default to avoid regressing frozen, working art. Retire when the toolbar is routed through `ItemListCellTemplate`. | If a future toolbar layout's `0x1000000e` points at a non-generic prototype, the toolbar would show stale art. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; catalog `0x21000037` |
|
| AP-55 | The toolbar's item slots keep the hardcoded `UiItemSlot.EmptySprite = 0x060074CF` rather than resolving their cell template via attribute `0x1000000e`. Correct in outcome (the toolbar's `0x1000000e` resolves to a generic prototype whose empty media is `0x060074CF`) but not dat-resolved. | `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite` default); `src/AcDream.App/UI/Layout/ToolbarController.cs` | The inventory lists now port the retail resolver (`ItemListCellTemplate`); the toolbar was left on its hardcoded default to avoid regressing frozen, working art. Retire when the toolbar is routed through `ItemListCellTemplate`. | If a future toolbar layout's `0x1000000e` points at a non-generic prototype, the toolbar would show stale art. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; catalog `0x21000037` |
|
||||||
| AP-56 | `UiItemSlot` is a flat single-sprite cell; for a prototype with both a frame child and an inner icon (the 36×36 backpack prototype `0x1000033F`: frame `0x06005D9C` + inner `0x06000F6E`), only the dominant background (the frame) is drawn. Retail clones the full prototype subtree (frame + inner layered). | `src/AcDream.App/UI/UiItemSlot.cs`; `src/AcDream.App/UI/Layout/ItemListCellTemplate.cs` (`ResolveEmptySprite` single-sprite contract) | acdream's cell draws one empty sprite; replicating retail's multi-layer prototype needs a layered cell or a subtree clone. Frame-first chosen as the dominant visible art. Revisit at the visual gate / build a layered cell if the backpack slots read wrong. | The 36×36 backpack cells may lack the inner placeholder layer vs retail. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; prototype `0x1000033F` |
|
| AP-56 | The container/main-pack empty cells render only the empty-slot BACKGROUND (the inner `ItemSlot_Empty` `0x06000F6E`, resolved through `BaseElement` inheritance), not the open/selected-container indicator overlay — the triangle `0x06005D9C` + a green/yellow selection square that retail draws on the SELECTED container only. The indicator is deferred to the container-switching sub-phase. | `src/AcDream.App/UI/Layout/ItemListCellTemplate.cs` (`FindIconEmpty`); `src/AcDream.App/UI/Layout/InventoryController.cs` | acdream has no selected-container state yet; the first cut wrongly stamped the `0x06005D9C` triangle onto EVERY empty cell (frame-first heuristic) — omitting it entirely is correct until selection state exists. Retire when container-switching draws the indicator on the selected pack. | Empty container cells show only the dark slot background; the selected container lacks its triangle + selection square until container-switching ships. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; prototype `0x1000033F` child `0x10000450` (`0x06005D9C`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
using DatReaderWriter;
|
using DatReaderWriter;
|
||||||
using DatReaderWriter.DBObjs;
|
using DatReaderWriter.DBObjs;
|
||||||
using DatReaderWriter.Types;
|
using DatReaderWriter.Types;
|
||||||
|
|
@ -44,11 +45,15 @@ public static class ItemListCellTemplate
|
||||||
var proto = FindDesc(catalog, protoId);
|
var proto = FindDesc(catalog, protoId);
|
||||||
if (proto is null) return 0;
|
if (proto is null) return 0;
|
||||||
|
|
||||||
// Priority: a child's DirectState background frame (the 36x36 backpack cell's recessed
|
// The empty-slot BACKGROUND is the m_elem_Icon's ItemSlot_Empty media, resolved THROUGH
|
||||||
// border, e.g. 0x06005D9C); else the m_elem_Icon child's ItemSlot_Empty media (the 32x32
|
// BaseElement inheritance: the 36x36 container prototype (0x1000033F) nests its icon behind
|
||||||
// contents cell, e.g. 0x06004D20). See the spec's explicit single-sprite contract.
|
// an inner wrapper (0x10000340 -> base 0x1000033E -> 0x1000033B -> 0x06000F6E), so a raw
|
||||||
uint frame = FirstChildDirectStateImage(proto);
|
// child search alone returns nothing for containers. We deliberately do NOT use the
|
||||||
return frame != 0 ? frame : IconChildEmptyImage(proto);
|
// 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) ──
|
// ── attribute 0x1000000e: stored as EnumBaseProperty in the dat (Value is the prototype id) ──
|
||||||
|
|
@ -80,28 +85,44 @@ public static class ItemListCellTemplate
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── prototype media ──
|
// ── prototype media: the m_elem_Icon (0x1000033B) ItemSlot_Empty, resolved through inheritance ──
|
||||||
private static uint FirstChildDirectStateImage(ElementDesc proto)
|
// 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)
|
||||||
{
|
{
|
||||||
foreach (var kv in proto.Children)
|
if (element.ElementId == IconChildId)
|
||||||
{
|
{
|
||||||
uint f = DirectStateImage(kv.Value);
|
uint e = IconEmptyState(element);
|
||||||
if (f != 0) return f;
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static uint IconChildEmptyImage(ElementDesc proto)
|
private static uint IconEmptyState(ElementDesc icon)
|
||||||
{
|
{
|
||||||
var icon = FindDescIn(proto, IconChildId); // recursive: handles inner wrappers (e.g. 0x10000340)
|
|
||||||
if (icon is null) return 0;
|
|
||||||
foreach (var s in icon.States)
|
foreach (var s in icon.States)
|
||||||
if (s.Key.ToString() == ItemSlotEmpty)
|
if (s.Key.ToString() == ItemSlotEmpty)
|
||||||
{
|
{
|
||||||
uint f = StateImage(s.Value);
|
uint f = StateImage(s.Value);
|
||||||
if (f != 0) return f;
|
if (f != 0) return f;
|
||||||
}
|
}
|
||||||
return DirectStateImage(icon); // some prototypes carry empty media on the DirectState
|
return DirectStateImage(icon); // some icons carry empty media on the DirectState
|
||||||
}
|
}
|
||||||
|
|
||||||
private static uint DirectStateImage(ElementDesc d)
|
private static uint DirectStateImage(ElementDesc d)
|
||||||
|
|
|
||||||
|
|
@ -56,4 +56,20 @@ public class ItemListCellTemplateTests
|
||||||
Assert.True((sprite & 0xFF000000u) == 0x06000000u, $"{name}: 0x{sprite:X8} is not a 0x06 RenderSurface id");
|
Assert.True((sprite & 0xFF000000u) == 0x06000000u, $"{name}: 0x{sprite:X8} is not a 0x06 RenderSurface id");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Inventory_lists_resolve_the_pinned_retail_sprites()
|
||||||
|
{
|
||||||
|
var datDir = DatDir();
|
||||||
|
if (datDir is null) return; // CI: no live dat — skip
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
// Pinned from the live dat + the 2026-06-22 visual gate. Contents = the 32x32 prototype's
|
||||||
|
// ItemSlot_Empty; the 36x36 container/main-pack resolve THROUGH inheritance to their inner
|
||||||
|
// ItemSlot_Empty (NOT the 0x06005D9C open-container triangle the first cut grabbed — a
|
||||||
|
// structural-only assertion let that wrong-but-valid 0x06 sprite pass, so we pin exacts).
|
||||||
|
Assert.Equal(0x06004D20u, ItemListCellTemplate.ResolveEmptySprite(dats, ContentsLayout, ContentsGrid));
|
||||||
|
Assert.Equal(0x06000F6Eu, ItemListCellTemplate.ResolveEmptySprite(dats, BackpackLayout, SideBag));
|
||||||
|
Assert.Equal(0x06000F6Eu, ItemListCellTemplate.ResolveEmptySprite(dats, BackpackLayout, MainPack));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue