# 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 ```text 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 ```text 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: ```text 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: ```text 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: ```text 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: ```text 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: ```text 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`