acdream/src/AcDream.App/World/LiveEntityRuntime.cs
Erik a51ebc66e9 feat(physics): integrate live projectile runtime
Attach the retail projectile driver to canonical LiveEntityRecord ownership, sharing one PhysicsBody and full-cell identity with RemoteMotion regardless of creation order. Apply timestamp-gated state, vector, and position corrections in place, preserve active ordinary-body behavior when Missile clears, and keep renderer, effects, shadows, and spatial buckets synchronized across loaded/pending transitions.

Validate malformed packets before canonical timestamp mutation, validate adopted bodies from their current frame rather than stale CreateObject data, serialize late Setup resolution with streaming DAT reads, and preserve classification across clock anomalies. Retain shadow registrations through temporary leave-world residence while logical teardown remains generation-scoped.

Add 31 App lifecycle/adversarial tests plus Core shadow suspension coverage, and synchronize the retail pseudocode, architecture, milestones, roadmap, and durable physics memory.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-14 12:51:42 +02:00

890 lines
34 KiB
C#

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>
/// Optional seam for a remote-motion component that consumes the canonical
/// full-cell identity owned by <see cref="LiveEntityRecord"/>. The runtime
/// binds this for every production remote, regardless of whether projectile
/// classification arrives before or after the MovementManager.
/// </summary>
public interface ILiveEntityCanonicalCellConsumer
{
void BindCanonicalCell(Func<uint> read, Action<uint> write);
}
/// <summary>Projectile physics state owned by a live object.</summary>
public interface ILiveEntityProjectileRuntime
{
PhysicsBody Body { get; }
}
/// <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; }
/// <summary>
/// Cell used when a streamed landblock asks live objects to restore their
/// spatial projection. Once materialized, the canonical runtime cell wins;
/// the retained server snapshot is only a first-materialization source.
/// </summary>
public uint ProjectionCellId => WorldEntity is not null
? 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;
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 _rebucketingGuid;
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();
/// <summary>
/// Raised after a materialized projection enters or leaves the currently
/// loaded spatial view. Logical resources remain owned by the record.
/// </summary>
public event Action<LiveEntityRecord, bool>? ProjectionVisibilityChanged;
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;
bool wasProjected = record.IsSpatiallyProjected;
bool wasVisible = record.IsSpatiallyVisible;
// GpuWorldState reports an intermediate false/true pair while moving
// between two loaded buckets. Suppress those implementation details
// and publish only the final logical visibility edge.
record.IsSpatiallyProjected = true;
_rebucketingGuid = serverGuid;
try
{
_spatial.RebucketLiveEntity(entity, spatialCellOrLandblockId);
}
finally
{
_rebucketingGuid = 0;
}
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;
if (!wasProjected || wasVisible != visible)
ProjectionVisibilityChanged?.Invoke(record, visible);
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.");
if (record.ProjectileRuntime is { } projectile
&& !ReferenceEquals(projectile.Body, runtime.Body))
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} already owns a different projectile physics body.");
}
record.RemoteMotionRuntime = runtime;
record.PhysicsBody = runtime.Body;
if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer)
{
// Capture the incarnation, not only its GUID. A callback retained
// by an old MovementManager must never mutate a replacement that
// later reuses the same server GUID.
LiveEntityRecord incarnation = record;
cellConsumer.BindCanonicalCell(
read: () =>
_recordsByGuid.TryGetValue(serverGuid, out var current)
&& ReferenceEquals(current, incarnation)
&& ReferenceEquals(current.RemoteMotionRuntime, runtime)
? current.FullCellId
: 0u,
write: cellId =>
{
if (cellId != 0
&& _recordsByGuid.TryGetValue(serverGuid, out var current)
&& ReferenceEquals(current, incarnation)
&& ReferenceEquals(current.RemoteMotionRuntime, runtime))
{
RebucketLiveEntity(serverGuid, cellId);
}
});
}
_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;
if (record.ProjectileRuntime is null)
record.PhysicsBody = null;
return true;
}
public void SetProjectileRuntime(uint serverGuid, ILiveEntityProjectileRuntime runtime)
{
ArgumentNullException.ThrowIfNull(runtime);
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|| record.WorldEntity is null)
{
throw new InvalidOperationException(
$"Cannot bind projectile physics before live entity 0x{serverGuid:X8} is materialized.");
}
if (record.RemoteMotionRuntime is { } remote
&& !ReferenceEquals(remote.Body, runtime.Body))
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} already owns a different remote-motion physics body.");
}
if (record.ProjectileRuntime is { } projectile
&& !ReferenceEquals(projectile.Body, runtime.Body))
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} already owns a different projectile physics body.");
}
record.ProjectileRuntime = runtime;
record.PhysicsBody = runtime.Body;
}
public bool TryGetProjectileRuntime(
uint serverGuid,
out ILiveEntityProjectileRuntime runtime)
{
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
&& record.ProjectileRuntime is { } found)
{
runtime = found;
return true;
}
runtime = null!;
return false;
}
public bool ClearProjectileRuntime(uint serverGuid)
{
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|| record.ProjectileRuntime is null)
return false;
record.ProjectileRuntime = null;
if (record.RemoteMotionRuntime is null)
record.PhysicsBody = null;
return true;
}
public void SetEffectProfile(uint serverGuid, ILiveEntityEffectProfile profile)
{
ArgumentNullException.ThrowIfNull(profile);
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record))
throw new InvalidOperationException(
$"Cannot bind an effect profile before live entity 0x{serverGuid:X8} exists.");
record.EffectProfile = profile;
}
public bool TryGetEffectProfile(
uint serverGuid,
out ILiveEntityEffectProfile profile)
{
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
&& record.EffectProfile is { } found)
{
profile = found;
return true;
}
profile = null!;
return false;
}
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 <c>CPhysicsObj::update_object</c> (<c>0x00515D40</c>) runs root
/// movement/animation only while the object has visible world-cell
/// membership and is not Frozen. 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.IsSpatiallyVisible
&& record.FullCellId != 0
&& (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 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);
if (_rebucketingGuid != serverGuid)
ProjectionVisibilityChanged?.Invoke(record, visible);
}
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);
}
}