372 lines
13 KiB
C#
372 lines
13 KiB
C#
using System.Numerics;
|
|
using AcDream.Core.Vfx;
|
|
using DatReaderWriter.Types;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Vfx;
|
|
|
|
public sealed class ParticleHookSinkTests
|
|
{
|
|
private const uint Owner = 0xCAFEu;
|
|
|
|
private static EmitterDesc MakeDesc(
|
|
uint id,
|
|
bool attachLocal,
|
|
int totalParticles = 0,
|
|
float totalDuration = 0f) =>
|
|
new()
|
|
{
|
|
DatId = id,
|
|
Type = ParticleType.Still,
|
|
Flags = EmitterFlags.Billboard | (attachLocal ? EmitterFlags.AttachLocal : 0),
|
|
EmitterKind = ParticleEmitterKind.BirthratePerSec,
|
|
MaxParticles = 4,
|
|
InitialParticles = 1,
|
|
TotalParticles = totalParticles,
|
|
TotalDuration = totalDuration,
|
|
LifetimeMin = 0.05f,
|
|
LifetimeMax = 0.05f,
|
|
Lifespan = 0.05f,
|
|
StartSize = 1f,
|
|
EndSize = 1f,
|
|
StartAlpha = 1f,
|
|
EndAlpha = 1f,
|
|
Birthrate = 1000f,
|
|
};
|
|
|
|
private static (ParticleSystem System, ParticleHookSink Sink, MutablePoseSource Poses) Harness(
|
|
uint emitterId,
|
|
bool attachLocal = false,
|
|
int totalParticles = 0,
|
|
float totalDuration = 0f)
|
|
{
|
|
var registry = new EmitterDescRegistry();
|
|
registry.Register(MakeDesc(emitterId, attachLocal, totalParticles, totalDuration));
|
|
var system = new ParticleSystem(registry, new Random(42));
|
|
var poses = new MutablePoseSource();
|
|
poses.Publish(Owner, Matrix4x4.Identity, Matrix4x4.Identity);
|
|
return (system, new ParticleHookSink(system, poses), poses);
|
|
}
|
|
|
|
private static CreateParticleHook Create(uint emitterInfoId, uint logicalId, int partIndex = 0) =>
|
|
new()
|
|
{
|
|
EmitterInfoId = emitterInfoId,
|
|
EmitterId = logicalId,
|
|
PartIndex = unchecked((uint)partIndex),
|
|
Offset = new Frame(),
|
|
};
|
|
|
|
[Fact]
|
|
public void RefreshAttachedEmitters_WithAttachLocal_MovesExistingParticleToCurrentPose()
|
|
{
|
|
const uint emitterId = 0x32000010u;
|
|
var (system, sink, poses) = Harness(emitterId, attachLocal: true);
|
|
sink.OnHook(Owner, Vector3.Zero, Create(emitterId, 0));
|
|
system.Tick(0.01f);
|
|
Assert.Equal(Vector3.Zero, system.EnumerateLive().Single().Emitter.Particles[0].Position);
|
|
|
|
poses.Publish(Owner, Matrix4x4.CreateTranslation(5, 7, 0), Matrix4x4.Identity);
|
|
sink.RefreshAttachedEmitters();
|
|
system.Tick(0.01f);
|
|
|
|
Assert.Equal(new Vector3(5, 7, 0),
|
|
system.EnumerateLive().Single().Emitter.Particles[0].Position);
|
|
}
|
|
|
|
[Fact]
|
|
public void WorldReleasedParticles_KeepBirthPosition_WhileNewAnchorMoves()
|
|
{
|
|
const uint emitterId = 0x32000011u;
|
|
var (system, sink, poses) = Harness(emitterId, attachLocal: false);
|
|
sink.OnHook(Owner, Vector3.Zero, Create(emitterId, 0));
|
|
system.Tick(0.01f);
|
|
|
|
poses.Publish(Owner, Matrix4x4.CreateTranslation(9, 0, 0), Matrix4x4.Identity);
|
|
sink.RefreshAttachedEmitters();
|
|
system.Tick(0.01f);
|
|
|
|
Assert.Equal(Vector3.Zero,
|
|
system.EnumerateLive().Single().Emitter.Particles[0].Position);
|
|
}
|
|
|
|
[Fact]
|
|
public void Spawn_UsesCurrentIndexedPartAndRoot_WithRowVectorComposition()
|
|
{
|
|
const uint emitterId = 0x32000030u;
|
|
var (system, sink, poses) = Harness(emitterId);
|
|
Matrix4x4 root = Matrix4x4.CreateRotationZ(MathF.PI / 2f)
|
|
* Matrix4x4.CreateTranslation(10, 20, 30);
|
|
poses.Publish(
|
|
Owner,
|
|
root,
|
|
Matrix4x4.Identity,
|
|
Matrix4x4.CreateTranslation(0, 0, 2));
|
|
|
|
var hook = Create(emitterId, 0, partIndex: 1);
|
|
hook.Offset = new Frame
|
|
{
|
|
Origin = new Vector3(1, 0, 0),
|
|
// Retail retains but does not consume this quaternion in
|
|
// Particle::Init 0x0051C930.
|
|
Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1.1f),
|
|
};
|
|
sink.OnHook(Owner, Vector3.Zero, hook);
|
|
system.Tick(0.001f);
|
|
|
|
Vector3 position = system.EnumerateLive().Single().Emitter.Particles[0].Position;
|
|
Assert.InRange(position.X, 9.99f, 10.01f);
|
|
Assert.InRange(position.Y, 20.99f, 21.01f);
|
|
Assert.InRange(position.Z, 31.99f, 32.01f);
|
|
}
|
|
|
|
[Fact]
|
|
public void PartMinusOne_UsesRoot_AndInvalidIndexedPartDoesNotFallback()
|
|
{
|
|
const uint emitterId = 0x32000031u;
|
|
var (system, sink, poses) = Harness(emitterId);
|
|
poses.Publish(
|
|
Owner,
|
|
Matrix4x4.CreateTranslation(3, 4, 5),
|
|
Matrix4x4.CreateTranslation(50, 0, 0));
|
|
|
|
sink.OnHook(Owner, Vector3.Zero, Create(emitterId, 0, partIndex: -1));
|
|
sink.OnHook(Owner, Vector3.Zero, Create(emitterId, 0, partIndex: 99));
|
|
system.Tick(0.001f);
|
|
|
|
Assert.Single(system.EnumerateLive());
|
|
Assert.Equal(new Vector3(3, 4, 5),
|
|
system.EnumerateLive().Single().Emitter.Particles[0].Position);
|
|
}
|
|
|
|
[Fact]
|
|
public void NormalLogicalId_Replaces_BlockingSuppresses_ThenCreatesAfterRetirement()
|
|
{
|
|
const uint emitterId = 0x32000040u;
|
|
const uint logicalId = 0x1234u;
|
|
var (system, sink, _) = Harness(emitterId);
|
|
var normal = Create(emitterId, logicalId);
|
|
var blocking = new RetailCreateBlockingParticleHook
|
|
{
|
|
EmitterInfoId = emitterId,
|
|
EmitterId = logicalId,
|
|
PartIndex = 0,
|
|
Offset = new Frame(),
|
|
};
|
|
|
|
sink.OnHook(Owner, Vector3.Zero, normal);
|
|
sink.OnHook(Owner, Vector3.Zero, blocking);
|
|
Assert.Equal(1, system.ActiveEmitterCount);
|
|
|
|
sink.OnHook(Owner, Vector3.Zero, normal);
|
|
system.Tick(0.001f);
|
|
Assert.Equal(1, system.ActiveEmitterCount);
|
|
|
|
sink.OnHook(Owner, Vector3.Zero,
|
|
new DestroyParticleHook { EmitterId = logicalId });
|
|
system.Tick(0.001f);
|
|
Assert.Equal(0, system.ActiveEmitterCount);
|
|
|
|
sink.OnHook(Owner, Vector3.Zero, blocking);
|
|
Assert.Equal(1, system.ActiveEmitterCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void BlockingLogicalIdZero_AlwaysCreatesAnonymousEmitter()
|
|
{
|
|
const uint emitterId = 0x32000041u;
|
|
var (system, sink, _) = Harness(emitterId);
|
|
var blocking = new RetailCreateBlockingParticleHook
|
|
{
|
|
EmitterInfoId = emitterId,
|
|
EmitterId = 0,
|
|
PartIndex = 0,
|
|
Offset = new Frame(),
|
|
};
|
|
|
|
sink.OnHook(Owner, Vector3.Zero, blocking);
|
|
sink.OnHook(Owner, Vector3.Zero, blocking);
|
|
Assert.Equal(2, system.ActiveEmitterCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void StopLogicalEmitter_RemainsBlockingUntilFinalParticleRetires()
|
|
{
|
|
const uint emitterId = 0x32000042u;
|
|
const uint logicalId = 0x777u;
|
|
var (system, sink, _) = Harness(emitterId);
|
|
var blocking = new RetailCreateBlockingParticleHook
|
|
{
|
|
EmitterInfoId = emitterId,
|
|
EmitterId = logicalId,
|
|
PartIndex = 0,
|
|
Offset = new Frame(),
|
|
};
|
|
|
|
sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId));
|
|
sink.OnHook(Owner, Vector3.Zero, new StopParticleHook { EmitterId = logicalId });
|
|
sink.OnHook(Owner, Vector3.Zero, blocking);
|
|
|
|
Assert.Equal(1, system.ActiveEmitterCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void StopLogicalEmitter_NormalCreateStillReplacesIt()
|
|
{
|
|
const uint emitterId = 0x32000043u;
|
|
const uint logicalId = 0x778u;
|
|
var (system, sink, _) = Harness(emitterId);
|
|
|
|
sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId));
|
|
sink.OnHook(Owner, Vector3.Zero, new StopParticleHook { EmitterId = logicalId });
|
|
sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId));
|
|
system.Tick(0.001f);
|
|
|
|
Assert.Equal(1, system.ActiveEmitterCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void NaturalRetirement_PrunesLogicalIdForBlockingReuse()
|
|
{
|
|
const uint emitterId = 0x32000020u;
|
|
const uint logicalId = 0xABCDu;
|
|
var (system, sink, _) = Harness(emitterId, totalParticles: 1);
|
|
var blocking = new RetailCreateBlockingParticleHook
|
|
{
|
|
EmitterInfoId = emitterId,
|
|
EmitterId = logicalId,
|
|
PartIndex = 0,
|
|
Offset = new Frame(),
|
|
};
|
|
sink.OnHook(Owner, Vector3.Zero, blocking);
|
|
for (int i = 0; i < 5; i++)
|
|
system.Tick(0.05f);
|
|
Assert.Equal(0, system.ActiveEmitterCount);
|
|
|
|
sink.OnHook(Owner, Vector3.Zero, blocking);
|
|
Assert.Equal(1, system.ActiveEmitterCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingEmitterAsset_ReportsDiagnosticWithoutFabricatingEffect()
|
|
{
|
|
const uint registeredId = 0x32000020u;
|
|
const uint missingId = 0x32DEAD00u;
|
|
var (system, sink, _) = Harness(registeredId);
|
|
var diagnostics = new List<string>();
|
|
sink.DiagnosticSink = diagnostics.Add;
|
|
|
|
sink.OnHook(Owner, Vector3.Zero, Create(missingId, logicalId: 7u));
|
|
|
|
Assert.Equal(0, system.ActiveEmitterCount);
|
|
string diagnostic = Assert.Single(diagnostics);
|
|
Assert.Contains($"0x{missingId:X8}", diagnostic, StringComparison.Ordinal);
|
|
Assert.Contains($"0x{Owner:X8}", diagnostic, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingLivePose_HidesPresentationWithoutEndingEmitterLifetime()
|
|
{
|
|
const uint emitterId = 0x32000055u;
|
|
var (system, sink, poses) = Harness(emitterId);
|
|
sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId: 11u));
|
|
ParticleEmitter emitter = system.EnumerateLive().Single().Emitter;
|
|
Assert.True(emitter.PresentationVisible);
|
|
|
|
poses.Remove(Owner);
|
|
sink.RefreshAttachedEmitters();
|
|
|
|
Assert.Equal(1, system.ActiveEmitterCount);
|
|
Assert.False(emitter.PresentationVisible);
|
|
|
|
poses.Publish(Owner, Matrix4x4.CreateTranslation(8, 0, 0), Matrix4x4.Identity);
|
|
sink.RefreshAttachedEmitters();
|
|
Assert.True(emitter.PresentationVisible);
|
|
}
|
|
|
|
[Fact]
|
|
public void PendingProjection_HidesPresentationWhileContinuingLiveAnchorRefresh()
|
|
{
|
|
const uint emitterId = 0x32000056u;
|
|
var (system, sink, poses) = Harness(emitterId, totalDuration: 0.05f);
|
|
sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId: 12u));
|
|
ParticleEmitter emitter = system.EnumerateLive().Single().Emitter;
|
|
int emittedBeforePause = emitter.TotalEmitted;
|
|
float spawnedAt = emitter.Particles[0].SpawnedAt;
|
|
|
|
sink.SetEntityPresentationVisible(Owner, false);
|
|
poses.Publish(
|
|
Owner,
|
|
Matrix4x4.CreateTranslation(12, 3, 0),
|
|
Matrix4x4.Identity);
|
|
sink.RefreshAttachedEmitters();
|
|
system.Tick(1f);
|
|
|
|
Assert.Equal(1, system.ActiveEmitterCount);
|
|
Assert.Equal(new Vector3(12, 3, 0), emitter.AnchorPos);
|
|
Assert.Equal(emittedBeforePause, emitter.TotalEmitted);
|
|
Assert.Equal(spawnedAt, emitter.Particles[0].SpawnedAt);
|
|
Assert.Equal(new Vector3(12, 3, 0), emitter.LastEmitOffset);
|
|
Assert.False(emitter.SimulationEnabled);
|
|
Assert.False(emitter.PresentationVisible);
|
|
|
|
sink.SetEntityPresentationVisible(Owner, true);
|
|
sink.RefreshAttachedEmitters();
|
|
Assert.True(emitter.SimulationEnabled);
|
|
Assert.True(emitter.PresentationVisible);
|
|
Assert.Equal(spawnedAt, emitter.Particles[0].SpawnedAt);
|
|
|
|
system.Tick(0.01f);
|
|
Assert.Equal(0, system.ActiveEmitterCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void LogicalTeardown_RemovesEmitterThatWasSpatiallyPaused()
|
|
{
|
|
const uint emitterId = 0x32000057u;
|
|
var (system, sink, _) = Harness(emitterId);
|
|
sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId: 13u));
|
|
sink.SetEntityPresentationVisible(Owner, false);
|
|
int deathNotices = 0;
|
|
system.EmitterDied += _ => deathNotices++;
|
|
|
|
sink.StopAllForEntity(Owner, fadeOut: false);
|
|
|
|
Assert.Equal(0, system.ActiveEmitterCount);
|
|
Assert.Equal(1, deathNotices);
|
|
}
|
|
|
|
private sealed class MutablePoseSource : IEntityEffectPoseSource
|
|
{
|
|
private readonly Dictionary<uint, (Matrix4x4 Root, Matrix4x4[] Parts)> _poses = new();
|
|
|
|
public void Publish(uint id, Matrix4x4 root, params Matrix4x4[] parts) =>
|
|
_poses[id] = (root, parts);
|
|
|
|
public void Remove(uint id) => _poses.Remove(id);
|
|
|
|
public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
|
|
{
|
|
if (_poses.TryGetValue(localEntityId, out var pose))
|
|
{
|
|
rootWorld = pose.Root;
|
|
return true;
|
|
}
|
|
rootWorld = default;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetPartPose(uint localEntityId, int partIndex, out Matrix4x4 partLocal)
|
|
{
|
|
if (_poses.TryGetValue(localEntityId, out var pose)
|
|
&& partIndex >= 0
|
|
&& partIndex < pose.Parts.Length)
|
|
{
|
|
partLocal = pose.Parts[partIndex];
|
|
return true;
|
|
}
|
|
partLocal = default;
|
|
return false;
|
|
}
|
|
}
|
|
}
|