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
InventoryPutObjectIn3Dconfirmation 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 @ 0x0055D8C0ACCWeenieObject::AddContent @ 0x0058CCE0ACCWeenieObject::RemoveContent @ 0x0058CD70ACCWeenieObject::UIAttemptPutIn3D @ 0x0058D700ACCWeenieObject::UIAttemptSplitTo3D @ 0x0058D850ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0ACCWeenieObject::IsOwnedByObject @ 0x0058CEB0ACCWeenieObject::IsOwnedByPlayer @ 0x0058D160ACCWeenieObject::DeclareValid @ 0x0058E340VividTargetIndicator::SetSelected @ 0x004F5CE0VividTargetIndicator::RecvNotice_ServerSaysMoveItem @ 0x004F5F40gmInventoryUI::RecvNotice_ShowPendingInPlayer @ 0x004A6B50UIElement_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:
- A normal pickup carries placement zero and inserts at the head of the relevant retail list.
- 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.
- 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.
- 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.HandleActionStackableSplitTo3DandTryDropItemconfirm 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_PlacementZeroInsertsAtRetailListHeadClientObjectTableTests.AuthoritativeContainerMove_IndexesOnlyTheRetailContainerListClientObjectTableTests.IsOwnedByObjectMatchesRetailDirectPackAndLocationRulesEquippedChildProjectionWithdrawalTests.InventoryPutObjectIn3D_DoesNotWithdrawRecoveredWorldProjectionWorldSelectionQueryTests.VividTargetMarkerWaitsForFreshProjectionWhenDropMovePrecedesPositionWorldSelectionQueryTests.VividTargetMarkerUsesFreshPoseWhenPositionPrecedesDropMoveWorldSelectionQueryTests.VividTargetMarkerRejectsRetainedProjectionInsideExternalContainerPendingSplitToWorldProjectionTests.Resolve_ClonesSourceAppearanceIntoAuthoritativeGroundPlacementPendingSplitToWorldProjectionTests.Resolve_ConsumesOneRetailPendingIdentityPendingSplitToWorldProjectionTests.Resolve_RejectsAtRetailTenSecondBoundaryPendingSplitToWorldProjectionTests.Resolve_SourceGuidDoesNotConsumePendingIdentity