From 003c502774a64cbce2ecd1d66ee3fc7b2033d27f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 11 May 2026 14:10:38 +0200 Subject: [PATCH] feat(vfx #C.1.5a): add EntityScriptActivator (no wiring yet) New ~50-line orchestrator that fires Setup.DefaultScript through the already-shipped PhysicsScriptRunner on entity spawn and stops scripts + live emitters on despawn. Resolver delegate avoids DatCollection coupling so the class is fully unit-testable with stubs. Three xUnit tests cover the three branches: fire-with-script, no-op-without-script, stop-on-remove. No wiring into the live spawn path yet -- that lands in the next commit. Spec: docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Rendering/Vfx/EntityScriptActivator.cs | 86 ++++++++++++++ .../Vfx/EntityScriptActivatorTests.cs | 110 ++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs create mode 100644 tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs 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); + } +}