acdream/docs/research/2026-07-23-retail-use-busy-ownership-pseudocode.md
Erik f9736ece6c 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>
2026-07-23 05:51:51 +02:00

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

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

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.