276 lines
11 KiB
Markdown
276 lines
11 KiB
Markdown
# 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`
|