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