diff --git a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs index 828ccae..ad14615 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs @@ -68,6 +68,14 @@ public sealed class EntityScriptActivator uint scriptId = _defaultScriptResolver(entity); if (scriptId == 0) return; + // Seed the sink's per-entity rotation so CreateParticleHook.Offset.Origin + // (in entity-local frame) transforms correctly to world space when the + // hook fires. Without this, the sink falls through to Quaternion.Identity + // and the offset gets applied in world axes — visual symptom for portals: + // swirl oriented along world XYZ instead of the portal's facing, partially + // buried because the local-Z lift becomes a world-axis offset. + _particleSink.SetEntityRotation(entity.ServerGuid, entity.Rotation); + _scriptRunner.Play(scriptId, entity.ServerGuid, entity.Position); } diff --git a/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs b/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs index d835e8c..e1e75f9 100644 --- a/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs @@ -114,6 +114,64 @@ public sealed class EntityScriptActivatorTests 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 { [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(), + }; + + 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() {