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
|
|
@ -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,
|
||||
|
|
|
|||
282
src/AcDream.App/World/LiveEntityPresentationController.cs
Normal file
282
src/AcDream.App/World/LiveEntityPresentationController.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
33
src/AcDream.App/World/LiveEntityTeardown.cs
Normal file
33
src/AcDream.App/World/LiveEntityTeardown.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue