fix(interaction): restore retail loot placement and world-drop projection
This commit is contained in:
parent
4d095be286
commit
921712f412
24 changed files with 1581 additions and 34 deletions
|
|
@ -0,0 +1,276 @@
|
|||
# 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`
|
||||
Loading…
Add table
Add a link
Reference in a new issue