using System; using System.Collections.Concurrent; using System.Numerics; using System.Threading; using AcDream.Core.Physics; using DatReaderWriter.Types; namespace AcDream.Core.Vfx; /// /// that translates particle-bearing /// animation hooks into spawn / stop calls. /// /// /// Hook types handled (r04 §6): /// /// /// — spawn an emitter from the /// hook's EmitterInfoId at the entity's world pose plus the /// hook offset. Retail attaches to a specific mesh part; we /// attach to the entity's root and will refine per-part when the /// renderer exposes per-part world transforms. /// /// /// — same as /// CreateParticleHook but the retail sequencer blocks animation /// progression until the particle is done. We spawn the emitter /// identically; the "blocking" semantics belong to the sequencer. /// /// /// — stop the most-recent emitter /// matching the hook's EmitterId. Deferred to a future pass /// when we retain per-entity emitter-id → handle maps. /// /// /// — pause all emitters on the /// entity (fade out). /// /// /// / /// — trigger the entity's DefaultScriptId PhysicsScript. /// Requires PhysicsScript table; deferred. /// /// /// — fire a PhysicsScript by id. /// Deferred until DRW exposes PhysicsScript dat. /// /// /// /// /// /// Per-entity emitter handle tracking is kept here so DestroyParticle / /// StopParticle can target the right emitter when a server-sent /// PlayEffect fires. /// /// public sealed class ParticleHookSink : IAnimationHookSink { private readonly ParticleSystem _system; // entityId → most-recently-spawned emitter handle per emitterId. // DestroyParticleHook.EmitterId is effectively an application-layer // key ("the smoke trail I spawned 2 seconds ago"), so we track by // (entity, emitterId). private readonly ConcurrentDictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new(); // entityId → set of live emitter handles. Dictionary-as-set so we can // remove individual handles when their emitter dies (M4 fix — // ConcurrentBag couldn't drop entries, so handles for naturally-expired // emitters used to leak). private readonly ConcurrentDictionary> _handlesByEntity = new(); // Reverse lookup: handle → (entity, key) for O(1) cleanup on EmitterDied. private readonly ConcurrentDictionary _trackingByHandle = new(); private readonly ConcurrentDictionary _renderPassByEntity = new(); private readonly ConcurrentDictionary _rotationByEntity = new(); // C.1.5b #56: per-entity static part transforms (PlacementFrames[Resting] // baked into a Matrix4x4 per Setup part). When set, SpawnFromHook applies // partTransforms[hook.PartIndex] to the hook offset BEFORE rotating to // world space. Without this, every emitter in a multi-part Setup // collapses to the entity root (the bug). Cleared by StopAllForEntity. // For ANIMATED entities this map would need a per-tick refresh similar // to UpdateEntityAnchor — deferred to a future phase. private readonly ConcurrentDictionary> _partTransformsByEntity = new(); private int _anonymousEmitterSerial; public ParticleHookSink(ParticleSystem system) { _system = system ?? throw new ArgumentNullException(nameof(system)); _system.EmitterDied += OnEmitterDied; } private void OnEmitterDied(int handle) { if (!_trackingByHandle.TryRemove(handle, out var t)) return; _handlesByKey.TryRemove((t.EntityId, t.KeyId), out _); if (_handlesByEntity.TryGetValue(t.EntityId, out var bag)) bag.TryRemove(handle, out _); } public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook) { switch (hook) { case CreateParticleHook cph: SpawnFromHook(entityId, entityWorldPosition, emitterInfoId: (uint)cph.EmitterInfoId, offset: cph.Offset.Origin, partIndex: (int)cph.PartIndex, logicalId: cph.EmitterId); break; case CreateBlockingParticleHook: // Retail's CreateBlockingParticleHook is a sentinel (no // payload) — the sequencer pauses advancement until the // current particle emitter finishes. We surface this as // "no spawn needed, just keep the current particle alive"; // blocking semantics live in the sequencer, not here. break; case DestroyParticleHook dph: if (_handlesByKey.TryRemove((entityId, dph.EmitterId), out var handleToDestroy)) _system.StopEmitter(handleToDestroy, fadeOut: false); break; case StopParticleHook sph: if (_handlesByKey.TryGetValue((entityId, sph.EmitterId), out var handleToStop)) _system.StopEmitter(handleToStop, fadeOut: true); break; // DefaultScript / CallPES are wired when PhysicsScript loading // is available. They arrive at the sink but we can't act until // the script table returns a real emitter list. } } public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass) => _renderPassByEntity[entityId] = renderPass; public void SetEntityRotation(uint entityId, Quaternion rotation) => _rotationByEntity[entityId] = rotation; /// /// Register per-part static transforms for an entity. The caller /// (typically EntityScriptActivator) precomputes one /// per Setup part using /// SetupPartTransforms.Compute and pushes them here at spawn /// time. applies /// partTransforms[hook.PartIndex] to the hook offset BEFORE /// transforming to world space. Cleared on /// . /// public void SetEntityPartTransforms(uint entityId, IReadOnlyList partTransforms) => _partTransformsByEntity[entityId] = partTransforms; public void ClearEntityRenderPass(uint entityId) => _renderPassByEntity.TryRemove(entityId, out _); /// /// Refresh every live emitter on this entity to a new world anchor + /// rotation. The owning subsystem (sky-PES driver, animation tick) /// drives this each frame for AttachLocal emitters so they track their /// moving parent — retail-faithful via /// ParticleEmitter::UpdateParticles at 0x0051d2d4, which /// re-reads the parent frame each tick when is_parent_local != 0. /// Safe to call for entities with no live emitters (no-op). /// public void UpdateEntityAnchor(uint entityId, Vector3 anchor, Quaternion rotation) { _rotationByEntity[entityId] = rotation; if (!_handlesByEntity.TryGetValue(entityId, out var bag)) return; foreach (var handle in bag.Keys) _system.UpdateEmitterAnchor(handle, anchor, rotation); } public void StopAllForEntity(uint entityId, bool fadeOut) { if (_handlesByEntity.TryRemove(entityId, out var handles)) { foreach (var handle in handles.Keys) { _system.StopEmitter(handle, fadeOut); _trackingByHandle.TryRemove(handle, out _); } } foreach (var key in _handlesByKey.Keys) { if (key.EntityId == entityId) _handlesByKey.TryRemove(key, out _); } ClearEntityRenderPass(entityId); _rotationByEntity.TryRemove(entityId, out _); _partTransformsByEntity.TryRemove(entityId, out _); } private void SpawnFromHook( uint entityId, Vector3 worldPos, uint emitterInfoId, Vector3 offset, int partIndex, uint logicalId) { // Spawn position: entity pose + hook offset, with the hook // offset first passed through the per-part transform when // available (C.1.5b #56 fix). Without the per-part transform, // every emitter in a multi-emitter PES script collapses to the // entity root — visible symptom: ground-buried portal swirls. var rotation = _rotationByEntity.TryGetValue(entityId, out var rot) ? rot : Quaternion.Identity; Vector3 partLocal = offset; if (_partTransformsByEntity.TryGetValue(entityId, out var partTransforms) && partIndex >= 0 && partIndex < partTransforms.Count) { partLocal = Vector3.Transform(offset, partTransforms[partIndex]); } var anchor = worldPos + Vector3.Transform(partLocal, rotation); var renderPass = _renderPassByEntity.TryGetValue(entityId, out var pass) ? pass : ParticleRenderPass.Scene; int handle = _system.SpawnEmitterById( emitterId: emitterInfoId, anchor: anchor, rot: rotation, attachedObjectId: entityId, attachedPartIndex: partIndex, renderPass: renderPass); uint keyId = logicalId != 0 ? logicalId : 0x80000000u | (uint)Interlocked.Increment(ref _anonymousEmitterSerial); if (logicalId != 0 && _handlesByKey.TryRemove((entityId, keyId), out var oldHandle)) { _system.StopEmitter(oldHandle, fadeOut: false); _trackingByHandle.TryRemove(oldHandle, out _); } _handlesByKey[(entityId, keyId)] = handle; _handlesByEntity .GetOrAdd(entityId, _ => new ConcurrentDictionary()) .TryAdd(handle, 0); _trackingByHandle[handle] = (entityId, keyId); } }