fix(vfx #C.1.5a): seed entity rotation in activator so hook offset rotates

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>
This commit is contained in:
Erik 2026-05-11 15:56:27 +02:00
parent 849690c814
commit 334f0c6a26
2 changed files with 66 additions and 0 deletions

View file

@ -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);
}

View file

@ -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<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()
{