using System.Numerics; using AcDream.Core.Vfx; using AcDream.Core.World; namespace AcDream.App.Rendering.Vfx; /// /// Setup script/profile data resolved once when a logical effect owner is /// registered. Part transforms are indexed and root-local. /// public sealed record ScriptActivationInfo( uint ScriptId, IReadOnlyList PartTransforms, EntityEffectProfile? EffectProfile = null, IReadOnlyList? PartAvailability = null, DatReaderWriter.DBObjs.Setup? Setup = null, uint DefaultAnimationId = 0, bool UsesStaticAnimationWorkset = false); /// /// Owns create-time Setup script activation and its symmetric teardown for /// live and DAT-static entities. /// /// /// Setup DefaultScript is a direct PES played during Setup /// initialization. Live registration invokes this class once per logical /// generation; spatial rebucketing never replays it. /// public sealed class EntityScriptActivator { private sealed class StaticOwnerState { public required WorldEntity Entity; public required ScriptActivationInfo Info; public bool PosePublished; public bool EffectOwnerRegistered; public bool AnimationOwnerRegistered; public bool ScriptStarted; public bool ReleaseStarted; public bool ScriptsStopped; public bool EmittersStopped; public bool PoseRemoved; public WorldEntity? PendingReplacement; public ScriptActivationInfo? PendingReplacementInfo; public bool ReplacementPosePublished; public bool ReplacementEffectOwnerUpdated; public bool ReplacementAnimationOwnerUpdated; public bool ReplacementAnchorUpdated; } private readonly PhysicsScriptRunner _scriptRunner; private readonly ParticleHookSink _particleSink; private readonly EntityEffectPoseRegistry _poses; private readonly Func _resolver; private readonly Action? _registerDatStaticEffectOwner; private readonly Action? _unregisterDatStaticEffectOwner; private readonly Func? _registerDatStaticAnimationOwner; private readonly Action? _unregisterDatStaticAnimationOwner; private readonly Action? _rebindDatStaticAnimationOwner; private readonly Dictionary _staticOwners = new(); public EntityScriptActivator( PhysicsScriptRunner scriptRunner, ParticleHookSink particleSink, EntityEffectPoseRegistry poses, Func resolver, Action? registerDatStaticEffectOwner = null, Action? unregisterDatStaticEffectOwner = null, Func? registerDatStaticAnimationOwner = null, Action? unregisterDatStaticAnimationOwner = null, Action? rebindDatStaticAnimationOwner = null) { _scriptRunner = scriptRunner ?? throw new ArgumentNullException(nameof(scriptRunner)); _particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink)); _poses = poses ?? throw new ArgumentNullException(nameof(poses)); _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); _registerDatStaticEffectOwner = registerDatStaticEffectOwner; _unregisterDatStaticEffectOwner = unregisterDatStaticEffectOwner; _registerDatStaticAnimationOwner = registerDatStaticAnimationOwner; _unregisterDatStaticAnimationOwner = unregisterDatStaticAnimationOwner; _rebindDatStaticAnimationOwner = rebindDatStaticAnimationOwner; } /// /// Rebinds a retained DAT-static logical owner to the fresh immutable /// snapshot produced by a same-landblock /// rehydrate. This is not a retail create edge: default Setup scripts and /// existing emitters keep their logical lifetime and are never replayed. /// public void OnRebindStatic(WorldEntity previous, WorldEntity replacement) { ArgumentNullException.ThrowIfNull(previous); ArgumentNullException.ThrowIfNull(replacement); if (previous.ServerGuid != 0 || replacement.ServerGuid != 0 || previous.Id == 0 || previous.Id != replacement.Id) { throw new ArgumentException( "DAT-static rebind requires the same nonzero local ID.", nameof(replacement)); } uint key = previous.Id; if (!_staticOwners.TryGetValue(key, out StaticOwnerState? state)) return; if (ReferenceEquals(state.Entity, replacement)) return; if (!ReferenceEquals(state.Entity, previous)) { throw new InvalidOperationException( $"DAT-static owner 0x{key:X8} does not match the retained incarnation."); } if (state.ReleaseStarted) { throw new InvalidOperationException( $"DAT-static owner 0x{key:X8} cannot rebind while teardown is pending."); } if (state.PendingReplacement is null) { ScriptActivationInfo replacementInfo = _resolver(replacement) ?? throw new InvalidOperationException( $"DAT-static owner 0x{key:X8} lost its Setup activation data during rebind."); ValidateLogicalRebind(state, replacement, replacementInfo); state.PendingReplacement = replacement; state.PendingReplacementInfo = replacementInfo; } else if (!ReferenceEquals(state.PendingReplacement, replacement)) { throw new InvalidOperationException( $"DAT-static owner 0x{key:X8} already has another rebind pending."); } ScriptActivationInfo info = state.PendingReplacementInfo!; if (state.PosePublished && !state.ReplacementPosePublished) { _poses.Publish(replacement, info.PartTransforms, info.PartAvailability); state.ReplacementPosePublished = true; } if (state.EffectOwnerRegistered && !state.ReplacementEffectOwnerUpdated) { _registerDatStaticEffectOwner!(key, replacement, info.EffectProfile!); state.ReplacementEffectOwnerUpdated = true; } if (state.AnimationOwnerRegistered && !state.ReplacementAnimationOwnerUpdated) { (_rebindDatStaticAnimationOwner ?? throw new InvalidOperationException( "DAT-static animation ownership has no retained-owner rebind callback."))( replacement, info); state.ReplacementAnimationOwnerUpdated = true; } if (!state.ReplacementAnchorUpdated) { _scriptRunner.SetOwnerAnchor(key, replacement.Position); state.ReplacementAnchorUpdated = true; } state.Entity = replacement; state.Info = info; state.PendingReplacement = null; state.PendingReplacementInfo = null; state.ReplacementPosePublished = false; state.ReplacementEffectOwnerUpdated = false; state.ReplacementAnimationOwnerUpdated = false; state.ReplacementAnchorUpdated = false; } private static void ValidateLogicalRebind( StaticOwnerState state, WorldEntity replacement, ScriptActivationInfo replacementInfo) { ScriptActivationInfo retained = state.Info; bool retainedAnimation = retained.UsesStaticAnimationWorkset && retained.DefaultAnimationId != 0; bool replacementAnimation = replacementInfo.UsesStaticAnimationWorkset && replacementInfo.DefaultAnimationId != 0; if (state.Entity.SourceGfxObjOrSetupId != replacement.SourceGfxObjOrSetupId || retained.ScriptId != replacementInfo.ScriptId || retainedAnimation != replacementAnimation || (retainedAnimation && retained.DefaultAnimationId != replacementInfo.DefaultAnimationId) || (retained.EffectProfile is null) != (replacementInfo.EffectProfile is null)) { throw new InvalidOperationException( $"DAT-static owner 0x{state.Entity.Id:X8} changed Setup lifetime during a retained rebind."); } } public void OnCreate(WorldEntity entity) { ArgumentNullException.ThrowIfNull(entity); uint key = entity.Id; if (key == 0) return; if (entity.ServerGuid == 0 && _staticOwners.TryGetValue(key, out StaticOwnerState? retained)) { if (!ReferenceEquals(retained.Entity, entity)) { throw new InvalidOperationException( $"Dat-static WorldEntity id 0x{key:X8} is already active; static allocators must be globally unique."); } if (retained.ReleaseStarted) { throw new InvalidOperationException( $"Dat-static WorldEntity id 0x{key:X8} cannot reactivate while teardown is pending."); } ActivateStaticOwner(retained); return; } ScriptActivationInfo? info = _resolver(entity); if (info is null) return; if (entity.ServerGuid == 0) { var state = new StaticOwnerState { Entity = entity, Info = info, }; _staticOwners.Add(key, state); ActivateStaticOwner(state); return; } // Live registration is an atomic logical-lifetime edge owned by // LiveEntityRuntime and is never retried at this activator in isolation. _poses.Publish(entity, info.PartTransforms, info.PartAvailability); if (info.UsesStaticAnimationWorkset && info.DefaultAnimationId != 0) { _registerDatStaticAnimationOwner?.Invoke(entity, info); } if (info.ScriptId != 0) _scriptRunner.Play(info.ScriptId, key, entity.Position); } private void ActivateStaticOwner(StaticOwnerState state) { WorldEntity entity = state.Entity; ScriptActivationInfo info = state.Info; uint key = entity.Id; // Particle::Init (0x0051C930) samples the current root/part frames. // Commit each acquisition only after its callback returns. A later // failure can then retry without replaying completed create-time work. if (!state.PosePublished) { _poses.Publish(entity, info.PartTransforms, info.PartAvailability); state.PosePublished = true; } if (!state.EffectOwnerRegistered && info.EffectProfile is { } profile && _registerDatStaticEffectOwner is not null) { _registerDatStaticEffectOwner(key, entity, profile); state.EffectOwnerRegistered = true; } // TS-51: script-only statics still use the shared script runner. The // dedicated static animation scheduler owns only real DefaultAnimation // PartArray work, so script-only owners incur no empty frame scan. if (!state.AnimationOwnerRegistered && info.UsesStaticAnimationWorkset && info.DefaultAnimationId != 0 && _registerDatStaticAnimationOwner is not null) { state.AnimationOwnerRegistered = _registerDatStaticAnimationOwner(entity, info); } if (!state.ScriptStarted && info.ScriptId != 0) { _scriptRunner.Play(info.ScriptId, key, entity.Position); state.ScriptStarted = true; } } public void OnRemove(WorldEntity entity) { ArgumentNullException.ThrowIfNull(entity); uint key = entity.Id; if (key == 0) return; if (entity.ServerGuid == 0) { if (!_staticOwners.TryGetValue(key, out StaticOwnerState? state) || !ReferenceEquals(state.Entity, entity)) { return; } state.ReleaseStarted = true; if (!state.ScriptsStopped) { _scriptRunner.StopAllForEntity(key); state.ScriptsStopped = true; } if (!state.EmittersStopped) { _particleSink.StopAllForEntity(key, fadeOut: false); state.EmittersStopped = true; } if (!state.PoseRemoved) { _poses.Remove(key); state.PoseRemoved = true; } if (state.AnimationOwnerRegistered) { _unregisterDatStaticAnimationOwner?.Invoke(key); state.AnimationOwnerRegistered = false; } if (state.EffectOwnerRegistered) { _unregisterDatStaticEffectOwner?.Invoke(key); state.EffectOwnerRegistered = false; } _staticOwners.Remove(key); return; } _unregisterDatStaticAnimationOwner?.Invoke(key); _scriptRunner.StopAllForEntity(key); _particleSink.StopAllForEntity(key, fadeOut: false); _poses.Remove(key); } }