feat(ui): D.2b-B — InventoryController bind + grid population (loose/side-bag partition)

Task 4: InventoryController.Bind + Populate: find-by-id bind for the 7 inventory
element ids, configure grid (6 cols x 32px) + container list, partition
GetContents(player) into loose items (contentsGrid) vs side bags (containerList),
populate main-pack cell in topContainer, subscribe ObjectAdded/Moved/Removed/Updated
for live rebuilds. Handles both Ingest-indexed (production) and AddOrUpdate-direct
(test) paths via fallback scan.

Task 5: burden meter (vertical UiMeter, FillFromBottom=true) + RefreshBurden port
of CACQualities::InqLoad (decomp 0x0058f130) → gmBackpackUI::SetLoadLevel
(0x004a6ea0): EncumbranceCapacity/LoadRatio/LoadToFill/LoadToPercent. Three
AttachCaption overlays (Burden, Contents of Backpack, %text). 5 tests all green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-21 09:04:48 +02:00
parent 5e75d2ac76
commit 89c640a54d
2 changed files with 368 additions and 0 deletions

View file

@ -0,0 +1,230 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Items;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the imported gmInventoryUI tree (LayoutDesc 0x21000023) and populates it from
/// <see cref="ClientObjectTable"/>. 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).
/// </summary>
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<uint> _playerGuid;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
private readonly Func<int?> _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<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Func<int?> 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<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Func<int?> 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(); }
/// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary>
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
}
/// <summary>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.</summary>
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<uint> 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<uint>(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<string> 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<UiText.Line>()
: new[] { new UiText.Line(s, CaptionColor) };
},
};
host.AddChild(label);
}
/// <summary>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.</summary>
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);
}
/// <summary>Detach event handlers (idempotent).</summary>
public void Dispose()
{
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectRemoved -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged;
}
}

View file

@ -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<uint, UiElement>
{
[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 "";
}
}