using System.Collections.Concurrent; using System.Numerics; using AcDream.Core.Physics; using DatReaderWriter.Types; namespace AcDream.Core.Vfx; /// /// Routes particle animation hooks through the owner's current root/part pose /// and retains the retail logical-emitter identity rules. /// /// /// /// Retail oracle: CreateParticleHook::Execute (0x00526EC0), /// CreateBlockingParticleHook::Execute (0x00526EF0), /// ParticleManager::CreateParticleEmitter (0x0051B6C0), and /// CreateBlockingParticleEmitter (0x0051B8A0). /// /// /// The complete hook offset frame is retained on each binding for schema /// fidelity. Retail Particle::Init (0x0051C930) transforms the /// offset origin through the current owner frame, but does not apply the hook /// offset quaternion to particle vectors; emitter orientation is the current /// root/part orientation. /// /// public sealed class ParticleHookSink : IAnimationHookSink { private readonly ParticleSystem _system; private readonly IEntityEffectPoseSource _poses; private readonly IEntityEffectCellSource? _cells; private readonly ConcurrentDictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new(); private readonly ConcurrentDictionary> _handlesByEntity = new(); private readonly ConcurrentDictionary _bindingsByHandle = new(); private readonly ConcurrentDictionary _renderPassByEntity = new(); private readonly ConcurrentDictionary _examinationOwners = new(); private readonly ConcurrentDictionary _hiddenPresentationOwners = new(); public ParticleHookSink(ParticleSystem system, IEntityEffectPoseSource poses) { _system = system ?? throw new ArgumentNullException(nameof(system)); _poses = poses ?? throw new ArgumentNullException(nameof(poses)); _cells = poses as IEntityEffectCellSource; _system.EmitterDied += OnEmitterDied; } public Action? DiagnosticSink { get; set; } /// /// Diagnostic ownership counts used by lifecycle stress gates. These count /// the sink's retained bookkeeping, not only emitters still present in the /// particle simulator, so teardown tests can detect a stale logical ID or /// per-owner handle bag after the underlying emitter has gone away. /// public int ActiveBindingCount => _bindingsByHandle.Count; public int LogicalEmitterCount => _handlesByKey.Count; public int TrackedOwnerCount => _handlesByEntity.Count; public int RenderPassOwnerCount => _renderPassByEntity.Count; public int ExaminationOwnerCount => _examinationOwners.Count; public int HiddenPresentationOwnerCount => _hiddenPresentationOwners.Count; private void OnEmitterDied(int handle) { if (!_bindingsByHandle.TryRemove(handle, out EmitterBinding binding)) return; if (binding.LogicalId != 0 && _handlesByKey.TryGetValue((binding.OwnerLocalId, binding.LogicalId), out int current) && current == handle) { _handlesByKey.TryRemove((binding.OwnerLocalId, binding.LogicalId), out _); } if (_handlesByEntity.TryGetValue(binding.OwnerLocalId, out var handles)) { handles.TryRemove(handle, out _); if (handles.IsEmpty) _handlesByEntity.TryRemove(binding.OwnerLocalId, out _); } } public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook) { switch (hook) { case RetailCreateBlockingParticleHook blocking: SpawnFromHook( entityId, (uint)blocking.EmitterInfoId, blocking.Offset, unchecked((int)blocking.PartIndex), blocking.EmitterId, isBlocking: true); break; case CreateParticleHook create: SpawnFromHook( entityId, (uint)create.EmitterInfoId, create.Offset, unchecked((int)create.PartIndex), create.EmitterId, isBlocking: false); break; case CreateBlockingParticleHook: // The upstream package exposes only the common header for this // type. Production loaders replace it with the retail payload // above; a header-only instance cannot create an emitter. DiagnosticSink?.Invoke( $"CreateBlockingParticle for owner 0x{entityId:X8} has no retail payload."); break; case DestroyParticleHook destroy: DestroyLogical(entityId, destroy.EmitterId, fadeOut: false); break; case StopParticleHook stop: DestroyLogical(entityId, stop.EmitterId, fadeOut: true); break; } } public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass) { _renderPassByEntity[entityId] = renderPass; RefreshOwnerVisibilityPolicy(entityId); } public void ClearEntityRenderPass(uint entityId) { _renderPassByEntity.TryRemove(entityId, out _); RefreshOwnerVisibilityPolicy(entityId); } /// /// Mirrors retail CPhysicsObj::m_bExaminationObject. Examination /// previews bypass world distance and cell visibility without conflating /// that policy with attachment identity. /// public void SetEntityExaminationObject(uint entityId, bool isExaminationObject) { if (isExaminationObject) _examinationOwners[entityId] = 0; else _examinationOwners.TryRemove(entityId, out _); RefreshOwnerVisibilityPolicy(entityId); } /// /// Mirrors live cell membership without ending emitter lifetime. Retail /// pauses the owner's particle manager while cell-less, then resumes the /// same particles and logical IDs without catch-up emission on re-entry. /// public void SetEntityPresentationVisible(uint entityId, bool visible) { if (visible) _hiddenPresentationOwners.TryRemove(entityId, out _); else _hiddenPresentationOwners[entityId] = 0; // Withdrawal is immediate. Re-entry is enabled by the next pose // refresh so an attached owner cannot flash for one frame at its old // anchor before the composed child pose is published. if (!visible && _handlesByEntity.TryGetValue(entityId, out var handles)) { foreach (int handle in handles.Keys) { _system.SetEmitterPresentationVisible(handle, false); _system.SetEmitterSimulationEnabled(handle, false); } } } /// /// Refreshes every emitter from the current root/part pose. World-released /// particles keep their stored emission origin; AttachLocal particles read /// the refreshed anchor when the particle system advances. /// public void RefreshAttachedEmitters() { foreach ((int handle, EmitterBinding binding) in _bindingsByHandle) { bool presentationVisible = !_hiddenPresentationOwners.ContainsKey(binding.OwnerLocalId); if (TryResolveAnchor(binding.OwnerLocalId, binding.PartIndex, binding.HookOffsetOrigin, out Vector3 anchor, out Quaternion rotation, out Vector3 ownerPosition)) { _system.UpdateEmitterAnchor(handle, anchor, rotation); _system.UpdateEmitterOwnerPosition(handle, ownerPosition); _system.UpdateEmitterOwnerCell( handle, _cells is not null && _cells.TryGetCellId(binding.OwnerLocalId, out uint cellId) ? cellId : 0u); // Keep the anchor current while spatially paused; re-entry // resumes at the authoritative pose without generating the // absent interval's time- or distance-driven emissions. _system.SetEmitterPresentationVisible(handle, presentationVisible); _system.SetEmitterSimulationEnabled(handle, presentationVisible); } else _system.SetEmitterPresentationVisible(handle, false); } } public void StopAllForEntity(uint entityId, bool fadeOut) { if (_handlesByEntity.TryRemove(entityId, out var handles)) { foreach (int handle in handles.Keys) { // A fading emitter needs its clock to retire naturally. A // hard destroy is removed synchronously by ParticleSystem. if (fadeOut) _system.SetEmitterSimulationEnabled(handle, true); _system.StopEmitter(handle, fadeOut); _bindingsByHandle.TryRemove(handle, out _); } } foreach (var key in _handlesByKey.Keys) { if (key.EntityId == entityId) _handlesByKey.TryRemove(key, out _); } ClearEntityRenderPass(entityId); _examinationOwners.TryRemove(entityId, out _); _hiddenPresentationOwners.TryRemove(entityId, out _); } private void DestroyLogical(uint ownerLocalId, uint logicalId, bool fadeOut) { if (logicalId == 0 || !_handlesByKey.TryGetValue((ownerLocalId, logicalId), out int handle)) { return; } // Retail StopParticleEmitter (0x0051B7B0) only marks the emitter as // stopped; it remains in particle_table until its final particle dies. // A blocking create with the same logical ID must therefore continue // to see it. DestroyParticleEmitter (0x0051B770) removes it at once. if (!fadeOut) _handlesByKey.TryRemove((ownerLocalId, logicalId), out _); if (fadeOut) _system.SetEmitterSimulationEnabled(handle, true); _system.StopEmitter(handle, fadeOut); } private void SpawnFromHook( uint ownerLocalId, uint emitterInfoId, Frame offset, int partIndex, uint logicalId, bool isBlocking) { if (logicalId != 0 && _handlesByKey.TryGetValue((ownerLocalId, logicalId), out int existing)) { if (isBlocking && _system.IsEmitterAlive(existing)) return; _handlesByKey.TryRemove((ownerLocalId, logicalId), out _); _system.StopEmitter(existing, fadeOut: false); } Vector3 offsetOrigin = offset?.Origin ?? Vector3.Zero; Quaternion offsetOrientation = offset?.Orientation ?? Quaternion.Identity; if (!TryResolveAnchor(ownerLocalId, partIndex, offsetOrigin, out Vector3 anchor, out Quaternion rotation, out Vector3 ownerPosition)) { DiagnosticSink?.Invoke( $"No live effect pose for owner 0x{ownerLocalId:X8}, part {partIndex}; " + $"emitter 0x{emitterInfoId:X8} was not created."); return; } ParticleRenderPass renderPass = _renderPassByEntity.TryGetValue(ownerLocalId, out var pass) ? pass : ParticleRenderPass.Scene; ParticleVisibilityPolicy visibilityPolicy = ResolveVisibilityPolicy(ownerLocalId); if (!_system.TrySpawnEmitterById( emitterInfoId, anchor, rotation, ownerLocalId, partIndex, renderPass, visibilityPolicy, out int handle)) { DiagnosticSink?.Invoke( $"ParticleEmitterInfo 0x{emitterInfoId:X8} for owner " + $"0x{ownerLocalId:X8} was not found; no fallback was created."); return; } _system.UpdateEmitterOwnerPosition(handle, ownerPosition); _system.UpdateEmitterOwnerCell( handle, _cells is not null && _cells.TryGetCellId(ownerLocalId, out uint cellId) ? cellId : 0u); if (_hiddenPresentationOwners.ContainsKey(ownerLocalId)) { _system.SetEmitterPresentationVisible(handle, false); _system.SetEmitterSimulationEnabled(handle, false); } var binding = new EmitterBinding( ownerLocalId, partIndex, offsetOrigin, offsetOrientation, logicalId, renderPass); _bindingsByHandle[handle] = binding; _handlesByEntity .GetOrAdd(ownerLocalId, _ => new ConcurrentDictionary()) .TryAdd(handle, 0); if (logicalId != 0) _handlesByKey[(ownerLocalId, logicalId)] = handle; } private ParticleVisibilityPolicy ResolveVisibilityPolicy(uint ownerLocalId) { if (_examinationOwners.ContainsKey(ownerLocalId)) return ParticleVisibilityPolicy.Examination; return _renderPassByEntity.TryGetValue(ownerLocalId, out ParticleRenderPass pass) && pass != ParticleRenderPass.Scene ? ParticleVisibilityPolicy.PassOwned : ParticleVisibilityPolicy.World; } private void RefreshOwnerVisibilityPolicy(uint ownerLocalId) { if (!_handlesByEntity.TryGetValue(ownerLocalId, out var handles)) return; ParticleVisibilityPolicy policy = ResolveVisibilityPolicy(ownerLocalId); foreach (int handle in handles.Keys) _system.SetEmitterVisibilityPolicy(handle, policy); } private bool TryResolveAnchor( uint ownerLocalId, int partIndex, Vector3 offsetOrigin, out Vector3 anchor, out Quaternion rotation, out Vector3 ownerPosition) { if (!_poses.TryGetRootPose(ownerLocalId, out Matrix4x4 rootWorld)) { anchor = default; rotation = default; ownerPosition = default; return false; } ownerPosition = new Vector3(rootWorld.M41, rootWorld.M42, rootWorld.M43); Matrix4x4 ownerFrame = rootWorld; if (partIndex != -1) { if (!_poses.TryGetPartPose(ownerLocalId, partIndex, out Matrix4x4 partLocal)) { anchor = default; rotation = default; ownerPosition = default; return false; } ownerFrame = partLocal * rootWorld; } anchor = Vector3.Transform(offsetOrigin, ownerFrame); if (!Matrix4x4.Decompose(ownerFrame, out _, out rotation, out _)) { anchor = default; rotation = default; ownerPosition = default; return false; } rotation = Quaternion.Normalize(rotation); return true; } private readonly record struct EmitterBinding( uint OwnerLocalId, int PartIndex, Vector3 HookOffsetOrigin, Quaternion HookOffsetOrientation, uint LogicalId, ParticleRenderPass RenderPass); }