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:
parent
a51ebc66e9
commit
1e98d81448
46 changed files with 4883 additions and 127 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue