refactor(runtime): own canonical entity identity and incarnations
Introduce the presentation-free RuntimeEntityDirectory and RuntimeEntityRecord as the sole owners of server GUIDs, INSTANCE_TS incarnations, accepted snapshots, authority versions, retryable tombstones, session epochs, and local-ID allocation. Convert LiveEntityRuntime into an App projection sidecar host resolved through canonical record identity without a second GUID map. Preserve the existing synchronous and reentrant projection lifecycle, including retryable registration/delete/session teardown. Add Runtime-only identity/lifetime tests and App ownership guards. Validated by the Release solution build and 8,441 complete Release tests with five existing skips. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
f7442d13e9
commit
f46ddb5cdb
5 changed files with 1218 additions and 225 deletions
|
|
@ -209,48 +209,78 @@ public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLif
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one logical record for a server object incarnation. The record survives
|
||||
/// loaded/pending landblock movement and temporary loss of its render bucket.
|
||||
/// Only an accepted DeleteObject, session reset, newer INSTANCE_TS, or the
|
||||
/// active side of the leave-visibility lifecycle ends it. ACE-compatible
|
||||
/// cold snapshot ownership lives outside this active runtime.
|
||||
/// App projection sidecar for one canonical <see cref="RuntimeEntityRecord"/>.
|
||||
/// The canonical record survives loaded/pending landblock movement and
|
||||
/// temporary loss of its render bucket; this sidecar retains only graphical
|
||||
/// components, visibility, hydration, and teardown progress.
|
||||
/// </summary>
|
||||
public sealed class LiveEntityRecord
|
||||
{
|
||||
private readonly Queue<RetailPhysicsStateTransition> _pendingStateTransitions = new();
|
||||
private readonly RuntimeEntityDirectory _directory;
|
||||
|
||||
internal LiveEntityRecord(WorldSession.EntitySpawn snapshot)
|
||||
internal LiveEntityRecord(
|
||||
RuntimeEntityDirectory directory,
|
||||
RuntimeEntityRecord canonical)
|
||||
{
|
||||
ServerGuid = snapshot.Guid;
|
||||
Snapshot = snapshot;
|
||||
RefreshDerivedState();
|
||||
PositionAuthorityVersion = snapshot.Position is null ? 0UL : 1UL;
|
||||
VectorAuthorityVersion = snapshot.Physics is null ? 0UL : 1UL;
|
||||
VelocityAuthorityVersion = snapshot.Position is null
|
||||
&& snapshot.Physics is null
|
||||
? 0UL
|
||||
: 1UL;
|
||||
MovementAuthorityVersion = 1UL;
|
||||
ObjDescAuthorityVersion = 1UL;
|
||||
FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState;
|
||||
ApplyRawPhysicsState(RawPhysicsState);
|
||||
_directory = directory ?? throw new ArgumentNullException(nameof(directory));
|
||||
Canonical = canonical ?? throw new ArgumentNullException(nameof(canonical));
|
||||
}
|
||||
|
||||
public uint ServerGuid { get; }
|
||||
public ushort Generation => Snapshot.InstanceSequence;
|
||||
public WorldSession.EntitySpawn Snapshot { get; internal set; }
|
||||
/// <summary>
|
||||
/// Detached presentation fixture used by App component tests which do not
|
||||
/// exercise live identity lookup. Production records are always created
|
||||
/// from the session's shared RuntimeEntityDirectory.
|
||||
/// </summary>
|
||||
internal LiveEntityRecord(WorldSession.EntitySpawn snapshot)
|
||||
: this(new RuntimeEntityDirectory(), snapshot)
|
||||
{
|
||||
}
|
||||
|
||||
private LiveEntityRecord(
|
||||
RuntimeEntityDirectory directory,
|
||||
WorldSession.EntitySpawn snapshot)
|
||||
: this(directory, directory.AddActive(snapshot))
|
||||
{
|
||||
}
|
||||
|
||||
internal RuntimeEntityRecord Canonical { get; }
|
||||
public uint ServerGuid => Canonical.ServerGuid;
|
||||
public ushort Generation => Canonical.Incarnation;
|
||||
public WorldSession.EntitySpawn Snapshot
|
||||
{
|
||||
get => Canonical.Snapshot;
|
||||
internal set => _directory.RefreshSnapshot(Canonical, value);
|
||||
}
|
||||
public WorldEntity? WorldEntity { get; internal set; }
|
||||
public uint? LocalEntityId => WorldEntity?.Id;
|
||||
public uint FullCellId { get; internal set; }
|
||||
public uint CanonicalLandblockId { get; internal set; }
|
||||
public uint RawPhysicsState { get; internal set; }
|
||||
public PhysicsStateFlags FinalPhysicsState { get; internal set; }
|
||||
public uint? LocalEntityId => Canonical.LocalEntityId ?? WorldEntity?.Id;
|
||||
public uint FullCellId
|
||||
{
|
||||
get => Canonical.FullCellId;
|
||||
internal set => _directory.SetFullCell(
|
||||
Canonical,
|
||||
value,
|
||||
Canonical.CanonicalLandblockId);
|
||||
}
|
||||
public uint CanonicalLandblockId
|
||||
{
|
||||
get => Canonical.CanonicalLandblockId;
|
||||
internal set => _directory.SetFullCell(
|
||||
Canonical,
|
||||
Canonical.FullCellId,
|
||||
value);
|
||||
}
|
||||
public uint RawPhysicsState => Canonical.RawPhysicsState;
|
||||
public PhysicsStateFlags FinalPhysicsState
|
||||
{
|
||||
get => Canonical.FinalPhysicsState;
|
||||
internal set => _directory.SetFinalPhysicsState(Canonical, value);
|
||||
}
|
||||
/// <summary>
|
||||
/// Incarnation-stable retail <c>CPhysicsObj::update_time</c> owner. It
|
||||
/// survives animation and MovementManager attachment/replacement, spatial
|
||||
/// rebucketing, and appearance rehydration, and dies with this record.
|
||||
/// </summary>
|
||||
public RetailObjectQuantumClock ObjectClock { get; } = new();
|
||||
public RetailObjectQuantumClock ObjectClock => Canonical.ObjectClock;
|
||||
/// <summary>
|
||||
/// Changes whenever this record leaves or re-enters the ordinary-object
|
||||
/// workset. Unlike <see cref="ProjectionMutationVersion"/>, loaded-to-loaded
|
||||
|
|
@ -258,20 +288,36 @@ public sealed class LiveEntityRecord
|
|||
/// catch-up batch that crossed a leave/enter boundary without rejecting an
|
||||
/// ordinary same-frame cell move.
|
||||
/// </summary>
|
||||
internal ulong ObjectClockEpoch { get; private set; }
|
||||
internal ulong ObjectClockEpoch => Canonical.ObjectClockEpoch;
|
||||
/// <summary>
|
||||
/// True after this incarnation has successfully constructed its retail
|
||||
/// <c>CPartArray</c>. This is an object-lifetime fact, not an animation-
|
||||
/// runtime proxy: static poses and objects without a MotionTable still
|
||||
/// own a PartArray and participate in the 96-unit Active gate.
|
||||
/// </summary>
|
||||
public bool HasPartArray { get; internal set; }
|
||||
public PhysicsBody? PhysicsBody { get; internal set; }
|
||||
internal bool PhysicsBodyAcquisitionInProgress { get; set; }
|
||||
public bool HasPartArray
|
||||
{
|
||||
get => Canonical.HasPartArray;
|
||||
internal set => _directory.SetHasPartArray(Canonical, value);
|
||||
}
|
||||
public PhysicsBody? PhysicsBody
|
||||
{
|
||||
get => Canonical.PhysicsBody;
|
||||
internal set => _directory.SetPhysicsBody(Canonical, value);
|
||||
}
|
||||
internal bool PhysicsBodyAcquisitionInProgress
|
||||
{
|
||||
get => Canonical.PhysicsBodyAcquisitionInProgress;
|
||||
set => _directory.SetPhysicsBodyAcquisitionInProgress(Canonical, value);
|
||||
}
|
||||
public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; }
|
||||
public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; }
|
||||
internal bool RemoteMotionBindingInProgress { get; set; }
|
||||
public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost { get; internal set; }
|
||||
public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost
|
||||
{
|
||||
get => Canonical.PhysicsHost;
|
||||
internal set => _directory.SetPhysicsHost(Canonical, value);
|
||||
}
|
||||
internal bool RequiresRemotePlacementRuntime { get; set; }
|
||||
public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; }
|
||||
public ILiveEntityEffectProfile? EffectProfile { get; internal set; }
|
||||
|
|
@ -284,18 +330,18 @@ public sealed class LiveEntityRecord
|
|||
/// handler detect a newer same-incarnation packet delivered re-entrantly
|
||||
/// while an older packet is crossing presentation or spatial callbacks.
|
||||
/// </summary>
|
||||
internal ulong PositionAuthorityVersion { get; private set; }
|
||||
internal ulong StateAuthorityVersion { get; private set; }
|
||||
internal ulong VectorAuthorityVersion { get; private set; }
|
||||
internal ulong VelocityAuthorityVersion { get; private set; }
|
||||
internal ulong MovementAuthorityVersion { get; private set; }
|
||||
internal ulong ObjDescAuthorityVersion { get; private set; }
|
||||
internal ulong PositionAuthorityVersion => Canonical.PositionAuthorityVersion;
|
||||
internal ulong StateAuthorityVersion => Canonical.StateAuthorityVersion;
|
||||
internal ulong VectorAuthorityVersion => Canonical.VectorAuthorityVersion;
|
||||
internal ulong VelocityAuthorityVersion => Canonical.VelocityAuthorityVersion;
|
||||
internal ulong MovementAuthorityVersion => Canonical.MovementAuthorityVersion;
|
||||
internal ulong ObjDescAuthorityVersion => Canonical.ObjDescAuthorityVersion;
|
||||
/// <summary>
|
||||
/// Advances for every accepted CreateObject affecting this incarnation,
|
||||
/// including a fresher same-INSTANCE_TS retransmit. App integration uses
|
||||
/// it to distinguish two transactions that intentionally share identity.
|
||||
/// </summary>
|
||||
internal ulong CreateIntegrationVersion { get; private set; } = 1UL;
|
||||
internal ulong CreateIntegrationVersion => Canonical.CreateIntegrationVersion;
|
||||
public bool WorldSpawnPublished { get; internal set; }
|
||||
/// <summary>
|
||||
/// True only after the incarnation's projection, collision/animation
|
||||
|
|
@ -330,82 +376,45 @@ public sealed class LiveEntityRecord
|
|||
internal LiveEntityTeardownPlan? RuntimeComponentTeardownPlan { get; set; }
|
||||
internal bool SpatialProjectionTeardownCompleted { get; set; }
|
||||
internal bool TeardownInProgress { get; set; }
|
||||
internal bool DeleteAcceptedForTeardown { get; set; }
|
||||
internal bool DeleteAcceptedForTeardown
|
||||
{
|
||||
get => Canonical.DeleteAcceptedForTeardown;
|
||||
set => _directory.SetDeleteAcceptedForTeardown(Canonical, value);
|
||||
}
|
||||
|
||||
internal bool TryDequeueStateTransition(out RetailPhysicsStateTransition transition) =>
|
||||
_pendingStateTransitions.TryDequeue(out transition);
|
||||
_directory.TryDequeueStateTransition(Canonical, out transition);
|
||||
|
||||
internal void SuspendObjectClock()
|
||||
{
|
||||
ObjectClock.Deactivate();
|
||||
ObjectClockEpoch++;
|
||||
}
|
||||
internal void SuspendObjectClock() => _directory.SuspendObjectClock(Canonical);
|
||||
|
||||
internal void ResetObjectClockForEnterWorld(bool isStatic)
|
||||
{
|
||||
ObjectClock.ResetForEnterWorld(isStatic);
|
||||
ObjectClockEpoch++;
|
||||
}
|
||||
internal void ResetObjectClockForEnterWorld(bool isStatic) =>
|
||||
_directory.ResetObjectClockForEnterWorld(Canonical, isStatic);
|
||||
|
||||
internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState)
|
||||
{
|
||||
StateAuthorityVersion++;
|
||||
RawPhysicsState = rawState;
|
||||
RetailPhysicsStateTransition transition = RetailPhysicsStateTransitions.Apply(
|
||||
FinalPhysicsState,
|
||||
(PhysicsStateFlags)rawState);
|
||||
FinalPhysicsState = transition.FinalState;
|
||||
if (PhysicsBody is not null)
|
||||
PhysicsBody.State = FinalPhysicsState;
|
||||
_pendingStateTransitions.Enqueue(transition);
|
||||
return transition;
|
||||
}
|
||||
internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState) =>
|
||||
_directory.ApplyRawPhysicsState(Canonical, rawState);
|
||||
|
||||
internal void AdvancePositionAuthority()
|
||||
{
|
||||
PositionAuthorityVersion++;
|
||||
VelocityAuthorityVersion++;
|
||||
}
|
||||
internal void AdvancePositionAuthority() =>
|
||||
_directory.AdvancePositionAuthority(Canonical);
|
||||
|
||||
internal void AdvanceVectorAuthority()
|
||||
{
|
||||
VectorAuthorityVersion++;
|
||||
VelocityAuthorityVersion++;
|
||||
}
|
||||
internal void AdvanceVectorAuthority() =>
|
||||
_directory.AdvanceVectorAuthority(Canonical);
|
||||
|
||||
internal void AdvanceMovementAuthority()
|
||||
{
|
||||
MovementAuthorityVersion++;
|
||||
VelocityAuthorityVersion++;
|
||||
}
|
||||
internal void AdvanceMovementAuthority() =>
|
||||
_directory.AdvanceMovementAuthority(Canonical);
|
||||
|
||||
internal void AdvanceObjDescAuthority()
|
||||
{
|
||||
ObjDescAuthorityVersion++;
|
||||
_directory.AdvanceObjDescAuthority(Canonical);
|
||||
AppearanceProjectionSynchronizationPending = true;
|
||||
if (AppearanceHydrationInProgress)
|
||||
AppearanceHydrationRetryRequested = true;
|
||||
}
|
||||
|
||||
internal void AdvanceCreateAuthority()
|
||||
{
|
||||
PositionAuthorityVersion++;
|
||||
StateAuthorityVersion++;
|
||||
VectorAuthorityVersion++;
|
||||
VelocityAuthorityVersion++;
|
||||
MovementAuthorityVersion++;
|
||||
ObjDescAuthorityVersion++;
|
||||
CreateIntegrationVersion++;
|
||||
}
|
||||
internal void AdvanceCreateAuthority() =>
|
||||
_directory.AdvanceCreateAuthority(Canonical);
|
||||
|
||||
internal void SetChildNoDraw(bool noDraw)
|
||||
{
|
||||
FinalPhysicsState = noDraw
|
||||
? FinalPhysicsState | PhysicsStateFlags.NoDraw
|
||||
: FinalPhysicsState & ~PhysicsStateFlags.NoDraw;
|
||||
if (PhysicsBody is not null)
|
||||
PhysicsBody.State = FinalPhysicsState;
|
||||
}
|
||||
internal void SetChildNoDraw(bool noDraw) =>
|
||||
_directory.SetChildNoDraw(Canonical, noDraw);
|
||||
|
||||
/// <summary>
|
||||
/// Cell used when a streamed landblock asks live objects to restore their
|
||||
|
|
@ -416,17 +425,8 @@ public sealed class LiveEntityRecord
|
|||
? FullCellId
|
||||
: Snapshot.Position?.LandblockId ?? FullCellId;
|
||||
|
||||
internal void RefreshDerivedState(bool refreshPosition = true)
|
||||
{
|
||||
if (refreshPosition && Snapshot.Position is { } position)
|
||||
{
|
||||
FullCellId = position.LandblockId;
|
||||
CanonicalLandblockId = (FullCellId & 0xFFFF0000u) | 0xFFFFu;
|
||||
}
|
||||
RawPhysicsState = Snapshot.Physics?.RawState
|
||||
?? Snapshot.PhysicsState
|
||||
?? 0u;
|
||||
}
|
||||
internal void RefreshDerivedState(bool refreshPosition = true) =>
|
||||
_directory.RefreshSnapshot(Canonical, Snapshot, refreshPosition);
|
||||
}
|
||||
|
||||
public readonly record struct LiveEntityRegistrationResult(
|
||||
|
|
@ -437,10 +437,13 @@ public readonly record struct LiveEntityRegistrationResult(
|
|||
Exception? PriorGenerationCleanupFailure = null);
|
||||
|
||||
/// <summary>
|
||||
/// Update-thread owner of live server-object identity, accepted state, runtime
|
||||
/// components, and logical teardown. <see cref="GpuWorldState"/> is a spatial
|
||||
/// projection only; moving between loaded and pending buckets never replays a
|
||||
/// create-time renderer, script, animation, or effect action.
|
||||
/// Update-thread App projection/lifecycle host over the canonical
|
||||
/// <see cref="RuntimeEntityDirectory"/>. Runtime owns server GUIDs,
|
||||
/// incarnations, accepted snapshots, timestamps, tombstones, and local IDs;
|
||||
/// this host owns graphical components and retryable projection teardown.
|
||||
/// <see cref="GpuWorldState"/> is a spatial projection only; moving between
|
||||
/// loaded and pending buckets never replays a create-time renderer, script,
|
||||
/// animation, or effect action.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail keeps one object per accepted INSTANCE_TS: same-generation
|
||||
|
|
@ -454,23 +457,31 @@ public readonly record struct LiveEntityRegistrationResult(
|
|||
/// </summary>
|
||||
public sealed class LiveEntityRuntime
|
||||
{
|
||||
public const uint FirstLiveEntityId = 1_000_000u;
|
||||
public const uint LastLiveEntityId = 0x3FFF_FFFFu;
|
||||
public const uint FirstLiveEntityId = RuntimeEntityDirectory.FirstLocalEntityId;
|
||||
public const uint LastLiveEntityId = RuntimeEntityDirectory.LastLocalEntityId;
|
||||
|
||||
private readonly GpuWorldState _spatial;
|
||||
private readonly ILiveEntityResourceLifecycle _resources;
|
||||
private readonly ILiveEntityRuntimeComponentLifecycle _runtimeComponentLifecycle;
|
||||
private readonly InboundPhysicsStateController _inbound = new();
|
||||
private readonly Dictionary<uint, LiveEntityRecord> _recordsByGuid = new();
|
||||
private readonly RuntimeEntityDirectory _directory;
|
||||
// App sidecars are keyed by the canonical Runtime record itself. Runtime
|
||||
// owns the only server-GUID/incarnation maps; these collections only
|
||||
// attach presentation state to records already accepted by that owner.
|
||||
private readonly Dictionary<RuntimeEntityRecord, LiveEntityRecord> _activeRecords = new();
|
||||
// Transitional dictionary-shaped adapter for the existing App projection
|
||||
// methods. It owns no GUID map: every lookup and mutation is resolved
|
||||
// through RuntimeEntityDirectory, then translated to the App sidecar by
|
||||
// canonical record reference. J3.3 removes this facade with the projection
|
||||
// store cutover.
|
||||
private readonly ActiveRecordView _recordsByGuid;
|
||||
// Records leave the active gameplay map before arbitrary teardown callbacks
|
||||
// so a newer GUID generation can be accepted re-entrantly. They remain
|
||||
// canonical teardown tombstones until every resource owner confirms
|
||||
// unregister; failures are therefore retryable by Delete or session Clear.
|
||||
private readonly Dictionary<(uint Guid, ushort Generation), LiveEntityRecord>
|
||||
private readonly Dictionary<RuntimeEntityRecord, LiveEntityRecord>
|
||||
_teardownRecords = new();
|
||||
private readonly Dictionary<uint, WorldEntity> _materializedWorldEntitiesByGuid = new();
|
||||
private readonly Dictionary<uint, WorldEntity> _visibleWorldEntitiesByGuid = new();
|
||||
private readonly Dictionary<uint, uint> _guidByLocalId = new();
|
||||
private readonly Dictionary<uint, ILiveEntityAnimationRuntime> _animationsByLocalId = new();
|
||||
private readonly Dictionary<uint, ILiveEntityRemoteMotionRuntime> _remoteMotionByGuid = new();
|
||||
// Logical components survive retail's 25-second leave-visibility lifetime.
|
||||
|
|
@ -489,10 +500,7 @@ public sealed class LiveEntityRuntime
|
|||
private bool _sessionClearPendingFinalization;
|
||||
private bool _isRegisteringResources;
|
||||
private int _logicalTeardownDepth;
|
||||
private ulong _sessionLifetimeVersion;
|
||||
private readonly Dictionary<uint, ulong> _lifetimeMutationVersionByGuid = new();
|
||||
private uint _rebucketingGuid;
|
||||
private uint _nextLocalEntityId;
|
||||
|
||||
public LiveEntityRuntime(
|
||||
GpuWorldState spatial,
|
||||
|
|
@ -529,18 +537,15 @@ public sealed class LiveEntityRuntime
|
|||
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
|
||||
_runtimeComponentLifecycle = runtimeComponentLifecycle
|
||||
?? throw new ArgumentNullException(nameof(runtimeComponentLifecycle));
|
||||
if (firstLocalEntityId is < FirstLiveEntityId or > LastLiveEntityId)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(firstLocalEntityId),
|
||||
$"Live entity ids must stay in 0x{FirstLiveEntityId:X8}..0x{LastLiveEntityId:X8}.");
|
||||
_nextLocalEntityId = firstLocalEntityId;
|
||||
_directory = new RuntimeEntityDirectory(firstLocalEntityId);
|
||||
_recordsByGuid = new ActiveRecordView(_directory, _activeRecords);
|
||||
_spatial.LiveProjectionVisibilityChanged += OnSpatialVisibilityChanged;
|
||||
}
|
||||
|
||||
public int Count => _recordsByGuid.Count;
|
||||
public int PendingTeardownCount => _teardownRecords.Count;
|
||||
public int MaterializedCount => _guidByLocalId.Count;
|
||||
public IReadOnlyCollection<LiveEntityRecord> Records => _recordsByGuid.Values;
|
||||
public int Count => _directory.Count;
|
||||
public int PendingTeardownCount => _directory.PendingTeardownCount;
|
||||
public int MaterializedCount => _directory.ClaimedLocalIdCount;
|
||||
public IReadOnlyCollection<LiveEntityRecord> Records => _activeRecords.Values;
|
||||
/// <summary>
|
||||
/// Every materialized top-level world projection, including an object
|
||||
/// parked in a pending (currently unloaded) spatial bucket. Network and
|
||||
|
|
@ -558,7 +563,7 @@ public sealed class LiveEntityRuntime
|
|||
/// </summary>
|
||||
public IReadOnlyDictionary<uint, WorldEntity> WorldEntities =>
|
||||
_visibleWorldEntitiesByGuid;
|
||||
public IReadOnlyDictionary<uint, WorldSession.EntitySpawn> Snapshots => _inbound.Snapshots;
|
||||
public IReadOnlyDictionary<uint, WorldSession.EntitySpawn> Snapshots => _directory.Snapshots;
|
||||
public IReadOnlyDictionary<uint, ILiveEntityAnimationRuntime> AnimationRuntimes => _animationsByLocalId;
|
||||
public IReadOnlyDictionary<uint, ILiveEntityRemoteMotionRuntime> RemoteMotionRuntimes => _remoteMotionByGuid;
|
||||
public IReadOnlyDictionary<uint, ILiveEntityAnimationRuntime> SpatialAnimationRuntimes =>
|
||||
|
|
@ -569,7 +574,7 @@ public sealed class LiveEntityRuntime
|
|||
_spatialProjectilesByGuid;
|
||||
internal IReadOnlyDictionary<uint, LiveEntityRecord> SpatialRootObjects =>
|
||||
_spatialRootObjectsByGuid;
|
||||
public ParentAttachmentState ParentAttachments { get; } = new();
|
||||
public ParentAttachmentState ParentAttachments => _directory.ParentAttachments;
|
||||
|
||||
/// <summary>
|
||||
/// Raised after a materialized projection enters or leaves the currently
|
||||
|
|
@ -587,10 +592,10 @@ public sealed class LiveEntityRuntime
|
|||
: "A live entity cannot register from inside atomic resource registration.");
|
||||
}
|
||||
|
||||
InboundCreateResult result = _inbound.AcceptCreate(incoming);
|
||||
InboundCreateResult result = _directory.AcceptCreate(incoming);
|
||||
if (result.Disposition is CreateObjectTimestampDisposition.StaleGeneration)
|
||||
return new LiveEntityRegistrationResult(result, null, false, false);
|
||||
ulong sessionVersion = _sessionLifetimeVersion;
|
||||
ulong sessionVersion = _directory.SessionLifetimeVersion;
|
||||
ulong operationVersion = AdvanceLifetimeMutation(incoming.Guid);
|
||||
|
||||
if (result.Disposition is CreateObjectTimestampDisposition.ExistingGeneration)
|
||||
|
|
@ -608,8 +613,10 @@ public sealed class LiveEntityRuntime
|
|||
// as a teardown tombstone. Do not create a second active record
|
||||
// with the same (GUID, INSTANCE_TS) while its partial resources are
|
||||
// still converging; a later retransmit can recover after teardown.
|
||||
if (_teardownRecords.ContainsKey(
|
||||
(incoming.Guid, result.Snapshot.InstanceSequence)))
|
||||
if (_directory.TryGetTeardown(
|
||||
incoming.Guid,
|
||||
result.Snapshot.InstanceSequence,
|
||||
out _))
|
||||
{
|
||||
return new LiveEntityRegistrationResult(
|
||||
result,
|
||||
|
|
@ -620,7 +627,9 @@ public sealed class LiveEntityRuntime
|
|||
|
||||
// Defensive repair for a caller that cleared only the logical map.
|
||||
// Normal session teardown clears both owners together.
|
||||
var recovered = new LiveEntityRecord(result.Snapshot);
|
||||
RuntimeEntityRecord recoveredCanonical =
|
||||
_directory.AddActive(result.Snapshot);
|
||||
var recovered = new LiveEntityRecord(_directory, recoveredCanonical);
|
||||
_recordsByGuid.Add(incoming.Guid, recovered);
|
||||
return new LiveEntityRegistrationResult(result, recovered, true, false);
|
||||
}
|
||||
|
|
@ -657,7 +666,7 @@ public sealed class LiveEntityRuntime
|
|||
// outer packet is then superseded even when the nested action left no
|
||||
// record/snapshot (delete/reset); reinstalling it would resurrect an
|
||||
// incarnation after an accepted terminal event.
|
||||
if (_sessionLifetimeVersion != sessionVersion
|
||||
if (_directory.SessionLifetimeVersion != sessionVersion
|
||||
|| CurrentLifetimeMutation(incoming.Guid) != operationVersion)
|
||||
{
|
||||
if (tearDownFailure is not null)
|
||||
|
|
@ -675,7 +684,8 @@ public sealed class LiveEntityRuntime
|
|||
tearDownFailure);
|
||||
}
|
||||
|
||||
var record = new LiveEntityRecord(result.Snapshot);
|
||||
RuntimeEntityRecord canonical = _directory.AddActive(result.Snapshot);
|
||||
var record = new LiveEntityRecord(_directory, canonical);
|
||||
_recordsByGuid.Add(incoming.Guid, record);
|
||||
return new LiveEntityRegistrationResult(
|
||||
result,
|
||||
|
|
@ -709,17 +719,30 @@ public sealed class LiveEntityRuntime
|
|||
|
||||
if (record.WorldEntity is null)
|
||||
{
|
||||
uint localId = ReserveLocalEntityId();
|
||||
WorldEntity entity = factory(localId)
|
||||
?? throw new InvalidOperationException("A live-entity projection factory returned null.");
|
||||
uint localId = _directory.ClaimLocalId(record.Canonical);
|
||||
WorldEntity entity;
|
||||
try
|
||||
{
|
||||
entity = factory(localId)
|
||||
?? throw new InvalidOperationException(
|
||||
"A live-entity projection factory returned null.");
|
||||
if (entity.Id != localId)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live projection id 0x{entity.Id:X8} did not match reserved id 0x{localId:X8}.");
|
||||
}
|
||||
if (entity.ServerGuid != serverGuid)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live projection guid 0x{entity.ServerGuid:X8} did not match record 0x{serverGuid:X8}.");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
_directory.ReleaseLocalId(record.Canonical);
|
||||
throw;
|
||||
}
|
||||
|
||||
_guidByLocalId.Add(localId, serverGuid);
|
||||
record.WorldEntity = entity;
|
||||
_isRegisteringResources = true;
|
||||
try
|
||||
|
|
@ -749,7 +772,7 @@ public sealed class LiveEntityRuntime
|
|||
|
||||
if (rollbackError is null)
|
||||
{
|
||||
_guidByLocalId.Remove(localId);
|
||||
_directory.ReleaseLocalId(record.Canonical);
|
||||
record.WorldEntity = null;
|
||||
throw;
|
||||
}
|
||||
|
|
@ -1067,14 +1090,21 @@ public sealed class LiveEntityRuntime
|
|||
"A live entity cannot unregister from inside atomic resource registration.");
|
||||
}
|
||||
var teardownKey = (delete.Guid, delete.InstanceSequence);
|
||||
_teardownRecords.TryGetValue(teardownKey, out LiveEntityRecord? record);
|
||||
LiveEntityRecord? record = null;
|
||||
if (_directory.TryGetTeardown(
|
||||
teardownKey.Guid,
|
||||
teardownKey.InstanceSequence,
|
||||
out RuntimeEntityRecord retainedCanonical))
|
||||
{
|
||||
_teardownRecords.TryGetValue(retainedCanonical, out record);
|
||||
}
|
||||
bool retryingAcceptedDelete = record is not null
|
||||
&& (record.DeleteAcceptedForTeardown
|
||||
|| _isClearing
|
||||
|| _sessionClearPendingFinalization);
|
||||
if (!retryingAcceptedDelete)
|
||||
{
|
||||
if (!_inbound.TryDelete(delete, isLocalPlayer))
|
||||
if (!_directory.TryDelete(delete, isLocalPlayer))
|
||||
return false;
|
||||
AdvanceLifetimeMutation(delete.Guid);
|
||||
ParentAttachments.DeleteGeneration(delete.Guid, delete.InstanceSequence);
|
||||
|
|
@ -1226,8 +1256,19 @@ public sealed class LiveEntityRuntime
|
|||
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||
&& record.WorldEntity is not null;
|
||||
|
||||
public bool TryGetServerGuid(uint localEntityId, out uint serverGuid) =>
|
||||
_guidByLocalId.TryGetValue(localEntityId, out serverGuid);
|
||||
public bool TryGetServerGuid(uint localEntityId, out uint serverGuid)
|
||||
{
|
||||
if (_directory.TryGetByLocalId(
|
||||
localEntityId,
|
||||
out RuntimeEntityRecord canonical))
|
||||
{
|
||||
serverGuid = canonical.ServerGuid;
|
||||
return true;
|
||||
}
|
||||
|
||||
serverGuid = 0u;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetLocalEntityId(uint serverGuid, out uint localEntityId)
|
||||
{
|
||||
|
|
@ -1377,7 +1418,7 @@ public sealed class LiveEntityRuntime
|
|||
// exact record, authority epoch, and expected owners are revalidated
|
||||
// before commit just like resource/physics-body acquisition.
|
||||
LiveEntityRecord incarnation = record;
|
||||
ulong sessionVersion = _sessionLifetimeVersion;
|
||||
ulong sessionVersion = _directory.SessionLifetimeVersion;
|
||||
ulong lifetimeVersion = CurrentLifetimeMutation(serverGuid);
|
||||
PhysicsBody? expectedBody = record.PhysicsBody;
|
||||
ILiveEntityRemoteMotionRuntime? expectedRuntime = record.RemoteMotionRuntime;
|
||||
|
|
@ -1436,7 +1477,7 @@ public sealed class LiveEntityRuntime
|
|||
== expectedIndexPresent
|
||||
&& (!expectedIndexPresent
|
||||
|| ReferenceEquals(currentIndexedRuntime, expectedIndexedRuntime));
|
||||
if (_sessionLifetimeVersion != sessionVersion
|
||||
if (_directory.SessionLifetimeVersion != sessionVersion
|
||||
|| CurrentLifetimeMutation(serverGuid) != lifetimeVersion
|
||||
|| !_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current)
|
||||
|| !ReferenceEquals(current, incarnation)
|
||||
|
|
@ -1592,11 +1633,11 @@ public sealed class LiveEntityRuntime
|
|||
}
|
||||
|
||||
public bool TryGetSnapshot(uint guid, out WorldSession.EntitySpawn spawn) =>
|
||||
_inbound.TryGetSnapshot(guid, out spawn);
|
||||
_directory.TryGetSnapshot(guid, out spawn);
|
||||
|
||||
public bool TryApplyObjDesc(ObjDescEvent.Parsed update, out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
bool applied = _inbound.TryApplyObjDesc(update, out accepted);
|
||||
bool applied = _directory.TryApplyObjDesc(update, out accepted);
|
||||
if (applied)
|
||||
{
|
||||
RefreshRecord(update.Guid, accepted);
|
||||
|
|
@ -1608,7 +1649,7 @@ public sealed class LiveEntityRuntime
|
|||
|
||||
public bool TryApplyPickup(PickupEvent.Parsed update, out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
bool applied = _inbound.TryApplyPickup(update, out accepted);
|
||||
bool applied = _directory.TryApplyPickup(update, out accepted);
|
||||
if (applied)
|
||||
{
|
||||
RefreshRecord(update.Guid, accepted);
|
||||
|
|
@ -1622,7 +1663,7 @@ public sealed class LiveEntityRuntime
|
|||
|
||||
public bool TryApplyCreateParent(CreateParentUpdate update, out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
bool applied = _inbound.TryApplyCreateParent(update, out accepted);
|
||||
bool applied = _directory.TryApplyCreateParent(update, out accepted);
|
||||
if (applied)
|
||||
{
|
||||
RefreshRecord(update.ChildGuid, accepted);
|
||||
|
|
@ -1634,7 +1675,7 @@ public sealed class LiveEntityRuntime
|
|||
|
||||
public bool TryApplyParent(ParentEvent.Parsed update, out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
bool applied = _inbound.TryApplyParent(update, out accepted);
|
||||
bool applied = _directory.TryApplyParent(update, out accepted);
|
||||
if (applied)
|
||||
{
|
||||
RefreshRecord(update.ChildGuid, accepted);
|
||||
|
|
@ -1648,7 +1689,7 @@ public sealed class LiveEntityRuntime
|
|||
ParentAttachmentRelation relation,
|
||||
out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
bool committed = _inbound.TryCommitParent(
|
||||
bool committed = _directory.TryCommitParent(
|
||||
relation.ChildGuid,
|
||||
relation.ParentGuid,
|
||||
relation.ParentLocation,
|
||||
|
|
@ -1683,8 +1724,8 @@ public sealed class LiveEntityRuntime
|
|||
out WorldSession.EntitySpawn accepted,
|
||||
out AcceptedPhysicsTimestamps timestamps)
|
||||
{
|
||||
bool applied = _inbound.TryApplyMotion(update, retainPayload, out accepted, out timestamps);
|
||||
if (_inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot))
|
||||
bool applied = _directory.TryApplyMotion(update, retainPayload, out accepted, out timestamps);
|
||||
if (_directory.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot))
|
||||
RefreshRecord(update.Guid, snapshot);
|
||||
if (applied
|
||||
&& retainPayload
|
||||
|
|
@ -1697,7 +1738,7 @@ public sealed class LiveEntityRuntime
|
|||
|
||||
public bool TryApplyVector(VectorUpdate.Parsed update, out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
bool applied = _inbound.TryApplyVector(update, out accepted);
|
||||
bool applied = _directory.TryApplyVector(update, out accepted);
|
||||
if (applied)
|
||||
{
|
||||
RefreshRecord(update.Guid, accepted);
|
||||
|
|
@ -1715,7 +1756,7 @@ public sealed class LiveEntityRuntime
|
|||
out WorldSession.EntitySpawn accepted,
|
||||
out RetailPhysicsStateTransition transition)
|
||||
{
|
||||
bool applied = _inbound.TryApplyState(update, out accepted);
|
||||
bool applied = _directory.TryApplyState(update, out accepted);
|
||||
transition = default;
|
||||
if (applied)
|
||||
{
|
||||
|
|
@ -1757,7 +1798,7 @@ public sealed class LiveEntityRuntime
|
|||
&& (beforePosition.FullCellId == 0
|
||||
|| !beforePosition.IsSpatiallyProjected
|
||||
|| !beforePosition.IsSpatiallyVisible);
|
||||
bool known = _inbound.TryApplyPosition(
|
||||
bool known = _directory.TryApplyPosition(
|
||||
update,
|
||||
isLocalPlayer,
|
||||
forcePositionRotation,
|
||||
|
|
@ -1765,7 +1806,7 @@ public sealed class LiveEntityRuntime
|
|||
out disposition,
|
||||
out accepted,
|
||||
out timestamps);
|
||||
if (known && _inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot))
|
||||
if (known && _directory.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot))
|
||||
{
|
||||
bool acceptedPosition = disposition is not PositionTimestampDisposition.Rejected;
|
||||
if (disposition is PositionTimestampDisposition.Apply)
|
||||
|
|
@ -1792,7 +1833,7 @@ public sealed class LiveEntityRuntime
|
|||
}
|
||||
|
||||
public bool IsFreshTeleportStart(uint localPlayerGuid, ushort teleportSequence) =>
|
||||
_inbound.IsFreshTeleportStart(localPlayerGuid, teleportSequence);
|
||||
_directory.IsFreshTeleportStart(localPlayerGuid, teleportSequence);
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CPhysicsObj::update_object</c> (<c>0x00515D40</c>) runs root
|
||||
|
|
@ -2305,8 +2346,7 @@ public sealed class LiveEntityRuntime
|
|||
|
||||
_isClearing = true;
|
||||
_sessionClearPendingFinalization = true;
|
||||
_sessionLifetimeVersion++;
|
||||
_lifetimeMutationVersionByGuid.Clear();
|
||||
_directory.BeginSessionClear();
|
||||
List<Exception>? failures = null;
|
||||
try
|
||||
{
|
||||
|
|
@ -2319,11 +2359,16 @@ public sealed class LiveEntityRuntime
|
|||
RetainTeardownRecord(record);
|
||||
}
|
||||
|
||||
ParentAttachments.Clear();
|
||||
_inbound.Clear();
|
||||
|
||||
foreach (LiveEntityRecord record in _teardownRecords.Values.ToArray())
|
||||
{
|
||||
if (!_teardownRecords.TryGetValue(
|
||||
record.Canonical,
|
||||
out LiveEntityRecord? retained)
|
||||
|| !ReferenceEquals(retained, record))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// A re-entrant Clear can observe the tombstone currently being
|
||||
// unregistered by its caller. Leave it queued; the outer
|
||||
// teardown's ReleaseTeardownRecord finalizes the session once
|
||||
|
|
@ -2369,6 +2414,14 @@ public sealed class LiveEntityRuntime
|
|||
List<Exception>? failures = null;
|
||||
foreach (LiveEntityRecord record in _teardownRecords.Values.ToArray())
|
||||
{
|
||||
if (!_teardownRecords.TryGetValue(
|
||||
record.Canonical,
|
||||
out LiveEntityRecord? retained)
|
||||
|| !ReferenceEquals(retained, record))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (record.TeardownInProgress)
|
||||
continue;
|
||||
|
||||
|
|
@ -2382,7 +2435,7 @@ public sealed class LiveEntityRuntime
|
|||
completed++;
|
||||
if (!_recordsByGuid.ContainsKey(record.ServerGuid)
|
||||
&& !HasPendingTeardown(record.ServerGuid)
|
||||
&& !_inbound.TryGetSnapshot(record.ServerGuid, out _))
|
||||
&& !_directory.TryGetSnapshot(record.ServerGuid, out _))
|
||||
{
|
||||
_spatial.ForgetLiveEntity(record.ServerGuid);
|
||||
}
|
||||
|
|
@ -2421,14 +2474,10 @@ public sealed class LiveEntityRuntime
|
|||
default);
|
||||
|
||||
private ulong AdvanceLifetimeMutation(uint serverGuid)
|
||||
{
|
||||
ulong next = _lifetimeMutationVersionByGuid.GetValueOrDefault(serverGuid) + 1UL;
|
||||
_lifetimeMutationVersionByGuid[serverGuid] = next;
|
||||
return next;
|
||||
}
|
||||
=> _directory.AdvanceLifetimeMutation(serverGuid);
|
||||
|
||||
private ulong CurrentLifetimeMutation(uint serverGuid) =>
|
||||
_lifetimeMutationVersionByGuid.GetValueOrDefault(serverGuid);
|
||||
_directory.CurrentLifetimeMutation(serverGuid);
|
||||
|
||||
private bool IsCurrentProjectionOperation(
|
||||
uint serverGuid,
|
||||
|
|
@ -2574,23 +2623,6 @@ public sealed class LiveEntityRuntime
|
|||
}
|
||||
}
|
||||
|
||||
private uint ReserveLocalEntityId()
|
||||
{
|
||||
uint start = _nextLocalEntityId;
|
||||
do
|
||||
{
|
||||
uint candidate = _nextLocalEntityId;
|
||||
_nextLocalEntityId = candidate == LastLiveEntityId
|
||||
? FirstLiveEntityId
|
||||
: candidate + 1u;
|
||||
if (!_guidByLocalId.ContainsKey(candidate))
|
||||
return candidate;
|
||||
}
|
||||
while (_nextLocalEntityId != start);
|
||||
|
||||
throw new InvalidOperationException("The live entity id namespace is exhausted.");
|
||||
}
|
||||
|
||||
private void OnSpatialVisibilityChanged(uint serverGuid, bool visible)
|
||||
{
|
||||
// GpuWorldState serializes re-entrant visibility callbacks. A callback
|
||||
|
|
@ -2688,19 +2720,18 @@ public sealed class LiveEntityRuntime
|
|||
_visibleWorldEntitiesByGuid.Remove(record.ServerGuid);
|
||||
}
|
||||
|
||||
private static (uint Guid, ushort Generation) TeardownKey(LiveEntityRecord record) =>
|
||||
(record.ServerGuid, record.Generation);
|
||||
|
||||
private void RetainTeardownRecord(LiveEntityRecord record)
|
||||
{
|
||||
var key = TeardownKey(record);
|
||||
if (_teardownRecords.TryGetValue(key, out LiveEntityRecord? retained)
|
||||
_directory.RetainTeardown(record.Canonical);
|
||||
if (_teardownRecords.TryGetValue(
|
||||
record.Canonical,
|
||||
out LiveEntityRecord? retained)
|
||||
&& !ReferenceEquals(retained, record))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity teardown tombstone collision for 0x{record.ServerGuid:X8} generation {record.Generation}.");
|
||||
}
|
||||
_teardownRecords[key] = record;
|
||||
_teardownRecords[record.Canonical] = record;
|
||||
if (record.WorldEntity is { } entity
|
||||
&& _visibleWorldEntitiesByGuid.TryGetValue(
|
||||
record.ServerGuid,
|
||||
|
|
@ -2713,45 +2744,36 @@ public sealed class LiveEntityRuntime
|
|||
|
||||
private void ReleaseTeardownRecord(LiveEntityRecord record)
|
||||
{
|
||||
var key = TeardownKey(record);
|
||||
if (_teardownRecords.TryGetValue(key, out LiveEntityRecord? retained)
|
||||
if (_teardownRecords.TryGetValue(
|
||||
record.Canonical,
|
||||
out LiveEntityRecord? retained)
|
||||
&& ReferenceEquals(retained, record))
|
||||
{
|
||||
_teardownRecords.Remove(key);
|
||||
_teardownRecords.Remove(record.Canonical);
|
||||
}
|
||||
_directory.ReleaseTeardown(record.Canonical);
|
||||
CompleteSessionClearIfConverged();
|
||||
}
|
||||
|
||||
private bool HasPendingTeardown(uint serverGuid)
|
||||
{
|
||||
foreach ((uint Guid, ushort Generation) key in _teardownRecords.Keys)
|
||||
{
|
||||
if (key.Guid == serverGuid)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
private bool HasPendingTeardown(uint serverGuid) =>
|
||||
_directory.HasPendingTeardown(serverGuid);
|
||||
|
||||
private void CompleteSessionClearIfConverged()
|
||||
{
|
||||
if (!_sessionClearPendingFinalization
|
||||
|| _recordsByGuid.Count != 0
|
||||
|| _teardownRecords.Count != 0)
|
||||
|| !_directory.CompleteSessionClearIfConverged())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_materializedWorldEntitiesByGuid.Clear();
|
||||
_visibleWorldEntitiesByGuid.Clear();
|
||||
_guidByLocalId.Clear();
|
||||
_animationsByLocalId.Clear();
|
||||
_remoteMotionByGuid.Clear();
|
||||
_spatialAnimationsByLocalId.Clear();
|
||||
_spatialRemoteMotionByGuid.Clear();
|
||||
_spatialProjectilesByGuid.Clear();
|
||||
_spatialRootObjectsByGuid.Clear();
|
||||
ParentAttachments.Clear();
|
||||
_inbound.Clear();
|
||||
_spatial.ClearLiveEntityLifetimeState();
|
||||
_sessionClearPendingFinalization = false;
|
||||
}
|
||||
|
|
@ -2815,7 +2837,7 @@ public sealed class LiveEntityRuntime
|
|||
|
||||
if (record.WorldEntity is { } finalizedEntity)
|
||||
{
|
||||
_guidByLocalId.Remove(finalizedEntity.Id);
|
||||
_directory.ReleaseLocalId(record.Canonical);
|
||||
if (_materializedWorldEntitiesByGuid.TryGetValue(
|
||||
record.ServerGuid,
|
||||
out WorldEntity? materialized)
|
||||
|
|
@ -2864,4 +2886,80 @@ public sealed class LiveEntityRuntime
|
|||
record.TeardownInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dictionary-shaped compatibility view used only while J3.2 migrates the
|
||||
/// large App projection surface. RuntimeEntityDirectory remains the sole
|
||||
/// GUID/incarnation authority; this type stores sidecars by canonical
|
||||
/// record reference and cannot resolve a GUID independently.
|
||||
/// </summary>
|
||||
private sealed class ActiveRecordView
|
||||
{
|
||||
private readonly RuntimeEntityDirectory _directory;
|
||||
private readonly Dictionary<RuntimeEntityRecord, LiveEntityRecord> _sidecars;
|
||||
|
||||
public ActiveRecordView(
|
||||
RuntimeEntityDirectory directory,
|
||||
Dictionary<RuntimeEntityRecord, LiveEntityRecord> sidecars)
|
||||
{
|
||||
_directory = directory;
|
||||
_sidecars = sidecars;
|
||||
}
|
||||
|
||||
public int Count => _directory.Count;
|
||||
public IReadOnlyCollection<LiveEntityRecord> Values => _sidecars.Values;
|
||||
|
||||
public bool TryGetValue(uint guid, out LiveEntityRecord record)
|
||||
{
|
||||
if (_directory.TryGetActive(guid, out RuntimeEntityRecord canonical)
|
||||
&& _sidecars.TryGetValue(canonical, out LiveEntityRecord? sidecar))
|
||||
{
|
||||
record = sidecar;
|
||||
return true;
|
||||
}
|
||||
|
||||
record = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool ContainsKey(uint guid) =>
|
||||
_directory.TryGetActive(guid, out RuntimeEntityRecord canonical)
|
||||
&& _sidecars.ContainsKey(canonical);
|
||||
|
||||
public LiveEntityRecord? GetValueOrDefault(uint guid) =>
|
||||
TryGetValue(guid, out LiveEntityRecord record) ? record : null;
|
||||
|
||||
public void Add(uint guid, LiveEntityRecord record)
|
||||
{
|
||||
if (record.ServerGuid != guid
|
||||
|| !_directory.TryGetActive(guid, out RuntimeEntityRecord canonical)
|
||||
|| !ReferenceEquals(canonical, record.Canonical))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"App projection sidecar 0x{record.ServerGuid:X8} does not match the canonical Runtime identity 0x{guid:X8}.");
|
||||
}
|
||||
|
||||
_sidecars.Add(canonical, record);
|
||||
}
|
||||
|
||||
public bool Remove(uint guid) => Remove(guid, out _);
|
||||
|
||||
public bool Remove(uint guid, out LiveEntityRecord? record)
|
||||
{
|
||||
if (!_directory.RemoveActive(guid, out RuntimeEntityRecord? canonical)
|
||||
|| canonical is null)
|
||||
{
|
||||
record = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_sidecars.Remove(canonical, out record))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Canonical Runtime identity 0x{guid:X8} had no App projection sidecar.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
428
src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs
Normal file
428
src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Runtime.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// The single update-thread authority for live server GUIDs, incarnations,
|
||||
/// accepted wire snapshots, teardown tombstones, and runtime-local identity.
|
||||
/// It owns no graphical state and can run without loading App or a backend.
|
||||
/// </summary>
|
||||
public sealed class RuntimeEntityDirectory
|
||||
{
|
||||
public const uint FirstLocalEntityId = 1_000_000u;
|
||||
public const uint LastLocalEntityId = 0x3FFF_FFFFu;
|
||||
|
||||
private readonly InboundPhysicsStateController _inbound = new();
|
||||
private readonly Dictionary<uint, RuntimeEntityRecord> _activeByGuid = new();
|
||||
private readonly Dictionary<(uint Guid, ushort Incarnation), RuntimeEntityRecord>
|
||||
_teardownByIncarnation = new();
|
||||
private readonly Dictionary<uint, RuntimeEntityRecord> _byLocalId = new();
|
||||
private readonly Dictionary<uint, ulong> _lifetimeMutationByGuid = new();
|
||||
private uint _nextLocalEntityId;
|
||||
|
||||
public RuntimeEntityDirectory(uint firstLocalEntityId = FirstLocalEntityId)
|
||||
{
|
||||
if (firstLocalEntityId is < FirstLocalEntityId or > LastLocalEntityId)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(firstLocalEntityId),
|
||||
$"Live entity ids must stay in 0x{FirstLocalEntityId:X8}..0x{LastLocalEntityId:X8}.");
|
||||
}
|
||||
|
||||
_nextLocalEntityId = firstLocalEntityId;
|
||||
}
|
||||
|
||||
public int Count => _activeByGuid.Count;
|
||||
public int PendingTeardownCount => _teardownByIncarnation.Count;
|
||||
public int ClaimedLocalIdCount => _byLocalId.Count;
|
||||
public ulong SessionLifetimeVersion { get; private set; }
|
||||
public IReadOnlyCollection<RuntimeEntityRecord> ActiveRecords => _activeByGuid.Values;
|
||||
public IReadOnlyCollection<RuntimeEntityRecord> TeardownRecords =>
|
||||
_teardownByIncarnation.Values;
|
||||
public IReadOnlyDictionary<uint, WorldSession.EntitySpawn> Snapshots => _inbound.Snapshots;
|
||||
public ParentAttachmentState ParentAttachments { get; } = new();
|
||||
|
||||
public InboundCreateResult AcceptCreate(WorldSession.EntitySpawn incoming) =>
|
||||
_inbound.AcceptCreate(incoming);
|
||||
|
||||
public bool TryDelete(
|
||||
AcDream.Core.Net.Messages.DeleteObject.Parsed delete,
|
||||
bool isLocalPlayer) =>
|
||||
_inbound.TryDelete(delete, isLocalPlayer);
|
||||
|
||||
public bool TryGetSnapshot(uint guid, out WorldSession.EntitySpawn spawn) =>
|
||||
_inbound.TryGetSnapshot(guid, out spawn);
|
||||
|
||||
public bool TryGetActive(uint guid, out RuntimeEntityRecord record) =>
|
||||
_activeByGuid.TryGetValue(guid, out record!);
|
||||
|
||||
public bool IsCurrent(RuntimeEntityRecord record) =>
|
||||
_activeByGuid.TryGetValue(record.ServerGuid, out RuntimeEntityRecord? current)
|
||||
&& ReferenceEquals(current, record);
|
||||
|
||||
public bool TryGetByLocalId(uint localEntityId, out RuntimeEntityRecord record) =>
|
||||
_byLocalId.TryGetValue(localEntityId, out record!);
|
||||
|
||||
public bool TryGetTeardown(
|
||||
uint guid,
|
||||
ushort incarnation,
|
||||
out RuntimeEntityRecord record) =>
|
||||
_teardownByIncarnation.TryGetValue((guid, incarnation), out record!);
|
||||
|
||||
public RuntimeEntityRecord AddActive(WorldSession.EntitySpawn snapshot)
|
||||
{
|
||||
if (_activeByGuid.ContainsKey(snapshot.Guid))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{snapshot.Guid:X8} already has an active incarnation.");
|
||||
}
|
||||
|
||||
var record = new RuntimeEntityRecord(snapshot);
|
||||
_activeByGuid.Add(snapshot.Guid, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
public bool RemoveActive(uint guid, out RuntimeEntityRecord? record) =>
|
||||
_activeByGuid.Remove(guid, out record);
|
||||
|
||||
public bool RemoveActive(RuntimeEntityRecord expected)
|
||||
{
|
||||
if (!IsCurrent(expected))
|
||||
return false;
|
||||
return _activeByGuid.Remove(expected.ServerGuid);
|
||||
}
|
||||
|
||||
public void RetainTeardown(RuntimeEntityRecord record)
|
||||
{
|
||||
var key = (record.ServerGuid, record.Incarnation);
|
||||
if (_teardownByIncarnation.TryGetValue(
|
||||
key,
|
||||
out RuntimeEntityRecord? retained)
|
||||
&& !ReferenceEquals(retained, record))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity teardown tombstone collision for 0x{record.ServerGuid:X8} generation {record.Incarnation}.");
|
||||
}
|
||||
|
||||
_teardownByIncarnation[key] = record;
|
||||
}
|
||||
|
||||
public void ReleaseTeardown(RuntimeEntityRecord record)
|
||||
{
|
||||
var key = (record.ServerGuid, record.Incarnation);
|
||||
if (_teardownByIncarnation.TryGetValue(
|
||||
key,
|
||||
out RuntimeEntityRecord? retained)
|
||||
&& ReferenceEquals(retained, record))
|
||||
{
|
||||
_teardownByIncarnation.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasPendingTeardown(uint serverGuid)
|
||||
{
|
||||
foreach ((uint Guid, ushort Incarnation) key in _teardownByIncarnation.Keys)
|
||||
{
|
||||
if (key.Guid == serverGuid)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public uint ClaimLocalId(RuntimeEntityRecord record)
|
||||
{
|
||||
if (!IsKnown(record))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A local id can only be claimed for an active or retained incarnation.");
|
||||
}
|
||||
|
||||
if (record.LocalEntityId is { } existing)
|
||||
return existing;
|
||||
|
||||
uint start = _nextLocalEntityId;
|
||||
do
|
||||
{
|
||||
uint candidate = _nextLocalEntityId;
|
||||
_nextLocalEntityId = candidate == LastLocalEntityId
|
||||
? FirstLocalEntityId
|
||||
: candidate + 1u;
|
||||
if (_byLocalId.ContainsKey(candidate))
|
||||
continue;
|
||||
|
||||
_byLocalId.Add(candidate, record);
|
||||
record.LocalEntityId = candidate;
|
||||
return candidate;
|
||||
}
|
||||
while (_nextLocalEntityId != start);
|
||||
|
||||
throw new InvalidOperationException("The live entity id namespace is exhausted.");
|
||||
}
|
||||
|
||||
public bool ReleaseLocalId(RuntimeEntityRecord record)
|
||||
{
|
||||
if (record.LocalEntityId is not { } localId)
|
||||
return false;
|
||||
if (_byLocalId.TryGetValue(localId, out RuntimeEntityRecord? retained)
|
||||
&& ReferenceEquals(retained, record))
|
||||
{
|
||||
_byLocalId.Remove(localId);
|
||||
}
|
||||
|
||||
record.LocalEntityId = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public ulong AdvanceLifetimeMutation(uint serverGuid)
|
||||
{
|
||||
ulong next = _lifetimeMutationByGuid.GetValueOrDefault(serverGuid) + 1UL;
|
||||
_lifetimeMutationByGuid[serverGuid] = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
public ulong CurrentLifetimeMutation(uint serverGuid) =>
|
||||
_lifetimeMutationByGuid.GetValueOrDefault(serverGuid);
|
||||
|
||||
public void BeginSessionClear()
|
||||
{
|
||||
SessionLifetimeVersion++;
|
||||
_lifetimeMutationByGuid.Clear();
|
||||
ParentAttachments.Clear();
|
||||
_inbound.Clear();
|
||||
}
|
||||
|
||||
public bool CompleteSessionClearIfConverged()
|
||||
{
|
||||
if (_activeByGuid.Count != 0 || _teardownByIncarnation.Count != 0)
|
||||
return false;
|
||||
|
||||
_byLocalId.Clear();
|
||||
ParentAttachments.Clear();
|
||||
_inbound.Clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void RefreshSnapshot(
|
||||
RuntimeEntityRecord record,
|
||||
WorldSession.EntitySpawn accepted,
|
||||
bool refreshPosition = false)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
record.Snapshot = accepted;
|
||||
record.RefreshDerivedState(refreshPosition);
|
||||
}
|
||||
|
||||
public void AdvanceCreateAuthority(RuntimeEntityRecord record)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
record.AdvanceCreateAuthority();
|
||||
}
|
||||
|
||||
public void AdvancePositionAuthority(RuntimeEntityRecord record)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
record.AdvancePositionAuthority();
|
||||
}
|
||||
|
||||
public void AdvanceVectorAuthority(RuntimeEntityRecord record)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
record.AdvanceVectorAuthority();
|
||||
}
|
||||
|
||||
public void AdvanceMovementAuthority(RuntimeEntityRecord record)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
record.AdvanceMovementAuthority();
|
||||
}
|
||||
|
||||
public void AdvanceObjDescAuthority(RuntimeEntityRecord record)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
record.AdvanceObjDescAuthority();
|
||||
}
|
||||
|
||||
public RetailPhysicsStateTransition ApplyRawPhysicsState(
|
||||
RuntimeEntityRecord record,
|
||||
uint rawState)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
return record.ApplyRawPhysicsState(rawState);
|
||||
}
|
||||
|
||||
public bool TryDequeueStateTransition(
|
||||
RuntimeEntityRecord record,
|
||||
out RetailPhysicsStateTransition transition)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
return record.TryDequeueStateTransition(out transition);
|
||||
}
|
||||
|
||||
public void SetChildNoDraw(RuntimeEntityRecord record, bool noDraw)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
record.SetChildNoDraw(noDraw);
|
||||
}
|
||||
|
||||
public void SuspendObjectClock(RuntimeEntityRecord record)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
record.SuspendObjectClock();
|
||||
}
|
||||
|
||||
public void ResetObjectClockForEnterWorld(RuntimeEntityRecord record, bool isStatic)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
record.ResetObjectClockForEnterWorld(isStatic);
|
||||
}
|
||||
|
||||
public void SetFullCell(
|
||||
RuntimeEntityRecord record,
|
||||
uint fullCellId,
|
||||
uint canonicalLandblockId)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
record.FullCellId = fullCellId;
|
||||
record.CanonicalLandblockId = canonicalLandblockId;
|
||||
}
|
||||
|
||||
public void SetFinalPhysicsState(
|
||||
RuntimeEntityRecord record,
|
||||
PhysicsStateFlags state)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
record.FinalPhysicsState = state;
|
||||
}
|
||||
|
||||
public void SetHasPartArray(RuntimeEntityRecord record, bool value)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
record.HasPartArray = value;
|
||||
}
|
||||
|
||||
public void SetPhysicsBody(
|
||||
RuntimeEntityRecord record,
|
||||
AcDream.Core.Physics.PhysicsBody? body)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
record.PhysicsBody = body;
|
||||
}
|
||||
|
||||
public void SetPhysicsBodyAcquisitionInProgress(
|
||||
RuntimeEntityRecord record,
|
||||
bool value)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
record.PhysicsBodyAcquisitionInProgress = value;
|
||||
}
|
||||
|
||||
public void SetPhysicsHost(
|
||||
RuntimeEntityRecord record,
|
||||
AcDream.Core.Physics.Motion.IPhysicsObjHost? host)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
record.PhysicsHost = host;
|
||||
}
|
||||
|
||||
public void SetDeleteAcceptedForTeardown(
|
||||
RuntimeEntityRecord record,
|
||||
bool value)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
record.DeleteAcceptedForTeardown = value;
|
||||
}
|
||||
|
||||
public bool TryApplyObjDesc(
|
||||
AcDream.Core.Net.Messages.ObjDescEvent.Parsed update,
|
||||
out WorldSession.EntitySpawn accepted) =>
|
||||
_inbound.TryApplyObjDesc(update, out accepted);
|
||||
|
||||
public bool TryApplyPickup(
|
||||
AcDream.Core.Net.Messages.PickupEvent.Parsed update,
|
||||
out WorldSession.EntitySpawn accepted) =>
|
||||
_inbound.TryApplyPickup(update, out accepted);
|
||||
|
||||
public bool TryApplyCreateParent(
|
||||
CreateParentUpdate update,
|
||||
out WorldSession.EntitySpawn accepted) =>
|
||||
_inbound.TryApplyCreateParent(update, out accepted);
|
||||
|
||||
public bool TryApplyParent(
|
||||
AcDream.Core.Net.Messages.ParentEvent.Parsed update,
|
||||
out WorldSession.EntitySpawn accepted) =>
|
||||
_inbound.TryApplyParent(update, out accepted);
|
||||
|
||||
public bool TryCommitParent(
|
||||
uint childGuid,
|
||||
uint parentGuid,
|
||||
uint parentLocation,
|
||||
uint placementId,
|
||||
ushort positionSequence,
|
||||
out WorldSession.EntitySpawn accepted) =>
|
||||
_inbound.TryCommitParent(
|
||||
childGuid,
|
||||
parentGuid,
|
||||
parentLocation,
|
||||
placementId,
|
||||
positionSequence,
|
||||
out accepted);
|
||||
|
||||
public bool TryApplyMotion(
|
||||
WorldSession.EntityMotionUpdate update,
|
||||
bool retainPayload,
|
||||
out WorldSession.EntitySpawn accepted,
|
||||
out AcceptedPhysicsTimestamps timestamps) =>
|
||||
_inbound.TryApplyMotion(update, retainPayload, out accepted, out timestamps);
|
||||
|
||||
public bool TryApplyVector(
|
||||
AcDream.Core.Net.Messages.VectorUpdate.Parsed update,
|
||||
out WorldSession.EntitySpawn accepted) =>
|
||||
_inbound.TryApplyVector(update, out accepted);
|
||||
|
||||
public bool TryApplyState(
|
||||
AcDream.Core.Net.Messages.SetState.Parsed update,
|
||||
out WorldSession.EntitySpawn accepted) =>
|
||||
_inbound.TryApplyState(update, out accepted);
|
||||
|
||||
public bool TryApplyPosition(
|
||||
WorldSession.EntityPositionUpdate update,
|
||||
bool isLocalPlayer,
|
||||
System.Numerics.Quaternion? forcePositionRotation,
|
||||
System.Numerics.Vector3? currentLocalVelocity,
|
||||
out PositionTimestampDisposition disposition,
|
||||
out WorldSession.EntitySpawn accepted,
|
||||
out AcceptedPhysicsTimestamps timestamps) =>
|
||||
_inbound.TryApplyPosition(
|
||||
update,
|
||||
isLocalPlayer,
|
||||
forcePositionRotation,
|
||||
currentLocalVelocity,
|
||||
out disposition,
|
||||
out accepted,
|
||||
out timestamps);
|
||||
|
||||
public bool IsFreshTeleportStart(uint guid, ushort teleportSequence) =>
|
||||
_inbound.IsFreshTeleportStart(guid, teleportSequence);
|
||||
|
||||
private bool IsKnown(RuntimeEntityRecord record)
|
||||
{
|
||||
if (IsCurrent(record))
|
||||
return true;
|
||||
return _teardownByIncarnation.TryGetValue(
|
||||
(record.ServerGuid, record.Incarnation),
|
||||
out RuntimeEntityRecord? retained)
|
||||
&& ReferenceEquals(retained, record);
|
||||
}
|
||||
|
||||
private void EnsureKnown(RuntimeEntityRecord record)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
if (!IsKnown(record))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} is no longer owned by the directory.");
|
||||
}
|
||||
}
|
||||
}
|
||||
160
src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs
Normal file
160
src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Runtime.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Stable identity issued by the canonical runtime directory. The local id is
|
||||
/// claimed when a graphical host first materializes the incarnation; a
|
||||
/// no-window host may claim it immediately.
|
||||
/// </summary>
|
||||
public readonly record struct RuntimeEntityKey(
|
||||
uint LocalEntityId,
|
||||
ushort Incarnation);
|
||||
|
||||
/// <summary>
|
||||
/// Presentation-free canonical state for one accepted server-object
|
||||
/// incarnation. App projection, animation, effects, visibility, and hydration
|
||||
/// state must never be stored here.
|
||||
/// </summary>
|
||||
public sealed class RuntimeEntityRecord
|
||||
{
|
||||
private readonly Queue<RetailPhysicsStateTransition> _pendingStateTransitions = new();
|
||||
|
||||
internal RuntimeEntityRecord(WorldSession.EntitySpawn snapshot)
|
||||
{
|
||||
ServerGuid = snapshot.Guid;
|
||||
Snapshot = snapshot;
|
||||
RefreshDerivedState();
|
||||
PositionAuthorityVersion = snapshot.Position is null ? 0UL : 1UL;
|
||||
VectorAuthorityVersion = snapshot.Physics is null ? 0UL : 1UL;
|
||||
VelocityAuthorityVersion = snapshot.Position is null
|
||||
&& snapshot.Physics is null
|
||||
? 0UL
|
||||
: 1UL;
|
||||
MovementAuthorityVersion = 1UL;
|
||||
ObjDescAuthorityVersion = 1UL;
|
||||
FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState;
|
||||
ApplyRawPhysicsState(RawPhysicsState);
|
||||
}
|
||||
|
||||
public uint ServerGuid { get; }
|
||||
public ushort Incarnation => Snapshot.InstanceSequence;
|
||||
public ushort Generation => Incarnation;
|
||||
public WorldSession.EntitySpawn Snapshot { get; internal set; }
|
||||
public uint? LocalEntityId { get; internal set; }
|
||||
public RuntimeEntityKey? Key => LocalEntityId is { } localId
|
||||
? new RuntimeEntityKey(localId, Incarnation)
|
||||
: null;
|
||||
public uint FullCellId { get; internal set; }
|
||||
public uint CanonicalLandblockId { get; internal set; }
|
||||
public uint RawPhysicsState { get; internal set; }
|
||||
public PhysicsStateFlags FinalPhysicsState { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Incarnation-stable retail <c>CPhysicsObj::update_time</c> owner.
|
||||
/// </summary>
|
||||
public RetailObjectQuantumClock ObjectClock { get; } = new();
|
||||
|
||||
public ulong ObjectClockEpoch { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// True after this incarnation has successfully constructed its retail
|
||||
/// <c>CPartArray</c>. This is a simulation/lifetime fact, not a renderer
|
||||
/// object reference.
|
||||
/// </summary>
|
||||
public bool HasPartArray { get; internal set; }
|
||||
public PhysicsBody? PhysicsBody { get; internal set; }
|
||||
public bool PhysicsBodyAcquisitionInProgress { get; internal set; }
|
||||
public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost { get; internal set; }
|
||||
public ulong PositionAuthorityVersion { get; private set; }
|
||||
public ulong StateAuthorityVersion { get; private set; }
|
||||
public ulong VectorAuthorityVersion { get; private set; }
|
||||
public ulong VelocityAuthorityVersion { get; private set; }
|
||||
public ulong MovementAuthorityVersion { get; private set; }
|
||||
public ulong ObjDescAuthorityVersion { get; private set; }
|
||||
public ulong CreateIntegrationVersion { get; private set; } = 1UL;
|
||||
public bool DeleteAcceptedForTeardown { get; internal set; }
|
||||
|
||||
internal bool TryDequeueStateTransition(out RetailPhysicsStateTransition transition) =>
|
||||
_pendingStateTransitions.TryDequeue(out transition);
|
||||
|
||||
internal void SuspendObjectClock()
|
||||
{
|
||||
ObjectClock.Deactivate();
|
||||
ObjectClockEpoch++;
|
||||
}
|
||||
|
||||
internal void ResetObjectClockForEnterWorld(bool isStatic)
|
||||
{
|
||||
ObjectClock.ResetForEnterWorld(isStatic);
|
||||
ObjectClockEpoch++;
|
||||
}
|
||||
|
||||
internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState)
|
||||
{
|
||||
StateAuthorityVersion++;
|
||||
RawPhysicsState = rawState;
|
||||
RetailPhysicsStateTransition transition = RetailPhysicsStateTransitions.Apply(
|
||||
FinalPhysicsState,
|
||||
(PhysicsStateFlags)rawState);
|
||||
FinalPhysicsState = transition.FinalState;
|
||||
if (PhysicsBody is not null)
|
||||
PhysicsBody.State = FinalPhysicsState;
|
||||
_pendingStateTransitions.Enqueue(transition);
|
||||
return transition;
|
||||
}
|
||||
|
||||
internal void AdvancePositionAuthority()
|
||||
{
|
||||
PositionAuthorityVersion++;
|
||||
VelocityAuthorityVersion++;
|
||||
}
|
||||
|
||||
internal void AdvanceVectorAuthority()
|
||||
{
|
||||
VectorAuthorityVersion++;
|
||||
VelocityAuthorityVersion++;
|
||||
}
|
||||
|
||||
internal void AdvanceMovementAuthority()
|
||||
{
|
||||
MovementAuthorityVersion++;
|
||||
VelocityAuthorityVersion++;
|
||||
}
|
||||
|
||||
internal void AdvanceObjDescAuthority() => ObjDescAuthorityVersion++;
|
||||
|
||||
internal void AdvanceCreateAuthority()
|
||||
{
|
||||
PositionAuthorityVersion++;
|
||||
StateAuthorityVersion++;
|
||||
VectorAuthorityVersion++;
|
||||
VelocityAuthorityVersion++;
|
||||
MovementAuthorityVersion++;
|
||||
ObjDescAuthorityVersion++;
|
||||
CreateIntegrationVersion++;
|
||||
}
|
||||
|
||||
internal void SetChildNoDraw(bool noDraw)
|
||||
{
|
||||
FinalPhysicsState = noDraw
|
||||
? FinalPhysicsState | PhysicsStateFlags.NoDraw
|
||||
: FinalPhysicsState & ~PhysicsStateFlags.NoDraw;
|
||||
if (PhysicsBody is not null)
|
||||
PhysicsBody.State = FinalPhysicsState;
|
||||
}
|
||||
|
||||
internal void RefreshDerivedState(bool refreshPosition = true)
|
||||
{
|
||||
if (refreshPosition && Snapshot.Position is { } position)
|
||||
{
|
||||
FullCellId = position.LandblockId;
|
||||
CanonicalLandblockId = (FullCellId & 0xFFFF0000u) | 0xFFFFu;
|
||||
}
|
||||
|
||||
RawPhysicsState = Snapshot.Physics?.RawState
|
||||
?? Snapshot.PhysicsState
|
||||
?? 0u;
|
||||
}
|
||||
}
|
||||
76
tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs
Normal file
76
tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
using System.Reflection;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Runtime.Entities;
|
||||
|
||||
namespace AcDream.App.Tests.World;
|
||||
|
||||
public sealed class RuntimeEntityOwnershipTests
|
||||
{
|
||||
[Fact]
|
||||
public void LiveEntityRuntime_ComposesCanonicalRuntimeDirectory()
|
||||
{
|
||||
FieldInfo directory = typeof(LiveEntityRuntime).GetField(
|
||||
"_directory",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException("Missing canonical entity directory.");
|
||||
FieldInfo sidecars = typeof(LiveEntityRuntime).GetField(
|
||||
"_activeRecords",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException("Missing App projection sidecar store.");
|
||||
|
||||
Assert.Equal(typeof(RuntimeEntityDirectory), directory.FieldType);
|
||||
Assert.Equal(typeof(RuntimeEntityRecord), sidecars.FieldType.GetGenericArguments()[0]);
|
||||
Assert.Equal(typeof(LiveEntityRecord), sidecars.FieldType.GetGenericArguments()[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppCompatibilityLookup_IsNotASecondGuidDictionary()
|
||||
{
|
||||
FieldInfo compatibilityView = typeof(LiveEntityRuntime).GetField(
|
||||
"_recordsByGuid",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException("Missing migration compatibility view.");
|
||||
|
||||
Assert.False(compatibilityView.FieldType.IsGenericType);
|
||||
Assert.DoesNotContain(
|
||||
compatibilityView.FieldType.GetInterfaces(),
|
||||
type => type.IsGenericType
|
||||
&& type.GetGenericTypeDefinition() == typeof(IDictionary<,>));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalRuntimeRecord_HasNoProjectionOrBackendSurface()
|
||||
{
|
||||
string[] forbiddenPropertyNames =
|
||||
[
|
||||
"WorldEntity",
|
||||
"AnimationRuntime",
|
||||
"RemoteMotionRuntime",
|
||||
"ProjectileRuntime",
|
||||
"EffectProfile",
|
||||
"ResourcesRegistered",
|
||||
"IsSpatiallyProjected",
|
||||
"IsSpatiallyVisible",
|
||||
];
|
||||
PropertyInfo[] properties = typeof(RuntimeEntityRecord).GetProperties(
|
||||
BindingFlags.Instance | BindingFlags.Public);
|
||||
|
||||
Assert.DoesNotContain(
|
||||
properties,
|
||||
property => forbiddenPropertyNames.Contains(
|
||||
property.Name,
|
||||
StringComparer.Ordinal));
|
||||
Assert.DoesNotContain(
|
||||
properties,
|
||||
property => IsPresentationType(property.PropertyType));
|
||||
}
|
||||
|
||||
private static bool IsPresentationType(Type type)
|
||||
{
|
||||
string? ns = type.Namespace;
|
||||
return ns?.StartsWith("AcDream.App", StringComparison.Ordinal) == true
|
||||
|| ns?.StartsWith("AcDream.UI", StringComparison.Ordinal) == true
|
||||
|| ns?.StartsWith("Silk.NET", StringComparison.Ordinal) == true
|
||||
|| ns?.StartsWith("ImGuiNET", StringComparison.Ordinal) == true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime.Entities;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Entities;
|
||||
|
||||
public sealed class RuntimeEntityDirectoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddActive_OwnsOneCurrentIncarnationPerGuid()
|
||||
{
|
||||
var directory = new RuntimeEntityDirectory();
|
||||
RuntimeEntityRecord first = directory.AddActive(Spawn(0x70000001u, 1));
|
||||
|
||||
Assert.True(directory.TryGetActive(first.ServerGuid, out RuntimeEntityRecord found));
|
||||
Assert.Same(first, found);
|
||||
Assert.True(directory.IsCurrent(first));
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => directory.AddActive(Spawn(first.ServerGuid, 2)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveAndRetainTombstone_AllowsReplacementWithoutLosingExactOldIncarnation()
|
||||
{
|
||||
var directory = new RuntimeEntityDirectory();
|
||||
RuntimeEntityRecord old = directory.AddActive(Spawn(0x70000002u, 7));
|
||||
|
||||
Assert.True(directory.RemoveActive(old.ServerGuid, out RuntimeEntityRecord? removed));
|
||||
Assert.Same(old, removed);
|
||||
directory.RetainTeardown(old);
|
||||
RuntimeEntityRecord replacement = directory.AddActive(Spawn(old.ServerGuid, 8));
|
||||
|
||||
Assert.True(directory.TryGetActive(old.ServerGuid, out RuntimeEntityRecord current));
|
||||
Assert.Same(replacement, current);
|
||||
Assert.True(directory.TryGetTeardown(old.ServerGuid, 7, out RuntimeEntityRecord tombstone));
|
||||
Assert.Same(old, tombstone);
|
||||
Assert.False(directory.IsCurrent(old));
|
||||
Assert.True(directory.IsCurrent(replacement));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalIdentity_IsOwnedByDirectoryAndReverseLookupRejectsReleasedIncarnation()
|
||||
{
|
||||
var directory = new RuntimeEntityDirectory();
|
||||
RuntimeEntityRecord old = directory.AddActive(Spawn(0x70000003u, 1));
|
||||
uint oldLocalId = directory.ClaimLocalId(old);
|
||||
Assert.True(directory.TryGetByLocalId(oldLocalId, out RuntimeEntityRecord foundOld));
|
||||
Assert.Same(old, foundOld);
|
||||
|
||||
Assert.True(directory.RemoveActive(old.ServerGuid, out _));
|
||||
directory.RetainTeardown(old);
|
||||
RuntimeEntityRecord replacement = directory.AddActive(Spawn(old.ServerGuid, 2));
|
||||
uint replacementLocalId = directory.ClaimLocalId(replacement);
|
||||
|
||||
Assert.NotEqual(oldLocalId, replacementLocalId);
|
||||
Assert.True(directory.ReleaseLocalId(old));
|
||||
Assert.False(directory.TryGetByLocalId(oldLocalId, out _));
|
||||
Assert.True(directory.TryGetByLocalId(
|
||||
replacementLocalId,
|
||||
out RuntimeEntityRecord foundReplacement));
|
||||
Assert.Same(replacement, foundReplacement);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalIdentity_WrapsWithinReservedNamespaceWithoutCollision()
|
||||
{
|
||||
var directory = new RuntimeEntityDirectory(RuntimeEntityDirectory.LastLocalEntityId);
|
||||
RuntimeEntityRecord first = directory.AddActive(Spawn(0x70000004u, 1));
|
||||
RuntimeEntityRecord second = directory.AddActive(Spawn(0x70000005u, 1));
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeEntityDirectory.LastLocalEntityId,
|
||||
directory.ClaimLocalId(first));
|
||||
Assert.Equal(
|
||||
RuntimeEntityDirectory.FirstLocalEntityId,
|
||||
directory.ClaimLocalId(second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionClear_AdvancesEpochAndPreservesRetryableTombstone()
|
||||
{
|
||||
var directory = new RuntimeEntityDirectory();
|
||||
RuntimeEntityRecord record = directory.AddActive(Spawn(0x70000006u, 3));
|
||||
ulong operation = directory.AdvanceLifetimeMutation(record.ServerGuid);
|
||||
Assert.Equal(operation, directory.CurrentLifetimeMutation(record.ServerGuid));
|
||||
|
||||
Assert.True(directory.RemoveActive(record.ServerGuid, out _));
|
||||
directory.RetainTeardown(record);
|
||||
directory.BeginSessionClear();
|
||||
|
||||
Assert.Equal(1UL, directory.SessionLifetimeVersion);
|
||||
Assert.Equal(0UL, directory.CurrentLifetimeMutation(record.ServerGuid));
|
||||
Assert.False(directory.CompleteSessionClearIfConverged());
|
||||
Assert.True(directory.TryGetTeardown(record.ServerGuid, 3, out _));
|
||||
|
||||
directory.ReleaseTeardown(record);
|
||||
Assert.True(directory.CompleteSessionClearIfConverged());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AcceptedCreateSnapshotAndAuthorityLiveOnCanonicalRecord()
|
||||
{
|
||||
var directory = new RuntimeEntityDirectory();
|
||||
WorldSession.EntitySpawn spawn = Spawn(0x70000007u, 4);
|
||||
InboundCreateResult accepted = directory.AcceptCreate(spawn);
|
||||
RuntimeEntityRecord record = directory.AddActive(accepted.Snapshot);
|
||||
|
||||
ulong priorCreateVersion = record.CreateIntegrationVersion;
|
||||
directory.AdvanceCreateAuthority(record);
|
||||
directory.RefreshSnapshot(
|
||||
record,
|
||||
accepted.Snapshot with { Name = "refreshed" },
|
||||
refreshPosition: true);
|
||||
|
||||
Assert.Equal("refreshed", record.Snapshot.Name);
|
||||
Assert.Equal(priorCreateVersion + 1UL, record.CreateIntegrationVersion);
|
||||
Assert.Equal(spawn.Position!.Value.LandblockId, record.FullCellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteGateAndIdentityRemovalRemainSeparateRetryableEdges()
|
||||
{
|
||||
var directory = new RuntimeEntityDirectory();
|
||||
WorldSession.EntitySpawn spawn = Spawn(0x70000008u, 9);
|
||||
directory.AcceptCreate(spawn);
|
||||
RuntimeEntityRecord record = directory.AddActive(spawn);
|
||||
|
||||
Assert.True(directory.TryDelete(
|
||||
new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence),
|
||||
isLocalPlayer: false));
|
||||
Assert.True(directory.IsCurrent(record));
|
||||
Assert.True(directory.RemoveActive(record));
|
||||
directory.RetainTeardown(record);
|
||||
Assert.True(directory.TryGetTeardown(
|
||||
spawn.Guid,
|
||||
spawn.InstanceSequence,
|
||||
out RuntimeEntityRecord retained));
|
||||
Assert.Same(record, retained);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalRecord_PublicSurfaceContainsNoAppOrBackendTypes()
|
||||
{
|
||||
Type canonical = typeof(RuntimeEntityRecord);
|
||||
Type[] exposed = canonical
|
||||
.GetProperties()
|
||||
.Select(property => property.PropertyType)
|
||||
.Concat(canonical.GetMethods().Select(method => method.ReturnType))
|
||||
.ToArray();
|
||||
|
||||
Assert.DoesNotContain(exposed, type =>
|
||||
type.Namespace?.StartsWith("AcDream.App", StringComparison.Ordinal) == true
|
||||
|| type.Namespace?.StartsWith("Silk.NET", StringComparison.Ordinal) == true
|
||||
|| type.Namespace?.StartsWith("ImGuiNET", StringComparison.Ordinal) == true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_RejectsIdsOutsideLiveNamespace()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => new RuntimeEntityDirectory(
|
||||
RuntimeEntityDirectory.FirstLocalEntityId - 1u));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => new RuntimeEntityDirectory(
|
||||
RuntimeEntityDirectory.LastLocalEntityId + 1u));
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance)
|
||||
{
|
||||
var position = new CreateObject.ServerPosition(
|
||||
0x0101FFFFu,
|
||||
10f,
|
||||
20f,
|
||||
5f,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0f);
|
||||
var timestamps = new PhysicsTimestamps(
|
||||
Position: 1,
|
||||
Movement: 1,
|
||||
State: 1,
|
||||
Vector: 1,
|
||||
Teleport: 0,
|
||||
ServerControlledMove: 1,
|
||||
ForcePosition: 0,
|
||||
ObjDesc: 1,
|
||||
Instance: instance);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: 0x408u,
|
||||
Position: position,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: 0x02000001u,
|
||||
MotionTableId: 0x09000001u,
|
||||
SoundTableId: null,
|
||||
PhysicsScriptTableId: null,
|
||||
Parent: null,
|
||||
Children: null,
|
||||
Scale: null,
|
||||
Friction: null,
|
||||
Elasticity: null,
|
||||
Translucency: null,
|
||||
Velocity: null,
|
||||
Acceleration: null,
|
||||
AngularVelocity: null,
|
||||
DefaultScriptType: null,
|
||||
DefaultScriptIntensity: null,
|
||||
Timestamps: timestamps);
|
||||
return new WorldSession.EntitySpawn(
|
||||
guid,
|
||||
position,
|
||||
0x02000001u,
|
||||
Array.Empty<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
null,
|
||||
null,
|
||||
"fixture",
|
||||
null,
|
||||
null,
|
||||
0x09000001u,
|
||||
PhysicsState: 0x408u,
|
||||
InstanceSequence: instance,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1,
|
||||
Physics: physics);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue