test(vfx): harden live missile and effect lifetimes

Add deterministic 96-owner lifecycle and renderer-resource stress gates plus an exact twelve-cycle recall/portal ownership gate. Prove zero retained records, projectiles, spatial buckets, script queues, particle/light owners, shadows, pending effects, and mesh references after churn, GUID reuse, deletion, and session reset.

Make logical teardown incarnation-specific and reentrancy-safe with lifetime epochs, atomic resource registration, generation-aware effect/teleport cleanup, projection mutation tokens, and failure-isolated visibility fan-out. Finish canonical spatial transactions before reporting observer failures and never discard superseded cleanup failures.

Synchronize architecture, roadmap, milestones, retail research, divergence bookkeeping, and durable memory. All three independent review tracks are clean; Release build and the full 5,454-pass/5-skip suite are green.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-14 15:47:00 +02:00
parent 1e98d81448
commit 8d63e5c28a
21 changed files with 2704 additions and 100 deletions

View file

@ -176,12 +176,25 @@ internal sealed class RemoteTeleportController : IDisposable
_pending.Remove(serverGuid);
}
internal void Forget(LiveEntityRecord record)
{
ArgumentNullException.ThrowIfNull(record);
if (_pending.TryGetValue(record.ServerGuid, out PendingPlacement pending)
&& pending.Generation == record.Generation
&& (record.WorldEntity is null
|| ReferenceEquals(pending.Entity, record.WorldEntity)))
{
_pending.Remove(record.ServerGuid);
}
}
internal void Clear()
{
_pending.Clear();
}
internal bool HasPending(uint serverGuid) => _pending.ContainsKey(serverGuid);
internal int PendingPlacementCount => _pending.Count;
/// <summary>
/// Transfers any older deferred shadow restoration before the accepted

View file

@ -5044,7 +5044,7 @@ public sealed class GameWindow : IDisposable
{
() => _liveEntityPresentation?.Forget(record),
() => _entityEffects?.OnLiveEntityUnregistered(record),
() => _remoteTeleportController?.Forget(serverGuid),
() => _remoteTeleportController?.Forget(record),
};
if (_pendingPostArrivalAction is { Guid: var pendingGuid }
&& pendingGuid == serverGuid)

View file

