acdream/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md
Erik 0f82a08f0a fix(items): use retail cylinder range for loot windows
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>
2026-07-17 20:32:34 +02:00

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 @ 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

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.