Visual verification at the Holtburg Town network portal revealed the swirl was oriented along world axes (NS) instead of the portal's actual facing (EW), and partially buried in the ground because the hook's local-frame Offset.Origin was being applied in world axes too. Root cause: EntityScriptActivator.OnCreate fired _scriptRunner.Play but never called _particleSink.SetEntityRotation. When the runner's CreateParticleHook fires, the sink reads per-entity rotation from _rotationByEntity (defaults to Quaternion.Identity for unknown entities) and uses it to transform the hook's Offset.Origin from entity-local to world space. Without the seed call, the rotation lookup falls through to Identity and the offset goes off along world XYZ. Fix is a single SetEntityRotation call before the Play call. Added a 4th unit test that constructs an entity with a 90 deg yaw and asserts the spawned particle's world position reflects the rotated offset. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
210 lines
8.3 KiB
C#
210 lines
8.3 KiB
C#
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
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.Core.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);
|
|
}
|
|
|
|
[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, _ => 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, _ => 0u);
|
|
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, _ => 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, _ => 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(0xCAFEu);
|
|
|
|
Assert.Equal(0, runner.ActiveScriptCount);
|
|
// sink.StopAllForEntity marks the emitter Finished; system.Tick reaps it.
|
|
system.Tick(0.01f);
|
|
Assert.Equal(0, system.ActiveEmitterCount);
|
|
}
|
|
}
|