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

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

View file

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

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)

View file

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