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

@ -115,8 +115,9 @@ and the final two-client portal-out/materialization observer flow are
user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; the automated structural closeout passes 293 focused tests,
3,451 App tests / 3 skips, and 7,823 complete-solution tests / 5 skips.
Release builds; the structural closeout plus verified interaction/recall
regressions pass 293 focused tests, 3,462 App tests / 3 skips, and 7,834
complete-solution tests / 5 skips.
**Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated
@ -125,12 +126,13 @@ composition/callback shell, down 14,101 lines (89.7%) from baseline. Ordered
startup, update/render frame graphs, native/input callbacks, settings,
session/reset wiring, canonical checkpoint capture, and retryable shutdown are
owned by focused typed collaborators without a service locator or stored
window back-reference. All 293 focused tests, App Release 3,451/3, the complete
Release suite 7,823/5, connected lifecycle/reconnect, two fresh-process
window back-reference. All 293 focused tests, App Release 3,462/3, the complete
Release suite 7,834/5, connected lifecycle/reconnect, two fresh-process
canonical nine-stop soaks, three corrected-diff reviews, and the exact Slice-7
framebuffer comparison pass. Issue #232 is closed without loosening process
limits. Only the user's final connected visual matrix remains before M4 feature
bodies resume.
limits. The user's connected visual matrix passed 2026-07-23, including the
post-closeout radar correction; the structural campaign is complete and M4
feature bodies may resume after any active regression gate.
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
`docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Pipeline MP1b+, and #225's

View file

@ -113,8 +113,9 @@ and the final two-client portal-out/materialization observer flow are
user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; the automated structural closeout passes 293 focused tests,
3,451 App tests / 3 skips, and 7,823 complete-solution tests / 5 skips.
Release builds; the structural closeout plus verified interaction/recall
regressions pass 293 focused tests, 3,462 App tests / 3 skips, and 7,834
complete-solution tests / 5 skips.
**Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated
@ -123,12 +124,13 @@ composition/callback shell, down 14,101 lines (89.7%) from baseline. Ordered
startup, update/render frame graphs, native/input callbacks, settings,
session/reset wiring, canonical checkpoint capture, and retryable shutdown are
owned by focused typed collaborators without a service locator or stored
window back-reference. All 293 focused tests, App Release 3,451/3, the complete
Release suite 7,823/5, connected lifecycle/reconnect, two fresh-process
window back-reference. All 293 focused tests, App Release 3,462/3, the complete
Release suite 7,834/5, connected lifecycle/reconnect, two fresh-process
canonical nine-stop soaks, three corrected-diff reviews, and the exact Slice-7
framebuffer comparison pass. Issue #232 is closed without loosening process
limits. Only the user's final connected visual matrix remains before M4 feature
bodies resume.
limits. The user's connected visual matrix passed 2026-07-23, including the
post-closeout radar correction; the structural campaign is complete and M4
feature bodies may resume after any active regression gate.
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
`docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Pipeline MP1b+, and #225's

View file

@ -30,9 +30,9 @@ What does NOT go here:
automated closeout are complete. The 1,622-line native shell has typed
startup, frame, callback, and shutdown owners; 293 focused tests, the
7,823/5 Release suite, the lifecycle/reconnect route, two canonical
nine-stop soaks, and the Slice-7 framebuffer comparison pass. Only the
user's final connected visual matrix remains before the campaign is marked
complete in [`docs/architecture/code-structure.md`](architecture/code-structure.md).
nine-stop soaks, and the Slice-7 framebuffer comparison pass. The user's
connected visual matrix passed 2026-07-23, so the campaign is complete in
[`docs/architecture/code-structure.md`](architecture/code-structure.md).
- **Separate rendering gate:** `#225`, lifestone/particle alpha ordering. Its
connected performance, lifetime, and unattended portal routes pass.
- **Carried behavior debt:** `#153` far-teleport unstreamed-edge arrival and
@ -7600,6 +7600,40 @@ outdoors at the angle that previously erased it.
# Recently closed
## #234 — [DONE 2026-07-23] Cancelled close-range Use could strand the busy cursor
**Closed:** 2026-07-23
**Severity:** HIGH
**Component:** interaction / retained UI / local approach
**Resolution:** `ItemInteractionController` acquired retail's shared busy
reference before `SelectionInteractionController` completed a close-range
turn. If that pre-wire approach was cancelled, failed to install, lost its
session, or crossed an entity-incarnation boundary, no Use packet existed and
therefore ACE could never return the `UseDone` needed to release the count.
An explicit, generation-bound `ItemUseRequestReservation` now owns that
boundary. Successful wire dispatch transfers ownership to authoritative
`UseDone`; every pre-dispatch cancellation path releases the reservation
locally and idempotently. Session reset invalidates late reservations so an
old callback cannot decrement a new session. Focused tests pin cancellation,
failed movement installation, stale identity, synchronous turn completion,
successful dispatch, and late post-reset resolution.
The connected report itself completed normally: the log contained the
successful NPC Use and `UseDone(0)`, and a managed heap snapshot afterward
showed busy count zero with no pending inventory or auto-wield transaction.
That evidence separated an ordinary in-flight rejection from the real latent
pre-wire leak.
**Connected gate:** Passed 2026-07-23. Cancelling the close-range turn no
longer strands the busy cursor, and the next NPC/item Use succeeds normally.
**Research:**
[`docs/research/2026-07-23-retail-use-busy-ownership-pseudocode.md`](research/2026-07-23-retail-use-busy-ownership-pseudocode.md).
---
## #229 — [DONE 2026-07-20] Initial login revealed an incomplete world
**Closed:** 2026-07-20

