Replace the incomplete package path with one DatCollection-backed compatibility seam for PhysicsScripts and Animations. Preserve CreateBlockingParticle's inherited payload and following cursor, route every production and audit consumer through the corrected loaders, and apply retail's post-UnPack StartTime ordering. Add exact stored-order PhysicsScriptTable upper-threshold resolution, high-byte DID and embedded-ID validation, plus live effect profiles with Setup-to-PhysicsDesc precedence across top-level and attached entity lifetimes. Keep blocking execution deferred and narrow TS-11 accordingly. Pin synthetic malformed/cursor/order fixtures, installed-DAT blocking and recall audits, high-index IDs, IEEE boundaries, profile teardown, and ordinary decoder parity; synchronize architecture, inventory, milestones, roadmap, research, and memory. Co-Authored-By: Codex <noreply@openai.com>
756 lines
29 KiB
C#
756 lines
29 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>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 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 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);
|
|
}
|
|
}
|