feat(vfx): port retail effect scheduling and delivery
This commit is contained in:
parent
363e046112
commit
96ddfdf175
28 changed files with 2473 additions and 691 deletions
|
|
@ -0,0 +1,378 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Vfx;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Vfx;
|
||||
|
||||
public sealed class EntityScriptActivatorTests
|
||||
{
|
||||
/// <summary>Recording sink so we can assert which hooks the runner fires.</summary>
|
||||
private sealed class RecordingSink : IAnimationHookSink
|
||||
{
|
||||
public List<(uint EntityId, Vector3 Pos, AnimationHook Hook)> Calls = new();
|
||||
public void OnHook(uint entityId, Vector3 worldPos, AnimationHook hook)
|
||||
=> Calls.Add((entityId, worldPos, hook));
|
||||
}
|
||||
|
||||
private static DatPhysicsScript BuildScript(params (double time, AnimationHook hook)[] items)
|
||||
{
|
||||
var script = new DatPhysicsScript();
|
||||
foreach (var (t, h) in items)
|
||||
script.ScriptData.Add(new PhysicsScriptData { StartTime = t, Hook = h });
|
||||
return script;
|
||||
}
|
||||
|
||||
private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) =>
|
||||
new()
|
||||
{
|
||||
Id = serverGuid,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = position,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
private record Pipeline(
|
||||
ParticleSystem System,
|
||||
ParticleHookSink Sink,
|
||||
PhysicsScriptRunner Runner,
|
||||
RecordingSink Recording);
|
||||
|
||||
private static Pipeline BuildPipeline(params (uint id, DatPhysicsScript script)[] scripts)
|
||||
{
|
||||
var registry = new EmitterDescRegistry();
|
||||
var system = new ParticleSystem(registry);
|
||||
var hookSink = new ParticleHookSink(system); // for activator's StopAllForEntity
|
||||
var recording = new RecordingSink(); // for runner's hook dispatch
|
||||
var table = new Dictionary<uint, DatPhysicsScript>();
|
||||
foreach (var (id, s) in scripts) table[id] = s;
|
||||
var runner = new PhysicsScriptRunner(
|
||||
id => table.TryGetValue(id, out var s) ? s : null,
|
||||
recording);
|
||||
return new Pipeline(system, hookSink, runner, recording);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience: a resolver that always returns the given scriptId with
|
||||
/// an empty part-transforms list (the C.1.5a-equivalent — no per-part
|
||||
/// math). Useful for tests that exercise the scheduler without caring
|
||||
/// about #56's per-part pipeline.
|
||||
/// </summary>
|
||||
private static System.Func<WorldEntity, ScriptActivationInfo?> StaticResolver(uint scriptId)
|
||||
=> _ => new ScriptActivationInfo(scriptId, System.Array.Empty<Matrix4x4>());
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_WithDefaultScript_FiresRunnerWithEntityGuidAndPosition()
|
||||
{
|
||||
var p = BuildPipeline(
|
||||
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 }))));
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
|
||||
var entity = MakeEntity(serverGuid: 0xCAFEu, position: new Vector3(1, 2, 3));
|
||||
|
||||
activator.OnCreate(entity);
|
||||
|
||||
Assert.Equal(1, p.Runner.ActiveScriptCount);
|
||||
p.Runner.Tick(0.001f);
|
||||
Assert.Single(p.Recording.Calls);
|
||||
Assert.Equal(0xCAFEu, p.Recording.Calls[0].EntityId);
|
||||
Assert.Equal(new Vector3(1, 2, 3), p.Recording.Calls[0].Pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_WithoutDefaultScript_DoesNothing()
|
||||
{
|
||||
var p = BuildPipeline(); // no scripts registered
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => null);
|
||||
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
|
||||
|
||||
activator.OnCreate(entity);
|
||||
|
||||
Assert.Equal(0, p.Runner.ActiveScriptCount);
|
||||
Assert.Empty(p.Recording.Calls);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persistent emitter: TotalDuration=0 and TotalParticles=0 prevent
|
||||
/// auto-finish; InitialParticles=1 ensures a particle spawns at t=0
|
||||
/// without waiting for the Birthrate timer; Lifespan=999f keeps that
|
||||
/// particle alive far past the test horizon.
|
||||
/// </summary>
|
||||
private static EmitterDesc BuildPersistentEmitterDesc() => new()
|
||||
{
|
||||
DatId = 100u,
|
||||
Type = ParticleType.Still,
|
||||
EmitterKind = ParticleEmitterKind.BirthratePerSec,
|
||||
MaxParticles = 4,
|
||||
InitialParticles = 1,
|
||||
TotalParticles = 0, // 0 = no particle-count cap
|
||||
TotalDuration = 0f, // 0 = no time-based finish
|
||||
Lifespan = 999f,
|
||||
LifetimeMin = 999f,
|
||||
LifetimeMax = 999f,
|
||||
Birthrate = 0.5f,
|
||||
StartSize = 0.5f,
|
||||
EndSize = 0.5f,
|
||||
StartAlpha = 1f,
|
||||
EndAlpha = 1f,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_SetsEntityRotationForHookOffsetTransform()
|
||||
{
|
||||
// The CreateParticleHook's Offset is in entity-local frame; the sink
|
||||
// needs the entity's rotation to transform it to world space. If the
|
||||
// activator forgets SetEntityRotation, the offset goes off in world
|
||||
// axes — visual symptom: portal swirls misaligned to the portal stone.
|
||||
// This test verifies the seed happens by checking the spawned particle's
|
||||
// world position matches the rotated offset, not the unrotated offset.
|
||||
|
||||
var registry = new EmitterDescRegistry();
|
||||
registry.Register(BuildPersistentEmitterDesc());
|
||||
|
||||
var system = new ParticleSystem(registry);
|
||||
var hookSink = new ParticleHookSink(system);
|
||||
|
||||
// Hook offset = (1, 0, 0) in entity-local frame.
|
||||
var hookOffset = new Frame
|
||||
{
|
||||
Origin = new Vector3(1f, 0f, 0f),
|
||||
Orientation = Quaternion.Identity,
|
||||
};
|
||||
var script = BuildScript(
|
||||
(0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = hookOffset }));
|
||||
var table = new Dictionary<uint, DatPhysicsScript> { [0xAAu] = script };
|
||||
var runner = new PhysicsScriptRunner(
|
||||
id => table.TryGetValue(id, out var s) ? s : null,
|
||||
hookSink);
|
||||
|
||||
var activator = new EntityScriptActivator(runner, hookSink, StaticResolver(0xAAu));
|
||||
|
||||
// Entity rotated 90° around world-Z (yaw left); local +X maps to world +Y.
|
||||
var entityRotation = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ, MathF.PI / 2f);
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 0xCAFEu,
|
||||
ServerGuid = 0xCAFEu,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = entityRotation,
|
||||
MeshRefs = System.Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
activator.OnCreate(entity);
|
||||
runner.Tick(0.001f);
|
||||
system.Tick(0.001f);
|
||||
|
||||
// Find the live particle. With the rotation applied, world position of
|
||||
// the local-(1,0,0) offset should be approximately world-(0,1,0). Without
|
||||
// the rotation seed (the bug), it would be world-(1,0,0).
|
||||
var live = system.EnumerateLive().FirstOrDefault();
|
||||
Assert.NotNull(live.Emitter);
|
||||
var worldPos = live.Emitter.Particles[live.Index].Position;
|
||||
Assert.InRange(worldPos.X, -0.01f, 0.01f);
|
||||
Assert.InRange(worldPos.Y, 0.99f, 1.01f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnRemove_StopsScriptsAndEmitters()
|
||||
{
|
||||
// For this test we need the runner to dispatch into the REAL
|
||||
// ParticleHookSink so OnRemove's sink.StopAllForEntity has a live
|
||||
// emitter to kill. This is the only observable way to verify the
|
||||
// call had effect without subclassing the sealed sink.
|
||||
var registry = new EmitterDescRegistry();
|
||||
registry.Register(BuildPersistentEmitterDesc());
|
||||
|
||||
var system = new ParticleSystem(registry);
|
||||
var hookSink = new ParticleHookSink(system);
|
||||
|
||||
var script = BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() }));
|
||||
var table = new Dictionary<uint, DatPhysicsScript> { [0xAAu] = script };
|
||||
var runner = new PhysicsScriptRunner(
|
||||
id => table.TryGetValue(id, out var s) ? s : null,
|
||||
hookSink); // runner dispatches into real sink, not RecordingSink
|
||||
|
||||
var activator = new EntityScriptActivator(runner, hookSink, StaticResolver(0xAAu));
|
||||
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
|
||||
|
||||
activator.OnCreate(entity);
|
||||
runner.Tick(0.001f); // fires the CreateParticleHook → spawns emitter
|
||||
|
||||
Assert.True(system.ActiveEmitterCount > 0,
|
||||
"Setup precondition failed: emitter should be alive after the hook fires.");
|
||||
|
||||
activator.OnRemove(entity);
|
||||
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
// sink.StopAllForEntity marks the emitter Finished; system.Tick reaps it.
|
||||
system.Tick(0.01f);
|
||||
Assert.Equal(0, system.ActiveEmitterCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_UsesCanonicalStaticIdentity_WhenServerGuidZero()
|
||||
{
|
||||
// C.1.5b: dat-hydrated EnvCell statics + exterior stabs have
|
||||
// ServerGuid == 0 but a stable entity.Id in the 0x40xxxxxx range.
|
||||
// OnCreate must use entity.Id as the key (not skip).
|
||||
var p = BuildPipeline(
|
||||
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 }))));
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 0x40A9B401u, // dat-hydrated interior id
|
||||
ServerGuid = 0u, // no server guid
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(5, 5, 5),
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
activator.OnCreate(entity);
|
||||
|
||||
Assert.Equal(1, p.Runner.ActiveScriptCount);
|
||||
p.Runner.Tick(0.001f);
|
||||
Assert.Single(p.Recording.Calls);
|
||||
Assert.Equal(entity.Id, p.Recording.Calls[0].EntityId);
|
||||
Assert.Equal(new Vector3(5, 5, 5), p.Recording.Calls[0].Pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollidingDatEntityIds_FailFastAtAllocatorBoundary()
|
||||
{
|
||||
var p = BuildPipeline(
|
||||
(0xAAu, BuildScript((1.0, new CreateParticleHook { EmitterInfoId = 100 }))));
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
|
||||
WorldEntity first = MakeDatStatic(0x80A9B400u, new Vector3(1, 0, 0));
|
||||
WorldEntity second = MakeDatStatic(0x80A9B400u, new Vector3(2, 0, 0));
|
||||
|
||||
activator.OnCreate(first);
|
||||
InvalidOperationException error = Assert.Throws<InvalidOperationException>(
|
||||
() => activator.OnCreate(second));
|
||||
|
||||
Assert.Contains("globally unique", error.Message, StringComparison.Ordinal);
|
||||
Assert.Equal(1, p.Runner.ActiveOwnerCount);
|
||||
Assert.Equal(first.Id, GameWindow.ParticleEntityKey(first));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiveParticleFilterUsesCanonicalLocalEffectOwnerNotServerGuid()
|
||||
{
|
||||
var p = BuildPipeline();
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => null);
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 1_000_123u,
|
||||
ServerGuid = 0x70000001u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
activator.OnCreate(entity);
|
||||
|
||||
Assert.Equal(entity.Id, GameWindow.ParticleEntityKey(entity));
|
||||
Assert.NotEqual(entity.ServerGuid, GameWindow.ParticleEntityKey(entity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_PassesPartTransformsToSink()
|
||||
{
|
||||
// C.1.5b #56: end-to-end test that the activator pushes the
|
||||
// resolver's PartTransforms into the sink, and the sink applies
|
||||
// them. Part 1 lifted +Z=1; hookOffset (1,0,0) with PartIndex=1
|
||||
// + identity rotation → expected world (1, 0, 1).
|
||||
var registry = new EmitterDescRegistry();
|
||||
registry.Register(BuildPersistentEmitterDesc());
|
||||
var system = new ParticleSystem(registry);
|
||||
var hookSink = new ParticleHookSink(system);
|
||||
|
||||
var hookOffset = new Frame { Origin = new Vector3(1f, 0, 0), Orientation = Quaternion.Identity };
|
||||
var script = BuildScript(
|
||||
(0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = hookOffset, PartIndex = 1 }));
|
||||
var table = new Dictionary<uint, DatPhysicsScript> { [0xAAu] = script };
|
||||
var runner = new PhysicsScriptRunner(
|
||||
id => table.TryGetValue(id, out var s) ? s : null,
|
||||
hookSink);
|
||||
|
||||
var partTransforms = new Matrix4x4[]
|
||||
{
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.CreateTranslation(0f, 0f, 1f),
|
||||
};
|
||||
|
||||
var activator = new EntityScriptActivator(runner, hookSink,
|
||||
_ => new ScriptActivationInfo(0xAAu, partTransforms));
|
||||
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
|
||||
|
||||
activator.OnCreate(entity);
|
||||
runner.Tick(0.001f);
|
||||
system.Tick(0.001f);
|
||||
|
||||
var live = system.EnumerateLive().FirstOrDefault();
|
||||
Assert.NotNull(live.Emitter);
|
||||
var pos = live.Emitter.Particles[live.Index].Position;
|
||||
Assert.InRange(pos.X, 0.99f, 1.01f);
|
||||
Assert.InRange(pos.Y, -0.01f, 0.01f);
|
||||
Assert.InRange(pos.Z, 0.99f, 1.01f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnRemove_StopsReservedOwner_ForDatHydratedEntity()
|
||||
{
|
||||
// C.1.5b: caller passes the entity.Id as the key for dat-hydrated
|
||||
// entities (not ServerGuid). OnRemove must clean up correctly.
|
||||
var registry = new EmitterDescRegistry();
|
||||
registry.Register(BuildPersistentEmitterDesc());
|
||||
var system = new ParticleSystem(registry);
|
||||
var hookSink = new ParticleHookSink(system);
|
||||
|
||||
var script = BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() }));
|
||||
var table = new Dictionary<uint, DatPhysicsScript> { [0xAAu] = script };
|
||||
var runner = new PhysicsScriptRunner(
|
||||
id => table.TryGetValue(id, out var s) ? s : null,
|
||||
hookSink);
|
||||
|
||||
var activator = new EntityScriptActivator(runner, hookSink, StaticResolver(0xAAu));
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 0x40A9B402u,
|
||||
ServerGuid = 0u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
activator.OnCreate(entity);
|
||||
runner.Tick(0.001f);
|
||||
Assert.True(system.ActiveEmitterCount > 0);
|
||||
|
||||
activator.OnRemove(entity);
|
||||
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
system.Tick(0.01f);
|
||||
Assert.Equal(0, system.ActiveEmitterCount);
|
||||
}
|
||||
|
||||
private static WorldEntity MakeDatStatic(uint id, Vector3 position) => new()
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = 0,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = position,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue