fix(rendering): bound portal resource lifetime

Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-18 21:35:16 +02:00
parent 3971997689
commit 749e8ceeb1
225 changed files with 29107 additions and 3914 deletions

View file

@ -0,0 +1,163 @@
using AcDream.Core.World;
namespace AcDream.App.World;
/// <summary>
/// Symmetric multi-owner live-resource transaction. Each owner's acquisition
/// and release is tracked independently so a late failure cannot replay an
/// earlier script/effect teardown on the next tombstone retry.
/// </summary>
internal sealed class CompositeLiveEntityResourceLifecycle : ILiveEntityResourceLifecycle
{
internal readonly record struct Owner(
Action<WorldEntity> Register,
Action<WorldEntity> Unregister);
private sealed class OwnerState(int count)
{
public bool[] Held { get; } = new bool[count];
public bool DesiredRegistered { get; set; }
public bool Reconciling { get; set; }
}
private sealed record ReconcileFailures(
List<Exception> Registration,
List<Exception> Release);
private readonly Owner[] _owners;
private readonly Dictionary<WorldEntity, OwnerState> _states =
new(ReferenceEqualityComparer.Instance);
public CompositeLiveEntityResourceLifecycle(params Owner[] owners)
{
ArgumentNullException.ThrowIfNull(owners);
if (owners.Length == 0)
throw new ArgumentException("At least one resource owner is required.", nameof(owners));
_owners = [.. owners];
}
public void Register(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
if (!_states.TryGetValue(entity, out OwnerState? state))
{
state = new OwnerState(_owners.Length);
_states.Add(entity, state);
}
state.DesiredRegistered = true;
ReconcileFailures? failures = Reconcile(entity, state);
if (failures is null)
return;
if (failures.Registration.Count == 1 && failures.Release.Count == 0)
System.Runtime.ExceptionServices.ExceptionDispatchInfo
.Capture(failures.Registration[0])
.Throw();
throw new AggregateException(
"Live entity resource registration and owner rollback failed.",
failures.Registration.Concat(failures.Release));
}
public void Unregister(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
if (!_states.TryGetValue(entity, out OwnerState? state))
return;
state.DesiredRegistered = false;
ReconcileFailures? failures = Reconcile(entity, state);
if (failures is not null)
throw new AggregateException(
"One or more live entity resource owners failed to release.",
failures.Registration.Concat(failures.Release));
}
private ReconcileFailures? Reconcile(WorldEntity entity, OwnerState state)
{
if (state.Reconciling)
return null;
state.Reconciling = true;
List<Exception>? registrationFailures = null;
List<Exception>? releaseFailures = null;
try
{
while (true)
{
bool directionChanged = false;
if (state.DesiredRegistered)
{
for (int i = 0; i < _owners.Length; i++)
{
if (!state.DesiredRegistered)
{
directionChanged = true;
break;
}
if (state.Held[i])
continue;
// Entering Register creates the symmetric rollback
// obligation even when the callback partially commits
// and then throws.
state.Held[i] = true;
try
{
_owners[i].Register(entity);
}
catch (Exception error)
{
(registrationFailures ??= []).Add(error);
state.DesiredRegistered = false;
directionChanged = true;
break;
}
}
}
if (!state.DesiredRegistered)
{
for (int i = _owners.Length - 1; i >= 0; i--)
{
if (state.DesiredRegistered)
{
directionChanged = true;
break;
}
if (!state.Held[i])
continue;
try
{
_owners[i].Unregister(entity);
state.Held[i] = false;
}
catch (Exception error)
{
(releaseFailures ??= []).Add(error);
}
}
if (state.DesiredRegistered)
directionChanged = true;
}
if (!directionChanged)
break;
}
}
finally
{
state.Reconciling = false;
if (!state.DesiredRegistered && !state.Held.Contains(true))
_states.Remove(entity);
}
return registrationFailures is null && releaseFailures is null
? null
: new ReconcileFailures(
registrationFailures ?? [],
releaseFailures ?? []);
}
}

