fix(interaction): restore retail loot placement and world-drop projection

This commit is contained in:
Erik 2026-07-27 00:03:15 +02:00
parent 4d095be286
commit 921712f412
24 changed files with 1581 additions and 34 deletions

View file

@ -103,6 +103,63 @@ Copy this block when adding a new issue:
---
## #247 — Loot ordering and local dropped-item projection regressed
**Status:** DONE — 2026-07-26; connected loot/drop/selection gate passed
**Severity:** HIGH
**Filed:** 2026-07-26
**Component:** inventory / live-entity projection
**Description:** Double-click loot transferred successfully but settled at an
unrelated backpack position. Locally dropped items were authoritative and
visible to retail observers, yet remained invisible in acdream.
**Root cause / status:** The compact container projection sorted a completed
server move by stale slot state instead of performing retail's separate
remove-then-`IDList::AddAtNum` insertion. Whole-object drops then exposed two
projection errors. Inventory-only objects retained canonical CreateObject and
timestamp state without an App projection sidecar; the Position authority gate
committed their server position and then rejected the presentation tail
because it required a pre-existing `LiveEntityRecord`. Accepted Position now
returns the exact canonical incarnation, and the network presenter crosses
the normal spatial-hydration boundary before continuing. Objects which already
retain a sidecar recover whenever `IsSpatiallyProjected` is false.
`EquippedChildRenderController` also no longer interprets every final `None`
equip mask as a detach; it requires the actual equipped-to-unequipped edge.
Partial-stack drops have an additional ACE-specific wire omission.
`HandleActionStackableSplitTo3D` creates a new GUID and sends Position updates,
but does not send that new object's CreateObject to the initiating session.
Retail records the source WCID, selected count, and request time for ten
seconds so the created object can be recognized. acdream now retains that same
single pending identity; the one matching unknown ACE Position hydrates a
canonical clone of the source appearance through the normal live-entity path.
Reconnect evidence proved all prior drops were persisted and received as
ordinary CreateObjects. Focused ordering, projection, and pending-identity
tests pass, including stale-authority rejection, first projection construction,
and no duplicate resource/ready publication.
The follow-up stale marker was a separate projection-lifetime ordering edge.
Retail keeps canonical selection but suppresses its vivid world marker while
the object is player-owned or `IN_CONTAINER`. acdream additionally must not
mistake a retained leave-world `WorldEntity` pose for a current ground pose:
ACE may publish the drop placement before the new Position. Marker resolution
now requires both world placement and a current top-level spatial projection.
The inverse packet order is also safe: a fresh Position remains suppressed by
ownership until the placement arrives. This uses no timer and does not impose
camera/PVS visibility, preserving off-screen and through-wall indicators.
**Research:**
[`research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md`](research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md).
**Acceptance:** Looted items occupy the retail-selected backpack slot in
server order; locally and remotely dropped items appear once in the world;
pickup removes the ground marker without clearing inventory selection; re-drop
shows the marker only at the new authoritative pose. User accepted all four
connected checks on 2026-07-26.
---
## #246 — Prepared indoor cells stopped traversing their portals
**Status:** DONE — 2026-07-26; connected house/dungeon gate passed

View file

@ -108,7 +108,7 @@ accepted-divergence entries (#96, #49, #50).
---
## 3. Documented approximation (AP) — 90 active rows
## 3. Documented approximation (AP) — 91 active rows
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
@ -221,6 +221,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-121 | The `/framerate` command persists acdream's live ShowFps value in the modern `settings.json` Display bag after performing retail's live toggle/notice behavior. The named retail `DoFrameRate` body itself only flips `fShowFramerate` and sends `SetFramerateDisplay`; no equivalent persistence write occurs in that command body. | `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`ToggleFrameRate`) | This preserves acdream's shipped L.0 cross-launch display preference while keeping the live retail mechanism and SmartBox visibility result exact; it changes no gameplay or wire state | A relaunch can retain FPS visibility where the named retail command body alone would not; a future port of retail's wider preference owner must avoid double-persisting or applying conflicting startup state | `ClientCommunicationSystem::DoFrameRate @ 0x005707D0`; `CM_UI::SendNotice_SetFramerateDisplay @ 0x0047A050`; `gmSmartBoxUI::RecvNotice_SetFramerateDisplay @ 0x004D65E0` |
| AP-122 | The toolbar Use hand is Ghosted when selection is empty. Retail sets state 1 (Normal) and an empty-selection click enters generic `TARGET_MODE_USE`; selected-object enablement and activation remain retail-faithful. | `src/AcDream.App/UI/Layout/ToolbarController.cs` (`RefreshUseButton`) | User explicitly requires no selection to present as unavailable. The disabled retained button consumes the click without opening a cursor; every nonempty selection still uses the exact retail CombatUse/type/ItemUses predicate and the shared activation owner. | Generic pick-then-use mode cannot be entered from the empty toolbar hand; an object must first be selected, after which the same Use command is available. | `gmToolbarUI::HandleSelectionChanged @ 0x004BF380`; `gmToolbarUI::ListenToElementMessage @ 0x004BEE90` |
| AP-123 | Item cooldowns use the retained toolkit's existing procedural `UiItemSlot` leaf rather than materializing retail's ten `m_elem_Icon_Cooldown_*` child elements. The group lookup, remaining-time formula, exact DAT sprites, step choice, and topmost ReadOrder-8 outcome are faithful. | `src/AcDream.App/UI/Layout/ItemCooldownUiController.cs`; `src/AcDream.App/UI/UiItemSlot.cs` | `UiItemSlot` already consumes/reproduces UIItem children procedurally under the IA-15 retained-toolkit architecture. Selecting one imported sprite at draw time gives every inventory/equipment/shortcut alias the same output without a parallel widget tree or per-cell timers. | A future feature that observes the individual cooldown child visibility/state rather than the rendered UIItem could see no child elements even though the cell looks and advances correctly. | `CEnchantmentRegistry::OnCooldown @ 0x005943C0`; `UIElement_UIItem::UpdateCooldownDisplay @ 0x004E1E20`; common UIItem prototype `0x1000033E` |
| AP-124 | Local ACE omits the new object's CreateObject to the initiating session after `StackableSplitTo3D`, sending only F748 Position for the previously unknown GUID. acdream retains retail's one pending split source/count/time identity for ten seconds and, only for that otherwise-impossible unknown Position, hydrates a canonical clone of the source description with the new GUID and authoritative world placement. | `src/AcDream.App/World/InventoryWorldDropProjectionController.cs`; `src/AcDream.App/UI/ItemInteractionController.cs`; `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` | Nearby/reconnecting clients receive the ordinary CreateObject, while the initiator otherwise cannot render the authoritative object until relog. A server that sends CreateObject never enters this path; the pending identity is consumed before normal hydration and all other unknown Positions remain rejected. | ACE's F748 does not carry WCID or stack size, so an unrelated unknown Position arriving during the exact pending ten-second window could be associated with the split. Retail confirms WCID/count from CreateObject; removing the approximation requires ACE to send that packet to the initiator. | `ACCWeenieObject::UIAttemptSplitTo3D @ 0x0058D850`; `ACCWeenieObject::DeclareValid @ 0x0058E340`; ACE `Player.HandleActionStackableSplitTo3D` / `TryDropItem`; `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md` |
## 4. Temporary stopgap (TS) — 38 active rows + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)

View file

@ -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`