acdream/docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md

11 KiB

Retail inventory placement and world-drop confirmation

Scope

This note pins the two retail mechanisms needed by the 2026-07-26 loot/drop regression repair:

  • where an authoritative pickup is inserted in a container's ordered contents;
  • why an InventoryPutObjectIn3D confirmation must not be treated as an equipped-object detach.

The client remains server-authoritative. Pending drag/drop presentation may reserve a slot, but the accepted ServerSaysMoveItem placement is the canonical container order.

Named-retail anchors

  • CPlayerSystem::PlaceInBackpack @ 0x0055D8C0
  • ACCWeenieObject::AddContent @ 0x0058CCE0
  • ACCWeenieObject::RemoveContent @ 0x0058CD70
  • ACCWeenieObject::UIAttemptPutIn3D @ 0x0058D700
  • ACCWeenieObject::UIAttemptSplitTo3D @ 0x0058D850
  • ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0
  • ACCWeenieObject::IsOwnedByObject @ 0x0058CEB0
  • ACCWeenieObject::IsOwnedByPlayer @ 0x0058D160
  • ACCWeenieObject::DeclareValid @ 0x0058E340
  • VividTargetIndicator::SetSelected @ 0x004F5CE0
  • VividTargetIndicator::RecvNotice_ServerSaysMoveItem @ 0x004F5F40
  • gmInventoryUI::RecvNotice_ShowPendingInPlayer @ 0x004A6B50
  • UIElement_ItemList::ItemList_InsertItem @ 0x004E3F40

Source: docs/research/named-retail/acclient_2013_pseudo_c.txt.

Container-order pseudocode

PlaceInBackpack(itemId, forceMainPack):
    notify UI that itemId is pending in the player
    destination = openContainer
    if forceMainPack or MainPackPreferred:
        destination = player
    AttemptToPlaceInContainer(
        itemId,
        playerId,
        destination,
        wholeStack = true,
        placement = 0)

ServerSaysMoveItem(item, newContainerId, placement, newWielderId, location):
    if item.oldContainerId != 0:
        oldContainer.RemoveContent(item)

    item.containerId = newContainerId
    if newContainerId != 0:
        newContainer.AddContent(item, placement)

    update wield and location state
    clear the matching pending item request
    publish the move notice

AddContent(container, item, placement):
    list = item is a container ? container.containersList
                               : container.itemsList
    if item is not already in list:
        insertionIndex = min(placement, list.count)
        list.AddAtNum(item.id, insertionIndex)
        publish ItemAttributesChanged

Consequences:

  1. A normal pickup carries placement zero and inserts at the head of the relevant retail list.
  2. Items and subcontainers have separate ordered lists. A compact client model may store both in one array, but it must preserve the insertion order within each category.
  3. Removing the item from every stale container projection must precede the authoritative insert; otherwise a duplicate or an old equal-slot entry can win a stable sort.
  4. The raw accepted placement remains part of the move notice. Renumbering neighboring UI slots is a derived projection, not a rewrite of the server packet.

World-drop state pseudocode

ServerSaysMoveItem(item, containerId = 0, wielderId = 0, location = 0):
    remove item from its old container
    item.containerId = 0
    if item was wielded by the player:
        clear player wield location
        item.wielderId = 0
    item.location = 0
    item.currentState = not-in-container / not-wielded

The move confirmation describes ownership, not spatial creation. The live object's Position/CreateObject channel owns its world pose and projection. Therefore:

if previous equip location != None
   and current equip location == None:
       withdraw the old equipped-child projection
       begin the detached-object handoff
else:
       do not withdraw a recovered world projection

A ground drop can arrive as:

Position (recover/rebucket existing live object into the world)
then InventoryPutObjectIn3D / ServerSaysMoveItem (None -> None)

Inventory-only objects retain their canonical CreateObject snapshot and retail timestamp authority but may never have crossed the App projection boundary. Position admission must therefore resolve the canonical incarnation first, consume the shared Position timestamp there, and only then construct the first LiveEntityRecord/world projection from the accepted canonical snapshot. Requiring a pre-existing projection record at the authority gate drops the presentation tail after authority has already accepted the packet; reconnect appears to fix it only because the new session receives a complete CreateObject and takes the ordinary creation path.

Treating every current EquipLocation == None notice as an unequip tears down the world projection that the preceding Position just recovered. This is why the local client could fail to draw its own dropped item while another client, which received a normal world CreateObject, drew it correctly.

Selection versus vivid world-marker lifetime

Retail keeps the canonical selected object and the vivid world marker as separate projections. Every authoritative move notice makes the marker controller re-evaluate the current selection:

RecvNotice_ServerSaysMoveItem:
    SetSelected(ACCWeenieObject.selectedID)

SetSelected(selectedID):
    object = GetWeenieObject(selectedID)

    if selectedID == playerID
       or object is missing
       or object.IsOwnedByPlayer()
       or object.currentState == IN_CONTAINER:
        markerTargetID = 0
        hide on-screen and off-screen marker elements
        return

    markerTargetID = selectedID
    update radar color and marker geometry

