refactor(world): separate live lifetime from spatial buckets
Introduce LiveEntityRuntime as the canonical owner of each accepted server-object incarnation, stable local identity, timestamped state, parent relations, runtime components, and exactly-once teardown. Split logical registration from rebucketing so pending landblocks, equipment attachment, pickup re-entry, and GUID replacement reuse the same entity and effect owners. Keep canonical materialized and visible target/radar views distinct, preserve retail leave_world versus exit_world semantics, gate root simulation while cell-less, and track transitional pre-Create F754 owners through delete and session reset. Remove stale-spawn rehydration and make GpuWorldState spatial-only for live objects. Add lifecycle, generation, pending, unload, attachment, event-publication, local-ID, rollback, and effect-cleanup coverage; update architecture, milestones, memory, and the divergence register. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
parent
8a5d77f7f4
commit
8dd996053d
24 changed files with 2449 additions and 631 deletions
732
src/AcDream.App/World/LiveEntityRuntime.cs
Normal file
732
src/AcDream.App/World/LiveEntityRuntime.cs
Normal file
|
|
@ -0,0 +1,732 @@
|
|||
using AcDream.App.Streaming;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.World;
|
||||
|
||||
/// <summary>
|
||||
/// Animation state owned by a live object. The concrete App animation runtime
|
||||
/// implements this seam so identity storage does not depend on a render backend.
|
||||
/// </summary>
|
||||
public interface ILiveEntityAnimationRuntime
|
||||
{
|
||||
WorldEntity Entity { get; }
|
||||
}
|
||||
|
||||
/// <summary>Remote motion state owned by a live object.</summary>
|
||||
public interface ILiveEntityRemoteMotionRuntime
|
||||
{
|
||||
PhysicsBody Body { get; }
|
||||
}
|
||||
|
||||
/// <summary>Marker for the projectile runtime added by the projectile phase.</summary>
|
||||
public interface ILiveEntityProjectileRuntime { }
|
||||
|
||||
/// <summary>Marker for the DAT-driven effect profile added by the effect phase.</summary>
|
||||
public interface ILiveEntityEffectProfile { }
|
||||
|
||||
public enum LiveEntityProjectionKind
|
||||
{
|
||||
World,
|
||||
Attached,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logical-resource seam coordinated by <see cref="LiveEntityRuntime"/>.
|
||||
/// Spatial bucketing is deliberately absent: registering or removing meshes,
|
||||
/// scripts, and effects is a logical-lifetime operation, not a landblock move.
|
||||
/// </summary>
|
||||
public interface ILiveEntityResourceLifecycle
|
||||
{
|
||||
void Register(WorldEntity entity);
|
||||
void Unregister(WorldEntity entity);
|
||||
}
|
||||
|
||||
/// <summary>Delegate adapter used by the App composition root.</summary>
|
||||
public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLifecycle
|
||||
{
|
||||
private readonly Action<WorldEntity> _register;
|
||||
private readonly Action<WorldEntity> _unregister;
|
||||
|
||||
public DelegateLiveEntityResourceLifecycle(
|
||||
Action<WorldEntity> register,
|
||||
Action<WorldEntity> unregister)
|
||||
{
|
||||
_register = register ?? throw new ArgumentNullException(nameof(register));
|
||||
_unregister = unregister ?? throw new ArgumentNullException(nameof(unregister));
|
||||
}
|
||||
|
||||
public void Register(WorldEntity entity) => _register(entity);
|
||||
public void Unregister(WorldEntity entity) => _unregister(entity);
|
||||
}
|
||||
|
||||
/// <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, or newer INSTANCE_TS ends it.
|
||||
/// </summary>
|
||||
public sealed class LiveEntityRecord
|
||||
{
|
||||
internal LiveEntityRecord(WorldSession.EntitySpawn snapshot)
|
||||
{
|
||||
ServerGuid = snapshot.Guid;
|
||||
Snapshot = snapshot;
|
||||
RefreshDerivedState();
|
||||
}
|
||||
|
||||
public uint ServerGuid { get; }
|
||||
public ushort Generation => Snapshot.InstanceSequence;
|
||||
public WorldSession.EntitySpawn Snapshot { get; internal set; }
|
||||
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 PhysicsBody? PhysicsBody { get; internal set; }
|
||||
public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; }
|
||||
public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; }
|
||||
public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; }
|
||||
public ILiveEntityEffectProfile? EffectProfile { get; internal set; }
|
||||
public bool ResourcesRegistered { get; internal set; }
|
||||
public bool IsSpatiallyProjected { get; internal set; }
|
||||
public bool IsSpatiallyVisible { get; internal set; }
|
||||
public bool WorldSpawnPublished { get; internal set; }
|
||||
public LiveEntityProjectionKind ProjectionKind { get; internal set; }
|
||||
|
||||
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;
|
||||
FinalPhysicsState = (PhysicsStateFlags)RawPhysicsState;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct LiveEntityRegistrationResult(
|
||||
InboundCreateResult Inbound,
|
||||
LiveEntityRecord? Record,
|
||||
bool LogicalRegistrationCreated,
|
||||
bool ReplacedExistingGeneration,
|
||||
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.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail keeps one object per accepted INSTANCE_TS: same-generation
|
||||
/// CreateObject branches mutate it, Pickup leaves world without destruction,
|
||||
/// and Delete/instance replacement end it. See SmartBox CreateObject/event
|
||||
/// pseudocode in <c>2026-07-13-retail-projectile-vfx-pseudocode.md</c>,
|
||||
/// <c>CPhysicsObj::set_description</c> (0x00514F40),
|
||||
/// <c>CPhysicsObj::change_cell</c> (0x00513390), and
|
||||
/// <c>CPhysicsObj::exit_world</c> (0x00514E60).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class LiveEntityRuntime
|
||||
{
|
||||
public const uint FirstLiveEntityId = 1_000_000u;
|
||||
public const uint LastLiveEntityId = 0x3FFF_FFFFu;
|
||||
|
||||
private readonly GpuWorldState _spatial;
|
||||
private readonly ILiveEntityResourceLifecycle _resources;
|
||||
private readonly Action<LiveEntityRecord>? _tearDownRuntimeComponents;
|
||||
private readonly InboundPhysicsStateController _inbound = new();
|
||||
private readonly Dictionary<uint, LiveEntityRecord> _recordsByGuid = 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();
|
||||
private uint _nextLocalEntityId;
|
||||
|
||||
public LiveEntityRuntime(
|
||||
GpuWorldState spatial,
|
||||
ILiveEntityResourceLifecycle resources,
|
||||
Action<LiveEntityRecord>? tearDownRuntimeComponents = null,
|
||||
uint firstLocalEntityId = FirstLiveEntityId)
|
||||
{
|
||||
_spatial = spatial ?? throw new ArgumentNullException(nameof(spatial));
|
||||
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
|
||||
_tearDownRuntimeComponents = tearDownRuntimeComponents;
|
||||
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;
|
||||
_spatial.LiveProjectionVisibilityChanged += OnSpatialVisibilityChanged;
|
||||
}
|
||||
|
||||
public int Count => _recordsByGuid.Count;
|
||||
public int MaterializedCount => _guidByLocalId.Count;
|
||||
public IReadOnlyCollection<LiveEntityRecord> Records => _recordsByGuid.Values;
|
||||
/// <summary>
|
||||
/// Every materialized top-level world projection, including an object
|
||||
/// parked in a pending (currently unloaded) spatial bucket. Network and
|
||||
/// physics mutation must use this stable view so streaming visibility can
|
||||
/// never masquerade as logical destruction.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<uint, WorldEntity> MaterializedWorldEntities =>
|
||||
_materializedWorldEntitiesByGuid;
|
||||
|
||||
/// <summary>
|
||||
/// Currently visible top-level world projections. Attached children and
|
||||
/// pending projections are excluded from radar, picking, status, and target
|
||||
/// candidate consumers.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<uint, WorldEntity> WorldEntities =>
|
||||
_visibleWorldEntitiesByGuid;
|
||||
public IReadOnlyDictionary<uint, WorldSession.EntitySpawn> Snapshots => _inbound.Snapshots;
|
||||
public IReadOnlyDictionary<uint, ILiveEntityAnimationRuntime> AnimationRuntimes => _animationsByLocalId;
|
||||
public IReadOnlyDictionary<uint, ILiveEntityRemoteMotionRuntime> RemoteMotionRuntimes => _remoteMotionByGuid;
|
||||
public ParentAttachmentState ParentAttachments { get; } = new();
|
||||
|
||||
public LiveEntityRegistrationResult RegisterLiveEntity(WorldSession.EntitySpawn incoming)
|
||||
{
|
||||
InboundCreateResult result = _inbound.AcceptCreate(incoming);
|
||||
if (result.Disposition is CreateObjectTimestampDisposition.StaleGeneration)
|
||||
return new LiveEntityRegistrationResult(result, null, false, false);
|
||||
|
||||
if (result.Disposition is CreateObjectTimestampDisposition.ExistingGeneration)
|
||||
{
|
||||
if (_recordsByGuid.TryGetValue(incoming.Guid, out LiveEntityRecord? retained))
|
||||
{
|
||||
retained.Snapshot = result.Snapshot;
|
||||
retained.RefreshDerivedState();
|
||||
return new LiveEntityRegistrationResult(result, retained, false, false);
|
||||
}
|
||||
|
||||
// Defensive repair for a caller that cleared only the logical map.
|
||||
// Normal session teardown clears both owners together.
|
||||
var recovered = new LiveEntityRecord(result.Snapshot);
|
||||
_recordsByGuid.Add(incoming.Guid, recovered);
|
||||
return new LiveEntityRegistrationResult(result, recovered, true, false);
|
||||
}
|
||||
|
||||
bool replaced = _recordsByGuid.Remove(incoming.Guid, out LiveEntityRecord? old);
|
||||
Exception? tearDownFailure = null;
|
||||
if (old is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
TearDownRecord(old);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
tearDownFailure = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.Disposition is CreateObjectTimestampDisposition.NewGeneration)
|
||||
ParentAttachments.EndGeneration(incoming.Guid, result.Snapshot.InstanceSequence);
|
||||
|
||||
var record = new LiveEntityRecord(result.Snapshot);
|
||||
_recordsByGuid.Add(incoming.Guid, record);
|
||||
return new LiveEntityRegistrationResult(
|
||||
result,
|
||||
record,
|
||||
true,
|
||||
replaced,
|
||||
tearDownFailure);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes logical registration when a renderable projection can be
|
||||
/// hydrated. The factory is called at most once for the incarnation.
|
||||
/// </summary>
|
||||
public WorldEntity? MaterializeLiveEntity(
|
||||
uint serverGuid,
|
||||
uint fullCellId,
|
||||
Func<uint, WorldEntity> factory,
|
||||
LiveEntityProjectionKind projectionKind = LiveEntityProjectionKind.World)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record))
|
||||
return null;
|
||||
|
||||
if (record.WorldEntity is null)
|
||||
{
|
||||
uint localId = ReserveLocalEntityId();
|
||||
WorldEntity 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}.");
|
||||
|
||||
_guidByLocalId.Add(localId, serverGuid);
|
||||
record.WorldEntity = entity;
|
||||
try
|
||||
{
|
||||
_resources.Register(entity);
|
||||
record.ResourcesRegistered = true;
|
||||
}
|
||||
catch (Exception registrationError)
|
||||
{
|
||||
// Registration is an atomic logical-lifetime boundary. Give
|
||||
// composite owners a symmetric rollback opportunity, then
|
||||
// remove the identity even if cleanup itself fails.
|
||||
try
|
||||
{
|
||||
_resources.Unregister(entity);
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Live entity resource registration and rollback both failed.",
|
||||
registrationError,
|
||||
rollbackError);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_guidByLocalId.Remove(localId);
|
||||
record.WorldEntity = null;
|
||||
record.ResourcesRegistered = false;
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
record.ProjectionKind = projectionKind;
|
||||
RebucketLiveEntity(serverGuid, fullCellId);
|
||||
return record.WorldEntity;
|
||||
}
|
||||
|
||||
/// <summary>Changes spatial buckets only. No logical resource callbacks run.</summary>
|
||||
public bool RebucketLiveEntity(uint serverGuid, uint spatialCellOrLandblockId)
|
||||
{
|
||||
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||
|| record.WorldEntity is not { } entity)
|
||||
return false;
|
||||
|
||||
_spatial.RebucketLiveEntity(entity, spatialCellOrLandblockId);
|
||||
bool visible = _spatial.IsLiveEntityVisible(serverGuid);
|
||||
record.IsSpatiallyVisible = visible;
|
||||
if (record.ProjectionKind is LiveEntityProjectionKind.World)
|
||||
_materializedWorldEntitiesByGuid[serverGuid] = entity;
|
||||
else
|
||||
_materializedWorldEntitiesByGuid.Remove(serverGuid);
|
||||
if (visible && record.ProjectionKind is LiveEntityProjectionKind.World)
|
||||
_visibleWorldEntitiesByGuid[serverGuid] = entity;
|
||||
else
|
||||
_visibleWorldEntitiesByGuid.Remove(serverGuid);
|
||||
if ((spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu)
|
||||
record.FullCellId = spatialCellOrLandblockId;
|
||||
record.CanonicalLandblockId = spatialCellOrLandblockId == 0
|
||||
? 0u
|
||||
: (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu;
|
||||
record.IsSpatiallyProjected = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes only the render-bucket reference. The logical record and every
|
||||
/// create-time resource remain alive for later projection/rebucketing.
|
||||
/// </summary>
|
||||
public bool WithdrawLiveEntityProjection(uint serverGuid)
|
||||
{
|
||||
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||
|| record.WorldEntity is null)
|
||||
return false;
|
||||
|
||||
_spatial.RemoveLiveEntityProjection(serverGuid);
|
||||
_materializedWorldEntitiesByGuid.Remove(serverGuid);
|
||||
_visibleWorldEntitiesByGuid.Remove(serverGuid);
|
||||
record.IsSpatiallyProjected = false;
|
||||
record.IsSpatiallyVisible = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool UnregisterLiveEntity(
|
||||
DeleteObject.Parsed delete,
|
||||
bool isLocalPlayer,
|
||||
Action? beforeTeardown = null)
|
||||
{
|
||||
if (!_inbound.TryDelete(delete, isLocalPlayer))
|
||||
return false;
|
||||
|
||||
ParentAttachments.DeleteGeneration(delete.Guid, delete.InstanceSequence);
|
||||
|
||||
List<Exception>? failures = null;
|
||||
try
|
||||
{
|
||||
beforeTeardown?.Invoke();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= new List<Exception>()).Add(error);
|
||||
}
|
||||
|
||||
if (_recordsByGuid.Remove(delete.Guid, out LiveEntityRecord? record))
|
||||
{
|
||||
try
|
||||
{
|
||||
TearDownRecord(record);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= new List<Exception>()).Add(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures is not null)
|
||||
throw new AggregateException(
|
||||
$"Live entity 0x{delete.Guid:X8} deletion cleanup failed.",
|
||||
failures);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryGetRecord(uint serverGuid, out LiveEntityRecord record) =>
|
||||
_recordsByGuid.TryGetValue(serverGuid, out record!);
|
||||
|
||||
public bool TryGetWorldEntity(uint serverGuid, out WorldEntity entity)
|
||||
{
|
||||
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||
&& record.WorldEntity is { } found)
|
||||
{
|
||||
entity = found;
|
||||
return true;
|
||||
}
|
||||
|
||||
entity = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool ContainsWorldEntity(uint serverGuid) =>
|
||||
_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 TryGetLocalEntityId(uint serverGuid, out uint localEntityId)
|
||||
{
|
||||
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||
&& record.LocalEntityId is { } found)
|
||||
{
|
||||
localEntityId = found;
|
||||
return true;
|
||||
}
|
||||
|
||||
localEntityId = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void SetAnimationRuntime(uint serverGuid, ILiveEntityAnimationRuntime runtime)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||
|| record.WorldEntity is not { } entity)
|
||||
throw new InvalidOperationException($"Cannot bind animation before live entity 0x{serverGuid:X8} is materialized.");
|
||||
if (!ReferenceEquals(runtime.Entity, entity))
|
||||
throw new InvalidOperationException("Animation runtime belongs to a different WorldEntity.");
|
||||
|
||||
if (record.AnimationRuntime is not null)
|
||||
_animationsByLocalId.Remove(entity.Id);
|
||||
record.AnimationRuntime = runtime;
|
||||
_animationsByLocalId[entity.Id] = runtime;
|
||||
}
|
||||
|
||||
public bool TryGetAnimationRuntime(uint localEntityId, out ILiveEntityAnimationRuntime runtime) =>
|
||||
_animationsByLocalId.TryGetValue(localEntityId, out runtime!);
|
||||
|
||||
public bool ClearAnimationRuntime(uint serverGuid)
|
||||
{
|
||||
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||
|| record.AnimationRuntime is null)
|
||||
return false;
|
||||
if (record.LocalEntityId is { } localId)
|
||||
_animationsByLocalId.Remove(localId);
|
||||
record.AnimationRuntime = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void SetRemoteMotionRuntime(uint serverGuid, ILiveEntityRemoteMotionRuntime runtime)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record))
|
||||
throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists.");
|
||||
record.RemoteMotionRuntime = runtime;
|
||||
record.PhysicsBody = runtime.Body;
|
||||
_remoteMotionByGuid[serverGuid] = runtime;
|
||||
}
|
||||
|
||||
public bool TryGetRemoteMotionRuntime(uint serverGuid, out ILiveEntityRemoteMotionRuntime runtime) =>
|
||||
_remoteMotionByGuid.TryGetValue(serverGuid, out runtime!);
|
||||
|
||||
public bool ClearRemoteMotionRuntime(uint serverGuid)
|
||||
{
|
||||
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||
|| record.RemoteMotionRuntime is null)
|
||||
return false;
|
||||
_remoteMotionByGuid.Remove(serverGuid);
|
||||
record.RemoteMotionRuntime = null;
|
||||
record.PhysicsBody = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryGetSnapshot(uint guid, out WorldSession.EntitySpawn spawn) =>
|
||||
_inbound.TryGetSnapshot(guid, out spawn);
|
||||
|
||||
public bool TryApplyObjDesc(ObjDescEvent.Parsed update, out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
bool applied = _inbound.TryApplyObjDesc(update, out accepted);
|
||||
if (applied) RefreshRecord(update.Guid, accepted);
|
||||
return applied;
|
||||
}
|
||||
|
||||
public bool TryApplyPickup(PickupEvent.Parsed update, out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
bool applied = _inbound.TryApplyPickup(update, out accepted);
|
||||
if (applied)
|
||||
{
|
||||
RefreshRecord(update.Guid, accepted);
|
||||
ClearWorldCell(update.Guid);
|
||||
ParentAttachments.EndChildProjection(update.Guid);
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
public bool TryApplyCreateParent(CreateParentUpdate update, out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
bool applied = _inbound.TryApplyCreateParent(update, out accepted);
|
||||
if (applied)
|
||||
{
|
||||
RefreshRecord(update.ChildGuid, accepted);
|
||||
ClearWorldCell(update.ChildGuid);
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
public bool TryApplyParent(ParentEvent.Parsed update, out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
bool applied = _inbound.TryApplyParent(update, out accepted);
|
||||
if (applied)
|
||||
{
|
||||
RefreshRecord(update.ChildGuid, accepted);
|
||||
ClearWorldCell(update.ChildGuid);
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
public bool TryApplyMotion(
|
||||
WorldSession.EntityMotionUpdate update,
|
||||
bool retainPayload,
|
||||
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))
|
||||
RefreshRecord(update.Guid, snapshot);
|
||||
return applied;
|
||||
}
|
||||
|
||||
public bool TryApplyVector(VectorUpdate.Parsed update, out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
bool applied = _inbound.TryApplyVector(update, out accepted);
|
||||
if (applied) RefreshRecord(update.Guid, accepted);
|
||||
return applied;
|
||||
}
|
||||
|
||||
public bool TryApplyState(SetState.Parsed update, out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
bool applied = _inbound.TryApplyState(update, out accepted);
|
||||
if (applied) RefreshRecord(update.Guid, accepted);
|
||||
return applied;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
bool known = _inbound.TryApplyPosition(
|
||||
update,
|
||||
isLocalPlayer,
|
||||
forcePositionRotation,
|
||||
currentLocalVelocity,
|
||||
out disposition,
|
||||
out accepted,
|
||||
out timestamps);
|
||||
if (known && _inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot))
|
||||
{
|
||||
bool acceptedPosition = disposition is not PositionTimestampDisposition.Rejected;
|
||||
RefreshRecord(update.Guid, snapshot, refreshPosition: acceptedPosition);
|
||||
if (acceptedPosition)
|
||||
{
|
||||
ParentAttachments.EndChildProjection(update.Guid);
|
||||
}
|
||||
}
|
||||
return known;
|
||||
}
|
||||
|
||||
public bool IsFreshTeleportStart(uint localPlayerGuid, ushort teleportSequence) =>
|
||||
_inbound.IsFreshTeleportStart(localPlayerGuid, teleportSequence);
|
||||
|
||||
/// <summary>
|
||||
/// Retail runs root movement/animation only while the object has world-cell
|
||||
/// membership. A pickup or parent transition keeps script/effect owners but
|
||||
/// clears the cell and withdraws the root projection until a fresh Position
|
||||
/// re-enters it.
|
||||
/// </summary>
|
||||
public bool ShouldAdvanceRootRuntime(uint serverGuid) =>
|
||||
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||
&& record.IsSpatiallyProjected
|
||||
&& record.FullCellId != 0;
|
||||
|
||||
/// <summary>
|
||||
/// Claims the one logical top-level spawn notification for this object
|
||||
/// incarnation. Spatial re-entry must restore presentation without replaying
|
||||
/// plugin/event registration. An attached-only child claims nothing until it
|
||||
/// first becomes a top-level world projection.
|
||||
/// </summary>
|
||||
public bool TryMarkWorldSpawnPublished(uint serverGuid)
|
||||
{
|
||||
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||
|| record.WorldEntity is null
|
||||
|| record.ProjectionKind is not LiveEntityProjectionKind.World
|
||||
|| record.WorldSpawnPublished)
|
||||
return false;
|
||||
record.WorldSpawnPublished = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
List<Exception>? failures = null;
|
||||
foreach (LiveEntityRecord record in _recordsByGuid.Values.ToArray())
|
||||
{
|
||||
try
|
||||
{
|
||||
TearDownRecord(record);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= new List<Exception>()).Add(error);
|
||||
}
|
||||
}
|
||||
_recordsByGuid.Clear();
|
||||
_materializedWorldEntitiesByGuid.Clear();
|
||||
_visibleWorldEntitiesByGuid.Clear();
|
||||
_guidByLocalId.Clear();
|
||||
_animationsByLocalId.Clear();
|
||||
_remoteMotionByGuid.Clear();
|
||||
ParentAttachments.Clear();
|
||||
_inbound.Clear();
|
||||
|
||||
if (failures is not null)
|
||||
throw new AggregateException("One or more live entities failed session teardown.", failures);
|
||||
}
|
||||
|
||||
private void RefreshRecord(
|
||||
uint guid,
|
||||
WorldSession.EntitySpawn accepted,
|
||||
bool refreshPosition = false)
|
||||
{
|
||||
if (!_recordsByGuid.TryGetValue(guid, out LiveEntityRecord? record))
|
||||
return;
|
||||
record.Snapshot = accepted;
|
||||
record.RefreshDerivedState(refreshPosition);
|
||||
}
|
||||
|
||||
private void ClearWorldCell(uint guid)
|
||||
{
|
||||
if (!_recordsByGuid.TryGetValue(guid, out LiveEntityRecord? record))
|
||||
return;
|
||||
record.FullCellId = 0;
|
||||
record.CanonicalLandblockId = 0;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||
|| record.WorldEntity is not { } entity)
|
||||
return;
|
||||
|
||||
record.IsSpatiallyVisible = visible;
|
||||
if (visible && record.ProjectionKind is LiveEntityProjectionKind.World)
|
||||
_visibleWorldEntitiesByGuid[serverGuid] = entity;
|
||||
else
|
||||
_visibleWorldEntitiesByGuid.Remove(serverGuid);
|
||||
}
|
||||
|
||||
private void TearDownRecord(LiveEntityRecord record)
|
||||
{
|
||||
List<Exception>? failures = null;
|
||||
void RunCleanup(Action cleanup)
|
||||
{
|
||||
try
|
||||
{
|
||||
cleanup();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= new List<Exception>()).Add(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (_tearDownRuntimeComponents is not null)
|
||||
RunCleanup(() => _tearDownRuntimeComponents(record));
|
||||
|
||||
if (record.WorldEntity is { } entity)
|
||||
{
|
||||
RunCleanup(() => _spatial.RemoveLiveEntityProjection(record.ServerGuid));
|
||||
if (record.ResourcesRegistered)
|
||||
RunCleanup(() => _resources.Unregister(entity));
|
||||
_guidByLocalId.Remove(entity.Id);
|
||||
_materializedWorldEntitiesByGuid.Remove(record.ServerGuid);
|
||||
_visibleWorldEntitiesByGuid.Remove(record.ServerGuid);
|
||||
_animationsByLocalId.Remove(entity.Id);
|
||||
}
|
||||
|
||||
_remoteMotionByGuid.Remove(record.ServerGuid);
|
||||
record.AnimationRuntime = null;
|
||||
record.RemoteMotionRuntime = null;
|
||||
record.PhysicsBody = null;
|
||||
record.ProjectileRuntime = null;
|
||||
record.EffectProfile = null;
|
||||
record.ResourcesRegistered = false;
|
||||
record.IsSpatiallyProjected = false;
|
||||
record.IsSpatiallyVisible = false;
|
||||
record.WorldSpawnPublished = false;
|
||||
record.WorldEntity = null;
|
||||
|
||||
if (failures is not null)
|
||||
throw new AggregateException(
|
||||
$"Live entity 0x{record.ServerGuid:X8} teardown failed.",
|
||||
failures);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue