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

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