fix(vfx): bind effects after canonical placement
C3c created graphical effect, projectile, and static-animation sidecars before Runtime finished the entity's first SetPosition. One-shot F754/F755 packets could be discarded, projectiles could adopt a cell-less body, and animated statics could compete for body ownership. Keep effects behind an exact-incarnation presentation barrier, retry projectile/static binding on the committed visibility edge, and keep effect cells synchronized with canonical rebuckets. User verified spell, recall, arrow, projectile, portal, and static presentation; 90 focused App tests and the Release build pass.
This commit is contained in:
parent
1fc529cdcb
commit
f24532adf3
11 changed files with 417 additions and 50 deletions
|
|
@ -293,6 +293,25 @@ internal sealed class LivePresentationCompositionPhase
|
|||
d.WorldOrigin,
|
||||
d.EffectPoses);
|
||||
var staticResidency = new LiveStaticAnimationResidency(d.RuntimeSlot);
|
||||
(LiveEntityAnimationState Animation, PhysicsBody Body)?
|
||||
ResolveLiveStaticOwner(WorldEntity entity)
|
||||
{
|
||||
if (entity.ServerGuid == 0
|
||||
|| liveEntities?.TryGetRecord(
|
||||
entity.ServerGuid,
|
||||
out LiveEntityRecord record) != true
|
||||
|| !ReferenceEquals(record.WorldEntity, entity)
|
||||
|| !record.IsSpatiallyProjected
|
||||
|| !record.IsSpatiallyVisible
|
||||
|| record.AnimationRuntime
|
||||
is not LiveEntityAnimationState animation
|
||||
|| record.PhysicsBody is not { } body)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return (animation, body);
|
||||
}
|
||||
var staticAnimationScheduler =
|
||||
new RetailStaticAnimatingObjectScheduler(
|
||||
content.AnimationLoader,
|
||||
|
|
@ -300,7 +319,8 @@ internal sealed class LivePresentationCompositionPhase
|
|||
d.EffectPoses.Publish,
|
||||
staticResidency.IsResident,
|
||||
(entity, body) => _ = staticRootCommitter.Commit(entity, body),
|
||||
staticResidency.ProjectionVersion);
|
||||
staticResidency.ProjectionVersion,
|
||||
ResolveLiveStaticOwner);
|
||||
|
||||
ScriptActivationInfo? ResolveActivation(WorldEntity entity)
|
||||
{
|
||||
|
|
@ -453,7 +473,7 @@ internal sealed class LivePresentationCompositionPhase
|
|||
particleVisibility,
|
||||
"particle projection visibility");
|
||||
var placementVisibilitySinks = new List<
|
||||
Action<LiveEntityRecord, bool>>(3)
|
||||
Action<LiveEntityRecord, bool>>(4)
|
||||
{
|
||||
wbVisibility,
|
||||
};
|
||||
|
|
@ -463,6 +483,15 @@ internal sealed class LivePresentationCompositionPhase
|
|||
liveRenderProjections.OnProjectionVisibilityChanged);
|
||||
}
|
||||
placementVisibilitySinks.Add(particleVisibility);
|
||||
// Retail enters the CPhysicsObj before ProcessObjectNetBlobs.
|
||||
// C3c's initial Runtime placement is the equivalent world-entry
|
||||
// edge, so open/replay the one-shot F754/F755 barrier only after
|
||||
// mesh poses and particle presentation have both been published.
|
||||
placementVisibilitySinks.Add((record, visible) =>
|
||||
{
|
||||
if (visible)
|
||||
entityEffects?.OnPresentationBound(record);
|
||||
});
|
||||
var placementProjection = new RuntimePlacementPresentationSink(
|
||||
liveEntities,
|
||||
worldTransit,
|
||||
|
|
|
|||
|
|
@ -181,12 +181,14 @@ internal sealed class ProjectileController
|
|||
// animation workset, classification adopts that same body instead
|
||||
// of replacing it or replaying CreateObject vectors.
|
||||
body = sharedBody;
|
||||
uint currentCellId = record.FullCellId;
|
||||
Vector3 currentCellLocal = CellLocalFromWorld(
|
||||
body.Position,
|
||||
currentCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
// Retail has one CPhysicsObj, whose Position owns both objcell_id
|
||||
// and the cell-local frame. The graphical record's FullCellId is
|
||||
// only the later projection receipt and is legitimately still
|
||||
// zero while a residence-managed Create awaits presentation.
|
||||
// Validate and adopt the canonical body frame itself; successful
|
||||
// classification projects that same cell into the sidecar below.
|
||||
uint currentCellId = body.CellPosition.ObjCellId;
|
||||
Vector3 currentCellLocal = body.CellPosition.Frame.Origin;
|
||||
if (!IsFinite(body.Position)
|
||||
|| !IsFinite(body.Velocity)
|
||||
|| !IsFinite(body.Omega)
|
||||
|
|
@ -268,7 +270,17 @@ internal sealed class ProjectileController
|
|||
entity.SetPosition(body.Position);
|
||||
entity.Rotation = body.Orientation;
|
||||
entity.ParentCellId = canonicalCellId;
|
||||
if (!_liveEntities.RebucketLiveEntity(record.ServerGuid, canonicalCellId)
|
||||
// Classification can run from the projection-visible callback after
|
||||
// Runtime has already installed this exact cell. Re-entering Rebucket
|
||||
// from that callback would supersede the outer projection transaction
|
||||
// merely to write the same bucket again. Only perform a spatial move
|
||||
// when classification is actually changing residence.
|
||||
bool alreadyProjectedInCanonicalCell = record.IsSpatiallyProjected
|
||||
&& record.FullCellId == canonicalCellId;
|
||||
if ((!alreadyProjectedInCanonicalCell
|
||||
&& !_liveEntities.RebucketLiveEntity(
|
||||
record.ServerGuid,
|
||||
canonicalCellId))
|
||||
|| !_liveEntities.TryGetRecord(record.ServerGuid, out var currentRecord)
|
||||
|| !ReferenceEquals(currentRecord, record)
|
||||
|| !ReferenceEquals(currentRecord.WorldEntity, entity)
|
||||
|
|
@ -848,10 +860,43 @@ internal sealed class ProjectileController
|
|||
|
||||
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
||||
{
|
||||
if (record.ProjectileRuntime is not RuntimeProjectile runtime
|
||||
|| record.WorldEntity is not { } entity
|
||||
if (record.WorldEntity is not { } entity
|
||||
|| !_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
|
||||
|| !ReferenceEquals(current, record)
|
||||
|| !ReferenceEquals(current, record))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (visible
|
||||
&& record.ProjectileRuntime is null
|
||||
&& record.PhysicsBody is not null
|
||||
&& (record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0)
|
||||
{
|
||||
// A residence-managed Create builds its one CPhysicsObj before
|
||||
// presentation, but Runtime intentionally withholds the committed
|
||||
// cell frame until enter_world completes. Materialization may
|
||||
// therefore observe the body while it is still cell-less and its
|
||||
// eager TryBind correctly refuses that incomplete frame. The
|
||||
// projection-visible edge is the first point at which both the
|
||||
// canonical body and its authoritative placement are guaranteed
|
||||
// to be committed, so retry classification here instead of
|
||||
// fabricating a CreateObject-frame fallback.
|
||||
Setup? setup = _setupResolver?.Resolve(
|
||||
entity.SourceGfxObjOrSetupId);
|
||||
if (setup is not null)
|
||||
{
|
||||
int liveCenterX = _origin?.CenterX ?? 0;
|
||||
int liveCenterY = _origin?.CenterY ?? 0;
|
||||
_ = TryBind(
|
||||
record,
|
||||
setup,
|
||||
_lastFiniteGameTime,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
}
|
||||
}
|
||||
|
||||
if (record.ProjectileRuntime is not RuntimeProjectile runtime
|
||||
|| !ReferenceEquals(current.ProjectileRuntime, runtime))
|
||||
{
|
||||
return;
|
||||
|
|
@ -933,20 +978,6 @@ internal sealed class ProjectileController
|
|||
&& float.IsFinite(value.Y)
|
||||
&& float.IsFinite(value.Z);
|
||||
|
||||
private static Vector3 CellLocalFromWorld(
|
||||
Vector3 worldPosition,
|
||||
uint cellId,
|
||||
int liveCenterX,
|
||||
int liveCenterY)
|
||||
{
|
||||
int landblockX = (int)((cellId >> 24) & 0xFFu);
|
||||
int landblockY = (int)((cellId >> 16) & 0xFFu);
|
||||
return worldPosition - new Vector3(
|
||||
(landblockX - liveCenterX) * 192f,
|
||||
(landblockY - liveCenterY) * 192f,
|
||||
0f);
|
||||
}
|
||||
|
||||
private bool TryGetCurrent(
|
||||
uint serverGuid,
|
||||
out LiveEntityRecord record,
|
||||
|
|
|
|||
|
|
@ -837,12 +837,24 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
expectedCreateIntegrationVersion)
|
||||
|| !ReferenceEquals(expectedRecord.WorldEntity, entity))
|
||||
return false;
|
||||
_projectiles.TryBind(
|
||||
expectedRecord,
|
||||
setup,
|
||||
_gameTime.CurrentScriptTime,
|
||||
_origin.CenterX,
|
||||
_origin.CenterY);
|
||||
bool initialResidenceActive =
|
||||
_runtime.HasActiveInitialCreateResidence(expectedCanonical);
|
||||
// C3c first entry owns CPhysicsObj construction and SetPosition for a
|
||||
// residence-managed Create. Eager projectile classification used to
|
||||
// acquire that same body here, before the conductor ran; the
|
||||
// conductor then correctly rejected the unexpected owner and the
|
||||
// missile remained permanently cell-less. The committed projection
|
||||
// visibility edge retries TryBind after Runtime has constructed and
|
||||
// placed the one canonical body.
|
||||
if (!initialResidenceActive)
|
||||
{
|
||||
_projectiles.TryBind(
|
||||
expectedRecord,
|
||||
setup,
|
||||
_gameTime.CurrentScriptTime,
|
||||
_origin.CenterX,
|
||||
_origin.CenterY);
|
||||
}
|
||||
|
||||
if (!_runtime.IsCurrentCreateIntegration(
|
||||
expectedRecord,
|
||||
|
|
@ -1033,6 +1045,15 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
return;
|
||||
}
|
||||
|
||||
if (_runtime.HasActiveInitialCreateResidence(expectedRecord.Canonical))
|
||||
{
|
||||
// RuntimeRemoteFirstEntryState owns CPhysicsObj construction and
|
||||
// SetPosition until the Create residence completes. The static
|
||||
// scheduler retains this pending animation owner and binds the
|
||||
// canonical body after projection becomes spatially visible.
|
||||
return;
|
||||
}
|
||||
|
||||
PhysicsBody body = _runtime.GetOrCreatePhysicsBody(
|
||||
spawn.Guid,
|
||||
incarnation =>
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
|
|||
private readonly Func<WorldEntity, bool> _isResident;
|
||||
private readonly Action<WorldEntity, PhysicsBody> _commitLiveRoot;
|
||||
private readonly Func<WorldEntity, ulong> _residencyVersion;
|
||||
private readonly Func<WorldEntity,
|
||||
(LiveEntityAnimationState Animation, PhysicsBody Body)?>?
|
||||
_resolveLiveOwner;
|
||||
private readonly Dictionary<uint, Owner> _owners = new();
|
||||
private readonly List<Owner> _snapshot = new();
|
||||
private readonly List<Owner> _hookSnapshot = new();
|
||||
|
|
@ -63,7 +66,10 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
|
|||
Action<WorldEntity, IReadOnlyList<Matrix4x4>, IReadOnlyList<bool>> publishPartPoses,
|
||||
Func<WorldEntity, bool>? isResident = null,
|
||||
Action<WorldEntity, PhysicsBody>? commitLiveRoot = null,
|
||||
Func<WorldEntity, ulong>? residencyVersion = null)
|
||||
Func<WorldEntity, ulong>? residencyVersion = null,
|
||||
Func<WorldEntity,
|
||||
(LiveEntityAnimationState Animation, PhysicsBody Body)?>?
|
||||
resolveLiveOwner = null)
|
||||
{
|
||||
_animationLoader = animationLoader
|
||||
?? throw new ArgumentNullException(nameof(animationLoader));
|
||||
|
|
@ -74,6 +80,7 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
|
|||
_isResident = isResident ?? (_ => true);
|
||||
_commitLiveRoot = commitLiveRoot ?? ((_, _) => { });
|
||||
_residencyVersion = residencyVersion ?? (_ => 0UL);
|
||||
_resolveLiveOwner = resolveLiveOwner;
|
||||
}
|
||||
|
||||
internal int Count => _owners.Count;
|
||||
|
|
@ -363,12 +370,30 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
|
|||
foreach (Owner owner in _snapshot)
|
||||
{
|
||||
if (!_owners.TryGetValue(owner.Entity.Id, out Owner? current)
|
||||
|| !ReferenceEquals(current, owner)
|
||||
|| owner.Sequencer is not { } sequencer)
|
||||
|| !ReferenceEquals(current, owner))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// C3c: live Static objects are registered while their visual
|
||||
// sidecar is hydrated, before Runtime has completed the initial
|
||||
// CreateObject SetPosition transaction. The first-entry
|
||||
// conductor owns construction of the canonical CPhysicsObj.
|
||||
// Resolve and bind that already-placed owner lazily; constructing
|
||||
// a second/eager body here makes the conductor reject authority
|
||||
// and leaves portals, doors, and other animated statics cell-less.
|
||||
if (owner.Sequencer is null
|
||||
&& owner.Entity.ServerGuid != 0
|
||||
&& _resolveLiveOwner?.Invoke(owner.Entity) is { } binding)
|
||||
{
|
||||
_ = BindLiveOwner(
|
||||
owner.Entity,
|
||||
binding.Animation,
|
||||
binding.Body);
|
||||
}
|
||||
if (owner.Sequencer is not { } sequencer)
|
||||
continue;
|
||||
|
||||
owner.ElapsedSinceUpdate += elapsedSeconds;
|
||||
if (!_isResident(owner.Entity))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -36,6 +36,14 @@ public sealed class EntityEffectController : IAnimationHookSink,
|
|||
private readonly Action<uint, uint?> _ownerSoundTableChanged;
|
||||
private readonly Dictionary<RuntimeEntityKey, EntityEffectProfile> _liveProfiles = [];
|
||||
private readonly HashSet<RuntimeEntityKey> _readyLiveOwners = [];
|
||||
// C3c constructs the App effect owner before Runtime's initial placement
|
||||
// receipt binds its world presentation. During that one-time split-lifetime
|
||||
// window, retail still considers the CPhysicsObj absent: SmartBox queues
|
||||
// F754/F755 at 0x00452020/0x00452070, enters the object, then drains them
|
||||
// through ProcessObjectNetBlobs in HandleCreateObject 0x00454C80. Keep the
|
||||
// exact incarnation behind an equivalent barrier until the graphical
|
||||
// placement publishes its pose and resource visibility.
|
||||
private readonly HashSet<RuntimeEntityKey> _initialPresentationBarriers = [];
|
||||
private readonly Dictionary<uint, Queue<PendingEffect>> _pendingByServerGuid = new();
|
||||
private readonly Dictionary<uint, WorldEntity> _staticOwners = new();
|
||||
private readonly Dictionary<uint, EntityEffectProfile> _staticProfiles = new();
|
||||
|
|
@ -90,6 +98,8 @@ public sealed class EntityEffectController : IAnimationHookSink,
|
|||
RefreshLiveAnchor(message.Guid, localId);
|
||||
if (CanStartOwner(localId))
|
||||
PlayDirect(localId, message.ScriptDid);
|
||||
else if (IsWaitingForInitialPresentation(message.Guid))
|
||||
Enqueue(message.Guid, PendingEffect.Direct(message.ScriptDid));
|
||||
return;
|
||||
}
|
||||
Enqueue(message.Guid, PendingEffect.Direct(message.ScriptDid));
|
||||
|
|
@ -104,6 +114,12 @@ public sealed class EntityEffectController : IAnimationHookSink,
|
|||
RefreshLiveAnchor(message.Guid, localId);
|
||||
if (CanStartOwner(localId))
|
||||
PlayTyped(localId, message.RawScriptType, message.Intensity);
|
||||
else if (IsWaitingForInitialPresentation(message.Guid))
|
||||
{
|
||||
Enqueue(
|
||||
message.Guid,
|
||||
PendingEffect.Typed(message.RawScriptType, message.Intensity));
|
||||
}
|
||||
return;
|
||||
}
|
||||
Enqueue(message.Guid, PendingEffect.Typed(message.RawScriptType, message.Intensity));
|
||||
|
|
@ -140,11 +156,45 @@ public sealed class EntityEffectController : IAnimationHookSink,
|
|||
RuntimeEntityKey key = RequireProjectionKey(record);
|
||||
_readyLiveOwners.Add(key);
|
||||
_liveProfiles[key] = profile;
|
||||
if (record.MaterializationResidence is
|
||||
LiveEntityMaterializationResidence.AwaitRuntimePlacement
|
||||
&& !record.IsSpatiallyProjected)
|
||||
{
|
||||
_initialPresentationBarriers.Add(key);
|
||||
}
|
||||
else
|
||||
{
|
||||
_initialPresentationBarriers.Remove(key);
|
||||
}
|
||||
_runner.SetOwnerAnchor(entity.Id, entity.Position);
|
||||
_ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the C3c-only network-script barrier after the initial placement
|
||||
/// has published the entity pose and presentation resources, then replays
|
||||
/// the retained mixed F754/F755 FIFO synchronously in arrival order.
|
||||
/// </summary>
|
||||
public bool OnPresentationBound(LiveEntityRecord record)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
if (record.ProjectionKey is not { } key
|
||||
|| !_liveEntities.TryGetRecord(key, out LiveEntityRecord current)
|
||||
|| !ReferenceEquals(current, record))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_initialPresentationBarriers.Remove(key);
|
||||
if (!TryGetReadyLocalId(record.ServerGuid, out uint localId))
|
||||
return true;
|
||||
|
||||
RefreshLiveAnchor(record.ServerGuid, localId);
|
||||
TryReplayPending(record.ServerGuid, localId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Replays the mixed F754/F755 FIFO after full object construction.</summary>
|
||||
public bool ReplayPendingForLiveEntity(uint serverGuid)
|
||||
{
|
||||
|
|
@ -219,6 +269,7 @@ public sealed class EntityEffectController : IAnimationHookSink,
|
|||
{
|
||||
_readyLiveOwners.Remove(key);
|
||||
_liveProfiles.Remove(key);
|
||||
_initialPresentationBarriers.Remove(key);
|
||||
}
|
||||
if (record.LocalEntityId is not { } localId)
|
||||
return;
|
||||
|
|
@ -241,6 +292,7 @@ public sealed class EntityEffectController : IAnimationHookSink,
|
|||
}
|
||||
_readyLiveOwners.Clear();
|
||||
_liveProfiles.Clear();
|
||||
_initialPresentationBarriers.Clear();
|
||||
_pendingByServerGuid.Clear();
|
||||
_dirtyLiveOwners.Clear();
|
||||
_dirtyLiveOwnerOrder.Clear();
|
||||
|
|
@ -455,6 +507,11 @@ public sealed class EntityEffectController : IAnimationHookSink,
|
|||
return false;
|
||||
}
|
||||
|
||||
private bool IsWaitingForInitialPresentation(uint serverGuid) =>
|
||||
_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
&& record.ProjectionKey is { } key
|
||||
&& _initialPresentationBarriers.Contains(key);
|
||||
|
||||
private void OnEffectPoseChanged(uint localId)
|
||||
{
|
||||
if (_posePublishLocalId == localId)
|
||||
|
|
|
|||
|
|
@ -842,6 +842,18 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
// between two loaded buckets. Suppress those implementation details
|
||||
// and publish only the final logical visibility edge.
|
||||
record.IsSpatiallyProjected = true;
|
||||
bool hasExactDestinationCell = spatialCellOrLandblockId != 0u
|
||||
&& (spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu;
|
||||
if (hasExactDestinationCell)
|
||||
{
|
||||
// Runtime's physics cell commit and the graphical sidecar are one
|
||||
// SetPosition result. Retail CPhysicsObj::set_cell changes the
|
||||
// CObjCell read by ShouldDrawParticles at the same edge; retaining
|
||||
// the prior sidecar cell makes newly-created spell particles fail
|
||||
// IsInView as soon as the player crosses an outdoor landcell.
|
||||
entity.ParentCellId = spatialCellOrLandblockId;
|
||||
entity.EffectCellId = spatialCellOrLandblockId;
|
||||
}
|
||||
Exception? spatialNotificationFailure = null;
|
||||
uint priorRebucketingGuid = _rebucketingGuid;
|
||||
_rebucketingGuid = serverGuid;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue