Separate loaded spatial residency from the world presentation gate so destination live objects acquire mesh owners behind portal space while drawing and simulation stay quiesced. Prevent unowned CPU-cache hits from recreating stale GPU staging work. Co-authored-by: Erik Nilsson <erikn@users.noreply.github.com>
2853 lines
113 KiB
C#
2853 lines
113 KiB
C#
using AcDream.App.Streaming;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.World;
|
|
using System.Numerics;
|
|
using System.Runtime.ExceptionServices;
|
|
|
|
namespace AcDream.App.World;
|
|
|
|
/// <summary>
|
|
/// Animation state owned by a live object. The concrete App animation runtime
|
|
/// implements this seam so identity storage does not depend on a render backend.
|
|
/// </summary>
|
|
public interface ILiveEntityAnimationRuntime
|
|
{
|
|
WorldEntity Entity { get; }
|
|
uint CurrentMotion { get; }
|
|
}
|
|
|
|
/// <summary>Remote motion state owned by a live object.</summary>
|
|
public interface ILiveEntityRemoteMotionRuntime
|
|
{
|
|
PhysicsBody Body { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Optional seam for a remote-motion component that reads the exact
|
|
/// incarnation's canonical movement-manager host from
|
|
/// <see cref="LiveEntityRecord"/>.
|
|
/// </summary>
|
|
public interface ILiveEntityPhysicsHostConsumer
|
|
{
|
|
void BindPhysicsHost(Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> read);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Atomic composition seam for a component that consumes both canonical host
|
|
/// and cell identity. Implementations validate every delegate before
|
|
/// publishing any of them, so a failed bind leaves the component reusable.
|
|
/// </summary>
|
|
public interface ILiveEntityCanonicalRuntimeConsumer
|
|
{
|
|
void BindCanonicalRuntime(
|
|
Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> readPhysicsHost,
|
|
Func<uint> readCell,
|
|
Action<uint> writeCell);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Optional seam for a remote-motion component that consumes the canonical
|
|
/// full-cell identity owned by <see cref="LiveEntityRecord"/>. The runtime
|
|
/// binds this for every production remote, regardless of whether projectile
|
|
/// classification arrives before or after the MovementManager.
|
|
/// </summary>
|
|
public interface ILiveEntityCanonicalCellConsumer
|
|
{
|
|
void BindCanonicalCell(Func<uint> read, Action<uint> write);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remote CPhysicsObj state required to finish a retail MoveOrTeleport
|
|
/// placement. A same-incarnation runtime replacement may wrap the same body,
|
|
/// but it must preserve this complete placement contract.
|
|
/// </summary>
|
|
public interface ILiveEntityRemotePlacementRuntime :
|
|
ILiveEntityRemoteMotionRuntime,
|
|
ILiveEntityCanonicalCellConsumer
|
|
{
|
|
uint CellId { get; set; }
|
|
bool Airborne { get; set; }
|
|
Vector3 LastServerPosition { get; set; }
|
|
double LastServerPositionTime { get; set; }
|
|
Vector3 LastShadowSyncPosition { get; set; }
|
|
Quaternion LastShadowSyncOrientation { get; set; }
|
|
void HitGround();
|
|
void LeaveGround();
|
|
}
|
|
|
|
/// <summary>Projectile physics state owned by a live object.</summary>
|
|
public interface ILiveEntityProjectileRuntime
|
|
{
|
|
PhysicsBody Body { get; }
|
|
}
|
|
|
|
/// <summary>Marker for the DAT-driven effect profile added by the effect phase.</summary>
|
|
public interface ILiveEntityEffectProfile { }
|
|
|
|
public enum LiveEntityProjectionKind
|
|
{
|
|
World,
|
|
Attached,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Logical-resource seam coordinated by <see cref="LiveEntityRuntime"/>.
|
|
/// Spatial bucketing is deliberately absent: registering or removing meshes,
|
|
/// scripts, and effects is a logical-lifetime operation, not a landblock move.
|
|
/// </summary>
|
|
public interface ILiveEntityResourceLifecycle
|
|
{
|
|
void Register(WorldEntity entity);
|
|
void Unregister(WorldEntity entity);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exact-incarnation App component cleanup invoked by the canonical runtime
|
|
/// after it has retired the active identity. Mesh/script resource lifetime is
|
|
/// owned separately by <see cref="ILiveEntityResourceLifecycle"/>.
|
|
/// </summary>
|
|
internal interface ILiveEntityRuntimeComponentLifecycle
|
|
{
|
|
void TearDown(LiveEntityRecord record);
|
|
}
|
|
|
|
internal sealed class NullLiveEntityRuntimeComponentLifecycle :
|
|
ILiveEntityRuntimeComponentLifecycle
|
|
{
|
|
public static NullLiveEntityRuntimeComponentLifecycle Instance { get; } = new();
|
|
private NullLiveEntityRuntimeComponentLifecycle() { }
|
|
public void TearDown(LiveEntityRecord record) { }
|
|
}
|
|
|
|
internal sealed class DelegateLiveEntityRuntimeComponentLifecycle(
|
|
Action<LiveEntityRecord> tearDown) : ILiveEntityRuntimeComponentLifecycle
|
|
{
|
|
private readonly Action<LiveEntityRecord> _tearDown =
|
|
tearDown ?? throw new ArgumentNullException(nameof(tearDown));
|
|
|
|
public void TearDown(LiveEntityRecord record) => _tearDown(record);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Composition bridge for the runtime-to-hydration construction cycle. It is
|
|
/// deliberately fail-fast and single-assignment: a live session must not start
|
|
/// before the exact component owner is bound, and ownership cannot be replaced
|
|
/// during a session.
|
|
/// </summary>
|
|
internal sealed class DeferredLiveEntityRuntimeComponentLifecycle :
|
|
ILiveEntityRuntimeComponentLifecycle
|
|
{
|
|
private ILiveEntityRuntimeComponentLifecycle? _inner;
|
|
|
|
public bool IsBound => Volatile.Read(ref _inner) is not null;
|
|
|
|
public void Bind(ILiveEntityRuntimeComponentLifecycle lifecycle)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(lifecycle);
|
|
if (ReferenceEquals(lifecycle, this))
|
|
throw new ArgumentException("A lifecycle bridge cannot bind to itself.", nameof(lifecycle));
|
|
if (Interlocked.CompareExchange(ref _inner, lifecycle, null) is not null)
|
|
throw new InvalidOperationException("The live-entity runtime component lifecycle is already bound.");
|
|
}
|
|
|
|
public IDisposable BindOwned(ILiveEntityRuntimeComponentLifecycle lifecycle)
|
|
{
|
|
Bind(lifecycle);
|
|
return new Binding(this, lifecycle);
|
|
}
|
|
|
|
private void Unbind(ILiveEntityRuntimeComponentLifecycle expected)
|
|
{
|
|
_ = Interlocked.CompareExchange(ref _inner, null, expected);
|
|
}
|
|
|
|
public void TearDown(LiveEntityRecord record)
|
|
{
|
|
ILiveEntityRuntimeComponentLifecycle lifecycle = Volatile.Read(ref _inner)
|
|
?? throw new InvalidOperationException(
|
|
"The live-entity runtime component lifecycle has not been bound.");
|
|
lifecycle.TearDown(record);
|
|
}
|
|
|
|
private sealed class Binding : IDisposable
|
|
{
|
|
private DeferredLiveEntityRuntimeComponentLifecycle? _owner;
|
|
private readonly ILiveEntityRuntimeComponentLifecycle _expected;
|
|
|
|
public Binding(
|
|
DeferredLiveEntityRuntimeComponentLifecycle owner,
|
|
ILiveEntityRuntimeComponentLifecycle expected)
|
|
{
|
|
_owner = owner;
|
|
_expected = expected;
|
|
}
|
|
|
|
public void Dispose() =>
|
|
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
|
|
}
|
|
}
|
|
|
|
/// <summary>Delegate adapter used by the App composition root.</summary>
|
|
public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLifecycle
|
|
{
|
|
private readonly Action<WorldEntity> _register;
|
|
private readonly Action<WorldEntity> _unregister;
|
|
|
|
public DelegateLiveEntityResourceLifecycle(
|
|
Action<WorldEntity> register,
|
|
Action<WorldEntity> unregister)
|
|
{
|
|
_register = register ?? throw new ArgumentNullException(nameof(register));
|
|
_unregister = unregister ?? throw new ArgumentNullException(nameof(unregister));
|
|
}
|
|
|
|
public void Register(WorldEntity entity) => _register(entity);
|
|
public void Unregister(WorldEntity entity) => _unregister(entity);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The one logical record for a server object incarnation. The record survives
|
|
/// loaded/pending landblock movement and temporary loss of its render bucket.
|
|
/// Only an accepted DeleteObject, session reset, newer INSTANCE_TS, or the
|
|
/// retail 25-second leave-visibility lifecycle ends it.
|
|
/// </summary>
|
|
public sealed class LiveEntityRecord
|
|
{
|
|
private readonly Queue<RetailPhysicsStateTransition> _pendingStateTransitions = new();
|
|
|
|
internal LiveEntityRecord(WorldSession.EntitySpawn snapshot)
|
|
{
|
|
ServerGuid = snapshot.Guid;
|
|
Snapshot = snapshot;
|
|
RefreshDerivedState();
|
|
PositionAuthorityVersion = snapshot.Position is null ? 0UL : 1UL;
|
|
VectorAuthorityVersion = snapshot.Physics is null ? 0UL : 1UL;
|
|
VelocityAuthorityVersion = snapshot.Position is null
|
|
&& snapshot.Physics is null
|
|
? 0UL
|
|
: 1UL;
|
|
MovementAuthorityVersion = 1UL;
|
|
ObjDescAuthorityVersion = 1UL;
|
|
FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState;
|
|
ApplyRawPhysicsState(RawPhysicsState);
|
|
}
|
|
|
|
public uint ServerGuid { get; }
|
|
public ushort Generation => Snapshot.InstanceSequence;
|
|
public WorldSession.EntitySpawn Snapshot { get; internal set; }
|
|
public WorldEntity? WorldEntity { get; internal set; }
|
|
public uint? LocalEntityId => WorldEntity?.Id;
|
|
public uint FullCellId { get; internal set; }
|
|
public uint CanonicalLandblockId { get; internal set; }
|
|
public uint RawPhysicsState { get; internal set; }
|
|
public PhysicsStateFlags FinalPhysicsState { get; internal set; }
|
|
/// <summary>
|
|
/// Incarnation-stable retail <c>CPhysicsObj::update_time</c> owner. It
|
|
/// survives animation and MovementManager attachment/replacement, spatial
|
|
/// rebucketing, and appearance rehydration, and dies with this record.
|
|
/// </summary>
|
|
public RetailObjectQuantumClock ObjectClock { get; } = new();
|
|
/// <summary>
|
|
/// Changes whenever this record leaves or re-enters the ordinary-object
|
|
/// workset. Unlike <see cref="ProjectionMutationVersion"/>, loaded-to-loaded
|
|
/// rebucketing does not change this token. A scheduler can therefore stop a
|
|
/// catch-up batch that crossed a leave/enter boundary without rejecting an
|
|
/// ordinary same-frame cell move.
|
|
/// </summary>
|
|
internal ulong ObjectClockEpoch { get; private set; }
|
|
/// <summary>
|
|
/// True after this incarnation has successfully constructed its retail
|
|
/// <c>CPartArray</c>. This is an object-lifetime fact, not an animation-
|
|
/// runtime proxy: static poses and objects without a MotionTable still
|
|
/// own a PartArray and participate in the 96-unit Active gate.
|
|
/// </summary>
|
|
public bool HasPartArray { get; internal set; }
|
|
public PhysicsBody? PhysicsBody { get; internal set; }
|
|
internal bool PhysicsBodyAcquisitionInProgress { get; set; }
|
|
public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; }
|
|
public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; }
|
|
internal bool RemoteMotionBindingInProgress { get; set; }
|
|
public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost { get; internal set; }
|
|
internal bool RequiresRemotePlacementRuntime { get; set; }
|
|
public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; }
|
|
public ILiveEntityEffectProfile? EffectProfile { get; internal set; }
|
|
public bool ResourcesRegistered { get; internal set; }
|
|
public bool IsSpatiallyProjected { get; internal set; }
|
|
public bool IsSpatiallyVisible { get; internal set; }
|
|
internal ulong ProjectionMutationVersion { get; set; }
|
|
/// <summary>
|
|
/// Monotonic App operation tokens for accepted wire authority. They let a
|
|
/// handler detect a newer same-incarnation packet delivered re-entrantly
|
|
/// while an older packet is crossing presentation or spatial callbacks.
|
|
/// </summary>
|
|
internal ulong PositionAuthorityVersion { get; private set; }
|
|
internal ulong StateAuthorityVersion { get; private set; }
|
|
internal ulong VectorAuthorityVersion { get; private set; }
|
|
internal ulong VelocityAuthorityVersion { get; private set; }
|
|
internal ulong MovementAuthorityVersion { get; private set; }
|
|
internal ulong ObjDescAuthorityVersion { get; private set; }
|
|
/// <summary>
|
|
/// Advances for every accepted CreateObject affecting this incarnation,
|
|
/// including a fresher same-INSTANCE_TS retransmit. App integration uses
|
|
/// it to distinguish two transactions that intentionally share identity.
|
|
/// </summary>
|
|
internal ulong CreateIntegrationVersion { get; private set; } = 1UL;
|
|
public bool WorldSpawnPublished { get; internal set; }
|
|
/// <summary>
|
|
/// True only after the incarnation's projection, collision/animation
|
|
/// components, effect owner, state presentation, and pending-effect replay
|
|
/// have crossed the initial ready barrier. A non-null
|
|
/// <see cref="WorldEntity"/> alone is not sufficient: failures after
|
|
/// resource registration retain the same projection for exact retry.
|
|
/// </summary>
|
|
public bool InitialHydrationCompleted { get; internal set; }
|
|
/// <summary>
|
|
/// A fresher same-incarnation CreateObject interrupted projection after
|
|
/// logical ownership had already been established. The retained entity
|
|
/// must replay the newest canonical appearance/current snapshot/animation
|
|
/// before ordinary rebucketing can resume. This obligation deliberately
|
|
/// survives a failed recovery attempt.
|
|
/// </summary>
|
|
internal bool CreateProjectionSynchronizationPending { get; set; }
|
|
internal bool ProjectionHydrationInProgress { get; set; }
|
|
internal ulong ProjectionHydrationCreateVersion { get; set; }
|
|
internal bool ProjectionHydrationRetryRequested { get; set; }
|
|
/// <summary>
|
|
/// A renderer mesh transaction can be re-entered by a newer accepted
|
|
/// ObjDesc. Keep that independent projection obligation on the exact
|
|
/// incarnation until the newest visual description publishes.
|
|
/// </summary>
|
|
internal bool AppearanceProjectionSynchronizationPending { get; set; }
|
|
internal bool AppearanceHydrationInProgress { get; set; }
|
|
internal ulong AppearanceHydrationVersion { get; set; }
|
|
internal bool AppearanceHydrationRetryRequested { get; set; }
|
|
public LiveEntityProjectionKind ProjectionKind { get; internal set; }
|
|
internal bool RuntimeComponentsTeardownCompleted { get; set; }
|
|
internal LiveEntityTeardownPlan? RuntimeComponentTeardownPlan { get; set; }
|
|
internal bool SpatialProjectionTeardownCompleted { get; set; }
|
|
internal bool TeardownInProgress { get; set; }
|
|
internal bool DeleteAcceptedForTeardown { get; set; }
|
|
|
|
internal bool TryDequeueStateTransition(out RetailPhysicsStateTransition transition) =>
|
|
_pendingStateTransitions.TryDequeue(out transition);
|
|
|
|
internal void SuspendObjectClock()
|
|
{
|
|
ObjectClock.Deactivate();
|
|
ObjectClockEpoch++;
|
|
}
|
|
|
|
internal void ResetObjectClockForEnterWorld(bool isStatic)
|
|
{
|
|
ObjectClock.ResetForEnterWorld(isStatic);
|
|
ObjectClockEpoch++;
|
|
}
|
|
|
|
internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState)
|
|
{
|
|
StateAuthorityVersion++;
|
|
RawPhysicsState = rawState;
|
|
RetailPhysicsStateTransition transition = RetailPhysicsStateTransitions.Apply(
|
|
FinalPhysicsState,
|
|
(PhysicsStateFlags)rawState);
|
|
FinalPhysicsState = transition.FinalState;
|
|
if (PhysicsBody is not null)
|
|
PhysicsBody.State = FinalPhysicsState;
|
|
_pendingStateTransitions.Enqueue(transition);
|
|
return transition;
|
|
}
|
|
|
|
internal void AdvancePositionAuthority()
|
|
{
|
|
PositionAuthorityVersion++;
|
|
VelocityAuthorityVersion++;
|
|
}
|
|
|
|
internal void AdvanceVectorAuthority()
|
|
{
|
|
VectorAuthorityVersion++;
|
|
VelocityAuthorityVersion++;
|
|
}
|
|
|
|
internal void AdvanceMovementAuthority()
|
|
{
|
|
MovementAuthorityVersion++;
|
|
VelocityAuthorityVersion++;
|
|
}
|
|
|
|
internal void AdvanceObjDescAuthority()
|
|
{
|
|
ObjDescAuthorityVersion++;
|
|
AppearanceProjectionSynchronizationPending = true;
|
|
if (AppearanceHydrationInProgress)
|
|
AppearanceHydrationRetryRequested = true;
|
|
}
|
|
|
|
internal void AdvanceCreateAuthority()
|
|
{
|
|
PositionAuthorityVersion++;
|
|
StateAuthorityVersion++;
|
|
VectorAuthorityVersion++;
|
|
VelocityAuthorityVersion++;
|
|
MovementAuthorityVersion++;
|
|
ObjDescAuthorityVersion++;
|
|
CreateIntegrationVersion++;
|
|
}
|
|
|
|
internal void SetChildNoDraw(bool noDraw)
|
|
{
|
|
FinalPhysicsState = noDraw
|
|
? FinalPhysicsState | PhysicsStateFlags.NoDraw
|
|
: FinalPhysicsState & ~PhysicsStateFlags.NoDraw;
|
|
if (PhysicsBody is not null)
|
|
PhysicsBody.State = FinalPhysicsState;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cell used when a streamed landblock asks live objects to restore their
|
|
/// spatial projection. Once materialized, the canonical runtime cell wins;
|
|
/// the retained server snapshot is only a first-materialization source.
|
|
/// </summary>
|
|
public uint ProjectionCellId => WorldEntity is not null
|
|
? FullCellId
|
|
: Snapshot.Position?.LandblockId ?? FullCellId;
|
|
|
|
internal void RefreshDerivedState(bool refreshPosition = true)
|
|
{
|
|
if (refreshPosition && Snapshot.Position is { } position)
|
|
{
|
|
FullCellId = position.LandblockId;
|
|
CanonicalLandblockId = (FullCellId & 0xFFFF0000u) | 0xFFFFu;
|
|
}
|
|
RawPhysicsState = Snapshot.Physics?.RawState
|
|
?? Snapshot.PhysicsState
|
|
?? 0u;
|
|
}
|
|
}
|
|
|
|
public readonly record struct LiveEntityRegistrationResult(
|
|
InboundCreateResult Inbound,
|
|
LiveEntityRecord? Record,
|
|
bool LogicalRegistrationCreated,
|
|
bool ReplacedExistingGeneration,
|
|
Exception? PriorGenerationCleanupFailure = null);
|
|
|
|
/// <summary>
|
|
/// Update-thread owner of live server-object identity, accepted state, runtime
|
|
/// components, and logical teardown. <see cref="GpuWorldState"/> is a spatial
|
|
/// projection only; moving between loaded and pending buckets never replays a
|
|
/// create-time renderer, script, animation, or effect action.
|
|
///
|
|
/// <para>
|
|
/// Retail keeps one object per accepted INSTANCE_TS: same-generation
|
|
/// CreateObject branches mutate it, Pickup leaves world without destruction,
|
|
/// and Delete/instance replacement end it. See SmartBox CreateObject/event
|
|
/// pseudocode in <c>2026-07-13-retail-projectile-vfx-pseudocode.md</c>,
|
|
/// <c>CPhysicsObj::set_description</c> (0x00514F40),
|
|
/// <c>CPhysicsObj::change_cell</c> (0x00513390), and
|
|
/// <c>CPhysicsObj::exit_world</c> (0x00514E60).
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class LiveEntityRuntime
|
|
{
|
|
public const uint FirstLiveEntityId = 1_000_000u;
|
|
public const uint LastLiveEntityId = 0x3FFF_FFFFu;
|
|
|
|
private readonly GpuWorldState _spatial;
|
|
private readonly ILiveEntityResourceLifecycle _resources;
|
|
private readonly ILiveEntityRuntimeComponentLifecycle _runtimeComponentLifecycle;
|
|
private readonly InboundPhysicsStateController _inbound = new();
|
|
private readonly Dictionary<uint, LiveEntityRecord> _recordsByGuid = new();
|
|
// Records leave the active gameplay map before arbitrary teardown callbacks
|
|
// so a newer GUID generation can be accepted re-entrantly. They remain
|
|
// canonical teardown tombstones until every resource owner confirms
|
|
// unregister; failures are therefore retryable by Delete or session Clear.
|
|
private readonly Dictionary<(uint Guid, ushort Generation), LiveEntityRecord>
|
|
_teardownRecords = new();
|
|
private readonly Dictionary<uint, WorldEntity> _materializedWorldEntitiesByGuid = new();
|
|
private readonly Dictionary<uint, WorldEntity> _visibleWorldEntitiesByGuid = new();
|
|
private readonly Dictionary<uint, uint> _guidByLocalId = new();
|
|
private readonly Dictionary<uint, ILiveEntityAnimationRuntime> _animationsByLocalId = new();
|
|
private readonly Dictionary<uint, ILiveEntityRemoteMotionRuntime> _remoteMotionByGuid = new();
|
|
// Logical components survive retail's 25-second leave-visibility lifetime.
|
|
// Frame loops must not scan those retained owners. These component-specific
|
|
// indexes contain only records with a loaded, non-cellless spatial
|
|
// projection. Hidden and Frozen remain members: their state controls what
|
|
// each retail update path advances after the O(active) lookup.
|
|
private readonly Dictionary<uint, ILiveEntityAnimationRuntime> _spatialAnimationsByLocalId = new();
|
|
private readonly Dictionary<uint, ILiveEntityRemoteMotionRuntime> _spatialRemoteMotionByGuid = new();
|
|
private readonly Dictionary<uint, ILiveEntityProjectileRuntime> _spatialProjectilesByGuid = new();
|
|
// Retail CPhysics::UseTime walks the ordinary object table, not a render-
|
|
// animation component table. This index is the loaded/cell-backed root
|
|
// workset; component-specific indexes remain lookup accelerators only.
|
|
private readonly Dictionary<uint, LiveEntityRecord> _spatialRootObjectsByGuid = new();
|
|
private bool _isClearing;
|
|
private bool _sessionClearPendingFinalization;
|
|
private bool _isRegisteringResources;
|
|
private int _logicalTeardownDepth;
|
|
private ulong _sessionLifetimeVersion;
|
|
private readonly Dictionary<uint, ulong> _lifetimeMutationVersionByGuid = new();
|
|
private uint _rebucketingGuid;
|
|
private uint _nextLocalEntityId;
|
|
|
|
public LiveEntityRuntime(
|
|
GpuWorldState spatial,
|
|
ILiveEntityResourceLifecycle resources,
|
|
uint firstLocalEntityId = FirstLiveEntityId)
|
|
: this(
|
|
spatial,
|
|
resources,
|
|
NullLiveEntityRuntimeComponentLifecycle.Instance,
|
|
firstLocalEntityId)
|
|
{
|
|
}
|
|
|
|
public LiveEntityRuntime(
|
|
GpuWorldState spatial,
|
|
ILiveEntityResourceLifecycle resources,
|
|
Action<LiveEntityRecord> tearDownRuntimeComponents,
|
|
uint firstLocalEntityId = FirstLiveEntityId)
|
|
: this(
|
|
spatial,
|
|
resources,
|
|
new DelegateLiveEntityRuntimeComponentLifecycle(tearDownRuntimeComponents),
|
|
firstLocalEntityId)
|
|
{
|
|
}
|
|
|
|
internal LiveEntityRuntime(
|
|
GpuWorldState spatial,
|
|
ILiveEntityResourceLifecycle resources,
|
|
ILiveEntityRuntimeComponentLifecycle? runtimeComponentLifecycle,
|
|
uint firstLocalEntityId = FirstLiveEntityId)
|
|
{
|
|
_spatial = spatial ?? throw new ArgumentNullException(nameof(spatial));
|
|
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
|
|
_runtimeComponentLifecycle = runtimeComponentLifecycle
|
|
?? throw new ArgumentNullException(nameof(runtimeComponentLifecycle));
|
|
if (firstLocalEntityId is < FirstLiveEntityId or > LastLiveEntityId)
|
|
throw new ArgumentOutOfRangeException(
|
|
nameof(firstLocalEntityId),
|
|
$"Live entity ids must stay in 0x{FirstLiveEntityId:X8}..0x{LastLiveEntityId:X8}.");
|
|
_nextLocalEntityId = firstLocalEntityId;
|
|
_spatial.LiveProjectionVisibilityChanged += OnSpatialVisibilityChanged;
|
|
}
|
|
|
|
public int Count => _recordsByGuid.Count;
|
|
public int PendingTeardownCount => _teardownRecords.Count;
|
|
public int MaterializedCount => _guidByLocalId.Count;
|
|
public IReadOnlyCollection<LiveEntityRecord> Records => _recordsByGuid.Values;
|
|
/// <summary>
|
|
/// Every materialized top-level world projection, including an object
|
|
/// parked in a pending (currently unloaded) spatial bucket. Network and
|
|
/// physics mutation must use this stable view so streaming visibility can
|
|
/// never masquerade as logical destruction.
|
|
/// </summary>
|
|
public IReadOnlyDictionary<uint, WorldEntity> MaterializedWorldEntities =>
|
|
_materializedWorldEntitiesByGuid;
|
|
|
|
/// <summary>
|
|
/// Currently visible top-level world projections. Attached children and
|
|
/// pending or Hidden projections are excluded from radar, picking, status,
|
|
/// and target candidate consumers. NoDraw alone suppresses the mesh but is
|
|
/// not a retail cell-visibility transition.
|
|
/// </summary>
|
|
public IReadOnlyDictionary<uint, WorldEntity> WorldEntities =>
|
|
_visibleWorldEntitiesByGuid;
|
|
public IReadOnlyDictionary<uint, WorldSession.EntitySpawn> Snapshots => _inbound.Snapshots;
|
|
public IReadOnlyDictionary<uint, ILiveEntityAnimationRuntime> AnimationRuntimes => _animationsByLocalId;
|
|
public IReadOnlyDictionary<uint, ILiveEntityRemoteMotionRuntime> RemoteMotionRuntimes => _remoteMotionByGuid;
|
|
public IReadOnlyDictionary<uint, ILiveEntityAnimationRuntime> SpatialAnimationRuntimes =>
|
|
_spatialAnimationsByLocalId;
|
|
public IReadOnlyDictionary<uint, ILiveEntityRemoteMotionRuntime> SpatialRemoteMotionRuntimes =>
|
|
_spatialRemoteMotionByGuid;
|
|
internal IReadOnlyDictionary<uint, ILiveEntityProjectileRuntime> SpatialProjectileRuntimes =>
|
|
_spatialProjectilesByGuid;
|
|
internal IReadOnlyDictionary<uint, LiveEntityRecord> SpatialRootObjects =>
|
|
_spatialRootObjectsByGuid;
|
|
public ParentAttachmentState ParentAttachments { get; } = new();
|
|
|
|
/// <summary>
|
|
/// Raised after a materialized projection enters or leaves the currently
|
|
/// loaded spatial view. Logical resources remain owned by the record.
|
|
/// </summary>
|
|
public event Action<LiveEntityRecord, bool>? ProjectionVisibilityChanged;
|
|
|
|
public LiveEntityRegistrationResult RegisterLiveEntity(WorldSession.EntitySpawn incoming)
|
|
{
|
|
if (_isClearing || _sessionClearPendingFinalization || _isRegisteringResources)
|
|
{
|
|
throw new InvalidOperationException(
|
|
_isClearing || _sessionClearPendingFinalization
|
|
? "A live entity cannot register while the session lifetime is clearing."
|
|
: "A live entity cannot register from inside atomic resource registration.");
|
|
}
|
|
|
|
InboundCreateResult result = _inbound.AcceptCreate(incoming);
|
|
if (result.Disposition is CreateObjectTimestampDisposition.StaleGeneration)
|
|
return new LiveEntityRegistrationResult(result, null, false, false);
|
|
ulong sessionVersion = _sessionLifetimeVersion;
|
|
ulong operationVersion = AdvanceLifetimeMutation(incoming.Guid);
|
|
|
|
if (result.Disposition is CreateObjectTimestampDisposition.ExistingGeneration)
|
|
{
|
|
if (_recordsByGuid.TryGetValue(incoming.Guid, out LiveEntityRecord? retained))
|
|
{
|
|
retained.Snapshot = result.Snapshot;
|
|
retained.RefreshDerivedState();
|
|
retained.AdvanceCreateAuthority();
|
|
RefreshSpatialRuntimeIndexes(retained);
|
|
return new LiveEntityRegistrationResult(result, retained, false, false);
|
|
}
|
|
|
|
// A failed materialization rollback retains this exact incarnation
|
|
// as a teardown tombstone. Do not create a second active record
|
|
// with the same (GUID, INSTANCE_TS) while its partial resources are
|
|
// still converging; a later retransmit can recover after teardown.
|
|
if (_teardownRecords.ContainsKey(
|
|
(incoming.Guid, result.Snapshot.InstanceSequence)))
|
|
{
|
|
return new LiveEntityRegistrationResult(
|
|
result,
|
|
null,
|
|
false,
|
|
false);
|
|
}
|
|
|
|
// Defensive repair for a caller that cleared only the logical map.
|
|
// Normal session teardown clears both owners together.
|
|
var recovered = new LiveEntityRecord(result.Snapshot);
|
|
_recordsByGuid.Add(incoming.Guid, recovered);
|
|
return new LiveEntityRegistrationResult(result, recovered, true, false);
|
|
}
|
|
|
|
bool replaced = _recordsByGuid.Remove(incoming.Guid, out LiveEntityRecord? old);
|
|
if (result.Disposition is CreateObjectTimestampDisposition.NewGeneration)
|
|
ParentAttachments.EndGeneration(incoming.Guid, result.Snapshot.InstanceSequence);
|
|
|
|
Exception? tearDownFailure = null;
|
|
if (old is not null)
|
|
{
|
|
RetainTeardownRecord(old);
|
|
_logicalTeardownDepth++;
|
|
try
|
|
{
|
|
try
|
|
{
|
|
TearDownRecord(old);
|
|
ReleaseTeardownRecord(old);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
tearDownFailure = error;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_logicalTeardownDepth--;
|
|
}
|
|
}
|
|
|
|
// Resource callbacks are arbitrary App integration code. Any nested
|
|
// create, accepted delete, or session reset advances this epoch. The
|
|
// outer packet is then superseded even when the nested action left no
|
|
// record/snapshot (delete/reset); reinstalling it would resurrect an
|
|
// incarnation after an accepted terminal event.
|
|
if (_sessionLifetimeVersion != sessionVersion
|
|
|| CurrentLifetimeMutation(incoming.Guid) != operationVersion)
|
|
{
|
|
if (tearDownFailure is not null)
|
|
{
|
|
throw new AggregateException(
|
|
$"Prior incarnation of live entity 0x{incoming.Guid:X8} failed teardown while its incoming replacement was superseded.",
|
|
tearDownFailure);
|
|
}
|
|
|
|
return new LiveEntityRegistrationResult(
|
|
SupersededCreateResult(),
|
|
_recordsByGuid.GetValueOrDefault(incoming.Guid),
|
|
false,
|
|
replaced,
|
|
tearDownFailure);
|
|
}
|
|
|
|
var record = new LiveEntityRecord(result.Snapshot);
|
|
_recordsByGuid.Add(incoming.Guid, record);
|
|
return new LiveEntityRegistrationResult(
|
|
result,
|
|
record,
|
|
true,
|
|
replaced,
|
|
tearDownFailure);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Completes logical registration when a renderable projection can be
|
|
/// hydrated. The factory is called at most once for the incarnation.
|
|
/// </summary>
|
|
public WorldEntity? MaterializeLiveEntity(
|
|
uint serverGuid,
|
|
uint fullCellId,
|
|
Func<uint, WorldEntity> factory,
|
|
LiveEntityProjectionKind projectionKind = LiveEntityProjectionKind.World)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(factory);
|
|
if (_isClearing
|
|
|| _sessionClearPendingFinalization
|
|
|| _logicalTeardownDepth != 0
|
|
|| _isRegisteringResources)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"A live entity cannot materialize inside an active logical-lifetime transition.");
|
|
}
|
|
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record))
|
|
return null;
|
|
|
|
if (record.WorldEntity is null)
|
|
{
|
|
uint localId = ReserveLocalEntityId();
|
|
WorldEntity entity = factory(localId)
|
|
?? throw new InvalidOperationException("A live-entity projection factory returned null.");
|
|
if (entity.Id != localId)
|
|
throw new InvalidOperationException(
|
|
$"Live projection id 0x{entity.Id:X8} did not match reserved id 0x{localId:X8}.");
|
|
if (entity.ServerGuid != serverGuid)
|
|
throw new InvalidOperationException(
|
|
$"Live projection guid 0x{entity.ServerGuid:X8} did not match record 0x{serverGuid:X8}.");
|
|
|
|
_guidByLocalId.Add(localId, serverGuid);
|
|
record.WorldEntity = entity;
|
|
_isRegisteringResources = true;
|
|
try
|
|
{
|
|
// Registration may span several App owners. Until the whole
|
|
// edge returns, Unregister remains a required rollback
|
|
// obligation; a failed rollback retains this identity as a
|
|
// teardown tombstone instead of making partial owners
|
|
// unreachable.
|
|
record.ResourcesRegistered = true;
|
|
try
|
|
{
|
|
_resources.Register(entity);
|
|
}
|
|
catch (Exception registrationError)
|
|
{
|
|
Exception? rollbackError = null;
|
|
try
|
|
{
|
|
_resources.Unregister(entity);
|
|
record.ResourcesRegistered = false;
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
rollbackError = error;
|
|
}
|
|
|
|
if (rollbackError is null)
|
|
{
|
|
_guidByLocalId.Remove(localId);
|
|
record.WorldEntity = null;
|
|
throw;
|
|
}
|
|
|
|
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? active)
|
|
&& ReferenceEquals(active, record))
|
|
{
|
|
_recordsByGuid.Remove(serverGuid);
|
|
}
|
|
RetainTeardownRecord(record);
|
|
throw new AggregateException(
|
|
"Live entity resource registration and rollback both failed; " +
|
|
"the partial owner was retained for teardown retry.",
|
|
registrationError,
|
|
rollbackError);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_isRegisteringResources = false;
|
|
}
|
|
}
|
|
|
|
record.ProjectionKind = projectionKind;
|
|
if (projectionKind is LiveEntityProjectionKind.World)
|
|
{
|
|
// Attached projections inherit the complete ancestor visibility
|
|
// chain. Once the same logical entity becomes a top-level world
|
|
// projection it has no render ancestor, so retained inherited
|
|
// suppression must be cleared at this ownership transition.
|
|
record.WorldEntity!.IsAncestorDrawVisible = true;
|
|
}
|
|
RefreshPresentation(record);
|
|
WorldEntity materialized = record.WorldEntity!;
|
|
if (!RebucketLiveEntity(serverGuid, fullCellId)
|
|
|| !_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current)
|
|
|| !ReferenceEquals(current, record)
|
|
|| !ReferenceEquals(current.WorldEntity, materialized))
|
|
{
|
|
// Visibility observers may replace this GUID while rebucketing.
|
|
// A failed teardown can retain the displaced entity as a retry
|
|
// tombstone; it is not the result of this materialization.
|
|
return null;
|
|
}
|
|
return materialized;
|
|
}
|
|
|
|
/// <summary>Changes spatial buckets only. No logical resource callbacks run.</summary>
|
|
public bool RebucketLiveEntity(uint serverGuid, uint spatialCellOrLandblockId)
|
|
{
|
|
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
|| record.WorldEntity is not { } entity)
|
|
return false;
|
|
|
|
bool wasProjected = record.IsSpatiallyProjected;
|
|
bool wasVisible = record.IsSpatiallyVisible;
|
|
bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue(
|
|
serverGuid,
|
|
out LiveEntityRecord? indexedRoot)
|
|
&& ReferenceEquals(indexedRoot, record);
|
|
ulong projectionOperation = ++record.ProjectionMutationVersion;
|
|
// GpuWorldState reports an intermediate false/true pair while moving
|
|
// between two loaded buckets. Suppress those implementation details
|
|
// and publish only the final logical visibility edge.
|
|
record.IsSpatiallyProjected = true;
|
|
Exception? spatialNotificationFailure = null;
|
|
uint priorRebucketingGuid = _rebucketingGuid;
|
|
_rebucketingGuid = serverGuid;
|
|
try
|
|
{
|
|
try
|
|
{
|
|
_spatial.RebucketLiveEntity(entity, spatialCellOrLandblockId);
|
|
}
|
|
catch (AggregateException error)
|
|
{
|
|
// GpuWorldState has already committed the bucket move and
|
|
// drained every visibility observer before reporting their
|
|
// failures. Finish this runtime transaction before surfacing
|
|
// the notification error to the caller.
|
|
spatialNotificationFailure = error;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_rebucketingGuid = priorRebucketingGuid;
|
|
}
|
|
if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation))
|
|
{
|
|
ThrowAfterCommittedProjectionChange(
|
|
serverGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure: null);
|
|
return false;
|
|
}
|
|
bool visible = _spatial.IsLiveEntityProjectionResident(serverGuid);
|
|
record.IsSpatiallyVisible = visible;
|
|
if (record.ProjectionKind is LiveEntityProjectionKind.World)
|
|
_materializedWorldEntitiesByGuid[serverGuid] = entity;
|
|
else
|
|
_materializedWorldEntitiesByGuid.Remove(serverGuid);
|
|
RefreshPresentation(record);
|
|
if ((spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu)
|
|
record.FullCellId = spatialCellOrLandblockId;
|
|
record.CanonicalLandblockId = spatialCellOrLandblockId == 0
|
|
? 0u
|
|
: (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu;
|
|
bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World
|
|
&& record.IsSpatiallyProjected
|
|
&& record.IsSpatiallyVisible
|
|
&& record.FullCellId != 0;
|
|
// prepare_to_enter_world (0x00511FA0) writes update_time = cur_time.
|
|
// Only a root-workset membership edge rebases the clock. Ordinary
|
|
// loaded-to-loaded movement preserves it; parented/attached objects
|
|
// take retail update_object's parent early-out and remain suspended.
|
|
if (!wasProjected && !isOrdinaryRoot)
|
|
{
|
|
record.SuspendObjectClock();
|
|
SynchronizePhysicsBodyActiveState(record);
|
|
}
|
|
else if (wasOrdinaryRoot != isOrdinaryRoot)
|
|
{
|
|
if (isOrdinaryRoot)
|
|
{
|
|
// InitObjectBegin and every leave-visibility edge are inactive
|
|
// before retail prepare_to_enter_world. Preserve that
|
|
// prerequisite for a first materialization as well.
|
|
if (!wasProjected)
|
|
record.ObjectClock.Deactivate();
|
|
record.ResetObjectClockForEnterWorld(
|
|
(record.FinalPhysicsState & PhysicsStateFlags.Static) != 0);
|
|
SynchronizePhysicsBodyActiveState(record);
|
|
}
|
|
else
|
|
{
|
|
record.SuspendObjectClock();
|
|
SynchronizePhysicsBodyActiveState(record);
|
|
}
|
|
}
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
Exception? runtimeNotificationFailure = null;
|
|
if (!wasProjected || wasVisible != visible)
|
|
{
|
|
try
|
|
{
|
|
PublishProjectionVisibilityChanged(record, visible);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
runtimeNotificationFailure = error;
|
|
}
|
|
}
|
|
// Final observers are intentionally re-entrant. A callback can delete
|
|
// and recreate this GUID or start a newer rebucket on the same record;
|
|
// the outer caller must not continue with captures from that displaced
|
|
// projection operation.
|
|
if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation))
|
|
{
|
|
ThrowAfterCommittedProjectionChange(
|
|
serverGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure);
|
|
return false;
|
|
}
|
|
ThrowAfterCommittedProjectionChange(
|
|
serverGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes only the render-bucket reference. The logical record and every
|
|
/// create-time resource remain alive for later projection/rebucketing.
|
|
/// </summary>
|
|
public bool WithdrawLiveEntityProjection(uint serverGuid)
|
|
{
|
|
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
|| record.WorldEntity is null)
|
|
return false;
|
|
|
|
bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue(
|
|
serverGuid,
|
|
out LiveEntityRecord? rootRecord)
|
|
&& ReferenceEquals(rootRecord, record);
|
|
ulong objectClockEpoch = record.ObjectClockEpoch;
|
|
ulong projectionOperation = ++record.ProjectionMutationVersion;
|
|
Exception? spatialNotificationFailure = null;
|
|
try
|
|
{
|
|
_spatial.RemoveLiveEntityProjection(serverGuid);
|
|
}
|
|
catch (AggregateException error)
|
|
{
|
|
// The projection is already absent and GpuWorldState has already
|
|
// drained its observer queue. Complete canonical withdrawal below
|
|
// before reporting the notification failure.
|
|
spatialNotificationFailure = error;
|
|
}
|
|
if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation))
|
|
{
|
|
ThrowAfterCommittedProjectionChange(
|
|
serverGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure: null);
|
|
return false;
|
|
}
|
|
// Usually GpuWorldState delivers the false edge synchronously above.
|
|
// During a reentrant visibility callback it queues that edge until the
|
|
// outer notification completes, so publish the logical withdrawal now;
|
|
// OnSpatialVisibilityChanged rejects the later duplicate against this
|
|
// committed record state.
|
|
bool spatialEdgeWasDeferred = record.IsSpatiallyVisible;
|
|
_materializedWorldEntitiesByGuid.Remove(serverGuid);
|
|
_visibleWorldEntitiesByGuid.Remove(serverGuid);
|
|
record.IsSpatiallyProjected = false;
|
|
record.IsSpatiallyVisible = false;
|
|
// GpuWorldState normally publishes the leave edge synchronously and
|
|
// OnSpatialVisibilityChanged suspends this exact clock there. During a
|
|
// reentrant notification the edge is deferred, so commit the missing
|
|
// suspension here. One root-workset leave is one epoch edge -- never
|
|
// advance it once in the observer and again in this owner.
|
|
if (wasOrdinaryRoot && record.ObjectClockEpoch == objectClockEpoch)
|
|
record.SuspendObjectClock();
|
|
SynchronizePhysicsBodyActiveState(record);
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
Exception? runtimeNotificationFailure = null;
|
|
if (spatialEdgeWasDeferred)
|
|
{
|
|
try
|
|
{
|
|
PublishProjectionVisibilityChanged(record, false);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
runtimeNotificationFailure = error;
|
|
}
|
|
}
|
|
// The manually published deferred edge is an arbitrary callback
|
|
// boundary just like Rebucket's final visibility edge. It may
|
|
// reproject this record or replace the GUID; the displaced outer
|
|
// withdrawal must not report itself as the current operation.
|
|
if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation))
|
|
{
|
|
ThrowAfterCommittedProjectionChange(
|
|
serverGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure);
|
|
return false;
|
|
}
|
|
ThrowAfterCommittedProjectionChange(
|
|
serverGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exact-incarnation withdrawal used after App leave-world callbacks. A
|
|
/// callback may accept a newer INSTANCE_TS for the same GUID; that newer
|
|
/// projection must never be withdrawn by the displaced operation.
|
|
/// </summary>
|
|
public bool WithdrawLiveEntityProjection(LiveEntityRecord expectedRecord)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
return WithdrawLiveEntityProjection(
|
|
expectedRecord,
|
|
expectedRecord.PositionAuthorityVersion,
|
|
expectedRecord.ProjectionMutationVersion);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exact-operation withdrawal used across arbitrary App callbacks. Both
|
|
/// the accepted Position authority and projection mutation token are
|
|
/// pinned so a same-incarnation re-entry cannot be removed by an older
|
|
/// parent/pickup/leave-world operation.
|
|
/// </summary>
|
|
internal bool WithdrawLiveEntityProjection(
|
|
LiveEntityRecord expectedRecord,
|
|
ulong expectedPositionAuthorityVersion,
|
|
ulong expectedProjectionMutationVersion)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
return IsCurrentRecord(expectedRecord)
|
|
&& expectedRecord.PositionAuthorityVersion == expectedPositionAuthorityVersion
|
|
&& expectedRecord.ProjectionMutationVersion == expectedProjectionMutationVersion
|
|
&& WithdrawLiveEntityProjection(expectedRecord.ServerGuid);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Withdraws a still-live projection whose authoritative rollback frame
|
|
/// is outside every world cell. Logical owners remain registered.
|
|
/// </summary>
|
|
internal bool WithdrawLiveEntityProjectionToCellless(uint serverGuid)
|
|
{
|
|
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
|| record.WorldEntity is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
ClearWorldCell(serverGuid);
|
|
record.WorldEntity.ParentCellId = 0u;
|
|
return WithdrawLiveEntityProjection(serverGuid);
|
|
}
|
|
|
|
public bool UnregisterLiveEntity(
|
|
DeleteObject.Parsed delete,
|
|
bool isLocalPlayer,
|
|
Action? beforeTeardown = null)
|
|
{
|
|
if (_isRegisteringResources)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"A live entity cannot unregister from inside atomic resource registration.");
|
|
}
|
|
var teardownKey = (delete.Guid, delete.InstanceSequence);
|
|
_teardownRecords.TryGetValue(teardownKey, out LiveEntityRecord? record);
|
|
bool retryingAcceptedDelete = record is not null
|
|
&& (record.DeleteAcceptedForTeardown
|
|
|| _isClearing
|
|
|| _sessionClearPendingFinalization);
|
|
if (!retryingAcceptedDelete)
|
|
{
|
|
if (!_inbound.TryDelete(delete, isLocalPlayer))
|
|
return false;
|
|
AdvanceLifetimeMutation(delete.Guid);
|
|
ParentAttachments.DeleteGeneration(delete.Guid, delete.InstanceSequence);
|
|
|
|
// End active gameplay identity before arbitrary callbacks so a
|
|
// newer generation can be accepted re-entrantly, but retain the
|
|
// exact incarnation as a teardown tombstone until unregister has
|
|
// succeeded. A throwing resource callback can then be retried by
|
|
// the same accepted Delete instead of orphaning its owner.
|
|
LiveEntityRecord? retainedRegistrationFailure = record;
|
|
_recordsByGuid.Remove(delete.Guid, out LiveEntityRecord? activeRecord);
|
|
if (activeRecord is not null)
|
|
{
|
|
record = activeRecord;
|
|
RetainTeardownRecord(record);
|
|
}
|
|
else
|
|
{
|
|
record = retainedRegistrationFailure;
|
|
}
|
|
if (record is not null)
|
|
record.DeleteAcceptedForTeardown = true;
|
|
}
|
|
|
|
List<Exception>? failures = null;
|
|
_logicalTeardownDepth++;
|
|
try
|
|
{
|
|
if (!retryingAcceptedDelete)
|
|
{
|
|
try
|
|
{
|
|
beforeTeardown?.Invoke();
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= new List<Exception>()).Add(error);
|
|
}
|
|
}
|
|
|
|
if (record is not null)
|
|
{
|
|
try
|
|
{
|
|
TearDownRecord(record);
|
|
ReleaseTeardownRecord(record);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= new List<Exception>()).Add(error);
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_logicalTeardownDepth--;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Persistence is GUID-scoped. End it only when the accepted
|
|
// delete left no replacement incarnation or unfinished teardown
|
|
// behind.
|
|
if (!_recordsByGuid.ContainsKey(delete.Guid)
|
|
&& !HasPendingTeardown(delete.Guid))
|
|
{
|
|
_spatial.ForgetLiveEntity(delete.Guid);
|
|
}
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= new List<Exception>()).Add(error);
|
|
}
|
|
|
|
if (failures is not null)
|
|
throw new AggregateException(
|
|
$"Live entity 0x{delete.Guid:X8} deletion cleanup failed.",
|
|
failures);
|
|
return true;
|
|
}
|
|
|
|
public bool TryGetRecord(uint serverGuid, out LiveEntityRecord record) =>
|
|
_recordsByGuid.TryGetValue(serverGuid, out record!);
|
|
|
|
public bool TryGetWorldEntity(uint serverGuid, out WorldEntity entity)
|
|
{
|
|
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
&& record.WorldEntity is { } found)
|
|
{
|
|
entity = found;
|
|
return true;
|
|
}
|
|
|
|
entity = null!;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves a top-level object that currently participates in picking,
|
|
/// targeting, radar, and wire-driven MoveTo/Sticky establishment.
|
|
/// Pending, attached, and Hidden projections are intentionally excluded.
|
|
/// </summary>
|
|
public bool TryGetInteractionEligibleEntity(
|
|
uint serverGuid,
|
|
out WorldEntity entity) =>
|
|
_visibleWorldEntitiesByGuid.TryGetValue(serverGuid, out entity!);
|
|
|
|
public bool TryGetInteractionEligibleRecord(
|
|
uint serverGuid,
|
|
out LiveEntityRecord record)
|
|
{
|
|
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? found)
|
|
&& found.WorldEntity is { } entity
|
|
&& _visibleWorldEntitiesByGuid.TryGetValue(serverGuid, out WorldEntity? visible)
|
|
&& ReferenceEquals(entity, visible))
|
|
{
|
|
record = found;
|
|
return true;
|
|
}
|
|
|
|
record = null!;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves a render-published interaction candidate only while the same
|
|
/// logical WorldEntity incarnation still owns the server GUID. A stale
|
|
/// frame must never retarget a replacement which reused that GUID.
|
|
/// </summary>
|
|
public bool TryGetInteractionEligibleRecord(
|
|
uint serverGuid,
|
|
uint localEntityId,
|
|
out LiveEntityRecord record)
|
|
{
|
|
if (serverGuid != 0u
|
|
&& localEntityId != 0u
|
|
&& TryGetInteractionEligibleRecord(serverGuid, out LiveEntityRecord found)
|
|
&& found.WorldEntity!.Id == localEntityId)
|
|
{
|
|
record = found;
|
|
return true;
|
|
}
|
|
|
|
record = null!;
|
|
return false;
|
|
}
|
|
|
|
public bool ContainsWorldEntity(uint serverGuid) =>
|
|
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
&& record.WorldEntity is not null;
|
|
|
|
public bool TryGetServerGuid(uint localEntityId, out uint serverGuid) =>
|
|
_guidByLocalId.TryGetValue(localEntityId, out serverGuid);
|
|
|
|
public bool TryGetLocalEntityId(uint serverGuid, out uint localEntityId)
|
|
{
|
|
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
&& record.LocalEntityId is { } found)
|
|
{
|
|
localEntityId = found;
|
|
return true;
|
|
}
|
|
|
|
localEntityId = 0;
|
|
return false;
|
|
}
|
|
|
|
public void SetAnimationRuntime(uint serverGuid, ILiveEntityAnimationRuntime runtime)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(runtime);
|
|
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
|| record.WorldEntity is not { } entity)
|
|
throw new InvalidOperationException($"Cannot bind animation before live entity 0x{serverGuid:X8} is materialized.");
|
|
if (!ReferenceEquals(runtime.Entity, entity))
|
|
throw new InvalidOperationException("Animation runtime belongs to a different WorldEntity.");
|
|
|
|
if (record.AnimationRuntime is not null)
|
|
_animationsByLocalId.Remove(entity.Id);
|
|
record.AnimationRuntime = runtime;
|
|
_animationsByLocalId[entity.Id] = runtime;
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
}
|
|
|
|
public bool TryGetAnimationRuntime(uint localEntityId, out ILiveEntityAnimationRuntime runtime) =>
|
|
_animationsByLocalId.TryGetValue(localEntityId, out runtime!);
|
|
|
|
public bool ClearAnimationRuntime(uint serverGuid)
|
|
{
|
|
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
|| record.AnimationRuntime is null)
|
|
return false;
|
|
if (record.LocalEntityId is { } localId)
|
|
_animationsByLocalId.Remove(localId);
|
|
record.AnimationRuntime = null;
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Acquires the one retail <c>CPhysicsObj</c> body for this exact live
|
|
/// incarnation. Static animation may need the body before a MovementManager
|
|
/// or projectile component exists; later components must adopt this same
|
|
/// instance instead of replaying CreateObject vector initialization.
|
|
/// </summary>
|
|
internal PhysicsBody GetOrCreatePhysicsBody(
|
|
uint serverGuid,
|
|
Func<LiveEntityRecord, PhysicsBody> factory)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(factory);
|
|
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
|| record.WorldEntity is null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Cannot acquire physics body before live entity 0x{serverGuid:X8} is materialized.");
|
|
}
|
|
if (record.PhysicsBody is { } retained)
|
|
return retained;
|
|
if (record.PhysicsBodyAcquisitionInProgress)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{serverGuid:X8} physics-body acquisition is already in progress.");
|
|
}
|
|
|
|
record.PhysicsBodyAcquisitionInProgress = true;
|
|
try
|
|
{
|
|
PhysicsBody candidate = factory(record)
|
|
?? throw new InvalidOperationException("Physics-body factory returned null.");
|
|
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current)
|
|
|| !ReferenceEquals(current, record)
|
|
|| current.WorldEntity is null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{serverGuid:X8} changed incarnation during physics-body acquisition.");
|
|
}
|
|
if (record.PhysicsBody is { } concurrentlyBound)
|
|
{
|
|
if (ReferenceEquals(concurrentlyBound, candidate))
|
|
return concurrentlyBound;
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{serverGuid:X8} acquired two physics bodies within one incarnation.");
|
|
}
|
|
|
|
record.PhysicsBody = candidate;
|
|
candidate.State = record.FinalPhysicsState;
|
|
SynchronizePhysicsBodyActiveState(record);
|
|
return candidate;
|
|
}
|
|
finally
|
|
{
|
|
record.PhysicsBodyAcquisitionInProgress = false;
|
|
}
|
|
}
|
|
|
|
public void SetRemoteMotionRuntime(uint serverGuid, ILiveEntityRemoteMotionRuntime runtime)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(runtime);
|
|
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record))
|
|
throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists.");
|
|
if (record.RemoteMotionBindingInProgress)
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{serverGuid:X8} remote-motion binding is already in progress.");
|
|
PhysicsBody candidateBody = runtime.Body
|
|
?? throw new InvalidOperationException("A remote-motion runtime returned no physics body.");
|
|
if (ReferenceEquals(record.RemoteMotionRuntime, runtime))
|
|
{
|
|
if (!ReferenceEquals(record.PhysicsBody, candidateBody))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{serverGuid:X8} remote motion changed its canonical physics body.");
|
|
}
|
|
// Idempotent publication is important when motion and vector
|
|
// packets converge on the same runtime in one update turn. The
|
|
// component seams are incarnation-bound and must never be rebound.
|
|
candidateBody.State = record.FinalPhysicsState;
|
|
SynchronizePhysicsBodyActiveState(record);
|
|
_remoteMotionByGuid[serverGuid] = runtime;
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
return;
|
|
}
|
|
if (record.PhysicsBodyAcquisitionInProgress && record.PhysicsBody is null)
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{serverGuid:X8} cannot bind remote motion during physics-body acquisition.");
|
|
if (record.PhysicsBody is { } canonicalBody
|
|
&& !ReferenceEquals(canonicalBody, candidateBody))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{serverGuid:X8} cannot replace its canonical physics body within one incarnation.");
|
|
}
|
|
if (record.RequiresRemotePlacementRuntime
|
|
&& runtime is not ILiveEntityRemotePlacementRuntime)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{serverGuid:X8} cannot discard its remote placement contract within one incarnation.");
|
|
}
|
|
// Bind every possibly-throwing exact-incarnation seam before
|
|
// publishing any canonical record/index state. An already-bound
|
|
// component from an older GUID generation must fail without poisoning
|
|
// the replacement record. The callback is arbitrary App code, so the
|
|
// exact record, authority epoch, and expected owners are revalidated
|
|
// before commit just like resource/physics-body acquisition.
|
|
LiveEntityRecord incarnation = record;
|
|
ulong sessionVersion = _sessionLifetimeVersion;
|
|
ulong lifetimeVersion = CurrentLifetimeMutation(serverGuid);
|
|
PhysicsBody? expectedBody = record.PhysicsBody;
|
|
ILiveEntityRemoteMotionRuntime? expectedRuntime = record.RemoteMotionRuntime;
|
|
bool expectedPlacementContract = record.RequiresRemotePlacementRuntime;
|
|
bool expectedIndexPresent = _remoteMotionByGuid.TryGetValue(
|
|
serverGuid,
|
|
out ILiveEntityRemoteMotionRuntime? expectedIndexedRuntime);
|
|
Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> readPhysicsHost = () =>
|
|
_recordsByGuid.TryGetValue(serverGuid, out var current)
|
|
&& ReferenceEquals(current, incarnation)
|
|
&& ReferenceEquals(current.RemoteMotionRuntime, runtime)
|
|
? current.PhysicsHost
|
|
: null;
|
|
// Reads remain bound to the exact CPhysicsObj incarnation through its
|
|
// exit_world notifications. Only writes require active ownership.
|
|
Func<uint> readCell = () => incarnation.FullCellId;
|
|
Action<uint> writeCell = cellId =>
|
|
{
|
|
if (cellId != 0
|
|
&& _recordsByGuid.TryGetValue(serverGuid, out var current)
|
|
&& ReferenceEquals(current, incarnation)
|
|
&& ReferenceEquals(current.RemoteMotionRuntime, runtime))
|
|
{
|
|
RebucketLiveEntity(serverGuid, cellId);
|
|
}
|
|
};
|
|
|
|
record.RemoteMotionBindingInProgress = true;
|
|
try
|
|
{
|
|
if (runtime is ILiveEntityCanonicalRuntimeConsumer canonicalConsumer)
|
|
{
|
|
canonicalConsumer.BindCanonicalRuntime(
|
|
readPhysicsHost,
|
|
readCell,
|
|
writeCell);
|
|
}
|
|
else
|
|
{
|
|
bool consumesHost = runtime is ILiveEntityPhysicsHostConsumer;
|
|
bool consumesCell = runtime is ILiveEntityCanonicalCellConsumer;
|
|
if (consumesHost && consumesCell)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"A remote runtime that consumes both canonical host and cell identity must bind them atomically.");
|
|
}
|
|
if (runtime is ILiveEntityPhysicsHostConsumer hostConsumer)
|
|
hostConsumer.BindPhysicsHost(readPhysicsHost);
|
|
if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer)
|
|
cellConsumer.BindCanonicalCell(readCell, writeCell);
|
|
}
|
|
|
|
bool indexUnchanged = _remoteMotionByGuid.TryGetValue(
|
|
serverGuid,
|
|
out ILiveEntityRemoteMotionRuntime? currentIndexedRuntime)
|
|
== expectedIndexPresent
|
|
&& (!expectedIndexPresent
|
|
|| ReferenceEquals(currentIndexedRuntime, expectedIndexedRuntime));
|
|
if (_sessionLifetimeVersion != sessionVersion
|
|
|| CurrentLifetimeMutation(serverGuid) != lifetimeVersion
|
|
|| !_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current)
|
|
|| !ReferenceEquals(current, incarnation)
|
|
|| !ReferenceEquals(current.PhysicsBody, expectedBody)
|
|
|| !ReferenceEquals(current.RemoteMotionRuntime, expectedRuntime)
|
|
|| current.RequiresRemotePlacementRuntime != expectedPlacementContract
|
|
|| !indexUnchanged)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{serverGuid:X8} changed incarnation or component ownership during remote-motion binding.");
|
|
}
|
|
|
|
record.RequiresRemotePlacementRuntime |=
|
|
runtime is ILiveEntityRemotePlacementRuntime;
|
|
record.RemoteMotionRuntime = runtime;
|
|
record.PhysicsBody = candidateBody;
|
|
candidateBody.State = record.FinalPhysicsState;
|
|
SynchronizePhysicsBodyActiveState(record);
|
|
_remoteMotionByGuid[serverGuid] = runtime;
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
}
|
|
finally
|
|
{
|
|
record.RemoteMotionBindingInProgress = false;
|
|
}
|
|
}
|
|
|
|
public void InstallPhysicsHost(
|
|
LiveEntityRecord expectedRecord,
|
|
AcDream.Core.Physics.Motion.IPhysicsObjHost host)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
ArgumentNullException.ThrowIfNull(host);
|
|
uint serverGuid = expectedRecord.ServerGuid;
|
|
if (host.Id != serverGuid)
|
|
throw new ArgumentException("A physics host must match its live entity GUID.", nameof(host));
|
|
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
|| !ReferenceEquals(record, expectedRecord))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{serverGuid:X8} changed incarnation during physics-host installation.");
|
|
}
|
|
if (record.PhysicsHost is not null)
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{serverGuid:X8} already owns its incarnation-stable physics host.");
|
|
record.PhysicsHost = host;
|
|
}
|
|
|
|
public bool TryGetPhysicsHost(
|
|
uint serverGuid,
|
|
out AcDream.Core.Physics.Motion.IPhysicsObjHost host)
|
|
{
|
|
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
&& record.PhysicsHost is { } existing)
|
|
{
|
|
host = existing;
|
|
return true;
|
|
}
|
|
|
|
host = null!;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetRemoteMotionRuntime(uint serverGuid, out ILiveEntityRemoteMotionRuntime runtime) =>
|
|
_remoteMotionByGuid.TryGetValue(serverGuid, out runtime!);
|
|
|
|
public bool ClearRemoteMotionRuntime(uint serverGuid)
|
|
{
|
|
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
|| record.RemoteMotionRuntime is null)
|
|
return false;
|
|
_remoteMotionByGuid.Remove(serverGuid);
|
|
record.RemoteMotionRuntime = null;
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
return true;
|
|
}
|
|
|
|
public void SetProjectileRuntime(uint serverGuid, ILiveEntityProjectileRuntime runtime)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(runtime);
|
|
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
|| record.WorldEntity is null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Cannot bind projectile physics before live entity 0x{serverGuid:X8} is materialized.");
|
|
}
|
|
if (record.PhysicsBodyAcquisitionInProgress && record.PhysicsBody is null)
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{serverGuid:X8} cannot bind projectile physics during physics-body acquisition.");
|
|
PhysicsBody candidateBody = runtime.Body;
|
|
if (record.PhysicsBody is { } canonicalBody
|
|
&& !ReferenceEquals(canonicalBody, candidateBody))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{serverGuid:X8} cannot replace its canonical physics body within one incarnation.");
|
|
}
|
|
|
|
record.ProjectileRuntime = runtime;
|
|
record.PhysicsBody = candidateBody;
|
|
candidateBody.State = record.FinalPhysicsState;
|
|
SynchronizePhysicsBodyActiveState(record);
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
}
|
|
|
|
public bool TryGetProjectileRuntime(
|
|
uint serverGuid,
|
|
out ILiveEntityProjectileRuntime runtime)
|
|
{
|
|
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
&& record.ProjectileRuntime is { } found)
|
|
{
|
|
runtime = found;
|
|
return true;
|
|
}
|
|
|
|
runtime = null!;
|
|
return false;
|
|
}
|
|
|
|
public bool ClearProjectileRuntime(uint serverGuid)
|
|
{
|
|
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
|| record.ProjectileRuntime is null)
|
|
return false;
|
|
|
|
record.ProjectileRuntime = null;
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
return true;
|
|
}
|
|
|
|
public void SetEffectProfile(uint serverGuid, ILiveEntityEffectProfile profile)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(profile);
|
|
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record))
|
|
throw new InvalidOperationException(
|
|
$"Cannot bind an effect profile before live entity 0x{serverGuid:X8} exists.");
|
|
record.EffectProfile = profile;
|
|
}
|
|
|
|
public bool TryGetEffectProfile(
|
|
uint serverGuid,
|
|
out ILiveEntityEffectProfile profile)
|
|
{
|
|
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
&& record.EffectProfile is { } found)
|
|
{
|
|
profile = found;
|
|
return true;
|
|
}
|
|
|
|
profile = null!;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetSnapshot(uint guid, out WorldSession.EntitySpawn spawn) =>
|
|
_inbound.TryGetSnapshot(guid, out spawn);
|
|
|
|
public bool TryApplyObjDesc(ObjDescEvent.Parsed update, out WorldSession.EntitySpawn accepted)
|
|
{
|
|
bool applied = _inbound.TryApplyObjDesc(update, out accepted);
|
|
if (applied)
|
|
{
|
|
RefreshRecord(update.Guid, accepted);
|
|
if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record))
|
|
record.AdvanceObjDescAuthority();
|
|
}
|
|
return applied;
|
|
}
|
|
|
|
public bool TryApplyPickup(PickupEvent.Parsed update, out WorldSession.EntitySpawn accepted)
|
|
{
|
|
bool applied = _inbound.TryApplyPickup(update, out accepted);
|
|
if (applied)
|
|
{
|
|
RefreshRecord(update.Guid, accepted);
|
|
if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record))
|
|
record.AdvancePositionAuthority();
|
|
ClearWorldCell(update.Guid);
|
|
ParentAttachments.EndChildProjection(update.Guid);
|
|
}
|
|
return applied;
|
|
}
|
|
|
|
public bool TryApplyCreateParent(CreateParentUpdate update, out WorldSession.EntitySpawn accepted)
|
|
{
|
|
bool applied = _inbound.TryApplyCreateParent(update, out accepted);
|
|
if (applied)
|
|
{
|
|
RefreshRecord(update.ChildGuid, accepted);
|
|
if (_recordsByGuid.TryGetValue(update.ChildGuid, out LiveEntityRecord? record))
|
|
record.AdvancePositionAuthority();
|
|
}
|
|
return applied;
|
|
}
|
|
|
|
public bool TryApplyParent(ParentEvent.Parsed update, out WorldSession.EntitySpawn accepted)
|
|
{
|
|
bool applied = _inbound.TryApplyParent(update, out accepted);
|
|
if (applied)
|
|
{
|
|
RefreshRecord(update.ChildGuid, accepted);
|
|
if (_recordsByGuid.TryGetValue(update.ChildGuid, out LiveEntityRecord? record))
|
|
record.AdvancePositionAuthority();
|
|
}
|
|
return applied;
|
|
}
|
|
|
|
internal bool CommitStagedParent(
|
|
ParentAttachmentRelation relation,
|
|
out WorldSession.EntitySpawn accepted)
|
|
{
|
|
bool committed = _inbound.TryCommitParent(
|
|
relation.ChildGuid,
|
|
relation.ParentGuid,
|
|
relation.ParentLocation,
|
|
relation.PlacementId,
|
|
relation.ChildPositionSequence,
|
|
out accepted);
|
|
if (committed)
|
|
RefreshRecord(relation.ChildGuid, accepted);
|
|
return committed;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Commits retail <c>CPhysicsObj::set_parent</c>'s cell-less edge only
|
|
/// after the parent accepted the child (PartArray + holding-location
|
|
/// validation). The POSITION_TS is consumed before this step, matching
|
|
/// retail; invalid parent relationships retain the old world projection.
|
|
/// </summary>
|
|
internal bool CommitAcceptedParentCellless(
|
|
LiveEntityRecord record,
|
|
ulong positionAuthorityVersion)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(record);
|
|
if (!IsCurrentPositionAuthority(record, positionAuthorityVersion))
|
|
return false;
|
|
ClearWorldCell(record.ServerGuid);
|
|
return IsCurrentPositionAuthority(record, positionAuthorityVersion);
|
|
}
|
|
|
|
public bool TryApplyMotion(
|
|
WorldSession.EntityMotionUpdate update,
|
|
bool retainPayload,
|
|
out WorldSession.EntitySpawn accepted,
|
|
out AcceptedPhysicsTimestamps timestamps)
|
|
{
|
|
bool applied = _inbound.TryApplyMotion(update, retainPayload, out accepted, out timestamps);
|
|
if (_inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot))
|
|
RefreshRecord(update.Guid, snapshot);
|
|
if (applied
|
|
&& retainPayload
|
|
&& _recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record))
|
|
{
|
|
record.AdvanceMovementAuthority();
|
|
}
|
|
return applied;
|
|
}
|
|
|
|
public bool TryApplyVector(VectorUpdate.Parsed update, out WorldSession.EntitySpawn accepted)
|
|
{
|
|
bool applied = _inbound.TryApplyVector(update, out accepted);
|
|
if (applied)
|
|
{
|
|
RefreshRecord(update.Guid, accepted);
|
|
if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record))
|
|
record.AdvanceVectorAuthority();
|
|
}
|
|
return applied;
|
|
}
|
|
|
|
public bool TryApplyState(SetState.Parsed update, out WorldSession.EntitySpawn accepted)
|
|
=> TryApplyState(update, out accepted, out _);
|
|
|
|
public bool TryApplyState(
|
|
SetState.Parsed update,
|
|
out WorldSession.EntitySpawn accepted,
|
|
out RetailPhysicsStateTransition transition)
|
|
{
|
|
bool applied = _inbound.TryApplyState(update, out accepted);
|
|
transition = default;
|
|
if (applied)
|
|
{
|
|
RefreshRecord(update.Guid, accepted);
|
|
if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record))
|
|
{
|
|
transition = record.ApplyRawPhysicsState(update.PhysicsState);
|
|
RefreshPresentation(record);
|
|
}
|
|
}
|
|
return applied;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail parent Hidden directly toggles each attached child's NoDraw bit
|
|
/// without consuming the child's STATE_TS or replacing its raw wire state.
|
|
/// </summary>
|
|
public bool SetAttachedChildNoDraw(uint childServerGuid, bool noDraw)
|
|
{
|
|
if (!_recordsByGuid.TryGetValue(childServerGuid, out LiveEntityRecord? record))
|
|
return false;
|
|
record.SetChildNoDraw(noDraw);
|
|
RefreshPresentation(record);
|
|
return true;
|
|
}
|
|
|
|
public bool TryApplyPosition(
|
|
WorldSession.EntityPositionUpdate update,
|
|
bool isLocalPlayer,
|
|
System.Numerics.Quaternion? forcePositionRotation,
|
|
System.Numerics.Vector3? currentLocalVelocity,
|
|
out PositionTimestampDisposition disposition,
|
|
out WorldSession.EntitySpawn accepted,
|
|
out AcceptedPhysicsTimestamps timestamps)
|
|
{
|
|
bool wasCellLess = _recordsByGuid.TryGetValue(
|
|
update.Guid,
|
|
out LiveEntityRecord? beforePosition)
|
|
&& (beforePosition.FullCellId == 0
|
|
|| !beforePosition.IsSpatiallyProjected
|
|
|| !beforePosition.IsSpatiallyVisible);
|
|
bool known = _inbound.TryApplyPosition(
|
|
update,
|
|
isLocalPlayer,
|
|
forcePositionRotation,
|
|
currentLocalVelocity,
|
|
out disposition,
|
|
out accepted,
|
|
out timestamps);
|
|
if (known && _inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot))
|
|
{
|
|
bool acceptedPosition = disposition is not PositionTimestampDisposition.Rejected;
|
|
if (disposition is PositionTimestampDisposition.Apply)
|
|
{
|
|
timestamps = timestamps with
|
|
{
|
|
TeleportHookRequired = timestamps.TeleportAdvanced || wasCellLess,
|
|
};
|
|
}
|
|
RefreshRecord(update.Guid, snapshot, refreshPosition: acceptedPosition);
|
|
if (acceptedPosition)
|
|
{
|
|
if (_recordsByGuid.TryGetValue(
|
|
update.Guid,
|
|
out LiveEntityRecord? acceptedRecord)
|
|
&& ReferenceEquals(acceptedRecord, beforePosition))
|
|
{
|
|
acceptedRecord.AdvancePositionAuthority();
|
|
}
|
|
ParentAttachments.EndChildProjection(update.Guid);
|
|
}
|
|
}
|
|
return known;
|
|
}
|
|
|
|
public bool IsFreshTeleportStart(uint localPlayerGuid, ushort teleportSequence) =>
|
|
_inbound.IsFreshTeleportStart(localPlayerGuid, teleportSequence);
|
|
|
|
/// <summary>
|
|
/// Retail <c>CPhysicsObj::update_object</c> (<c>0x00515D40</c>) runs root
|
|
/// object tick only while the object has visible world-cell membership and
|
|
/// is not Frozen. Hidden is deliberately not a broad tick stop: retail
|
|
/// skips PartArray animation and physics, but still applies the
|
|
/// PositionManager offset and advances targeting/movement/script/particle
|
|
/// owners. A pickup or parent transition keeps
|
|
/// script/effect owners but clears the cell and withdraws the root
|
|
/// projection until a fresh Position re-enters it.
|
|
/// </summary>
|
|
public RetailObjectClockDisposition GetRootObjectClockDisposition(
|
|
uint serverGuid)
|
|
{
|
|
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
|| record.ProjectionKind is not LiveEntityProjectionKind.World
|
|
|| !record.IsSpatiallyProjected
|
|
|| !record.IsSpatiallyVisible
|
|
|| record.FullCellId == 0)
|
|
{
|
|
return RetailObjectClockDisposition.Suspend;
|
|
}
|
|
|
|
return (record.FinalPhysicsState & PhysicsStateFlags.Frozen) != 0
|
|
? RetailObjectClockDisposition.Suspend
|
|
: RetailObjectClockDisposition.Advance;
|
|
}
|
|
|
|
public bool ShouldAdvanceRootRuntime(uint serverGuid) =>
|
|
GetRootObjectClockDisposition(serverGuid)
|
|
is RetailObjectClockDisposition.Advance;
|
|
|
|
internal bool IsCurrentPositionAuthority(
|
|
LiveEntityRecord record,
|
|
ulong authorityVersion) =>
|
|
_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
|
|
&& ReferenceEquals(current, record)
|
|
&& current.PositionAuthorityVersion == authorityVersion;
|
|
|
|
internal bool IsCurrentStateAuthority(
|
|
LiveEntityRecord record,
|
|
ulong authorityVersion) =>
|
|
_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
|
|
&& ReferenceEquals(current, record)
|
|
&& current.StateAuthorityVersion == authorityVersion;
|
|
|
|
internal bool IsCurrentVectorAuthority(
|
|
LiveEntityRecord record,
|
|
ulong authorityVersion) =>
|
|
_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
|
|
&& ReferenceEquals(current, record)
|
|
&& current.VectorAuthorityVersion == authorityVersion;
|
|
|
|
internal bool IsCurrentVelocityAuthority(
|
|
LiveEntityRecord record,
|
|
ulong authorityVersion) =>
|
|
_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
|
|
&& ReferenceEquals(current, record)
|
|
&& current.VelocityAuthorityVersion == authorityVersion;
|
|
|
|
internal bool IsCurrentMovementAuthority(
|
|
LiveEntityRecord record,
|
|
ulong authorityVersion) =>
|
|
_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
|
|
&& ReferenceEquals(current, record)
|
|
&& current.MovementAuthorityVersion == authorityVersion;
|
|
|
|
internal bool IsCurrentObjDescAuthority(
|
|
LiveEntityRecord record,
|
|
ulong authorityVersion) =>
|
|
_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
|
|
&& ReferenceEquals(current, record)
|
|
&& current.ObjDescAuthorityVersion == authorityVersion;
|
|
|
|
/// <summary>
|
|
/// Copies the canonical loaded root-object workset used by retail
|
|
/// <c>CPhysics::UseTime</c>. The record reference is the incarnation token;
|
|
/// callers must revalidate it after callbacks that can delete or rebucket.
|
|
/// </summary>
|
|
internal void CopySpatialRootObjectRecordsTo(List<LiveEntityRecord> destination)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(destination);
|
|
destination.Clear();
|
|
foreach ((uint serverGuid, LiveEntityRecord indexed) in _spatialRootObjectsByGuid)
|
|
{
|
|
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current)
|
|
&& ReferenceEquals(current, indexed)
|
|
&& HasSpatialRuntimeProjection(current))
|
|
{
|
|
destination.Add(current);
|
|
}
|
|
}
|
|
}
|
|
|
|
internal bool IsCurrentSpatialRootObject(LiveEntityRecord record) =>
|
|
IsCurrentRecord(record)
|
|
&& _spatialRootObjectsByGuid.TryGetValue(record.ServerGuid, out var indexed)
|
|
&& ReferenceEquals(indexed, record)
|
|
&& HasSpatialRuntimeProjection(record);
|
|
|
|
internal bool IsCurrentSpatialAnimation(
|
|
LiveEntityRecord record,
|
|
ILiveEntityAnimationRuntime animation) =>
|
|
IsCurrentSpatialRootObject(record)
|
|
&& ReferenceEquals(record.AnimationRuntime, animation)
|
|
&& record.WorldEntity is { } entity
|
|
&& _spatialAnimationsByLocalId.TryGetValue(entity.Id, out var indexed)
|
|
&& ReferenceEquals(indexed, animation);
|
|
|
|
/// <summary>
|
|
/// Copies the currently spatial animation owners through the concrete
|
|
/// dictionary enumerator. This keeps per-frame traversal allocation-free;
|
|
/// exposing the workset as <see cref="IReadOnlyDictionary{TKey,TValue}"/>
|
|
/// would box its enumerator at a hot call site.
|
|
/// </summary>
|
|
internal void CopySpatialAnimationRuntimesTo<TAnimation>(
|
|
List<KeyValuePair<uint, TAnimation>> destination)
|
|
where TAnimation : class, ILiveEntityAnimationRuntime
|
|
{
|
|
ArgumentNullException.ThrowIfNull(destination);
|
|
destination.Clear();
|
|
foreach ((uint localId, ILiveEntityAnimationRuntime animation)
|
|
in _spatialAnimationsByLocalId)
|
|
{
|
|
if (animation is TAnimation typed)
|
|
{
|
|
destination.Add(
|
|
new KeyValuePair<uint, TAnimation>(localId, typed));
|
|
}
|
|
}
|
|
}
|
|
|
|
internal void CopySpatialAnimationLocalIdsTo(HashSet<uint> destination)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(destination);
|
|
destination.Clear();
|
|
foreach (uint localId in _spatialAnimationsByLocalId.Keys)
|
|
destination.Add(localId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Host side of retail <c>CPhysicsObj::set_active(1)</c> for a movement
|
|
/// manager owned by this exact incarnation. Static is a required no-op.
|
|
/// </summary>
|
|
internal bool TryActivateOrdinaryObject(
|
|
uint serverGuid,
|
|
ILiveEntityRemoteMotionRuntime runtime)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(runtime);
|
|
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
|| !ReferenceEquals(record.RemoteMotionRuntime, runtime)
|
|
|| (record.FinalPhysicsState & PhysicsStateFlags.Static) != 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
record.ObjectClock.Activate();
|
|
runtime.Body.TransientState |= TransientStateFlags.Active;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Commits retail <c>CPhysicsObj::set_velocity</c> (0x005113F0) to the
|
|
/// canonical body for this exact incarnation. The body and the retained
|
|
/// object clock are one CPhysicsObj: a non-Static inactive-to-active edge
|
|
/// rebases both together, while Static preserves its prior Active state.
|
|
/// </summary>
|
|
internal bool TryCommitAuthoritativeVelocity(
|
|
LiveEntityRecord record,
|
|
PhysicsBody body,
|
|
Vector3 velocity,
|
|
double currentTime) =>
|
|
TryCommitAuthoritativeVectorCore(
|
|
record,
|
|
body,
|
|
velocity,
|
|
angularVelocity: null,
|
|
currentTime);
|
|
|
|
/// <summary>
|
|
/// Commits the paired retail VectorUpdate writes
|
|
/// (<c>set_velocity</c>, then <c>set_omega</c>) to one canonical body.
|
|
/// Validation is atomic: malformed wire vectors cannot partially mutate
|
|
/// the live object.
|
|
/// </summary>
|
|
internal bool TryCommitAuthoritativeVector(
|
|
LiveEntityRecord record,
|
|
PhysicsBody body,
|
|
Vector3 velocity,
|
|
Vector3 angularVelocity,
|
|
double currentTime) =>
|
|
TryCommitAuthoritativeVectorCore(
|
|
record,
|
|
body,
|
|
velocity,
|
|
angularVelocity,
|
|
currentTime);
|
|
|
|
private bool TryCommitAuthoritativeVectorCore(
|
|
LiveEntityRecord record,
|
|
PhysicsBody body,
|
|
Vector3 velocity,
|
|
Vector3? angularVelocity,
|
|
double currentTime)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(record);
|
|
ArgumentNullException.ThrowIfNull(body);
|
|
if (!IsFinite(velocity)
|
|
|| (angularVelocity is { } omega && !IsFinite(omega))
|
|
|| !double.IsFinite(currentTime)
|
|
|| !_recordsByGuid.TryGetValue(
|
|
record.ServerGuid,
|
|
out LiveEntityRecord? current)
|
|
|| !ReferenceEquals(current, record)
|
|
|| !ReferenceEquals(current.PhysicsBody, body))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
bool wasBodyActive =
|
|
(body.TransientState & TransientStateFlags.Active) != 0;
|
|
body.set_velocity(velocity);
|
|
if ((current.FinalPhysicsState & PhysicsStateFlags.Static) != 0)
|
|
{
|
|
// PhysicsBody models set_velocity's leaf write and therefore sets
|
|
// Active unconditionally. Retail CPhysicsObj::set_active(1) is a
|
|
// no-op for Static, so restore the exact prior bit here.
|
|
if (!wasBodyActive)
|
|
body.TransientState &= ~TransientStateFlags.Active;
|
|
}
|
|
else
|
|
{
|
|
bool clockReactivated = current.ObjectClock.Activate();
|
|
// PhysicsBody.LastUpdateTime remains only the compatibility clock
|
|
// for older absolute-time helpers; the record clock is canonical.
|
|
if (clockReactivated || !wasBodyActive)
|
|
body.LastUpdateTime = currentTime;
|
|
}
|
|
|
|
if (angularVelocity is { } acceptedOmega)
|
|
body.Omega = acceptedOmega;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Copies the currently spatial remote-motion owners into caller-owned
|
|
/// reusable storage. The record reference is the incarnation token: a
|
|
/// delete and GUID reuse cannot make an older snapshot address the newer
|
|
/// object.
|
|
/// </summary>
|
|
internal void CopySpatialRemoteMotionRecordsTo(List<LiveEntityRecord> destination)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(destination);
|
|
destination.Clear();
|
|
|
|
foreach ((uint serverGuid, ILiveEntityRemoteMotionRuntime runtime) in _spatialRemoteMotionByGuid)
|
|
{
|
|
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
&& ReferenceEquals(record.RemoteMotionRuntime, runtime)
|
|
&& HasSpatialRuntimeProjection(record))
|
|
{
|
|
destination.Add(record);
|
|
}
|
|
}
|
|
}
|
|
|
|
internal bool IsCurrentSpatialRemoteMotion(
|
|
LiveEntityRecord record,
|
|
ILiveEntityRemoteMotionRuntime runtime) =>
|
|
IsCurrentRecord(record)
|
|
&& ReferenceEquals(record.RemoteMotionRuntime, runtime)
|
|
&& _spatialRemoteMotionByGuid.TryGetValue(record.ServerGuid, out var indexed)
|
|
&& ReferenceEquals(indexed, runtime)
|
|
&& HasSpatialRuntimeProjection(record);
|
|
|
|
/// <summary>
|
|
/// Copies only projectile owners that currently have a loaded world-cell
|
|
/// projection. Pending projectiles retain their logical runtime but impose
|
|
/// no per-frame scan cost.
|
|
/// </summary>
|
|
internal void CopySpatialProjectileRecordsTo(List<LiveEntityRecord> destination)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(destination);
|
|
destination.Clear();
|
|
|
|
foreach ((uint serverGuid, ILiveEntityProjectileRuntime runtime) in _spatialProjectilesByGuid)
|
|
{
|
|
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
&& ReferenceEquals(record.ProjectileRuntime, runtime)
|
|
&& HasSpatialRuntimeProjection(record))
|
|
{
|
|
destination.Add(record);
|
|
}
|
|
}
|
|
}
|
|
|
|
internal bool IsCurrentSpatialProjectile(
|
|
LiveEntityRecord record,
|
|
ILiveEntityProjectileRuntime runtime) =>
|
|
IsCurrentRecord(record)
|
|
&& ReferenceEquals(record.ProjectileRuntime, runtime)
|
|
&& _spatialProjectilesByGuid.TryGetValue(record.ServerGuid, out var indexed)
|
|
&& ReferenceEquals(indexed, runtime)
|
|
&& HasSpatialRuntimeProjection(record);
|
|
|
|
public bool IsHidden(uint serverGuid) =>
|
|
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
&& (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0;
|
|
|
|
/// <summary>
|
|
/// Claims the one logical top-level spawn notification for this object
|
|
/// incarnation. Spatial re-entry must restore presentation without replaying
|
|
/// plugin/event registration. An attached-only child claims nothing until it
|
|
/// first becomes a top-level world projection.
|
|
/// </summary>
|
|
public bool TryMarkWorldSpawnPublished(uint serverGuid)
|
|
{
|
|
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
|| record.WorldEntity is null
|
|
|| record.ProjectionKind is not LiveEntityProjectionKind.World
|
|
|| record.WorldSpawnPublished)
|
|
return false;
|
|
record.WorldSpawnPublished = true;
|
|
return true;
|
|
}
|
|
|
|
internal bool TryMarkInitialHydrationCompleted(
|
|
LiveEntityRecord expectedRecord,
|
|
ulong expectedCreateIntegrationVersion)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
if (!IsCurrentRecord(expectedRecord)
|
|
|| expectedRecord.CreateIntegrationVersion != expectedCreateIntegrationVersion
|
|
|| expectedRecord.WorldEntity is null
|
|
|| !expectedRecord.ResourcesRegistered)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
expectedRecord.InitialHydrationCompleted = true;
|
|
return true;
|
|
}
|
|
|
|
internal bool TryMarkCreateProjectionSynchronizationPending(
|
|
LiveEntityRecord expectedRecord,
|
|
ulong expectedCreateIntegrationVersion)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
if (!IsCurrentCreateIntegration(
|
|
expectedRecord,
|
|
expectedCreateIntegrationVersion))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
expectedRecord.CreateProjectionSynchronizationPending = true;
|
|
return true;
|
|
}
|
|
|
|
internal bool TryCompleteCreateProjectionSynchronization(
|
|
LiveEntityRecord expectedRecord,
|
|
ulong expectedCreateIntegrationVersion)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
if (!IsCurrentCreateIntegration(
|
|
expectedRecord,
|
|
expectedCreateIntegrationVersion))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
expectedRecord.CreateProjectionSynchronizationPending = false;
|
|
return true;
|
|
}
|
|
|
|
internal bool TryBeginProjectionHydration(
|
|
LiveEntityRecord expectedRecord,
|
|
ulong? expectedCreateIntegrationVersion)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
if (!IsCurrentRecord(expectedRecord)
|
|
|| (expectedCreateIntegrationVersion is { } expected
|
|
&& expectedRecord.CreateIntegrationVersion != expected))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (expectedRecord.ProjectionHydrationInProgress)
|
|
{
|
|
if (expectedRecord.CreateIntegrationVersion
|
|
!= expectedRecord.ProjectionHydrationCreateVersion)
|
|
{
|
|
expectedRecord.ProjectionHydrationRetryRequested = true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
expectedRecord.ProjectionHydrationInProgress = true;
|
|
expectedRecord.ProjectionHydrationCreateVersion =
|
|
expectedRecord.CreateIntegrationVersion;
|
|
expectedRecord.ProjectionHydrationRetryRequested = false;
|
|
return true;
|
|
}
|
|
|
|
internal bool EndProjectionHydration(LiveEntityRecord expectedRecord)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
bool retry = IsCurrentRecord(expectedRecord)
|
|
&& (expectedRecord.ProjectionHydrationRetryRequested
|
|
|| expectedRecord.CreateIntegrationVersion
|
|
!= expectedRecord.ProjectionHydrationCreateVersion);
|
|
if (retry)
|
|
{
|
|
// Persist the exact replay obligation at the point version drift
|
|
// is observed. The stale projection body may be unwinding through
|
|
// an exception and therefore never reach the controller's next
|
|
// retry-loop iteration.
|
|
expectedRecord.CreateProjectionSynchronizationPending = true;
|
|
}
|
|
expectedRecord.ProjectionHydrationInProgress = false;
|
|
expectedRecord.ProjectionHydrationCreateVersion = 0UL;
|
|
expectedRecord.ProjectionHydrationRetryRequested = false;
|
|
return retry;
|
|
}
|
|
|
|
internal bool TryBeginAppearanceHydration(
|
|
LiveEntityRecord expectedRecord,
|
|
ulong expectedObjDescAuthorityVersion)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
if (!expectedRecord.AppearanceProjectionSynchronizationPending
|
|
|| !IsCurrentObjDescAuthority(
|
|
expectedRecord,
|
|
expectedObjDescAuthorityVersion))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
expectedRecord.AppearanceProjectionSynchronizationPending = true;
|
|
if (expectedRecord.AppearanceHydrationInProgress)
|
|
{
|
|
expectedRecord.AppearanceHydrationRetryRequested = true;
|
|
return false;
|
|
}
|
|
|
|
expectedRecord.AppearanceHydrationInProgress = true;
|
|
expectedRecord.AppearanceHydrationVersion =
|
|
expectedObjDescAuthorityVersion;
|
|
expectedRecord.AppearanceHydrationRetryRequested = false;
|
|
return true;
|
|
}
|
|
|
|
internal bool EndAppearanceHydration(LiveEntityRecord expectedRecord)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
bool retry = IsCurrentRecord(expectedRecord)
|
|
&& (expectedRecord.AppearanceHydrationRetryRequested
|
|
|| expectedRecord.ObjDescAuthorityVersion
|
|
!= expectedRecord.AppearanceHydrationVersion);
|
|
expectedRecord.AppearanceHydrationInProgress = false;
|
|
expectedRecord.AppearanceHydrationVersion = 0UL;
|
|
expectedRecord.AppearanceHydrationRetryRequested = false;
|
|
return retry;
|
|
}
|
|
|
|
internal bool TryCompleteAppearanceProjectionSynchronization(
|
|
LiveEntityRecord expectedRecord,
|
|
ulong expectedObjDescAuthorityVersion)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
if (!expectedRecord.AppearanceProjectionSynchronizationPending
|
|
|| !IsCurrentObjDescAuthority(
|
|
expectedRecord,
|
|
expectedObjDescAuthorityVersion))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
expectedRecord.AppearanceProjectionSynchronizationPending = false;
|
|
return true;
|
|
}
|
|
|
|
internal bool IsCurrentCreateIntegration(
|
|
LiveEntityRecord expectedRecord,
|
|
ulong expectedCreateIntegrationVersion) =>
|
|
IsCurrentRecord(expectedRecord)
|
|
&& expectedRecord.CreateIntegrationVersion == expectedCreateIntegrationVersion;
|
|
|
|
public void Clear()
|
|
{
|
|
if (_isClearing || _isRegisteringResources)
|
|
{
|
|
throw new InvalidOperationException(
|
|
_isClearing
|
|
? "Live entity session teardown is already in progress."
|
|
: "Live entity session teardown cannot begin inside atomic resource registration.");
|
|
}
|
|
|
|
_isClearing = true;
|
|
_sessionClearPendingFinalization = true;
|
|
_sessionLifetimeVersion++;
|
|
_lifetimeMutationVersionByGuid.Clear();
|
|
List<Exception>? failures = null;
|
|
try
|
|
{
|
|
foreach (LiveEntityRecord record in _recordsByGuid.Values.ToArray())
|
|
{
|
|
if (!_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
|
|
|| !ReferenceEquals(current, record))
|
|
continue;
|
|
_recordsByGuid.Remove(record.ServerGuid);
|
|
RetainTeardownRecord(record);
|
|
}
|
|
|
|
ParentAttachments.Clear();
|
|
_inbound.Clear();
|
|
|
|
foreach (LiveEntityRecord record in _teardownRecords.Values.ToArray())
|
|
{
|
|
// A re-entrant Clear can observe the tombstone currently being
|
|
// unregistered by its caller. Leave it queued; the outer
|
|
// teardown's ReleaseTeardownRecord finalizes the session once
|
|
// it unwinds successfully.
|
|
if (record.TeardownInProgress)
|
|
continue;
|
|
try
|
|
{
|
|
TearDownRecord(record);
|
|
ReleaseTeardownRecord(record);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= new List<Exception>()).Add(error);
|
|
}
|
|
}
|
|
|
|
CompleteSessionClearIfConverged();
|
|
}
|
|
finally
|
|
{
|
|
_isClearing = false;
|
|
}
|
|
|
|
if (failures is not null)
|
|
throw new AggregateException("One or more live entities failed session teardown.", failures);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drains teardown tombstones whose first unregister attempt failed. This
|
|
/// runs on the update thread after the callback stack that deferred the
|
|
/// removal has unwound; successful steps are not replayed.
|
|
/// </summary>
|
|
public int RetryPendingTeardowns()
|
|
{
|
|
if (_teardownRecords.Count == 0
|
|
|| _isClearing
|
|
|| _isRegisteringResources
|
|
|| _logicalTeardownDepth != 0)
|
|
return 0;
|
|
|
|
int completed = 0;
|
|
List<Exception>? failures = null;
|
|
foreach (LiveEntityRecord record in _teardownRecords.Values.ToArray())
|
|
{
|
|
if (record.TeardownInProgress)
|
|
continue;
|
|
|
|
_logicalTeardownDepth++;
|
|
try
|
|
{
|
|
try
|
|
{
|
|
TearDownRecord(record);
|
|
ReleaseTeardownRecord(record);
|
|
completed++;
|
|
if (!_recordsByGuid.ContainsKey(record.ServerGuid)
|
|
&& !HasPendingTeardown(record.ServerGuid)
|
|
&& !_inbound.TryGetSnapshot(record.ServerGuid, out _))
|
|
{
|
|
_spatial.ForgetLiveEntity(record.ServerGuid);
|
|
}
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= new List<Exception>()).Add(error);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_logicalTeardownDepth--;
|
|
}
|
|
}
|
|
|
|
if (failures is not null)
|
|
throw new AggregateException("One or more pending live-entity teardowns failed again.", failures);
|
|
return completed;
|
|
}
|
|
|
|
private void RefreshRecord(
|
|
uint guid,
|
|
WorldSession.EntitySpawn accepted,
|
|
bool refreshPosition = false)
|
|
{
|
|
if (!_recordsByGuid.TryGetValue(guid, out LiveEntityRecord? record))
|
|
return;
|
|
record.Snapshot = accepted;
|
|
record.RefreshDerivedState(refreshPosition);
|
|
}
|
|
|
|
private static InboundCreateResult SupersededCreateResult() => new(
|
|
CreateObjectTimestampDisposition.StaleGeneration,
|
|
default,
|
|
null,
|
|
default);
|
|
|
|
private ulong AdvanceLifetimeMutation(uint serverGuid)
|
|
{
|
|
ulong next = _lifetimeMutationVersionByGuid.GetValueOrDefault(serverGuid) + 1UL;
|
|
_lifetimeMutationVersionByGuid[serverGuid] = next;
|
|
return next;
|
|
}
|
|
|
|
private ulong CurrentLifetimeMutation(uint serverGuid) =>
|
|
_lifetimeMutationVersionByGuid.GetValueOrDefault(serverGuid);
|
|
|
|
private bool IsCurrentProjectionOperation(
|
|
uint serverGuid,
|
|
LiveEntityRecord record,
|
|
ulong projectionOperation) =>
|
|
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current)
|
|
&& ReferenceEquals(current, record)
|
|
&& record.ProjectionMutationVersion == projectionOperation;
|
|
|
|
private static void ThrowAfterCommittedProjectionChange(
|
|
uint serverGuid,
|
|
Exception? spatialNotificationFailure,
|
|
Exception? runtimeNotificationFailure)
|
|
{
|
|
if (spatialNotificationFailure is not null
|
|
&& runtimeNotificationFailure is not null)
|
|
{
|
|
throw new AggregateException(
|
|
$"Projection change for live entity 0x{serverGuid:X8} committed, but spatial and runtime observers failed.",
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure);
|
|
}
|
|
|
|
Exception? failure = spatialNotificationFailure ?? runtimeNotificationFailure;
|
|
if (failure is not null)
|
|
ExceptionDispatchInfo.Capture(failure).Throw();
|
|
}
|
|
|
|
private void ClearWorldCell(uint guid)
|
|
{
|
|
if (!_recordsByGuid.TryGetValue(guid, out LiveEntityRecord? record))
|
|
return;
|
|
bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue(
|
|
guid,
|
|
out LiveEntityRecord? indexedRoot)
|
|
&& ReferenceEquals(indexedRoot, record);
|
|
// Pickup/Parent is retail's leave-world edge. Suspend the canonical
|
|
// object clock while the record is still indexed as a root; clearing
|
|
// FullCellId first removes that evidence and makes the later projection
|
|
// withdrawal look like a duplicate non-root edge.
|
|
if (wasOrdinaryRoot)
|
|
{
|
|
record.SuspendObjectClock();
|
|
SynchronizePhysicsBodyActiveState(record);
|
|
}
|
|
record.FullCellId = 0;
|
|
record.CanonicalLandblockId = 0;
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
}
|
|
|
|
internal bool IsCurrentRecord(LiveEntityRecord record) =>
|
|
_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
|
|
&& ReferenceEquals(current, record);
|
|
|
|
private static bool HasSpatialRuntimeProjection(LiveEntityRecord record) =>
|
|
record.WorldEntity is not null
|
|
&& record.ProjectionKind is LiveEntityProjectionKind.World
|
|
&& record.IsSpatiallyProjected
|
|
&& record.IsSpatiallyVisible
|
|
&& record.FullCellId != 0;
|
|
|
|
private void RefreshSpatialRuntimeIndexes(LiveEntityRecord record)
|
|
{
|
|
bool current = IsCurrentRecord(record);
|
|
bool spatial = current && HasSpatialRuntimeProjection(record);
|
|
|
|
if (spatial)
|
|
{
|
|
_spatialRootObjectsByGuid[record.ServerGuid] = record;
|
|
}
|
|
else if (current
|
|
|| (_spatialRootObjectsByGuid.TryGetValue(record.ServerGuid, out var indexedRoot)
|
|
&& ReferenceEquals(indexedRoot, record)))
|
|
{
|
|
_spatialRootObjectsByGuid.Remove(record.ServerGuid);
|
|
}
|
|
|
|
if (record.WorldEntity is { } entity)
|
|
{
|
|
if (spatial && record.AnimationRuntime is { } animation)
|
|
{
|
|
_spatialAnimationsByLocalId[entity.Id] = animation;
|
|
}
|
|
else if (current
|
|
|| (record.AnimationRuntime is { } retainedAnimation
|
|
&& _spatialAnimationsByLocalId.TryGetValue(entity.Id, out var indexedAnimation)
|
|
&& ReferenceEquals(indexedAnimation, retainedAnimation)))
|
|
{
|
|
_spatialAnimationsByLocalId.Remove(entity.Id);
|
|
}
|
|
}
|
|
|
|
if (spatial && record.RemoteMotionRuntime is { } remote)
|
|
{
|
|
_spatialRemoteMotionByGuid[record.ServerGuid] = remote;
|
|
}
|
|
else if (current
|
|
|| (record.RemoteMotionRuntime is { } retainedRemote
|
|
&& _spatialRemoteMotionByGuid.TryGetValue(record.ServerGuid, out var indexedRemote)
|
|
&& ReferenceEquals(indexedRemote, retainedRemote)))
|
|
{
|
|
_spatialRemoteMotionByGuid.Remove(record.ServerGuid);
|
|
}
|
|
|
|
if (spatial && record.ProjectileRuntime is { } projectile)
|
|
{
|
|
_spatialProjectilesByGuid[record.ServerGuid] = projectile;
|
|
}
|
|
else if (current
|
|
|| (record.ProjectileRuntime is { } retainedProjectile
|
|
&& _spatialProjectilesByGuid.TryGetValue(record.ServerGuid, out var indexedProjectile)
|
|
&& ReferenceEquals(indexedProjectile, retainedProjectile)))
|
|
{
|
|
_spatialProjectilesByGuid.Remove(record.ServerGuid);
|
|
}
|
|
}
|
|
|
|
private void RemoveSpatialRuntimeIndexes(LiveEntityRecord record)
|
|
{
|
|
if (_spatialRootObjectsByGuid.TryGetValue(record.ServerGuid, out var indexedRoot)
|
|
&& ReferenceEquals(indexedRoot, record))
|
|
{
|
|
_spatialRootObjectsByGuid.Remove(record.ServerGuid);
|
|
}
|
|
|
|
if (record.WorldEntity is { } entity
|
|
&& _spatialAnimationsByLocalId.TryGetValue(entity.Id, out var indexedAnimation)
|
|
&& ReferenceEquals(indexedAnimation, record.AnimationRuntime))
|
|
{
|
|
_spatialAnimationsByLocalId.Remove(entity.Id);
|
|
}
|
|
|
|
if (_spatialRemoteMotionByGuid.TryGetValue(record.ServerGuid, out var indexedRemote)
|
|
&& ReferenceEquals(indexedRemote, record.RemoteMotionRuntime))
|
|
{
|
|
_spatialRemoteMotionByGuid.Remove(record.ServerGuid);
|
|
}
|
|
|
|
if (_spatialProjectilesByGuid.TryGetValue(record.ServerGuid, out var indexedProjectile)
|
|
&& ReferenceEquals(indexedProjectile, record.ProjectileRuntime))
|
|
{
|
|
_spatialProjectilesByGuid.Remove(record.ServerGuid);
|
|
}
|
|
}
|
|
|
|
private uint ReserveLocalEntityId()
|
|
{
|
|
uint start = _nextLocalEntityId;
|
|
do
|
|
{
|
|
uint candidate = _nextLocalEntityId;
|
|
_nextLocalEntityId = candidate == LastLiveEntityId
|
|
? FirstLiveEntityId
|
|
: candidate + 1u;
|
|
if (!_guidByLocalId.ContainsKey(candidate))
|
|
return candidate;
|
|
}
|
|
while (_nextLocalEntityId != start);
|
|
|
|
throw new InvalidOperationException("The live entity id namespace is exhausted.");
|
|
}
|
|
|
|
private void OnSpatialVisibilityChanged(uint serverGuid, bool visible)
|
|
{
|
|
// GpuWorldState serializes re-entrant visibility callbacks. A callback
|
|
// may delete an incarnation and materialize the same server GUID before
|
|
// an older queued edge drains. Apply an edge only while it still
|
|
// matches current spatial truth; otherwise it belongs to the displaced
|
|
// projection and must not mutate the replacement generation.
|
|
if (_spatial.IsLiveEntityProjectionResident(serverGuid) != visible)
|
|
return;
|
|
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
|
|| record.WorldEntity is not { } entity)
|
|
return;
|
|
|
|
bool wasVisible = record.IsSpatiallyVisible;
|
|
bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue(
|
|
serverGuid,
|
|
out LiveEntityRecord? indexedRoot)
|
|
&& ReferenceEquals(indexedRoot, record);
|
|
record.IsSpatiallyVisible = visible;
|
|
bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World
|
|
&& record.IsSpatiallyProjected
|
|
&& visible
|
|
&& record.FullCellId != 0;
|
|
if (_rebucketingGuid != serverGuid && wasOrdinaryRoot != isOrdinaryRoot)
|
|
{
|
|
if (isOrdinaryRoot)
|
|
record.ResetObjectClockForEnterWorld(
|
|
(record.FinalPhysicsState & PhysicsStateFlags.Static) != 0);
|
|
else
|
|
record.SuspendObjectClock();
|
|
SynchronizePhysicsBodyActiveState(record);
|
|
}
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
RefreshPresentation(record);
|
|
if (_rebucketingGuid != serverGuid && wasVisible != visible)
|
|
PublishProjectionVisibilityChanged(record, visible);
|
|
}
|
|
|
|
private static void SynchronizePhysicsBodyActiveState(
|
|
LiveEntityRecord record)
|
|
{
|
|
if (record.PhysicsBody is not { } body)
|
|
return;
|
|
if (record.ObjectClock.IsActive)
|
|
body.TransientState |= TransientStateFlags.Active;
|
|
else
|
|
body.TransientState &= ~TransientStateFlags.Active;
|
|
}
|
|
|
|
private static bool IsFinite(Vector3 value) =>
|
|
float.IsFinite(value.X)
|
|
&& float.IsFinite(value.Y)
|
|
&& float.IsFinite(value.Z);
|
|
|
|
private void PublishProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
|
{
|
|
Delegate[] subscribers = ProjectionVisibilityChanged?.GetInvocationList()
|
|
?? Array.Empty<Delegate>();
|
|
List<Exception>? failures = null;
|
|
for (int i = 0; i < subscribers.Length; i++)
|
|
{
|
|
try
|
|
{
|
|
((Action<LiveEntityRecord, bool>)subscribers[i])(record, visible);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= new List<Exception>()).Add(error);
|
|
}
|
|
}
|
|
|
|
if (failures is not null)
|
|
{
|
|
throw new AggregateException(
|
|
$"One or more projection observers failed for live entity 0x{record.ServerGuid:X8}.",
|
|
failures);
|
|
}
|
|
}
|
|
|
|
private void RefreshPresentation(LiveEntityRecord record)
|
|
{
|
|
if (record.WorldEntity is not { } entity)
|
|
return;
|
|
|
|
PhysicsStateFlags state = record.FinalPhysicsState;
|
|
entity.IsDrawVisible =
|
|
(state & (PhysicsStateFlags.NoDraw | PhysicsStateFlags.Hidden)) == 0;
|
|
|
|
bool interactionVisible = record.IsSpatiallyVisible
|
|
&& record.ProjectionKind is LiveEntityProjectionKind.World
|
|
&& (state & PhysicsStateFlags.Hidden) == 0;
|
|
if (interactionVisible)
|
|
_visibleWorldEntitiesByGuid[record.ServerGuid] = entity;
|
|
else
|
|
_visibleWorldEntitiesByGuid.Remove(record.ServerGuid);
|
|
}
|
|
|
|
private static (uint Guid, ushort Generation) TeardownKey(LiveEntityRecord record) =>
|
|
(record.ServerGuid, record.Generation);
|
|
|
|
private void RetainTeardownRecord(LiveEntityRecord record)
|
|
{
|
|
var key = TeardownKey(record);
|
|
if (_teardownRecords.TryGetValue(key, out LiveEntityRecord? retained)
|
|
&& !ReferenceEquals(retained, record))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Live entity teardown tombstone collision for 0x{record.ServerGuid:X8} generation {record.Generation}.");
|
|
}
|
|
_teardownRecords[key] = record;
|
|
if (record.WorldEntity is { } entity
|
|
&& _visibleWorldEntitiesByGuid.TryGetValue(
|
|
record.ServerGuid,
|
|
out WorldEntity? visible)
|
|
&& ReferenceEquals(visible, entity))
|
|
{
|
|
_visibleWorldEntitiesByGuid.Remove(record.ServerGuid);
|
|
}
|
|
}
|
|
|
|
private void ReleaseTeardownRecord(LiveEntityRecord record)
|
|
{
|
|
var key = TeardownKey(record);
|
|
if (_teardownRecords.TryGetValue(key, out LiveEntityRecord? retained)
|
|
&& ReferenceEquals(retained, record))
|
|
{
|
|
_teardownRecords.Remove(key);
|
|
}
|
|
CompleteSessionClearIfConverged();
|
|
}
|
|
|
|
private bool HasPendingTeardown(uint serverGuid)
|
|
{
|
|
foreach ((uint Guid, ushort Generation) key in _teardownRecords.Keys)
|
|
{
|
|
if (key.Guid == serverGuid)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private void CompleteSessionClearIfConverged()
|
|
{
|
|
if (!_sessionClearPendingFinalization
|
|
|| _recordsByGuid.Count != 0
|
|
|| _teardownRecords.Count != 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_materializedWorldEntitiesByGuid.Clear();
|
|
_visibleWorldEntitiesByGuid.Clear();
|
|
_guidByLocalId.Clear();
|
|
_animationsByLocalId.Clear();
|
|
_remoteMotionByGuid.Clear();
|
|
_spatialAnimationsByLocalId.Clear();
|
|
_spatialRemoteMotionByGuid.Clear();
|
|
_spatialProjectilesByGuid.Clear();
|
|
_spatialRootObjectsByGuid.Clear();
|
|
ParentAttachments.Clear();
|
|
_inbound.Clear();
|
|
_spatial.ClearLiveEntityLifetimeState();
|
|
_sessionClearPendingFinalization = false;
|
|
}
|
|
|
|
private void TearDownRecord(LiveEntityRecord record)
|
|
{
|
|
if (record.TeardownInProgress)
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{record.ServerGuid:X8} teardown is already in progress.");
|
|
|
|
record.TeardownInProgress = true;
|
|
List<Exception>? failures = null;
|
|
bool TryCleanup(Action cleanup)
|
|
{
|
|
try
|
|
{
|
|
cleanup();
|
|
return true;
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= new List<Exception>()).Add(error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
// Remove frame-workset membership before arbitrary callbacks. The
|
|
// logical component dictionaries and WorldEntity remain intact as
|
|
// a retryable tombstone until every external cleanup acknowledges
|
|
// success.
|
|
RemoveSpatialRuntimeIndexes(record);
|
|
|
|
if (!record.RuntimeComponentsTeardownCompleted)
|
|
{
|
|
record.RuntimeComponentsTeardownCompleted =
|
|
TryCleanup(() => _runtimeComponentLifecycle.TearDown(record));
|
|
}
|
|
|
|
if (record.WorldEntity is { } entity)
|
|
{
|
|
if (!record.SpatialProjectionTeardownCompleted)
|
|
{
|
|
record.SpatialProjectionTeardownCompleted =
|
|
TryCleanup(() => _spatial.RemoveLiveEntityProjection(entity));
|
|
}
|
|
if (record.ResourcesRegistered
|
|
&& TryCleanup(() => _resources.Unregister(entity)))
|
|
{
|
|
record.ResourcesRegistered = false;
|
|
}
|
|
}
|
|
|
|
if (failures is not null)
|
|
{
|
|
throw new AggregateException(
|
|
$"Live entity 0x{record.ServerGuid:X8} teardown failed.",
|
|
failures);
|
|
}
|
|
|
|
if (record.WorldEntity is { } finalizedEntity)
|
|
{
|
|
_guidByLocalId.Remove(finalizedEntity.Id);
|
|
if (_materializedWorldEntitiesByGuid.TryGetValue(
|
|
record.ServerGuid,
|
|
out WorldEntity? materialized)
|
|
&& ReferenceEquals(materialized, finalizedEntity))
|
|
{
|
|
_materializedWorldEntitiesByGuid.Remove(record.ServerGuid);
|
|
}
|
|
if (_visibleWorldEntitiesByGuid.TryGetValue(
|
|
record.ServerGuid,
|
|
out WorldEntity? visible)
|
|
&& ReferenceEquals(visible, finalizedEntity))
|
|
{
|
|
_visibleWorldEntitiesByGuid.Remove(record.ServerGuid);
|
|
}
|
|
_animationsByLocalId.Remove(finalizedEntity.Id);
|
|
}
|
|
|
|
if (_remoteMotionByGuid.TryGetValue(record.ServerGuid, out var remote)
|
|
&& ReferenceEquals(remote, record.RemoteMotionRuntime))
|
|
{
|
|
_remoteMotionByGuid.Remove(record.ServerGuid);
|
|
}
|
|
record.AnimationRuntime = null;
|
|
record.RemoteMotionRuntime = null;
|
|
record.PhysicsHost = null;
|
|
record.RequiresRemotePlacementRuntime = false;
|
|
record.PhysicsBody = null;
|
|
record.ProjectileRuntime = null;
|
|
record.EffectProfile = null;
|
|
record.IsSpatiallyProjected = false;
|
|
record.IsSpatiallyVisible = false;
|
|
record.WorldSpawnPublished = false;
|
|
record.InitialHydrationCompleted = false;
|
|
record.CreateProjectionSynchronizationPending = false;
|
|
record.ProjectionHydrationInProgress = false;
|
|
record.ProjectionHydrationCreateVersion = 0UL;
|
|
record.ProjectionHydrationRetryRequested = false;
|
|
record.AppearanceProjectionSynchronizationPending = false;
|
|
record.AppearanceHydrationInProgress = false;
|
|
record.AppearanceHydrationVersion = 0UL;
|
|
record.AppearanceHydrationRetryRequested = false;
|
|
record.WorldEntity = null;
|
|
}
|
|
finally
|
|
{
|
|
record.TeardownInProgress = false;
|
|
}
|
|
}
|
|
}
|