View file

@ -0,0 +1,55 @@
namespace AcDream.App.World;
/// <summary>
/// Protects GUID-keyed runtime state while an older incarnation is draining.
/// A teardown tombstone may outlive the active record and a newer generation
/// may already own the same server GUID; only state captured by reference may
/// be removed unconditionally in that case.
/// </summary>
internal sealed class LiveEntityIncarnationCleanup
{
private readonly LiveEntityRecord _owner;
private readonly Func<uint, LiveEntityRecord?> _resolveCurrent;
public LiveEntityIncarnationCleanup(
LiveEntityRecord owner,
Func<uint, LiveEntityRecord?> resolveCurrent)
{
_owner = owner ?? throw new ArgumentNullException(nameof(owner));
_resolveCurrent = resolveCurrent
?? throw new ArgumentNullException(nameof(resolveCurrent));
}
/// <summary>
/// Runs a GUID-scoped mutation only while no replacement incarnation owns
/// that GUID. The original record may still be current (projection-only
/// withdrawal) or may already have moved into the teardown ledger.
/// </summary>
public void RunIfNoReplacement(Action mutation)
{
ArgumentNullException.ThrowIfNull(mutation);
LiveEntityRecord? current = _resolveCurrent(_owner.ServerGuid);
if (current is null || ReferenceEquals(current, _owner))
mutation();
}
/// <summary>
/// Removes a reference-owned dictionary entry only when the entry still
/// points at this incarnation's captured owner. This remains safe even if
/// a replacement has overwritten the same GUID between plan creation and
/// execution.
/// </summary>
public void RemoveCaptured<T>(
IDictionary<uint, T> owners,
T captured)
where T : class
{
ArgumentNullException.ThrowIfNull(owners);
ArgumentNullException.ThrowIfNull(captured);
if (owners.TryGetValue(_owner.ServerGuid, out T? current)
&& ReferenceEquals(current, captured))
{
owners.Remove(_owner.ServerGuid);
}
}
}

View file

@ -136,6 +136,11 @@ public sealed class LiveEntityRecord
internal ulong ProjectionMutationVersion { get; set; }
public bool WorldSpawnPublished { get; internal 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);
@ -217,12 +222,27 @@ public sealed class LiveEntityRuntime
private readonly Action<LiveEntityRecord>? _tearDownRuntimeComponents;
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();
private bool _isClearing;
private bool _sessionClearPendingFinalization;
private bool _isRegisteringResources;
private int _logicalTeardownDepth;
private ulong _sessionLifetimeVersion;
@ -248,6 +268,7 @@ public sealed class LiveEntityRuntime
}
public int Count => _recordsByGuid.Count;
public int PendingTeardownCount => _teardownRecords.Count;
public int MaterializedCount => _guidByLocalId.Count;
public IReadOnlyCollection<LiveEntityRecord> Records => _recordsByGuid.Values;
/// <summary>
@ -270,6 +291,12 @@ public sealed class LiveEntityRuntime
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;
public ParentAttachmentState ParentAttachments { get; } = new();
/// <summary>
@ -280,10 +307,10 @@ public sealed class LiveEntityRuntime
public LiveEntityRegistrationResult RegisterLiveEntity(WorldSession.EntitySpawn incoming)
{
if (_isClearing || _isRegisteringResources)
if (_isClearing || _sessionClearPendingFinalization || _isRegisteringResources)
{
throw new InvalidOperationException(
_isClearing
_isClearing || _sessionClearPendingFinalization
? "A live entity cannot register while the session lifetime is clearing."
: "A live entity cannot register from inside atomic resource registration.");
}
@ -300,9 +327,24 @@ public sealed class LiveEntityRuntime
{
retained.Snapshot = result.Snapshot;
retained.RefreshDerivedState();
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);
@ -317,12 +359,14 @@ public sealed class LiveEntityRuntime
Exception? tearDownFailure = null;
if (old is not null)
{
RetainTeardownRecord(old);
_logicalTeardownDepth++;
try
{
try
{
TearDownRecord(old);
ReleaseTeardownRecord(old);
}
catch (Exception error)
{
@ -379,7 +423,10 @@ public sealed class LiveEntityRuntime
LiveEntityProjectionKind projectionKind = LiveEntityProjectionKind.World)
{
ArgumentNullException.ThrowIfNull(factory);
if (_isClearing || _logicalTeardownDepth != 0 || _isRegisteringResources)
if (_isClearing
|| _sessionClearPendingFinalization
|| _logicalTeardownDepth != 0
|| _isRegisteringResources)
{
throw new InvalidOperationException(
"A live entity cannot materialize inside an active logical-lifetime transition.");
@ -404,34 +451,47 @@ public sealed class LiveEntityRuntime
_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);
record.ResourcesRegistered = true;
}
catch (Exception registrationError)
{
// Registration is an atomic logical-lifetime boundary.
// Give composite owners a symmetric rollback opportunity,
// then remove identity even if cleanup itself fails.
Exception? rollbackError = null;
try
{
_resources.Unregister(entity);
record.ResourcesRegistered = false;
}
catch (Exception rollbackError)
catch (Exception error)
{
throw new AggregateException(
"Live entity resource registration and rollback both failed.",
registrationError,
rollbackError);
rollbackError = error;
}
finally
if (rollbackError is null)
{
_guidByLocalId.Remove(localId);
record.WorldEntity = null;
record.ResourcesRegistered = false;
throw;
}
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
@ -510,6 +570,7 @@ public sealed class LiveEntityRuntime
record.CanonicalLandblockId = spatialCellOrLandblockId == 0
? 0u
: (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu;
RefreshSpatialRuntimeIndexes(record);
Exception? runtimeNotificationFailure = null;
if (!wasProjected || wasVisible != visible)
{
@ -570,6 +631,7 @@ public sealed class LiveEntityRuntime
_visibleWorldEntitiesByGuid.Remove(serverGuid);
record.IsSpatiallyProjected = false;
record.IsSpatiallyVisible = false;
RefreshSpatialRuntimeIndexes(record);
Exception? runtimeNotificationFailure = null;
if (spatialEdgeWasDeferred)
{
@ -616,30 +678,53 @@ public sealed class LiveEntityRuntime
throw new InvalidOperationException(
"A live entity cannot unregister from inside atomic resource registration.");
}
if (!_inbound.TryDelete(delete, isLocalPlayer))
return false;
AdvanceLifetimeMutation(delete.Guid);
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);
ParentAttachments.DeleteGeneration(delete.Guid, delete.InstanceSequence);
// Remove the accepted incarnation from canonical identity before
// invoking arbitrary callbacks. A callback may synchronously create a
// newer generation with the same server GUID; materialization waits
// until this teardown completes, and cleanup remains scoped to this
// captured record.
_recordsByGuid.Remove(delete.Guid, out LiveEntityRecord? record);
// 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
{
try
if (!retryingAcceptedDelete)
{
beforeTeardown?.Invoke();
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
try
{
beforeTeardown?.Invoke();
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
}
if (record is not null)
@ -647,6 +732,7 @@ public sealed class LiveEntityRuntime
try
{
TearDownRecord(record);
ReleaseTeardownRecord(record);
}
catch (Exception error)
{
@ -662,8 +748,10 @@ public sealed class LiveEntityRuntime
try
{
// Persistence is GUID-scoped. End it only when the accepted
// delete left no replacement incarnation behind.
if (!_recordsByGuid.ContainsKey(delete.Guid))
// delete left no replacement incarnation or unfinished teardown
// behind.
if (!_recordsByGuid.ContainsKey(delete.Guid)
&& !HasPendingTeardown(delete.Guid))
{
_spatial.ForgetLiveEntity(delete.Guid);
}
@ -739,6 +827,7 @@ public sealed class LiveEntityRuntime
_animationsByLocalId.Remove(entity.Id);
record.AnimationRuntime = runtime;
_animationsByLocalId[entity.Id] = runtime;
RefreshSpatialRuntimeIndexes(record);
}
public bool TryGetAnimationRuntime(uint localEntityId, out ILiveEntityAnimationRuntime runtime) =>
@ -752,6 +841,7 @@ public sealed class LiveEntityRuntime
if (record.LocalEntityId is { } localId)
_animationsByLocalId.Remove(localId);
record.AnimationRuntime = null;
RefreshSpatialRuntimeIndexes(record);
return true;
}
@ -803,6 +893,7 @@ public sealed class LiveEntityRuntime
});
}
_remoteMotionByGuid[serverGuid] = runtime;
RefreshSpatialRuntimeIndexes(record);
}
public bool TryGetRemoteMotionRuntime(uint serverGuid, out ILiveEntityRemoteMotionRuntime runtime) =>
@ -815,6 +906,7 @@ public sealed class LiveEntityRuntime
return false;
_remoteMotionByGuid.Remove(serverGuid);
record.RemoteMotionRuntime = null;
RefreshSpatialRuntimeIndexes(record);
return true;
}
@ -838,6 +930,7 @@ public sealed class LiveEntityRuntime
record.ProjectileRuntime = runtime;
record.PhysicsBody = candidateBody;
candidateBody.State = record.FinalPhysicsState;
RefreshSpatialRuntimeIndexes(record);
}
public bool TryGetProjectileRuntime(
@ -862,6 +955,7 @@ public sealed class LiveEntityRuntime
return false;
record.ProjectileRuntime = null;
RefreshSpatialRuntimeIndexes(record);
return true;
}
@ -1049,6 +1143,67 @@ public sealed class LiveEntityRuntime
&& record.FullCellId != 0
&& (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0;
/// <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;
@ -1081,6 +1236,7 @@ public sealed class LiveEntityRuntime
}
_isClearing = true;
_sessionClearPendingFinalization = true;
_sessionLifetimeVersion++;
_lifetimeMutationVersionByGuid.Clear();
List<Exception>? failures = null;
@ -1088,17 +1244,28 @@ public sealed class LiveEntityRuntime
{
foreach (LiveEntityRecord record in _recordsByGuid.Values.ToArray())
{
// Remove canonical identity first. Registration is rejected
// during this terminal clear, so callbacks cannot leak an
// owner that is absent from the teardown snapshot.
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)
{
@ -1106,15 +1273,7 @@ public sealed class LiveEntityRuntime
}
}
_recordsByGuid.Clear();
_materializedWorldEntitiesByGuid.Clear();
_visibleWorldEntitiesByGuid.Clear();
_guidByLocalId.Clear();
_animationsByLocalId.Clear();
_remoteMotionByGuid.Clear();
ParentAttachments.Clear();
_inbound.Clear();
_spatial.ClearLiveEntityLifetimeState();
CompleteSessionClearIfConverged();
}
finally
{
@ -1125,6 +1284,57 @@ public sealed class LiveEntityRuntime
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,
@ -1185,6 +1395,84 @@ public sealed class LiveEntityRuntime
return;
record.FullCellId = 0;
record.CanonicalLandblockId = 0;
RefreshSpatialRuntimeIndexes(record);
}
private 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.IsSpatiallyProjected
&& record.IsSpatiallyVisible
&& record.FullCellId != 0;
private void RefreshSpatialRuntimeIndexes(LiveEntityRecord record)
{
bool current = IsCurrentRecord(record);
bool spatial = current && HasSpatialRuntimeProjection(record);
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 (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()
@ -1219,6 +1507,7 @@ public sealed class LiveEntityRuntime
bool wasVisible = record.IsSpatiallyVisible;
record.IsSpatiallyVisible = visible;
RefreshSpatialRuntimeIndexes(record);
RefreshPresentation(record);
if (_rebucketingGuid != serverGuid && wasVisible != visible)
PublishProjectionVisibilityChanged(record, visible);
@ -1267,67 +1556,170 @@ public sealed class LiveEntityRuntime
_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();
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;
void RunCleanup(Action cleanup)
bool TryCleanup(Action cleanup)
{
try
{
cleanup();
return true;
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
return false;
}
}
if (_tearDownRuntimeComponents is not null)
RunCleanup(() => _tearDownRuntimeComponents(record));
if (record.WorldEntity is { } entity)
try
{
RunCleanup(() => _spatial.RemoveLiveEntityProjection(entity));
if (record.ResourcesRegistered)
RunCleanup(() => _resources.Unregister(entity));
_guidByLocalId.Remove(entity.Id);
if (_materializedWorldEntitiesByGuid.TryGetValue(
record.ServerGuid,
out WorldEntity? materialized)
&& ReferenceEquals(materialized, entity))
{
_materializedWorldEntitiesByGuid.Remove(record.ServerGuid);
}
if (_visibleWorldEntitiesByGuid.TryGetValue(
record.ServerGuid,
out WorldEntity? visible)
&& ReferenceEquals(visible, entity))
{
_visibleWorldEntitiesByGuid.Remove(record.ServerGuid);
}
_animationsByLocalId.Remove(entity.Id);
}
// 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 (_remoteMotionByGuid.TryGetValue(record.ServerGuid, out var remote)
&& ReferenceEquals(remote, record.RemoteMotionRuntime))
if (!record.RuntimeComponentsTeardownCompleted)
{
record.RuntimeComponentsTeardownCompleted =
_tearDownRuntimeComponents is null
|| TryCleanup(() => _tearDownRuntimeComponents(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.RequiresRemotePlacementRuntime = false;
record.PhysicsBody = null;
record.ProjectileRuntime = null;
record.EffectProfile = null;
record.IsSpatiallyProjected = false;
record.IsSpatiallyVisible = false;
record.WorldSpawnPublished = false;
record.WorldEntity = null;
}
finally
{
_remoteMotionByGuid.Remove(record.ServerGuid);
record.TeardownInProgress = false;
}
record.AnimationRuntime = null;
record.RemoteMotionRuntime = null;
record.RequiresRemotePlacementRuntime = false;
record.PhysicsBody = null;
record.ProjectileRuntime = null;
record.EffectProfile = null;
record.ResourcesRegistered = false;
record.IsSpatiallyProjected = false;
record.IsSpatiallyVisible = false;
record.WorldSpawnPublished = false;
record.WorldEntity = null;
if (failures is not null)
throw new AggregateException(
$"Live entity 0x{record.ServerGuid:X8} teardown failed.",
failures);
}
}

View file

@ -1,22 +1,23 @@
namespace AcDream.App.World;
/// <summary>
/// Strongly typed, storage-free view over animation components owned by a
/// <see cref="LiveEntityRuntime"/>. This keeps feature loops type-safe without
/// introducing a second component dictionary.
/// Strongly typed view over animation components owned by a
/// <see cref="LiveEntityRuntime"/>. It owns only reusable traversal scratch;
/// canonical component identity remains in the runtime.
/// </summary>
public sealed class LiveEntityAnimationRuntimeView<TAnimation>
: IEnumerable<KeyValuePair<uint, TAnimation>>
where TAnimation : class, ILiveEntityAnimationRuntime
{
private readonly Func<LiveEntityRuntime?> _runtime;
private readonly List<KeyValuePair<uint, TAnimation>> _iterationSnapshot = new();
public LiveEntityAnimationRuntimeView(Func<LiveEntityRuntime?> runtime) =>
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
public int Count => _runtime()?.AnimationRuntimes.Count ?? 0;
public int Count => _runtime()?.SpatialAnimationRuntimes.Count ?? 0;
public IEnumerable<uint> Keys =>
_runtime()?.AnimationRuntimes.Keys ?? Array.Empty<uint>();
_runtime()?.SpatialAnimationRuntimes.Keys ?? Array.Empty<uint>();
public TAnimation this[uint localEntityId]
{
@ -51,16 +52,79 @@ public sealed class LiveEntityAnimationRuntimeView<TAnimation>
&& runtime.ClearAnimationRuntime(guid);
}
public IEnumerator<KeyValuePair<uint, TAnimation>> GetEnumerator()
public Enumerator GetEnumerator()
{
if (_runtime() is not { } runtime)
yield break;
foreach (var pair in runtime.AnimationRuntimes)
if (pair.Value is TAnimation typed)
yield return new KeyValuePair<uint, TAnimation>(pair.Key, typed);
_iterationSnapshot.Clear();
LiveEntityRuntime? runtime = _runtime();
if (runtime is not null)
{
foreach (var pair in runtime.SpatialAnimationRuntimes)
{
if (pair.Value is TAnimation typed)
{
_iterationSnapshot.Add(
new KeyValuePair<uint, TAnimation>(pair.Key, typed));
}
}
}
return new Enumerator(runtime, _iterationSnapshot);
}
IEnumerator<KeyValuePair<uint, TAnimation>> IEnumerable<KeyValuePair<uint, TAnimation>>.GetEnumerator() =>
GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
/// <summary>
/// Struct enumerator over a reusable frame snapshot. A runtime can rebucket
/// or delete another entity from inside animation/physics callbacks without
/// invalidating this traversal. Each entry is identity-checked immediately
/// before it is yielded, so GUID reuse cannot advance the displaced owner.
/// </summary>
public struct Enumerator : IEnumerator<KeyValuePair<uint, TAnimation>>
{
private readonly LiveEntityRuntime? _runtime;
private readonly List<KeyValuePair<uint, TAnimation>> _snapshot;
private int _index;
internal Enumerator(
LiveEntityRuntime? runtime,
List<KeyValuePair<uint, TAnimation>> snapshot)
{
_runtime = runtime;
_snapshot = snapshot;
_index = -1;
Current = default;
}
public KeyValuePair<uint, TAnimation> Current { get; private set; }
object System.Collections.IEnumerator.Current => Current;
public bool MoveNext()
{
while (++_index < _snapshot.Count)
{
KeyValuePair<uint, TAnimation> pair = _snapshot[_index];
if (_runtime?.SpatialAnimationRuntimes.TryGetValue(
pair.Key,
out ILiveEntityAnimationRuntime? indexed) == true
&& ReferenceEquals(indexed, pair.Value))
{
Current = pair;
return true;
}
}
Current = default;
return false;
}
public void Dispose() { }
void System.Collections.IEnumerator.Reset() =>
throw new NotSupportedException();
}
}
/// <summary>Storage-free typed view over remote-motion components.</summary>

View file

@ -31,3 +31,48 @@ internal static class LiveEntityTeardown
failures);
}
}
/// <summary>
/// A stable teardown plan whose successful steps are committed individually.
/// The plan itself stays on the live-record tombstone, so retrying a later
/// failed owner never replays an earlier ExitWorld notification or release.
/// </summary>
internal sealed class LiveEntityTeardownPlan
{
private readonly Action[] _steps;
private readonly bool[] _completed;
public LiveEntityTeardownPlan(IEnumerable<Action> steps)
{
ArgumentNullException.ThrowIfNull(steps);
_steps = [.. steps];
_completed = new bool[_steps.Length];
}
internal int CompletedCount => _completed.Count(static completed => completed);
internal bool IsComplete => CompletedCount == _steps.Length;
public void Advance()
{
List<Exception>? failures = null;
for (int i = 0; i < _steps.Length; i++)
{
if (_completed[i])
continue;
try
{
_steps[i]();
_completed[i] = true;
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
if (failures is not null)
throw new AggregateException(
"One or more live-entity component teardown steps failed.",
failures);
}
}