refactor(app): key live projections by runtime identity

Move materialized live-object sidecars and presentation worksets to exact RuntimeEntityKey ownership. Runtime remains the only GUID/incarnation/local-ID authority while hydration, animation, effects, lights, equipped children, renderer resources, visibility, liveness, and teardown resolve exact projection identities. Preserve synchronous callbacks, local-ID allocation order, and current rendering behavior.
This commit is contained in:
Erik 2026-07-25 21:50:58 +02:00
parent e18df84437
commit 420e5eea70
73 changed files with 2939 additions and 1715 deletions

View file

@ -73,8 +73,10 @@ internal sealed class CombatAttackTargetSource : ICombatAttackTargetSource
}
(uint Guid, float DistanceSquared)? best = null;
foreach ((uint guid, WorldEntity entity) in _liveEntities.WorldEntities)
foreach (LiveEntityRecord record in _liveEntities.VisibleRecords)
{
uint guid = record.ServerGuid;
WorldEntity entity = record.WorldEntity!;
if (!IsHostileMonster(guid))
continue;

View file

@ -349,7 +349,19 @@ internal sealed class LivePresentationCompositionPhase
new List<CompositeLiveEntityResourceLifecycle.Owner>
{
new(
entity => _ = entitySpawnAdapter.OnCreate(entity),
entity =>
{
if (liveEntities is null
|| !liveEntities.TryGetRecordByLocalEntityId(
entity.Id,
out LiveEntityRecord record)
|| record.ProjectionKey is not { } key)
{
throw new InvalidOperationException(
"Live presentation registration requires an exact Runtime projection key.");
}
_ = entitySpawnAdapter.OnCreate(key, entity);
},
entity => _ = entitySpawnAdapter.OnRemove(entity)),
new(
entityScriptActivator.OnCreate,
@ -688,7 +700,7 @@ internal sealed class LivePresentationCompositionPhase
});
var radarSnapshotProvider = new RadarSnapshotProvider(
d.Objects,
() => liveEntities.WorldEntities,
liveEntities,
() => liveEntities.Snapshots,
playerGuid: () => d.PlayerIdentity.ServerGuid,
playerYawRadians: () => d.PlayerController.Controller?.Yaw ?? 0f,
@ -696,7 +708,6 @@ internal sealed class LivePresentationCompositionPhase
selectedGuid: () => d.Selection.SelectedObjectId,
coordinatesOnRadar: () => d.Settings.Gameplay.CoordinatesOnRadar,
uiLocked: () => d.Settings.Gameplay.LockUI,
playerEntities: () => liveEntities.MaterializedWorldEntities,
spatialQuery: () => worldState);
bindings.Adopt(
"radar snapshot",

View file

@ -61,7 +61,7 @@ internal sealed class LivePresentationRuntimeBindings : IDisposable
public void BindProjectionRemoved(
EquippedChildRenderController source,
Action<uint> handler)
Action<LiveEntityRecord> handler)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(handler);

View file

@ -335,6 +335,11 @@ internal sealed class SessionPlayerCompositionPhase
live.WorldAvailability,
d.Selection,
live.WorldState,
guid => live.LiveEntities.TryGetLocalEntityId(
guid,
out uint localEntityId)
? localEntityId
: null,
content.Audio?.Engine);
var compositeWarmupSource =
new CompositeWarmupEntitySource(live.WorldState);

View file