View file

@ -2,8 +2,8 @@
**Status:** Living document. Created 2026-05-16; implementation reconciliation
completed 2026-07-21; Slices 18 and their automated closeout landed by
2026-07-22. The final connected visual matrix remains before new M4 subsystems
enter the App layer.
2026-07-22. The connected visual matrix passed 2026-07-23; the campaign is
complete and new M4 subsystems may enter through the extracted owners.
**Purpose:** Describe the desired structural state of the App layer,
explain the rules we've adopted, and lay out the safe extraction
sequence from today's reality (one 15,723-line `GameWindow.cs` at the
@ -758,7 +758,9 @@ with graceful exits; deterministic framebuffer captures preserve accepted
Slice 7 geometry, UI/paperdoll layering, private viewports, depth/alpha, and
reveal. The clean solution build retains only the 17 test-project warnings
tracked by #228, and all three final corrected-diff reviews are clean. Only the
user's final connected visual matrix remains.
user's connected visual matrix passed 2026-07-23, including first-login radar,
movement/combat, shared retained panels, recall/portal presentation, and
graceful reconnect.
### 4.4 Exit criteria

View file

@ -1,6 +1,6 @@
# acdream — strategic roadmap
**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the final `GameWindow` visual handoff.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md) and their automated closeout are complete; only the user's connected visual matrix remains. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate.
**Status:** Living document. Updated 2026-07-23. **M3 landed; M4 is active.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md), their automated closeout, and the user's connected visual matrix are complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate.
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
---
@ -116,7 +116,8 @@ snapshots and closes #232 through two clean fresh-process nine-stop routes.
The shell is 1,622 raw lines. All 293 focused tests, App Release 3,451/3, the
complete Release suite 7,823/5, connected lifecycle/reconnect, both canonical
soaks, three corrected-diff reviews, and the exact Slice-7 framebuffer
comparison pass. Only the user's final connected visual matrix remains.
comparison pass. The user's connected visual matrix passed 2026-07-23 and the
structural program is complete.
This is a behavior-preserving structural program. Severe regressions still get
root-cause fixes in separate commits; ordinary feature work resumes with M4

View file

