fix(items): finish corpse pickup and combat switching

Port retail's first-slot ShowPendingInPlayer path for double-click loot and carry the current owned-container destination through deferred pickup. Retire the previous ground-container view as soon as a replacement is requested so its range close cannot cancel ACE's active MoveTo chain.

Preserve active-combat weapon intent across ACE's authoritative wand-to-missile stance tail while leaving peace-mode switches unchanged.

Release build succeeds and all 5,885 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 18:35:29 +02:00
parent d51a0fc825
commit 0392c6d721
17 changed files with 631 additions and 81 deletions

View file

@ -111,6 +111,17 @@ on server-confirmed WieldObject(item):
and pending.blockingItem is none:
clear pending
if the transaction began in an active combat mode:
// ACE compatibility boundary; see below.
retain the ready mode implied by the confirmed equip location
on combat-mode notices after confirmed WieldObject:
if the first notice is the retained ready mode:
clear the retained mode; the server already settled correctly
else if notices form NonCombat -> retained ready mode -> NonCombat:
clear the retained mode
request that ready mode from ACE's trailing NonCombat notice
on PrivateUpdatePropertyInt(property = CombatMode, value):
if value is exactly NonCombat, Melee, Missile, or Magic:
CombatState.SetCombatMode(value)
@ -119,11 +130,28 @@ on PrivateUpdatePropertyInt(property = CombatMode, value):
## Animation ownership
The client must not synthesize a local bow-to-sword animation. In peace mode,
ACE leaves `CombatMode` as `NonCombat`. In combat mode, ACE selects the new
weapon's stance and sends the authoritative motion stream plus PropertyInt 40.
The inventory controller only sequences the two wire requests and waits for
the old weapon's confirmed move; the existing motion and equipped-child
render paths consume the server events.
ACE leaves `CombatMode` as `NonCombat`. The inventory controller sequences the
two wire requests and waits for the old weapon's confirmed move; the existing
motion and equipped-child render paths consume the server events.
### ACE active-combat compatibility boundary
A connected 2026-07-17 trace of Magic wand -> missile weapon showed ACE emit
`Magic -> Melee -> NonCombat -> Missile -> NonCombat`. This is a consequence of
ACE `Player_Inventory.TryShuffleStance` plus its queued dequip callbacks: the
server does select Missile for the new weapon, but a later queued callback
publishes NonCombat after the authoritative `WieldObject`. Retail's client does
not need a second request against the retail server.
acdream therefore preserves the user's desired ready mode only when AutoWield
begins in active combat. Authoritative `WieldObject` arms settlement. A server
whose first subsequent notice is already the desired mode needs no request.
Local ACE instead emits `NonCombat -> desired mode -> NonCombat`; the client
sends one normal `ChangeCombatMode` request from that trailing notice,
necessarily after the queued callback that caused it. All resulting state and animation remain server-
authoritative. No update is ignored, delayed, or locally fabricated, and a
transaction begun in peace sends no mode request. This narrow server-
compatibility adaptation is recorded as AP-118.
The request is not complete when `GetAndWieldItem` is merely sent. Retail
`UIAttemptWield` records `prevRequest = IR_WIELD` and leaves the item waiting

View file

@ -22,6 +22,10 @@ Named Sept-2013 retail symbols:
- `UIElement_ItemList::ListenToElementMessage @ 0x004E4D50`
- `ClientUISystem::SetGroundObject @ 0x00564510`
- `ClientUISystem::OnViewContents @ 0x00565870`
- `CPlayerSystem::PlaceInBackpack @ 0x0055D8C0`
- `gmInventoryUI::RecvNotice_ShowPendingInPlayer @ 0x004A6B50`
- `gmInventoryUI::RecvNotice_EndPendingInPlayer @ 0x004A68B0`
- `UIElement_ItemList::ItemList_InsertItem @ 0x004E3F40`
- `ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680`
- `ACCWeenieObject::UIAttemptSplitToContainer @ 0x0058D7D0`
- `ItemHolder::AttemptToPlaceInContainer @ 0x00588140`
@ -69,9 +73,22 @@ thumb, and arrow sprites from LayoutDesc `0x2100003E`.
```text
UseWorldObject(objectId):
decision = ItemHolder policy
if decision is SetGroundObject:
expectedExternalRoot = objectId
send Use(objectId)
if decision is SetGroundObject:
SetGroundObject(objectId, notifyServer = true)
SetGroundObject(newRoot, notifyServer):
if currentExternalRoot == newRoot:
return
oldRoot = currentExternalRoot
if oldRoot != 0:
retire oldRoot's temporary contents tree
publish SetGroundObject(0) to the external panel
if notifyServer:
send NoLongerViewingContents(oldRoot)
currentExternalRoot = newRoot
expectedExternalRoot = newRoot
// The panel remains empty until ViewContents for newRoot is accepted.
UseCorpse(corpseId):
// A corpse is a TYPE_CONTAINER carrying BF_STUCK | BF_CORPSE.
@ -86,11 +103,6 @@ OnViewContents(containerId, orderedEntries):
if containerId != expectedExternalRoot:
return
old = currentExternalRoot
if old != 0 and old != containerId:
objectTable.StopViewingContentsTree(old)
send NoLongerViewingContents(old) exactly once
currentExternalRoot = containerId
expectedExternalRoot = containerId
externalPanel.SetGroundObject(containerId)
@ -138,6 +150,14 @@ OnExternalItemDoubleClick(itemId):
ItemHolder.UseObject(itemId)
// Because item.ContainerId is currentExternalRoot, retail policy emits
// PlaceInBackpack; the server remains authoritative for the move.
destination = current open owned pack
destinationList.RemoveTrailingEmptyItem()
destinationList.AddEmptySlot(at index 0)
destinationList.AddItem(itemId)
destinationList.pendingItem.SetWaitingState(true)
send PutItemInContainer(itemId, destination, placement = 0)
// The pending item therefore reserves the FIRST slot and shifts the
// existing packed list to the right until the server confirms or rejects.
DragExternalItemToOwnedInventory(itemId, placement, splitAmount):
if splitAmount < full stack:
@ -171,8 +191,10 @@ DragOwnedItemToExternalContainer(itemId, placement, splitAmount):
## Architectural mapping
- `ExternalContainerState` in Core owns only expected/current external-root
identity and transition ordering. It filters ACE's nested `ViewContents`
packets so they cannot replace the visible root.
identity and transition ordering. A replacement request retires current
presentation immediately, matching `SetGroundObject`; accepted
`ViewContents` later opens the requested root. It filters ACE's nested
`ViewContents` packets so they cannot replace the visible root.
- `ExternalContainerLifecycleController` in App observes those transitions,
retires the replaced root's temporary preview tree, then owns the
replacement-only `NoLongerViewingContents` network side effect. The wire