using System; using System.Numerics; using AcDream.App.UI; 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). /// Container-switching is live (click a side bag → Use 0x0036 → ViewContents 0x0196 full-replace); /// drag-into-bag / wield-drop wire are later sub-phases. /// public sealed class InventoryController : IItemListDragHandler { // 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" public const uint TitleTextId = 0x100001D3u; // "Inventory of " public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid) // Scrollbar chrome from base layout 0x2100003E (shared with the chat scrollbar; see // ChatWindowController). Both inventory + chat scrollbars inherit this base. private const uint ScrollTrackSprite = 0x06004C5Fu; private const uint ScrollThumbSprite = 0x06004C63u; // 3-slice middle tile private const uint ScrollThumbTop = 0x06004C60u; // 3-slice top cap private const uint ScrollThumbBot = 0x06004C66u; // 3-slice bottom cap private const uint ScrollUpSprite = 0x06004C6Cu; // up arrow (top button) private const uint ScrollDownSprite = 0x06004C69u; // down arrow (bottom button) // 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037). private const int ContentsColumns = 6; private const float ContentsCellPx = 32f; // gm3DItemsUI grid (192x96 = 6x3 of 32px) private const float BackpackCellPx = 36f; // gmBackpackUI column cells (0x100001C9/CA = 36px) private const int SideBagSlots = 7; // 0x100001CA is 36x252 = 7 slots private const int MainPackSlots = 102; // retail main-pack capacity (Wiki: "up to 102 items") private const int SidePackSlots = 24; // standard side-pack capacity (reference_retail_inventory_paperdoll) private readonly ClientObjectTable _objects; private readonly Func _playerGuid; private readonly Func _iconIds; private readonly Func _strength; private readonly Func? _ownerName; 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); private uint _openContainer; // 0 = the main pack (the player); else the open side bag's guid private uint _selectedItem; // 0 = none; the panel-wide selected item (green square) private readonly Action? _sendUse; private readonly Action? _sendNoLongerViewing; private readonly Action? _sendPutItemInContainer; // (item, container, placement) private readonly ItemInteractionController? _itemInteraction; // 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, Func? ownerName, UiDatFont? datFont, uint contentsEmptySprite, uint sideBagEmptySprite, uint mainPackEmptySprite, Action? sendUse, Action? sendNoLongerViewing, Action? sendPutItemInContainer, ItemInteractionController? itemInteraction, Action? onClose) { _objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _strength = strength; _ownerName = ownerName; _sendUse = sendUse; _sendNoLongerViewing = sendNoLongerViewing; _sendPutItemInContainer = sendPutItemInContainer; _itemInteraction = itemInteraction; WindowChromeController.BindCloseButton(layout, onClose); _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 = ContentsCellPx; _contentsGrid.CellHeight = ContentsCellPx; } // Bind the gutter scrollbar to the contents grid's scroll model (the factory built // 0x100001C7 as a bare Type-11 UiScrollbar; wire it like ChatWindowController does). if (_contentsGrid is not null && layout.FindElement(ContentsScrollbarId) is UiScrollbar bar) { bar.Model = _contentsGrid.Scroll; bar.SpriteResolve = _contentsGrid.SpriteResolve; // chrome resolve from the factory ctor bar.TrackSprite = ScrollTrackSprite; bar.ThumbSprite = ScrollThumbSprite; bar.ThumbTopSprite = ScrollThumbTop; bar.ThumbBotSprite = ScrollThumbBot; bar.UpSprite = ScrollUpSprite; bar.DownSprite = ScrollDownSprite; } if (_containerList is not null) { _containerList.Columns = 1; _containerList.CellWidth = BackpackCellPx; _containerList.CellHeight = BackpackCellPx; } // Per-list empty-slot art, resolved from the dat cell template (retail attr 0x1000000e // -> catalog 0x21000037 prototype's ItemSlot_Empty). 0 = leave the UiItemSlot default. if (_contentsGrid is not null) _contentsGrid.CellEmptySprite = contentsEmptySprite; if (_containerList is not null) _containerList.CellEmptySprite = sideBagEmptySprite; if (_topContainer is not null) _topContainer.CellEmptySprite = mainPackEmptySprite; // B-Drag: register this controller as the drag handler on all three lists. _contentsGrid?.RegisterDragHandler(this); _containerList?.RegisterDragHandler(this); _topContainer?.RegisterDragHandler(this); // 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: drive each host UiText directly with the known string (the caption // elements resolve to UiText). "Contents of Backpack" + "%d%%" are procedural in retail // (gm3DItemsUI/gmBackpackUI PostInit/SetLoadLevel); "Burden" is the dat label. (Spec §5.) AttachCaption(layout.FindElement(TitleTextId), () => "Inventory of " + OwnerName(), datFont); AttachCaption(layout.FindElement(BurdenCaptionId), () => "Burden", datFont); AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of " + OpenContainerName(), 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; if (_itemInteraction is not null) _itemInteraction.StateChanged += OnInteractionStateChanged; Populate(); } public static InventoryController Bind( ImportedLayout layout, ClientObjectTable objects, Func playerGuid, Func iconIds, Func strength, UiDatFont? datFont, Func? ownerName = null, uint contentsEmptySprite = 0u, uint sideBagEmptySprite = 0u, uint mainPackEmptySprite = 0u, Action? sendUse = null, Action? sendNoLongerViewing = null, Action? sendPutItemInContainer = null, ItemInteractionController? itemInteraction = null, Action? onClose = null) => new InventoryController(layout, objects, playerGuid, iconIds, strength, ownerName, datFont, contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite, sendUse, sendNoLongerViewing, sendPutItemInContainer, itemInteraction, onClose); 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(); } private void OnInteractionStateChanged() => ApplyIndicators(); /// 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.ObjectId == p // the player object IS the burden source || o.ContainerId == p || o.WielderId == p || o.ContainerId == EffectiveOpen() // the OPEN container's contents drive the grid || (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p); // 2-deep } public void Populate() { uint p = _playerGuid(); uint open = EffectiveOpen(); _contentsGrid?.Flush(); _containerList?.Flush(); // Side-bag column: ALWAYS the player's bags (constant across container switches; only the // open/selected indicators move). Equipped items never appear here. foreach (var guid in _objects.GetContents(p)) { var item = _objects.Get(guid); if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue; bool isBag = IsBag(item); if (isBag) AddCell(_containerList, guid, isContainer: true); } // Contents grid: the OPEN container's loose items. (Bags live in the column; a side bag has // no sub-bags, so the isBag skip is a no-op when a bag is open.) foreach (var guid in _objects.GetContents(open)) { var item = _objects.Get(guid); if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue; bool isBag = IsBag(item); if (!isBag) AddCell(_contentsGrid, guid, isContainer: false); } // Pad the grid to the OPEN container's capacity (main pack 102 / side bag 24). if (_contentsGrid is not null) { int cap = _objects.Get(open)?.ItemsCapacity ?? 0; int slots = cap > 0 ? cap : (open == p ? MainPackSlots : SidePackSlots); while (_contentsGrid.GetNumUIItems() < slots) AddEmptyCell(_contentsGrid); } // Pad the side-bag column to capacity, clamped to the 7-slot column (AP-52). if (_containerList is not null) { int capacity = _objects.Get(p)?.ContainersCapacity ?? 0; int bags = _containerList.GetNumUIItems(); int slots = capacity > 0 ? capacity : SideBagSlots; slots = Math.Max(slots, bags); slots = Math.Min(slots, SideBagSlots); while (_containerList.GetNumUIItems() < slots) AddEmptyCell(_containerList); } // Main-pack cell: the player's own container — clicking it opens/selects the main pack. // Retail draws a CONSTANT backpack icon here, NOT the player's body icon: IconData::RenderIcons // (0x0058d1ee) has an IsThePlayer() branch that draws a fixed backpack with m_itemType = // TYPE_CONTAINER. Compose that backpack base over the Container type-underlay (the player // object's own IconId is the character body, which would render wrong here). The backpack // RenderSurface is 0x0600127E, VISUALLY CONFIRMED at the live gate 2026-06-22 (the earlier // 0x060011F4 from a research dat-dump of GetDIDByEnum(0x10000004,7) was a green tile, not the // pack — the index value was misreported). Retires AP-51. if (_topContainer is not null) { const uint PlayerPackBaseIcon = 0x0600127Eu; // constant main-pack backpack (visual gate) _topContainer.Flush(); var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve }; main.SetItem(p, _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u)); main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u; main.Clicked = () => { if (_itemInteraction?.AcquireSelfTarget() == true) return; OpenContainer(p); }; main.DoubleClicked = () => _itemInteraction?.ActivateItem(p); SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity) _topContainer.AddItem(main); } ApplyIndicators(); RefreshBurden(); } /// The effective open container — the explicit one, or the player (main pack) by default. /// Resolved live (not cached at ctor) so a late-arriving player guid is handled. The default /// sentinel is 0; once the main pack is explicitly opened, _openContainer holds the player /// guid instead — both resolve here to the same main-pack container, so the paths are equivalent. private static bool IsBag(ClientObject item) => item.ContainerTypeHint != 0u || item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0; private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid(); /// Add a populated cell wired to its click role: container cell → open+select, /// item cell → select-only. private void AddCell(UiItemList? list, uint guid, bool isContainer) { if (list is null) return; var item = _objects.Get(guid); uint tex = item is null ? 0u : _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects); var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve }; cell.SetItem(guid, tex); cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list) cell.DragAcceptSprite = 0x060011F7u; // green insert arrow (export-confirmed) cell.DragRejectSprite = 0x060011F8u; // red circle cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid); if (isContainer) { cell.Clicked = () => OpenContainer(guid); SetCapacityBar(cell, guid); } else cell.Clicked = () => SelectItem(guid); list.AddItem(cell); } private static void AddEmptyCell(UiItemList list) { var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve, SlotIndex = list.GetNumUIItems() }; cell.DragAcceptSprite = 0x060011F7u; cell.DragRejectSprite = 0x060011F8u; list.AddItem(cell); } /// 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); } // ── IItemListDragHandler (B-Drag) — drop an item to move it (optimistic + wire) ────────────── /// Inventory items do NOT lift-remove (unlike the toolbar): the item stays in its slot /// until the server confirms the move. Retail dims the source; we leave it + the floating ghost. public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { } /// Advisory accept/reject overlay (green insert-arrow / red circle). Grid drops are always /// valid (reorder/insert); a side-bag/main-pack drop is valid unless that container is KNOWN-full. public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) { if (payload.ObjId == 0) return false; if (targetList == _contentsGrid) return true; if (targetList == _containerList || targetList == _topContainer) { if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId) return false; return !IsContainerFull(targetCell.ItemId); } return false; } /// Perform the move: resolve (container, placement) from the target, optimistically move /// locally (instant), and send PutItemInContainer. Retail: HandleDropRelease + ItemList_InsertItem. public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) { uint item = payload.ObjId; if (item == 0) return; uint container; int placement; if (targetList == _contentsGrid) { container = EffectiveOpen(); placement = targetCell.ItemId != 0 ? targetCell.SlotIndex // insert-before = the target's GRID INDEX (gapless), not its raw ContainerSlot : _objects.GetContents(container).Count; // first empty = append } else if (targetList == _containerList || targetList == _topContainer) { if (targetCell.ItemId == 0 || targetCell.ItemId == item) return; container = targetCell.ItemId; // the bag / main pack if (IsContainerFull(container)) return; // red already shown placement = _objects.GetContents(container).Count; // append into it } else return; if (container == item) return; // never into itself _objects.MoveItemOptimistic(item, container, placement); // instant local move _sendPutItemInContainer?.Invoke(item, container, placement); } /// True only when we KNOW the container is full (capacity known + contents indexed). A /// closed bag (unknown count) returns false → advisory accept; the server is authoritative. private bool IsContainerFull(uint container) { int cap = _objects.Get(container)?.ItemsCapacity ?? 0; if (cap <= 0) return false; return _objects.GetContents(container).Count >= cap; } /// 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) { if (guid == 0) return; _selectedItem = guid; ApplyIndicators(); } /// Open a container (side bag or main pack): it becomes both the selected item and the /// open container. Sends Use to open a side bag server-side (ViewContents arrives async), and /// NoLongerViewingContents when switching away from a previously-open side bag. Retail: /// gmBackpackUI container-selection + CM_Physics::Event_Use. private void OpenContainer(uint guid) { if (guid == 0) return; _selectedItem = guid; // the container cell is also the selected item uint open = EffectiveOpen(); if (guid == open) { ApplyIndicators(); return; } // already open — just move the square uint p = _playerGuid(); if (open != p && open != 0) _sendNoLongerViewing?.Invoke(open); // close the previously-open side bag _openContainer = guid; if (guid != p) _sendUse?.Invoke(guid); // open the side bag (ViewContents will land) Populate(); // repaint the grid for the new open container } /// Stamp the open-container triangle + selected-item square onto every cell per the /// retail keying (item.itemID == openContainerId / selectedItemId). private void ApplyIndicators() { SetIndicators(_contentsGrid); SetIndicators(_containerList); SetIndicators(_topContainer); } private void SetIndicators(UiItemList? list) { if (list is null) return; uint open = EffectiveOpen(); for (int i = 0; i < list.GetNumUIItems(); i++) { var cell = list.GetItem(i); if (cell is null) continue; bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true; cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem && !pendingTargetSource; cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open; } } /// The open container's display name for the contents caption. private string OpenContainerName() { uint open = EffectiveOpen(); return open == _playerGuid() ? "Backpack" : (_objects.Get(open)?.Name ?? "Backpack"); } private string OwnerName() { var name = _ownerName?.Invoke(); return string.IsNullOrWhiteSpace(name) ? "Player" : name; } private void AttachCaption(UiElement? host, Func text, UiDatFont? datFont) { if (host is null) return; // The caption elements (0x100001D7 "Burden", 0x100001C5 "Contents of Backpack", // 0x100001D8 "%") resolve to UiText (Type-0 inheriting a text base — confirmed live). // Drive the host UiText DIRECTLY: it is already in the paint tree and renders, whereas // a nested child UiText did not paint. Set it to a static centered single-line label. if (host is UiText t) { t.Centered = true; t.DatFont = datFont; t.ClickThrough = true; t.AcceptsFocus = false; t.IsEditControl = false; t.CapturesPointerDrag = false; t.LinesProvider = () => { var s = text(); return string.IsNullOrEmpty(s) ? Array.Empty() : new[] { new UiText.Line(s, CaptionColor) }; }; return; } // Fallback (non-UiText host): attach a centered label child. 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; if (_itemInteraction is not null) _itemInteraction.StateChanged -= OnInteractionStateChanged; } }