Keep external-container visibility tied to the signed surface gap between the player and container physics cylinders, matching gmExternalContainerUI's authored UseRadius range watcher. This prevents a valid ViewContents response from being closed at ACE's natural approach endpoint. Port Position::cylinder_distance and ACCWeenieObject::ObjectsInRange into Core with conformance coverage for 3-D separation, overlap, mode precedence, and inclusive boundaries. Release build succeeds and all 5,895 tests pass with five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
11 KiB
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 @ 0x004CBAD0gmExternalContainerUI::SetGroundObject @ 0x004CBBD0gmExternalContainerUI::CloseCurrentContainer @ 0x004CBCB0CPlayerSystem::CalculateObjectRangeChecks @ 0x0055F360CPlayerSystem::RegisterObjectRangeHandler @ 0x005602D0ACCWeenieObject::ObjectsInRange @ 0x0058C1A0CPhysicsObj::get_distance_to_object @ 0x0050F7A0Position::cylinder_distance @ 0x005A97F0gmExternalContainerUI::PostInit @ 0x004CBDE0gmExternalContainerUI::OnVisibilityChanged @ 0x004CBF50gmExternalContainerUI::RecvNotice_SetGroundObject @ 0x004CBFD0gmExternalContainerUI::OnItemListDragOver @ 0x004CC0C0UIElement_ItemList::ListenToElementMessage @ 0x004E4D50ClientUISystem::SetGroundObject @ 0x00564510ClientUISystem::OnViewContents @ 0x00565870CPlayerSystem::PlaceInBackpack @ 0x0055D8C0gmInventoryUI::RecvNotice_ShowPendingInPlayer @ 0x004A6B50gmInventoryUI::RecvNotice_EndPendingInPlayer @ 0x004A68B0UIElement_ItemList::ItemList_InsertItem @ 0x004E3F40ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680ACCWeenieObject::UIAttemptSplitToContainer @ 0x0058D7D0ItemHolder::AttemptToPlaceInContainer @ 0x00588140ItemHolder::UseObject @ 0x00588A80CommandInterpreter::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, andContainer.FinishCloseconfirm the authoritative packet order and that ACE can sendViewContentsfor the root followed by its nested containers. - ACE
GameActionMoveToState.Handleconfirms that a movement state received afterUsecancels the Use-createdCreateMoveToChain. A cancelled chain still reachesTryUseItem(success:false)and produces successfulUseDonewithout callingContainer.Open, explaining the approach-with-no-loot trace. - holtburger protocol
inventory/events.rsandinventory/actions.rsconfirm the exact wire shapes. Its world inventory handler independently models open container contents as temporary preview retention and retires that preview onCloseGroundContainer.
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
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
ExternalContainerStatein Core owns only expected/current external-root identity and transition ordering. A replacement request retires current presentation immediately, matchingSetGroundObject; acceptedViewContentslater opens the requested root. It filters ACE's nestedViewContentspackets so they cannot replace the visible root.ExternalContainerLifecycleControllerin App observes those transitions, retires the replaced root's temporary preview tree, then owns the replacement-onlyNoLongerViewingContentsnetwork side effect. The wire lifetime therefore does not depend on whether retained UI is mounted.ClientObjectTableremains the canonical object and ordered-content projection owner.ExternalContainerControllerowns the authored strip, range/close behavior, nested-list selection, selection, and drag/drop presentation.InventoryControllerowns retailUIElement_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.ItemInteractionControllerremains the only interpreter of retail item-use and placement policy. It gains explicit adapters forSetGroundObjectandPlaceInContainer; panels do not reimplement those decisions.OutboundInteractionQueueowns the input-frame ordering seam. Keyboard Use, world double-click, and keyboard pickup are captured by the Silk callback, then drained immediately afterLocalPlayerOutboundControllerserializes that frame's movement edge and before inbound network dispatch. It sends one request; it is not an arrival retry.GameWindowonly 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.