feat(items): port retail external-container looting

Add the ClientUISystem ground-object lifecycle, authoritative root and nested ViewContents projections, replacement and close semantics, and the DAT-authored gmExternalContainerUI strip for chests and corpses.

Route double-click loot and full or partial drag transfers through the shared retail item policy without optimistic external ownership. Remove the incorrect NoLongerViewingContents behavior from owned side packs and retire AP-106/#196.

Release build succeeds and all 5,875 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 16:18:10 +02:00
parent 48a118db91
commit 20ce67b625
27 changed files with 1750 additions and 45 deletions

View file

@ -30,6 +30,8 @@ public sealed class ItemInteractionController : IDisposable
private readonly Action<uint, uint>? _sendWield;
private readonly Action<uint>? _sendDrop;
private readonly Action<uint, uint>? _sendSplitToWorld;
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
private readonly Action<uint, uint, uint, uint>? _sendSplitToContainer;
private readonly Action<uint, uint, uint>? _sendGive;
private readonly Action<string>? _toast;
private readonly Func<bool> _readyForInventoryRequest;
@ -39,6 +41,7 @@ public sealed class ItemInteractionController : IDisposable
private readonly Func<bool> _inNonCombatMode;
private readonly Func<uint, bool> _isComponentPack;
private readonly Action<uint>? _placeInBackpack;
private readonly Action<uint>? _requestExternalContainer;
private readonly Action<ItemPolicyAction>? _auxiliaryAction;
private readonly InteractionState _interactionState;
private readonly Func<uint> _selectedObjectId;
@ -78,7 +81,9 @@ public sealed class ItemInteractionController : IDisposable
Action<uint, uint, int>? sendPutItemInContainer = null,
Action<uint, uint, uint>? sendGive = null,
Func<bool>? dragOnPlayerOpensSecureTrade = null,
Action<string>? systemMessage = null)
Action<string>? systemMessage = null,
Action<uint, uint, uint, uint>? sendSplitToContainer = null,
Action<uint>? requestExternalContainer = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -88,6 +93,8 @@ public sealed class ItemInteractionController : IDisposable
_sendWield = sendWield;
_sendDrop = sendDrop;
_sendSplitToWorld = sendSplitToWorld;
_sendPutItemInContainer = sendPutItemInContainer;
_sendSplitToContainer = sendSplitToContainer;
_sendGive = sendGive;
_nowMs = nowMs ?? (() => Environment.TickCount64);
_toast = toast;
@ -98,6 +105,7 @@ public sealed class ItemInteractionController : IDisposable
_inNonCombatMode = inNonCombatMode ?? (() => false);
_isComponentPack = isComponentPack ?? (_ => false);
_placeInBackpack = placeInBackpack;
_requestExternalContainer = requestExternalContainer;
_auxiliaryAction = auxiliaryAction;
_selectedObjectId = selectedObjectId ?? (() => 0u);
_stackSplitQuantity = stackSplitQuantity;
@ -416,6 +424,10 @@ public sealed class ItemInteractionController : IDisposable
_sendUseWithTarget?.Invoke(action.ObjectId, action.TargetId);
acted |= _sendUseWithTarget is not null;
break;
case ItemPolicyActionKind.SetGroundObject:
_requestExternalContainer?.Invoke(action.ObjectId);
acted |= _requestExternalContainer is not null;
break;
case ItemPolicyActionKind.EnterTargetMode:
EnterTargetMode(action.ObjectId);
acted = true;
@ -464,6 +476,18 @@ public sealed class ItemInteractionController : IDisposable
action.ObjectId,
(uint)Math.Max(1, action.Amount));
break;
case ItemPolicyActionKind.PlaceInContainer:
{
uint fullStack = (uint)Math.Max(
1,
_objects.Get(action.ObjectId)?.StackSize ?? action.Amount);
uint amount = (uint)Math.Max(1, action.Amount);
if (amount < fullStack)
_sendSplitToContainer?.Invoke(action.ObjectId, action.TargetId, 0u, amount);
else
_sendPutItemInContainer?.Invoke(action.ObjectId, action.TargetId, 0);
break;
}
case ItemPolicyActionKind.Reject:
if (!string.IsNullOrWhiteSpace(action.Message))
_toast?.Invoke(action.Message);

