using System.Numerics; using AcDream.Core.Vfx; using DatReaderWriter.Types; using Xunit; namespace AcDream.Core.Tests.Vfx; public sealed class ParticleHookSinkTests { private static EmitterDesc MakeDesc(uint id, bool attachLocal, int totalParticles = 0) { return new EmitterDesc { DatId = id, Type = ParticleType.Still, Flags = EmitterFlags.Billboard | (attachLocal ? EmitterFlags.AttachLocal : 0), EmitterKind = ParticleEmitterKind.BirthratePerSec, MaxParticles = 4, InitialParticles = 1, TotalParticles = totalParticles, LifetimeMin = 0.05f, LifetimeMax = 0.05f, Lifespan = 0.05f, StartSize = 1f, EndSize = 1f, StartAlpha = 1f, EndAlpha = 1f, Birthrate = 1000f, // effectively never re-emit }; } [Fact] public void UpdateEntityAnchor_WithAttachLocal_MovesParticleToLiveAnchor() { var registry = new EmitterDescRegistry(); registry.Register(MakeDesc(0x32000010u, attachLocal: true)); var sys = new ParticleSystem(registry, new System.Random(42)); var sink = new ParticleHookSink(sys); var hook = new CreateParticleHook { EmitterInfoId = 0x32000010u, EmitterId = 0, PartIndex = 0, Offset = new Frame(), }; // First spawn at world origin. sink.OnHook(entityId: 0xCAFEu, entityWorldPosition: Vector3.Zero, hook); sys.Tick(0.01f); var live1 = System.Linq.Enumerable.Single(sys.EnumerateLive()); Assert.Equal(Vector3.Zero, live1.Emitter.Particles[live1.Index].Position); // Move the parent to (5, 7, 0) — UpdateEntityAnchor must propagate. sink.UpdateEntityAnchor(0xCAFEu, new Vector3(5, 7, 0), Quaternion.Identity); sys.Tick(0.01f); var live2 = System.Linq.Enumerable.Single(sys.EnumerateLive()); Assert.Equal(new Vector3(5, 7, 0), live2.Emitter.Particles[live2.Index].Position); } [Fact] public void EmitterDied_PrunesPerEntityHandleTracking() { // M4: ConcurrentBag couldn't drop entries when a particle // emitter expired naturally, so per-entity tracking grew without // bound. The sink now subscribes to ParticleSystem.EmitterDied // and prunes both the (entity,key) map and the per-entity set. var registry = new EmitterDescRegistry(); registry.Register(MakeDesc(0x32000020u, attachLocal: false, totalParticles: 1)); var sys = new ParticleSystem(registry, new System.Random(42)); var sink = new ParticleHookSink(sys); var hook = new CreateParticleHook { EmitterInfoId = 0x32000020u, EmitterId = 0xABCDu, // logical key PartIndex = 0, Offset = new Frame(), }; sink.OnHook(0xCAFEu, Vector3.Zero, hook); Assert.Equal(1, sys.ActiveEmitterCount); // TotalParticles=1 cap hit immediately by the InitialParticles spawn, // so the emitter Finishes once its single particle expires (0.05s // lifetime). After this, EmitterDied has fired and tracking is pruned. for (int i = 0; i < 5; i++) sys.Tick(0.05f); Assert.Equal(0, sys.ActiveEmitterCount); // A fresh spawn for the same (entity, key) succeeds and is the only // live emitter — i.e., the previous handle was pruned cleanly. sink.OnHook(0xCAFEu, Vector3.Zero, hook); Assert.Equal(1, sys.ActiveEmitterCount); sink.StopAllForEntity(0xCAFEu, fadeOut: false); sys.Tick(0.01f); Assert.Equal(0, sys.ActiveEmitterCount); } }