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

@ -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;