@ -74,8 +74,9 @@ without loosening the process thresholds. The automated Slice 8 closeout is
complete: `GameWindow` is 1,622 raw lines; 293 focused tests, App Release
3,451/3, the complete Release suite 7,823/5, lifecycle/reconnect, both
canonical soaks, all three corrected-diff reviews, and the Slice-7 framebuffer
comparison pass. Only the user's final connected visual matrix remains before
new M4 feature bodies begin.
comparison pass. The user's connected visual matrix passed 2026-07-23,
including the post-closeout radar correction, so new M4 feature bodies may
begin through the extracted owners.
Carried:
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus

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`.

View file

@ -6,8 +6,8 @@ All eight behavior-preserving App ownership slices and their automated closeout
are complete. `GameWindow.cs` moved from the 2026-07-21 baseline of 15,723
lines / 278 fields / 205 methods to a 1,622-line native
composition/callback shell: 14,101 lines (89.7%) were removed without changing
accepted gameplay or rendering behavior. Only the user's final connected
visual matrix remains before the campaign is marked complete.
accepted gameplay or rendering behavior. The user's final connected visual
matrix passed 2026-07-23; the campaign is complete.
| Slice | Ownership moved out | Closeout size |
|---|---|---:|
@ -209,12 +209,12 @@ review rule live in `docs/architecture/code-structure.md`.
## Next work
Run the final connected visual matrix: first login/radar; movement, mouse,
resize, focus, and combat; shared inventory/skills/spellbook panel; outdoor,
building, dungeon, portal, paperdoll, particle, and alpha presentation; then
graceful close and fresh reconnect. After user confirmation, mark Checkpoint L
and the campaign complete and resume M4. Full `GameEntity` aggregation remains
a separate migration and must not be folded into the visual handoff.
Resume M4 feature work through the extracted owners. The connected matrix
passed first login/radar; movement, mouse, resize, focus, and combat; the shared
inventory/skills/spellbook panel; outdoor, building, dungeon, recall/portal,
paperdoll, and particle presentation; then graceful reconnect. Full
`GameEntity` aggregation remains a separate migration and must not be folded
into ordinary M4 work.
## Do not retry

View file

@ -287,7 +287,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
return new ItemInteractionController(
d.Objects,
playerGuid: () => d.PlayerIdentity.ServerGuid,
sendUse: selection.SendUse,
sendUse: null,
sendExamine: guid => session.CurrentSession?.SendAppraise(guid),
sendUseWithTarget: (source, target) =>
session.CurrentSession?.SendUseWithTarget(source, target),
@ -332,7 +332,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
requestExternalContainer: guid =>
{
d.ExternalContainers.RequestOpen(guid);
});
},
requestUse: selection.RequestUse);
}
public RetainedUiComposition CreateRetainedUi(

View file

@ -169,6 +169,18 @@ internal sealed class DeferredSelectionUiAuthority
target?.SendUse(guid);
}
public void RequestUse(uint guid, ItemUseRequestReservation reservation)
{
ArgumentNullException.ThrowIfNull(reservation);
SelectionInteractionController? target;
lock (_gate)
target = !_deactivated ? _interactions : null;
if (target is null)
reservation.CancelBeforeDispatch();
else
target.RequestUse(guid, reservation);
}
public void SendPickup(uint itemGuid, uint destinationContainerId, int placement)
{
SelectionInteractionController? target;

View file

@ -460,10 +460,11 @@ internal sealed class LivePresentationCompositionPhase
liveEntities,
d.PhysicsEngine.ShadowObjects,
entityEffects.PlayTypedFromHiddenTransition,
new LiveEntityPartArrayEnterWorldPort(
partArrayLifecycle.HandleEnterWorld),
equippedLease.Resource.SetDirectChildrenNoDraw,
d.MotionBindings.ClearTargetForHiddenEntity,
d.WorldOrigin.GetCenter,
partArrayLifecycle.HandleEnterWorld),
d.WorldOrigin.GetCenter),
static value => value.Dispose());
var remoteShadowPlacement = new RemoteShadowPlacementSynchronizer(
d.RemotePhysicsUpdater,

View file

@ -21,13 +21,74 @@ internal interface ILocalPlayerControllerSource
PlayerMovementController? Controller { get; }
}
internal interface ILocalPlayerMotionSource
{
MotionInterpreter? Motion { get; }
}
/// <summary>
/// The one mutable local movement-controller slot. Player-mode lifecycle owns
/// assignment; update and presentation owners receive the read-only seam.
/// </summary>
internal sealed class LocalPlayerControllerSlot : ILocalPlayerControllerSource
internal sealed class LocalPlayerControllerSlot
: ILocalPlayerControllerSource,
ILocalPlayerMotionSource
{
private PlayerMovementController? _preparingMotionOwner;
public PlayerMovementController? Controller { get; set; }
/// <summary>
/// The motion owner visible to the local PartArray completion relay.
/// During player-mode construction this is the fully animation-bound
/// candidate controller; all other consumers continue to see only the
/// committed <see cref="Controller"/>.
/// </summary>
MotionInterpreter? ILocalPlayerMotionSource.Motion =>
_preparingMotionOwner?.Motion ?? Controller?.Motion;
/// <summary>
/// Opens the narrow construction-time ownership seam required by retail's
/// CPhysicsObj/PartArray lifetime. Initial placement synchronously queues
/// and completes StopCompletely, so its MotionDone relay must reach the
/// candidate interpreter before the complete controller is published.
/// </summary>
public IDisposable BeginMotionPreparation(PlayerMovementController controller)
{
ArgumentNullException.ThrowIfNull(controller);
if (_preparingMotionOwner is not null)
{
throw new InvalidOperationException(
"A local player motion owner is already being prepared.");
}
_preparingMotionOwner = controller;
return new MotionPreparation(this, controller);
}
private void EndMotionPreparation(PlayerMovementController controller)
{
if (!ReferenceEquals(_preparingMotionOwner, controller))
{
throw new InvalidOperationException(
"The local player motion preparation owner changed unexpectedly.");
}
_preparingMotionOwner = null;
}
private sealed class MotionPreparation(
LocalPlayerControllerSlot owner,
PlayerMovementController controller) : IDisposable
{
private LocalPlayerControllerSlot? _owner = owner;
public void Dispose()
{
LocalPlayerControllerSlot? current = Interlocked.Exchange(ref _owner, null);
current?.EndMotionPreparation(controller);
}
}
}
internal interface ILocalPlayerPhysicsHostSource

View file

@ -386,6 +386,15 @@ internal sealed class PlayerModeController :
new MotionTableDispatchSink(sequencer);
}
// Retail CPhysicsObj owns CMotionInterp and CPartArray throughout
// construction. Our split owners preserve that lifetime with a
// narrow preparation lease: SetPosition's synchronous type-5
// completion reaches this candidate MotionInterpreter, while the
// public controller slot remains unpublished until every other
// player-mode edge has prepared successfully.
using IDisposable motionPreparation =
_controllerSlot.BeginMotionPreparation(controller);
ResolveResult initial = _physics.Resolve(
playerEntity.Position,
initialCellId,

View file

@ -38,6 +38,7 @@ internal sealed class SelectionInteractionController
uint DestinationContainerId,
int Placement,
ulong PendingPlacementToken,
ItemUseRequestReservation? UseReservation,
PlayerApproachToken ApproachToken);
public SelectionInteractionController(
@ -197,16 +198,23 @@ internal sealed class SelectionInteractionController
}
public void SendUse(uint serverGuid)
=> RequestUse(serverGuid, reservation: null);
public void RequestUse(
uint serverGuid,
ItemUseRequestReservation? reservation)
{
CancelPendingApproach();
if (!_transport.IsInWorld)
{
_toast?.Invoke("Not in world");
reservation?.CancelBeforeDispatch();
return;
}
if (!_query.IsUseable(serverGuid)
|| !_query.TryGetApproach(serverGuid, out InteractionApproach approach))
{
reservation?.CancelBeforeDispatch();
return;
}
@ -219,18 +227,28 @@ internal sealed class SelectionInteractionController
DestinationContainerId: 0u,
Placement: 0,
PendingPlacementToken: 0u,
UseReservation: reservation,
ApproachToken: default);
bool started = _movement.BeginApproach(
approach,
token => _pendingPostArrival = pending with { ApproachToken = token });
if (!started && _pendingPostArrival?.ServerGuid == pending.ServerGuid)
CancelPendingApproach();
else if (!started)
reservation?.CancelBeforeDispatch();
return;
}
_movement.BeginApproach(approach);
if (_transport.TrySendUse(serverGuid, out uint sequence))
{
reservation?.MarkDispatched();
Console.WriteLine($"[B.4b] use guid=0x{serverGuid:X8} seq={sequence}");
}
else
{
reservation?.CancelBeforeDispatch();
}
}
public void SendPickup(uint itemGuid, uint destinationContainerId, int placement)
@ -272,6 +290,7 @@ internal sealed class SelectionInteractionController
destinationContainerId,
placement,
pendingPlacementToken,
UseReservation: null,
ApproachToken: default);
bool started = _movement.BeginApproach(
approach,
@ -327,6 +346,8 @@ internal sealed class SelectionInteractionController
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
else
pending.UseReservation?.CancelBeforeDispatch();
return;
}
@ -357,7 +378,10 @@ internal sealed class SelectionInteractionController
}
else
{
_transport.TrySendUse(pending.ServerGuid, out _);
if (_transport.TrySendUse(pending.ServerGuid, out _))
pending.UseReservation?.MarkDispatched();
else
pending.UseReservation?.CancelBeforeDispatch();
}
}
@ -484,6 +508,8 @@ internal sealed class SelectionInteractionController
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
else
pending.UseReservation?.CancelBeforeDispatch();
}
private void CancelPickupPresentation(uint itemGuid, ulong token = 0u)

View file

@ -13,17 +13,17 @@ internal sealed class LiveAnimationPresentationContext
{
private readonly LiveEntityRuntime _runtime;
private readonly ILocalPlayerIdentitySource _localPlayer;
private readonly ILocalPlayerControllerSource _playerController;
private readonly ILocalPlayerMotionSource _playerMotion;
public LiveAnimationPresentationContext(
LiveEntityRuntime runtime,
ILocalPlayerIdentitySource localPlayer,
ILocalPlayerControllerSource playerController)
ILocalPlayerMotionSource playerMotion)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer));
_playerController = playerController
?? throw new ArgumentNullException(nameof(playerController));
_playerMotion = playerMotion
?? throw new ArgumentNullException(nameof(playerMotion));
}
public uint LocalPlayerGuid => _localPlayer.ServerGuid;
@ -38,7 +38,7 @@ internal sealed class LiveAnimationPresentationContext
}
if (record.ServerGuid == _localPlayer.ServerGuid)
return _playerController.Controller?.Motion;
return _playerMotion.Motion;
return record.RemoteMotionRuntime is RemoteMotion remote
? remote.Motion
: null;

View file

@ -37,6 +37,32 @@ public readonly record struct PendingInventoryRequest(
ClientObject? ItemIdentity,
bool Dispatched);
/// <summary>
/// Owns one retail UI busy reference while an accepted Use request crosses the
/// local approach boundary. Dispatch transfers that reference to the matching
/// authoritative UseDone; cancellation before dispatch releases it locally.
/// </summary>
public sealed class ItemUseRequestReservation
{
private readonly Action<bool> _resolve;
private int _resolved;
internal ItemUseRequestReservation(Action<bool> resolve)
=> _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
public void MarkDispatched()
{
if (Interlocked.Exchange(ref _resolved, 1) == 0)
_resolve(true);
}
public void CancelBeforeDispatch()
{
if (Interlocked.Exchange(ref _resolved, 1) == 0)
_resolve(false);
}
}
/// <summary>
/// Shared retail item interaction orchestrator. UI widgets route item clicks,
/// target acquisition, and drag-out drops here instead of duplicating
@ -78,6 +104,7 @@ public sealed class ItemInteractionController : IDisposable
private readonly Func<bool> _dragOnPlayerOpensSecureTrade;
private readonly Action<string>? _systemMessage;
private readonly AutoWieldController _autoWield;
private readonly Action<uint, ItemUseRequestReservation>? _requestUse;
private long _lastUseMs = long.MinValue / 2;
private uint _consumedPrimaryClickTarget;
@ -86,6 +113,7 @@ public sealed class ItemInteractionController : IDisposable
private ulong _nextInventoryRequestToken;
private PendingBackpackPlacement? _pendingBackpackPlacement;
private PendingInventoryRequest? _pendingInventoryRequest;
private ulong _useReservationGeneration;
private bool _disposed;
public ItemInteractionController(
@ -118,7 +146,8 @@ public sealed class ItemInteractionController : IDisposable
Action<uint, uint, uint, uint>? sendSplitToContainer = null,
Action<uint>? requestExternalContainer = null,
CombatState? combatState = null,
Action<CombatMode>? sendChangeCombatMode = null)
Action<CombatMode>? sendChangeCombatMode = null,
Action<uint, ItemUseRequestReservation>? requestUse = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -147,6 +176,7 @@ public sealed class ItemInteractionController : IDisposable
_stackSplitQuantity = stackSplitQuantity;
_dragOnPlayerOpensSecureTrade = dragOnPlayerOpensSecureTrade ?? (() => true);
_systemMessage = systemMessage;
_requestUse = requestUse;
_interactionState = interactionState ?? new InteractionState();
_interactionState.Changed += OnInteractionModeChanged;
_objects.ObjectMoved += OnInventoryObjectMoved;
@ -757,12 +787,28 @@ public sealed class ItemInteractionController : IDisposable
/// </summary>
public bool ExecuteConfirmedUse(uint objectId)
{
if (objectId == 0u || _sendUse is null)
if (objectId == 0u || (_requestUse is null && _sendUse is null))
return false;
if (!EnsureInventoryRequestReady())
return false;
_sendUse(objectId);
_busyCount++;
if (_requestUse is not null)
{
ItemUseRequestReservation reservation = BeginUseRequestReservation();
try
{
_requestUse(objectId, reservation);
}
catch
{
reservation.CancelBeforeDispatch();
throw;
}
}
else
{
_sendUse!(objectId);
_busyCount++;
}
return true;
}
@ -815,6 +861,7 @@ public sealed class ItemInteractionController : IDisposable
{
uint openedOrUsed = 0;
bool acted = false;
bool busyOwnedByUseReservation = false;
foreach (var action in actions)
{
switch (action.Kind)
@ -835,9 +882,27 @@ public sealed class ItemInteractionController : IDisposable
// coalesce the pair to one packet.
if (openedOrUsed != action.ObjectId)
{
_sendUse?.Invoke(action.ObjectId);
if (_requestUse is not null)
{
ItemUseRequestReservation reservation =
BeginUseRequestReservation();
try
{
_requestUse(action.ObjectId, reservation);
}
catch
{
reservation.CancelBeforeDispatch();
throw;
}
busyOwnedByUseReservation = true;
}
else
{
_sendUse?.Invoke(action.ObjectId);
}
openedOrUsed = action.ObjectId;
acted |= _sendUse is not null;
acted |= _requestUse is not null || _sendUse is not null;
}
break;
case ItemPolicyActionKind.SendUseWithTarget:
@ -853,7 +918,14 @@ public sealed class ItemInteractionController : IDisposable
acted = true;
break;
case ItemPolicyActionKind.IncrementBusy:
_busyCount++;
if (busyOwnedByUseReservation)
{
busyOwnedByUseReservation = false;
}
else
{
_busyCount++;
}
break;
case ItemPolicyActionKind.Reject:
if (!string.IsNullOrWhiteSpace(action.Message))
@ -872,6 +944,21 @@ public sealed class ItemInteractionController : IDisposable
return acted;
}
private ItemUseRequestReservation BeginUseRequestReservation()
{
ulong generation = _useReservationGeneration;
_busyCount++;
StateChanged?.Invoke();
return new ItemUseRequestReservation(dispatched =>
{
if (generation != _useReservationGeneration || dispatched)
return;
if (_busyCount > 0)
_busyCount--;
StateChanged?.Invoke();
});
}
private void ExecutePlacementActions(System.Collections.Generic.IReadOnlyList<ItemPolicyAction> actions)
{
foreach (var action in actions)
@ -1120,6 +1207,7 @@ public sealed class ItemInteractionController : IDisposable
public void ClearBusy()
{
if (_busyCount == 0) return;
_useReservationGeneration++;
_busyCount = 0;
StateChanged?.Invoke();
}
@ -1133,6 +1221,7 @@ public sealed class ItemInteractionController : IDisposable
{
PendingBackpackPlacement? pendingPlacement = _pendingBackpackPlacement;
_pendingBackpackPlacement = null;
_useReservationGeneration++;
_busyCount = 0;
_pendingInventoryRequest = null;
_lastUseMs = long.MinValue / 2;

View file

@ -0,0 +1,22 @@
namespace AcDream.App.World;
/// <summary>
/// Strongly typed retail PartArray world-entry boundary.
/// </summary>
/// <remarks>
/// <c>CPhysicsObj::set_hidden @ 0x00514C60</c> invokes
/// <c>CPartArray::HandleEnterWorld @ 0x00517D70</c> on both Hidden edges.
/// This dedicated port prevents that mandatory lifecycle callback from being
/// confused with neighboring <see cref="Action{T}"/> presentation callbacks.
/// </remarks>
public sealed class LiveEntityPartArrayEnterWorldPort
{
private readonly Action<uint> _handleEnterWorld;
public LiveEntityPartArrayEnterWorldPort(Action<uint> handleEnterWorld) =>
_handleEnterWorld = handleEnterWorld
?? throw new ArgumentNullException(nameof(handleEnterWorld));
public void HandleEnterWorld(uint localEntityId) =>
_handleEnterWorld(localEntityId);
}

View file

@ -27,7 +27,7 @@ public sealed class LiveEntityPresentationController : IDisposable
private readonly Action<uint> _clearInvalidTarget;
private readonly Func<(int X, int Y)> _liveCenter;
private readonly Action<uint>? _onShadowRestored;
private readonly Action<uint> _handlePartArrayEnterWorld;
private readonly LiveEntityPartArrayEnterWorldPort _partArrayEnterWorld;
private readonly Dictionary<uint, ushort> _readyGenerationByGuid = new();
private readonly Dictionary<uint, ushort> _suspendedShadowGenerationByGuid = new();
private readonly Dictionary<uint, ushort> _activePlacementGenerationByGuid = new();
@ -38,20 +38,21 @@ public sealed class LiveEntityPresentationController : IDisposable
LiveEntityRuntime liveEntities,
ShadowObjectRegistry shadows,
Func<uint, uint, float, bool> playTyped,
LiveEntityPartArrayEnterWorldPort partArrayEnterWorld,
Action<uint, bool>? setDirectChildrenNoDraw = null,
Action<uint>? clearInvalidTarget = null,
Func<(int X, int Y)>? liveCenter = null,
Action<uint>? onShadowRestored = null,
Action<uint>? handlePartArrayEnterWorld = null)
Action<uint>? onShadowRestored = null)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
_playTyped = playTyped ?? throw new ArgumentNullException(nameof(playTyped));
_partArrayEnterWorld = partArrayEnterWorld
?? throw new ArgumentNullException(nameof(partArrayEnterWorld));
_setDirectChildrenNoDraw = setDirectChildrenNoDraw ?? ((_, _) => { });
_clearInvalidTarget = clearInvalidTarget ?? (_ => { });
_liveCenter = liveCenter ?? (() => (0, 0));
_onShadowRestored = onShadowRestored;
_handlePartArrayEnterWorld = handlePartArrayEnterWorld ?? (_ => { });
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
}
@ -262,7 +263,7 @@ public sealed class LiveEntityPresentationController : IDisposable
// timeline boundary: it strips link animations and
// aborts every pending completion through
// MotionTableManager::HandleEnterWorld @ 0x0051BDD0.
_handlePartArrayEnterWorld(entity.Id);
_partArrayEnterWorld.HandleEnterWorld(entity.Id);
if (!IsCurrent(record, entity))
return false;
_clearInvalidTarget(record.ServerGuid);
@ -279,7 +280,7 @@ public sealed class LiveEntityPresentationController : IDisposable
return false;
// Retail invokes the same PartArray boundary before
// CObjCell::unhide_object restores cell visibility.
_handlePartArrayEnterWorld(entity.Id);
_partArrayEnterWorld.HandleEnterWorld(entity.Id);
if (!IsCurrent(record, entity))
return false;
bool restored = !IsPlacementActive(record)

View file

@ -0,0 +1,57 @@
using AcDream.App.Input;
using AcDream.Core.Physics;
namespace AcDream.App.Tests.Input;
public sealed class LocalPlayerControllerSlotTests
{
[Fact]
public void MotionPreparation_RoutesCompletionWithoutPublishingController()
{
var slot = new LocalPlayerControllerSlot();
ILocalPlayerMotionSource source = slot;
var controller = new PlayerMovementController(new PhysicsEngine());
controller.Motion.AddToQueue(0, MotionCommand.Ready, 0);
using (slot.BeginMotionPreparation(controller))
{
Assert.Null(slot.Controller);
MotionInterpreter? owner = source.Motion;
Assert.Same(controller.Motion, owner);
owner!.MotionDone(MotionCommand.Ready, success: true);
Assert.False(controller.Motion.MotionsPending());
}
Assert.Null(source.Motion);
}
[Fact]
public void MotionPreparation_HandsOffToCommittedControllerOnDispose()
{
var slot = new LocalPlayerControllerSlot();
ILocalPlayerMotionSource source = slot;
var controller = new PlayerMovementController(new PhysicsEngine());
using (slot.BeginMotionPreparation(controller))
{
slot.Controller = controller;
Assert.Same(controller.Motion, source.Motion);
}
Assert.Same(controller.Motion, source.Motion);
}
[Fact]
public void MotionPreparation_RejectsConcurrentCandidate()
{
var slot = new LocalPlayerControllerSlot();
var first = new PlayerMovementController(new PhysicsEngine());
var second = new PlayerMovementController(new PhysicsEngine());
using IDisposable preparation = slot.BeginMotionPreparation(first);
Assert.Throws<InvalidOperationException>(
() => slot.BeginMotionPreparation(second));
}
}

View file

@ -154,12 +154,14 @@ public sealed class SelectionInteractionControllerTests
{
ObjectId = Target,
Name = "Target",
Type = ItemType.Misc,
Type = ItemType.Creature,
Useability = ItemUseability.Remote,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Stuck,
});
Items = new ItemInteractionController(
Objects,
() => Player,
sendUse: guid => controller!.SendUse(guid),
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
@ -169,7 +171,9 @@ public sealed class SelectionInteractionControllerTests
Examines.Add(guid);
},
placeInBackpack: (item, container, placement) =>
controller!.SendPickup(item, container, placement));
controller!.SendPickup(item, container, placement),
requestUse: (guid, reservation) =>
controller!.RequestUse(guid, reservation));
Controller = controller = new SelectionInteractionController(
Selection,
Query,
@ -248,6 +252,102 @@ public sealed class SelectionInteractionControllerTests
Assert.Equal(new[] { Target }, h.Transport.Uses);
}
[Fact]
public void CancelledDeferredUseReleasesBusyWithoutServerCompletion()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(InputAction.UseSelected);
h.Controller.DrainOutbound();
Assert.Equal(1, h.Items.BusyCount);
Assert.Empty(h.Transport.Uses);
h.CompletionLifetime.PublishCancellation(WeenieError.ActionCancelled);
h.Controller.DrainOutbound();
Assert.Equal(0, h.Items.BusyCount);
Assert.True(h.Items.CanMakeInventoryRequest);
Assert.Empty(h.Transport.Uses);
}
[Fact]
public void DispatchedDeferredUseRemainsBusyUntilAuthoritativeUseDone()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(InputAction.UseSelected);
h.Controller.DrainOutbound();
h.CompletionLifetime.PublishNaturalCompletion();
h.Controller.DrainOutbound();
Assert.Equal(new[] { Target }, h.Transport.Uses);
Assert.Equal(1, h.Items.BusyCount);
h.Items.CompleteUse(0u);
Assert.Equal(0, h.Items.BusyCount);
Assert.True(h.Items.CanMakeInventoryRequest);
}
[Fact]
public void FailedDeferredUseStartReleasesItsBusyReservation()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Movement.Starts = false;
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(InputAction.UseSelected);
h.Controller.DrainOutbound();
Assert.Equal(0, h.Items.BusyCount);
Assert.True(h.Items.CanMakeInventoryRequest);
Assert.Empty(h.Transport.Uses);
}
[Fact]
public void StaleDeferredUseReleasesItsBusyReservation()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(InputAction.UseSelected);
h.Controller.DrainOutbound();
Assert.Equal(1, h.Items.BusyCount);
h.Query.Current = false;
h.CompletionLifetime.PublishNaturalCompletion();
h.Controller.DrainOutbound();
Assert.Equal(0, h.Items.BusyCount);
Assert.Empty(h.Transport.Uses);
}
[Fact]
public void SynchronousTurnCompletionTransfersBusyOwnershipToUseDone()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Movement.AfterArm = h.Controller.OnNaturalMoveToComplete;
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(InputAction.UseSelected);
h.Controller.DrainOutbound();
Assert.Equal(new[] { Target }, h.Transport.Uses);
Assert.Equal(1, h.Items.BusyCount);
h.Items.CompleteUse(0u);
Assert.Equal(0, h.Items.BusyCount);
}
[Fact]
public void FarUseSendsImmediatelyAndNaturalCompletionDoesNotRetry()
{

View file

@ -857,6 +857,7 @@ public sealed class RemotePhysicsUpdaterTests
Live,
Engine.ShadowObjects,
(_, _, _) => true,
new LiveEntityPartArrayEnterWorldPort(_ => { }),
liveCenter: () => (0, 0));
RegisterShadow(Entity, Remote);
Assert.True(_presentation.OnLiveEntityReady(Guid));

View file

@ -1424,6 +1424,7 @@ public sealed class RemoteTeleportControllerTests
Live,
Engine.ShadowObjects,
(_, _, _) => true,
new LiveEntityPartArrayEnterWorldPort(_ => { }),
liveCenter: () => (0xA9, 0xB4),
onShadowRestored: _ => ShadowSyncs++);
Assert.True(Presentation.OnLiveEntityReady(Guid));

View file

@ -37,7 +37,8 @@ public sealed class ItemInteractionControllerTests
public uint GroundObject;
public long Now = 1_000;
public Harness()
public Harness(
Action<uint, ItemUseRequestReservation>? requestUse = null)
{
Objects.AddOrUpdate(new ClientObject
{
@ -57,7 +58,7 @@ public sealed class ItemInteractionControllerTests
Controller = new ItemInteractionController(
Objects,
playerGuid: () => Player,
sendUse: Uses.Add,
sendUse: requestUse is null ? Uses.Add : null,
sendExamine: Examines.Add,
sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)),
sendWield: (item, mask) => Wields.Add((item, mask)),
@ -84,7 +85,8 @@ public sealed class ItemInteractionControllerTests
ExternalRequests.Add(id);
},
combatState: Combat,
sendChangeCombatMode: CombatModeRequests.Add);
sendChangeCombatMode: CombatModeRequests.Add,
requestUse: requestUse);
}
public ItemInteractionController Controller { get; }
@ -1446,6 +1448,66 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(2, h.Uses.Count);
}
[Fact]
public void DeferredUseCancellationReleasesExactlyItsBusyReference()
{
ItemUseRequestReservation? pending = null;
var h = new Harness((_, reservation) => pending = reservation);
h.AddContained(
0x50000A0Bu,
item => item.Useability = ItemUseability.Contained);
Assert.True(h.Controller.ActivateItem(0x50000A0Bu));
Assert.Equal(1, h.Controller.BusyCount);
Assert.NotNull(pending);
pending.CancelBeforeDispatch();
pending.CancelBeforeDispatch();
Assert.Equal(0, h.Controller.BusyCount);
Assert.True(h.Controller.CanMakeInventoryRequest);
}
[Fact]
public void DispatchedUseReservationIsReleasedOnlyByUseDone()
{
ItemUseRequestReservation? pending = null;
var h = new Harness((_, reservation) => pending = reservation);
h.AddContained(
0x50000A0Cu,
item => item.Useability = ItemUseability.Contained);
Assert.True(h.Controller.ActivateItem(0x50000A0Cu));
Assert.NotNull(pending);
pending.MarkDispatched();
pending.CancelBeforeDispatch();
Assert.Equal(1, h.Controller.BusyCount);
h.Controller.CompleteUse(0u);
Assert.Equal(0, h.Controller.BusyCount);
}
[Fact]
public void SessionResetInvalidatesLateUseReservationResolution()
{
ItemUseRequestReservation? stale = null;
var h = new Harness((_, reservation) => stale = reservation);
h.AddContained(
0x50000A0Du,
item => item.Useability = ItemUseability.Contained);
Assert.True(h.Controller.ActivateItem(0x50000A0Du));
Assert.Equal(1, h.Controller.BusyCount);
h.Controller.ResetSession();
stale!.CancelBeforeDispatch();
Assert.Equal(0, h.Controller.BusyCount);
Assert.True(h.Controller.CanMakeInventoryRequest);
}
[Fact]
public void KeyboardPickup_PublishesPendingDestinationBeforeRequest()
{

View file

@ -564,6 +564,7 @@ public sealed class LiveEntityLifecycleStressTests
TypedScripts.Add(type);
return true;
},
new LiveEntityPartArrayEnterWorldPort(_ => { }),
liveCenter: () => (1, 1));
Assert.True(Presentation.OnLiveEntityReady(Guid));

View file

@ -452,6 +452,7 @@ public sealed class LiveEntityPresentationControllerTests
runtime,
shadows,
(_, _, _) => true,
new LiveEntityPartArrayEnterWorldPort(_ => { }),
liveCenter: () => (2, 2));
Assert.True(controller.OnLiveEntityReady(Fixture.Guid));
@ -560,6 +561,7 @@ public sealed class LiveEntityPresentationControllerTests
runtime,
shadows,
(_, _, _) => true,
new LiveEntityPartArrayEnterWorldPort(_ => { }),
liveCenter: () => (2, 2));
Assert.True(controller.OnLiveEntityReady(Fixture.Guid));
Assert.Equal(1, shadows.SuspendedRegistrationCount);
@ -696,6 +698,13 @@ public sealed class LiveEntityPresentationControllerTests
OnTypedPlay?.Invoke(type);
return true;
},
new LiveEntityPartArrayEnterWorldPort(localEntityId =>
{
PartArrayEnterWorld.Add(localEntityId);
PartArrayShadowCounts.Add(Shadows.TotalRegistered);
PresentationOrder.Add("part-array");
onPartArrayEnterWorld?.Invoke(localEntityId);
}),
(parent, noDraw) =>
{
ChildNoDraw.Add((parent, noDraw));
@ -707,13 +716,7 @@ public sealed class LiveEntityPresentationControllerTests
PresentationOrder.Add("target");
},
() => (1, 1),
handlePartArrayEnterWorld: localEntityId =>
{
PartArrayEnterWorld.Add(localEntityId);
PartArrayShadowCounts.Add(Shadows.TotalRegistered);
PresentationOrder.Add("part-array");
onPartArrayEnterWorld?.Invoke(localEntityId);
});
onShadowRestored: null);
}
internal static WorldSession.EntitySpawn Spawn(

View file

@ -214,7 +214,8 @@ public sealed class LiveEntityProjectionWithdrawalControllerTests
Presentation = new LiveEntityPresentationController(
Live,
Physics.ShadowObjects,
(_, _, _) => false);
(_, _, _) => false,
new LiveEntityPartArrayEnterWorldPort(_ => { }));
lifecycle.Bind(new DelegateLiveEntityRuntimeComponentLifecycle(
record => Controller.LeaveWorld(record, Guid)));
}