diff --git a/AGENTS.md b/AGENTS.md
index d21a8163..9d67886c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/CLAUDE.md b/CLAUDE.md
index 58d5cde5..dcffe858 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index e927a0de..2355586d 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -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
diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md
index 96d72b51..40ea38a6 100644
--- a/docs/architecture/code-structure.md
+++ b/docs/architecture/code-structure.md
@@ -2,8 +2,8 @@
**Status:** Living document. Created 2026-05-16; implementation reconciliation
completed 2026-07-21; Slices 1–8 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
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index c4fad285..48ebd4bf 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -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
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
index 98eb1d46..0ebf0285 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -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
diff --git a/docs/research/2026-07-23-retail-use-busy-ownership-pseudocode.md b/docs/research/2026-07-23-retail-use-busy-ownership-pseudocode.md
new file mode 100644
index 00000000..dce2a361
--- /dev/null
+++ b/docs/research/2026-07-23-retail-use-busy-ownership-pseudocode.md
@@ -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`.
diff --git a/memory/project_gamewindow_decomposition.md b/memory/project_gamewindow_decomposition.md
index e9fea187..c6846406 100644
--- a/memory/project_gamewindow_decomposition.md
+++ b/memory/project_gamewindow_decomposition.md
@@ -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
diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
index 94389ba7..cf127ad3 100644
--- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
+++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
@@ -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(
diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs
index 3d303a5b..46c19073 100644
--- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs
+++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs
@@ -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;
diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs
index 6dc3af07..6e1be6ea 100644
--- a/src/AcDream.App/Composition/LivePresentationComposition.cs
+++ b/src/AcDream.App/Composition/LivePresentationComposition.cs
@@ -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,
diff --git a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs
index 14e10965..da50fc5c 100644
--- a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs
+++ b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs
@@ -21,13 +21,74 @@ internal interface ILocalPlayerControllerSource
PlayerMovementController? Controller { get; }
}
+internal interface ILocalPlayerMotionSource
+{
+ MotionInterpreter? Motion { get; }
+}
+
///
/// The one mutable local movement-controller slot. Player-mode lifecycle owns
/// assignment; update and presentation owners receive the read-only seam.
///
-internal sealed class LocalPlayerControllerSlot : ILocalPlayerControllerSource
+internal sealed class LocalPlayerControllerSlot
+ : ILocalPlayerControllerSource,
+ ILocalPlayerMotionSource
{
+ private PlayerMovementController? _preparingMotionOwner;
+
public PlayerMovementController? Controller { get; set; }
+
+ ///
+ /// 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 .
+ ///
+ MotionInterpreter? ILocalPlayerMotionSource.Motion =>
+ _preparingMotionOwner?.Motion ?? Controller?.Motion;
+
+ ///
+ /// 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.
+ ///
+ 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
diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs
index 382395ff..c4a3dfa5 100644
--- a/src/AcDream.App/Input/PlayerModeController.cs
+++ b/src/AcDream.App/Input/PlayerModeController.cs
@@ -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,
diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs
index 794cf8b8..860f276f 100644
--- a/src/AcDream.App/Interaction/SelectionInteractionController.cs
+++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs
@@ -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)
diff --git a/src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs b/src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs
index ab75fc0d..38489081 100644
--- a/src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs
+++ b/src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs
@@ -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;
diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs
index 5d6c9bca..d2c872ac 100644
--- a/src/AcDream.App/UI/ItemInteractionController.cs
+++ b/src/AcDream.App/UI/ItemInteractionController.cs
@@ -37,6 +37,32 @@ public readonly record struct PendingInventoryRequest(
ClientObject? ItemIdentity,
bool Dispatched);
+///
+/// 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.
+///
+public sealed class ItemUseRequestReservation
+{
+ private readonly Action _resolve;
+ private int _resolved;
+
+ internal ItemUseRequestReservation(Action 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);
+ }
+}
+
///
/// 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 _dragOnPlayerOpensSecureTrade;
private readonly Action? _systemMessage;
private readonly AutoWieldController _autoWield;
+ private readonly Action? _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? sendSplitToContainer = null,
Action? requestExternalContainer = null,
CombatState? combatState = null,
- Action? sendChangeCombatMode = null)
+ Action? sendChangeCombatMode = null,
+ Action? 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
///
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 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;
diff --git a/src/AcDream.App/World/LiveEntityPartArrayEnterWorldPort.cs b/src/AcDream.App/World/LiveEntityPartArrayEnterWorldPort.cs
new file mode 100644
index 00000000..b7ede09a
--- /dev/null
+++ b/src/AcDream.App/World/LiveEntityPartArrayEnterWorldPort.cs
@@ -0,0 +1,22 @@
+namespace AcDream.App.World;
+
+///
+/// Strongly typed retail PartArray world-entry boundary.
+///
+///
+/// CPhysicsObj::set_hidden @ 0x00514C60 invokes
+/// CPartArray::HandleEnterWorld @ 0x00517D70 on both Hidden edges.
+/// This dedicated port prevents that mandatory lifecycle callback from being
+/// confused with neighboring presentation callbacks.
+///
+public sealed class LiveEntityPartArrayEnterWorldPort
+{
+ private readonly Action _handleEnterWorld;
+
+ public LiveEntityPartArrayEnterWorldPort(Action handleEnterWorld) =>
+ _handleEnterWorld = handleEnterWorld
+ ?? throw new ArgumentNullException(nameof(handleEnterWorld));
+
+ public void HandleEnterWorld(uint localEntityId) =>
+ _handleEnterWorld(localEntityId);
+}
diff --git a/src/AcDream.App/World/LiveEntityPresentationController.cs b/src/AcDream.App/World/LiveEntityPresentationController.cs
index ff0ee778..417ebca5 100644
--- a/src/AcDream.App/World/LiveEntityPresentationController.cs
+++ b/src/AcDream.App/World/LiveEntityPresentationController.cs
@@ -27,7 +27,7 @@ public sealed class LiveEntityPresentationController : IDisposable
private readonly Action _clearInvalidTarget;
private readonly Func<(int X, int Y)> _liveCenter;
private readonly Action? _onShadowRestored;
- private readonly Action _handlePartArrayEnterWorld;
+ private readonly LiveEntityPartArrayEnterWorldPort _partArrayEnterWorld;
private readonly Dictionary _readyGenerationByGuid = new();
private readonly Dictionary _suspendedShadowGenerationByGuid = new();
private readonly Dictionary _activePlacementGenerationByGuid = new();
@@ -38,20 +38,21 @@ public sealed class LiveEntityPresentationController : IDisposable
LiveEntityRuntime liveEntities,
ShadowObjectRegistry shadows,
Func playTyped,
+ LiveEntityPartArrayEnterWorldPort partArrayEnterWorld,
Action? setDirectChildrenNoDraw = null,
Action? clearInvalidTarget = null,
Func<(int X, int Y)>? liveCenter = null,
- Action? onShadowRestored = null,
- Action? handlePartArrayEnterWorld = null)
+ Action? 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)
diff --git a/tests/AcDream.App.Tests/Input/LocalPlayerControllerSlotTests.cs b/tests/AcDream.App.Tests/Input/LocalPlayerControllerSlotTests.cs
new file mode 100644
index 00000000..17a9eab0
--- /dev/null
+++ b/tests/AcDream.App.Tests/Input/LocalPlayerControllerSlotTests.cs
@@ -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(
+ () => slot.BeginMotionPreparation(second));
+ }
+}
diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs
index 18d0379b..344c7bed 100644
--- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs
+++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs
@@ -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()
{
diff --git a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs
index 02b2e6a1..00fec7ae 100644
--- a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs
+++ b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs
@@ -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));
diff --git a/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs b/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs
index 441b7780..9f20475c 100644
--- a/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs
+++ b/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs
@@ -1424,6 +1424,7 @@ public sealed class RemoteTeleportControllerTests
Live,
Engine.ShadowObjects,
(_, _, _) => true,
+ new LiveEntityPartArrayEnterWorldPort(_ => { }),
liveCenter: () => (0xA9, 0xB4),
onShadowRestored: _ => ShadowSyncs++);
Assert.True(Presentation.OnLiveEntityReady(Guid));
diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs
index 404e75c5..0684ce5b 100644
--- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs
@@ -37,7 +37,8 @@ public sealed class ItemInteractionControllerTests
public uint GroundObject;
public long Now = 1_000;
- public Harness()
+ public Harness(
+ Action? 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()
{
diff --git a/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs b/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs
index 1af6e7d3..7540111b 100644
--- a/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs
+++ b/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs
@@ -564,6 +564,7 @@ public sealed class LiveEntityLifecycleStressTests
TypedScripts.Add(type);
return true;
},
+ new LiveEntityPartArrayEnterWorldPort(_ => { }),
liveCenter: () => (1, 1));
Assert.True(Presentation.OnLiveEntityReady(Guid));
diff --git a/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs
index 9be041f9..5c272886 100644
--- a/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs
+++ b/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs
@@ -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(
diff --git a/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs
index 4e5080ac..42bbc4fd 100644
--- a/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs
+++ b/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs
@@ -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)));
}