First step of Sub-phase B (inventory window). Dumped LayoutDesc 0x21000023: the three nested panels (paperdoll/backpack/3D-items) are Type-0 pure- container leaves that nest via the EXISTING BaseElement+BaseLayoutId inheritance path (BaseLayoutId → 0x21000024/22/21), NOT game-class Types as the research agent claimed. ElementReader.Merge drops base children, so they import empty today. Mount = a surgical ~4-line LayoutImporter.Resolve change: a childless, media-less inheritor attaches its base's resolved subtree (predicate ShouldMountBaseChildren, unit-testable; inert for media-bearing inheritors + childless style prototypes, so vitals/chat/toolbar are unaffected). Plus UiItemList N-cell grid mode (single-cell toolbar preserved). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12 KiB
Design — D.2b Sub-phase B-Grid (inventory sub-window mount + UiItemList grid mode)
Date: 2026-06-20
Phase: D.2b retail-UI arc, Sub-phase B (inventory window), step B-Grid — the first of
four (B-Grid → B-Controller → B-Wire → B-Drag). Decomposition approved 2026-06-20.
Status: approved design shape, dat-grounded, pre-implementation.
Predecessor: Sub-phase A (window manager) shipped; F12 toggles a hidden placeholder window
registered as WindowNames.Inventory. B-Grid makes that window show the real nested frame.
1. Context & goal
B-Grid makes LayoutImporter.Import(0x21000023) (gmInventoryUI) produce the full nested
inventory frame and gives UiItemList an N-cell grid so item cells tile. After B-Grid,
F12 shows the real bordered inventory frame — outer chrome + a backpack strip + a 3D-items area
- an (empty) paperdoll panel — replacing the placeholder. Cells are empty until B-Controller populates them; paperdoll content is Sub-phase C.
Goal is structural, not interactive: the frame renders with the correct nested geometry and a working grid widget. No wire, no controller, no drag (those are B-Wire / B-Controller / B-Drag).
2. The dat-grounded finding (why this design)
Dumping LayoutDesc 0x21000023 (via AcDream.Cli dump-vitals-layout) overturned the research
agent's "game-class Type + recursive Import" model. The actual structure:
- Root
0x100001CC, Type0x10000023(gmInventoryUI), 300×362. Children:0x100001CDpaperdoll panel — Type 0,BaseElement 0x100001D4,BaseLayoutId 0x21000024, childless, no own media, 224×214 @ (0,23)0x100001CEbackpack panel — Type 0,BaseElement 0x100001C8,BaseLayoutId 0x21000022, childless, no own media, 61×339 @ (239,23)0x100001CF3D-items panel — Type 0,BaseElement 0x100001C4,BaseLayoutId 0x21000021, childless, no own media, 234×120 @ (0,237)0x100001D2close button — Type 0, inherits, has own media (Normal/Normal_pressed)0x100001D3title — Type 0, inherits, has own media (DirectState)0x100001D0backdrop,0x100001D1bottom rule — Type 3,BaseElement 0, own media
So the three panels are Type-0 pure-container leaves that nest via the existing
BaseElement+BaseLayoutId inheritance path — not game-class Types. Their content lives in
the separate layouts 0x21000024/22/21.
LayoutImporter.Resolve already loads BaseLayoutId, finds BaseElement, recurses it, and
Merges — but ElementReader.Merge (ElementReader.cs:162) sets
Children = new List(derived.Children), dropping the base's children. So today the panels import
as empty containers. The fix is a surgical attach of the base's resolved children to the
panel — not a new Type-dispatch mechanism.
3. Scope
In scope (B-Grid):
- Sub-window mount —
LayoutImporter.Resolveattaches a base element's resolved children to a childless, media-less inheriting element (the panels), soImport(0x21000023)yields the full nested tree. UiItemListgrid mode — column count + cell pitch soAddItem/OnDrawtile cells in a grid; single-cell (toolbar) behavior preserved.- Unit tests; a regression guard that vitals/chat/toolbar import unchanged.
Out of scope (deferred):
- Populating cells from
ClientObjectTable, the burden meter, find-by-id binding → B-Controller. - Inventory wire gaps (
DropItem/ViewContents/…) → B-Wire. - Inventory cell as a drag source → B-Drag.
- Paperdoll content rendering (the
UiViewportdoll) → Sub-phase C. (B-Grid's mount will pull in the paperdoll panel's equip-slot frames as ordinary elements, but the 3D doll viewport is C.) 0x10000032(UiItemSlot) factory registration — not needed:UiItemList.ConsumesDatChildrenis true, so the importer skips cell templates; cells are built procedurally.- Cell pitch values + exact column counts per panel — read by B-Controller from the nested layouts at bind time. B-Grid supplies the mechanism, not the numbers.
4. Component 1 — sub-window mount (LayoutImporter.Resolve)
Restructure Resolve to capture the base's children and attach them when the derived element is a
pure container that inherits from a base with content:
private static ElementInfo Resolve(
DatCollection dats, ElementDesc d, HashSet<(uint, uint)> baseChain)
{
var self = ToInfo(d);
var result = self;
List<ElementInfo>? baseChildren = null;
if (d.BaseElement != 0 && d.BaseLayoutId != 0
&& baseChain.Add((d.BaseLayoutId, d.BaseElement)))
{
var baseLd = dats.Get<LayoutDesc>(d.BaseLayoutId);
var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement);
if (baseDesc is not null)
{
var baseInfo = Resolve(dats, baseDesc, baseChain);
result = ElementReader.Merge(baseInfo, self);
baseChildren = baseInfo.Children; // capture the base's resolved subtree
}
}
// Derived's own children (authoritative when present).
foreach (var kv in d.Children)
result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>()));
// Sub-window mount: a PURE-CONTAINER leaf (no own children AND no own media) that inherits
// from a base WITH content attaches the base's subtree. This targets the gmInventoryUI
// panels (0x100001CD/CE/CF — Type-0, media-less, childless, BaseLayoutId → a gm*UI window)
// and is inert for: media-bearing inheritors (close button/title keep their own media,
// so self.StateMedia is non-empty → skipped), normal elements with their own children
// (result.Children already populated → skipped), and childless style-prototype inheritors
// (vitals/chat/toolbar text — the base prototype has no children → baseChildren empty).
if (LayoutImporter.ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseChildren?.Count ?? 0))
result.Children.AddRange(baseChildren!);
return result;
}
/// <summary>True when a pure-container leaf should inherit its base's subtree (sub-window mount):
/// the derived element has no own children, no own state media, and the base resolved to ≥1 child.</summary>
internal static bool ShouldMountBaseChildren(int derivedChildCount, int derivedMediaCount, int baseChildCount)
=> derivedChildCount == 0 && derivedMediaCount == 0 && baseChildCount > 0;
The predicate is extracted as ShouldMountBaseChildren so it is unit-testable without dats.
Coordinate correctness: the panel inherits the base window-root's fields via Merge (so it
gets that window's size) and the base's children via the mount. The children's X/Y are relative to
the panel; ScreenPosition composes parent offsets, so a child at panel-local (x,y) with the panel
at (0,23) lands at the right screen pixel.
Cycle safety: baseChain already guards the inheritance recursion; the nested layouts are
distinct ids, so no cycle.
5. Component 2 — UiItemList grid mode
Add a column count + cell pitch; keep single-cell as the default.
public int Columns { get; set; } = 1; // grid columns; 1 = single column
public float CellWidth { get; set; } // 0 = "fill the list" (single-cell legacy)
public float CellHeight { get; set; }
Layout rule (applied in AddItem and re-applied in OnDraw so a list resize reflows):
- Fill mode (
CellWidth <= 0): the single cell fills the list —cell[0]at(0, 0, Width, Height). Unchanged toolbar behavior. - Grid mode (
CellWidth > 0):cell[i]at(col·CellWidth, row·CellHeight, CellWidth, CellHeight)wherecol = i % Columns,row = i / Columns.
The toolbar constructor keeps adding its one cell with CellWidth = 0 (defaults), so it stays in
fill mode. B-Controller sets Columns + CellWidth/CellHeight (read from the nested layout's
ItemList element + cell template) and Flush() + AddItem()s the grid cells.
The grid math is a pure helper for testing:
internal static (float x, float y) CellOffset(int index, int columns, float cellW, float cellH)
{
int col = index % columns, row = index / columns;
return (col * cellW, row * cellH);
}
6. Testing
Grid mode (pure, tests/AcDream.App.Tests/UI/):
CellOffset(4, 3, 36, 36)⇒(36, 36)(index 4 → col 1, row 1).- Build a
UiItemList { Columns = 3, CellWidth = 36, CellHeight = 36 },AddItem7 cells, assertGetItem(4)is atLeft == 36 && Top == 36 && Width == 36. - Single-cell legacy: a default
UiItemList(CellWidth 0) keeps its cell at the list size afterOnDraw— assert the existing toolbar behavior is unchanged.
Mount predicate (pure):
ShouldMountBaseChildren(0, 0, 5)⇒ true (panel: childless, media-less, base has 5 kids).ShouldMountBaseChildren(0, 1, 5)⇒ false (close button: has own media).ShouldMountBaseChildren(2, 0, 5)⇒ false (has own children).ShouldMountBaseChildren(0, 0, 0)⇒ false (style prototype: base childless — vitals text).
Regression guard (hard acceptance): vitals (0x2100006C), chat (0x21000006), and toolbar
(0x21000016) must import + render unchanged. Their inherited bases are childless prototypes, so
the mount is inert for them — but this is verified, not assumed (the existing importer tests +
the visual check).
7. Divergence register
No new row. The mount makes BaseElement/BaseLayoutId inheritance carry the base's content
subtree, which is what retail must do for the gmInventoryUI panels to render at all (they are
childless in the dat — the content is only reachable through the base reference). This is
completing faithful inheritance, not diverging from it. IA-12 (toolkit-defined UI) already
covers the importer's reconstruction of keystone semantics. If a future retail-decomp pass shows
retail's child-inheritance differs in the children-present merge case (which no inventory element
exercises), that is a separate, later concern.
8. Acceptance criteria
dotnet buildgreen;dotnet testgreen (grid + mount-predicate tests added).Import(0x21000023)yields a tree where the three panels (0x100001CD/CE/CF) each have children (the nested layout content), confirmed by a dat-backed assertion or the dump.- Vitals/chat/toolbar import + render unchanged (regression guard).
- With
ACDREAM_RETAIL_UI=1, F12 shows the real nested inventory frame (chrome + backpack strip + 3D-items area + empty paperdoll panel) instead of the blank placeholder. - Visual verification by the user.
9. Open / confirmed-at-build items
- Cell pitch + column counts — B-Controller reads them from the nested layouts
(
0x21000022backpack,0x210000213D-items) at bind time. Geometry from the outer dump suggests the backpack strip is ~1 column and the 3D-items panel ~6 columns of 36×36, but the exact values are B-Controller's to read, not B-Grid's to hardcode. - The placeholder mount in
GameWindow(Sub-phase A) is replaced here: instead of a bareUiNineSlicePanel, the inventory window becomesLayoutImporter.Import(0x21000023)'s root, registered under the sameWindowNames.Inventory. (Mechanically a few lines in the RetailUi block; the F12 wiring is untouched.)
10. References
- Dat dump:
AcDream.Cli dump-vitals-layout "<datdir>" 0x21000023(the structure in §2). - Format:
docs/research/2026-06-15-layoutdesc-format.md(§8 Type table, §10/§12 inheritance). - Code:
src/AcDream.App/UI/Layout/LayoutImporter.cs(Resolve/Mergepath),src/AcDream.App/UI/Layout/ElementReader.cs:162(the children-drop),UiItemList.cs,UiItemSlot.cs(the shipped drag spine — reused in B-Drag, untouched here). - Handoff/decomposition:
docs/research/2026-06-20-window-manager-inventory-handoff.md§4; the Sub-phase B decomposition (B-Grid → B-Controller → B-Wire → B-Drag).