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>
4 KiB
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 @ 0x00588A80ClientUISystem::Handle_Item__UseDone @ 0x00564900CM_Item::DispatchUI_UseDone @ 0x006A8510ClientUISystem::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:
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
GameActionUseItemandPlayer_Use.TryUseItemconfirm that the Use request owns the server-side operation and that every completed/rejected operation returnsUseDone. - ACE
Player_Move.CreateMoveToChainconfirms that the server may approach a distant target before executing that operation. - holtburger
inventory/actions.rsandinventory/events.rsindependently 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
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.