From 5ca5827abee7d1c8c2bdfc7c123d9cfc3a90f85d Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 12 May 2026 00:02:16 +0200 Subject: [PATCH] feat(vfx #C.1.5b): activator handles dat-hydrated entities + per-part transforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolver returns ScriptActivationInfo(ScriptId, PartTransforms) — one dat lookup per spawn yields both pieces of info. The C.1.5a ServerGuid==0 guard is relaxed: activator now keys by ServerGuid when nonzero, else entity.Id, so dat-hydrated entities (EnvCell statics, exterior stabs) flow through the same code path as server-spawned ones. PartTransforms pushed into ParticleHookSink before scheduling Play, closing the activator side of #56. GameWindow resolver lambda upgraded: now constructs ScriptActivationInfo from setup.DefaultScript.DataId + SetupPartTransforms.Compute(setup), swallowing dat-lookup throws the same way C.1.5a did. Tests: 4 existing tests updated for new ScriptActivationInfo signature; 3 new tests cover entity.Id keying for dat-hydrated entities, end-to-end part-transform pipeline (resolver → sink → particle world position), and OnRemove with an arbitrary caller-picked key. 77 Vfx+Meshing+Activator tests green. GpuWorldState fire-site wiring (Task 4) lands next. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 26 ++-- .../Rendering/Vfx/EntityScriptActivator.cs | 118 +++++++++++------ .../Vfx/EntityScriptActivatorTests.cs | 125 +++++++++++++++++- 3 files changed, 216 insertions(+), 53 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e679b92..e079bbd 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1614,26 +1614,32 @@ public sealed class GameWindow : IDisposable _textureCache!, SequencerFactory, _wbMeshAdapter!); _wbEntitySpawnAdapter = wbEntitySpawnAdapter; - // Phase C.1.5a: construct EntityScriptActivator so server-spawned static - // entities (portals first) fire Setup.DefaultScript through the - // PhysicsScriptRunner on enter-world. _scriptRunner and _particleSink - // are initialised earlier in OnLoad (line ~1083); both are non-null - // here. The resolver lambda captures _dats and swallows dat-lookup - // throws — see C.1.5a spec §6 (error handling) for rationale. - uint ResolveDefaultScript(AcDream.Core.World.WorldEntity e) + // Phase C.1.5a/b: construct EntityScriptActivator so static entities + // (server-spawned AND dat-hydrated) fire Setup.DefaultScript through + // the PhysicsScriptRunner on enter-world. C.1.5b adds per-part + // transforms via SetupPartTransforms.Compute so multi-emitter scripts + // distribute across mesh parts (closes #56). _scriptRunner and + // _particleSink are initialised earlier in OnLoad (line ~1083); both + // are non-null here. The resolver lambda captures _dats and swallows + // dat-lookup throws — see C.1.5a spec §6 (error handling) for rationale. + AcDream.App.Rendering.Vfx.ScriptActivationInfo? ResolveActivation(AcDream.Core.World.WorldEntity e) { try { var setup = capturedDats?.Get(e.SourceGfxObjOrSetupId); - return setup?.DefaultScript.DataId ?? 0u; + if (setup is null) return null; + uint scriptId = setup.DefaultScript.DataId; + if (scriptId == 0) return null; + var parts = AcDream.Core.Meshing.SetupPartTransforms.Compute(setup); + return new AcDream.App.Rendering.Vfx.ScriptActivationInfo(scriptId, parts); } catch { - return 0u; + return null; } } var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator( - _scriptRunner!, _particleSink!, ResolveDefaultScript); + _scriptRunner!, _particleSink!, ResolveActivation); _entityScriptActivator = entityScriptActivator; // Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock diff --git a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs index ad14615..a8b0d2b 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs @@ -1,93 +1,133 @@ using System; +using System.Collections.Generic; +using System.Numerics; using AcDream.Core.Vfx; using AcDream.Core.World; namespace AcDream.App.Rendering.Vfx; +/// +/// What the activator's resolver returns when an entity's Setup carries +/// a DefaultScript. Bundles the script id with the per-part +/// transforms baked from Setup.PlacementFrames so a single dat +/// lookup yields both pieces of state. The activator pushes the part +/// transforms into +/// before calling , which closes +/// the part-anchor pipeline introduced for issue #56. +/// +public sealed record ScriptActivationInfo( + uint ScriptId, + IReadOnlyList PartTransforms); + /// /// 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. +/// when a enters the world, so static objects +/// (portals, chimneys, fireplaces, EnvCell decorations, building details) +/// emit their retail-faithful persistent particle effects automatically. +/// Stops the scripts and live emitters when the entity despawns. +/// +/// +/// Handles both server-spawned entities (ServerGuid != 0, keyed by +/// ServerGuid) and dat-hydrated entities (ServerGuid == 0, keyed by +/// entity.Id). The C.1.5a guard that early-returned for +/// ServerGuid == 0 was relaxed in C.1.5b so EnvCell static objects +/// (which have no server guid because they come from the dat file, not +/// the network) also fire their DefaultScript. +/// /// /// /// Wires alongside EntitySpawnAdapter in GpuWorldState: the -/// adapter handles meshes + animation state, the activator handles scripts + -/// particles. Both are render-thread-only. +/// adapter handles meshes + animation state, the activator handles scripts +/// + particles. Both are render-thread-only. The activator is invoked from +/// four GpuWorldState fire-sites (AppendLiveEntity, AddLandblock, +/// AddEntitiesToExistingLandblock, plus the matching remove paths). /// /// /// /// 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. +/// 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; + private readonly Func _resolver; /// 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. + /// activator pushes per-entity rotation + part transforms here, and + /// calls to drop + /// per-entity emitter handles on despawn. + /// Returns + /// with the entity's + /// Setup.DefaultScript.DataId and per-part transforms (via + /// SetupPartTransforms.Compute), or null on dat miss / + /// throw / missing DefaultScript. Production lambda hits + /// DatCollection; tests pass a hand-rolled stub. public EntityScriptActivator( PhysicsScriptRunner scriptRunner, ParticleHookSink particleSink, - Func defaultScriptResolver) + Func resolver) { ArgumentNullException.ThrowIfNull(scriptRunner); ArgumentNullException.ThrowIfNull(particleSink); - ArgumentNullException.ThrowIfNull(defaultScriptResolver); + ArgumentNullException.ThrowIfNull(resolver); _scriptRunner = scriptRunner; _particleSink = particleSink; - _defaultScriptResolver = defaultScriptResolver; + _resolver = resolver; } /// /// 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). + /// the script runner. Keys by entity.ServerGuid when non-zero, + /// otherwise by entity.Id (the latter handles dat-hydrated + /// EnvCell statics + exterior stabs whose entity.Id lives in + /// the 0x40xxxxxx range — collision-free with server guids). + /// No-op if the entity has no DefaultScript (resolver returns null + /// or zero-script). /// public void OnCreate(WorldEntity entity) { ArgumentNullException.ThrowIfNull(entity); - if (entity.ServerGuid == 0) return; + uint key = entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id; + if (key == 0) return; // malformed entity - uint scriptId = _defaultScriptResolver(entity); - if (scriptId == 0) return; + var info = _resolver(entity); + if (info is null || info.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); + // hook fires. C.1.5a fix: 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. + _particleSink.SetEntityRotation(key, entity.Rotation); - _scriptRunner.Play(scriptId, entity.ServerGuid, entity.Position); + // C.1.5b #56: seed the sink's per-entity part transforms so + // CreateParticleHook.PartIndex routes the hook offset through the + // right mesh part's resting transform. Without this, every emitter + // in a multi-part Setup collapses to the entity root. + _particleSink.SetEntityPartTransforms(key, info.PartTransforms); + + _scriptRunner.Play(info.ScriptId, key, 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). + /// Stop every script instance the runner is tracking for this key, and + /// kill every live emitter the sink has attributed to it. Caller picks + /// the key (the matching ServerGuid for live entities, or + /// entity.Id for dat-hydrated entities — mirror whatever was + /// used at ). Idempotent for unknown keys. /// - public void OnRemove(uint serverGuid) + public void OnRemove(uint key) { - if (serverGuid == 0) return; - _scriptRunner.StopAllForEntity(serverGuid); - _particleSink.StopAllForEntity(serverGuid, fadeOut: false); + if (key == 0) return; + _scriptRunner.StopAllForEntity(key); + _particleSink.StopAllForEntity(key, fadeOut: false); } } diff --git a/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs b/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs index e1e75f9..4a5bd3f 100644 --- a/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs @@ -59,12 +59,21 @@ public sealed class EntityScriptActivatorTests return new Pipeline(system, hookSink, runner, recording); } + /// + /// Convenience: a resolver that always returns the given scriptId with + /// an empty part-transforms list (the C.1.5a-equivalent — no per-part + /// math). Useful for tests that exercise the scheduler without caring + /// about #56's per-part pipeline. + /// + private static System.Func StaticResolver(uint scriptId) + => _ => new ScriptActivationInfo(scriptId, System.Array.Empty()); + [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 activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu)); var entity = MakeEntity(serverGuid: 0xCAFEu, position: new Vector3(1, 2, 3)); activator.OnCreate(entity); @@ -80,7 +89,7 @@ public sealed class EntityScriptActivatorTests public void OnCreate_WithoutDefaultScript_DoesNothing() { var p = BuildPipeline(); // no scripts registered - var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0u); + var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => null); var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero); activator.OnCreate(entity); @@ -143,7 +152,7 @@ public sealed class EntityScriptActivatorTests id => table.TryGetValue(id, out var s) ? s : null, hookSink); - var activator = new EntityScriptActivator(runner, hookSink, _ => 0xAAu); + var activator = new EntityScriptActivator(runner, hookSink, StaticResolver(0xAAu)); // Entity rotated 90° around world-Z (yaw left); local +X maps to world +Y. var entityRotation = Quaternion.CreateFromAxisAngle( @@ -191,7 +200,7 @@ public sealed class EntityScriptActivatorTests 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 activator = new EntityScriptActivator(runner, hookSink, StaticResolver(0xAAu)); var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero); activator.OnCreate(entity); @@ -207,4 +216,112 @@ public sealed class EntityScriptActivatorTests system.Tick(0.01f); Assert.Equal(0, system.ActiveEmitterCount); } + + [Fact] + public void OnCreate_KeysByEntityId_WhenServerGuidZero() + { + // C.1.5b: dat-hydrated EnvCell statics + exterior stabs have + // ServerGuid == 0 but a stable entity.Id in the 0x40xxxxxx range. + // OnCreate must use entity.Id as the key (not skip). + var p = BuildPipeline( + (0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 })))); + var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu)); + var entity = new WorldEntity + { + Id = 0x40A9B401u, // dat-hydrated interior id + ServerGuid = 0u, // no server guid + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(5, 5, 5), + Rotation = Quaternion.Identity, + MeshRefs = System.Array.Empty(), + }; + + activator.OnCreate(entity); + + Assert.Equal(1, p.Runner.ActiveScriptCount); + p.Runner.Tick(0.001f); + Assert.Single(p.Recording.Calls); + Assert.Equal(0x40A9B401u, p.Recording.Calls[0].EntityId); + Assert.Equal(new Vector3(5, 5, 5), p.Recording.Calls[0].Pos); + } + + [Fact] + public void OnCreate_PassesPartTransformsToSink() + { + // C.1.5b #56: end-to-end test that the activator pushes the + // resolver's PartTransforms into the sink, and the sink applies + // them. Part 1 lifted +Z=1; hookOffset (1,0,0) with PartIndex=1 + // + identity rotation → expected world (1, 0, 1). + var registry = new EmitterDescRegistry(); + registry.Register(BuildPersistentEmitterDesc()); + var system = new ParticleSystem(registry); + var hookSink = new ParticleHookSink(system); + + var hookOffset = new Frame { Origin = new Vector3(1f, 0, 0), Orientation = Quaternion.Identity }; + var script = BuildScript( + (0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = hookOffset, PartIndex = 1 })); + var table = new Dictionary { [0xAAu] = script }; + var runner = new PhysicsScriptRunner( + id => table.TryGetValue(id, out var s) ? s : null, + hookSink); + + var partTransforms = new Matrix4x4[] + { + Matrix4x4.Identity, + Matrix4x4.CreateTranslation(0f, 0f, 1f), + }; + + var activator = new EntityScriptActivator(runner, hookSink, + _ => new ScriptActivationInfo(0xAAu, partTransforms)); + var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero); + + activator.OnCreate(entity); + runner.Tick(0.001f); + system.Tick(0.001f); + + var live = system.EnumerateLive().FirstOrDefault(); + Assert.NotNull(live.Emitter); + var pos = live.Emitter.Particles[live.Index].Position; + Assert.InRange(pos.X, 0.99f, 1.01f); + Assert.InRange(pos.Y, -0.01f, 0.01f); + Assert.InRange(pos.Z, 0.99f, 1.01f); + } + + [Fact] + public void OnRemove_StopsByGivenKey_ForDatHydratedEntity() + { + // C.1.5b: caller passes the entity.Id as the key for dat-hydrated + // entities (not ServerGuid). OnRemove must clean up correctly. + 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 { [0xAAu] = script }; + var runner = new PhysicsScriptRunner( + id => table.TryGetValue(id, out var s) ? s : null, + hookSink); + + var activator = new EntityScriptActivator(runner, hookSink, StaticResolver(0xAAu)); + var entity = new WorldEntity + { + Id = 0x40A9B402u, + ServerGuid = 0u, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = System.Array.Empty(), + }; + + activator.OnCreate(entity); + runner.Tick(0.001f); + Assert.True(system.ActiveEmitterCount > 0); + + activator.OnRemove(0x40A9B402u); // caller passes the entity.Id key + + Assert.Equal(0, runner.ActiveScriptCount); + system.Tick(0.01f); + Assert.Equal(0, system.ActiveEmitterCount); + } }