# Retail external-container and looting pseudocode Date: 2026-07-17 Scope: chests, corpses, and other world containers opened through `Use`; the bottom-of-screen external-container strip; looting into the player's carried inventory; returning carried items to the open external container; and the external-view close/replacement lifetime. Owned backpack and side-pack browsing is deliberately not part of this lifetime. ## Retail oracle Named Sept-2013 retail symbols: - `gmExternalContainerUI::ListenToElementMessage @ 0x004CBAD0` - `gmExternalContainerUI::SetGroundObject @ 0x004CBBD0` - `gmExternalContainerUI::CloseCurrentContainer @ 0x004CBCB0` - `CPlayerSystem::CalculateObjectRangeChecks @ 0x0055F360` - `CPlayerSystem::RegisterObjectRangeHandler @ 0x005602D0` - `ACCWeenieObject::ObjectsInRange @ 0x0058C1A0` - `CPhysicsObj::get_distance_to_object @ 0x0050F7A0` - `Position::cylinder_distance @ 0x005A97F0` - `gmExternalContainerUI::PostInit @ 0x004CBDE0` - `gmExternalContainerUI::OnVisibilityChanged @ 0x004CBF50` - `gmExternalContainerUI::RecvNotice_SetGroundObject @ 0x004CBFD0` - `gmExternalContainerUI::OnItemListDragOver @ 0x004CC0C0` - `UIElement_ItemList::ListenToElementMessage @ 0x004E4D50` - `ClientUISystem::SetGroundObject @ 0x00564510` - `ClientUISystem::OnViewContents @ 0x00565870` - `CPlayerSystem::PlaceInBackpack @ 0x0055D8C0` - `gmInventoryUI::RecvNotice_ShowPendingInPlayer @ 0x004A6B50` - `gmInventoryUI::RecvNotice_EndPendingInPlayer @ 0x004A68B0` - `UIElement_ItemList::ItemList_InsertItem @ 0x004E3F40` - `ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680` - `ACCWeenieObject::UIAttemptSplitToContainer @ 0x0058D7D0` - `ItemHolder::AttemptToPlaceInContainer @ 0x00588140` - `ItemHolder::UseObject @ 0x00588A80` - `CommandInterpreter::SendMovementEvent @ 0x006B4680` The exact client dispatch for server event `CloseGroundContainer (0x0052)` is at `0x0055B187` in `acclient_2013_pseudo_c.txt`. Cross-references: - ACE `GameEventViewContents`, `GameEventCloseGroundContainer`, `GameActionNoLongerViewingContents`, `Container.SendInventory`, and `Container.FinishClose` confirm the authoritative packet order and that ACE can send `ViewContents` for the root followed by its nested containers. - ACE `GameActionMoveToState.Handle` confirms that a movement state received after `Use` cancels the Use-created `CreateMoveToChain`. A cancelled chain still reaches `TryUseItem(success:false)` and produces successful `UseDone` without calling `Container.Open`, explaining the approach-with-no-loot trace. - holtburger protocol `inventory/events.rs` and `inventory/actions.rs` confirm the exact wire shapes. Its world inventory handler independently models open container contents as temporary preview retention and retires that preview on `CloseGroundContainer`. ## Authored UI The strip is LayoutDesc `0x21000008`, selected root `0x10000063` (`0,400`, `800 x 110`) rather than a synthetic inventory window. The root's own DirectState is only the tiled center surface `0x06004CC2`; the common floating-window nine-slice supplies the missing outer bevel. The authored Left/Right edge rules stretch the two horizontal lists and scrollbar while pinning the close button, so the mounted strip resizes horizontally only. The modern retained mount starts at a compact 700-pixel content width and may be resized from either side up to the viewport edge. | Element | Purpose | Authored bounds | |---|---|---| | `0x10000064` | current root container item list | `5,5,36,36` | | `0x10000067` | nested-container selector list | `67,5,680,36` | | `0x10000068` | close button | `770,1,25,23` | | `0x1000006A` | current contents item list | `8,47,784,32` | | `0x1000006B` | horizontal contents scrollbar | `8,81,784,16` | The close button uses sprites `0x060011BD` (normal) and `0x060011BC` (pressed). The horizontal scrollbar inherits the retail track, three-part thumb, and arrow sprites from LayoutDesc `0x2100003E`. ## Pseudocode ```text OnInputFrame(): sample movement-key levels serialize any movement press/release edge as MoveToState drain later one-shot Use/PickUp inputs in original FIFO order // A preceding movement release must reach ACE before Use. Sending it // afterward cancels ACE's server-side approach callback. UseWorldObject(objectId): decision = ItemHolder policy send Use(objectId) if decision is SetGroundObject: SetGroundObject(objectId, notifyServer = true) SetGroundObject(newRoot, notifyServer): if currentExternalRoot == newRoot: return oldRoot = currentExternalRoot if oldRoot != 0: retire oldRoot's temporary contents tree publish SetGroundObject(0) to the external panel if notifyServer: send NoLongerViewingContents(oldRoot) currentExternalRoot = newRoot expectedExternalRoot = newRoot // The panel remains empty until ViewContents for newRoot is accepted. UseCorpse(corpseId): // A corpse is a TYPE_CONTAINER carrying BF_STUCK | BF_CORPSE. // BF_STUCK excludes DetermineUseResult's PlaceInBackpack branch. classify through ItemHolder::UseObject send Use(corpseId), never PutItemInContainer(corpseId) OnViewContents(containerId, orderedEntries): // Always update the projection: ACE sends nested-container snapshots too. objectTable.ReplaceContents(containerId, orderedEntries) if containerId != expectedExternalRoot: return currentExternalRoot = containerId expectedExternalRoot = containerId externalPanel.SetGroundObject(containerId) ExternalPanel.SetGroundObject(containerId): unregister old range watch clear root, nested-container, and contents lists add root object to top list set current local container to root populate nested-container selector and current contents if containerId == 0: hide panel else: register range watch(root, root.UseRadius, useRadii = true, ignoreZDelta = false) show panel CalculateExternalRangeWatch(root, player): // CPlayerSystem routes the authored range registration through // ACCWeenieObject::ObjectsInRange. With useRadii=true, this is the gap // between the two live physics cylinders, not center-to-center distance. distance = Position.cylinder_distance( root.radius, root.height, root.position, player.radius, player.height, player.position) return distance <= root.UseRadius ExternalPanel.CloseCurrentContainer(): if currentExternalRoot == 0: return send Use(currentExternalRoot) // Do not send NoLongerViewingContents here. ACE responds with 0x0052. detach local lists and wait for authoritative close OnCloseGroundContainer(containerId): if containerId != currentExternalRoot: return objectTable.StopViewingContentsTree(containerId) currentExternalRoot = 0 expectedExternalRoot = 0 externalPanel.SetGroundObject(0) OnUseDone(error): if error != 0 and expectedExternalRoot != currentExternalRoot: expectedExternalRoot = currentExternalRoot OnExternalRangeExit(): externalPanel.SetVisible(false) ExternalPanel.CloseCurrentContainer() OnExternalItemSingleClick(itemId): first offer it to target mode otherwise select the item OnExternalItemDoubleClick(itemId): ItemHolder.UseObject(itemId) // Because item.ContainerId is currentExternalRoot, retail policy emits // PlaceInBackpack; the server remains authoritative for the move. destination = current open owned pack destinationList.RemoveTrailingEmptyItem() destinationList.AddEmptySlot(at index 0) destinationList.AddItem(itemId) destinationList.pendingItem.SetWaitingState(true) send PutItemInContainer(itemId, destination, placement = 0) // The pending item therefore reserves the FIRST slot and shifts the // existing packed list to the right until the server confirms or rejects. DragExternalItemToOwnedInventory(itemId, placement, splitAmount): if splitAmount < full stack: send StackableSplitToContainer(itemId, player/openOwnedBag, placement, splitAmount) else: destinationList.InsertItem(itemId, placement) destinationList.pendingItem.SetWaitingState(true) send PutItemInContainer(itemId, player/openOwnedBag, placement) do not mutate canonical ownership before the server response OnServerMoveItem(itemId): if destinationList.pendingItem.itemId == itemId: clear pendingItem rebuild the list from authoritative placement OnInventoryServerSaveFailed(itemId): if destinationList.pendingItem.itemId == itemId: remove the pending copy leave the source ownership unchanged DragOwnedItemToExternalContainer(itemId, placement, splitAmount): if splitAmount < full stack: send StackableSplitToContainer(itemId, currentExternalSubcontainer, placement, splitAmount) else: send PutItemInContainer(itemId, currentExternalSubcontainer, placement) do not mutate canonical ownership before the server response ``` ## Architectural mapping - `ExternalContainerState` in Core owns only expected/current external-root identity and transition ordering. A replacement request retires current presentation immediately, matching `SetGroundObject`; accepted `ViewContents` later opens the requested root. It filters ACE's nested `ViewContents` packets so they cannot replace the visible root. - `ExternalContainerLifecycleController` in App observes those transitions, retires the replaced root's temporary preview tree, then owns the replacement-only `NoLongerViewingContents` network side effect. The wire lifetime therefore does not depend on whether retained UI is mounted. - `ClientObjectTable` remains the canonical object and ordered-content projection owner. - `ExternalContainerController` owns the authored strip, range/close behavior, nested-list selection, selection, and drag/drop presentation. - `InventoryController` owns retail `UIElement_ItemList::m_pendingItem`: a destination-only waiting projection at the exact chosen slot. It subscribes to authoritative move and failure notices and never rewrites external-item ownership while the request is in flight. - `ItemInteractionController` remains the only interpreter of retail item-use and placement policy. It gains explicit adapters for `SetGroundObject` and `PlaceInContainer`; panels do not reimplement those decisions. - `OutboundInteractionQueue` owns the input-frame ordering seam. Keyboard Use, world double-click, and keyboard pickup are captured by the Silk callback, then drained immediately after `LocalPlayerOutboundController` serializes that frame's movement edge and before inbound network dispatch. It sends one request; it is not an arrival retry. - `GameWindow` only creates the lifecycle/UI bindings and supplies network delegates. No external-container feature body lives there. There is no new intended retail divergence. Completing this mechanism retires AP-106/#196: `NoLongerViewingContents` belongs to replacement of an external root, not switching between owned side packs.