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 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 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) // 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, uint contentsEmptySprite, uint sideBagEmptySprite, uint mainPackEmptySprite, Action? sendUse, Action? sendNoLongerViewing, Action? sendPutItemInContainer) { _objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _strength = strength; _sendUse = sendUse; _sendNoLongerViewing = sendNoLongerViewing; _sendPutItemInContainer = sendPutItemInContainer; _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(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; Populate(); } public static InventoryController Bind( ImportedLayout layout, ClientObjectTable objects, Func playerGuid, Func iconIds, Func strength, UiDatFont? datFont, uint contentsEmptySprite = 0u, uint sideBagEmptySprite = 0u, uint mainPackEmptySprite = 0u, Action? sendUse = null, Action? sendNoLongerViewing = null, Action? sendPutItemInContainer = null) => new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont, contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite, sendUse, sendNoLongerViewing, sendPutItemInContainer); 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.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 = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0; 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 = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0; 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 = () => OpenContainer(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 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 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; cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem; 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 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; } }