View file

@ -0,0 +1,514 @@
using System;
using AcDream.Core.Items;
using AcDream.Core.Selection;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retail <c>gmExternalContainerUI</c> controller for LayoutDesc 0x21000008.
/// Chests, corpses, and other world containers share this bottom-screen strip;
/// it is intentionally independent from the owned backpack window.
/// </summary>
public sealed class ExternalContainerController : IItemListDragHandler, IRetainedPanelController
{
public const uint LayoutId = 0x21000008u;
public const uint RootId = 0x10000063u;
public const uint TopContainerId = 0x10000064u;
public const uint ContainerListId = 0x10000067u;
public const uint CloseButtonId = 0x10000068u;
public const uint ContentsListId = 0x1000006Au;
public const uint ContentsScrollbarId = 0x1000006Bu;
private const float ItemCellSize = 32f;
private const float ContainerCellSize = 36f;
private readonly ExternalContainerState _state;
private readonly ClientObjectTable _objects;
private readonly SelectionState _selection;
private readonly ItemInteractionController _itemInteraction;
private readonly StackSplitQuantityState _stackSplitQuantity;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _resolveIcon;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _resolveDragIcon;
private readonly Action<uint> _sendUse;
private readonly Action<uint, uint, int> _sendPutItemInContainer;
private readonly Action<uint, uint, uint, uint> _sendSplitToContainer;
private readonly Func<uint, bool> _isWithinUseRange;
private readonly RetailWindowHandle _window;
private readonly UiItemList _topContainer;
private readonly UiItemList _containerList;
private readonly UiItemList _contentsList;
private uint _openContainer;
private bool _closeRequested;
private bool _disposed;
private ExternalContainerController(
ImportedLayout layout,
ExternalContainerState state,
ClientObjectTable objects,
SelectionState selection,
ItemInteractionController itemInteraction,
StackSplitQuantityState stackSplitQuantity,
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon,
Func<ItemType, uint, uint, uint, uint, uint> resolveDragIcon,
Action<uint> sendUse,
Action<uint, uint, int> sendPutItemInContainer,
Action<uint, uint, uint, uint> sendSplitToContainer,
Func<uint, bool> isWithinUseRange,
RetailWindowHandle window,
uint contentsEmptySprite,
uint containerEmptySprite)
{
_state = state;
_objects = objects;
_selection = selection;
_itemInteraction = itemInteraction;
_stackSplitQuantity = stackSplitQuantity;
_resolveIcon = resolveIcon;
_resolveDragIcon = resolveDragIcon;
_sendUse = sendUse;
_sendPutItemInContainer = sendPutItemInContainer;
_sendSplitToContainer = sendSplitToContainer;
_isWithinUseRange = isWithinUseRange;
_window = window;
_topContainer = RequiredList(layout, TopContainerId);
_containerList = RequiredList(layout, ContainerListId);
_contentsList = RequiredList(layout, ContentsListId);
ConfigureList(_topContainer, ContainerCellSize, horizontalScroll: false, containerEmptySprite);
ConfigureList(_containerList, ContainerCellSize, horizontalScroll: true, containerEmptySprite);
ConfigureList(_contentsList, ItemCellSize, horizontalScroll: true, contentsEmptySprite);
_contentsList.RegisterDragHandler(this);
if (layout.FindElement(ContentsScrollbarId) is UiScrollbar scrollbar)
{
scrollbar.Model = _contentsList.Scroll;
scrollbar.Horizontal = true;
scrollbar.SpriteResolve ??= _contentsList.SpriteResolve;
// Horizontal base LayoutDesc 0x2100003E media. The compatibility
// factory treats all horizontal bars as scalar controls; this panel
// binds the authored model sprites explicitly.
scrollbar.TrackSprite = 0x06004C7Fu;
scrollbar.ThumbTopSprite = 0x06004C80u;
scrollbar.ThumbSprite = 0x06004C83u;
scrollbar.ThumbBotSprite = 0x06004C86u;
scrollbar.DownSprite = 0x06004C89u;
scrollbar.UpSprite = 0x06004C8Cu;
}
BindClose(layout, RequestClose);
_state.Changed += OnExternalContainerChanged;
_objects.ObjectAdded += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged;
_objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemoved += OnObjectRemoved;
_objects.ContainerContentsReplaced += OnContentsReplaced;
_objects.Cleared += OnObjectsCleared;
_selection.Changed += OnSelectionChanged;
_itemInteraction.StateChanged += OnInteractionStateChanged;
ClearLists();
}
public static ExternalContainerController Bind(
ImportedLayout layout,
ExternalContainerState state,
ClientObjectTable objects,
SelectionState selection,
ItemInteractionController itemInteraction,
StackSplitQuantityState stackSplitQuantity,
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon,
Func<ItemType, uint, uint, uint, uint, uint> resolveDragIcon,
Action<uint> sendUse,
Action<uint, uint, int> sendPutItemInContainer,
Action<uint, uint, uint, uint> sendSplitToContainer,
Func<uint, bool> isWithinUseRange,
RetailWindowHandle window,
uint contentsEmptySprite = 0u,
uint containerEmptySprite = 0u)
=> new(
layout,
state,
objects,
selection,
itemInteraction,
stackSplitQuantity,
resolveIcon,
resolveDragIcon,
sendUse,
sendPutItemInContainer,
sendSplitToContainer,
isWithinUseRange,
window,
contentsEmptySprite,
containerEmptySprite);
public void Tick()
{
uint root = _state.CurrentContainerId;
if (root != 0u && _window.IsVisible && !_closeRequested && !_isWithinUseRange(root))
RequestClose();
}
public void RequestClose()
{
uint root = _state.CurrentContainerId;
if (root == 0u || _closeRequested)
return;
// gmExternalContainerUI::CloseCurrentContainer @ 0x004CBCB0 sends
// Use(root), detaches the lists, and waits for event 0x0052. It does not
// send NoLongerViewingContents on this path.
_closeRequested = true;
_window.Hide();
ClearLists();
_sendUse(root);
}
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
{
if (payload.ObjId != 0u)
_selection.Select(payload.ObjId, SelectionChangeSource.ExternalContainer);
}
public ItemDragAcceptance OnDragOver(
UiItemList targetList,
UiItemSlot targetCell,
ItemDragPayload payload)
{
if (!ReferenceEquals(targetList, _contentsList)
|| payload.SourceKind == ItemDragSource.ShortcutBar
|| payload.ObjId == 0u
|| _openContainer == 0u
|| payload.ObjId == _openContainer)
return ItemDragAcceptance.Reject;
return ItemDragAcceptance.Accept;
}
public void HandleDropRelease(
UiItemList targetList,
UiItemSlot targetCell,
ItemDragPayload payload)
{
if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept)
return;
if (_objects.Get(payload.ObjId) is not { } item)
return;
uint fullStack = (uint)Math.Max(1, item.StackSize);
uint amount = _stackSplitQuantity.GetObjectSplitSize(
item.ObjectId,
_selection.SelectedObjectId ?? 0u,
fullStack);
int placement = targetCell.ItemId != 0u
? Math.Max(0, targetCell.SlotIndex)
: _objects.GetContents(_openContainer).Count;
// ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680 and
// UIAttemptSplitToContainer @ 0x0058D7D0 are request-only. Do not
// optimistically rewrite external ownership.
if (amount < fullStack)
_sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount);
else
_sendPutItemInContainer(item.ObjectId, _openContainer, placement);
}
private void OnExternalContainerChanged(ExternalContainerTransition transition)
{
if (transition.ContainerId == 0u)
{
_openContainer = 0u;
_closeRequested = false;
_window.Hide();
ClearLists();
return;
}
_openContainer = transition.ContainerId;
_closeRequested = false;
ScrollToHome();
Populate();
_window.Show();
}
private void Populate()
{
uint root = _state.CurrentContainerId;
if (root == 0u)
{
ClearLists();
return;
}
if (_openContainer == 0u)
_openContainer = root;
using IDisposable topLayout = _topContainer.DeferLayout();
using IDisposable containerLayout = _containerList.DeferLayout();
using IDisposable contentsLayout = _contentsList.DeferLayout();
_topContainer.Flush();
_containerList.Flush();
_contentsList.Flush();
AddRootCell(root);
foreach (uint guid in _objects.GetContents(root))
{
if (IsContainer(_objects.Get(guid)))
AddContainerCell(guid);
}
foreach (uint guid in _objects.GetContents(_openContainer))
{
if (!IsContainer(_objects.Get(guid)))
AddContentsCell(guid);
}
ApplyIndicators();
}
private void AddRootCell(uint guid)
{
UiItemSlot cell = CreateCell(_topContainer, guid, ItemDragSource.Ground);
cell.Clicked = () => HandlePrimaryClick(guid, () => Select(guid));
cell.DoubleClicked = RequestClose;
cell.IsOpenContainer = _openContainer == guid;
SetCapacity(cell, guid);
_topContainer.AddItem(cell);
}
private void AddContainerCell(uint guid)
{
UiItemSlot cell = CreateCell(_containerList, guid, ItemDragSource.Ground);
cell.Clicked = () => HandlePrimaryClick(guid, () => OpenNestedContainer(guid));
SetCapacity(cell, guid);
_containerList.AddItem(cell);
}
private void AddContentsCell(uint guid)
{
UiItemSlot cell = CreateCell(_contentsList, guid, ItemDragSource.Ground);
cell.Clicked = () => HandlePrimaryClick(guid, () => Select(guid));
cell.DoubleClicked = () => _itemInteraction.ActivateItem(guid);
cell.DragAcceptSprite = 0x060011F9u;
cell.DragRejectSprite = 0x060011F8u;
_contentsList.AddItem(cell);
}
private UiItemSlot CreateCell(UiItemList owner, uint guid, ItemDragSource source)
{
ClientObject? item = _objects.Get(guid);
uint icon = item is null ? 0u : _resolveIcon(
item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
uint dragIcon = item is null ? 0u : _resolveDragIcon(
item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
var cell = new UiItemSlot
{
SpriteResolve = owner.SpriteResolve,
SlotIndex = owner.GetNumUIItems(),
SourceKind = source,
};
cell.SetItem(guid, icon, dragIconTexture: dragIcon);
return cell;
}
private void OpenNestedContainer(uint guid)
{
Select(guid);
_openContainer = guid;
_contentsList.Scroll.SetScrollY(0);
Populate();
}
private void Select(uint guid)
=> _selection.Select(guid, SelectionChangeSource.ExternalContainer);
private void HandlePrimaryClick(uint guid, Action fallback)
{
if (_itemInteraction.OfferPrimaryClick(guid) != ItemPrimaryClickResult.NotActive)
return;
fallback();
}
private void ApplyIndicators()
{
ApplyIndicators(_topContainer);
ApplyIndicators(_containerList);
ApplyIndicators(_contentsList);
}
private void ApplyIndicators(UiItemList list)
{
for (int i = 0; i < list.GetNumUIItems(); i++)
{
if (list.GetItem(i) is not { } cell) continue;
bool pendingSource = _itemInteraction.IsPendingSource(cell.ItemId);
cell.Selected = cell.ItemId != 0u
&& cell.ItemId == _selection.SelectedObjectId
&& !pendingSource;
cell.IsOpenContainer = cell.ItemId != 0u && cell.ItemId == _openContainer;
}
}
private void SetCapacity(UiItemSlot cell, uint containerId)
{
int capacity = _objects.Get(containerId)?.ItemsCapacity ?? 0;
cell.CapacityFill = capacity <= 0
? -1f
: Math.Clamp(_objects.GetContents(containerId).Count / (float)capacity, 0f, 1f);
}
private void ClearLists()
{
_topContainer.Flush();
_containerList.Flush();
_contentsList.Flush();
ScrollToHome();
}
private void ScrollToHome()
{
_topContainer.Scroll.SetScrollY(0);
_containerList.Scroll.SetScrollY(0);
_contentsList.Scroll.SetScrollY(0);
}
private void OnObjectChanged(ClientObject item)
{
if (_window.IsVisible && Concerns(item))
Populate();
}
private void OnObjectMoved(ClientObjectMove move)
{
if (!_window.IsVisible) return;
uint root = _state.CurrentContainerId;
if ((move.Item is { } item && Concerns(item))
|| move.Previous.ContainerId == root
|| move.Current.ContainerId == root
|| move.Previous.ContainerId == _openContainer
|| move.Current.ContainerId == _openContainer)
{
Populate();
}
}
private void OnContentsReplaced(uint containerId)
{
if (_window.IsVisible
&& (containerId == _state.CurrentContainerId
|| containerId == _openContainer
|| ProjectionContains(_state.CurrentContainerId, containerId)))
Populate();
}
private void OnObjectRemoved(ClientObject item)
{
if (_selection.SelectedObjectId == item.ObjectId)
{
_selection.Clear(
SelectionChangeSource.System,
SelectionChangeReason.SelectedObjectRemoved);
}
if (item.ObjectId == _state.CurrentContainerId)
_state.ApplyClose(item.ObjectId);
else if (_window.IsVisible && Concerns(item))
Populate();
}
private void OnObjectsCleared()
{
_openContainer = 0u;
_closeRequested = false;
_window.Hide();
ClearLists();
}
private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators();
private void OnInteractionStateChanged() => ApplyIndicators();
private static bool IsContainer(ClientObject? item)
=> item is not null
&& (item.ContainerTypeHint != 0u
|| item.Type.HasFlag(ItemType.Container)
|| item.ItemsCapacity > 0);
private bool Concerns(ClientObject item)
{
uint root = _state.CurrentContainerId;
return item.ObjectId == root
|| item.ObjectId == _openContainer
|| item.ContainerId == root
|| item.ContainerId == _openContainer
|| ProjectionContains(root, item.ObjectId)
|| ProjectionContains(_openContainer, item.ObjectId);
}
private bool ProjectionContains(uint containerId, uint itemId)
{
if (containerId == 0u || itemId == 0u) return false;
foreach (uint candidate in _objects.GetContents(containerId))
{
if (candidate == itemId) return true;
}
return false;
}
private static void ConfigureList(
UiItemList list,
float cellSize,
bool horizontalScroll,
uint emptySprite)
{
list.Columns = 1;
list.CellWidth = cellSize;
list.CellHeight = cellSize;
list.SingleRow = true;
list.HorizontalScroll = horizontalScroll;
list.FillVisibleEmptySlots = true;
if (emptySprite != 0u)
list.CellEmptySprite = emptySprite;
list.EmptySlotFactory = () => new UiItemSlot
{
SpriteResolve = list.SpriteResolve,
SourceKind = ItemDragSource.Ground,
DragAcceptSprite = 0x060011F9u,
DragRejectSprite = 0x060011F8u,
};
}
private static UiItemList RequiredList(ImportedLayout layout, uint id)
=> layout.FindElement(id) as UiItemList
?? throw new InvalidOperationException(
$"External-container LayoutDesc is missing ItemList 0x{id:X8}.");
private static void BindClose(ImportedLayout layout, Action close)
{
switch (layout.FindElement(CloseButtonId))
{
case UiButton button:
button.OnClick = close;
break;
case UiDatElement element:
element.ClickThrough = false;
element.OnClick = close;
break;
default:
throw new InvalidOperationException(
"External-container LayoutDesc is missing its close button.");
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_state.Changed -= OnExternalContainerChanged;
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectRemoved -= OnObjectRemoved;
_objects.ContainerContentsReplaced -= OnContentsReplaced;
_objects.Cleared -= OnObjectsCleared;
_selection.Changed -= OnSelectionChanged;
_itemInteraction.StateChanged -= OnInteractionStateChanged;
}
}

View file

@ -57,7 +57,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
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;
@ -84,7 +83,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
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,
@ -100,7 +98,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_strength = strength;
_ownerName = ownerName;
_sendUse = sendUse;
_sendNoLongerViewing = sendNoLongerViewing;
_sendPutItemInContainer = sendPutItemInContainer;
_sendStackableSplitToContainer = sendStackableSplitToContainer;
_sendStackableMerge = sendStackableMerge;
@ -212,7 +209,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
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,
@ -224,7 +220,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
=> new InventoryController(layout, objects, playerGuid, iconIds, dragIconIds, strength, selection,
ownerName, datFont,
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
sendUse, sendNoLongerViewing, sendPutItemInContainer,
sendUse, sendPutItemInContainer,
sendStackableSplitToContainer, sendStackableMerge,
notifyMergeAttempt, itemInteraction,
onClose, stackSplitQuantity);
@ -529,7 +525,12 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
}
}
_objects.MoveItemOptimistic(item, container, placement); // instant local move
// External-container contents are a temporary ViewContents projection.
// Retail UIAttemptPutInContainer leaves them untouched until the server's
// authoritative move response; only rearrangements within owned inventory
// use the existing optimistic projection.
if (payload.SourceKind != ItemDragSource.Ground)
_objects.MoveItemOptimistic(item, container, placement);
_sendPutItemInContainer?.Invoke(item, container, placement);
}
@ -588,8 +589,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
}
/// <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:
/// open container. Sends Use to open a side bag server-side (ViewContents arrives async).
/// Owned packs are not ClientUISystem::groundObject and never send
/// NoLongerViewingContents. Retail:
/// gmBackpackUI container-selection + CM_Physics::Event_Use.</summary>
private void OpenContainer(uint guid)
{
@ -599,8 +601,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
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)

