acdream/src/AcDream.App/UI/Layout/InventoryController.cs
Erik 15aa3b9aff fix(ui): complete retail inventory scroll polish
Preserve pixel scroll offsets across inventory rebuilds, crop partially visible rows with nested geometry/UV clips, and replace the obsolete 560px resize ceiling with available screen height. Keep retail's row-sized wheel step while allowing continuous scrollbar thumb positions.

Co-Authored-By: Codex <codex@openai.com>
2026-07-11 10:56:33 +02:00

680 lines
32 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
using AcDream.App.UI;
using AcDream.Core.Items;
using AcDream.Core.Selection;
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).
/// Container-switching is live (click a side bag → Use 0x0036 → ViewContents 0x0196 full-replace);
/// drag-into-bag / wield-drop wire are later sub-phases.
/// </summary>
public sealed class InventoryController : IItemListDragHandler, IRetainedPanelController
{
// 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 <name>"
public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid)
private const uint BackdropId = 0x100001D0u;
private const uint ContentsWindowId = 0x100001CFu;
private const uint PaperdollWindowId = 0x100001CDu;
private const uint BackpackWindowId = 0x100001CEu;
// 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<uint> _playerGuid;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
private readonly Func<int?> _strength;
private readonly Func<string>? _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 readonly SelectionState _selection;
private readonly Action<uint>? _sendUse;
private readonly Action<uint>? _sendNoLongerViewing;
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
private readonly Action<uint, uint, uint, uint>? _sendStackableSplitToContainer;
private readonly Action<uint, uint, uint>? _sendStackableMerge;
private readonly Action<uint, uint>? _notifyMergeAttempt;
private readonly ItemInteractionController? _itemInteraction;
private readonly StackSplitQuantityState? _stackSplitQuantity;
private bool _disposed;
// 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,
SelectionState selection,
Func<string>? ownerName,
UiDatFont? datFont,
uint contentsEmptySprite,
uint sideBagEmptySprite,
uint mainPackEmptySprite,
Action<uint>? sendUse,
Action<uint>? sendNoLongerViewing,
Action<uint, uint, int>? sendPutItemInContainer,
Action<uint, uint, uint, uint>? sendStackableSplitToContainer,
Action<uint, uint, uint>? sendStackableMerge,
Action<uint, uint>? notifyMergeAttempt,
ItemInteractionController? itemInteraction,
Action? onClose,
StackSplitQuantityState? stackSplitQuantity)
{
_objects = objects;
_playerGuid = playerGuid;
_iconIds = iconIds;
_strength = strength;
_ownerName = ownerName;
_sendUse = sendUse;
_sendNoLongerViewing = sendNoLongerViewing;
_sendPutItemInContainer = sendPutItemInContainer;
_sendStackableSplitToContainer = sendStackableSplitToContainer;
_sendStackableMerge = sendStackableMerge;
_notifyMergeAttempt = notifyMergeAttempt;
_itemInteraction = itemInteraction;
_stackSplitQuantity = stackSplitQuantity;
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
WindowChromeController.BindCloseButton(layout, onClose);
_contentsGrid = layout.FindElement(ContentsGridId) as UiItemList;
_containerList = layout.FindElement(ContainerListId) as UiItemList;
_topContainer = layout.FindElement(TopContainerId) as UiItemList;
ConfigureResizeLayout(layout);
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
// already imported its inherited track/thumb/arrow media from LayoutDesc.
if (_contentsGrid is not null
&& layout.FindElement(ContentsScrollbarId) is UiScrollbar bar)
{
bar.Model = _contentsGrid.Scroll;
bar.SpriteResolve ??= _contentsGrid.SpriteResolve;
}
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 += OnObjectRemoved;
_objects.ObjectUpdated += OnObjectChanged;
_selection.Changed += OnSelectionChanged;
if (_itemInteraction is not null)
_itemInteraction.StateChanged += OnInteractionStateChanged;
Populate();
}
internal static void ConfigureResizeLayout(ImportedLayout layout)
{
static void Set(ImportedLayout imported, uint id, AnchorEdges anchors)
{
if (imported.FindElement(id) is not { } element) return;
element.Anchors = anchors;
element.CaptureCurrentAnchorBaseline();
}
AnchorEdges stretch = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom;
Set(layout, BackdropId, stretch);
Set(layout, ContentsWindowId, stretch);
Set(layout, ContentsGridId, stretch);
Set(layout, ContentsScrollbarId, stretch);
Set(layout, PaperdollWindowId, AnchorEdges.Left | AnchorEdges.Top);
Set(layout, BackpackWindowId, AnchorEdges.Left | AnchorEdges.Top);
}
public static InventoryController Bind(
ImportedLayout layout,
ClientObjectTable objects,
Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Func<int?> strength,
SelectionState selection,
UiDatFont? datFont,
Func<string>? ownerName = null,
uint contentsEmptySprite = 0u,
uint sideBagEmptySprite = 0u,
uint mainPackEmptySprite = 0u,
Action<uint>? sendUse = null,
Action<uint>? sendNoLongerViewing = null,
Action<uint, uint, int>? sendPutItemInContainer = null,
Action<uint, uint, uint, uint>? sendStackableSplitToContainer = null,
Action<uint, uint, uint>? sendStackableMerge = null,
Action<uint, uint>? notifyMergeAttempt = null,
ItemInteractionController? itemInteraction = null,
Action? onClose = null,
StackSplitQuantityState? stackSplitQuantity = null)
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, selection,
ownerName, datFont,
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
sendUse, sendNoLongerViewing, sendPutItemInContainer,
sendStackableSplitToContainer, sendStackableMerge,
notifyMergeAttempt, itemInteraction,
onClose, stackSplitQuantity);
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
private void OnObjectRemoved(ClientObject o)
{
if (_selection.SelectedObjectId == o.ObjectId)
{
_selection.Clear(
SelectionChangeSource.System,
SelectionChangeReason.SelectedObjectRemoved);
}
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();
private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators();
/// <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.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()
{
// Retail refreshes listbox contents without resetting m_iScrollableY.
// Defer our procedural rebuild so the first re-added cell cannot collapse
// MaxScroll temporarily and clamp the user's offset back to zero.
using IDisposable? contentsLayout = _contentsGrid?.DeferLayout();
using IDisposable? containersLayout = _containerList?.DeferLayout();
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?.OfferSelfPrimaryClick()
is not null and not ItemPrimaryClickResult.NotActive) return;
OpenContainer(p);
};
main.DoubleClicked = () => _itemInteraction?.ActivateItem(p);
SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity)
_topContainer.AddItem(main);
}
ApplyIndicators();
RefreshBurden();
}
/// <summary>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, <c>_openContainer</c> holds the player
/// guid instead — both resolve here to the same main-pack container, so the paths are equivalent.</summary>
private static bool IsBag(ClientObject item) =>
item.ContainerTypeHint != 0u
|| item.Type.HasFlag(ItemType.Container)
|| item.ItemsCapacity > 0;
private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid();
/// <summary>Add a populated cell wired to its click role: container cell → open+select,
/// item cell → select-only.</summary>
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 = () => HandlePrimaryClick(guid, () => OpenContainer(guid));
SetCapacityBar(cell, guid);
}
else
{
cell.Clicked = () => HandlePrimaryClick(guid, () => SelectItem(guid));
}
list.AddItem(cell);
}
private void HandlePrimaryClick(uint guid, Action fallback)
{
if (_itemInteraction?.OfferPrimaryClick(guid)
is not null and not ItemPrimaryClickResult.NotActive)
return;
fallback();
}
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);
}
/// <summary>Set the per-cell container capacity bar — retail UIElement_UIItem::UpdateCapacityDisplay
/// (0x004e16e0): visible only for a container with itemsCapacity &gt; 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.</summary>
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) ──────────────
/// <summary>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.</summary>
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { }
/// <summary>Advisory neutral/accept/reject overlay. Shortcut aliases stay neutral; physical grid
/// drops accept; a side-bag/main-pack drop rejects only when that container is known full.</summary>
public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
// UIElement_ItemList::ItemList_DragOver @ 0x004E3400 only evaluates
// physical targets when (DropItemFlags & 0xE) == 0. A toolbar alias
// carries bit 4: leave the inventory target neutral while the alias's
// remove-on-lift stands.
if (payload.SourceKind == ItemDragSource.ShortcutBar)
return ItemDragAcceptance.None;
if (payload.ObjId == 0)
return ItemDragAcceptance.Reject;
if (targetList == _contentsGrid)
return ItemDragAcceptance.Accept;
if (targetList == _containerList || targetList == _topContainer)
{
if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId)
return ItemDragAcceptance.Reject;
return IsContainerFull(targetCell.ItemId)
? ItemDragAcceptance.Reject
: ItemDragAcceptance.Accept;
}
return ItemDragAcceptance.Reject;
}
/// <summary>Resolve the destination and either split or move the stack. A partial split waits
/// for the server-created object's guid; a whole move remains optimistic. Retail:
/// <c>ItemHolder::AttemptToPlaceInContainer @ 0x00588140</c>.</summary>
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
// UIElement_ItemList::HandleDropRelease @ 0x004E4790 applies the same
// (flags & 0xE) == 0 gate before AcceptDragObject. A shortcut alias is
// not the equipped/contained object it points at.
if (payload.SourceKind == ItemDragSource.ShortcutBar)
return;
uint item = payload.ObjId;
if (item == 0) return;
// UIElement_ItemList::AcceptDragObject tries ItemHolder::AttemptMerge first
// when a physical item is released on an occupied inventory cell. A legal
// merge consumes the drop; an illegal merge falls through to insert/move.
if (targetList == _contentsGrid
&& targetCell.ItemId != 0
&& TryMergeStacks(item, targetCell.ItemId))
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
if (_objects.Get(item) is { } source)
{
uint fullStack = (uint)Math.Max(source.StackSize, 1);
uint splitSize = _stackSplitQuantity?.GetObjectSplitSize(
item, _selection.SelectedObjectId ?? 0u, fullStack) ?? fullStack;
if (splitSize < fullStack)
{
// UIAttemptSplitToContainer leaves the source stack where it is. ACE will
// publish the reduced source plus a newly-guided destination stack.
_sendStackableSplitToContainer?.Invoke(
item, container, (uint)placement, splitSize);
return;
}
}
_objects.MoveItemOptimistic(item, container, placement); // instant local move
_sendPutItemInContainer?.Invoke(item, container, placement);
}
private bool TryMergeStacks(uint sourceId, uint targetId)
{
if (_sendStackableMerge is null
|| _objects.Get(sourceId) is not { } source
|| _objects.Get(targetId) is not { } target)
return false;
int requestedAmount = _selection.SelectedObjectId == sourceId
&& _stackSplitQuantity is not null
? (int)Math.Min(_stackSplitQuantity.Value, int.MaxValue)
: Math.Max(1, source.StackSize);
StackMergePlan? plan = StackMergePlanner.Plan(
new StackMergeItem(
source.ObjectId,
source.WeenieClassId,
source.StackSize,
source.StackSizeMax,
source.TradeState),
new StackMergeItem(
target.ObjectId,
target.WeenieClassId,
target.StackSize,
target.StackSizeMax,
target.TradeState),
_itemInteraction?.CanMakeInventoryRequest ?? true,
requestedAmount);
if (plan is not { } merge)
return false;
_sendStackableMerge(merge.SourceObjectId, merge.TargetObjectId, merge.Amount);
// ItemHolder::AttemptMerge broadcasts this immediately after UIAttemptMerge;
// it is an attempt notice, not a later server-confirm event.
_notifyMergeAttempt?.Invoke(merge.SourceObjectId, merge.TargetObjectId);
_selection.Select(merge.TargetObjectId, SelectionChangeSource.Inventory);
return true;
}
/// <summary>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.</summary>
private bool IsContainerFull(uint container)
{
int cap = _objects.Get(container)?.ItemsCapacity ?? 0;
if (cap <= 0) return false;
return _objects.GetContents(container).Count >= cap;
}
/// <summary>Select an item (panel-wide green square) without changing the open container or
/// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0).</summary>
private void SelectItem(uint guid)
{
if (guid == 0) return;
_selection.Select(guid, SelectionChangeSource.Inventory);
}
/// <summary>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.</summary>
private void OpenContainer(uint guid)
{
if (guid == 0) return;
_selection.Select(guid, SelectionChangeSource.Inventory);
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
}
/// <summary>Stamp the open-container triangle + selected-item square onto every cell per the
/// retail keying (item.itemID == openContainerId / selectedItemId).</summary>
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 == _selection.SelectedObjectId
&& !pendingTargetSource;
cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open;
}
}
/// <summary>The open container's display name for the contents caption.</summary>
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<string> 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.OneLine = 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<UiText.Line>()
: 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, OneLine = 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()
{
if (_disposed) return;
_disposed = true;
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectRemoved -= OnObjectRemoved;
_objects.ObjectUpdated -= OnObjectChanged;
_selection.Changed -= OnSelectionChanged;
if (_itemInteraction is not null)
_itemInteraction.StateChanged -= OnInteractionStateChanged;
}
}