diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs
new file mode 100644
index 00000000..310e2239
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/InventoryController.cs
@@ -0,0 +1,230 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Items;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// Binds the imported gmInventoryUI tree (LayoutDesc 0x21000023) and populates it from
+/// . The acdream analogue of retail
+/// gmInventoryUI/gmBackpackUI/gm3DItemsUI ::PostInit (named-retail decomp 176236/176596/176728).
+/// Read-only: no container switching / drag / wield-drop wire (later sub-phases).
+///
+public sealed class InventoryController
+{
+ // Element ids — spec §1 (dat dump of 0x21000022 / 0x21000021 + the *::PostInit binds).
+ public const uint ContentsGridId = 0x100001C6u; // gm3DItemsUI m_itemList ("Contents of Backpack")
+ public const uint ContainerListId = 0x100001CAu; // gmBackpackUI m_containerList (side-bag selector)
+ public const uint TopContainerId = 0x100001C9u; // gmBackpackUI m_topContainer (main-pack cell)
+ public const uint BurdenMeterId = 0x100001D9u; // gmBackpackUI m_burdenMeter (vertical)
+ public const uint BurdenTextId = 0x100001D8u; // gmBackpackUI m_burdenText ("%d%%")
+ public const uint BurdenCaptionId = 0x100001D7u; // "Burden"
+ public const uint ContentsCaptionId = 0x100001C5u; // "Contents of Backpack"
+
+ // 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037).
+ private const int ContentsColumns = 6;
+ private const float CellPx = 32f;
+
+ private readonly ClientObjectTable _objects;
+ private readonly Func _playerGuid;
+ private readonly Func _iconIds;
+ private readonly Func _strength;
+
+ private readonly UiItemList? _contentsGrid;
+ private readonly UiItemList? _containerList;
+ private readonly UiItemList? _topContainer;
+ private readonly UiMeter? _burdenMeter;
+
+ private float _burdenFill;
+ private int _burdenPercent;
+ private static readonly Vector4 CaptionColor = new(1f, 1f, 1f, 1f);
+
+ // ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)).
+ private const uint EncumbranceValProperty = 5u; // total carried burden
+ private const uint EncumbranceAugProperty = 0xE6u; // carry-capacity augmentation
+
+ private InventoryController(
+ ImportedLayout layout,
+ ClientObjectTable objects,
+ Func playerGuid,
+ Func iconIds,
+ Func strength,
+ UiDatFont? datFont)
+ {
+ _objects = objects;
+ _playerGuid = playerGuid;
+ _iconIds = iconIds;
+ _strength = strength;
+
+ _contentsGrid = layout.FindElement(ContentsGridId) as UiItemList;
+ _containerList = layout.FindElement(ContainerListId) as UiItemList;
+ _topContainer = layout.FindElement(TopContainerId) as UiItemList;
+
+ if (_contentsGrid is not null)
+ {
+ _contentsGrid.Columns = ContentsColumns;
+ _contentsGrid.CellWidth = CellPx;
+ _contentsGrid.CellHeight = CellPx;
+ }
+ if (_containerList is not null)
+ {
+ _containerList.Columns = 1;
+ _containerList.CellWidth = CellPx;
+ _containerList.CellHeight = CellPx;
+ }
+
+ // Burden meter: vertical 11×58 bar (gmBackpackUI m_burdenMeter, retail direction 4).
+ _burdenMeter = layout.FindElement(BurdenMeterId) as UiMeter;
+ if (_burdenMeter is not null)
+ {
+ _burdenMeter.Vertical = true; // 11x58 vertical bar
+ _burdenMeter.FillFromBottom = true; // retail direction 4 (visual-confirmed)
+ _burdenMeter.Fill = () => _burdenFill;
+ }
+
+ // Captions: attach a centered UiText child carrying the known string. "Contents of
+ // Backpack" + "%d%%" are procedural in retail (gm3DItemsUI/gmBackpackUI PostInit/
+ // SetLoadLevel); "Burden" is the dat label. (Caption pass, spec §5.)
+ AttachCaption(layout.FindElement(BurdenCaptionId), () => "Burden", datFont);
+ AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of Backpack", datFont);
+ AttachCaption(layout.FindElement(BurdenTextId), () => _burdenPercent + "%", datFont);
+
+ // Rebuild on any change to the player's possessions.
+ _objects.ObjectAdded += OnObjectChanged;
+ _objects.ObjectMoved += OnObjectMoved;
+ _objects.ObjectRemoved += OnObjectChanged;
+ _objects.ObjectUpdated += OnObjectChanged;
+
+ Populate();
+ }
+
+ public static InventoryController Bind(
+ ImportedLayout layout,
+ ClientObjectTable objects,
+ Func playerGuid,
+ Func iconIds,
+ Func strength,
+ UiDatFont? datFont)
+ => new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont);
+
+ private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
+ private void OnObjectMoved(ClientObject o, uint from, uint to)
+ { if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
+
+ /// True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.
+ private bool Concerns(ClientObject o)
+ {
+ uint p = _playerGuid();
+ return o.ContainerId == p || o.WielderId == p
+ || (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p); // 2-deep
+ }
+
+ /// Retail gm*UI display refresh: partition the player's direct contents into loose
+ /// items (the "Contents of Backpack" grid) and side bags (the pack-selector list).
+ /// Uses the container index when available (items ingested via Ingest/MoveItem) or
+ /// falls back to a full scan — both paths are correct for the two production flows.
+ public void Populate()
+ {
+ uint p = _playerGuid();
+
+ // GetContents returns indexed + slot-sorted items (the production path via Ingest).
+ // For AddOrUpdate-seeded items (tests + initial populate before indexing), we fall
+ // back to scanning all objects with ContainerId == p, sorted by slot.
+ var indexed = _objects.GetContents(p);
+ IEnumerable guids;
+ if (indexed.Count > 0)
+ {
+ guids = indexed;
+ }
+ else
+ {
+ var direct = new List<(int slot, uint id)>();
+ foreach (var o in _objects.Objects)
+ if (o.ContainerId == p)
+ direct.Add((o.ContainerSlot, o.ObjectId));
+ direct.Sort((a, b) => a.slot.CompareTo(b.slot));
+ var ids = new List(direct.Count);
+ foreach (var (_, id) in direct) ids.Add(id);
+ guids = ids;
+ }
+
+ _contentsGrid?.Flush();
+ _containerList?.Flush();
+
+ foreach (var guid in guids)
+ {
+ var item = _objects.Get(guid);
+ if (item is null) continue;
+ bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
+ var list = isBag ? _containerList : _contentsGrid;
+ if (list is null) continue;
+ uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId,
+ item.IconOverlayId, item.Effects);
+ var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve };
+ cell.SetItem(guid, tex);
+ list.AddItem(cell);
+ }
+
+ // Main-pack cell: the player's own container. Icon = placeholder until a backpack
+ // RenderSurface DID is pinned (spec §3 / divergence row). Bind the guid so a future
+ // click can select it.
+ if (_topContainer is not null)
+ {
+ _topContainer.Flush();
+ var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve };
+ main.SetItem(p, 0u);
+ _topContainer.AddItem(main);
+ }
+
+ RefreshBurden();
+ }
+
+ private void AttachCaption(UiElement? host, Func text, UiDatFont? datFont)
+ {
+ if (host is null) return;
+ var label = new UiText
+ {
+ Left = 0f, Top = 0f, Width = host.Width, Height = host.Height,
+ Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
+ Centered = true, DatFont = datFont, ClickThrough = true,
+ AcceptsFocus = false, IsEditControl = false, CapturesPointerDrag = false,
+ LinesProvider = () =>
+ {
+ var s = text();
+ return string.IsNullOrEmpty(s)
+ ? Array.Empty()
+ : new[] { new UiText.Line(s, CaptionColor) };
+ },
+ };
+ host.AddChild(label);
+ }
+
+ /// Recompute the burden fill + percent. Port of CACQualities::InqLoad
+ /// (decomp 0x0058f130) → gmBackpackUI::SetLoadLevel (0x004a6ea0). currentBurden:
+ /// player wire EncumbranceVal (PropertyInt 5) if present, else the carried-Burden sum.
+ private void RefreshBurden()
+ {
+ uint p = _playerGuid();
+ int str = _strength() ?? 10; // InqAttribute default 0xa
+ int aug = _objects.Get(p)?.Properties.GetInt(EncumbranceAugProperty) ?? 0;
+ int capacity = BurdenMath.EncumbranceCapacity(str, aug);
+
+ int? wire = _objects.Get(p)?.Properties.Ints.TryGetValue(EncumbranceValProperty, out var ev) == true
+ ? ev : (int?)null;
+ int burden = wire ?? _objects.SumCarriedBurden(p);
+
+ float load = BurdenMath.LoadRatio(capacity, burden);
+ _burdenFill = BurdenMath.LoadToFill(load);
+ _burdenPercent = BurdenMath.LoadToPercent(load);
+ }
+
+ /// Detach event handlers (idempotent).
+ public void Dispose()
+ {
+ _objects.ObjectAdded -= OnObjectChanged;
+ _objects.ObjectMoved -= OnObjectMoved;
+ _objects.ObjectRemoved -= OnObjectChanged;
+ _objects.ObjectUpdated -= OnObjectChanged;
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
new file mode 100644
index 00000000..99d5b34b
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
@@ -0,0 +1,138 @@
+using System.Collections.Generic;
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+using AcDream.Core.Items;
+using Xunit;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+public class InventoryControllerTests
+{
+ private const uint Player = 0x50000001u;
+
+ // UiElement is abstract — a concrete, parameterless stand-in for the root + caption hosts
+ // (the real captions are Type-0 → UiDatElement; in tests we only need a host that holds children).
+ private sealed class TestElement : UiElement { }
+
+ // Element ids (spec §1).
+ private const uint ContentsGrid = 0x100001C6u;
+ private const uint ContainerList = 0x100001CAu;
+ private const uint TopContainer = 0x100001C9u;
+ private const uint BurdenMeter = 0x100001D9u;
+ private const uint BurdenText = 0x100001D8u;
+ private const uint BurdenCaption = 0x100001D7u;
+ private const uint ContentsCaption = 0x100001C5u;
+
+ private static (ImportedLayout layout, UiItemList grid, UiItemList containers,
+ UiItemList top, UiMeter meter, UiElement burdenText,
+ UiElement burdenCap, UiElement contentsCap) BuildLayout()
+ {
+ var grid = new UiItemList { Width = 192, Height = 96 };
+ var containers = new UiItemList { Width = 36, Height = 252 };
+ var top = new UiItemList { Width = 36, Height = 36 };
+ var meter = new UiMeter { Width = 11, Height = 58 };
+ var burdenText = new TestElement { Width = 36, Height = 15 };
+ var burdenCap = new TestElement { Width = 36, Height = 15 };
+ var contentsCap = new TestElement { Width = 192, Height = 15 };
+ var root = new TestElement { Width = 300, Height = 362 };
+ root.AddChild(grid); root.AddChild(containers); root.AddChild(top);
+ root.AddChild(meter); root.AddChild(burdenText); root.AddChild(burdenCap);
+ root.AddChild(contentsCap);
+ var byId = new Dictionary
+ {
+ [ContentsGrid] = grid, [ContainerList] = containers, [TopContainer] = top,
+ [BurdenMeter] = meter, [BurdenText] = burdenText, [BurdenCaption] = burdenCap,
+ [ContentsCaption] = contentsCap,
+ };
+ return (new ImportedLayout(root, byId), grid, containers, top, meter,
+ burdenText, burdenCap, contentsCap);
+ }
+
+ private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
+ int? strength = 100)
+ => InventoryController.Bind(layout, objects, () => Player,
+ iconIds: (_, _, _, _, _) => 0u, // no real GL upload in tests
+ strength: () => strength, datFont: null);
+
+ [Fact]
+ public void Populate_fills_contents_grid_with_loose_items_only()
+ {
+ var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
+ var objects = new ClientObjectTable();
+ objects.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player,
+ ContainerSlot = 0, Burden = 10 }); // loose
+ objects.AddOrUpdate(new ClientObject { ObjectId = 0xB, ContainerId = Player,
+ ContainerSlot = 1, Burden = 20 }); // loose
+ objects.AddOrUpdate(new ClientObject { ObjectId = 0xC, ContainerId = Player,
+ ContainerSlot = 2, Type = ItemType.Container }); // side bag
+
+ Bind(layout, objects);
+
+ Assert.Equal(2, grid.GetNumUIItems()); // 2 loose
+ Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
+ Assert.Equal(0xBu, grid.GetItem(1)!.ItemId);
+ Assert.Equal(1, containers.GetNumUIItems()); // 1 side bag
+ Assert.Equal(0xCu, containers.GetItem(0)!.ItemId);
+ }
+
+ [Fact]
+ public void Grid_is_six_columns_thirtytwo_px()
+ {
+ var (layout, grid, _, _, _, _, _, _) = BuildLayout();
+ Bind(layout, new ClientObjectTable());
+ Assert.Equal(6, grid.Columns);
+ Assert.Equal(32f, grid.CellWidth);
+ Assert.Equal(32f, grid.CellHeight);
+ }
+
+ [Fact]
+ public void ObjectAdded_for_player_item_rebuilds_grid()
+ {
+ var (layout, grid, _, _, _, _, _, _) = BuildLayout();
+ var objects = new ClientObjectTable();
+ Bind(layout, objects);
+ Assert.Equal(0, grid.GetNumUIItems());
+
+ objects.Ingest(new WeenieData(0xA, "Sword", ItemType.MeleeWeapon, 1, 0, 0, 0, 0,
+ null, null, null, 5, Player, null, null, null, null, null, null, null, null, null));
+
+ Assert.Equal(1, grid.GetNumUIItems());
+ Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
+ }
+
+ [Fact]
+ public void Burden_meter_fill_and_percent_from_load()
+ {
+ var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout();
+ var objects = new ClientObjectTable();
+ // Str 100 → capacity 15000. Carried 7500 → load 0.5 → fill 0.1667, "50%".
+ objects.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, Burden = 7500 });
+ Bind(layout, objects, strength: 100);
+
+ Assert.Equal(0.16667f, meter.Fill() ?? -1f, 3);
+ Assert.True(meter.Vertical);
+ // The % text caption child reads "50%".
+ Assert.Contains("50%", CaptionText(burdenText));
+ }
+
+ [Fact]
+ public void Captions_render_known_strings()
+ {
+ var (layout, _, _, _, _, _, burdenCap, contentsCap) = BuildLayout();
+ Bind(layout, new ClientObjectTable());
+ Assert.Contains("Burden", CaptionText(burdenCap));
+ Assert.Contains("Contents of Backpack", CaptionText(contentsCap));
+ }
+
+ // Reads the text of the UiText caption child attached by the controller.
+ private static string CaptionText(UiElement host)
+ {
+ foreach (var c in host.Children)
+ if (c is UiText t)
+ {
+ var lines = t.LinesProvider();
+ if (lines.Count > 0) return lines[0].Text;
+ }
+ return "";
+ }
+}