feat(vfx): port retail hidden and teleport presentation

Preserve canonical live-object ownership across Hidden transitions and remote teleport placement so effects, collision, streaming, and targeting remain synchronized.
This commit is contained in:
Erik 2026-07-14 14:59:48 +02:00
parent a51ebc66e9
commit 1e98d81448
46 changed files with 4883 additions and 127 deletions

View file

@ -319,7 +319,10 @@ public sealed class InboundPhysicsStateController
update.TeleportSequence,
update.ForcePositionSequence,
isLocalPlayer);
timestamps = Current(gate);
timestamps = Current(
gate,
teleportAdvanced: disposition is PositionTimestampDisposition.Apply
&& advancesTeleport);
if (disposition is PositionTimestampDisposition.Rejected)
{
accepted = MirrorGateTimestamps(old, gate);
@ -438,11 +441,14 @@ public sealed class InboundPhysicsStateController
0,
spawn.InstanceSequence);
private static AcceptedPhysicsTimestamps Current(PhysicsTimestampGate gate) => new(
private static AcceptedPhysicsTimestamps Current(
PhysicsTimestampGate gate,
bool teleportAdvanced = false) => new(
gate.InstanceTimestamp,
gate.ServerControlledMoveTimestamp,
gate.TeleportTimestamp,
gate.ForcePositionTimestamp);
gate.ForcePositionTimestamp,
teleportAdvanced);
private static WorldSession.EntitySpawn MirrorGateTimestamps(
WorldSession.EntitySpawn spawn,
@ -622,7 +628,9 @@ public readonly record struct AcceptedPhysicsTimestamps(
ushort Instance,
ushort ServerControlledMove,
ushort Teleport,
ushort ForcePosition);
ushort ForcePosition,
bool TeleportAdvanced = false,
bool TeleportHookRequired = false);
public readonly record struct CreateParentUpdate(
uint ChildGuid,

View file

@ -0,0 +1,282 @@
using AcDream.App.Physics;
using AcDream.Core.Physics;
namespace AcDream.App.World;
/// <summary>
/// Applies the presentation/collision side effects of accepted retail
/// PhysicsState transitions after the canonical live owner is ready.
/// Logical ownership stays in <see cref="LiveEntityRuntime"/>; Hidden never
/// unregisters scripts, particles, lights, meshes, or the live record.
/// </summary>
/// <remarks>
/// Ports <c>CPhysicsObj::set_state</c> (<c>0x00514DD0</c>),
/// <c>set_nodraw</c> (<c>0x0050FCA0</c>), and <c>set_hidden</c>
/// (<c>0x00514C60</c>). Typed script ids are retail
/// <c>PS_UnHide=0x75</c> and <c>PS_Hidden=0x76</c> from <c>acclient.h</c>.
/// </remarks>
public sealed class LiveEntityPresentationController : IDisposable
{
public const uint UnHideScriptType = 0x75u;
public const uint HiddenScriptType = 0x76u;
private readonly LiveEntityRuntime _liveEntities;
private readonly ShadowObjectRegistry _shadows;
private readonly Func<uint, uint, float, bool> _playTyped;
private readonly Action<uint, bool> _setDirectChildrenNoDraw;
private readonly Action<uint> _clearInvalidTarget;
private readonly Func<(int X, int Y)> _liveCenter;
private readonly Action<uint>? _onShadowRestored;
private readonly Dictionary<uint, ushort> _readyGenerationByGuid = new();
private readonly Dictionary<uint, ushort> _suspendedShadowGenerationByGuid = new();
private readonly Dictionary<uint, ushort> _activePlacementGenerationByGuid = new();
private bool _disposed;
public LiveEntityPresentationController(
LiveEntityRuntime liveEntities,
ShadowObjectRegistry shadows,
Func<uint, uint, float, bool> playTyped,
Action<uint, bool>? setDirectChildrenNoDraw = null,
Action<uint>? clearInvalidTarget = null,
Func<(int X, int Y)>? liveCenter = null,
Action<uint>? onShadowRestored = null)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
_playTyped = playTyped ?? throw new ArgumentNullException(nameof(playTyped));
_setDirectChildrenNoDraw = setDirectChildrenNoDraw ?? ((_, _) => { });
_clearInvalidTarget = clearInvalidTarget ?? (_ => { });
_liveCenter = liveCenter ?? (() => (0, 0));
_onShadowRestored = onShadowRestored;
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
}
/// <summary>
/// Opens the state-side-effect barrier after render/effect owners have
/// registered, then drains constructor and pre-materialization transitions
/// exactly once in accepted order.
/// </summary>
public bool OnLiveEntityReady(uint serverGuid)
{
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.WorldEntity is null
|| !record.ResourcesRegistered)
{
return false;
}
_readyGenerationByGuid[serverGuid] = record.Generation;
ApplyPendingTransitions(record);
return true;
}
/// <summary>Drains a newly accepted SetState when this incarnation is ready.</summary>
public bool OnStateAccepted(uint serverGuid)
{
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| !_readyGenerationByGuid.TryGetValue(serverGuid, out ushort generation)
|| generation != record.Generation)
{
return false;
}
ApplyPendingTransitions(record);
return true;
}
/// <summary>
/// Retains ownership of a collision shadow that another retail transition
/// suspended while this incarnation's spatial projection is unavailable.
/// Hidden/UnHide and teleport rollback intentionally converge on this one
/// generation-scoped restoration marker.
/// </summary>
public bool DeferShadowRestore(uint serverGuid, ushort generation)
=> CompleteAuthoritativePlacement(serverGuid, generation, deferShadowRestore: true);
/// <summary>
/// Completes active placement ownership after its final frame/cell is
/// known, either returning a stable deferred restore to presentation or
/// clearing the suspension for an immediate controller-owned restore.
/// </summary>
public bool CompleteAuthoritativePlacement(
uint serverGuid,
ushort generation,
bool deferShadowRestore)
{
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.Generation != generation
|| record.WorldEntity is null)
{
return false;
}
if (_activePlacementGenerationByGuid.TryGetValue(
serverGuid,
out ushort activeGeneration)
&& activeGeneration == generation)
{
_activePlacementGenerationByGuid.Remove(serverGuid);
}
if (deferShadowRestore)
_suspendedShadowGenerationByGuid[serverGuid] = generation;
else if (_suspendedShadowGenerationByGuid.TryGetValue(
serverGuid,
out ushort deferredGeneration)
&& deferredGeneration == generation)
_suspendedShadowGenerationByGuid.Remove(serverGuid);
return true;
}
/// <summary>
/// Transfers a deferred restore to a newer authoritative placement before
/// that placement rebuckets and can become visible. The placement hands
/// ownership back after it reaches a stable Hidden destination/rollback;
/// this prevents an intervening UnHide from restoring an unresolved pose.
/// </summary>
public bool BeginAuthoritativePlacement(uint serverGuid, ushort generation)
{
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.Generation != generation)
{
return false;
}
_activePlacementGenerationByGuid[serverGuid] = generation;
if (_suspendedShadowGenerationByGuid.TryGetValue(
serverGuid,
out ushort deferredGeneration)
&& deferredGeneration == generation)
{
_suspendedShadowGenerationByGuid.Remove(serverGuid);
}
return true;
}
internal bool HasDeferredShadowRestore(uint serverGuid) =>
_suspendedShadowGenerationByGuid.ContainsKey(serverGuid);
internal bool HasActivePlacement(uint serverGuid) =>
_activePlacementGenerationByGuid.ContainsKey(serverGuid);
public void Forget(LiveEntityRecord record)
{
ArgumentNullException.ThrowIfNull(record);
if (_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort generation)
&& generation == record.Generation)
{
_readyGenerationByGuid.Remove(record.ServerGuid);
}
if (_suspendedShadowGenerationByGuid.TryGetValue(
record.ServerGuid,
out ushort suspendedGeneration)
&& suspendedGeneration == record.Generation)
{
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
}
if (_activePlacementGenerationByGuid.TryGetValue(
record.ServerGuid,
out ushort activeGeneration)
&& activeGeneration == record.Generation)
{
_activePlacementGenerationByGuid.Remove(record.ServerGuid);
}
}
public void Clear()
{
_readyGenerationByGuid.Clear();
_suspendedShadowGenerationByGuid.Clear();
_activePlacementGenerationByGuid.Clear();
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_liveEntities.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged;
Clear();
}
private void ApplyPendingTransitions(LiveEntityRecord record)
{
while (record.TryDequeueStateTransition(out RetailPhysicsStateTransition transition))
{
if (record.WorldEntity is not { } entity)
return;
// set_state writes the final state before any PartArray/cell side
// effect. Retained shadow registrations must see those same bits
// even while their cell rows are suspended.
_shadows.UpdatePhysicsState(entity.Id, (uint)transition.FinalState);
switch (transition.HiddenTransition)
{
case RetailHiddenTransition.BecameHidden:
_playTyped(entity.Id, HiddenScriptType, 1f);
_setDirectChildrenNoDraw(record.ServerGuid, true);
_shadows.Suspend(entity.Id);
if (!IsPlacementActive(record))
_suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation;
_clearInvalidTarget(record.ServerGuid);
break;
case RetailHiddenTransition.BecameVisible:
_playTyped(entity.Id, UnHideScriptType, 1f);
_setDirectChildrenNoDraw(record.ServerGuid, false);
if (!IsPlacementActive(record) && RestoreShadow(record, entity))
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
break;
}
}
}
private bool RestoreShadow(LiveEntityRecord record, AcDream.Core.World.WorldEntity entity)
{
if (!record.IsSpatiallyProjected
|| !record.IsSpatiallyVisible
|| record.FullCellId == 0)
{
return false;
}
(int centerX, int centerY) = _liveCenter();
ShadowPositionSynchronizer.Sync(
_shadows,
entity.Id,
entity.Position,
entity.Rotation,
record.FullCellId,
centerX,
centerY);
_onShadowRestored?.Invoke(record.ServerGuid);
return true;
}
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{
if (!visible
|| record.WorldEntity is not { } entity
|| (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0
|| IsPlacementActive(record)
|| !_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort readyGeneration)
|| readyGeneration != record.Generation
|| !_suspendedShadowGenerationByGuid.TryGetValue(
record.ServerGuid,
out ushort suspendedGeneration)
|| suspendedGeneration != record.Generation)
{
return;
}
if (RestoreShadow(record, entity))
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
}
private bool IsPlacementActive(LiveEntityRecord record) =>
_activePlacementGenerationByGuid.TryGetValue(
record.ServerGuid,
out ushort generation)
&& generation == record.Generation;
}