View file

@ -118,13 +118,24 @@ public sealed record InventoryRuntimeBindings(
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
Func<int?> Strength,
Action<uint>? SendUse,
Action<uint>? SendNoLongerViewing,
Action<uint, uint, int>? SendPutItemInContainer,
Action<uint, uint, uint, uint>? SendStackableSplitToContainer,
Action<uint, uint, uint>? SendStackableMerge,
ItemInteractionController ItemInteraction,
SelectionState Selection);
public sealed record ExternalContainerRuntimeBindings(
ExternalContainerState State,
ClientObjectTable Objects,
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
ItemInteractionController ItemInteraction,
SelectionState Selection,
Action<uint> SendUse,
Action<uint, uint, int> SendPutItemInContainer,
Action<uint, uint, uint, uint> SendStackableSplitToContainer,
Func<uint, bool> IsWithinUseRange);
public sealed record RetailUiPersistenceBindings(
SettingsStore Store,
Func<string> CharacterKey,
@ -157,6 +168,7 @@ public sealed record RetailUiRuntimeBindings(
ToolbarRuntimeBindings Toolbar,
CharacterRuntimeBindings Character,
InventoryRuntimeBindings Inventory,
ExternalContainerRuntimeBindings ExternalContainer,
RetailUiCursorBindings Cursor,
ConfirmationRuntimeBindings Confirmations,
StackSplitQuantityState StackSplitQuantity,
@ -204,6 +216,7 @@ public sealed class RetailUiRuntime : IDisposable
MountCharacter();
MountPlugins();
MountInventory();
MountExternalContainer();
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
BindToolbarPanelButtons();
SyncToolbarWindowButtons();
@ -215,7 +228,12 @@ public sealed class RetailUiRuntime : IDisposable
persistence.Store,
persistence.CharacterKey,
persistence.ScreenSize,
stateManagedVisibilityWindows: [WindowNames.Combat, WindowNames.JumpPowerbar]);
stateManagedVisibilityWindows:
[
WindowNames.Combat,
WindowNames.JumpPowerbar,
WindowNames.ExternalContainer,
]);
}
if (bindings.Probe.Enabled)
@ -257,6 +275,7 @@ public sealed class RetailUiRuntime : IDisposable
public UiViewport? PaperdollViewportWidget { get; private set; }
public UiNineSlicePanel? InventoryFrame { get; private set; }
public RetailDialogFactory? DialogFactory { get; private set; }
public ExternalContainerController? ExternalContainerController { get; private set; }
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{
@ -283,6 +302,7 @@ public sealed class RetailUiRuntime : IDisposable
IndicatorBarController?.Tick();
JumpPowerbarController?.Tick();
SelectedObjectController?.Tick(deltaSeconds);
ExternalContainerController?.Tick();
DialogFactory?.Tick();
Host.Tick(deltaSeconds);
_automation?.Tick(deltaSeconds);
@ -1577,7 +1597,7 @@ public sealed class RetailUiRuntime : IDisposable
InventoryController inventory = InventoryController.Bind(
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Strength, b.Selection,
_bindings.Assets.DefaultFont, _bindings.Character.Provider.CharacterName,
contents, sideBag, mainPack, b.SendUse, b.SendNoLongerViewing,
contents, sideBag, mainPack, b.SendUse,
b.SendPutItemInContainer, b.SendStackableSplitToContainer, b.SendStackableMerge,
notifyMergeAttempt, b.ItemInteraction,
() => CloseWindow(WindowNames.Inventory),
@ -1596,6 +1616,83 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
}
private void MountExternalContainer()
{
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Import(
_bindings.Assets.Dats,
ExternalContainerController.LayoutId,
ExternalContainerController.RootId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (layout is null)
{
Console.WriteLine("[D.2b] external container: LayoutDesc 0x21000008 not found.");
return;
}
UiElement root = layout.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.ExternalContainer,
Chrome = RetailWindowChrome.Imported,
Left = root.Left,
Top = root.Top,
ContentWidth = root.Width,
ContentHeight = root.Height,
Visible = false,
Draggable = false,
Resizable = false,
ResizeX = false,
ResizeY = false,
});
uint contentsEmpty;
uint containerEmpty;
lock (_bindings.Assets.DatLock)
{
contentsEmpty = ItemListCellTemplate.ResolveEmptySprite(
_bindings.Assets.Dats,
ExternalContainerController.LayoutId,
ExternalContainerController.ContentsListId);
containerEmpty = ItemListCellTemplate.ResolveEmptySprite(
_bindings.Assets.Dats,
ExternalContainerController.LayoutId,
ExternalContainerController.ContainerListId);
}
ExternalContainerRuntimeBindings b = _bindings.ExternalContainer;
ExternalContainerController = ExternalContainerController.Bind(
layout,
b.State,
b.Objects,
b.Selection,
b.ItemInteraction,
StackSplitQuantity,
b.ResolveIcon,
b.ResolveDragIcon,
b.SendUse,
b.SendPutItemInContainer,
b.SendStackableSplitToContainer,
b.IsWithinUseRange,
handle,
contentsEmpty,
containerEmpty);
Host.WindowManager.AttachController(
WindowNames.ExternalContainer,
ExternalContainerController);
Console.WriteLine(
"[D.2b] retail external-container strip mounted from LayoutDesc 0x21000008.");
}
public void Dispose()
{
if (_disposed) return;

View file

@ -140,6 +140,13 @@ public sealed class UiItemList : UiElement
/// </summary>
public bool SingleRow { get; set; }
/// <summary>
/// The single row uses the shared pixel scroll model as a horizontal offset.
/// Retail's external-container list (LayoutDesc 0x21000008) is the authored
/// horizontal ItemList + Scrollbar consumer.
/// </summary>
public bool HorizontalScroll { get; set; }
/// <summary>
/// Maintain a tail of empty UIItems that fills the visible horizontal extent.
/// Port of UIElement_ItemList::UpdateEmptySlots @ 0x004E3700.
@ -206,6 +213,28 @@ public sealed class UiItemList : UiElement
: Columns < 1 ? 1 : Columns;
int cellH = (int)MathF.Round(CellHeight);
if (SingleRow && HorizontalScroll)
{
int cellW = Math.Max(1, (int)MathF.Round(CellWidth));
Scroll.LineHeight = cellW;
Scroll.ContentHeight = _cells.Count * cellW;
Scroll.ViewHeight = (int)MathF.Floor(Width);
Scroll.SetScrollY(Scroll.ScrollY);
float scrollX = Scroll.ScrollY;
for (int i = 0; i < _cells.Count; i++)
{
float left = i * CellWidth - scrollX;
UiItemSlot cell = _cells[i];
cell.Left = left;
cell.Top = 0f;
cell.Width = CellWidth;
cell.Height = CellHeight;
cell.Visible = left < Width && left + CellWidth > 0f;
}
return;
}
// Drive the shared scroll model from the current geometry, then re-clamp the offset to
// the (possibly changed) max. ContentHeight/ViewHeight must be set BEFORE reading ScrollY
// so the clamp uses the right max.
@ -292,6 +321,16 @@ public sealed class UiItemList : UiElement
public void ScrollItemIntoView(int index)
{
if (CellWidth <= 0f || index < 0 || index >= _cells.Count) return;
if (SingleRow && HorizontalScroll)
{
float left = index * CellWidth;
float right = left + CellWidth;
if (left < Scroll.ScrollY)
Scroll.SetScrollY((int)MathF.Floor(left));
else if (right > Scroll.ScrollY + Width)
Scroll.SetScrollY((int)MathF.Ceiling(right - Width));
return;
}
int columns = Math.Max(1, Columns);
int row = Flow == UiItemListFlow.ColumnMajor
? index % Math.Max(1, RowCount(_cells.Count, columns))

View file

@ -141,6 +141,11 @@ public sealed class UiScrollbar : UiElement
if (SpriteResolve is not { } resolve) return;
if (Horizontal)
{
if (ScalarChanged is null && Model is { } horizontalModel)
{
DrawHorizontalModel(ctx, resolve, horizontalModel);
return;
}
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
(float rangeLeft, float rangeWidth) = ScalarRangeRect();
if (ScalarRangeSprite != 0)
@ -196,6 +201,31 @@ public sealed class UiScrollbar : UiElement
}
}
private void DrawHorizontalModel(
UiRenderContext ctx,
Func<uint, (uint tex, int w, int h)> resolve,
UiScrollable model)
{
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, ButtonH, Height);
DrawSprite(ctx, resolve, DownSprite, Width - ButtonH, 0f, ButtonH, Height);
if (!model.HasOverflow) return;
float trackLeft = ButtonH;
float trackLength = MathF.Max(0f, Width - 2f * ButtonH);
var (tx, tw) = ThumbRect(model, trackLeft, trackLength);
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && tw >= 2f * CapH)
{
DrawSprite(ctx, resolve, ThumbTopSprite, tx, 0f, CapH, Height);
DrawTiled(ctx, resolve, ThumbSprite, tx + CapH, 0f, tw - 2f * CapH, Height);
DrawSprite(ctx, resolve, ThumbBotSprite, tx + tw - CapH, 0f, CapH, Height);
}
else
{
DrawTiled(ctx, resolve, ThumbSprite, tx, 0f, tw, Height);
}
}
/// <summary>Draw a sprite stretched 1:1 to the dest rect.</summary>
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h)
@ -263,6 +293,9 @@ public sealed class UiScrollbar : UiElement
if (Horizontal && ScalarChanged is not null)
return OnScalarEvent(e);
if (Horizontal && Model is not null)
return OnHorizontalModelEvent(e);
if (Model is not { } m) return false;
switch (e.Type)
@ -318,6 +351,50 @@ public sealed class UiScrollbar : UiElement
return false;
}
private bool OnHorizontalModelEvent(in UiEvent e)
{
UiScrollable m = Model!;
switch (e.Type)
{
case UiEventType.MouseDown:
{
float x = e.Data1;
if (x <= ButtonH) { m.ScrollByLines(-1); return true; }
if (x >= Width - ButtonH) { m.ScrollByLines(1); return true; }
float trackLeft = ButtonH;
float trackLength = MathF.Max(0f, Width - 2f * ButtonH);
var (tx, tw) = ThumbRect(m, trackLeft, trackLength);
if (x >= tx && x <= tx + tw)
{
_draggingThumb = true;
_dragOffsetX = x - tx;
}
else
{
m.ScrollByPage(x < tx ? -1 : 1);
}
return true;
}
case UiEventType.MouseMove when _draggingThumb:
{
float trackLeft = ButtonH;
float trackLength = MathF.Max(0f, Width - 2f * ButtonH);
float thumbWidth = MathF.Max(MinThumb, trackLength * m.ThumbRatio);
float travel = MathF.Max(1f, trackLength - thumbWidth);
float ratio = ((float)e.Data1 - _dragOffsetX - trackLeft) / travel;
m.SetPositionRatio(ratio);
return true;
}
case UiEventType.MouseUp:
_draggingThumb = false;
return true;
}
return false;
}
private bool OnScalarEvent(in UiEvent e)
{
switch (e.Type)

View file

@ -9,6 +9,7 @@ public static class WindowNames
public const string Character = "character";
public const string CharacterInformation = "character-information";
public const string Inventory = "inventory";
public const string ExternalContainer = "external-container";
public const string Chat = "chat";
public const string Radar = "radar";
public const string Combat = "combat";