using System.Linq; using System.Numerics; using AcDream.Core.Vfx; using Xunit; namespace AcDream.Core.Tests.Vfx; public sealed class ParticleSystemTests { private static ParticleSystem MakeSystem() => new ParticleSystem(new EmitterDescRegistry(), new System.Random(42)); private static EmitterDesc MakeDesc(ParticleType type = ParticleType.LocalVelocity, int maxParticles = 16, float emitRate = 20f, float lifetime = 1f) { return new EmitterDesc { DatId = 0x32000001u, Type = type, EmitRate = emitRate, MaxParticles = maxParticles, LifetimeMin = lifetime, LifetimeMax = lifetime, OffsetDir = Vector3.UnitZ, MinOffset = 0, MaxOffset = 0, SpawnDiskRadius = 0, InitialVelocity = new Vector3(0, 0, 1f), VelocityJitter = 0, StartSize = 0.5f, EndSize = 0.5f, StartAlpha = 1f, EndAlpha = 0f, Gravity = Vector3.Zero, }; } private static EmitterDesc MakeInitialParticleDesc( ParticleType type, Vector3 a, Vector3 b, Vector3 c) { return new EmitterDesc { DatId = 0x3200AA01u, Type = type, MaxParticles = 1, InitialParticles = 1, LifetimeMin = 10f, LifetimeMax = 10f, Lifespan = 10f, LifespanRand = 0f, OffsetDir = Vector3.UnitZ, MinOffset = 0f, MaxOffset = 0f, InitialVelocity = Vector3.Zero, Gravity = Vector3.Zero, A = a, MinA = 1f, MaxA = 1f, B = b, MinB = 1f, MaxB = 1f, C = c, MinC = 1f, MaxC = 1f, StartSize = 0.5f, EndSize = 0.5f, StartAlpha = 1f, EndAlpha = 1f, }; } [Fact] public void SpawnEmitter_ReturnsPositiveHandle_AndTracksEmitter() { var sys = MakeSystem(); int h = sys.SpawnEmitter(MakeDesc(), Vector3.Zero); Assert.True(h > 0); Assert.Equal(1, sys.ActiveEmitterCount); } [Fact] public void Tick_EmitsParticlesOverTime() { var sys = MakeSystem(); // Lifetime=2s so none die in the 1s test window. sys.SpawnEmitter(MakeDesc(emitRate: 10f, maxParticles: 100, lifetime: 2f), Vector3.Zero); // 10/sec * 1s = ~10 particles. sys.Tick(0.5f); sys.Tick(0.5f); Assert.InRange(sys.ActiveParticleCount, 8, 12); } [Fact] public void Tick_ParticlesDieAtLifetime() { var sys = MakeSystem(); int handle = sys.SpawnEmitter(MakeDesc(emitRate: 20f, lifetime: 0.5f, maxParticles: 100), Vector3.Zero); // Use many short ticks so we can observe the death curve. // At 20/sec with 0.5s lifetime and a stable emission pool, the // steady-state active count should be ~20 * 0.5 = 10 particles. for (int i = 0; i < 20; i++) sys.Tick(0.05f); // 1 second total int steadyState = sys.ActiveParticleCount; Assert.InRange(steadyState, 7, 13); // Now advance further with no new spawns; all should die. sys.StopEmitter(handle, fadeOut: true); for (int i = 0; i < 30; i++) sys.Tick(0.05f); // 1.5s more than lifetime Assert.Equal(0, sys.ActiveParticleCount); } [Fact] public void LocalVelocity_IntegrationMovesParticles() { var sys = MakeSystem(); var desc = MakeDesc(type: ParticleType.LocalVelocity); sys.SpawnEmitter(desc, Vector3.Zero); sys.Tick(0.1f); // spawn a few sys.Tick(0.5f); // move them 0.5s * 1 m/s = 0.5m in +Z var live = sys.EnumerateLive().ToList(); Assert.NotEmpty(live); // First particle spawned ~0.1s ago has moved ~0.5s in +Z. // Just assert z-positions are spread (not all at origin). bool anyMoved = live.Any(p => p.Emitter.Particles[p.Index].Position.Z > 0.3f); Assert.True(anyMoved, "Expected at least one particle to have moved in +Z"); } [Fact] public void Parabolic_GravityApplied() { var sys = MakeSystem(); var desc = new EmitterDesc { DatId = 0x32000002u, Type = ParticleType.ParabolicLVGA, EmitRate = 10f, MaxParticles = 100, LifetimeMin = 2f, LifetimeMax = 2f, OffsetDir = Vector3.UnitZ, InitialVelocity = new Vector3(0, 0, 5f), // straight up StartSize = 0.5f, EndSize = 0.5f, StartAlpha = 1f, EndAlpha = 0f, Gravity = new Vector3(0, 0, -10f), // strong gravity }; sys.SpawnEmitter(desc, Vector3.Zero); // Spawn burst. sys.Tick(0.1f); sys.Tick(0.5f); // 0.5s after spawn: v_z = 5 - 10*0.5 = 0; z peaks // Keep integrating; gravity should pull particles back below z=0 // by t ~= 1.0s total flight. for (int i = 0; i < 20; i++) sys.Tick(0.1f); var anyBelow = sys.EnumerateLive().Any(p => p.Emitter.Particles[p.Index].Position.Z < 0f); // If all particles died before falling below 0, the test is still OK // (lifetime=2s but fly-time was ~1s). Relaxed: just confirm gravity // produced a range of z values > 0 at start. Assert.True(anyBelow || sys.ActiveParticleCount == 0, "Expected some parabolic particles to fall below 0"); } [Fact] public void Explode_MovesOutwardFromAnchor() { var sys = MakeSystem(); // Seed particles with small offsets in spawn disk so they have // non-zero radial distance from anchor. var desc = new EmitterDesc { DatId = 0x32000003u, Type = ParticleType.Explode, EmitRate = 20f, MaxParticles = 100, LifetimeMin = 2f, LifetimeMax = 2f, OffsetDir = Vector3.UnitZ, MinOffset = 0.5f, MaxOffset = 0.5f, SpawnDiskRadius = 0.5f, InitialVelocity = new Vector3(1, 0, 0), // magnitude = 1 StartSize = 0.5f, EndSize = 0.5f, StartAlpha = 1f, EndAlpha = 0f, }; sys.SpawnEmitter(desc, Vector3.Zero); sys.Tick(0.1f); sys.Tick(0.5f); // All alive particles should be further from origin than their // initial disk radius (~0.5) because Explode pushes outward at // speed 1 m/s. var live = sys.EnumerateLive().ToList(); Assert.NotEmpty(live); foreach (var (em, idx) in live) Assert.True(em.Particles[idx].Position.Length() > 0.3f); } [Fact] public void StopEmitter_KillsAllParticles() { var sys = MakeSystem(); int h = sys.SpawnEmitter(MakeDesc(emitRate: 10f, maxParticles: 20), Vector3.Zero); sys.Tick(0.5f); Assert.True(sys.ActiveParticleCount > 0); sys.StopEmitter(h, fadeOut: false); sys.Tick(0.01f); // tick to let the cleanup happen Assert.Equal(0, sys.ActiveParticleCount); } [Fact] public void StopEmitter_FadeOut_PreservesCurrentParticles() { var sys = MakeSystem(); int h = sys.SpawnEmitter(MakeDesc(emitRate: 10f, lifetime: 1f, maxParticles: 20), Vector3.Zero); sys.Tick(0.3f); int before = sys.ActiveParticleCount; Assert.True(before > 0); sys.StopEmitter(h, fadeOut: true); sys.Tick(0.1f); // particles still alive, no NEW spawns int after = sys.ActiveParticleCount; Assert.Equal(before, after); } [Fact] public void MaxParticles_CapEnforced() { var sys = MakeSystem(); // Low cap, high rate, long life → rapidly hit cap. sys.SpawnEmitter(MakeDesc(emitRate: 100f, lifetime: 10f, maxParticles: 5), Vector3.Zero); sys.Tick(1f); // would spawn 100 if unbounded; cap at 5. Assert.InRange(sys.ActiveParticleCount, 1, 5); } [Fact] public void EmitterDescRegistry_FallsBackToDefault_ForUnknownId() { var reg = new EmitterDescRegistry(); var desc = reg.Get(0xDEADBEEFu); // not registered Assert.NotNull(desc); Assert.Equal(0xFFFFFFFFu, desc.DatId); // matches default sentinel } [Fact] public void EmitterDescRegistry_Register_StoresById() { var reg = new EmitterDescRegistry(); var desc = new EmitterDesc { DatId = 0x32001234u, Type = ParticleType.Still }; reg.Register(desc); Assert.Same(desc, reg.Get(0x32001234u)); } [Fact] public void LocalVelocity_TransformsABySpawnRotation() { var sys = MakeSystem(); var desc = MakeInitialParticleDesc( ParticleType.LocalVelocity, Vector3.UnitX, Vector3.Zero, Vector3.Zero); sys.SpawnEmitter(desc, Vector3.Zero, Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI * 0.5f)); sys.Tick(1f); var live = sys.EnumerateLive().Single(); var pos = live.Emitter.Particles[live.Index].Position; Assert.InRange(pos.X, -0.0001f, 0.0001f); Assert.InRange(pos.Y, 0.9999f, 1.0001f); } [Fact] public void GlobalVelocity_DoesNotTransformABySpawnRotation() { var sys = MakeSystem(); var desc = MakeInitialParticleDesc( ParticleType.GlobalVelocity, Vector3.UnitX, Vector3.Zero, Vector3.Zero); sys.SpawnEmitter(desc, Vector3.Zero, Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI * 0.5f)); sys.Tick(1f); var live = sys.EnumerateLive().Single(); var pos = live.Emitter.Particles[live.Index].Position; Assert.InRange(pos.X, 0.9999f, 1.0001f); Assert.InRange(pos.Y, -0.0001f, 0.0001f); } [Fact] public void ParabolicLVLA_TransformsLocalAcceleration() { var sys = MakeSystem(); var desc = MakeInitialParticleDesc( ParticleType.ParabolicLVLA, Vector3.Zero, Vector3.UnitX, Vector3.Zero); sys.SpawnEmitter(desc, Vector3.Zero, Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI * 0.5f)); sys.Tick(1f); var live = sys.EnumerateLive().Single(); var pos = live.Emitter.Particles[live.Index].Position; Assert.InRange(pos.X, -0.0001f, 0.0001f); Assert.InRange(pos.Y, 0.4999f, 0.5001f); } [Fact] public void ParabolicLVGA_KeepsGlobalAcceleration() { var sys = MakeSystem(); var desc = MakeInitialParticleDesc( ParticleType.ParabolicLVGA, Vector3.Zero, Vector3.UnitX, Vector3.Zero); sys.SpawnEmitter(desc, Vector3.Zero, Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI * 0.5f)); sys.Tick(1f); var live = sys.EnumerateLive().Single(); var pos = live.Emitter.Particles[live.Index].Position; Assert.InRange(pos.X, 0.4999f, 0.5001f); Assert.InRange(pos.Y, -0.0001f, 0.0001f); } [Fact] public void EmitterDescRegistry_FromDat_PreservesRetailEnumValuesAndRates() { var dat = new DatReaderWriter.DBObjs.ParticleEmitter { EmitterType = DatReaderWriter.Enums.EmitterType.BirthratePerSec, ParticleType = DatReaderWriter.Enums.ParticleType.Swarm, GfxObjId = 0x01000001u, HwGfxObjId = 0x01000002u, Birthrate = 0.25, MaxParticles = 17, InitialParticles = 3, TotalParticles = 9, TotalSeconds = 4, Lifespan = 2, LifespanRand = 0.5, A = new Vector3(1, 0, 0), MinA = 0.5f, MaxA = 2f, StartScale = 0.2f, FinalScale = 0.8f, StartTrans = 1f, FinalTrans = 0f, IsParentLocal = true, }; var desc = EmitterDescRegistry.FromDat(0x32000099u, dat); Assert.Equal(ParticleType.Swarm, desc.Type); Assert.Equal(ParticleEmitterKind.BirthratePerSec, desc.EmitterKind); Assert.Equal(4f, desc.EmitRate); Assert.Equal(0x01000001u, desc.GfxObjId); Assert.Equal(0x01000002u, desc.HwGfxObjId); Assert.Equal(3, desc.InitialParticles); Assert.Equal(9, desc.TotalParticles); Assert.Equal(1.5f, desc.LifetimeMin); Assert.Equal(2.5f, desc.LifetimeMax); Assert.Equal(0f, desc.StartAlpha); Assert.Equal(1f, desc.EndAlpha); Assert.Equal(EmitterFlags.Billboard | EmitterFlags.FaceCamera | EmitterFlags.AttachLocal, desc.Flags); Assert.True((desc.Flags & EmitterFlags.AttachLocal) != 0); } [Fact] public void UpdateEmitterAnchor_AttachLocal_ParticlePositionFollowsLiveAnchor() { // Retail ParticleEmitter::UpdateParticles 0x0051d2d4 reads the live // parent frame each tick when is_parent_local=1. With the cameraOffset // hack removed, AttachLocal correctness now depends on the owning // subsystem updating AnchorPos every frame via UpdateEmitterAnchor. var sys = MakeSystem(); var desc = new EmitterDesc { DatId = 0x32AABBCCu, Type = ParticleType.Still, Flags = EmitterFlags.AttachLocal | EmitterFlags.Billboard, MaxParticles = 1, InitialParticles = 1, LifetimeMin = 100f, LifetimeMax = 100f, Lifespan = 100f, StartSize = 1f, EndSize = 1f, StartAlpha = 1f, EndAlpha = 1f, // Zero motion + zero offset so position == origin == AnchorPos. }; int handle = sys.SpawnEmitter(desc, anchor: new Vector3(10, 0, 0)); sys.Tick(0.01f); var p1 = sys.EnumerateLive().Single().Emitter.Particles[0]; Assert.Equal(new Vector3(10, 0, 0), p1.Position); // Move the live anchor; AttachLocal should track it on the next tick. sys.UpdateEmitterAnchor(handle, new Vector3(50, 20, 5)); sys.Tick(0.01f); var p2 = sys.EnumerateLive().Single().Emitter.Particles[0]; Assert.Equal(new Vector3(50, 20, 5), p2.Position); } [Fact] public void UpdateEmitterAnchor_AttachLocalCleared_ParticleFrozenAtSpawnOrigin() { // is_parent_local=0 → particle uses its frozen EmissionOrigin; later // anchor updates must NOT move it (retail's "frame snapshotted at // spawn" semantics). var sys = MakeSystem(); var desc = new EmitterDesc { DatId = 0x32AABBCDu, Type = ParticleType.Still, Flags = EmitterFlags.Billboard, // NO AttachLocal MaxParticles = 1, InitialParticles = 1, LifetimeMin = 100f, LifetimeMax = 100f, Lifespan = 100f, StartSize = 1f, EndSize = 1f, StartAlpha = 1f, EndAlpha = 1f, }; int handle = sys.SpawnEmitter(desc, anchor: new Vector3(10, 0, 0)); sys.Tick(0.01f); sys.UpdateEmitterAnchor(handle, new Vector3(99, 99, 99)); sys.Tick(0.01f); var p = sys.EnumerateLive().Single().Emitter.Particles[0]; Assert.Equal(new Vector3(10, 0, 0), p.Position); } [Fact] public void EmitterDied_FiresOncePerHandle_AfterAllParticlesExpire() { var sys = MakeSystem(); var fired = new System.Collections.Generic.List(); sys.EmitterDied += h => fired.Add(h); int handle = sys.SpawnEmitter(MakeDesc(emitRate: 5f, lifetime: 0.2f, maxParticles: 4), Vector3.Zero); sys.StopEmitter(handle, fadeOut: false); // kill emitter + all particles immediately sys.Tick(0.01f); Assert.Single(fired); Assert.Equal(handle, fired[0]); Assert.False(sys.IsEmitterAlive(handle)); } [Fact] public void Birthrate_PerSec_EmitsOnePerTickWhenIntervalElapsed() { // Retail ParticleEmitterInfo::ShouldEmitParticle 0x00517420 checks // (cur_time - last_emit_time) > birthrate. RecordParticleEmission // 0x0051c870 then sets last_emit_time = cur_time, so retail's // UpdateParticles fires AT MOST one EmitParticle per frame // (the dispatch is `if (ShouldEmit) EmitParticle()`, not a loop). // Lock that behavior in. var sys = MakeSystem(); var desc = new EmitterDesc { DatId = 0x32AAAA01u, Type = ParticleType.Still, EmitterKind = ParticleEmitterKind.BirthratePerSec, Birthrate = 0.05f, // 50ms minimum between emits EmitRate = 0f, // disable the EmitRate fallback path MaxParticles = 100, LifetimeMin = 100f, LifetimeMax = 100f, Lifespan = 100f, StartSize = 1f, EndSize = 1f, StartAlpha = 1f, EndAlpha = 1f, }; sys.SpawnEmitter(desc, Vector3.Zero); // Single 1-second tick. Retail-faithful behavior: exactly one // particle emits, regardless of how many birthrate intervals fit in dt. sys.Tick(1.0f); Assert.Equal(1, sys.ActiveParticleCount); // Subsequent small ticks each emit once if birthrate has elapsed. sys.Tick(0.06f); // > 0.05s since last emit Assert.Equal(2, sys.ActiveParticleCount); // A tick smaller than birthrate adds nothing. sys.Tick(0.01f); Assert.Equal(2, sys.ActiveParticleCount); } }