diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 3b7fcf5b..10377769 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -46,6 +46,32 @@ Copy this block when adding a new issue:
---
+## #146 — D.2b inventory capacity-bar visual polish
+
+**Status:** OPEN
+**Severity:** LOW
+**Filed:** 2026-06-22
+
+The per-side-bag / main-pack container **capacity fill bar** (faithful port of retail
+`UIElement_UIItem::UpdateCapacityDisplay 0x004e16e0`, element `0x10000347`) shipped + is
+visually confirmed (`src/AcDream.App/UI/UiItemSlot.cs` `CapacityFill`;
+`InventoryController.SetCapacityBar`). User: "looks good, we need to polish it a bit, but lets do
+that later." Get the specific polish list from the user when picked up. Candidate points (confirm
+against retail):
+
+- **Exact bar rect / anchor** — currently right-anchored flush (`X = Width − barW`); the dat element
+ rect is `X=26 Y=1 W=5 H=30` (a 5px right margin). Flush was the visual-gate call; reconcile with
+ the dat once the desired look is pinned.
+- **Fill direction** — currently bottom-up (assumed); the dat `m_eDirection` (property `0x6f`) isn't
+ read (cf. AP-50). Confirm bottom-up vs retail.
+- **Closed-bag fill** — a closed side bag reads empty until opened (contents aren't indexed until
+ `ViewContents`). If retail shows real fill for closed bags, the per-container item counts must be
+ pre-loaded at login (the lazy-load question — also the container-switching "open verification").
+
+See divergence register **AP-59**.
+
+---
+
## #145 — Inventory window panels occluded by the full-window backdrop (importer ignores ZLevel)
**Status:** DONE (2026-06-21 · `45a5cc5` + continuation `417b137`) — VISUALLY CONFIRMED. The first fix (`45a5cc5`) folded `ZLevel` into `ZOrder` and put the backdrop behind the **ZLevel-0 top-level** panels, which made the **paperdoll** (its base root is ZLevel 0) render — so it *looked* done. But the **mounted backpack/3D-items panels** inherited their sub-window root's **ZLevel 1000** (via `ElementReader.Merge`'s zero-wins-base rule), so the `ReadOrder − ZLevel·10000` fold sank them to ZOrder ≈ −10,000,000 — *behind* the backdrop (ZLevel 100 → ≈ −1,000,000). The Alphablend backdrop then **washed out** the backpack/3D-items captions + burden meter + item cells (only the bright paperdoll survived the wash). Surfaced once B-Controller populated those panels. Continuation fix `417b137`: the sub-window mount keeps each slot's **own** frame ZLevel (not the base root's 1000), so panels sit in front of the backdrop. Confirmed live (Burden 17% + vertical bar + "Contents of Backpack" + full item grid all render on the dark backdrop). Locked by `InventoryFrameImportProbe` (real-dat: each mounted panel's ZOrder > the backdrop's). DO-NOT-RETRY: a mounted sub-window slot must NOT inherit the base layout root's ZLevel. Per-slot paperdoll silhouettes (generic `UiDatElement` sprite — need `0x10000032` UiItemSlot + per-slot art) → Sub-phase C.
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index d4ecf02f..31fc055e 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -157,6 +157,7 @@ accepted-divergence entries (#96, #49, #50).
| 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-57 | The open-container triangle (`0x06005D9C`) + selected-item square (`0x06004D21`) are drawn as **procedural `UiItemSlot` overlays** keyed by controller state (`_openContainer`/`_selectedItem`), reproducing the retail keying (`item.itemID == openContainerId / selectedItemId` per `UpdateOpenContainerIndicator 0x004e3070` / `ItemList_SetSelectedItem 0x004e2fe0`) but not via the dat prototype's `m_elem_Icon_OpenContainer`/`m_elem_Icon_Selected` state elements + `SetOpenContainerState`/`SetSelectedState` calls. | `src/AcDream.App/UI/UiItemSlot.cs`; `src/AcDream.App/UI/Layout/InventoryController.cs` | The procedural overlay produces the correct visual result; the 36×36 container prototype (`0x1000033F`) lacks the square child, so the procedural overlay is the only way to show the square on a selected bag — the dat-state path retail uses would require the prototype to be richer than it is. | A cell that should show an indicator via a dat-state path retail uses but we don't would be missed; outcome currently matches retail for the open/selected indicator cases. | `UIElement_ItemList::UpdateOpenContainerIndicator` 0x004e3070; `ItemList_SetSelectedItem` 0x004e2fe0; `SetOpenContainerState` 0x004e1200; `SetSelectedState` 0x004e1240 |
| AP-58 | Inventory item **selection is panel-local + visual-only** — clicking a grid item moves the green square but does not wire to the selected-object bar (`SelectedObjectController`) or to global 3D-world selection. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`SelectItem`) | The selected-object bar + 3D-world selection wiring is deferred to the selection follow-up; the panel-local square is the correct first step. | Selecting an inventory item doesn't populate the selected-object strip as retail does — functional gap deferred. | `gmInventoryUI`/`gmBackpackUI` item-select → `ClientUISystem::SetSelectedObject`; `SelectedObjectController` |
+| AP-59 | The per-cell container **capacity bar** (retail `UIElement_UIItem::UpdateCapacityDisplay 0x004e16e0`, element `0x10000347`) is drawn as a **procedural `UiItemSlot` overlay** (track `0x06004D22` full + fill `0x06004D23` clipped bottom-up to `GetContents(guid).Count / ItemsCapacity`), not via a real dat `UIElement_Meter` child. **Right-anchored flush** (the dat rect X=26 in a 36px cell sat ~5px off the edge — flush per the visual gate) and **bottom-up fill assumed** (the dat `m_eDirection` 0x6f isn't read, cf. AP-50). A CLOSED side bag reads empty until opened (its contents aren't indexed until `ViewContents`) — faithful to retail's known-children count, divergent if retail pre-loads. | `src/AcDream.App/UI/UiItemSlot.cs` (`CapacityFill` draw); `src/AcDream.App/UI/Layout/InventoryController.cs` (`SetCapacityBar`) | UiItemSlot is a behavioral leaf that paints overlays procedurally (cf. AP-57); the meter sprites + fill formula are the faithful port. Right-anchor + bottom-up were visual-gate calls. Further visual polish is deferred — ISSUES #146. | Fill direction / exact bar rect could differ from retail's `m_eDirection` + dat X; closed-bag bars read empty if retail pre-loads container counts. | `UIElement_UIItem::UpdateCapacityDisplay` 0x004e16e0; element `0x10000347` (back `0x06004D22` / front `0x06004D23`); `GetNumContainedItems` |
---
diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs
index 6429dabd..c0dbc8d6 100644
--- a/src/AcDream.App/UI/Layout/InventoryController.cs
+++ b/src/AcDream.App/UI/Layout/InventoryController.cs
@@ -238,6 +238,7 @@ public sealed class InventoryController
var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve };
main.SetItem(p, _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u));
main.Clicked = () => OpenContainer(p);
+ SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity)
_topContainer.AddItem(main);
}
@@ -261,7 +262,7 @@ public sealed class InventoryController
: _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve };
cell.SetItem(guid, tex);
- if (isContainer) cell.Clicked = () => OpenContainer(guid);
+ if (isContainer) { cell.Clicked = () => OpenContainer(guid); SetCapacityBar(cell, guid); }
else cell.Clicked = () => SelectItem(guid);
list.AddItem(cell);
}
@@ -269,6 +270,19 @@ public sealed class InventoryController
private static void AddEmptyCell(UiItemList list)
=> list.AddItem(new UiItemSlot { SpriteResolve = list.SpriteResolve });
+ /// Set the per-cell container capacity bar — retail UIElement_UIItem::UpdateCapacityDisplay
+ /// (0x004e16e0): visible only for a container with itemsCapacity > 0; fill =
+ /// GetNumContainedItems / itemsCapacity, clamped [0,1]. -1 hides the bar (non-container / unknown
+ /// capacity). For a CLOSED side bag the contents aren't indexed until it's opened (ViewContents),
+ /// so the bar reads empty until then — faithful to retail's known-children count.
+ private void SetCapacityBar(UiItemSlot cell, uint containerGuid)
+ {
+ int cap = _objects.Get(containerGuid)?.ItemsCapacity ?? 0;
+ if (cap <= 0) { cell.CapacityFill = -1f; return; }
+ int n = _objects.GetContents(containerGuid).Count;
+ cell.CapacityFill = Math.Clamp(n / (float)cap, 0f, 1f);
+ }
+
/// Select an item (panel-wide green square) without changing the open container or
/// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0).
private void SelectItem(uint guid)
diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs
index a3c95895..10e1d7fe 100644
--- a/src/AcDream.App/UI/UiItemSlot.cs
+++ b/src/AcDream.App/UI/UiItemSlot.cs
@@ -55,6 +55,16 @@ public sealed class UiItemSlot : UiElement
/// on the 36×36 container cell too (whose prototype lacks the square child).
public uint SelectedSprite { get; set; } = 0x06004D21u;
+ /// Container fullness [0..1], or -1 = hidden (the cell is not a container, or its
+ /// itemsCapacity is unknown/0). Port of UIElement_UIItem::UpdateCapacityDisplay (0x004e16e0): a
+ /// per-cell vertical UIElement_Meter (element 0x10000347, 5×30 at x=26,y=1) shown only when
+ /// isContainer && itemsCapacity > 0, fill = numContainedItems / itemsCapacity (meter attr 0x69).
+ public float CapacityFill { get; set; } = -1f;
+ /// Capacity-bar track sprite (meter 0x10000347 DirectState).
+ public uint CapacityBackSprite { get; set; } = 0x06004D22u;
+ /// Capacity-bar fill sprite (meter 0x10000347 front child 0x00000002 DirectState).
+ public uint CapacityFrontSprite { get; set; } = 0x06004D23u;
+
/// Accept/reject overlay state while a drag hovers this cell.
public enum DragAcceptState { None, Accept, Reject }
private DragAcceptState _dragAccept = DragAcceptState.None;
@@ -237,6 +247,33 @@ public sealed class UiItemSlot : UiElement
}
}
+ // Container capacity bar — UIElement_UIItem::UpdateCapacityDisplay (0x004e16e0): a vertical
+ // UIElement_Meter (element 0x10000347, 5×30 at x=26,y=1) shown only for container cells
+ // (CapacityFill >= 0). Track drawn full; fill clipped to the fraction from the BOTTOM (the
+ // "how full" direction). Procedural — UiItemSlot is a behavioral leaf. Guard id != 0 first.
+ if (CapacityFill >= 0f && SpriteResolve is not null)
+ {
+ const float by = 1f, bw = 5f, bh = 30f; // element 0x10000347 size (dat 5×30 at y=1)
+ float bx = Width - bw; // flush to the cell's right edge (visual gate: the dat X=26 sat ~5px off the edge)
+ if (CapacityBackSprite != 0)
+ {
+ var (bt, _, _) = SpriteResolve(CapacityBackSprite);
+ if (bt != 0) ctx.DrawSprite(bt, bx, by, bw, bh, 0f, 0f, 1f, 1f, Vector4.One);
+ }
+ float f = Math.Clamp(CapacityFill, 0f, 1f);
+ if (f > 0f && CapacityFrontSprite != 0)
+ {
+ var (ft, _, _) = SpriteResolve(CapacityFrontSprite);
+ if (ft != 0)
+ {
+ // Bottom-up fill: draw the bottom f-fraction of the bar, sampling the matching
+ // bottom slice of the front sprite (UV v from 1-f to 1).
+ float fh = bh * f;
+ ctx.DrawSprite(ft, bx, by + (bh - fh), bw, fh, 0f, 1f - f, 1f, 1f, Vector4.One);
+ }
+ }
+ }
+
// Open-container triangle (retail SetOpenContainerState 0x004e1200) + selected-item square
// (retail SetSelectedState 0x004e1240). Drawn procedurally like the digit/drag overlays;
// guard id != 0 BEFORE resolving (feedback_ui_resolve_zero_magenta). Triangle under the
diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
index 366d1093..9772068f 100644
--- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
@@ -380,6 +380,29 @@ public class InventoryControllerTests
Assert.Equal(0x0600127Eu, mainPackCall.Value.icon);
}
+ [Fact]
+ public void SideBagCell_capacityBar_reflectsContents()
+ {
+ var (layout, _, containers, _, _, _, _, _) = BuildLayout();
+ var objects = new ClientObjectTable();
+ SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24); // a side bag (cap 24)
+ for (uint i = 0; i < 6; i++) SeedContained(objects, 0xB0u + i, 0xC, slot: (int)i); // 6 items inside
+ Bind(layout, objects);
+
+ // Retail UpdateCapacityDisplay: fill = contained / itemsCapacity = 6/24 = 0.25 (exact in float).
+ Assert.Equal(0.25f, containers.GetItem(0)!.CapacityFill);
+ }
+
+ [Fact]
+ public void LooseGridItem_hasNoCapacityBar()
+ {
+ var (layout, grid, _, _, _, _, _, _) = BuildLayout();
+ var objects = new ClientObjectTable();
+ SeedContained(objects, 0xA, Player, slot: 0); // a loose, non-container item
+ Bind(layout, objects);
+ Assert.Equal(-1f, grid.GetItem(0)!.CapacityFill); // hidden — not a container
+ }
+
// Reads the text of the UiText caption child attached by the controller.
private static string CaptionText(UiElement host)
{
diff --git a/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs b/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs
index c0e91753..773f3039 100644
--- a/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs
@@ -157,4 +157,14 @@ public class UiItemSlotTests
[Fact]
public void OpenContainerSprite_default_isTriangle()
=> Assert.Equal(0x06005D9Cu, new UiItemSlot().OpenContainerSprite);
+
+ // ── Container capacity bar (decomp UpdateCapacityDisplay 0x004e16e0, element 0x10000347) ──
+ [Fact]
+ public void CapacityFill_defaultsHidden() => Assert.Equal(-1f, new UiItemSlot().CapacityFill);
+
+ [Fact]
+ public void CapacityBackSprite_default() => Assert.Equal(0x06004D22u, new UiItemSlot().CapacityBackSprite);
+
+ [Fact]
+ public void CapacityFrontSprite_default() => Assert.Equal(0x06004D23u, new UiItemSlot().CapacityFrontSprite);
}