acdream/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md
Erik 20ce67b625 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>
2026-07-17 16:18:10 +02:00

6.8 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 @ 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
  • 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.

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

UseWorldObject(objectId):
    decision = ItemHolder policy
    if decision is SetGroundObject:
        expectedExternalRoot = objectId
    send Use(objectId)

OnViewContents(containerId, orderedEntries):
    // Always update the projection: ACE sends nested-container snapshots too.
    objectTable.ReplaceContents(containerId, orderedEntries)

    if containerId != expectedExternalRoot:
        return

    old = currentExternalRoot
    if old != 0 and old != containerId:
        objectTable.StopViewingContentsTree(old)
        send NoLongerViewingContents(old) exactly once

    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.

DragExternalItemToOwnedInventory(itemId, placement, splitAmount):
    if splitAmount < full stack:
        send StackableSplitToContainer(itemId, player/openOwnedBag,
                                       placement, splitAmount)
    else:
        send PutItemInContainer(itemId, player/openOwnedBag, placement)
    do not mutate canonical ownership before the server response

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. 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.
  • 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.