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:
Erik 2026-08-03 12:10:21 +02:00
parent 1fc529cdcb
commit f24532adf3
11 changed files with 417 additions and 50 deletions

View file

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

View file

@ -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))
{

View file

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