fix(world): restore portal and distant use lifecycles

This commit is contained in:
Erik 2026-07-25 09:59:25 +02:00
parent c730632075
commit 1a14812c44
18 changed files with 401 additions and 196 deletions

View file

@ -53,7 +53,9 @@ internal sealed class LocalPlayerControllerSlot
/// and completes StopCompletely, so its MotionDone relay must reach the
/// candidate interpreter before the complete controller is published.
/// </summary>
public IDisposable BeginMotionPreparation(PlayerMovementController controller)
public IDisposable BeginMotionPreparation(
PlayerMovementController controller,
Action? drainPriorAnimationQueue = null)
{
ArgumentNullException.ThrowIfNull(controller);
if (_preparingMotionOwner is not null)
@ -62,6 +64,12 @@ internal sealed class LocalPlayerControllerSlot
"A local player motion owner is already being prepared.");
}
// The live PartArray exists before the local player controller. Drain
// any animation completions produced under that prior ownership while
// they still resolve to the prior (or null) interpreter. Publishing
// the fresh interpreter first would let those old callbacks pop its
// new pending_motions queue and leave an unmatched Ready sentinel.
drainPriorAnimationQueue?.Invoke();
_preparingMotionOwner = controller;
return new MotionPreparation(this, controller);
}

View file

@ -368,6 +368,7 @@ internal sealed class PlayerModeController :
ApplyStepHeights(controller, playerEntity);
uint initialCellId = ResolveInitialCell(playerGuid, playerEntity);
Action? drainPriorAnimationQueue = null;
if (_animations.TryGetValue(playerEntity.Id, out LiveEntityAnimationState? animation)
&& animation.Sequencer is { } sequencer)
{
@ -384,6 +385,7 @@ internal sealed class PlayerModeController :
sequencer.Manager.CheckForCompletedMotions;
controller.Motion.DefaultSink =
new MotionTableDispatchSink(sequencer);
drainPriorAnimationQueue = sequencer.Manager.HandleEnterWorld;
}
// Retail CPhysicsObj owns CMotionInterp and CPartArray throughout
@ -393,7 +395,9 @@ internal sealed class PlayerModeController :
// public controller slot remains unpublished until every other
// player-mode edge has prepared successfully.
using IDisposable motionPreparation =
_controllerSlot.BeginMotionPreparation(controller);
_controllerSlot.BeginMotionPreparation(
controller,
drainPriorAnimationQueue);
ResolveResult initial = _physics.Resolve(
playerEntity.Position,

View file

@ -24,21 +24,19 @@ internal sealed class SelectionInteractionController
private readonly OutboundInteractionQueue _outbound = new();
private readonly PlayerApproachCompletionState _approachCompletions;
private readonly Action<string>? _toast;
private PendingPostArrivalAction? _pendingPostArrival;
private PendingPostArrivalPickup? _pendingPostArrival;
private readonly record struct QueuedInteractionIdentity(
uint ServerGuid,
uint? LocalEntityId,
ClientObject? ClientObject);
private readonly record struct PendingPostArrivalAction(
private readonly record struct PendingPostArrivalPickup(
uint ServerGuid,
uint LocalEntityId,
bool IsPickup,
uint DestinationContainerId,
int Placement,
ulong PendingPlacementToken,
ItemUseRequestReservation? UseReservation,
PlayerApproachToken ApproachToken);
public SelectionInteractionController(
@ -238,46 +236,23 @@ internal sealed class SelectionInteractionController
return;
}
// ItemHolder::UseObject @ 0x00588A80 sends Event_UseEvent for an
// accepted carried-item activation before CPlayerSystem::UsingItem.
// A carried source has no world pose to approach: routing it through
// TryGetApproach drops the packet because inventory objects are not
// live spatial entities.
// ItemHolder::UseObject @ 0x00588A80 sends Event_UseEvent before
// CPlayerSystem::UsingItem for every accepted ordinary Use. The latter
// is presentation/local inventory policy; it never delays the wire
// request behind a client-side TurnTo/MoveTo completion. ACE owns the
// authoritative approach chain for spatial targets.
if (_items.IsOwnedByPlayer(serverGuid))
{
SendUseNow(serverGuid, reservation);
return;
}
if (!_query.IsUseable(serverGuid)
|| !_query.TryGetApproach(serverGuid, out InteractionApproach approach))
if (!_query.IsUseable(serverGuid))
{
reservation?.CancelBeforeDispatch();
return;
}
if (approach.IsCloseRange)
{
var pending = new PendingPostArrivalAction(
serverGuid,
approach.Target.LocalEntityId,
IsPickup: false,
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);
SendUseNow(serverGuid, reservation);
}
@ -327,14 +302,12 @@ internal sealed class SelectionInteractionController
if (approach.IsCloseRange)
{
var pending = new PendingPostArrivalAction(
var pending = new PendingPostArrivalPickup(
itemGuid,
approach.Target.LocalEntityId,
IsPickup: true,
destinationContainerId,
placement,
pendingPlacementToken,
UseReservation: null,
ApproachToken: default);
bool started = _movement.BeginApproach(
approach,
@ -386,46 +359,33 @@ internal sealed class SelectionInteractionController
_pendingPostArrival = null;
if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId))
{
if (pending.IsPickup)
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
else
pending.UseReservation?.CancelBeforeDispatch();
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
return;
}
if (pending.IsPickup)
if (!IsCurrentPickupPresentation(
pending.ServerGuid,
pending.DestinationContainerId,
pending.Placement,
pending.PendingPlacementToken))
{
if (!IsCurrentPickupPresentation(
return;
}
if (!_items.TryDispatchInventoryRequest(
InventoryRequestKind.Pickup,
pending.ServerGuid,
() => _transport.TrySendPickup(
pending.ServerGuid,
pending.DestinationContainerId,
pending.Placement,
pending.PendingPlacementToken))
{
return;
}
if (!_items.TryDispatchInventoryRequest(
InventoryRequestKind.Pickup,
pending.ServerGuid,
() => _transport.TrySendPickup(
pending.ServerGuid,
pending.DestinationContainerId,
pending.Placement,
out _),
pending.PendingPlacementToken))
{
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
}
}
else
out _),
pending.PendingPlacementToken))
{
if (_transport.TrySendUse(pending.ServerGuid, out _))
pending.UseReservation?.MarkDispatched();
else
pending.UseReservation?.CancelBeforeDispatch();
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
}
}
@ -548,12 +508,9 @@ internal sealed class SelectionInteractionController
if (_pendingPostArrival is not { } pending)
return;
_pendingPostArrival = null;
if (pending.IsPickup)
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
else
pending.UseReservation?.CancelBeforeDispatch();
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
}
private void CancelPickupPresentation(uint itemGuid, ulong token = 0u)

View file

@ -44,7 +44,12 @@ internal sealed class LiveEntityAnimationPresenter
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(animation);
LiveEntityRuntime runtime = _liveEntities;
if (!runtime.IsCurrentSpatialAnimation(record, animation)
// The PartArray completion relay is a logical CPhysicsObj component,
// not a render-workset component. Projection hydration can bind it
// while world rendering is intentionally quiesced during login or
// teleport; waiting for spatial visibility would orphan the initial
// StopCompletely pending-motion node before the first visible frame.
if (!runtime.IsCurrentAnimationOwner(record, animation)
|| !ReferenceEquals(record.WorldEntity, animation.Entity)
|| animation.Sequencer is not { MotionDoneTarget: null } sequencer)
{
@ -55,7 +60,12 @@ internal sealed class LiveEntityAnimationPresenter
sequencer.MotionDoneTarget = (motion, success) =>
{
LiveEntityRuntime current = _liveEntities;
if (!current.IsCurrentSpatialAnimation(record, animation)
// CPhysicsObj owns its PartArray and CMotionInterp across
// leave/enter-world projection edges. The callback may therefore
// complete while rendering is intentionally withheld (login or
// teleport reveal), but it must never cross an entity incarnation
// or an animation-component replacement.
if (!current.IsCurrentAnimationOwner(record, animation)
|| !ReferenceEquals(record.WorldEntity, capturedEntity)
|| !ReferenceEquals(animation.Entity, capturedEntity))
{

View file

@ -39,6 +39,7 @@ internal sealed class WorldRevealCoordinator
private readonly IWorldRevealStreamingScheduler? _streaming;
private readonly IWorldRevealRenderResourceScheduler? _renderResources;
private long _activeGeneration;
private bool _worldSimulationReleased;
private bool _worldViewportReleased;
public WorldRevealCoordinator(
@ -80,6 +81,7 @@ internal sealed class WorldRevealCoordinator
_readiness.Begin();
long generation = _lifecycle.Begin(kind);
_activeGeneration = generation;
_worldSimulationReleased = false;
_worldViewportReleased = false;
_quiescence?.Begin(generation);
_streaming?.BeginDestinationReservation(
@ -107,7 +109,22 @@ internal sealed class WorldRevealCoordinator
return snapshot;
}
public void ObserveMaterialized() => _lifecycle.ObserveMaterialized();
/// <summary>
/// Commits the accepted destination and resumes its hidden world
/// simulation while the private portal viewport remains in front.
/// </summary>
/// <remarks>
/// Retail <c>SmartBox::UseTime @ 0x00455410</c> resumes
/// <c>CObjectMaint</c>/<c>CPhysics</c> before
/// <c>gmSmartBoxUI::EndTeleportAnimation @ 0x004D65A0</c> enters
/// <c>TAS_TUNNEL_CONTINUE</c>. The normal world viewport is not restored
/// until the later <c>TUNNEL_FADE_OUT -&gt; WORLD_FADE_IN</c> edge.
/// </remarks>
public void ObserveMaterialized()
{
_lifecycle.ObserveMaterialized();
ReleaseWorldSimulation();
}
public void ObserveWorldViewportVisible() =>
_lifecycle.ObserveWorldViewportVisible();
@ -116,9 +133,10 @@ internal sealed class WorldRevealCoordinator
_lifecycle.ObserveWait(elapsed);
/// <summary>
/// Reopens the destination world at retail's TunnelFadeOut -&gt;
/// WorldFadeIn viewport swap without completing the one-second
/// WorldFadeIn/LoginComplete tail.
/// Releases the destination streaming/resource reservation at retail's
/// TunnelFadeOut -&gt; WorldFadeIn viewport swap without completing the
/// one-second WorldFadeIn/LoginComplete tail. World simulation already
/// resumed when the accepted destination materialized.
/// </summary>
public void RevealWorldViewport()
{
@ -128,7 +146,6 @@ internal sealed class WorldRevealCoordinator
_streaming?.EndDestinationReservation(generation);
_renderResources?.EndDestinationReveal(generation);
_quiescence?.End(generation);
_worldViewportReleased = true;
}
@ -150,8 +167,20 @@ internal sealed class WorldRevealCoordinator
if (generation == 0)
return;
ReleaseWorldSimulation();
RevealWorldViewport();
_activeGeneration = 0;
_worldSimulationReleased = false;
_worldViewportReleased = false;
}
private void ReleaseWorldSimulation()
{
long generation = _activeGeneration;
if (generation == 0 || _worldSimulationReleased)
return;
_quiescence?.End(generation);
_worldSimulationReleased = true;
}
}

View file

@ -1901,6 +1901,18 @@ public sealed class LiveEntityRuntime
&& _spatialAnimationsByLocalId.TryGetValue(entity.Id, out var indexed)
&& ReferenceEquals(indexed, animation);
/// <summary>
/// Returns whether <paramref name="animation"/> is still the canonical
/// animation component of this logical entity incarnation. Motion
/// completion belongs to that CPhysicsObj/PartArray lifetime, not to the
/// transient render-bucket projection used by per-frame presentation.
/// </summary>
internal bool IsCurrentAnimationOwner(
LiveEntityRecord record,
ILiveEntityAnimationRuntime animation) =>
IsCurrentRecord(record)
&& ReferenceEquals(record.AnimationRuntime, animation);
/// <summary>
/// Copies the currently spatial animation owners through the concrete
/// dictionary enumerator. This keeps per-frame traversal allocation-free;