ACCWeenieObject::IsOwnedByPlayer delegates to IsOwnedByObject(playerID). The latter accepts direct containment or wielding, an item inside one of the owner's pack containers, or an item on one of the owner's locations.

Consequently, picking a selected ground item up does not clear the shared selection used by the inventory. It only makes the vivid marker ineligible. Dropping that same GUID again makes the marker eligible at its new authoritative world pose. A retained WorldEntity and its old position are never sufficient reason to draw a marker while the object is owned, in any container, or between leave_world and the next accepted spatial projection.

ACE may deliver the drop placement and Position in either order. The marker therefore requires both independent truths:

placement says IN_3D_VIEW
and
the current incarnation has entered the spatial world after pickup

If Position arrives first, ownership suppresses the freshly projected pose until the placement follows. If placement arrives first, the withdrawn spatial state suppresses the retained old pose until Position follows. This is an ordering gate with no timer and does not use camera/PVS visibility, so off-screen and through-wall target indicators retain retail behavior.

Retail split-to-world identity

Dropping a selected portion of a stack creates a different object. Retail retains enough request identity to recognize that new object when its CreateObject becomes valid:

UIAttemptSplitTo3D(source, amount):
    if player cannot start an inventory request:
        return

    send StackableSplitTo3D(source.id, amount)
    prevRequestObjectID = source.id
    prevRequest = IR_SPLIT
    prevRequestTime = now
    splitClassID = source.wcid
    splitStackSize = amount
    splitTime = now

DeclareValid(createdObject):
    publish normal object/item validity notices

    if splitClassID is valid:
        createdAmount = createdObject.stackSize
        if createdAmount == 0:
            createdAmount = 1

        if createdObject.wcid == splitClassID
           and createdAmount == splitStackSize:
            select createdObject
            clear splitClassID
            return

        if now - splitTime >= 10 seconds:
            clear splitClassID

The matching identity is one global pending value, not a queue. A subsequent split replaces it. The exact boundary is ten seconds; equality expires.

ACE initiator packet difference

ACE Player.HandleActionStackableSplitTo3D creates a new world object, sets its stack size, and calls TryDropItem. TryDropItem publishes the new object to the landblock and sends Position updates. Unlike ACE's split-to-container path, split-to-3D does not enqueue GameMessageCreateObject(newStack) to the initiating session. Nearby clients learn the object through the ordinary landblock broadcast, and a reconnect receives its full CreateObject, but the initiator can receive only F748 Position packets for a previously unknown GUID.

That is why the live trace showed the newly split GUID rejected as unknown. Whole drops showed the distinct canonical-only projection failure above; both classes appeared normally after reconnect because reconnect supplied complete CreateObjects.

acdream keeps the retail pending source/amount/time identity after the successful wire send. For the one otherwise-impossible ACE case—an unknown Position while that exact split identity is younger than ten seconds—it clones the source object's canonical CreateObject description, replaces ownership/parenting with the authoritative ground Position and new GUID, and enters the normal LiveEntityHydrationController registration path. The pending identity is consumed before hydration so re-entrant packets cannot bind a second GUID. All unrelated unknown Position packets continue through the normal reject path.

This is an ACE protocol adaptation, recorded as AP-124. A server that sends the expected CreateObject never enters the adaptation.

Reference cross-checks

  • ACE: ACE.Server.WorldObjects.Player.HandleActionStackableSplitTo3D and TryDropItem confirm the initiating-session omission described above.
  • ACViewer has no inventory transaction client; it does not contradict the retail request identity or ACE packet sequence.
  • WorldBuilder is a DAT/world editor and contains no network inventory transaction path.

Conformance coverage

  • ClientObjectTableTests.AuthoritativePickup_PlacementZeroInsertsAtRetailListHead
  • ClientObjectTableTests.AuthoritativeContainerMove_IndexesOnlyTheRetailContainerList
  • ClientObjectTableTests.IsOwnedByObjectMatchesRetailDirectPackAndLocationRules
  • EquippedChildProjectionWithdrawalTests.InventoryPutObjectIn3D_DoesNotWithdrawRecoveredWorldProjection
  • WorldSelectionQueryTests.VividTargetMarkerWaitsForFreshProjectionWhenDropMovePrecedesPosition
  • WorldSelectionQueryTests.VividTargetMarkerUsesFreshPoseWhenPositionPrecedesDropMove
  • WorldSelectionQueryTests.VividTargetMarkerRejectsRetainedProjectionInsideExternalContainer
  • PendingSplitToWorldProjectionTests.Resolve_ClonesSourceAppearanceIntoAuthoritativeGroundPlacement
  • PendingSplitToWorldProjectionTests.Resolve_ConsumesOneRetailPendingIdentity
  • PendingSplitToWorldProjectionTests.Resolve_RejectsAtRetailTenSecondBoundary
  • PendingSplitToWorldProjectionTests.Resolve_SourceGuidDoesNotConsumePendingIdentity