@ -39,7 +39,7 @@ internal sealed class LocalPlayerAnimationController
output.Reset();
uint playerGuid = _identity.ServerGuid;
if (!_liveEntities.MaterializedWorldEntities.TryGetValue(playerGuid, out var entity)
if (!_liveEntities.TryGetWorldEntity(playerGuid, out var entity)
|| !_animations.TryGetValue(entity.Id, out var animation)
|| animation.Sequencer is not { } sequencer
|| !_liveEntities.ShouldAdvanceRootRuntime(playerGuid)
@ -65,7 +65,7 @@ internal sealed class LocalPlayerAnimationController
public void CaptureHooks()
{
uint playerGuid = _identity.ServerGuid;
if (_liveEntities.MaterializedWorldEntities.TryGetValue(playerGuid, out var entity)
if (_liveEntities.TryGetWorldEntity(playerGuid, out var entity)
&& _animations.TryGetValue(entity.Id, out var animation)
&& animation.Sequencer is { } sequencer)
{

View file

@ -81,7 +81,7 @@ internal sealed class LivePlayerModeAutoEntryContext
public bool IsLiveInWorld => _session.IsInWorld;
public bool IsPlayerEntityPresent =>
_liveEntities.MaterializedWorldEntities.ContainsKey(_identity.ServerGuid);
_liveEntities.ContainsWorldEntity(_identity.ServerGuid);
public bool IsPlayerControllerReady => true;

View file

@ -214,7 +214,7 @@ internal sealed class PlayerModeController :
private bool TryEnter(string loggingTag)
{
uint playerGuid = _identity.ServerGuid;
if (!_liveEntities.MaterializedWorldEntities.TryGetValue(
if (!_liveEntities.TryGetWorldEntity(
playerGuid,
out WorldEntity? playerEntity))
{

View file

@ -239,8 +239,10 @@ internal sealed class WorldSelectionQuery
return null;
ClosestCombatTarget? best = null;
foreach ((uint guid, WorldEntity entity) in _liveEntities.WorldEntities)
foreach (LiveEntityRecord record in _liveEntities.VisibleRecords)
{
uint guid = record.ServerGuid;
WorldEntity entity = record.WorldEntity!;
if (!IsHostileMonster(guid))
continue;
float distanceSquared = Vector3.DistanceSquared(entity.Position, player.Position);

View file

@ -22,11 +22,6 @@ internal sealed class LiveEntityMotionRuntimeController
private readonly SelectionState _selection;
private readonly LiveWorldOriginState _origin;
private IReadOnlyDictionary<uint, WorldEntity> _entitiesByServerGuid =>
_liveEntities.MaterializedWorldEntities;
private IReadOnlyDictionary<uint, WorldEntity> _visibleEntitiesByServerGuid =>
_liveEntities.WorldEntities;
public LiveEntityMotionRuntimeController(
LiveEntityRuntime liveEntities,
PhysicsDataCache physicsDataCache,
@ -101,7 +96,7 @@ internal sealed class LiveEntityMotionRuntimeController
// (own side) and StickyManager::adjust_offset's own-radius gap term.
// Replaces the R4 `() => 0f` pins ("setup cylsphere radius lands with
// R5-V3").
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var selfEntity))
if (!_liveEntities.TryGetWorldEntity(serverGuid, out var selfEntity))
return rm.Sink;
// R5-V2: forward-declared so the MoveToManager's target seams route
// into the entity's TargetManager (retail CPhysicsObj::set_target →
@ -237,7 +232,7 @@ internal sealed class LiveEntityMotionRuntimeController
{
Console.WriteLine(
$"[autowalk-host-miss] object=0x{id:X8} "
+ $"materialized={_entitiesByServerGuid.ContainsKey(id)} "
+ $"materialized={_liveEntities.ContainsWorldEntity(id)} "
+ $"registered={liveEntities.TryGetPhysicsHost(id, out _)} "
+ $"hidden={liveEntities.IsHidden(id)}");
}
@ -454,7 +449,9 @@ internal sealed class LiveEntityMotionRuntimeController
string target = turnPath.TargetGuid is { } targetGuid
? $"0x{targetGuid:X8}" : "null";
bool targetVisible = turnPath.TargetGuid is { } visibleGuid
&& _visibleEntitiesByServerGuid.ContainsKey(visibleGuid);
&& _liveEntities.TryGetInteractionEligibleEntity(
visibleGuid,
out _);
bool targetHost = turnPath.TargetGuid is { } hostGuid
&& _liveEntities?.TryGetPhysicsHost(hostGuid, out _) == true;
var moveTo = movement.MoveTo;

View file

@ -65,11 +65,6 @@ internal sealed class LiveEntityNetworkUpdateController
private EntityPhysicsHost? _playerHost => _playerHostSource.Host;
private uint _playerServerGuid => _playerIdentity.ServerGuid;
private double _physicsScriptGameTime => _gameTime.CurrentScriptTime;
private IReadOnlyDictionary<uint, WorldEntity> _entitiesByServerGuid =>
_liveEntities.MaterializedWorldEntities;
private IReadOnlyDictionary<uint, WorldEntity> _visibleEntitiesByServerGuid =>
_liveEntities.WorldEntities;
internal uint? LastLivePlayerLandblockId =>
_authorityGate.LastLivePlayerLandblockId;
@ -262,7 +257,7 @@ internal sealed class LiveEntityNetworkUpdateController
ulong acceptedMovementVelocityAuthorityVersion =
accepted.VelocityAuthorityVersion;
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
if (!_liveEntities.TryGetWorldEntity(update.Guid, out var entity)) return;
if (!_animatedEntities.TryGetValue(entity.Id, out var ae))
{
DispatchRemoteInboundMotion(
@ -566,8 +561,14 @@ internal sealed class LiveEntityNetworkUpdateController
// window from this transition. Without this, the entity
// starts its run animation and is instantly interrupted.
var refreshedTime = System.DateTime.UtcNow;
if (_remoteMovementObservations.TryGetValue(update.Guid, out var prev))
_remoteMovementObservations[update.Guid] = (prev.Pos, refreshedTime);
if (acceptedMotionRecord.ProjectionKey is { } motionKey
&& _remoteMovementObservations.TryGetValue(
motionKey,
out var prev))
{
_remoteMovementObservations[motionKey] =
(prev.Pos, refreshedTime);
}
if (_remoteDeadReckon.TryGetValue(update.Guid, out var dr))
dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds;
}
@ -817,7 +818,7 @@ internal sealed class LiveEntityNetworkUpdateController
ulong acceptedVectorAuthorityVersion,
ulong acceptedVectorVelocityAuthorityVersion)
{
if (!_entitiesByServerGuid.ContainsKey(update.Guid)) return;
if (!_liveEntities.ContainsWorldEntity(update.Guid)) return;
if (update.Guid == _playerServerGuid) return; // local jump uses our own physics
if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rm)) return;
@ -863,7 +864,7 @@ internal sealed class LiveEntityNetworkUpdateController
// state-derived velocity write (adaptation note: retail's
// equivalence comes from the per-tick transition-sweep order —
// R6 scope).
if (_entitiesByServerGuid.TryGetValue(update.Guid, out var ent)
if (_liveEntities.TryGetWorldEntity(update.Guid, out var ent)
&& _animatedEntities.TryGetValue(ent.Id, out var ae)
&& ae.Sequencer is not null)
{
@ -940,12 +941,12 @@ internal sealed class LiveEntityNetworkUpdateController
if (parsed.Guid == _playerServerGuid)
_playerController?.ApplyPhysicsState(record.FinalPhysicsState);
if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return;
if (!_liveEntities.TryGetWorldEntity(parsed.Guid, out var entity)) return;
// L.2g slice 1c (2026-05-13): the server addresses entities by
// ServerGuid (parsed.Guid, e.g. 0x7A9B4015), but
// ShadowObjectRegistry's cell index is keyed by local entity.Id
// (e.g. 0x000F4245). Translate via _entitiesByServerGuid before
// (e.g. 0x000F4245). Translate through the canonical Runtime directory before
// mutating the registry — otherwise the lookup misses and the
// state flip silently no-ops, leaving doors blocked even though
// ACE flipped the ETHEREAL bit.
@ -1035,7 +1036,7 @@ internal sealed class LiveEntityNetworkUpdateController
_playerController)))
return;
if (!_entitiesByServerGuid.ContainsKey(update.Guid))
if (!_liveEntities.ContainsWorldEntity(update.Guid))
{
if (!IsCurrentPositionOwner())
return;
@ -1068,7 +1069,7 @@ internal sealed class LiveEntityNetworkUpdateController
}
}
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
if (!_liveEntities.TryGetWorldEntity(update.Guid, out var entity)) return;
if (!IsCurrentPositionOwner(entity))
return;
_entityEffects?.MarkLiveOwnerPoseDirty(update.Guid);
@ -1223,16 +1224,20 @@ internal sealed class LiveEntityNetworkUpdateController
if (update.Guid != _playerServerGuid)
{
var now = System.DateTime.UtcNow;
if (_remoteMovementObservations.TryGetValue(update.Guid, out var prev))
RuntimeEntityKey positionKey = positionRecord.ProjectionKey
?? throw new InvalidOperationException(
$"Position owner 0x{update.Guid:X8}/" +
$"{positionRecord.Generation} has no exact projection key.");
if (_remoteMovementObservations.TryGetValue(positionKey, out var prev))
{
float moveDist = System.Numerics.Vector3.Distance(prev.Pos, worldPos);
if (moveDist > 0.05f)
_remoteMovementObservations[update.Guid] = (worldPos, now);
_remoteMovementObservations[positionKey] = (worldPos, now);
// else: leave old entry so "Time" = last real movement time
}
else
{
_remoteMovementObservations[update.Guid] = (worldPos, now);
_remoteMovementObservations[positionKey] = (worldPos, now);
}
// Retail-faithful hard-snap on UpdatePosition.

View file

@ -1,4 +1,5 @@
using System.Numerics;
using AcDream.Runtime.Entities;
namespace AcDream.App.Physics;
@ -9,19 +10,20 @@ namespace AcDream.App.Physics;
/// </summary>
internal sealed class RemoteMovementObservationTracker
{
private readonly Dictionary<uint, (Vector3 Pos, DateTime Time)> _lastMove = new();
private readonly Dictionary<RuntimeEntityKey, (Vector3 Pos, DateTime Time)>
_lastMove = [];
internal (Vector3 Pos, DateTime Time) this[uint serverGuid]
internal (Vector3 Pos, DateTime Time) this[RuntimeEntityKey key]
{
set => _lastMove[serverGuid] = value;
set => _lastMove[key] = value;
}
internal bool TryGetValue(
uint serverGuid,
RuntimeEntityKey key,
out (Vector3 Pos, DateTime Time) observation) =>
_lastMove.TryGetValue(serverGuid, out observation);
_lastMove.TryGetValue(key, out observation);
internal bool Remove(uint serverGuid) => _lastMove.Remove(serverGuid);
internal bool Remove(RuntimeEntityKey key) => _lastMove.Remove(key);
internal void Clear() => _lastMove.Clear();
}

View file

@ -3,6 +3,7 @@ using AcDream.App.Rendering;
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
namespace AcDream.App.Physics;
@ -32,7 +33,7 @@ internal sealed class RemoteTeleportController : IDisposable
private readonly Action<uint, ushort, bool> _completeAuthoritativePlacement;
private readonly Action<uint, ushort> _beginAuthoritativePlacement;
private readonly PlacementResolver _resolvePlacement;
private readonly Dictionary<uint, PendingPlacement> _pending = new();
private readonly Dictionary<RuntimeEntityKey, PendingPlacement> _pending = new();
internal RemoteTeleportController(
PhysicsEngine physics,
@ -178,9 +179,9 @@ internal sealed class RemoteTeleportController : IDisposable
bool wasInContact = liveRecord.PhysicsBody.InContact;
bool wasOnWalkable = liveRecord.PhysicsBody.OnWalkable;
RollbackPlacement rollback = CaptureRollback(remote, liveRecord.PhysicsBody);
if (_pending.TryGetValue(entity.ServerGuid, out PendingPlacement prior)
&& ReferenceEquals(prior.Record, liveRecord)
&& prior.Generation == generation)
RuntimeEntityKey key = RequireProjectionKey(liveRecord);
if (_pending.TryGetValue(key, out PendingPlacement prior)
&& ReferenceEquals(prior.Record, liveRecord))
{
wasInContact = prior.WasInContact;
wasOnWalkable = prior.WasOnWalkable;
@ -219,7 +220,7 @@ internal sealed class RemoteTeleportController : IDisposable
return Superseded(request);
if (!placement.Ok)
{
_pending.Remove(entity.ServerGuid);
_pending.Remove(key);
if (!RollBackDeferredPlacement(request))
return Superseded(request);
return new Result(
@ -230,24 +231,18 @@ internal sealed class RemoteTeleportController : IDisposable
request.Body.Orientation);
}
_pending.Remove(entity.ServerGuid);
_pending.Remove(key);
return CommitResolved(request, placement);
}
internal void Forget(uint serverGuid)
{
_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)))
if (record.ProjectionKey is { } key
&& _pending.TryGetValue(key, out PendingPlacement pending)
&& ReferenceEquals(pending.Record, record))
{
_pending.Remove(record.ServerGuid);
_pending.Remove(key);
}
}
@ -256,7 +251,10 @@ internal sealed class RemoteTeleportController : IDisposable
_pending.Clear();
}
internal bool HasPending(uint serverGuid) => _pending.ContainsKey(serverGuid);
internal bool HasPending(uint serverGuid) =>
_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
&& record.ProjectionKey is { } key
&& _pending.ContainsKey(key);
internal int PendingPlacementCount => _pending.Count;
/// <summary>
@ -404,7 +402,7 @@ internal sealed class RemoteTeleportController : IDisposable
request.Entity.Rotation = request.RequestedOrientation;
if (!IsCurrent(request))
return false;
_pending[request.Entity.ServerGuid] = request;
_pending[RequireProjectionKey(request.Record)] = request;
return true;
}
@ -413,14 +411,15 @@ internal sealed class RemoteTeleportController : IDisposable
if (!visible)
return;
if (_pending.TryGetValue(record.ServerGuid, out PendingPlacement pending))
RuntimeEntityKey key = RequireProjectionKey(record);
if (_pending.TryGetValue(key, out PendingPlacement pending))
{
if (record.WorldEntity is null
|| !ReferenceEquals(record, pending.Record)
|| !ReferenceEquals(record.WorldEntity, pending.Entity)
|| record.Generation != pending.Generation)
{
_pending.Remove(record.ServerGuid);
_pending.Remove(key);
return;
}
if (record.RemoteMotionRuntime is not ILiveEntityRemotePlacementRuntime currentRemote
@ -428,7 +427,7 @@ internal sealed class RemoteTeleportController : IDisposable
|| !ReferenceEquals(record.PhysicsBody, pending.Body)
|| !ReferenceEquals(currentRemote.Body, record.PhysicsBody))
{
_pending.Remove(record.ServerGuid);
_pending.Remove(key);
if (record.RemoteMotionRuntime is not null
&& (record.PhysicsBody is null
|| !ReferenceEquals(record.RemoteMotionRuntime.Body, record.PhysicsBody)))
@ -441,7 +440,7 @@ internal sealed class RemoteTeleportController : IDisposable
if (!ReferenceEquals(currentRemote, pending.Remote))
{
pending = pending with { Remote = currentRemote };
_pending[record.ServerGuid] = pending;
_pending[key] = pending;
}
if (!_liveEntities.IsCurrentPositionAuthority(
pending.Record,
@ -454,7 +453,7 @@ internal sealed class RemoteTeleportController : IDisposable
return;
}
_pending.Remove(record.ServerGuid);
_pending.Remove(key);
ResolveResult placement = Resolve(pending);
if (!IsCurrent(pending))
return;
@ -588,4 +587,11 @@ internal sealed class RemoteTeleportController : IDisposable
private static bool IsPlayerGuid(uint guid) =>
(guid & 0xFF000000u) == 0x50000000u;
private static RuntimeEntityKey RequireProjectionKey(
LiveEntityRecord record) =>
record.ProjectionKey
?? throw new InvalidOperationException(
$"Live entity 0x{record.ServerGuid:X8}/{record.Generation} " +
"has no exact projection key.");
}

View file

@ -7,24 +7,25 @@ namespace AcDream.App.Rendering;
/// those projections only after traversal, so dictionary enumeration remains
/// stable.
/// </summary>
internal sealed class AttachmentUpdateOrder<TChild>
internal sealed class AttachmentUpdateOrder<TKey, TChild>
where TKey : struct
{
private readonly HashSet<uint> _visiting = new();
private readonly HashSet<uint> _completed = new();
private readonly HashSet<uint> _failedSet = new();
private readonly List<uint> _failed = new();
private readonly List<uint> _subtree = new();
private readonly Queue<uint> _recoveryQueue = new();
private readonly HashSet<uint> _recoveryVisited = new();
private readonly HashSet<TKey> _visiting = new();
private readonly HashSet<TKey> _completed = new();
private readonly HashSet<TKey> _failedSet = new();
private readonly List<TKey> _failed = new();
private readonly List<TKey> _subtree = new();
private readonly Queue<TKey> _recoveryQueue = new();
private readonly HashSet<TKey> _recoveryVisited = new();
/// <summary>
/// Updates the current attachment map without per-frame delegate or
/// enumerator boxing. The returned list is borrowed until the next call.
/// </summary>
public IReadOnlyList<uint> ForEachParentFirst(
Dictionary<uint, TChild> children,
Func<TChild, uint?> parentOf,
Func<uint, bool> update)
public IReadOnlyList<TKey> ForEachParentFirst(
Dictionary<TKey, TChild> children,
Func<TChild, TKey?> parentOf,
Func<TKey, bool> update)
{
ArgumentNullException.ThrowIfNull(children);
ArgumentNullException.ThrowIfNull(parentOf);
@ -34,7 +35,7 @@ internal sealed class AttachmentUpdateOrder<TChild>
_completed.Clear();
_failedSet.Clear();
_failed.Clear();
foreach (uint childId in children.Keys)
foreach (TKey childId in children.Keys)
Visit(childId, children, parentOf, update);
return _failed;
}
@ -44,10 +45,10 @@ internal sealed class AttachmentUpdateOrder<TChild>
/// withdrawn without leaving a child rooted at a stale intermediate pose.
/// The returned list is borrowed until the next call.
/// </summary>
public IReadOnlyList<uint> CollectSubtreePostOrder(
Dictionary<uint, TChild> children,
uint rootId,
Func<TChild, uint?> parentOf)
public IReadOnlyList<TKey> CollectSubtreePostOrder(
Dictionary<TKey, TChild> children,
TKey rootId,
Func<TChild, TKey?> parentOf)
{
_subtree.Clear();
_visiting.Clear();
@ -61,9 +62,9 @@ internal sealed class AttachmentUpdateOrder<TChild>
/// so A→B→C recovers in one parent-first drain.
/// </summary>
public void RealizeDescendants(
uint parentId,
Func<uint, IReadOnlyList<uint>> childrenWaitingForParent,
Func<uint, bool> realize)
TKey parentId,
Func<TKey, IReadOnlyList<TKey>> childrenWaitingForParent,
Func<TKey, bool> realize)
{
ArgumentNullException.ThrowIfNull(childrenWaitingForParent);
ArgumentNullException.ThrowIfNull(realize);
@ -74,11 +75,11 @@ internal sealed class AttachmentUpdateOrder<TChild>
_recoveryVisited.Add(parentId);
while (_recoveryQueue.Count > 0)
{
uint currentParent = _recoveryQueue.Dequeue();
IReadOnlyList<uint> waiting = childrenWaitingForParent(currentParent);
TKey currentParent = _recoveryQueue.Dequeue();
IReadOnlyList<TKey> waiting = childrenWaitingForParent(currentParent);
for (int i = 0; i < waiting.Count; i++)
{
uint childId = waiting[i];
TKey childId = waiting[i];
if (realize(childId) && _recoveryVisited.Add(childId))
_recoveryQueue.Enqueue(childId);
}
@ -86,25 +87,26 @@ internal sealed class AttachmentUpdateOrder<TChild>
}
private void Collect(
uint rootId,
Dictionary<uint, TChild> children,
Func<TChild, uint?> parentOf)
TKey rootId,
Dictionary<TKey, TChild> children,
Func<TChild, TKey?> parentOf)
{
if (!_visiting.Add(rootId))
return;
foreach ((uint candidateId, TChild child) in children)
foreach ((TKey candidateId, TChild child) in children)
{
if (parentOf(child) == rootId)
if (parentOf(child) is { } parent
&& EqualityComparer<TKey>.Default.Equals(parent, rootId))
Collect(candidateId, children, parentOf);
}
_subtree.Add(rootId);
}
private void Visit(
uint childId,
Dictionary<uint, TChild> children,
Func<TChild, uint?> parentOf,
Func<uint, bool> update)
TKey childId,
Dictionary<TKey, TChild> children,
Func<TChild, TKey?> parentOf,
Func<TKey, bool> update)
{
if (_completed.Contains(childId)
|| !children.TryGetValue(childId, out TChild? child))
@ -114,7 +116,7 @@ internal sealed class AttachmentUpdateOrder<TChild>
if (!_visiting.Add(childId))
return;
uint? parentId = parentOf(child);
TKey? parentId = parentOf(child);
if (parentId is { } attachedParentId && children.ContainsKey(attachedParentId))
Visit(attachedParentId, children, parentOf, update);

View file

@ -11,6 +11,7 @@ using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Plugins;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
@ -116,13 +117,13 @@ internal sealed class DatLiveEntityProjectionMaterializer
}
public bool TryMaterialize(
LiveEntityRecord expectedRecord,
RuntimeEntityRecord expectedCanonical,
WorldSession.EntitySpawn canonicalSpawn,
LiveProjectionPurpose purpose,
ulong expectedCreateIntegrationVersion,
LiveEntityAppearanceUpdateState? appearanceUpdate = null)
{
ArgumentNullException.ThrowIfNull(expectedRecord);
ArgumentNullException.ThrowIfNull(expectedCanonical);
if (purpose is LiveProjectionPurpose.AppearanceMutation
!= (appearanceUpdate is not null))
{
@ -131,13 +132,18 @@ internal sealed class DatLiveEntityProjectionMaterializer
nameof(appearanceUpdate));
}
if (!_runtime.IsCurrentCreateIntegration(
expectedRecord,
expectedCanonical,
expectedCreateIntegrationVersion)
|| canonicalSpawn.Guid != expectedRecord.ServerGuid
|| canonicalSpawn.InstanceSequence != expectedRecord.Generation)
|| canonicalSpawn.Guid != expectedCanonical.ServerGuid
|| canonicalSpawn.InstanceSequence != expectedCanonical.Incarnation)
{
return false;
}
_runtime.TryGetProjection(
expectedCanonical,
out LiveEntityRecord? expectedRecord);
if (appearanceUpdate is not null && expectedRecord is null)
return false;
_received++;
bool dumpLiveSpawns = _options.DumpLiveSpawns;
@ -186,7 +192,7 @@ internal sealed class DatLiveEntityProjectionMaterializer
return false;
}
expectedRecord.HasPartArray = true;
_runtime.SetHasPartArray(expectedCanonical, true);
ushort? stanceOverride = canonicalSpawn.MotionState?.Stance;
ushort? commandOverride = canonicalSpawn.MotionState?.ForwardCommand;
var idleCycle = MotionResolver.GetIdleCycle(
@ -327,7 +333,8 @@ internal sealed class DatLiveEntityProjectionMaterializer
bool supersessionRecovery =
purpose is LiveProjectionPurpose.CreateSupersessionRecovery;
if (supersessionRecovery && expectedRecord.WorldEntity is not null)
if (supersessionRecovery
&& expectedRecord?.WorldEntity is not null)
{
return LiveEntityCreateSupersessionRecovery.TryApply(
_runtime,
@ -380,7 +387,7 @@ internal sealed class DatLiveEntityProjectionMaterializer
if (appearanceUpdate is { } visualUpdate)
{
return ApplyAppearance(
expectedRecord,
expectedRecord!,
canonicalSpawn,
visualUpdate,
setup,
@ -398,6 +405,7 @@ internal sealed class DatLiveEntityProjectionMaterializer
}
return MaterializeProjection(
expectedCanonical,
expectedRecord,
canonicalSpawn,
setup,
@ -670,7 +678,8 @@ internal sealed class DatLiveEntityProjectionMaterializer
}
private bool MaterializeProjection(
LiveEntityRecord expectedRecord,
RuntimeEntityRecord expectedCanonical,
LiveEntityRecord? retainedRecord,
WorldSession.EntitySpawn spawn,
Setup setup,
MotionResolver.IdleCycle? idleCycle,
@ -690,17 +699,18 @@ internal sealed class DatLiveEntityProjectionMaterializer
ulong expectedCreateIntegrationVersion,
bool synchronizeAnimation)
{
if (!_runtime.TryGetEffectProfile(spawn.Guid, out _))
EntityEffectProfile profile = spawn.Physics is { } physics
? EntityEffectProfile.CreateLive(setup, physics)
: EntityEffectProfile.CreateDatStatic(setup);
if (retainedRecord is not null
&& !_runtime.TryGetEffectProfile(spawn.Guid, out _))
{
EntityEffectProfile profile = spawn.Physics is { } physics
? EntityEffectProfile.CreateLive(setup, physics)
: EntityEffectProfile.CreateDatStatic(setup);
_runtime.SetEffectProfile(spawn.Guid, profile);
}
bool createdProjection = false;
WorldEntity? entity = _runtime.MaterializeLiveEntity(
spawn.Guid,
expectedCanonical,
spawn.Position!.Value.LandblockId,
localId =>
{
@ -721,8 +731,12 @@ internal sealed class DatLiveEntityProjectionMaterializer
if (bounds.TryGet(out Vector3 minimum, out Vector3 maximum))
created.SetLocalBounds(minimum, maximum);
return created;
});
},
LiveEntityProjectionKind.World,
initializeProjection: record => record.EffectProfile = profile,
out LiveEntityRecord? expectedRecord);
if (entity is null
|| expectedRecord is null
|| !_runtime.IsCurrentCreateIntegration(
expectedRecord,
expectedCreateIntegrationVersion)
@ -828,7 +842,7 @@ internal sealed class DatLiveEntityProjectionMaterializer
if (dumpLiveSpawns && _received % 20 == 0)
{
Console.WriteLine(
$"live: animated={_runtime.AnimationRuntimes.Count} "
$"live: animated={_runtime.AnimationRuntimeCount} "
+ $"animReject: noCycle={_noCycle} fr0={_zeroFramerate} "
+ $"1frame={_singleFrame} partFrames={_missingPartFrames}");
Console.WriteLine(

View file

@ -41,25 +41,34 @@ public sealed class EquippedChildRenderController : IDisposable
/// <summary>Raised after an attached projection has its composed pose.</summary>
public event Action<uint>? ProjectionPoseReady;
/// <summary>Raised when an attached projection leaves its cell presentation.</summary>
public event Action<uint>? ProjectionRemoved;
private readonly Dictionary<uint, AttachedChild> _attachedByChild = new();
private readonly Dictionary<uint, PendingUnparentTransition> _pendingUnparentByChild = new();
private readonly Dictionary<uint, PendingOrdinaryRemoval> _pendingOrdinaryRemovalByRoot = new();
private readonly Dictionary<uint, PendingProjectionSubtree> _pendingDetachedRemovalByChild = new();
private readonly Dictionary<uint, PendingProjectionSubtree> _pendingReparentRemovalByChild = new();
private readonly Dictionary<uint, PendingProjectionSubtree> _pendingPoseLossRemovalByChild = new();
private readonly Dictionary<uint, PendingProjectionSubtree> _pendingOrphanRemovalByChild = new();
public event Action<LiveEntityRecord>? ProjectionRemoved;
private readonly Dictionary<RuntimeEntityKey, AttachedChild> _attachedByChild = [];
private readonly Dictionary<RuntimeEntityKey, PendingUnparentTransition>
_pendingUnparentByChild = [];
private readonly Dictionary<RuntimeEntityKey, PendingOrdinaryRemoval>
_pendingOrdinaryRemovalByRoot = [];
private readonly Dictionary<RuntimeEntityKey, PendingProjectionSubtree>
_pendingDetachedRemovalByChild = [];
private readonly Dictionary<RuntimeEntityKey, PendingProjectionSubtree>
_pendingReparentRemovalByChild = [];
private readonly Dictionary<RuntimeEntityKey, PendingProjectionSubtree>
_pendingPoseLossRemovalByChild = [];
private readonly Dictionary<RuntimeEntityKey, PendingProjectionSubtree>
_pendingOrphanRemovalByChild = [];
private readonly List<uint> _pendingProjectionChildrenScratch = new();
private readonly List<KeyValuePair<uint, PendingOrdinaryRemoval>>
private readonly List<KeyValuePair<RuntimeEntityKey, PendingOrdinaryRemoval>>
_pendingOrdinaryRemovalScratch = new();
private readonly List<KeyValuePair<uint, PendingProjectionSubtree>>
private readonly List<KeyValuePair<RuntimeEntityKey, PendingProjectionSubtree>>
_pendingProjectionSubtreeScratch = new();
private readonly List<KeyValuePair<uint, PendingUnparentTransition>>
private readonly List<KeyValuePair<RuntimeEntityKey, PendingUnparentTransition>>
_pendingUnparentScratch = new();
private readonly AttachmentUpdateOrder<AttachedChild> _updateOrder = new();
private readonly Func<AttachedChild, uint?> _parentOfAttached;
private readonly Func<uint, bool> _tickAttached;
private readonly Func<uint, bool> _reconcileAttached;
private readonly AttachmentUpdateOrder<RuntimeEntityKey, AttachedChild>
_updateOrder = new();
private readonly AttachmentUpdateOrder<uint, uint> _relationRecoveryOrder =
new();
private readonly Func<AttachedChild, RuntimeEntityKey?> _parentOfAttached;
private readonly Func<RuntimeEntityKey, bool> _tickAttached;
private readonly Func<RuntimeEntityKey, bool> _reconcileAttached;
private int _activePoseCompositionVisits;
internal int LastFullPoseCompositionVisits { get; private set; }
@ -92,7 +101,7 @@ public sealed class EquippedChildRenderController : IDisposable
_acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent));
_withdrawProjection = withdrawProjection
?? throw new ArgumentNullException(nameof(withdrawProjection));
_parentOfAttached = static child => child.ParentGuid;
_parentOfAttached = static child => child.ParentRecord.ProjectionKey;
_tickAttached = TickChild;
_reconcileAttached = ReconcileChild;
@ -258,11 +267,11 @@ public sealed class EquippedChildRenderController : IDisposable
{
if (!_liveEntities.TryGetRecord(childGuid, out LiveEntityRecord record))
{
_pendingUnparentByChild.Remove(childGuid);
Relations.EndChildProjection(childGuid);
return ChildUnparentDisposition.NotAttached;
}
RuntimeEntityKey key = RequireProjectionKey(record);
var pending = new PendingUnparentTransition(
record,
record.PositionAuthorityVersion,
@ -271,7 +280,7 @@ public sealed class EquippedChildRenderController : IDisposable
restoreRootRelation: false,
restoreDescendantRelations: true),
continuation);
return AdvanceUnparentTransition(childGuid, pending);
return AdvanceUnparentTransition(key, pending);
}
/// <summary>Recompose every child after the parent's animation tick.</summary>
@ -279,7 +288,7 @@ public sealed class EquippedChildRenderController : IDisposable
{
RetryPendingProjectionTransitions();
_activePoseCompositionVisits = 0;
IReadOnlyList<uint> failed = _updateOrder.ForEachParentFirst(
IReadOnlyList<RuntimeEntityKey> failed = _updateOrder.ForEachParentFirst(
_attachedByChild,
_parentOfAttached,
_tickAttached);
@ -297,7 +306,7 @@ public sealed class EquippedChildRenderController : IDisposable
{
RetryPendingProjectionTransitions();
_activePoseCompositionVisits = 0;
IReadOnlyList<uint> failed = _updateOrder.ForEachParentFirst(
IReadOnlyList<RuntimeEntityKey> failed = _updateOrder.ForEachParentFirst(
_attachedByChild,
_parentOfAttached,
_reconcileAttached);
@ -313,9 +322,9 @@ public sealed class EquippedChildRenderController : IDisposable
_pendingOrdinaryRemovalScratch);
for (int i = 0; i < _pendingOrdinaryRemovalScratch.Count; i++)
{
(uint rootGuid, PendingOrdinaryRemoval pending) =
(RuntimeEntityKey rootKey, PendingOrdinaryRemoval pending) =
_pendingOrdinaryRemovalScratch[i];
AdvanceOrdinaryRemoval(rootGuid, pending);
AdvanceOrdinaryRemoval(rootKey, pending);
}
CopyEntries(
@ -323,9 +332,9 @@ public sealed class EquippedChildRenderController : IDisposable
_pendingProjectionSubtreeScratch);
for (int i = 0; i < _pendingProjectionSubtreeScratch.Count; i++)
{
(uint childGuid, PendingProjectionSubtree pending) =
(RuntimeEntityKey childKey, PendingProjectionSubtree pending) =
_pendingProjectionSubtreeScratch[i];
AdvanceDetachedRemoval(childGuid, pending);
AdvanceDetachedRemoval(childKey, pending);
}
RetryProjectionSubtrees(_pendingReparentRemovalByChild);
@ -335,16 +344,16 @@ public sealed class EquippedChildRenderController : IDisposable
CopyEntries(_pendingUnparentByChild, _pendingUnparentScratch);
for (int i = 0; i < _pendingUnparentScratch.Count; i++)
{
(uint childGuid, PendingUnparentTransition pending) =
(RuntimeEntityKey childKey, PendingUnparentTransition pending) =
_pendingUnparentScratch[i];
if (!_liveEntities.IsCurrentRecord(pending.Record)
|| pending.Record.PositionAuthorityVersion
!= pending.PositionAuthorityVersion)
{
_pendingUnparentByChild.Remove(childGuid);
_pendingUnparentByChild.Remove(childKey);
continue;
}
AdvanceUnparentTransition(childGuid, pending);
AdvanceUnparentTransition(childKey, pending);
}
Relations.CopyPendingProjectionChildrenTo(_pendingProjectionChildrenScratch);
@ -353,17 +362,17 @@ public sealed class EquippedChildRenderController : IDisposable
}
private static void CopyEntries<T>(
Dictionary<uint, T> source,
List<KeyValuePair<uint, T>> destination)
Dictionary<RuntimeEntityKey, T> source,
List<KeyValuePair<RuntimeEntityKey, T>> destination)
{
destination.Clear();
foreach (KeyValuePair<uint, T> entry in source)
foreach (KeyValuePair<RuntimeEntityKey, T> entry in source)
destination.Add(entry);
}
private bool TickChild(uint childGuid)
private bool TickChild(RuntimeEntityKey childKey)
{
if (!_attachedByChild.TryGetValue(childGuid, out AttachedChild? child))
if (!_attachedByChild.TryGetValue(childKey, out AttachedChild? child))
return false;
_activePoseCompositionVisits++;
@ -403,15 +412,15 @@ public sealed class EquippedChildRenderController : IDisposable
return false;
}
private bool ReconcileChild(uint childGuid)
private bool ReconcileChild(RuntimeEntityKey childKey)
{
if (!_attachedByChild.TryGetValue(childGuid, out AttachedChild? child)
if (!_attachedByChild.TryGetValue(childKey, out AttachedChild? child)
|| !TryResolveExactAttachment(child, out WorldEntity parent))
{
return false;
}
return ParentPresentationMatches(child, parent) || TickChild(childGuid);
return ParentPresentationMatches(child, parent) || TickChild(childKey);
}
private bool ParentPresentationMatches(
@ -445,7 +454,9 @@ public sealed class EquippedChildRenderController : IDisposable
if (!_liveEntities.TryGetRecord(pending.ParentGuid, out LiveEntityRecord parentRecord)
|| parentRecord.WorldEntity is not { } parentEntity
|| !parentRecord.IsSpatiallyProjected
|| !_liveEntities.TryGetRecord(childGuid, out LiveEntityRecord childRecord)
|| !_liveEntities.TryGetCanonical(
childGuid,
out RuntimeEntityRecord childCanonical)
|| !_liveEntities.TryGetSnapshot(pending.ParentGuid, out WorldSession.EntitySpawn parentSpawn)
|| !_liveEntities.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn childSpawn))
return false;
@ -496,22 +507,25 @@ public sealed class EquippedChildRenderController : IDisposable
// hydration path sees it. Install its logical effect profile before
// MaterializeLiveEntity registers runtime resources, exactly as for a
// top-level projection; rebucketing/reattachment must never recreate it.
if (!_liveEntities.TryGetEffectProfile(childGuid, out _))
var effectProfile = childSpawn.Physics is { } physics
? Vfx.EntityEffectProfile.CreateLive(childSetup, physics)
: Vfx.EntityEffectProfile.CreateDatStatic(childSetup);
if (_liveEntities.TryGetProjection(
childCanonical,
out LiveEntityRecord? retainedChild)
&& !_liveEntities.TryGetEffectProfile(childGuid, out _))
{
var effectProfile = childSpawn.Physics is { } physics
? Vfx.EntityEffectProfile.CreateLive(childSetup, physics)
: Vfx.EntityEffectProfile.CreateDatStatic(childSetup);
_liveEntities.SetEffectProfile(childGuid, effectProfile);
}
if (!_liveEntities.IsCurrentRecord(parentRecord)
|| !_liveEntities.IsCurrentRecord(childRecord)
|| !_liveEntities.IsCurrentCanonical(childCanonical)
|| !Relations.IsPending(pending, candidateKind))
{
return false;
}
WorldEntity? entity = _liveEntities.MaterializeLiveEntity(
childGuid,
childCanonical,
parentCellId,
localId =>
{
@ -529,8 +543,10 @@ public sealed class EquippedChildRenderController : IDisposable
created.SetIndexedPartPoses(pose.PartLocal, childPartAvailability);
return created;
},
LiveEntityProjectionKind.Attached);
if (entity is null)
LiveEntityProjectionKind.Attached,
initializeProjection: record => record.EffectProfile = effectProfile,
out LiveEntityRecord? childRecord);
if (entity is null || childRecord is null)
return false;
if (!_liveEntities.IsCurrentRecord(parentRecord)
|| !_liveEntities.IsCurrentRecord(childRecord)
@ -576,8 +592,9 @@ public sealed class EquippedChildRenderController : IDisposable
scale,
entity);
CaptureParentPresentation(attached, parentEntity);
_attachedByChild[childGuid] = attached;
_pendingUnparentByChild.Remove(childGuid);
RuntimeEntityKey childKey = RequireProjectionKey(childRecord);
_attachedByChild[childKey] = attached;
_pendingUnparentByChild.Remove(childKey);
if ((parentRecord.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0)
{
// A child attached while its parent is already Hidden inherits
@ -809,14 +826,15 @@ public sealed class EquippedChildRenderController : IDisposable
ParentAttachmentRelation relation,
ParentProjectionCandidateKind candidateKind)
{
if (!_liveEntities.TryGetRecord(
if (!_liveEntities.TryGetCanonical(
relation.ChildGuid,
out LiveEntityRecord childRecord))
out RuntimeEntityRecord childCanonical))
{
return default;
}
ulong positionAuthorityVersion = childRecord.PositionAuthorityVersion;
ulong positionAuthorityVersion =
childCanonical.PositionAuthorityVersion;
if (candidateKind is ParentProjectionCandidateKind.Staged)
{
if (!_liveEntities.CommitStagedParent(relation, out _)
@ -833,9 +851,15 @@ public sealed class EquippedChildRenderController : IDisposable
if (!Relations.IsPending(relation, candidateKind)
|| !_liveEntities.CommitAcceptedParentCellless(
childRecord,
positionAuthorityVersion)
|| !WithdrawPriorProjection(childRecord))
childCanonical,
positionAuthorityVersion))
{
return default;
}
if (_liveEntities.TryGetProjection(
childCanonical,
out LiveEntityRecord? childRecord)
&& !WithdrawPriorProjection(childRecord))
{
return default;
}
@ -850,7 +874,7 @@ public sealed class EquippedChildRenderController : IDisposable
if (relation.ParentGuid == relation.ChildGuid)
return ParentProjectionValidationDisposition.Rejected;
if (!_liveEntities.TryGetRecord(relation.ParentGuid, out LiveEntityRecord parent)
|| !_liveEntities.TryGetRecord(relation.ChildGuid, out _)
|| !_liveEntities.TryGetCanonical(relation.ChildGuid, out _)
|| parent.WorldEntity is null
|| !parent.HasPartArray)
{
@ -877,7 +901,7 @@ public sealed class EquippedChildRenderController : IDisposable
private void RetryWaitingDescendants(uint parentGuid)
{
_updateOrder.RealizeDescendants(
_relationRecoveryOrder.RealizeDescendants(
parentGuid,
Relations.ChildrenWaitingForParent,
ResolveAndTryRealize);
@ -985,12 +1009,12 @@ public sealed class EquippedChildRenderController : IDisposable
}
private void AdvanceDetachedRemoval(
uint childGuid,
RuntimeEntityKey childKey,
PendingProjectionSubtree pending)
{
AdvanceProjectionSubtree(
_pendingDetachedRemovalByChild,
childGuid,
childKey,
pending);
}
@ -998,22 +1022,29 @@ public sealed class EquippedChildRenderController : IDisposable
{
if (item.CurrentlyEquippedLocation == EquipMask.None)
return;
if (_attachedByChild.TryGetValue(item.ObjectId, out AttachedChild? attached)
if (!_liveEntities.TryGetRecord(
item.ObjectId,
out LiveEntityRecord record)
|| record.ProjectionKey is not { } key)
{
return;
}
if (_attachedByChild.TryGetValue(key, out AttachedChild? attached)
&& _liveEntities.IsCurrentRecord(attached.ChildRecord)
&& attached.ChildRecord.IsSpatiallyProjected)
{
// A component-stage failure left the exact equipped projection
// untouched. The authoritative rollback therefore has nothing to
// reconstruct and merely cancels its retained leave-world retry.
_pendingDetachedRemovalByChild.Remove(item.ObjectId);
_pendingDetachedRemovalByChild.Remove(key);
return;
}
if (_pendingDetachedRemovalByChild.TryGetValue(
item.ObjectId,
key,
out PendingProjectionSubtree pending))
{
AdvanceDetachedRemoval(item.ObjectId, pending);
if (_pendingDetachedRemovalByChild.ContainsKey(item.ObjectId))
AdvanceDetachedRemoval(key, pending);
if (_pendingDetachedRemovalByChild.ContainsKey(key))
return;
}
@ -1032,7 +1063,11 @@ public sealed class EquippedChildRenderController : IDisposable
private bool Remove(uint childGuid)
{
if (!_attachedByChild.TryGetValue(childGuid, out AttachedChild? child))
if (!_liveEntities.TryGetRecord(
childGuid,
out LiveEntityRecord record)
|| record.ProjectionKey is not { } key
|| !_attachedByChild.TryGetValue(key, out AttachedChild? child))
return false;
if (!_liveEntities.IsCurrentRecord(child.ChildRecord))
return CommitProjectionRemoval(child);
@ -1062,13 +1097,14 @@ public sealed class EquippedChildRenderController : IDisposable
private bool WithdrawPriorProjection(LiveEntityRecord childRecord)
{
RuntimeEntityKey key = RequireProjectionKey(childRecord);
if (_pendingReparentRemovalByChild.TryGetValue(
childRecord.ServerGuid,
key,
out PendingProjectionSubtree pending))
{
return AdvanceProjectionSubtree(
_pendingReparentRemovalByChild,
childRecord.ServerGuid,
key,
pending);
}
return BeginProjectionSubtreeWithdrawal(
@ -1079,7 +1115,7 @@ public sealed class EquippedChildRenderController : IDisposable
}
private ChildUnparentDisposition AdvanceUnparentTransition(
uint childGuid,
RuntimeEntityKey childKey,
PendingUnparentTransition pending)
{
ExactProjectionWithdrawalOutcome outcome = AdvanceProjectionSubtree(
@ -1088,7 +1124,7 @@ public sealed class EquippedChildRenderController : IDisposable
if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Pending)
{
_pendingUnparentByChild[childGuid] = pending with { Subtree = next };
_pendingUnparentByChild[childKey] = pending with { Subtree = next };
if (outcome.Failure is not null)
throw outcome.Failure;
return ChildUnparentDisposition.Pending;
@ -1096,22 +1132,22 @@ public sealed class EquippedChildRenderController : IDisposable
if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Superseded)
{
_pendingUnparentByChild.Remove(childGuid);
_pendingUnparentByChild.Remove(childKey);
if (outcome.Failure is not null)
throw outcome.Failure;
return ChildUnparentDisposition.Superseded;
}
PendingUnparentTransition awaitingContinuation = pending with { Subtree = next };
_pendingUnparentByChild[childGuid] = awaitingContinuation;
_pendingUnparentByChild[childKey] = awaitingContinuation;
if (outcome.Failure is not null)
throw outcome.Failure;
Relations.EndChildProjection(childGuid);
Relations.EndChildProjection(pending.Record.ServerGuid);
try
{
awaitingContinuation.Continuation?.Invoke();
_pendingUnparentByChild.Remove(childGuid);
_pendingUnparentByChild.Remove(childKey);
return ChildUnparentDisposition.Completed;
}
catch
@ -1124,9 +1160,9 @@ public sealed class EquippedChildRenderController : IDisposable
&& awaitingContinuation.Record.ProjectionKind
is LiveEntityProjectionKind.World;
if (continuationCommitted)
_pendingUnparentByChild.Remove(childGuid);
_pendingUnparentByChild.Remove(childKey);
else
_pendingUnparentByChild[childGuid] = awaitingContinuation;
_pendingUnparentByChild[childKey] = awaitingContinuation;
throw;
}
}
@ -1165,20 +1201,23 @@ public sealed class EquippedChildRenderController : IDisposable
private bool CommitProjectionRemoval(AttachedChild child)
{
if (!_attachedByChild.TryGetValue(child.ChildGuid, out AttachedChild? current)
RuntimeEntityKey key = RequireProjectionKey(child.ChildRecord);
if (!_attachedByChild.TryGetValue(key, out AttachedChild? current)
|| !ReferenceEquals(current, child))
{
return false;
}
_attachedByChild.Remove(child.ChildGuid);
ProjectionRemoved?.Invoke(child.Entity.Id);
_attachedByChild.Remove(key);
ProjectionRemoved?.Invoke(child.ChildRecord);
return true;
}
private void WithdrawForPoseLoss(uint childGuid)
private void WithdrawForPoseLoss(RuntimeEntityKey childKey)
{
if (!_liveEntities.TryGetRecord(childGuid, out LiveEntityRecord record))
if (!_liveEntities.TryGetRecord(
childKey,
out LiveEntityRecord record))
return;
BeginProjectionSubtreeWithdrawal(
_pendingPoseLossRemovalByChild,
@ -1191,25 +1230,28 @@ public sealed class EquippedChildRenderController : IDisposable
{
if (_liveEntities.TryGetRecord(guid, out LiveEntityRecord record))
{
RuntimeEntityKey key = RequireProjectionKey(record);
List<AttachedRemovalCapture> captures = CaptureRecordSubtreeParentFirst(record);
AdvanceOrdinaryRemoval(
guid,
key,
new PendingOrdinaryRemoval(record, captures, NextIndex: 0));
return;
}
if (_attachedByChild.TryGetValue(guid, out AttachedChild? stale))
AttachedChild? stale = _attachedByChild.Values.FirstOrDefault(
candidate => candidate.ChildGuid == guid);
if (stale is not null)
CommitProjectionRemoval(stale);
Relations.EndChildProjection(guid);
}
private void AdvanceOrdinaryRemoval(
uint rootGuid,
RuntimeEntityKey rootKey,
PendingOrdinaryRemoval pending)
{
if (!_liveEntities.IsCurrentRecord(pending.RootRecord))
{
_pendingOrdinaryRemovalByRoot.Remove(rootGuid);
_pendingOrdinaryRemovalByRoot.Remove(rootKey);
return;
}
@ -1219,7 +1261,7 @@ public sealed class EquippedChildRenderController : IDisposable
ExactProjectionWithdrawalOutcome outcome = WithdrawCaptured(captured);
if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Pending)
{
_pendingOrdinaryRemovalByRoot[rootGuid] = pending with { NextIndex = i };
_pendingOrdinaryRemovalByRoot[rootKey] = pending with { NextIndex = i };
if (outcome.Failure is not null)
throw outcome.Failure;
return;
@ -1233,7 +1275,7 @@ public sealed class EquippedChildRenderController : IDisposable
if (outcome.Failure is not null)
{
_pendingOrdinaryRemovalByRoot[rootGuid] = pending with
_pendingOrdinaryRemovalByRoot[rootKey] = pending with
{
NextIndex = i + 1,
};
@ -1241,23 +1283,24 @@ public sealed class EquippedChildRenderController : IDisposable
}
}
_pendingOrdinaryRemovalByRoot.Remove(rootGuid);
Relations.EndChildProjection(rootGuid);
_pendingOrdinaryRemovalByRoot.Remove(rootKey);
Relations.EndChildProjection(pending.RootRecord.ServerGuid);
}
private void TearDownRecordProjections(LiveEntityRecord record)
{
if (_pendingUnparentByChild.TryGetValue(record.ServerGuid, out var pending)
RuntimeEntityKey key = RequireProjectionKey(record);
if (_pendingUnparentByChild.TryGetValue(key, out var pending)
&& ReferenceEquals(pending.Record, record))
{
_pendingUnparentByChild.Remove(record.ServerGuid);
_pendingUnparentByChild.Remove(key);
}
if (_pendingOrdinaryRemovalByRoot.TryGetValue(
record.ServerGuid,
key,
out PendingOrdinaryRemoval ordinary)
&& ReferenceEquals(ordinary.RootRecord, record))
{
_pendingOrdinaryRemovalByRoot.Remove(record.ServerGuid);
_pendingOrdinaryRemovalByRoot.Remove(key);
}
RemovePendingProjectionSubtree(_pendingDetachedRemovalByChild, record);
RemovePendingProjectionSubtree(_pendingReparentRemovalByChild, record);
@ -1293,7 +1336,8 @@ public sealed class EquippedChildRenderController : IDisposable
{
var result = new List<AttachedRemovalCapture>();
var visited = new HashSet<LiveEntityRecord>(ReferenceEqualityComparer.Instance);
if (_attachedByChild.TryGetValue(record.ServerGuid, out AttachedChild? root)
if (record.ProjectionKey is { } key
&& _attachedByChild.TryGetValue(key, out AttachedChild? root)
&& ReferenceEquals(root.ChildRecord, record))
{
result.Add(Capture(root));
@ -1327,8 +1371,10 @@ public sealed class EquippedChildRenderController : IDisposable
private ExactProjectionWithdrawalOutcome WithdrawCaptured(
AttachedRemovalCapture captured)
{
RuntimeEntityKey key = RequireProjectionKey(
captured.Attached.ChildRecord);
if (!_attachedByChild.TryGetValue(
captured.Attached.ChildGuid,
key,
out AttachedChild? current)
|| !ReferenceEquals(current, captured.Attached))
{
@ -1369,7 +1415,9 @@ public sealed class EquippedChildRenderController : IDisposable
{
if (!visited.Add(record))
return;
_attachedByChild.TryGetValue(record.ServerGuid, out AttachedChild? attached);
AttachedChild? attached = null;
if (record.ProjectionKey is { } key)
_attachedByChild.TryGetValue(key, out attached);
if (attached is not null && !ReferenceEquals(attached.ChildRecord, record))
attached = null;
if (record.IsSpatiallyProjected || attached is not null)
@ -1396,7 +1444,7 @@ public sealed class EquippedChildRenderController : IDisposable
}
private bool BeginProjectionSubtreeWithdrawal(
Dictionary<uint, PendingProjectionSubtree> pendingByRoot,
Dictionary<RuntimeEntityKey, PendingProjectionSubtree> pendingByRoot,
LiveEntityRecord root,
bool restoreRootRelation,
bool restoreDescendantRelations)
@ -1405,12 +1453,15 @@ public sealed class EquippedChildRenderController : IDisposable
root,
restoreRootRelation,
restoreDescendantRelations);
return AdvanceProjectionSubtree(pendingByRoot, root.ServerGuid, pending);
return AdvanceProjectionSubtree(
pendingByRoot,
RequireProjectionKey(root),
pending);
}
private bool AdvanceProjectionSubtree(
Dictionary<uint, PendingProjectionSubtree> pendingByRoot,
uint rootGuid,
Dictionary<RuntimeEntityKey, PendingProjectionSubtree> pendingByRoot,
RuntimeEntityKey rootKey,
PendingProjectionSubtree pending)
{
ExactProjectionWithdrawalOutcome outcome = AdvanceProjectionSubtree(
@ -1419,9 +1470,9 @@ public sealed class EquippedChildRenderController : IDisposable
bool retry = outcome.Disposition is ExactProjectionWithdrawalDisposition.Pending
|| (outcome.Failure is not null && next.NextIndex < next.Captures.Count);
if (retry)
pendingByRoot[rootGuid] = next;
pendingByRoot[rootKey] = next;
else
pendingByRoot.Remove(rootGuid);
pendingByRoot.Remove(rootKey);
if (outcome.Failure is not null)
throw outcome.Failure;
return outcome.Disposition is ExactProjectionWithdrawalDisposition.Completed
@ -1508,27 +1559,29 @@ public sealed class EquippedChildRenderController : IDisposable
}
private void RetryProjectionSubtrees(
Dictionary<uint, PendingProjectionSubtree> pendingByRoot)
Dictionary<RuntimeEntityKey, PendingProjectionSubtree> pendingByRoot)
{
CopyEntries(pendingByRoot, _pendingProjectionSubtreeScratch);
for (int i = 0; i < _pendingProjectionSubtreeScratch.Count; i++)
{
(uint rootGuid, PendingProjectionSubtree pending) =
(RuntimeEntityKey rootKey, PendingProjectionSubtree pending) =
_pendingProjectionSubtreeScratch[i];
AdvanceProjectionSubtree(pendingByRoot, rootGuid, pending);
AdvanceProjectionSubtree(pendingByRoot, rootKey, pending);
}
}
private static void RemovePendingProjectionSubtree(
Dictionary<uint, PendingProjectionSubtree> pendingByRoot,
Dictionary<RuntimeEntityKey, PendingProjectionSubtree> pendingByRoot,
LiveEntityRecord record)
{
uint[] keys = pendingByRoot
.Where(pair => ReferenceEquals(pair.Value.RootRecord, record))
.Select(pair => pair.Key)
.ToArray();
for (int i = 0; i < keys.Length; i++)
pendingByRoot.Remove(keys[i]);
if (record.ProjectionKey is { } key
&& pendingByRoot.TryGetValue(
key,
out PendingProjectionSubtree pending)
&& ReferenceEquals(pending.RootRecord, record))
{
pendingByRoot.Remove(key);
}
}
public void Clear()
@ -1606,6 +1659,13 @@ public sealed class EquippedChildRenderController : IDisposable
LiveEntityRecord RootRecord,
IReadOnlyList<AttachedRemovalCapture> Captures,
int NextIndex);
private static RuntimeEntityKey RequireProjectionKey(
LiveEntityRecord record) =>
record.ProjectionKey
?? throw new InvalidOperationException(
$"Live entity 0x{record.ServerGuid:X8}/{record.Generation} " +
"has no exact projection key.");
}
public enum ChildUnparentDisposition

View file

@ -524,15 +524,8 @@ public sealed class GameWindow :
/// projection, including live objects parked in pending landblocks;
/// visibility must never suppress authoritative mutation.
/// </summary>
private static readonly IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity> EmptyLiveEntityMap =
new Dictionary<uint, AcDream.Core.World.WorldEntity>();
private static readonly IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> EmptyLiveSpawnMap =
new Dictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn>();
private IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid =>
_liveEntities?.MaterializedWorldEntities ?? EmptyLiveEntityMap;
/// <summary>Visible-only view for radar, picking, status, and targets.</summary>
private IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity> _visibleEntitiesByServerGuid =>
_liveEntities?.WorldEntities ?? EmptyLiveEntityMap;
/// <summary>
/// Latest <see cref="AcDream.Core.Net.WorldSession.EntitySpawn"/> for each
/// guid. Captured before the renderability gate so no-position inventory /

View file

@ -3,6 +3,7 @@ using AcDream.App.Rendering.Vfx;
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
namespace AcDream.App.Rendering;
@ -84,7 +85,7 @@ internal sealed class LiveEntityAnimationPresenter
}
public void Present(
IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> schedules)
IReadOnlyDictionary<RuntimeEntityKey, LiveEntityAnimationSchedule> schedules)
{
ArgumentNullException.ThrowIfNull(schedules);
LiveEntityRuntime runtime = _liveEntities;
@ -107,7 +108,9 @@ internal sealed class LiveEntityAnimationPresenter
if (!TryGetCurrent(runtime, record, entity, animation))
continue;
schedules.TryGetValue(entity.Id, out LiveEntityAnimationSchedule schedule);
LiveEntityAnimationSchedule schedule = default;
if (record.ProjectionKey is { } key)
schedules.TryGetValue(key, out schedule);
bool hasOrdinarySchedule = IsCurrentSchedule(runtime, schedule, record, entity, animation);
IReadOnlyList<PartTransform>? sequenceFrames = hasOrdinarySchedule
? schedule.SequenceFrames

View file

@ -6,6 +6,7 @@ using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
using DatReaderWriter.Types;
using RemoteMotion = AcDream.App.Physics.RemoteMotion;
@ -27,7 +28,8 @@ internal sealed class LiveEntityAnimationScheduler
private readonly IEntityRootPosePublisher _rootPoses;
private readonly IAnimationHookCaptureSink _animationHooks;
private readonly List<LiveEntityRecord> _rootSnapshot = new();
private readonly Dictionary<uint, LiveEntityAnimationSchedule> _schedules = new();
private readonly Dictionary<RuntimeEntityKey, LiveEntityAnimationSchedule>
_schedules = [];
private readonly Frame _rootFrameScratch = new();
private readonly MotionDeltaFrame _rootDeltaScratch = new();
@ -59,7 +61,7 @@ internal sealed class LiveEntityAnimationScheduler
/// callback. The returned dictionary is reused and valid until the next
/// call.
/// </summary>
public IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> Tick(
public IReadOnlyDictionary<RuntimeEntityKey, LiveEntityAnimationSchedule> Tick(
float elapsedSeconds,
Vector3? playerPosition,
bool localHiddenPartPoseDirty,
@ -139,7 +141,11 @@ internal sealed class LiveEntityAnimationScheduler
IReadOnlyList<PartTransform>? ownedFrames = schedule.SequenceFrames is { } produced
? animation.CaptureScheduleFrames(produced)
: null;
_schedules[entity.Id] = schedule with
RuntimeEntityKey key = record.ProjectionKey
?? throw new InvalidOperationException(
$"Live entity 0x{record.ServerGuid:X8}/" +
$"{record.Generation} has no exact projection key.");
_schedules[key] = schedule with
{
SequenceFrames = ownedFrames,
Record = record,

View file

@ -1,6 +1,7 @@
using AcDream.App.Update;
using AcDream.App.World;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
namespace AcDream.App.Rendering.Scene;
@ -9,7 +10,7 @@ internal interface ILiveRenderProjectionSink : IRenderProjectionSyncPhase
bool OnEntityReady(LiveEntityReadyCandidate candidate);
void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible);
void OnProjectionPoseReady(uint serverGuid);
void OnProjectionRemoved(uint localEntityId);
void OnProjectionRemoved(LiveEntityRecord record);
void OnResourceUnregister(WorldEntity entity);
}
@ -25,7 +26,7 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
private readonly LiveEntityRuntime _runtime;
private readonly RenderProjectionJournal _journal;
private readonly IRenderTraversalOrderSource _traversalOrder;
private readonly Dictionary<uint, TrackedProjection> _byLocalId = [];
private readonly Dictionary<RuntimeEntityKey, TrackedProjection> _byKey = [];
private readonly List<LiveEntityRecord> _activeRootScratch = [];
private readonly List<TrackedProjection> _activeScratch = [];
@ -40,7 +41,7 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
?? throw new ArgumentNullException(nameof(traversalOrder));
}
public int ProjectionCount => _byLocalId.Count;
public int ProjectionCount => _byKey.Count;
public bool OnEntityReady(LiveEntityReadyCandidate candidate)
{
@ -62,7 +63,8 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
ArgumentNullException.ThrowIfNull(record);
if (!_runtime.IsCurrentRecord(record)
|| record.WorldEntity is not { } entity
|| !_byLocalId.ContainsKey(entity.Id))
|| record.ProjectionKey is not { } key
|| !_byKey.ContainsKey(key))
{
return;
}
@ -87,10 +89,12 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
Upsert(record, entity, record.IsSpatiallyVisible);
}
public void OnProjectionRemoved(uint localEntityId)
public void OnProjectionRemoved(LiveEntityRecord record)
{
if (_byLocalId.TryGetValue(
localEntityId,
ArgumentNullException.ThrowIfNull(record);
if (record.ProjectionKey is { } key
&& _byKey.TryGetValue(
key,
out TrackedProjection? tracked)
&& tracked.Projection.ProjectionClass is
RenderProjectionClass.EquippedChild)
@ -116,11 +120,17 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
public void OnResourceUnregister(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
if (_byLocalId.TryGetValue(entity.Id, out TrackedProjection? tracked)
&& ReferenceEquals(tracked.Entity, entity))
TrackedProjection? tracked = null;
foreach (TrackedProjection candidate in _byKey.Values)
{
Remove(tracked);
if (ReferenceEquals(candidate.Entity, entity))
{
tracked = candidate;
break;
}
}
if (tracked is not null)
Remove(tracked);
}
/// <summary>
@ -153,7 +163,7 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
// synchronization only refreshes children whose attachment projection
// is still retained by this derived scene.
_activeScratch.Clear();
foreach (TrackedProjection tracked in _byLocalId.Values)
foreach (TrackedProjection tracked in _byKey.Values)
{
if (tracked.Record.IsSpatiallyProjected
&& tracked.Record.ProjectionKind is
@ -166,8 +176,9 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
TrackedProjection tracked = _activeScratch[i];
if (!_runtime.IsCurrentRecord(tracked.Record)
|| !ReferenceEquals(tracked.Record.WorldEntity, tracked.Entity)
|| !_byLocalId.TryGetValue(
tracked.Entity.Id,
|| tracked.Record.ProjectionKey is not { } key
|| !_byKey.TryGetValue(
key,
out TrackedProjection? current)
|| !ReferenceEquals(current, tracked))
{
@ -183,7 +194,7 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
public void ResetTracking()
{
_byLocalId.Clear();
_byKey.Clear();
_activeRootScratch.Clear();
_activeScratch.Clear();
}
@ -192,9 +203,11 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
uint localEntityId,
out RenderProjectionRecord record)
{
if (_byLocalId.TryGetValue(
if (_runtime.TryGetRecordByLocalEntityId(
localEntityId,
out TrackedProjection? tracked))
out LiveEntityRecord owner)
&& owner.ProjectionKey is { } key
&& _byKey.TryGetValue(key, out TrackedProjection? tracked))
{
record = tracked.Projection;
return true;
@ -217,11 +230,12 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
record,
entity,
spatiallyVisible);
if (!_byLocalId.TryGetValue(entity.Id, out TrackedProjection? tracked))
RuntimeEntityKey key = RequireProjectionKey(record);
if (!_byKey.TryGetValue(key, out TrackedProjection? tracked))
{
_journal.Register(in projected);
_byLocalId.Add(
entity.Id,
_byKey.Add(
key,
new TrackedProjection(record, entity, projected));
return;
}
@ -234,7 +248,7 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
!= projected.ProjectionClass)
{
_journal.Register(in projected);
_byLocalId[entity.Id] =
_byKey[key] =
new TrackedProjection(record, entity, projected);
return;
}
@ -283,8 +297,9 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
// Spatial withdrawal is not logical destruction. Retain the last
// resident order while the projection is inactive so a portal/rebucket
// edge does not manufacture an unrelated ordering mutation.
return _byLocalId.TryGetValue(
entity.Id,
RuntimeEntityKey key = RequireProjectionKey(record);
return _byKey.TryGetValue(
key,
out TrackedProjection? retained)
? projected with { SortKey = retained.Projection.SortKey }
: projected;
@ -292,7 +307,8 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
private void Remove(TrackedProjection tracked)
{
if (!_byLocalId.Remove(tracked.Entity.Id))
RuntimeEntityKey key = RequireProjectionKey(tracked.Record);
if (!_byKey.Remove(key))
return;
_journal.Unregister(
tracked.Projection.Id,
@ -302,6 +318,13 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
private static uint Canonicalize(uint cellOrLandblockId) =>
(cellOrLandblockId & 0xFFFF0000u) | 0xFFFFu;
private static RuntimeEntityKey RequireProjectionKey(
LiveEntityRecord record) =>
record.ProjectionKey
?? throw new InvalidOperationException(
$"Live entity 0x{record.ServerGuid:X8}/{record.Generation} " +
"has no exact projection key.");
private sealed class TrackedProjection(
LiveEntityRecord record,
WorldEntity entity,

View file

@ -68,7 +68,7 @@ internal sealed class RuntimeFramePipelineDiagnosticFactsSource :
_streaming?.DeferredApplyBacklog ?? 0,
_streaming?.FullWindowRetirementCount ?? 0,
_streaming?.LastFullWindowRetirementLandblockCount ?? 0,
_liveEntities.MaterializedWorldEntities.Count,
_liveEntities.MaterializedCount,
_liveEntities.Snapshots.Count,
_world.Entities.Count);
}

View file

@ -4,6 +4,7 @@ using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
using DatReaderWriter.Types;
namespace AcDream.App.Rendering.Vfx;
@ -33,16 +34,17 @@ public sealed class EntityEffectController : IAnimationHookSink,
private readonly Func<uint, uint?> _parentOfAttachedChild;
private readonly Action<uint> _ownerUnregistered;
private readonly Action<uint, uint?> _ownerSoundTableChanged;
private readonly Dictionary<uint, EntityEffectProfile> _profilesByLocalId = new();
private readonly Dictionary<uint, ushort> _readyGenerationByServerGuid = new();
private readonly Dictionary<RuntimeEntityKey, EntityEffectProfile> _liveProfiles = [];
private readonly HashSet<RuntimeEntityKey> _readyLiveOwners = [];
private readonly Dictionary<uint, Queue<PendingEffect>> _pendingByServerGuid = new();
private readonly Dictionary<uint, WorldEntity> _staticOwners = new();
private readonly Dictionary<uint, EntityEffectProfile> _staticProfiles = new();
private readonly HashSet<uint> _syntheticOwners = new();
private readonly HashSet<LiveEntityRecord> _dirtyLiveOwners =
new(ReferenceEqualityComparer.Instance);
private readonly List<LiveEntityRecord> _dirtyLiveOwnerOrder = [];
private readonly List<LiveEntityRecord> _dirtyLiveOwnerSnapshot = [];
private readonly List<uint> _readyGuidSnapshot = [];
private readonly List<RuntimeEntityKey> _readyKeySnapshot = [];
private uint _posePublishLocalId;
private Action<string>? _diagnosticSink = Console.WriteLine;
@ -76,7 +78,7 @@ public sealed class EntityEffectController : IAnimationHookSink,
}
public int PendingPacketCount => _pendingByServerGuid.Values.Sum(queue => queue.Count);
public int ReadyOwnerCount => _profilesByLocalId.Count;
public int ReadyOwnerCount => _liveProfiles.Count + _staticProfiles.Count;
internal int LastPoseRefreshOwnerVisitCount { get; private set; }
public void HandleDirect(PlayPhysicsScript message)
@ -135,8 +137,9 @@ public sealed class EntityEffectController : IAnimationHookSink,
return false;
}
_readyGenerationByServerGuid[serverGuid] = record.Generation;
_profilesByLocalId[entity.Id] = profile;
RuntimeEntityKey key = RequireProjectionKey(record);
_readyLiveOwners.Add(key);
_liveProfiles[key] = profile;
_runner.SetOwnerAnchor(entity.Id, entity.Position);
_ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid);
return true;
@ -166,7 +169,7 @@ public sealed class EntityEffectController : IAnimationHookSink,
return false;
}
_profilesByLocalId[localId] = profile;
_liveProfiles[RequireProjectionKey(record)] = profile;
_ownerSoundTableChanged(localId, profile.CurrentSoundTableDid);
return true;
}
@ -181,7 +184,7 @@ public sealed class EntityEffectController : IAnimationHookSink,
if (ownerLocalId == 0)
return;
_staticOwners[ownerLocalId] = entity;
_profilesByLocalId[ownerLocalId] = profile;
_staticProfiles[ownerLocalId] = profile;
_runner.SetOwnerAnchor(ownerLocalId, entity.Position);
_ownerSoundTableChanged(ownerLocalId, profile.CurrentSoundTableDid);
}
@ -190,7 +193,7 @@ public sealed class EntityEffectController : IAnimationHookSink,
{
if (!_staticOwners.Remove(localId))
return;
_profilesByLocalId.Remove(localId);
_staticProfiles.Remove(localId);
_runner.StopAllForEntity(localId);
_ownerUnregistered(localId);
}
@ -212,16 +215,13 @@ public sealed class EntityEffectController : IAnimationHookSink,
{
_pendingByServerGuid.Remove(record.ServerGuid);
}
if (_readyGenerationByServerGuid.TryGetValue(
record.ServerGuid,
out ushort readyGeneration)
&& readyGeneration == record.Generation)
if (record.ProjectionKey is { } key)
{
_readyGenerationByServerGuid.Remove(record.ServerGuid);
_readyLiveOwners.Remove(key);
_liveProfiles.Remove(key);
}
if (record.LocalEntityId is not { } localId)
return;
_profilesByLocalId.Remove(localId);
_runner.StopAllForEntity(localId);
_ownerUnregistered(localId);
}
@ -232,18 +232,15 @@ public sealed class EntityEffectController : IAnimationHookSink,
public void ClearNetworkState()
{
_readyGuidSnapshot.Clear();
_readyGuidSnapshot.AddRange(_readyGenerationByServerGuid.Keys);
foreach (uint serverGuid in _readyGuidSnapshot)
_readyKeySnapshot.Clear();
_readyKeySnapshot.AddRange(_readyLiveOwners);
foreach (RuntimeEntityKey key in _readyKeySnapshot)
{
if (_liveEntities.TryGetLocalEntityId(serverGuid, out uint localId))
{
_profilesByLocalId.Remove(localId);
_runner.StopAllForEntity(localId);
_ownerUnregistered(localId);
}
_runner.StopAllForEntity(key.LocalEntityId);
_ownerUnregistered(key.LocalEntityId);
}
_readyGenerationByServerGuid.Clear();
_readyLiveOwners.Clear();
_liveProfiles.Clear();
_pendingByServerGuid.Clear();
_dirtyLiveOwners.Clear();
_dirtyLiveOwnerOrder.Clear();
@ -331,7 +328,7 @@ public sealed class EntityEffectController : IAnimationHookSink,
uint rawScriptType,
float intensity)
{
if (!_profilesByLocalId.TryGetValue(ownerLocalId, out EntityEffectProfile? profile)
if (!TryGetProfile(ownerLocalId, out EntityEffectProfile? profile)
|| profile.CurrentPhysicsScriptTableDid is not { } tableDid)
{
DiagnosticSink?.Invoke(
@ -360,7 +357,7 @@ public sealed class EntityEffectController : IAnimationHookSink,
public bool PlayDefault(uint ownerLocalId)
{
if (!CanStartOwner(ownerLocalId)
|| !_profilesByLocalId.TryGetValue(ownerLocalId, out EntityEffectProfile? profile))
|| !TryGetProfile(ownerLocalId, out EntityEffectProfile? profile))
return false;
return PlayTyped(
ownerLocalId,
@ -433,8 +430,9 @@ public sealed class EntityEffectController : IAnimationHookSink,
private bool TryGetLiveRoot(uint ownerLocalId, out LiveEntityRecord record)
{
if (_liveEntities.TryGetServerGuid(ownerLocalId, out uint serverGuid)
&& _liveEntities.TryGetRecord(serverGuid, out record!))
if (_liveEntities.TryGetRecordByLocalEntityId(
ownerLocalId,
out record!))
{
return true;
}
@ -444,12 +442,12 @@ public sealed class EntityEffectController : IAnimationHookSink,
private bool TryGetReadyLocalId(uint serverGuid, out uint localId)
{
if (_readyGenerationByServerGuid.TryGetValue(serverGuid, out ushort readyGeneration)
&& _liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
&& record.Generation == readyGeneration
&& _liveEntities.TryGetLocalEntityId(serverGuid, out localId)
&& _profilesByLocalId.ContainsKey(localId))
if (_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
&& record.ProjectionKey is { } key
&& _readyLiveOwners.Contains(key)
&& _liveProfiles.ContainsKey(key))
{
localId = key.LocalEntityId;
return true;
}
@ -461,8 +459,9 @@ public sealed class EntityEffectController : IAnimationHookSink,
{
if (_posePublishLocalId == localId)
return;
if (_liveEntities.TryGetServerGuid(localId, out uint serverGuid)
&& _liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record))
if (_liveEntities.TryGetRecordByLocalEntityId(
localId,
out LiveEntityRecord record))
{
MarkLiveOwnerPoseDirty(record);
}
@ -476,10 +475,8 @@ public sealed class EntityEffectController : IAnimationHookSink,
private void MarkLiveOwnerPoseDirty(LiveEntityRecord record)
{
if (!_readyGenerationByServerGuid.TryGetValue(
record.ServerGuid,
out ushort readyGeneration)
|| readyGeneration != record.Generation
if (record.ProjectionKey is not { } key
|| !_readyLiveOwners.Contains(key)
|| !_dirtyLiveOwners.Add(record))
{
return;
@ -550,6 +547,29 @@ public sealed class EntityEffectController : IAnimationHookSink,
PlayTyped(localId, effect.RawScriptType, effect.Intensity);
}
private bool TryGetProfile(
uint ownerLocalId,
out EntityEffectProfile profile)
{
if (_liveEntities.TryGetRecordByLocalEntityId(
ownerLocalId,
out LiveEntityRecord record)
&& record.ProjectionKey is { } key
&& _liveProfiles.TryGetValue(key, out profile!))
{
return true;
}
return _staticProfiles.TryGetValue(ownerLocalId, out profile!);
}
private static RuntimeEntityKey RequireProjectionKey(
LiveEntityRecord record) =>
record.ProjectionKey
?? throw new InvalidOperationException(
$"Live entity 0x{record.ServerGuid:X8}/{record.Generation} " +
"has no exact projection key.");
private enum PendingEffectKind
{
Direct,

View file

@ -1,6 +1,7 @@
using AcDream.App.World;
using AcDream.Core.Lighting;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Rendering.Vfx;
@ -22,8 +23,8 @@ public sealed class LiveEntityLightController : IDisposable
private readonly EntityEffectPoseRegistry _poses;
private readonly LightingHookSink _lighting;
private readonly Func<uint, Setup?> _loadSetup;
private readonly Dictionary<uint, uint> _serverGuidByOwner = new();
private readonly HashSet<uint> _presentOwners = new();
private readonly HashSet<RuntimeEntityKey> _trackedOwners = [];
private readonly HashSet<RuntimeEntityKey> _presentOwners = [];
public LiveEntityLightController(
LiveEntityRuntime liveEntities,
@ -49,8 +50,9 @@ public sealed class LiveEntityLightController : IDisposable
return false;
}
_serverGuidByOwner[entity.Id] = serverGuid;
_presentOwners.Add(entity.Id);
RuntimeEntityKey key = RequireProjectionKey(record);
_trackedOwners.Add(key);
_presentOwners.Add(key);
_lighting.UnregisterOwner(entity.Id, forgetState: false);
_lighting.InitializeOwnerLighting(
entity.Id,
@ -87,7 +89,13 @@ public sealed class LiveEntityLightController : IDisposable
/// <summary>Withdraw cell-scoped presentation but retain logical light state.</summary>
public void Unregister(uint ownerLocalId)
{
_presentOwners.Remove(ownerLocalId);
if (_liveEntities.TryGetRecordByLocalEntityId(
ownerLocalId,
out LiveEntityRecord record)
&& record.ProjectionKey is { } key)
{
_presentOwners.Remove(key);
}
_lighting.UnregisterOwner(ownerLocalId, forgetState: false);
}
@ -105,16 +113,20 @@ public sealed class LiveEntityLightController : IDisposable
}
/// <summary>End logical ownership after leave-world presentation teardown.</summary>
public void Forget(uint ownerLocalId)
public void Forget(LiveEntityRecord record)
{
_presentOwners.Remove(ownerLocalId);
_serverGuidByOwner.Remove(ownerLocalId);
_lighting.UnregisterOwner(ownerLocalId);
ArgumentNullException.ThrowIfNull(record);
if (record.ProjectionKey is not { } key)
return;
_presentOwners.Remove(key);
_trackedOwners.Remove(key);
_lighting.UnregisterOwner(key.LocalEntityId);
}
public void Refresh() => _lighting.RefreshAttachedLights();
internal int TrackedOwnerCount => _serverGuidByOwner.Count;
internal int TrackedOwnerCount => _trackedOwners.Count;
internal int PresentedOwnerCount => _presentOwners.Count;
/// <summary>
@ -127,7 +139,8 @@ public sealed class LiveEntityLightController : IDisposable
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.ProjectionKind is not LiveEntityProjectionKind.Attached
|| record.WorldEntity is not { } entity
|| _presentOwners.Contains(entity.Id))
|| record.ProjectionKey is not { } key
|| _presentOwners.Contains(key))
{
return;
}
@ -136,8 +149,11 @@ public sealed class LiveEntityLightController : IDisposable
private void OnOwnerLightingChanged(uint ownerLocalId, bool enabled)
{
if (!_presentOwners.Contains(ownerLocalId)
|| !_serverGuidByOwner.TryGetValue(ownerLocalId, out uint serverGuid))
if (!_liveEntities.TryGetRecordByLocalEntityId(
ownerLocalId,
out LiveEntityRecord record)
|| record.ProjectionKey is not { } key
|| !_presentOwners.Contains(key))
{
return;
}
@ -148,15 +164,17 @@ public sealed class LiveEntityLightController : IDisposable
return;
}
Register(serverGuid);
Register(record.ServerGuid);
}
public void Dispose()
{
_lighting.OwnerLightingChanged -= OnOwnerLightingChanged;
_liveEntities.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged;
foreach (uint ownerId in _serverGuidByOwner.Keys.ToArray())
Forget(ownerId);
foreach (RuntimeEntityKey key in _trackedOwners)
_lighting.UnregisterOwner(key.LocalEntityId);
_trackedOwners.Clear();
_presentOwners.Clear();
}
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
@ -176,4 +194,11 @@ public sealed class LiveEntityLightController : IDisposable
else
Unregister(entity.Id);
}
private static RuntimeEntityKey RequireProjectionKey(
LiveEntityRecord record) =>
record.ProjectionKey
?? throw new InvalidOperationException(
$"Live entity 0x{record.ServerGuid:X8}/{record.Generation} " +
"has no exact projection key.");
}

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using AcDream.Core.Physics;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
namespace AcDream.App.Rendering.Wb;
@ -48,18 +49,20 @@ public sealed class EntitySpawnAdapter
private readonly Func<WorldEntity, AnimationSequencer> _sequencerFactory;
private readonly IWbMeshAdapter? _meshAdapter;
// One logical owner per server GUID. Animated state survives projection
// One logical owner per exact Runtime identity. Animated state survives projection
// suspension, while the resident bit controls the shorter GPU-presentation
// lifetime. The exact WorldEntity reference makes delayed visibility edges
// from a displaced GUID generation harmless.
// Single-threaded: called only from the render thread (same as GpuWorldState).
private readonly Dictionary<uint, Owner> _ownersByGuid = new();
private readonly Dictionary<RuntimeEntityKey, Owner> _ownersByKey = [];
private sealed class Owner(
RuntimeEntityKey key,
WorldEntity entity,
AnimatedEntityState state,
HashSet<ulong> meshIds)
{
public RuntimeEntityKey Key { get; } = key;
public WorldEntity Entity { get; } = entity;
public AnimatedEntityState State { get; } = state;
public HashSet<ulong> MeshIds { get; set; } = meshIds;
@ -137,12 +140,25 @@ public sealed class EntitySpawnAdapter
/// <paramref name="entity"/> is atlas-tier (<c>ServerGuid == 0</c>).
/// </summary>
public AnimatedEntityState? OnCreate(WorldEntity entity)
=> OnCreate(new RuntimeEntityKey(entity.Id, 0), entity);
/// <summary>
/// Creates one exact Runtime-owned presentation resource set.
/// </summary>
public AnimatedEntityState? OnCreate(
RuntimeEntityKey key,
WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
// Atlas-tier entities (procedural / dat-hydrated, ServerGuid == 0)
// are handled by LandblockSpawnAdapter, not here.
if (entity.ServerGuid == 0) return null;
if (key.LocalEntityId == 0 || key.LocalEntityId != entity.Id)
{
throw new InvalidOperationException(
"The exact Runtime projection key must match the WorldEntity local ID.");
}
// A.5 T18: populate cached AABB so WalkEntities reads from the cache
// rather than recomputing Position±5 per frame. Called here because
@ -177,8 +193,8 @@ public sealed class EntitySpawnAdapter
// valid. Retirement is also completed before replacement publication:
// a failed texture or mesh release therefore leaves the prior owner in
// the dictionary, with its per-resource progress available to retry.
var replacementOwner = new Owner(entity, state, meshIds);
if (_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? displacedOwner))
var replacementOwner = new Owner(key, entity, state, meshIds);
if (_ownersByKey.TryGetValue(key, out Owner? displacedOwner))
{
if (displacedOwner.RemovalPending)
{
@ -200,11 +216,11 @@ public sealed class EntitySpawnAdapter
}
}
_ownersByGuid[entity.ServerGuid] = replacementOwner;
_ownersByKey[key] = replacementOwner;
}
else
{
_ownersByGuid.Add(entity.ServerGuid, replacementOwner);
_ownersByKey.Add(key, replacementOwner);
}
return state;
@ -223,8 +239,7 @@ public sealed class EntitySpawnAdapter
public bool SetPresentationResident(WorldEntity entity, bool resident)
{
ArgumentNullException.ThrowIfNull(entity);
if (!_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? owner)
|| !ReferenceEquals(owner.Entity, entity)
if (!TryFindOwner(entity, out _, out Owner owner)
|| owner.RemovalPending
|| (resident ? owner.IsFullyResident : owner.IsFullySuspended))
{
@ -270,8 +285,7 @@ public sealed class EntitySpawnAdapter
ArgumentNullException.ThrowIfNull(partOverrides);
ArgumentNullException.ThrowIfNull(publishAppearance);
if (!_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? owner)
|| !ReferenceEquals(owner.Entity, entity)
if (!TryFindOwner(entity, out _, out Owner owner)
|| owner.RemovalPending
|| owner.Transition != PresentationTransition.None)
{
@ -340,19 +354,31 @@ public sealed class EntitySpawnAdapter
/// and propagates the exception; a later call resumes its unfinished releases.
/// </summary>
public void OnRemove(uint serverGuid)
=> _ = TryRemove(serverGuid, expectedEntity: null);
private bool TryRemove(uint serverGuid, WorldEntity? expectedEntity)
{
if (!_ownersByGuid.TryGetValue(serverGuid, out Owner? owner)
|| (expectedEntity is not null && !ReferenceEquals(owner.Entity, expectedEntity)))
foreach ((RuntimeEntityKey key, Owner owner) in _ownersByKey)
{
if (owner.Entity.ServerGuid == serverGuid)
{
_ = TryRemove(key, owner.Entity);
return;
}
}
}
private bool TryRemove(
RuntimeEntityKey key,
WorldEntity expectedEntity)
{
if (!_ownersByKey.TryGetValue(key, out Owner? owner)
|| !ReferenceEquals(owner.Entity, expectedEntity))
{
return false;
}
owner.RemovalPending = true;
if (owner.Transition != PresentationTransition.None)
throw new EntityPresentationRemovalDeferredException(serverGuid);
throw new EntityPresentationRemovalDeferredException(
owner.Entity.ServerGuid);
// A resident owner still holds both mesh and potentially-lazy texture
// resources. A suspended owner released them at the visibility edge,
@ -363,10 +389,10 @@ public sealed class EntitySpawnAdapter
if (owner.HasPresentationResources && !SuspendPresentation(owner))
return false;
if (_ownersByGuid.TryGetValue(serverGuid, out Owner? current)
if (_ownersByKey.TryGetValue(key, out Owner? current)
&& ReferenceEquals(current, owner))
{
_ownersByGuid.Remove(serverGuid);
_ownersByKey.Remove(key);
return true;
}
@ -384,7 +410,8 @@ public sealed class EntitySpawnAdapter
public bool OnRemove(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
return TryRemove(entity.ServerGuid, entity);
return TryFindOwner(entity, out RuntimeEntityKey key, out _)
&& TryRemove(key, entity);
}
/// <summary>
@ -393,9 +420,35 @@ public sealed class EntitySpawnAdapter
/// been removed.
/// </summary>
public AnimatedEntityState? GetState(uint serverGuid)
=> _ownersByGuid.TryGetValue(serverGuid, out Owner? owner)
? owner.State
: null;
{
foreach (Owner owner in _ownersByKey.Values)
{
if (owner.Entity.ServerGuid == serverGuid)
return owner.State;
}
return null;
}
private bool TryFindOwner(
WorldEntity entity,
out RuntimeEntityKey key,
out Owner owner)
{
foreach ((RuntimeEntityKey candidateKey, Owner candidate) in _ownersByKey)
{
if (ReferenceEquals(candidate.Entity, entity))
{
key = candidateKey;
owner = candidate;
return true;
}
}
key = default;
owner = null!;
return false;
}
private bool ResumePresentation(Owner owner)
{

View file

@ -150,15 +150,14 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
// This index mirrors only loaded live projections and changes at the same
// transaction boundary as _projectionLocations.
private readonly Dictionary<uint, HashSet<WorldEntity>> _loadedLiveByLandblock = new();
// A server GUID normally has exactly one spatial projection. Keep that
// allocation-free common case directly in the primary map; the secondary
// list exists only for the rare overlap during GUID reuse/re-entrant
// lifetime transitions.
private readonly Dictionary<uint, WorldEntity> _primaryProjectionByGuid = new();
private readonly Dictionary<uint, List<WorldEntity>> _additionalProjectionsByGuid = new();
// Runtime-issued local IDs stay unique for the complete materialized
// lifetime, including retryable teardown. Spatial storage therefore never
// groups graphical owners by reusable server GUID.
private readonly Dictionary<uint, WorldEntity> _liveProjectionByLocalId = new();
private readonly Dictionary<uint, int> _visibleLiveProjectionCounts = new();
private readonly Dictionary<uint, bool> _visibilityBeforeMutation = new();
private readonly Queue<(uint ServerGuid, bool Visible)> _visibilityTransitions = new();
private readonly Queue<(uint LocalEntityId, bool Visible)> _visibilityTransitions = new();
private readonly List<WorldEntity> _guidRemovalScratch = new();
private bool _dispatchingVisibilityTransitions;
private int _mutationDepth;
private long _visibilityCommitCount;
@ -180,18 +179,18 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
_loaded.ContainsKey(landblockId)
&& (_wbSpawnAdapter?.IsLandblockRenderReady(landblockId) ?? true);
/// <summary>
/// True when at least one projection for the server object belongs to a
/// loaded spatial bucket. This deliberately ignores the world-generation
/// True when the exact Runtime-issued local projection belongs to a loaded
/// spatial bucket. This deliberately ignores the world-generation
/// presentation gate: destination objects must acquire their render
/// resources while portal/login reveal keeps normal world consumers closed.
/// </summary>
public bool IsLiveEntityProjectionResident(uint serverGuid) =>
serverGuid != 0
&& _visibleLiveProjectionCounts.ContainsKey(serverGuid);
public bool IsLiveEntityProjectionResident(uint localEntityId) =>
localEntityId != 0
&& _visibleLiveProjectionCounts.ContainsKey(localEntityId);
public bool IsLiveEntityVisible(uint serverGuid) =>
public bool IsLiveEntityVisible(uint localEntityId) =>
_availability.IsWorldAvailable
&& IsLiveEntityProjectionResident(serverGuid);
&& IsLiveEntityProjectionResident(localEntityId);
/// <summary>
/// Try to grab the loaded record for a landblock — useful for callers
@ -583,11 +582,11 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
if (--_mutationDepth != 0)
return;
foreach ((uint guid, bool wasVisible) in _visibilityBeforeMutation)
foreach ((uint localEntityId, bool wasVisible) in _visibilityBeforeMutation)
{
bool isVisible = _visibleLiveProjectionCounts.ContainsKey(guid);
bool isVisible = _visibleLiveProjectionCounts.ContainsKey(localEntityId);
if (wasVisible != isVisible)
_visibilityTransitions.Enqueue((guid, isVisible));
_visibilityTransitions.Enqueue((localEntityId, isVisible));
}
_visibilityBeforeMutation.Clear();
_visibilityCommitCount++;
@ -604,26 +603,27 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
private void AdjustVisibleLiveProjection(WorldEntity entity, int delta)
{
uint guid = entity.ServerGuid;
if (guid == 0 || delta == 0)
uint localEntityId = entity.Id;
if (entity.ServerGuid == 0 || localEntityId == 0 || delta == 0)
return;
_visibleLiveProjectionCounts.TryGetValue(guid, out int priorCount);
if (!_visibilityBeforeMutation.ContainsKey(guid))
_visibilityBeforeMutation.Add(guid, priorCount > 0);
_visibleLiveProjectionCounts.TryGetValue(localEntityId, out int priorCount);
if (!_visibilityBeforeMutation.ContainsKey(localEntityId))
_visibilityBeforeMutation.Add(localEntityId, priorCount > 0);
int nextCount = checked(priorCount + delta);
if (nextCount < 0)
throw new InvalidOperationException(
$"Live projection visibility count underflow for 0x{guid:X8}.");
$"Live projection visibility count underflow for local " +
$"0x{localEntityId:X8} / server 0x{entity.ServerGuid:X8}.");
if (nextCount == 0)
{
_visibleLiveProjectionCounts.Remove(guid);
_visibleLiveProjectionCounts.Remove(localEntityId);
}
else
{
_visibleLiveProjectionCounts[guid] = nextCount;
_visibleLiveProjectionCounts[localEntityId] = nextCount;
}
}
@ -695,16 +695,11 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
if (!isNew)
return;
uint guid = entity.ServerGuid;
if (_primaryProjectionByGuid.TryAdd(guid, entity))
return;
if (!_additionalProjectionsByGuid.TryGetValue(guid, out List<WorldEntity>? projections))
if (!_liveProjectionByLocalId.TryAdd(entity.Id, entity))
{
projections = new List<WorldEntity>(1);
_additionalProjectionsByGuid.Add(guid, projections);
throw new InvalidOperationException(
$"Runtime local projection id 0x{entity.Id:X8} is already spatially owned.");
}
projections.Add(entity);
}
private void RemoveProjectionLocation(WorldEntity entity)
@ -715,51 +710,18 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
if (location.IsLoaded)
RemoveLoadedLiveIndex(location.LandblockId, entity);
uint guid = entity.ServerGuid;
if (!_primaryProjectionByGuid.TryGetValue(guid, out WorldEntity? primary))
throw new InvalidOperationException(
$"Live projection 0x{entity.Id:X8} had no server-guid index entry.");
if (ReferenceEquals(primary, entity))
if (!_liveProjectionByLocalId.Remove(
entity.Id,
out WorldEntity? indexed))
{
if (_additionalProjectionsByGuid.TryGetValue(guid, out List<WorldEntity>? projections)
&& projections.Count > 0)
{
int lastIndex = projections.Count - 1;
_primaryProjectionByGuid[guid] = projections[lastIndex];
projections.RemoveAt(lastIndex);
if (projections.Count == 0)
_additionalProjectionsByGuid.Remove(guid);
}
else
{
_primaryProjectionByGuid.Remove(guid);
}
return;
}
if (!_additionalProjectionsByGuid.TryGetValue(guid, out List<WorldEntity>? additional))
throw new InvalidOperationException(
$"Live projection 0x{entity.Id:X8} was absent from its server-guid index.");
int index = -1;
for (int i = 0; i < additional.Count; i++)
$"Live projection 0x{entity.Id:X8} had no exact local-id index entry.");
}
if (!ReferenceEquals(indexed, entity))
{
if (!ReferenceEquals(additional[i], entity))
continue;
index = i;
break;
}
if (index < 0)
throw new InvalidOperationException(
$"Live projection 0x{entity.Id:X8} was absent from its server-guid index.");
int lastAdditionalIndex = additional.Count - 1;
if (index != lastAdditionalIndex)
additional[index] = additional[lastAdditionalIndex];
additional.RemoveAt(lastAdditionalIndex);
if (additional.Count == 0)
_additionalProjectionsByGuid.Remove(guid);
$"Runtime local projection id 0x{entity.Id:X8} resolved a different owner.");
}
}
private void AddLoadedLiveIndex(uint landblockId, WorldEntity entity)
@ -1397,10 +1359,13 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
MutationBatch mutation = BeginMutationBatch();
try
{
foreach ((uint guid, int count) in _visibleLiveProjectionCounts)
foreach ((uint localEntityId, int count) in _visibleLiveProjectionCounts)
{
if (count > 0 && !_visibilityBeforeMutation.ContainsKey(guid))
_visibilityBeforeMutation.Add(guid, true);
if (count > 0
&& !_visibilityBeforeMutation.ContainsKey(localEntityId))
{
_visibilityBeforeMutation.Add(localEntityId, true);
}
}
_visibleLiveProjectionCounts.Clear();
@ -1410,8 +1375,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
_flatEntityIndices.Clear();
_projectionLocations.Clear();
_loadedLiveByLandblock.Clear();
_primaryProjectionByGuid.Clear();
_additionalProjectionsByGuid.Clear();
_liveProjectionByLocalId.Clear();
_loaded.Clear();
_renderTraversalLandblockSlots.Clear();
@ -1525,12 +1489,18 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
using MutationBatch mutation = BeginMutationBatch();
// Canonical live projections are indexed by server GUID and entity
// identity, so delete/withdraw never scans every resident landblock.
while (_primaryProjectionByGuid.TryGetValue(serverGuid, out WorldEntity? projection))
// GUID-only removal is reserved for session cleanup paths that do not
// have an exact projection owner. Ordinary lifecycle operations use
// the exact-entity overload below.
_guidRemovalScratch.Clear();
foreach (WorldEntity projection in _projectionLocations.Keys)
{
RemoveEntityFromAllBuckets(projection);
if (projection.ServerGuid == serverGuid)
_guidRemovalScratch.Add(projection);
}
for (int i = 0; i < _guidRemovalScratch.Count; i++)
RemoveEntityFromAllBuckets(_guidRemovalScratch[i]);
_guidRemovalScratch.Clear();
// A persistent projection may have been rescued by RemoveLandblock
// and not yet drained by the next GameWindow frame. Rebucketing or
@ -1582,8 +1552,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
_visibilityBeforeMutation.Clear();
_projectionLocations.Clear();
_loadedLiveByLandblock.Clear();
_primaryProjectionByGuid.Clear();
_additionalProjectionsByGuid.Clear();
_liveProjectionByLocalId.Clear();
}
/// <summary>
@ -1905,7 +1874,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
try
{
((Action<uint, bool>)subscribers[i])(
transition.ServerGuid,
transition.LocalEntityId,
transition.Visible);
}
catch (Exception error)

View file

@ -229,7 +229,7 @@ internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlaceme
// SnapToCell owns the retail Position frame and may normalize an
// outdoor land-cell index from the cell-local origin. Publish that
// canonical result, not the pre-snap resolver hint, to rendering.
if (_liveEntities.MaterializedWorldEntities.TryGetValue(
if (_liveEntities.TryGetWorldEntity(
playerGuid,
out WorldEntity? entity))
{

View file

@ -62,18 +62,22 @@ internal sealed class WorldGenerationQuiescence
private readonly WorldGenerationAvailabilityState _availability;
private readonly SelectionState _selection;
private readonly GpuWorldState _world;
private readonly Func<uint, uint?> _resolveLocalEntityId;
private readonly IWorldAudioQuiescence? _audio;
public WorldGenerationQuiescence(
WorldGenerationAvailabilityState availability,
SelectionState selection,
GpuWorldState world,
Func<uint, uint?> resolveLocalEntityId,
IWorldAudioQuiescence? audio)
{
_availability = availability
?? throw new ArgumentNullException(nameof(availability));
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_world = world ?? throw new ArgumentNullException(nameof(world));
_resolveLocalEntityId = resolveLocalEntityId
?? throw new ArgumentNullException(nameof(resolveLocalEntityId));
_audio = audio;
}
@ -85,7 +89,8 @@ internal sealed class WorldGenerationQuiescence
uint? selected = firstEdge ? _selection.SelectedObjectId : null;
bool clearWorldSelection =
selected is uint selectedGuid
&& _world.IsLiveEntityVisible(selectedGuid);
&& _resolveLocalEntityId(selectedGuid) is uint localEntityId
&& _world.IsLiveEntityVisible(localEntityId);
_availability.Begin(generation);
if (!firstEdge)

View file

@ -20,8 +20,7 @@ public sealed class RadarSnapshotProvider
private static readonly Vector2 ProductionCenter = new(60f, 60f);
private readonly ClientObjectTable _objects;
private readonly Func<IReadOnlyDictionary<uint, WorldEntity>> _worldEntities;
private readonly Func<IReadOnlyDictionary<uint, WorldEntity>> _playerEntities;
private readonly ILiveEntityRadarSource _liveEntities;
private readonly Func<IReadOnlyDictionary<uint, WorldSession.EntitySpawn>> _spawns;
private readonly Func<uint> _playerGuid;
private readonly Func<float> _playerYawRadians;
@ -35,7 +34,7 @@ public sealed class RadarSnapshotProvider
public RadarSnapshotProvider(
ClientObjectTable objects,
Func<IReadOnlyDictionary<uint, WorldEntity>> worldEntities,
ILiveEntityRadarSource liveEntities,
Func<IReadOnlyDictionary<uint, WorldSession.EntitySpawn>> spawns,
Func<uint> playerGuid,
Func<float> playerYawRadians,
@ -44,12 +43,10 @@ public sealed class RadarSnapshotProvider
Func<bool> coordinatesOnRadar,
Func<bool> uiLocked,
Func<uint, RadarRelationshipTraits>? relationshipFor = null,
Func<IReadOnlyDictionary<uint, WorldEntity>>? playerEntities = null,
Func<ILiveEntitySpatialQuery?>? spatialQuery = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_worldEntities = worldEntities ?? throw new ArgumentNullException(nameof(worldEntities));
_playerEntities = playerEntities ?? worldEntities;
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_spawns = spawns ?? throw new ArgumentNullException(nameof(spawns));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_playerYawRadians = playerYawRadians ?? throw new ArgumentNullException(nameof(playerYawRadians));
@ -67,13 +64,14 @@ public sealed class RadarSnapshotProvider
// startup. Resolve these canonical projections per snapshot so the
// provider observes that runtime once it is installed, rather than
// retaining the bootstrap empty-map sentinels forever.
IReadOnlyDictionary<uint, WorldEntity> worldEntities = _worldEntities();
IReadOnlyDictionary<uint, WorldEntity> playerEntities = _playerEntities();
IReadOnlyDictionary<uint, WorldSession.EntitySpawn> spawns = _spawns();
bool uiLocked = _uiLocked();
uint playerGuid = _playerGuid();
if (playerGuid == 0u || !playerEntities.TryGetValue(playerGuid, out var playerEntity))
if (playerGuid == 0u
|| !_liveEntities.TryGetMaterialized(
playerGuid,
out WorldEntity playerEntity))
return UiRadarSnapshot.Empty with { UiLocked = uiLocked };
float heading = MoveToMath.HeadingFromYaw(_playerYawRadians());
@ -104,8 +102,7 @@ public sealed class RadarSnapshotProvider
}
else
{
foreach (var pair in worldEntities)
_candidateScratch.Add(pair);
_liveEntities.CopyVisibleTo(_candidateScratch);
}
var blips = new List<UiRadarBlip>(Math.Min(_candidateScratch.Count, 64));
@ -116,7 +113,7 @@ public sealed class RadarSnapshotProvider
continue;
var entity = pair.Value;
if (!worldEntities.TryGetValue(guid, out WorldEntity? visibleEntity)
if (!_liveEntities.TryGetVisible(guid, out WorldEntity? visibleEntity)
|| !ReferenceEquals(entity, visibleEntity))
{
continue;

View file

@ -10,6 +10,7 @@ using AcDream.Core.Physics;
using AcDream.Core.Rendering;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Update;
@ -196,12 +197,12 @@ internal sealed class LiveObjectFrameController : ILiveObjectFramePhase
_selectionInteractions?.DrainOutbound();
Vector3? playerPosition =
_liveEntities.MaterializedWorldEntities.TryGetValue(
_liveEntities.TryGetWorldEntity(
_localPlayer.ServerGuid,
out WorldEntity? player)
? player.Position
: null;
IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> schedules =
IReadOnlyDictionary<RuntimeEntityKey, LiveEntityAnimationSchedule> schedules =
_animations.Tick(
deltaSeconds,
playerPosition,

View file

@ -0,0 +1,15 @@
using AcDream.Core.World;
namespace AcDream.App.World;
/// <summary>
/// Allocation-free live-projection query used by the radar. Implementations
/// expose current presentation state without publishing a second GUID identity
/// map.
/// </summary>
public interface ILiveEntityRadarSource
{
bool TryGetMaterialized(uint serverGuid, out WorldEntity entity);
bool TryGetVisible(uint serverGuid, out WorldEntity entity);
void CopyVisibleTo(List<KeyValuePair<uint, WorldEntity>> destination);
}

View file

@ -78,9 +78,9 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink
public bool Prune(LiveEntityPruneCandidate candidate)
{
if (!_runtime.TryGetRecord(
candidate.ServerGuid,
candidate.Key,
out LiveEntityRecord record)
|| record.Generation != candidate.Generation)
|| record.ServerGuid != candidate.ServerGuid)
{
return false;
}

View file

@ -24,7 +24,7 @@ internal enum LiveProjectionPurpose
internal interface ILiveEntityProjectionMaterializer
{
bool TryMaterialize(
LiveEntityRecord expectedRecord,
RuntimeEntityRecord expectedCanonical,
WorldSession.EntitySpawn canonicalSpawn,
LiveProjectionPurpose purpose,
ulong expectedCreateIntegrationVersion,
@ -181,6 +181,15 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
private readonly LiveEntityDeletionController _deletion;
private readonly DormantLiveEntityStore _dormant;
private readonly Action<string>? _diagnostic;
private readonly Dictionary<RuntimeEntityRecord, CanonicalProjectionOperation>
_projectionOperations =
new(ReferenceEqualityComparer.Instance);
private sealed class CanonicalProjectionOperation
{
public ulong CreateIntegrationVersion { get; set; }
public bool RetryRequested { get; set; }
}
public LiveEntityHydrationController(
LiveEntityRuntime runtime,
@ -264,17 +273,17 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
// dormant snapshot until an active record actually accepts
// ownership; otherwise a transient teardown failure would discard
// the only ACE revisit source.
if (registration.Record is not { } record)
if (registration.Canonical is not { } canonical)
return;
_dormant.RemoveThroughAcceptedCreate(spawn);
ulong createIntegrationVersion = record.CreateIntegrationVersion;
ulong createIntegrationVersion = canonical.CreateIntegrationVersion;
try
{
_timestamps.Publish(spawn.Guid, result.Timestamps);
if (_runtime.IsCurrentCreateIntegration(
record,
canonical,
createIntegrationVersion)
&& ObjectTableWiring.ApplyEntitySpawn(
_objects,
@ -283,10 +292,10 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration
|| dormantDisposition is DormantCreateDisposition.NewGeneration,
accepting: () => _runtime.IsCurrentCreateIntegration(
record,
canonical,
createIntegrationVersion))
&& _runtime.IsCurrentCreateIntegration(
record,
canonical,
createIntegrationVersion))
{
if (result.Disposition is
@ -300,7 +309,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
// that snapshot rather than replaying a delta tail
// against owners that did not exist.
ProjectExact(
record,
canonical,
result.Snapshot,
LiveProjectionPurpose.LogicalRegistration,
expectedCreateIntegrationVersion:
@ -312,15 +321,23 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
_networkUpdates.ApplySameGeneration(refresh);
if (_runtime.IsCurrentCreateIntegration(
record,
createIntegrationVersion)
&& (record.CreateProjectionSynchronizationPending
|| !record.InitialHydrationCompleted))
canonical,
createIntegrationVersion))
{
_runtime.TryGetProjection(
canonical,
out LiveEntityRecord? projection);
if (projection is not null
&& !projection.CreateProjectionSynchronizationPending
&& projection.InitialHydrationCompleted)
{
goto AppearanceSynchronization;
}
ProjectExact(
record,
record.Snapshot,
record.CreateProjectionSynchronizationPending
canonical,
canonical.Snapshot,
projection?.CreateProjectionSynchronizationPending
is true
? LiveProjectionPurpose.CreateSupersessionRecovery
: LiveProjectionPurpose.SpatialRecovery,
expectedCreateIntegrationVersion:
@ -331,19 +348,22 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
else
{
ProjectExact(
record,
canonical,
result.Snapshot,
LiveProjectionPurpose.LogicalRegistration,
expectedCreateIntegrationVersion:
createIntegrationVersion);
}
if (_runtime.IsCurrentRecord(record)
&& record.AppearanceProjectionSynchronizationPending)
AppearanceSynchronization:
if (_runtime.TryGetProjection(
canonical,
out LiveEntityRecord? currentProjection)
&& currentProjection.AppearanceProjectionSynchronizationPending)
{
SynchronizeAppearance(
record,
record.ObjDescAuthorityVersion);
currentProjection,
currentProjection.ObjDescAuthorityVersion);
}
}
}
@ -375,12 +395,25 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
{
lock (_datLock)
{
if (!_runtime.TryApplyObjDesc(update, out _)
|| !_runtime.TryGetRecord(update.Guid, out LiveEntityRecord record))
if (!_runtime.TryApplyObjDesc(update, out _))
{
return false;
}
if (!_runtime.TryGetRecord(
update.Guid,
out LiveEntityRecord record))
{
return _runtime.TryGetCanonical(
update.Guid,
out RuntimeEntityRecord canonical)
&& ProjectExact(
canonical,
canonical.Snapshot,
LiveProjectionPurpose.SpatialRecovery,
canonical.CreateIntegrationVersion);
}
return SynchronizeAppearance(
record,
record.ObjDescAuthorityVersion);
@ -466,42 +499,64 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
if (_runtime.Count == 0)
return;
uint canonical = (loadedLandblockId & 0xFFFF0000u) | 0xFFFFu;
LiveEntityRecord[] records = _runtime.Records
.Where(record => (record.ServerGuid != _identity.ServerGuid
|| !record.InitialHydrationCompleted
|| record.CreateProjectionSynchronizationPending
|| record.AppearanceProjectionSynchronizationPending)
&& record.ProjectionCellId != 0
&& ((record.ProjectionCellId & 0xFFFF0000u) | 0xFFFFu) == canonical
&& record.Snapshot.SetupTableId is not null)
.ToArray();
if (records.Length == 0)
uint canonicalLandblock =
(loadedLandblockId & 0xFFFF0000u) | 0xFFFFu;
var candidates = new List<RuntimeEntityRecord>();
foreach (RuntimeEntityRecord candidate in _runtime.CanonicalRecords)
{
_runtime.TryGetProjection(
candidate,
out LiveEntityRecord? projection);
if (candidate.ServerGuid == _identity.ServerGuid
&& projection is not null
&& projection.InitialHydrationCompleted
&& !projection.CreateProjectionSynchronizationPending
&& !projection.AppearanceProjectionSynchronizationPending)
{
continue;
}
uint projectionCellId = projection?.ProjectionCellId
?? candidate.Snapshot.Position?.LandblockId
?? candidate.FullCellId;
if (projectionCellId != 0
&& ((projectionCellId & 0xFFFF0000u) | 0xFFFFu)
== canonicalLandblock
&& candidate.Snapshot.SetupTableId is not null)
{
candidates.Add(candidate);
}
}
if (candidates.Count == 0)
return;
int projected = 0;
lock (_datLock)
{
foreach (LiveEntityRecord record in records)
foreach (RuntimeEntityRecord candidate in candidates)
{
if (!_runtime.IsCurrentRecord(record))
if (!_runtime.IsCurrentCanonical(candidate))
continue;
ulong projectedObjDescVersion = record.ObjDescAuthorityVersion;
_runtime.TryGetProjection(
candidate,
out LiveEntityRecord? record);
ulong projectedObjDescVersion =
candidate.ObjDescAuthorityVersion;
bool projectedCanonicalAppearance = false;
if (record.CreateProjectionSynchronizationPending)
if (record?.CreateProjectionSynchronizationPending is true)
{
if (ProjectExact(
record,
record.Snapshot,
candidate,
candidate.Snapshot,
LiveProjectionPurpose.CreateSupersessionRecovery))
{
projected++;
projectedCanonicalAppearance = true;
}
}
else if (record.WorldEntity is not null
else if (record?.WorldEntity is not null
&& record.InitialHydrationCompleted)
{
if (_runtime.RebucketLiveEntity(
@ -512,15 +567,17 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
}
}
else if (ProjectExact(
record,
record.Snapshot,
candidate,
candidate.Snapshot,
LiveProjectionPurpose.SpatialRecovery))
{
projected++;
projectedCanonicalAppearance = true;
}
if (projectedCanonicalAppearance
_runtime.TryGetProjection(candidate, out record);
if (record is not null
&& projectedCanonicalAppearance
&& record.AppearanceProjectionSynchronizationPending)
{
CompleteAppearanceProjectionSynchronization(
@ -528,7 +585,8 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
projectedObjDescVersion);
}
if (_runtime.IsCurrentRecord(record)
if (record is not null
&& _runtime.IsCurrentRecord(record)
&& record.AppearanceProjectionSynchronizationPending
&& SynchronizeAppearance(
record,
@ -684,7 +742,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
else
{
published = _materializer.TryMaterialize(
expectedRecord,
expectedRecord.Canonical,
expectedRecord.Snapshot,
LiveProjectionPurpose.AppearanceMutation,
expectedRecord.CreateIntegrationVersion,
@ -715,78 +773,138 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
LiveEntityRecord expectedRecord,
WorldSession.EntitySpawn acceptedSpawn,
LiveProjectionPurpose purpose,
ulong? expectedCreateIntegrationVersion = null) =>
ProjectExact(
expectedRecord.Canonical,
acceptedSpawn,
purpose,
expectedCreateIntegrationVersion);
private bool ProjectExact(
RuntimeEntityRecord expectedCanonical,
WorldSession.EntitySpawn acceptedSpawn,
LiveProjectionPurpose purpose,
ulong? expectedCreateIntegrationVersion = null)
{
while (true)
if (_projectionOperations.TryGetValue(
expectedCanonical,
out CanonicalProjectionOperation? active))
{
ulong createIntegrationVersion = expectedCreateIntegrationVersion
?? expectedRecord.CreateIntegrationVersion;
if (purpose is LiveProjectionPurpose.CreateSupersessionRecovery
&& !_runtime.TryMarkCreateProjectionSynchronizationPending(
expectedRecord,
createIntegrationVersion))
if (expectedCanonical.CreateIntegrationVersion
!= active.CreateIntegrationVersion)
{
return false;
}
if (!_runtime.TryBeginProjectionHydration(
expectedRecord,
createIntegrationVersion))
{
return false;
active.RetryRequested = true;
}
return false;
}
bool retry;
bool result;
try
var operation = new CanonicalProjectionOperation();
_projectionOperations.Add(expectedCanonical, operation);
try
{
while (true)
{
result = ProjectExactOnce(
expectedRecord,
ulong createIntegrationVersion = expectedCreateIntegrationVersion
?? expectedCanonical.CreateIntegrationVersion;
if (!_runtime.IsCurrentCreateIntegration(
expectedCanonical,
createIntegrationVersion))
{
return false;
}
if (purpose is LiveProjectionPurpose.CreateSupersessionRecovery
&& _runtime.TryGetProjection(
expectedCanonical,
out LiveEntityRecord? synchronizedProjection)
&& !_runtime.TryMarkCreateProjectionSynchronizationPending(
synchronizedProjection,
createIntegrationVersion))
{
return false;
}
operation.RetryRequested = false;
operation.CreateIntegrationVersion =
createIntegrationVersion;
bool result = ProjectExactOnce(
expectedCanonical,
acceptedSpawn,
purpose,
createIntegrationVersion);
bool retry = operation.RetryRequested
|| (_runtime.IsCurrentCanonical(expectedCanonical)
&& expectedCanonical.CreateIntegrationVersion
!= createIntegrationVersion);
if (!retry || !_runtime.IsCurrentCanonical(expectedCanonical))
return result;
// A fresher same-incarnation CreateObject or a synchronous
// loaded-landblock callback re-entered this exact canonical
// construction. Resume immediately from Runtime's newest
// state; no projection work is deferred to another frame.
if (_runtime.TryGetProjection(
expectedCanonical,
out LiveEntityRecord? retryProjection))
{
retryProjection.CreateProjectionSynchronizationPending = true;
}
acceptedSpawn = expectedCanonical.Snapshot;
purpose = LiveProjectionPurpose.CreateSupersessionRecovery;
expectedCreateIntegrationVersion =
expectedCanonical.CreateIntegrationVersion;
}
finally
}
catch
{
if (_runtime.IsCurrentCanonical(expectedCanonical)
&& _runtime.TryGetProjection(
expectedCanonical,
out LiveEntityRecord? interruptedProjection))
{
retry = _runtime.EndProjectionHydration(expectedRecord);
interruptedProjection.CreateProjectionSynchronizationPending = true;
}
throw;
}
finally
{
if (!_projectionOperations.Remove(expectedCanonical, out var removed)
|| !ReferenceEquals(removed, operation))
{
throw new InvalidOperationException(
"Canonical projection construction ownership was corrupted.");
}
if (!retry || !_runtime.IsCurrentRecord(expectedRecord))
return result;
// A fresher same-incarnation CreateObject arrived while this
// transaction owned the hydration edge. Resume in-place from the
// newest canonical snapshot; logical resources remain registered.
acceptedSpawn = expectedRecord.Snapshot;
purpose = LiveProjectionPurpose.CreateSupersessionRecovery;
expectedCreateIntegrationVersion =
expectedRecord.CreateIntegrationVersion;
}
}
private bool ProjectExactOnce(
LiveEntityRecord expectedRecord,
RuntimeEntityRecord expectedCanonical,
WorldSession.EntitySpawn acceptedSpawn,
LiveProjectionPurpose purpose,
ulong createIntegrationVersion)
{
_relationships.OnSpawn(acceptedSpawn);
if (!_runtime.IsCurrentCreateIntegration(
expectedRecord,
expectedCanonical,
createIntegrationVersion))
return false;
// A queued Parent event can synchronously mutate the canonical
// Position/Parent snapshot. Never continue from the stale argument.
WorldSession.EntitySpawn canonicalSpawn = expectedRecord.Snapshot;
WorldSession.EntitySpawn canonicalSpawn = expectedCanonical.Snapshot;
InitializeOriginAndRecoverLoaded(canonicalSpawn);
if (!_runtime.IsCurrentCreateIntegration(
expectedRecord,
expectedCanonical,
createIntegrationVersion)
|| !_origin.IsKnown)
{
return false;
}
_runtime.TryGetProjection(
expectedCanonical,
out LiveEntityRecord? expectedRecord);
// Retail's same-instance CreateObject branch completes a POSITION_TS
// with no world Position through DoParentEvent or DoPickupEvent, not
// through CPhysicsObj::enter_world. The relationship/update owners
@ -797,6 +915,9 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
{
if (canonicalSpawn.ParentGuid is not null and not 0)
{
if (expectedRecord is null)
return false;
if (expectedRecord.InitialHydrationCompleted
&& !expectedRecord.CreateProjectionSynchronizationPending)
{
@ -822,19 +943,20 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
createIntegrationVersion);
}
if (expectedRecord.FullCellId != 0
|| expectedRecord.IsSpatiallyProjected)
if (expectedCanonical.FullCellId != 0
|| expectedRecord?.IsSpatiallyProjected is true)
{
return false;
}
if (expectedRecord.WorldEntity is null)
if (expectedRecord?.WorldEntity is null)
{
// A pickup can supersede initial hydration before a render
// projection exists. The logical snapshot/table state is
// already canonical; ready remains false so a later world
// Position still constructs and publishes the first owner.
return !expectedRecord.CreateProjectionSynchronizationPending
return expectedRecord is null
|| !expectedRecord.CreateProjectionSynchronizationPending
|| _runtime.TryCompleteCreateProjectionSynchronization(
expectedRecord,
createIntegrationVersion);
@ -854,19 +976,26 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
}
bool materialized = _materializer.TryMaterialize(
expectedRecord,
expectedRecord.Snapshot,
expectedCanonical,
expectedCanonical.Snapshot,
purpose,
createIntegrationVersion);
if (!materialized
|| !_runtime.IsCurrentCreateIntegration(
expectedRecord,
expectedCanonical,
createIntegrationVersion)
|| purpose is LiveProjectionPurpose.AppearanceMutation)
{
return materialized;
}
if (!_runtime.TryGetProjection(
expectedCanonical,
out expectedRecord))
{
return false;
}
if (!PublishReady(
expectedRecord,
createIntegrationVersion))

View file

@ -1,55 +0,0 @@
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

@ -1,17 +1,25 @@
using AcDream.Core.Net.Messages;
using AcDream.App.Input;
using AcDream.Runtime.Entities;
namespace AcDream.App.World;
internal readonly record struct LiveEntityLivenessSample(
RuntimeEntityKey Key,
uint ServerGuid,
ushort Generation,
bool IsConservativelyVisible,
bool HasNonWorldRetention);
internal readonly record struct LiveEntityPruneCandidate(
RuntimeEntityKey Key,
uint ServerGuid,
ushort Generation);
ushort Generation)
{
public LiveEntityPruneCandidate(RuntimeEntityKey key, uint serverGuid)
: this(key, serverGuid, key.Incarnation)
{
}
}
/// <summary>
/// Deadline state for ACE's retained KnownObjects behavior. Retail
@ -23,7 +31,10 @@ internal sealed class LiveEntityLivenessTracker
{
internal const double DestructionTimeoutSeconds = 25.0;
private readonly Dictionary<uint, Deadline> _deadlines = new();
private readonly Dictionary<RuntimeEntityKey, double> _deadlines = [];
private readonly HashSet<RuntimeEntityKey> _present = [];
private readonly List<RuntimeEntityKey> _stale = [];
private readonly List<LiveEntityPruneCandidate> _due = [];
internal int DeadlineCount => _deadlines.Count;
@ -31,46 +42,44 @@ internal sealed class LiveEntityLivenessTracker
double now,
IReadOnlyList<LiveEntityLivenessSample> samples)
{
var present = new HashSet<uint>(samples.Count);
var due = new List<LiveEntityPruneCandidate>();
_present.Clear();
_due.Clear();
for (int i = 0; i < samples.Count; i++)
{
LiveEntityLivenessSample sample = samples[i];
present.Add(sample.ServerGuid);
_present.Add(sample.Key);
if (sample.IsConservativelyVisible || sample.HasNonWorldRetention)
{
_deadlines.Remove(sample.ServerGuid);
_deadlines.Remove(sample.Key);
continue;
}
if (!_deadlines.TryGetValue(sample.ServerGuid, out Deadline deadline)
|| deadline.Generation != sample.Generation)
if (!_deadlines.TryGetValue(sample.Key, out double expiresAt))
{
_deadlines[sample.ServerGuid] = new Deadline(
sample.Generation,
now + DestructionTimeoutSeconds);
_deadlines[sample.Key] = now + DestructionTimeoutSeconds;
continue;
}
if (deadline.ExpiresAt > now)
if (expiresAt > now)
continue;
due.Add(new LiveEntityPruneCandidate(sample.ServerGuid, sample.Generation));
_deadlines.Remove(sample.ServerGuid);
_due.Add(new LiveEntityPruneCandidate(sample.Key, sample.ServerGuid));
_deadlines.Remove(sample.Key);
}
uint[] stale = _deadlines.Keys
.Where(guid => !present.Contains(guid))
.ToArray();
for (int i = 0; i < stale.Length; i++)
_deadlines.Remove(stale[i]);
_stale.Clear();
foreach (RuntimeEntityKey key in _deadlines.Keys)
{
if (!_present.Contains(key))
_stale.Add(key);
}
for (int i = 0; i < _stale.Count; i++)
_deadlines.Remove(_stale[i]);
return due;
return _due;
}
internal void Clear() => _deadlines.Clear();
private readonly record struct Deadline(ushort Generation, double ExpiresAt);
}
/// <summary>
@ -134,8 +143,11 @@ internal sealed class LiveEntityLivenessController
|| NonZero(record.Snapshot.WielderId)
|| NonZero(record.Snapshot.ParentGuid);
_samples.Add(new LiveEntityLivenessSample(
record.ProjectionKey
?? throw new InvalidOperationException(
$"Materialized liveness owner 0x{record.ServerGuid:X8}/" +
$"{record.Generation} has no exact projection key."),
record.ServerGuid,
record.Generation,
record.IsSpatiallyVisible
|| IsWithinConservativeVisibility(playerPosition, position),
retained));
@ -145,8 +157,8 @@ internal sealed class LiveEntityLivenessController
for (int i = 0; i < due.Count; i++)
{
LiveEntityPruneCandidate candidate = due[i];
if (_runtime.TryGetRecord(candidate.ServerGuid, out LiveEntityRecord current)
&& current.Generation == candidate.Generation)
if (_runtime.TryGetRecord(candidate.Key, out LiveEntityRecord current)
&& current.ServerGuid == candidate.ServerGuid)
{
_prune.Prune(candidate);
}

View file

@ -1,5 +1,6 @@
using AcDream.App.Physics;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
namespace AcDream.App.World;
@ -28,9 +29,9 @@ public sealed class LiveEntityPresentationController : IDisposable
private readonly Func<(int X, int Y)> _liveCenter;
private readonly Action<uint>? _onShadowRestored;
private readonly LiveEntityPartArrayEnterWorldPort _partArrayEnterWorld;
private readonly Dictionary<uint, ushort> _readyGenerationByGuid = new();
private readonly Dictionary<uint, ushort> _suspendedShadowGenerationByGuid = new();
private readonly Dictionary<uint, ushort> _activePlacementGenerationByGuid = new();
private readonly HashSet<RuntimeEntityKey> _readyOwners = [];
private readonly HashSet<RuntimeEntityKey> _suspendedShadowOwners = [];
private readonly HashSet<RuntimeEntityKey> _activePlacementOwners = [];
private readonly HashSet<LiveEntityRecord> _drainingRecords = new();
private bool _disposed;
@ -70,7 +71,8 @@ public sealed class LiveEntityPresentationController : IDisposable
return false;
}
_readyGenerationByGuid[serverGuid] = record.Generation;
RuntimeEntityKey key = RequireProjectionKey(record);
_readyOwners.Add(key);
if (!ApplyPendingTransitions(record)
|| record.WorldEntity is not { } entity
|| !IsCurrent(record, entity))
@ -91,8 +93,8 @@ public sealed class LiveEntityPresentationController : IDisposable
public bool OnStateAccepted(uint serverGuid)
{
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| !_readyGenerationByGuid.TryGetValue(serverGuid, out ushort generation)
|| generation != record.Generation)
|| !TryGetProjectionKey(record, out RuntimeEntityKey key)
|| !_readyOwners.Contains(key))
{
return false;
}
@ -126,21 +128,13 @@ public sealed class LiveEntityPresentationController : IDisposable
return false;
}
if (_activePlacementGenerationByGuid.TryGetValue(
serverGuid,
out ushort activeGeneration)
&& activeGeneration == generation)
{
_activePlacementGenerationByGuid.Remove(serverGuid);
}
RuntimeEntityKey key = RequireProjectionKey(record);
_activePlacementOwners.Remove(key);
if (deferShadowRestore)
_suspendedShadowGenerationByGuid[serverGuid] = generation;
else if (_suspendedShadowGenerationByGuid.TryGetValue(
serverGuid,
out ushort deferredGeneration)
&& deferredGeneration == generation)
_suspendedShadowGenerationByGuid.Remove(serverGuid);
_suspendedShadowOwners.Add(key);
else
_suspendedShadowOwners.Remove(key);
return true;
}
@ -158,57 +152,41 @@ public sealed class LiveEntityPresentationController : IDisposable
return false;
}
_activePlacementGenerationByGuid[serverGuid] = generation;
if (_suspendedShadowGenerationByGuid.TryGetValue(
serverGuid,
out ushort deferredGeneration)
&& deferredGeneration == generation)
{
_suspendedShadowGenerationByGuid.Remove(serverGuid);
}
RuntimeEntityKey key = RequireProjectionKey(record);
_activePlacementOwners.Add(key);
_suspendedShadowOwners.Remove(key);
return true;
}
internal bool HasDeferredShadowRestore(uint serverGuid) =>
_suspendedShadowGenerationByGuid.ContainsKey(serverGuid);
TryGetCurrentProjectionKey(serverGuid, out RuntimeEntityKey key)
&& _suspendedShadowOwners.Contains(key);
internal bool HasActivePlacement(uint serverGuid) =>
_activePlacementGenerationByGuid.ContainsKey(serverGuid);
TryGetCurrentProjectionKey(serverGuid, out RuntimeEntityKey key)
&& _activePlacementOwners.Contains(key);
internal int ReadyOwnerCount => _readyGenerationByGuid.Count;
internal int DeferredShadowRestoreCount => _suspendedShadowGenerationByGuid.Count;
internal int ActivePlacementCount => _activePlacementGenerationByGuid.Count;
internal int ReadyOwnerCount => _readyOwners.Count;
internal int DeferredShadowRestoreCount => _suspendedShadowOwners.Count;
internal int ActivePlacementCount => _activePlacementOwners.Count;
public void Forget(LiveEntityRecord record)
{
ArgumentNullException.ThrowIfNull(record);
if (_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort generation)
&& generation == record.Generation)
if (TryGetProjectionKey(record, out RuntimeEntityKey key))
{
_readyGenerationByGuid.Remove(record.ServerGuid);
}
if (_suspendedShadowGenerationByGuid.TryGetValue(
record.ServerGuid,
out ushort suspendedGeneration)
&& suspendedGeneration == record.Generation)
{
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
}
if (_activePlacementGenerationByGuid.TryGetValue(
record.ServerGuid,
out ushort activeGeneration)
&& activeGeneration == record.Generation)
{
_activePlacementGenerationByGuid.Remove(record.ServerGuid);
_readyOwners.Remove(key);
_suspendedShadowOwners.Remove(key);
_activePlacementOwners.Remove(key);
}
_drainingRecords.Remove(record);
}
public void Clear()
{
_readyGenerationByGuid.Clear();
_suspendedShadowGenerationByGuid.Clear();
_activePlacementGenerationByGuid.Clear();
_readyOwners.Clear();
_suspendedShadowOwners.Clear();
_activePlacementOwners.Clear();
_drainingRecords.Clear();
}
@ -256,7 +234,7 @@ public sealed class LiveEntityPresentationController : IDisposable
return false;
_shadows.Suspend(entity.Id);
if (!IsPlacementActive(record))
_suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation;
_suspendedShadowOwners.Add(RequireProjectionKey(record));
// Retail CPhysicsObj::set_hidden @ 0x00514C60 calls
// CPartArray::HandleEnterWorld after hiding the object
// from its cell. Despite the name, this is the motion
@ -288,7 +266,7 @@ public sealed class LiveEntityPresentationController : IDisposable
if (!IsCurrent(record, entity))
return false;
if (restored)
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
_suspendedShadowOwners.Remove(RequireProjectionKey(record));
break;
}
}
@ -345,12 +323,9 @@ public sealed class LiveEntityPresentationController : IDisposable
if ((record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0
|| IsPlacementActive(record)
|| !_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort readyGeneration)
|| readyGeneration != record.Generation
|| !_suspendedShadowGenerationByGuid.TryGetValue(
record.ServerGuid,
out ushort suspendedGeneration)
|| suspendedGeneration != record.Generation)
|| !TryGetProjectionKey(record, out RuntimeEntityKey key)
|| !_readyOwners.Contains(key)
|| !_suspendedShadowOwners.Contains(key))
{
return;
}
@ -359,7 +334,7 @@ public sealed class LiveEntityPresentationController : IDisposable
if (!IsCurrent(record, entity))
return;
if (restored)
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
_suspendedShadowOwners.Remove(key);
}
private void SuspendOrdinaryShadowOutsideProjection(
@ -369,16 +344,14 @@ public sealed class LiveEntityPresentationController : IDisposable
if (record.IsSpatiallyVisible
|| record.ProjectileRuntime is not null
|| IsPlacementActive(record)
|| !_readyGenerationByGuid.TryGetValue(
record.ServerGuid,
out ushort readyGeneration)
|| readyGeneration != record.Generation
|| !TryGetProjectionKey(record, out RuntimeEntityKey key)
|| !_readyOwners.Contains(key)
|| !_shadows.Suspend(entity.Id))
{
return;
}
_suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation;
_suspendedShadowOwners.Add(key);
}
private bool IsCurrent(
@ -390,8 +363,42 @@ public sealed class LiveEntityPresentationController : IDisposable
&& ReferenceEquals(current.WorldEntity, entity);
private bool IsPlacementActive(LiveEntityRecord record) =>
_activePlacementGenerationByGuid.TryGetValue(
record.ServerGuid,
out ushort generation)
&& generation == record.Generation;
TryGetProjectionKey(record, out RuntimeEntityKey key)
&& _activePlacementOwners.Contains(key);
private bool TryGetCurrentProjectionKey(
uint serverGuid,
out RuntimeEntityKey key)
{
if (_liveEntities.TryGetRecord(
serverGuid,
out LiveEntityRecord record))
{
return TryGetProjectionKey(record, out key);
}
key = default;
return false;
}
private static bool TryGetProjectionKey(
LiveEntityRecord record,
out RuntimeEntityKey key)
{
if (record.ProjectionKey is { } projectionKey)
{
key = projectionKey;
return true;
}
key = default;
return false;
}
private static RuntimeEntityKey RequireProjectionKey(
LiveEntityRecord record) =>
record.ProjectionKey
?? throw new InvalidOperationException(
$"Live entity 0x{record.ServerGuid:X8}/{record.Generation} " +
"has no exact projection key.");
}

View file

@ -0,0 +1,313 @@
using AcDream.Runtime.Entities;
namespace AcDream.App.World;
/// <summary>
/// App-only projection storage. RuntimeEntityDirectory remains the sole
/// server-GUID/incarnation/local-ID authority; every App sidecar exists only
/// after Runtime has issued its exact local-ID plus INSTANCE_TS key.
/// </summary>
internal sealed class LiveEntityProjectionStore
{
private readonly RuntimeEntityDirectory _directory;
private readonly Dictionary<RuntimeEntityKey, LiveEntityRecord> _materialized = new();
private readonly Dictionary<RuntimeEntityKey, LiveEntityRecord> _visible = new();
private readonly Dictionary<RuntimeEntityKey, LiveEntityRecord> _teardown = new();
private readonly Dictionary<RuntimeEntityRecord, LiveEntityRecord>
_unmaterializedTeardown = new(ReferenceEqualityComparer.Instance);
private readonly List<LiveEntityRecord> _materializedInRegistrationOrder = [];
private readonly TeardownRecordCollection _teardownRecords;
public LiveEntityProjectionStore(RuntimeEntityDirectory directory)
{
_directory = directory ?? throw new ArgumentNullException(nameof(directory));
_teardownRecords = new TeardownRecordCollection(
_teardown,
_unmaterializedTeardown);
}
public int ActiveCount => _materializedInRegistrationOrder.Count;
public int MaterializedCount => _materialized.Count + _teardown.Count;
public int TeardownCount => _teardown.Count + _unmaterializedTeardown.Count;
public IReadOnlyList<LiveEntityRecord> ActiveRecords =>
_materializedInRegistrationOrder;
public IReadOnlyList<LiveEntityRecord> Values =>
_materializedInRegistrationOrder;
public IReadOnlyCollection<LiveEntityRecord> MaterializedRecords =>
_materialized.Values;
public IReadOnlyCollection<LiveEntityRecord> VisibleRecords => _visible.Values;
public IReadOnlyCollection<LiveEntityRecord> TeardownRecords =>
_teardownRecords;
/// <summary>
/// Creates the exact App sidecar after Runtime has claimed a local ID.
/// Publication is synchronous so no input or presentation callback moves
/// to a later frame.
/// </summary>
public LiveEntityRecord AddMaterializing(RuntimeEntityRecord canonical)
{
ArgumentNullException.ThrowIfNull(canonical);
if (!_directory.IsCurrent(canonical))
{
throw new InvalidOperationException(
"An App projection sidecar can only be added for the current Runtime incarnation.");
}
RuntimeEntityKey key = canonical.Key
?? throw new InvalidOperationException(
"Runtime must claim the local projection ID before App creates a sidecar.");
if (_materialized.ContainsKey(key))
{
throw new InvalidOperationException(
$"Runtime entity 0x{canonical.ServerGuid:X8}/{canonical.Incarnation} already has an App sidecar.");
}
var record = new LiveEntityRecord(_directory, canonical)
{
ProjectionKey = key,
};
_materialized.Add(key, record);
_materializedInRegistrationOrder.Add(record);
return record;
}
public bool TryGetCurrent(uint serverGuid, out LiveEntityRecord record)
{
if (_directory.TryGetActive(serverGuid, out RuntimeEntityRecord canonical))
return TryGet(canonical, out record);
record = null!;
return false;
}
public bool ContainsCurrent(uint serverGuid) =>
TryGetCurrent(serverGuid, out _);
public LiveEntityRecord? GetCurrentOrDefault(uint serverGuid) =>
TryGetCurrent(serverGuid, out LiveEntityRecord record) ? record : null;
public bool TryGet(RuntimeEntityRecord canonical, out LiveEntityRecord record)
{
if (canonical.Key is { } key
&& _materialized.TryGetValue(key, out LiveEntityRecord? candidate)
&& ReferenceEquals(candidate.Canonical, canonical))
{
record = candidate;
return true;
}
record = null!;
return false;
}
public bool TryGet(RuntimeEntityKey key, out LiveEntityRecord record) =>
_materialized.TryGetValue(key, out record!);
public bool TryGetByLocalId(uint localEntityId, out LiveEntityRecord record)
{
if (_directory.TryGetByLocalId(
localEntityId,
out RuntimeEntityRecord canonical))
{
return TryGet(canonical, out record);
}
record = null!;
return false;
}
public bool IsCurrent(LiveEntityRecord record) =>
_directory.IsCurrent(record.Canonical)
&& TryGet(record.Canonical, out LiveEntityRecord current)
&& ReferenceEquals(current, record);
public void SetVisible(LiveEntityRecord record, bool visible)
{
RuntimeEntityKey key = RequireProjectionKey(record);
if (!_materialized.TryGetValue(key, out LiveEntityRecord? materialized)
|| !ReferenceEquals(materialized, record))
{
throw new InvalidOperationException(
"Only a current exact App sidecar can change visibility.");
}
if (visible)
_visible[key] = record;
else
_visible.Remove(key);
}
public bool TryGetVisibleCurrent(uint serverGuid, out LiveEntityRecord record)
{
if (_directory.TryGetActive(serverGuid, out RuntimeEntityRecord canonical)
&& canonical.Key is { } key
&& _visible.TryGetValue(key, out LiveEntityRecord? visible)
&& ReferenceEquals(visible.Canonical, canonical))
{
record = visible;
return true;
}
record = null!;
return false;
}
public bool RemoveCurrent(
uint serverGuid,
out LiveEntityRecord? record)
{
if (!_directory.TryGetActive(
serverGuid,
out RuntimeEntityRecord canonical)
|| !TryGet(canonical, out LiveEntityRecord sidecar)
|| !RemoveActive(sidecar))
{
record = null;
return false;
}
record = sidecar;
return true;
}
public bool RemoveCurrent(uint serverGuid) =>
RemoveCurrent(serverGuid, out _);
public bool RemoveActive(LiveEntityRecord record)
{
if (record.ProjectionKey is not { } key)
return false;
_visible.Remove(key);
bool removed = _materialized.Remove(
key,
out LiveEntityRecord? materialized)
&& ReferenceEquals(materialized, record);
if (!removed)
return false;
if (!_materializedInRegistrationOrder.Remove(record))
{
throw new InvalidOperationException(
"The exact App sidecar was absent from materialization order.");
}
return true;
}
public void RetainTeardown(LiveEntityRecord record)
{
if (record.ProjectionKey is { } key)
AddExact(_teardown, key, record);
else
AddExact(_unmaterializedTeardown, record.Canonical, record);
}
public bool TryGetTeardown(
RuntimeEntityRecord canonical,
out LiveEntityRecord record)
{
if (canonical.Key is { } key
&& _teardown.TryGetValue(key, out record!))
{
return true;
}
foreach (LiveEntityRecord retained in _teardown.Values)
{
if (ReferenceEquals(retained.Canonical, canonical))
{
record = retained;
return true;
}
}
return _unmaterializedTeardown.TryGetValue(canonical, out record!);
}
public void ReleaseTeardown(LiveEntityRecord record)
{
if (record.ProjectionKey is { } key)
{
RemoveExact(_teardown, key, record);
record.ProjectionKey = null;
return;
}
RemoveExact(_unmaterializedTeardown, record.Canonical, record);
}
public bool IsRetainedTeardown(LiveEntityRecord record) =>
TryGetTeardown(record.Canonical, out LiveEntityRecord retained)
&& ReferenceEquals(retained, record);
public void ClearConverged()
{
if (_materializedInRegistrationOrder.Count != 0 || TeardownCount != 0)
{
throw new InvalidOperationException(
"Projection storage cannot clear before active and teardown ownership converges.");
}
_materialized.Clear();
_visible.Clear();
_teardown.Clear();
_unmaterializedTeardown.Clear();
}
private static RuntimeEntityKey RequireProjectionKey(LiveEntityRecord record) =>
record.ProjectionKey
?? throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Generation} has no App projection key.");
private static void AddExact<TKey>(
Dictionary<TKey, LiveEntityRecord> destination,
TKey key,
LiveEntityRecord record)
where TKey : notnull
{
if (destination.TryGetValue(key, out LiveEntityRecord? retained)
&& !ReferenceEquals(retained, record))
{
throw new InvalidOperationException(
$"App teardown sidecar collision for 0x{record.ServerGuid:X8}/{record.Generation}.");
}
destination[key] = record;
}
private static void RemoveExact<TKey>(
Dictionary<TKey, LiveEntityRecord> source,
TKey key,
LiveEntityRecord record)
where TKey : notnull
{
if (source.TryGetValue(key, out LiveEntityRecord? retained)
&& ReferenceEquals(retained, record))
{
source.Remove(key);
}
}
private sealed class TeardownRecordCollection :
IReadOnlyCollection<LiveEntityRecord>
{
private readonly Dictionary<RuntimeEntityKey, LiveEntityRecord> _keyed;
private readonly Dictionary<RuntimeEntityRecord, LiveEntityRecord> _unmaterialized;
public TeardownRecordCollection(
Dictionary<RuntimeEntityKey, LiveEntityRecord> keyed,
Dictionary<RuntimeEntityRecord, LiveEntityRecord> unmaterialized)
{
_keyed = keyed;
_unmaterialized = unmaterialized;
}
public int Count => _keyed.Count + _unmaterialized.Count;
public IEnumerator<LiveEntityRecord> GetEnumerator()
{
foreach (LiveEntityRecord record in _keyed.Values)
yield return record;
foreach (LiveEntityRecord record in _unmaterialized.Values)
yield return record;
}
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() =>
GetEnumerator();
}
}

File diff suppressed because it is too large Load diff

View file

@ -106,11 +106,6 @@ internal sealed class LiveEntityRuntimeTeardownController
{
LiveEntityRuntime runtime = _runtime!;
uint serverGuid = record.ServerGuid;
var incarnation = new LiveEntityIncarnationCleanup(
record,
guid => runtime.TryGetRecord(guid, out LiveEntityRecord current)
? current
: null);
var cleanups = new List<Action>
{
() => _presentation!.Forget(record),
@ -152,17 +147,17 @@ internal sealed class LiveEntityRuntimeTeardownController
cleanups.Add(physicsHost.NotifyExitWorld);
}
cleanups.Add(() => _animations.Remove(existingEntity.Id));
cleanups.Add(() => _animations.Remove(record));
cleanups.Add(() => _classification!.InvalidateEntity(existingEntity.Id));
cleanups.Add(() => incarnation.RunIfNoReplacement(
() => _remoteMovementObservations!.Remove(serverGuid)));
if (record.ProjectionKey is { } projectionKey)
cleanups.Add(() => _remoteMovementObservations!.Remove(projectionKey));
cleanups.Add(() => _translucencyFades!.ClearEntity(existingEntity.Id));
cleanups.Add(() => _projectionWithdrawal!.LeaveWorld(
record,
_identity!.ServerGuid));
cleanups.Add(() => _children!.OnLogicalTeardown(record));
cleanups.Add(() => _shadows!.Deregister(existingEntity.Id));
cleanups.Add(() => _lights!.Forget(existingEntity.Id));
cleanups.Add(() => _lights!.Forget(record));
return new LiveEntityTeardownPlan(cleanups);
}
}

View file

@ -15,9 +15,7 @@ public sealed class LiveEntityAnimationRuntimeView<TAnimation>
internal LiveEntityAnimationRuntimeView(ILiveEntityRuntimeSource runtime) =>
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
public int Count => _runtime.Current?.SpatialAnimationRuntimes.Count ?? 0;
public IEnumerable<uint> Keys =>
_runtime.Current?.SpatialAnimationRuntimes.Keys ?? Array.Empty<uint>();
public int Count => _runtime.Current?.SpatialAnimationRuntimeCount ?? 0;
public TAnimation this[uint localEntityId]
{
@ -52,6 +50,12 @@ public sealed class LiveEntityAnimationRuntimeView<TAnimation>
&& runtime.ClearAnimationRuntime(guid);
}
internal bool Remove(LiveEntityRecord record)
{
ArgumentNullException.ThrowIfNull(record);
return _runtime.Current?.ClearAnimationRuntime(record) == true;
}
/// <summary>Copies only currently resident local IDs without boxing a
/// dictionary-key enumerator.</summary>
public void CopySpatialIdsTo(HashSet<uint> destination)
@ -110,10 +114,9 @@ public sealed class LiveEntityAnimationRuntimeView<TAnimation>
while (++_index < _snapshot.Count)
{
KeyValuePair<uint, TAnimation> pair = _snapshot[_index];
if (_runtime?.SpatialAnimationRuntimes.TryGetValue(
if (_runtime?.IsCurrentSpatialAnimation(
pair.Key,
out ILiveEntityAnimationRuntime? indexed) == true
&& ReferenceEquals(indexed, pair.Value))
pair.Value) == true)
{
Current = pair;
return true;