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); }