@ -33,7 +33,7 @@ public sealed class EntityEffectController : IAnimationHookSink
private readonly Action<uint> _ownerUnregistered;
private readonly Action<uint, uint?> _ownerSoundTableChanged;
private readonly Dictionary<uint, EntityEffectProfile> _profilesByLocalId = new();
private readonly HashSet<uint> _readyServerGuids = new();
private readonly Dictionary<uint, ushort> _readyGenerationByServerGuid = new();
private readonly Dictionary<uint, Queue<PendingEffect>> _pendingByServerGuid = new();
private readonly Dictionary<uint, WorldEntity> _staticOwners = new();
private readonly HashSet<uint> _syntheticOwners = new();
@ -125,7 +125,7 @@ public sealed class EntityEffectController : IAnimationHookSink
return false;
}
_readyServerGuids.Add(serverGuid);
_readyGenerationByServerGuid[serverGuid] = record.Generation;
_profilesByLocalId[entity.Id] = profile;
_runner.SetOwnerAnchor(entity.Id, entity.Position);
_ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid);
@ -197,8 +197,18 @@ public sealed class EntityEffectController : IAnimationHookSink
public void OnLiveEntityUnregistered(LiveEntityRecord record)
{
ArgumentNullException.ThrowIfNull(record);
_pendingByServerGuid.Remove(record.ServerGuid);
_readyServerGuids.Remove(record.ServerGuid);
if (!_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
|| ReferenceEquals(current, record))
{
_pendingByServerGuid.Remove(record.ServerGuid);
}
if (_readyGenerationByServerGuid.TryGetValue(
record.ServerGuid,
out ushort readyGeneration)
&& readyGeneration == record.Generation)
{
_readyGenerationByServerGuid.Remove(record.ServerGuid);
}
if (record.LocalEntityId is not { } localId)
return;
_profilesByLocalId.Remove(localId);
@ -212,7 +222,7 @@ public sealed class EntityEffectController : IAnimationHookSink
public void ClearNetworkState()
{
foreach (uint serverGuid in _readyServerGuids.ToArray())
foreach (uint serverGuid in _readyGenerationByServerGuid.Keys.ToArray())
{
if (_liveEntities.TryGetLocalEntityId(serverGuid, out uint localId))
{
@ -221,14 +231,14 @@ public sealed class EntityEffectController : IAnimationHookSink
_ownerUnregistered(localId);
}
}
_readyServerGuids.Clear();
_readyGenerationByServerGuid.Clear();
_pendingByServerGuid.Clear();
}
/// <summary>Refreshes every live root after animation/movement.</summary>
public void RefreshLiveOwnerPoses()
{
foreach (uint serverGuid in _readyServerGuids.ToArray())
foreach (uint serverGuid in _readyGenerationByServerGuid.Keys.ToArray())
{
if (!TryGetReadyLocalId(serverGuid, out uint localId))
continue;
@ -360,7 +370,9 @@ public sealed class EntityEffectController : IAnimationHookSink
private bool TryGetReadyLocalId(uint serverGuid, out uint localId)
{
if (_readyServerGuids.Contains(serverGuid)
if (_readyGenerationByServerGuid.TryGetValue(serverGuid, out ushort readyGeneration)
&& _liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
&& record.Generation == readyGeneration
&& _liveEntities.TryGetLocalEntityId(serverGuid, out localId)
&& _profilesByLocalId.ContainsKey(localId))
{

View file

@ -114,6 +114,9 @@ public sealed class LiveEntityLightController : IDisposable
public void Refresh() => _lighting.RefreshAttachedLights();
internal int TrackedOwnerCount => _serverGuidByOwner.Count;
internal int PresentedOwnerCount => _presentOwners.Count;
/// <summary>
/// Attached projection visibility can become true before its holding-part
/// composition is published. The attachment owner calls this barrier only

View file

@ -199,6 +199,10 @@ public sealed class GpuWorldState
/// pending path is doing its job.
/// </summary>
public int PendingLiveEntityCount => _pendingByLandblock.Values.Sum(list => list.Count);
public int PendingBucketCount => _pendingByLandblock.Count;
public int PendingRescueCount => _persistentRescued.Count;
public int PersistentGuidCount => _persistentGuids.Count;
public int PendingVisibilityTransitionCount => _visibilityTransitions.Count;
public void AddLandblock(LoadedLandblock landblock)
{
@ -449,12 +453,84 @@ public sealed class GpuWorldState
}
// Scrub pending buckets too so rebucketing cannot leave a second slot.
foreach (var kvp in _pendingByLandblock)
kvp.Value.RemoveAll(e => e.ServerGuid == serverGuid);
foreach (uint landblockId in _pendingByLandblock.Keys.ToArray())
{
List<WorldEntity> bucket = _pendingByLandblock[landblockId];
bucket.RemoveAll(e => e.ServerGuid == serverGuid);
if (bucket.Count == 0)
_pendingByLandblock.Remove(landblockId);
}
// A persistent projection may have been rescued by RemoveLandblock
// and not yet drained by the next GameWindow frame. Rebucketing or
// logical teardown before that drain must remove the stale rescue
// reference or it can later re-inject a deleted/duplicated object.
_persistentRescued.RemoveAll(e => e.ServerGuid == serverGuid);
if (rebuiltLoaded) RebuildFlatView();
}
/// <summary>
/// Removes one exact live projection incarnation. Unlike the GUID overload,
/// this cannot detach a replacement that reused the same server GUID from a
/// re-entrant logical teardown callback.
/// </summary>
public void RemoveLiveEntityProjection(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
if (entity.ServerGuid == 0) return;
bool rebuiltLoaded = false;
foreach (var kvp in _loaded.ToArray())
{
IReadOnlyList<WorldEntity> entities = kvp.Value.Entities;
if (!entities.Any(candidate => ReferenceEquals(candidate, entity)))
continue;
_loaded[kvp.Key] = new LoadedLandblock(
kvp.Value.LandblockId,
kvp.Value.Heightmap,
entities.Where(candidate => !ReferenceEquals(candidate, entity)).ToArray());
rebuiltLoaded = true;
}
foreach (uint landblockId in _pendingByLandblock.Keys.ToArray())
{
List<WorldEntity> bucket = _pendingByLandblock[landblockId];
bucket.RemoveAll(candidate => ReferenceEquals(candidate, entity));
if (bucket.Count == 0)
_pendingByLandblock.Remove(landblockId);
}
_persistentRescued.RemoveAll(candidate => ReferenceEquals(candidate, entity));
if (rebuiltLoaded)
RebuildFlatView();
}
/// <summary>
/// Ends all spatial lifetime retained for one server object. Temporary
/// rebucketing uses <see cref="RemoveLiveEntityProjection"/> and keeps the
/// persistence classification; logical delete also forgets that class so
/// a later session/GUID reuse cannot be rescued as the old player.
/// </summary>
public void ForgetLiveEntity(uint serverGuid)
{
RemoveLiveEntityProjection(serverGuid);
_persistentGuids.Remove(serverGuid);
_persistentInFlatProbe.Remove(serverGuid);
}
/// <summary>Clears session-scoped persistence and undrained rescues.</summary>
public void ClearLiveEntityLifetimeState()
{
_persistentGuids.Clear();
_persistentRescued.Clear();
_persistentInFlatProbe.Clear();
_visibilityTransitions.Clear();
_visibleLiveGuids.Clear();
}
/// <summary>
/// Place an already-registered live entity in a landblock slot whose
/// terrain may or may not be loaded yet.
@ -656,20 +732,41 @@ public sealed class GpuWorldState
if (_dispatchingVisibilityTransitions)
return;
List<Exception>? failures = null;
_dispatchingVisibilityTransitions = true;
try
{
while (_visibilityTransitions.TryDequeue(out var transition))
{
LiveProjectionVisibilityChanged?.Invoke(
transition.ServerGuid,
transition.Visible);
Delegate[] subscribers = LiveProjectionVisibilityChanged?
.GetInvocationList()
?? Array.Empty<Delegate>();
for (int i = 0; i < subscribers.Length; i++)
{
try
{
((Action<uint, bool>)subscribers[i])(
transition.ServerGuid,
transition.Visible);
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
}
}
}
finally
{
_dispatchingVisibilityTransitions = false;
}
if (failures is not null)
{
throw new AggregateException(
"One or more live projection visibility observers failed.",
failures);
}
}
// TEMP (#138-B): persistent guids currently present in the drawn flat

View file

@ -159,6 +159,10 @@ public sealed class LiveEntityPresentationController : IDisposable
internal bool HasActivePlacement(uint serverGuid) =>
_activePlacementGenerationByGuid.ContainsKey(serverGuid);
internal int ReadyOwnerCount => _readyGenerationByGuid.Count;
internal int DeferredShadowRestoreCount => _suspendedShadowGenerationByGuid.Count;
internal int ActivePlacementCount => _activePlacementGenerationByGuid.Count;
public void Forget(LiveEntityRecord record)
{
ArgumentNullException.ThrowIfNull(record);

View file

@ -4,6 +4,7 @@ using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using System.Numerics;
using System.Runtime.ExceptionServices;
namespace AcDream.App.World;
@ -131,6 +132,7 @@ public sealed class LiveEntityRecord
public bool ResourcesRegistered { get; internal set; }
public bool IsSpatiallyProjected { get; internal set; }
public bool IsSpatiallyVisible { get; internal set; }
internal ulong ProjectionMutationVersion { get; set; }
public bool WorldSpawnPublished { get; internal set; }
public LiveEntityProjectionKind ProjectionKind { get; internal set; }
@ -219,6 +221,11 @@ public sealed class LiveEntityRuntime
private readonly Dictionary<uint, uint> _guidByLocalId = new();
private readonly Dictionary<uint, ILiveEntityAnimationRuntime> _animationsByLocalId = new();
private readonly Dictionary<uint, ILiveEntityRemoteMotionRuntime> _remoteMotionByGuid = new();
private bool _isClearing;
private bool _isRegisteringResources;
private int _logicalTeardownDepth;
private ulong _sessionLifetimeVersion;
private readonly Dictionary<uint, ulong> _lifetimeMutationVersionByGuid = new();
private uint _rebucketingGuid;
private uint _nextLocalEntityId;
@ -272,9 +279,19 @@ public sealed class LiveEntityRuntime
public LiveEntityRegistrationResult RegisterLiveEntity(WorldSession.EntitySpawn incoming)
{
if (_isClearing || _isRegisteringResources)
{
throw new InvalidOperationException(
_isClearing
? "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)
{
@ -293,21 +310,52 @@ public sealed class LiveEntityRuntime
}
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)
{
_logicalTeardownDepth++;
try
{
TearDownRecord(old);
try
{
TearDownRecord(old);
}
catch (Exception error)
{
tearDownFailure = error;
}
}
catch (Exception error)
finally
{
tearDownFailure = error;
_logicalTeardownDepth--;
}
}
if (result.Disposition is CreateObjectTimestampDisposition.NewGeneration)
ParentAttachments.EndGeneration(incoming.Guid, result.Snapshot.InstanceSequence);
// 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);
@ -330,6 +378,11 @@ public sealed class LiveEntityRuntime
LiveEntityProjectionKind projectionKind = LiveEntityProjectionKind.World)
{
ArgumentNullException.ThrowIfNull(factory);
if (_isClearing || _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;
@ -347,34 +400,42 @@ public sealed class LiveEntityRuntime
_guidByLocalId.Add(localId, serverGuid);
record.WorldEntity = entity;
_isRegisteringResources = 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 the identity even if cleanup itself fails.
try
{
_resources.Unregister(entity);
_resources.Register(entity);
record.ResourcesRegistered = true;
}
catch (Exception rollbackError)
catch (Exception registrationError)
{
throw new AggregateException(
"Live entity resource registration and rollback both failed.",
registrationError,
rollbackError);
// Registration is an atomic logical-lifetime boundary.
// Give composite owners a symmetric rollback opportunity,
// then remove identity even if cleanup itself fails.
try
{
_resources.Unregister(entity);
}
catch (Exception rollbackError)
{
throw new AggregateException(
"Live entity resource registration and rollback both failed.",
registrationError,
rollbackError);
}
finally
{
_guidByLocalId.Remove(localId);
record.WorldEntity = null;
record.ResourcesRegistered = false;
}
throw;
}
finally
{
_guidByLocalId.Remove(localId);
record.WorldEntity = null;
record.ResourcesRegistered = false;
}
throw;
}
finally
{
_isRegisteringResources = false;
}
}
@ -401,18 +462,40 @@ public sealed class LiveEntityRuntime
bool wasProjected = record.IsSpatiallyProjected;
bool wasVisible = record.IsSpatiallyVisible;
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
{
_spatial.RebucketLiveEntity(entity, spatialCellOrLandblockId);
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 = 0;
_rebucketingGuid = priorRebucketingGuid;
}
if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation))
{
ThrowAfterCommittedProjectionChange(
serverGuid,
spatialNotificationFailure,
runtimeNotificationFailure: null);
return false;
}
bool visible = _spatial.IsLiveEntityVisible(serverGuid);
record.IsSpatiallyVisible = visible;
@ -426,8 +509,22 @@ public sealed class LiveEntityRuntime
record.CanonicalLandblockId = spatialCellOrLandblockId == 0
? 0u
: (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu;
Exception? runtimeNotificationFailure = null;
if (!wasProjected || wasVisible != visible)
ProjectionVisibilityChanged?.Invoke(record, visible);
{
try
{
PublishProjectionVisibilityChanged(record, visible);
}
catch (Exception error)
{
runtimeNotificationFailure = error;
}
}
ThrowAfterCommittedProjectionChange(
serverGuid,
spatialNotificationFailure,
runtimeNotificationFailure);
return true;
}
@ -441,7 +538,27 @@ public sealed class LiveEntityRuntime
|| record.WorldEntity is null)
return false;
_spatial.RemoveLiveEntityProjection(serverGuid);
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;
@ -452,8 +569,22 @@ public sealed class LiveEntityRuntime
_visibleWorldEntitiesByGuid.Remove(serverGuid);
record.IsSpatiallyProjected = false;
record.IsSpatiallyVisible = false;
Exception? runtimeNotificationFailure = null;
if (spatialEdgeWasDeferred)
ProjectionVisibilityChanged?.Invoke(record, false);
{
try
{
PublishProjectionVisibilityChanged(record, false);
}
catch (Exception error)
{
runtimeNotificationFailure = error;
}
}
ThrowAfterCommittedProjectionChange(
serverGuid,
spatialNotificationFailure,
runtimeNotificationFailure);
return true;
}
@ -479,31 +610,66 @@ public sealed class LiveEntityRuntime
bool isLocalPlayer,
Action? beforeTeardown = null)
{
if (_isRegisteringResources)
{
throw new InvalidOperationException(
"A live entity cannot unregister from inside atomic resource registration.");
}
if (!_inbound.TryDelete(delete, isLocalPlayer))
return false;
AdvanceLifetimeMutation(delete.Guid);
ParentAttachments.DeleteGeneration(delete.Guid, delete.InstanceSequence);
List<Exception>? failures = null;
try
{
beforeTeardown?.Invoke();
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
// 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);
if (_recordsByGuid.Remove(delete.Guid, out LiveEntityRecord? record))
List<Exception>? failures = null;
_logicalTeardownDepth++;
try
{
try
{
TearDownRecord(record);
beforeTeardown?.Invoke();
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
if (record is not null)
{
try
{
TearDownRecord(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 behind.
if (!_recordsByGuid.ContainsKey(delete.Guid))
{
_spatial.ForgetLiveEntity(delete.Guid);
}
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
if (failures is not null)
@ -905,26 +1071,54 @@ public sealed class LiveEntityRuntime
public void Clear()
{
List<Exception>? failures = null;
foreach (LiveEntityRecord record in _recordsByGuid.Values.ToArray())
if (_isClearing || _isRegisteringResources)
{
try
{
TearDownRecord(record);
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
throw new InvalidOperationException(
_isClearing
? "Live entity session teardown is already in progress."
: "Live entity session teardown cannot begin inside atomic resource registration.");
}
_isClearing = true;
_sessionLifetimeVersion++;
_lifetimeMutationVersionByGuid.Clear();
List<Exception>? failures = null;
try
{
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);
try
{
TearDownRecord(record);
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
}
_recordsByGuid.Clear();
_materializedWorldEntitiesByGuid.Clear();
_visibleWorldEntitiesByGuid.Clear();
_guidByLocalId.Clear();
_animationsByLocalId.Clear();
_remoteMotionByGuid.Clear();
ParentAttachments.Clear();
_inbound.Clear();
_spatial.ClearLiveEntityLifetimeState();
}
finally
{
_isClearing = false;
}
_recordsByGuid.Clear();
_materializedWorldEntitiesByGuid.Clear();
_visibleWorldEntitiesByGuid.Clear();
_guidByLocalId.Clear();
_animationsByLocalId.Clear();
_remoteMotionByGuid.Clear();
ParentAttachments.Clear();
_inbound.Clear();
if (failures is not null)
throw new AggregateException("One or more live entities failed session teardown.", failures);
@ -941,6 +1135,49 @@ public sealed class LiveEntityRuntime
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))
@ -968,6 +1205,13 @@ public sealed class LiveEntityRuntime
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.IsLiveEntityVisible(serverGuid) != visible)
return;
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|| record.WorldEntity is not { } entity)
return;
@ -976,7 +1220,32 @@ public sealed class LiveEntityRuntime
record.IsSpatiallyVisible = visible;
RefreshPresentation(record);
if (_rebucketingGuid != serverGuid && wasVisible != visible)
ProjectionVisibilityChanged?.Invoke(record, visible);
PublishProjectionVisibilityChanged(record, visible);
}
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)
@ -1017,16 +1286,32 @@ public sealed class LiveEntityRuntime
if (record.WorldEntity is { } entity)
{
RunCleanup(() => _spatial.RemoveLiveEntityProjection(record.ServerGuid));
RunCleanup(() => _spatial.RemoveLiveEntityProjection(entity));
if (record.ResourcesRegistered)
RunCleanup(() => _resources.Unregister(entity));
_guidByLocalId.Remove(entity.Id);
_materializedWorldEntitiesByGuid.Remove(record.ServerGuid);
_visibleWorldEntitiesByGuid.Remove(record.ServerGuid);
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);
}
_remoteMotionByGuid.Remove(record.ServerGuid);
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;