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:
parent
3971997689
commit
749e8ceeb1
225 changed files with 29107 additions and 3914 deletions
163
src/AcDream.App/World/CompositeLiveEntityResourceLifecycle.cs
Normal file
163
src/AcDream.App/World/CompositeLiveEntityResourceLifecycle.cs
Normal 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 ?? []);
|
||||
}
|
||||
}
|
||||
55
src/AcDream.App/World/LiveEntityIncarnationCleanup.cs
Normal file
55
src/AcDream.App/World/LiveEntityIncarnationCleanup.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue