diff --git a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs
new file mode 100644
index 0000000..7211f85
--- /dev/null
+++ b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs
@@ -0,0 +1,86 @@
+using System;
+using System.Numerics;
+using AcDream.Core.Vfx;
+using AcDream.Core.World;
+
+namespace AcDream.App.Rendering.Vfx;
+
+///
+/// Fires Setup.DefaultScript through
+/// when a server-spawned enters the world, so static
+/// objects (portals, chimneys, fireplaces, building details) emit their
+/// retail-faithful persistent particle effects automatically. Stops the
+/// scripts and live emitters when the entity despawns.
+///
+///
+/// Wires alongside EntitySpawnAdapter in GpuWorldState: the
+/// adapter handles meshes + animation state, the activator handles scripts +
+/// particles. Both are render-thread-only.
+///
+///
+///
+/// Retail oracle: play_script_internal(setup.DefaultScript) is what
+/// retail's CPhysicsObj invokes at object load (see Phase C.1 plan §C.1
+/// and memory/project_sky_pes_port.md). C.1 already shipped the runner;
+/// this class adds the missing fire-on-spawn call site.
+///
+///
+public sealed class EntityScriptActivator
+{
+ private readonly PhysicsScriptRunner _scriptRunner;
+ private readonly ParticleHookSink _particleSink;
+ private readonly Func _defaultScriptResolver;
+
+ /// Already-shipped runner from C.1. Owns the
+ /// (scriptId, entityId) instance table and schedules hooks at their
+ /// StartTime offsets.
+ /// Already-shipped hook sink from C.1. The
+ /// activator only calls its
+ /// to drop any per-entity emitter handles on despawn.
+ /// Returns
+ /// entity.SourceGfxObjOrSetupId's Setup.DefaultScript.DataId,
+ /// or 0 on miss / dat throw / missing field. Production lambda hits
+ /// ; tests pass a hand-rolled
+ /// stub.
+ public EntityScriptActivator(
+ PhysicsScriptRunner scriptRunner,
+ ParticleHookSink particleSink,
+ Func defaultScriptResolver)
+ {
+ ArgumentNullException.ThrowIfNull(scriptRunner);
+ ArgumentNullException.ThrowIfNull(particleSink);
+ ArgumentNullException.ThrowIfNull(defaultScriptResolver);
+ _scriptRunner = scriptRunner;
+ _particleSink = particleSink;
+ _defaultScriptResolver = defaultScriptResolver;
+ }
+
+ ///
+ /// Resolve the entity's Setup.DefaultScript and fire it through
+ /// the script runner. No-op if the entity has no DefaultScript
+ /// (resolver returns 0) or if the entity has no server guid
+ /// (atlas-tier entities are out of scope for this activator).
+ ///
+ public void OnCreate(WorldEntity entity)
+ {
+ ArgumentNullException.ThrowIfNull(entity);
+ if (entity.ServerGuid == 0) return;
+
+ uint scriptId = _defaultScriptResolver(entity);
+ if (scriptId == 0) return;
+
+ _scriptRunner.Play(scriptId, entity.ServerGuid, entity.Position);
+ }
+
+ ///
+ /// Stop every script instance the runner is tracking for this entity, and
+ /// kill every live emitter the sink has attributed to it. Idempotent for
+ /// unknown guids (both calls no-op).
+ ///
+ public void OnRemove(uint serverGuid)
+ {
+ if (serverGuid == 0) return;
+ _scriptRunner.StopAllForEntity(serverGuid);
+ _particleSink.StopAllForEntity(serverGuid, fadeOut: false);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs b/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs
new file mode 100644
index 0000000..d31ca29
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs
@@ -0,0 +1,110 @@
+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
+{
+ /// Recording sink so we can assert which hooks the runner fires.
+ 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(),
+ };
+
+ 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();
+ 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);
+ }
+
+ [Fact]
+ public void OnRemove_StopsScriptsAndEmitters()
+ {
+ 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: Vector3.Zero);
+
+ activator.OnCreate(entity);
+ Assert.Equal(1, p.Runner.ActiveScriptCount);
+
+ activator.OnRemove(0xCAFEu);
+
+ Assert.Equal(0, p.Runner.ActiveScriptCount);
+ // Tick after Remove must not surface any further hook fires.
+ p.Runner.Tick(1.0f);
+ Assert.Empty(p.Recording.Calls);
+ }
+}