View file

@ -3,6 +3,7 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using System.Numerics;
namespace AcDream.App.World;
@ -32,6 +33,24 @@ public interface ILiveEntityCanonicalCellConsumer
void BindCanonicalCell(Func<uint> read, Action<uint> write);
}
/// <summary>
/// Remote CPhysicsObj state required to finish a retail MoveOrTeleport
/// placement. A same-incarnation runtime replacement may wrap the same body,
/// but it must preserve this complete placement contract.
/// </summary>
public interface ILiveEntityRemotePlacementRuntime :
ILiveEntityRemoteMotionRuntime,
ILiveEntityCanonicalCellConsumer
{
uint CellId { get; set; }
bool Airborne { get; set; }
Vector3 LastServerPosition { get; set; }
double LastServerPositionTime { get; set; }
Vector3 LastShadowSyncPosition { get; set; }
void HitGround();
void LeaveGround();
}
/// <summary>Projectile physics state owned by a live object.</summary>
public interface ILiveEntityProjectileRuntime
{
@ -83,11 +102,15 @@ public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLif
/// </summary>
public sealed class LiveEntityRecord
{
private readonly Queue<RetailPhysicsStateTransition> _pendingStateTransitions = new();
internal LiveEntityRecord(WorldSession.EntitySpawn snapshot)
{
ServerGuid = snapshot.Guid;
Snapshot = snapshot;
RefreshDerivedState();
FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState;
ApplyRawPhysicsState(RawPhysicsState);
}
public uint ServerGuid { get; }
@ -102,6 +125,7 @@ public sealed class LiveEntityRecord
public PhysicsBody? PhysicsBody { get; internal set; }
public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; }
public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; }
internal bool RequiresRemotePlacementRuntime { get; set; }
public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; }
public ILiveEntityEffectProfile? EffectProfile { get; internal set; }
public bool ResourcesRegistered { get; internal set; }
@ -110,6 +134,31 @@ public sealed class LiveEntityRecord
public bool WorldSpawnPublished { get; internal set; }
public LiveEntityProjectionKind ProjectionKind { get; internal set; }
internal bool TryDequeueStateTransition(out RetailPhysicsStateTransition transition) =>
_pendingStateTransitions.TryDequeue(out transition);
internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState)
{
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 SetChildNoDraw(bool noDraw)
{
FinalPhysicsState = noDraw
? FinalPhysicsState | PhysicsStateFlags.NoDraw
: FinalPhysicsState & ~PhysicsStateFlags.NoDraw;
if (PhysicsBody is not null)
PhysicsBody.State = FinalPhysicsState;
}
/// <summary>
/// Cell used when a streamed landblock asks live objects to restore their
/// spatial projection. Once materialized, the canonical runtime cell wins;
@ -129,7 +178,6 @@ public sealed class LiveEntityRecord
RawPhysicsState = Snapshot.Physics?.RawState
?? Snapshot.PhysicsState
?? 0u;
FinalPhysicsState = (PhysicsStateFlags)RawPhysicsState;
}
}
@ -205,8 +253,9 @@ public sealed class LiveEntityRuntime
/// <summary>
/// Currently visible top-level world projections. Attached children and
/// pending projections are excluded from radar, picking, status, and target
/// candidate consumers.
/// pending or Hidden projections are excluded from radar, picking, status,
/// and target candidate consumers. NoDraw alone suppresses the mesh but is
/// not a retail cell-visibility transition.
/// </summary>
public IReadOnlyDictionary<uint, WorldEntity> WorldEntities =>
_visibleWorldEntitiesByGuid;
@ -330,6 +379,15 @@ public sealed class LiveEntityRuntime
}
record.ProjectionKind = projectionKind;
if (projectionKind is LiveEntityProjectionKind.World)
{
// Attached projections inherit the complete ancestor visibility
// chain. Once the same logical entity becomes a top-level world
// projection it has no render ancestor, so retained inherited
// suppression must be cleared at this ownership transition.
record.WorldEntity!.IsAncestorDrawVisible = true;
}
RefreshPresentation(record);
RebucketLiveEntity(serverGuid, fullCellId);
return record.WorldEntity;
}
@ -362,10 +420,7 @@ public sealed class LiveEntityRuntime
_materializedWorldEntitiesByGuid[serverGuid] = entity;
else
_materializedWorldEntitiesByGuid.Remove(serverGuid);
if (visible && record.ProjectionKind is LiveEntityProjectionKind.World)
_visibleWorldEntitiesByGuid[serverGuid] = entity;
else
_visibleWorldEntitiesByGuid.Remove(serverGuid);
RefreshPresentation(record);
if ((spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu)
record.FullCellId = spatialCellOrLandblockId;
record.CanonicalLandblockId = spatialCellOrLandblockId == 0
@ -387,13 +442,38 @@ public sealed class LiveEntityRuntime
return false;
_spatial.RemoveLiveEntityProjection(serverGuid);
// Usually GpuWorldState delivers the false edge synchronously above.
// During a reentrant visibility callback it queues that edge until the
// outer notification completes, so publish the logical withdrawal now;
// OnSpatialVisibilityChanged rejects the later duplicate against this
// committed record state.
bool spatialEdgeWasDeferred = record.IsSpatiallyVisible;
_materializedWorldEntitiesByGuid.Remove(serverGuid);
_visibleWorldEntitiesByGuid.Remove(serverGuid);
record.IsSpatiallyProjected = false;
record.IsSpatiallyVisible = false;
if (spatialEdgeWasDeferred)
ProjectionVisibilityChanged?.Invoke(record, false);
return true;
}
/// <summary>
/// Withdraws a still-live projection whose authoritative rollback frame
/// is outside every world cell. Logical owners remain registered.
/// </summary>
internal bool WithdrawLiveEntityProjectionToCellless(uint serverGuid)
{
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|| record.WorldEntity is null)
{
return false;
}
ClearWorldCell(serverGuid);
record.WorldEntity.ParentCellId = 0u;
return WithdrawLiveEntityProjection(serverGuid);
}
public bool UnregisterLiveEntity(
DeleteObject.Parsed delete,
bool isLocalPlayer,
@ -449,6 +529,16 @@ public sealed class LiveEntityRuntime
return false;
}
/// <summary>
/// Resolves a top-level object that currently participates in picking,
/// targeting, radar, and wire-driven MoveTo/Sticky establishment.
/// Pending, attached, and Hidden projections are intentionally excluded.
/// </summary>
public bool TryGetInteractionEligibleEntity(
uint serverGuid,
out WorldEntity entity) =>
_visibleWorldEntitiesByGuid.TryGetValue(serverGuid, out entity!);
public bool ContainsWorldEntity(uint serverGuid) =>
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
&& record.WorldEntity is not null;
@ -503,14 +593,24 @@ public sealed class LiveEntityRuntime
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))
PhysicsBody candidateBody = runtime.Body;
if (record.PhysicsBody is { } canonicalBody
&& !ReferenceEquals(canonicalBody, candidateBody))
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} already owns a different projectile physics body.");
$"Live entity 0x{serverGuid:X8} cannot replace its canonical physics body within one incarnation.");
}
if (record.RequiresRemotePlacementRuntime
&& runtime is not ILiveEntityRemotePlacementRuntime)
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} cannot discard its remote placement contract within one incarnation.");
}
record.RequiresRemotePlacementRuntime |=
runtime is ILiveEntityRemotePlacementRuntime;
record.RemoteMotionRuntime = runtime;
record.PhysicsBody = runtime.Body;
record.PhysicsBody = candidateBody;
candidateBody.State = record.FinalPhysicsState;
if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer)
{
// Capture the incarnation, not only its GUID. A callback retained
@ -548,8 +648,6 @@ public sealed class LiveEntityRuntime
return false;
_remoteMotionByGuid.Remove(serverGuid);
record.RemoteMotionRuntime = null;
if (record.ProjectileRuntime is null)
record.PhysicsBody = null;
return true;
}
@ -562,21 +660,17 @@ public sealed class LiveEntityRuntime
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))
PhysicsBody candidateBody = runtime.Body;
if (record.PhysicsBody is { } canonicalBody
&& !ReferenceEquals(canonicalBody, candidateBody))
{
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.");
$"Live entity 0x{serverGuid:X8} cannot replace its canonical physics body within one incarnation.");
}
record.ProjectileRuntime = runtime;
record.PhysicsBody = runtime.Body;
record.PhysicsBody = candidateBody;
candidateBody.State = record.FinalPhysicsState;
}
public bool TryGetProjectileRuntime(
@ -601,8 +695,6 @@ public sealed class LiveEntityRuntime
return false;
record.ProjectileRuntime = null;
if (record.RemoteMotionRuntime is null)
record.PhysicsBody = null;
return true;
}
@ -694,12 +786,40 @@ public sealed class LiveEntityRuntime
}
public bool TryApplyState(SetState.Parsed update, out WorldSession.EntitySpawn accepted)
=> TryApplyState(update, out accepted, out _);
public bool TryApplyState(
SetState.Parsed update,
out WorldSession.EntitySpawn accepted,
out RetailPhysicsStateTransition transition)
{
bool applied = _inbound.TryApplyState(update, out accepted);
if (applied) RefreshRecord(update.Guid, accepted);
transition = default;
if (applied)
{
RefreshRecord(update.Guid, accepted);
if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record))
{
transition = record.ApplyRawPhysicsState(update.PhysicsState);
RefreshPresentation(record);
}
}
return applied;
}
/// <summary>
/// Retail parent Hidden directly toggles each attached child's NoDraw bit
/// without consuming the child's STATE_TS or replacing its raw wire state.
/// </summary>
public bool SetAttachedChildNoDraw(uint childServerGuid, bool noDraw)
{
if (!_recordsByGuid.TryGetValue(childServerGuid, out LiveEntityRecord? record))
return false;
record.SetChildNoDraw(noDraw);
RefreshPresentation(record);
return true;
}
public bool TryApplyPosition(
WorldSession.EntityPositionUpdate update,
bool isLocalPlayer,
@ -709,6 +829,12 @@ public sealed class LiveEntityRuntime
out WorldSession.EntitySpawn accepted,
out AcceptedPhysicsTimestamps timestamps)
{
bool wasCellLess = _recordsByGuid.TryGetValue(
update.Guid,
out LiveEntityRecord? beforePosition)
&& (beforePosition.FullCellId == 0
|| !beforePosition.IsSpatiallyProjected
|| !beforePosition.IsSpatiallyVisible);
bool known = _inbound.TryApplyPosition(
update,
isLocalPlayer,
@ -720,6 +846,13 @@ public sealed class LiveEntityRuntime
if (known && _inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot))
{
bool acceptedPosition = disposition is not PositionTimestampDisposition.Rejected;
if (disposition is PositionTimestampDisposition.Apply)
{
timestamps = timestamps with
{
TeleportHookRequired = timestamps.TeleportAdvanced || wasCellLess,
};
}
RefreshRecord(update.Guid, snapshot, refreshPosition: acceptedPosition);
if (acceptedPosition)
{
@ -734,8 +867,11 @@ public sealed class LiveEntityRuntime
/// <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
/// object tick only while the object has visible world-cell membership and
/// is not Frozen. Hidden is deliberately not a broad tick stop: retail
/// skips PartArray animation and physics, but still applies the
/// PositionManager offset and advances targeting/movement/script/particle
/// owners. 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>
@ -746,6 +882,10 @@ public sealed class LiveEntityRuntime
&& record.FullCellId != 0
&& (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0;
public bool IsHidden(uint serverGuid) =>
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
&& (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0;
/// <summary>
/// Claims the one logical top-level spawn notification for this object
/// incarnation. Spatial re-entry must restore presentation without replaying
@ -832,15 +972,31 @@ public sealed class LiveEntityRuntime
|| record.WorldEntity is not { } entity)
return;
bool wasVisible = record.IsSpatiallyVisible;
record.IsSpatiallyVisible = visible;
if (visible && record.ProjectionKind is LiveEntityProjectionKind.World)
_visibleWorldEntitiesByGuid[serverGuid] = entity;
else
_visibleWorldEntitiesByGuid.Remove(serverGuid);
if (_rebucketingGuid != serverGuid)
RefreshPresentation(record);
if (_rebucketingGuid != serverGuid && wasVisible != visible)
ProjectionVisibilityChanged?.Invoke(record, visible);
}
private void RefreshPresentation(LiveEntityRecord record)
{
if (record.WorldEntity is not { } entity)
return;
PhysicsStateFlags state = record.FinalPhysicsState;
entity.IsDrawVisible =
(state & (PhysicsStateFlags.NoDraw | PhysicsStateFlags.Hidden)) == 0;
bool interactionVisible = record.IsSpatiallyVisible
&& record.ProjectionKind is LiveEntityProjectionKind.World
&& (state & PhysicsStateFlags.Hidden) == 0;
if (interactionVisible)
_visibleWorldEntitiesByGuid[record.ServerGuid] = entity;
else
_visibleWorldEntitiesByGuid.Remove(record.ServerGuid);
}
private void TearDownRecord(LiveEntityRecord record)
{
List<Exception>? failures = null;
@ -873,6 +1029,7 @@ public sealed class LiveEntityRuntime
_remoteMotionByGuid.Remove(record.ServerGuid);
record.AnimationRuntime = null;
record.RemoteMotionRuntime = null;
record.RequiresRemotePlacementRuntime = false;
record.PhysicsBody = null;
record.ProjectileRuntime = null;
record.EffectProfile = null;

View file

@ -0,0 +1,33 @@
namespace AcDream.App.World;
/// <summary>
/// Runs the independent owners that participate in one live incarnation's
/// teardown without letting an earlier callback strand later state. Retail's
/// object destruction drains these owners as separate lifecycle steps; the App
/// composition callback must preserve that symmetry even when a plugin,
/// renderer, or effect sink throws.
/// </summary>
internal static class LiveEntityTeardown
{
internal static void Run(IEnumerable<Action> cleanups)
{
ArgumentNullException.ThrowIfNull(cleanups);
List<Exception>? failures = null;
foreach (Action cleanup in cleanups)
{
try
{
cleanup();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
if (failures is not null)
throw new AggregateException(
"One or more live-entity component teardown steps failed.",
failures);
}
}