fix(runtime): restore interaction completion ownership

Preserve prepublication local motion completion, require the PartArray enter-world lifecycle port, and balance deferred Use busy ownership across dispatch and cancellation. Reconcile the completed GameWindow connected gates and add regression coverage.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 05:51:51 +02:00
parent 5c955c36ec
commit f9736ece6c
26 changed files with 675 additions and 68 deletions

View file

@ -0,0 +1,117 @@
# Retail Use busy-reference ownership
Date: 2026-07-23
Scope: the retained UI busy cursor and the
`You can only move or use one item at a time` gate while an object/NPC Use
crosses acdream's close-range turn/approach boundary.
## Retail oracle
Named Sept-2013 retail symbols:
- `ItemHolder::UseObject @ 0x00588A80`
- `ClientUISystem::Handle_Item__UseDone @ 0x00564900`
- `CM_Item::DispatchUI_UseDone @ 0x006A8510`
- `ClientUISystem::UsageCallback @ 0x00565B20`
`ItemHolder::UseObject` checks the 200 ms throttle and
`IsPlayerReadyToMakeInventoryRequest`, emits `CM_Inventory::Event_UseEvent`,
increments `ClientUISystem::m_cBusy`, and then calls
`CPlayerSystem::UsingItem`. The confirmation callback has the same order.
`CM_Item::DispatchUI_UseDone` routes event `0x01C7` to
`Handle_Item__UseDone`, which decrements exactly one busy reference and updates
the cursor when the count reaches zero.
Therefore the invariant is:
```text
one accepted Use request that reached the wire
owns one busy reference
until one authoritative UseDone arrives
```
There is no retail timeout and movement cancellation is not a substitute for
`UseDone` after dispatch.
## ACE and protocol cross-reference
- ACE `GameActionUseItem` and `Player_Use.TryUseItem` confirm that the Use
request owns the server-side operation and that every completed/rejected
operation returns `UseDone`.
- ACE `Player_Move.CreateMoveToChain` confirms that the server may approach a
distant target before executing that operation.
- holtburger `inventory/actions.rs` and `inventory/events.rs` independently
confirm the one Use action / one UseDone event protocol pair.
The existing close-range compatibility seam is recorded as divergence AD-27:
acdream may finish its local turn before emitting Use. That seam creates a
real pre-wire interval which retail does not have, so it needs explicit
ownership rather than pretending a server completion is already owed.
## Ported ownership pseudocode
```text
BeginAcceptedUse(object):
reservation = BusyOwner.Reserve()
busyCount += 1
RequestUse(object, reservation)
RequestUse(object, reservation):
if session/identity/approach is invalid:
reservation.CancelBeforeDispatch()
return
if a close-range turn must finish first:
pending = (exact object incarnation, approach token, reservation)
if movement installation fails:
pending.reservation.CancelBeforeDispatch()
return
if SendUse(object) succeeds:
reservation.MarkDispatched()
else:
reservation.CancelBeforeDispatch()
OnNaturalApproachCompletion(pending):
if pending incarnation is stale:
pending.reservation.CancelBeforeDispatch()
else if SendUse(pending.object) succeeds:
pending.reservation.MarkDispatched()
else:
pending.reservation.CancelBeforeDispatch()
OnApproachCancelledOrTargetRemoved(pending):
pending.reservation.CancelBeforeDispatch()
reservation.MarkDispatched():
// Ownership transfers to the server completion. Keep busyCount unchanged.
make later local cancellation inert
reservation.CancelBeforeDispatch():
// No packet exists, therefore no UseDone can arrive for this reservation.
busyCount -= 1
make duplicate cancellation inert
OnUseDone(error):
busyCount -= 1
if busyCount == 0:
update cursor
OnSessionReset():
invalidate the reservation generation
busyCount = 0
```
The reservation is idempotent and generation-bound. A late callback from a
retired session cannot decrement a new session's busy reference.
## Regression evidence
The connected trace showed successful NPC Use packets and matching
`UseDone(0)`. A managed heap snapshot after the final completion showed
`ItemInteractionController._busyCount == 0`, no pending inventory request, and
no pending auto-wield transaction. The permanent failure path was instead the
pre-wire close-range branch: it incremented busy before local movement, but
cancellation or stale identity discarded the pending send, leaving no packet
and therefore no possible `UseDone`.