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; } 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 Action? _registerDatStaticAnimationOwner; private readonly Action? _unregisterDatStaticAnimationOwner; private readonly Dictionary _staticOwners = new(); public EntityScriptActivator( PhysicsScriptRunner scriptRunner, ParticleHookSink particleSink, EntityEffectPoseRegistry poses, Func resolver, Action? registerDatStaticEffectOwner = null, Action? unregisterDatStaticEffectOwner = null, Action? registerDatStaticAnimationOwner = null, Action? unregisterDatStaticAnimationOwner = 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; } 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) { _registerDatStaticAnimationOwner(entity, info); state.AnimationOwnerRegistered = true; } 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); } }