Port retail's first-slot ShowPendingInPlayer path for double-click loot and carry the current owned-container destination through deferred pickup. Retire the previous ground-container view as soon as a replacement is requested so its range close cannot cancel ACE's active MoveTo chain. Preserve active-combat weapon intent across ACE's authoritative wand-to-missile stance tail while leaving peace-mode switches unchanged. Release build succeeds and all 5,885 tests pass with five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
218 lines
9.2 KiB
Markdown
218 lines
9.2 KiB
Markdown
# 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`
|
|
- `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`
|
|
|
|
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.
|
|
- 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
|
|
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 using root UseRadius
|
|
show panel
|
|
|
|
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.
|
|
- `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.
|