feat(vfx #C.1.5b): activator handles dat-hydrated entities + per-part transforms

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-12 00:02:16 +02:00
parent 11521f4418
commit 5ca5827abe
3 changed files with 216 additions and 53 deletions

View file

@ -1614,26 +1614,32 @@ public sealed class GameWindow : IDisposable
_textureCache!, SequencerFactory, _wbMeshAdapter!); _textureCache!, SequencerFactory, _wbMeshAdapter!);
_wbEntitySpawnAdapter = wbEntitySpawnAdapter; _wbEntitySpawnAdapter = wbEntitySpawnAdapter;
// Phase C.1.5a: construct EntityScriptActivator so server-spawned static // Phase C.1.5a/b: construct EntityScriptActivator so static entities
// entities (portals first) fire Setup.DefaultScript through the // (server-spawned AND dat-hydrated) fire Setup.DefaultScript through
// PhysicsScriptRunner on enter-world. _scriptRunner and _particleSink // the PhysicsScriptRunner on enter-world. C.1.5b adds per-part
// are initialised earlier in OnLoad (line ~1083); both are non-null // transforms via SetupPartTransforms.Compute so multi-emitter scripts
// here. The resolver lambda captures _dats and swallows dat-lookup // distribute across mesh parts (closes #56). _scriptRunner and
// throws — see C.1.5a spec §6 (error handling) for rationale. // _particleSink are initialised earlier in OnLoad (line ~1083); both
uint ResolveDefaultScript(AcDream.Core.World.WorldEntity e) // 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 try
{ {
var setup = capturedDats?.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId); var setup = capturedDats?.Get<DatReaderWriter.DBObjs.Setup>(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 catch
{ {
return 0u; return null;
} }
} }
var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator( var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator(
_scriptRunner!, _particleSink!, ResolveDefaultScript); _scriptRunner!, _particleSink!, ResolveActivation);
_entityScriptActivator = entityScriptActivator; _entityScriptActivator = entityScriptActivator;
// Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock // Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock

View file

@ -1,93 +1,133 @@
using System; using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Vfx; using AcDream.Core.Vfx;
using AcDream.Core.World; using AcDream.Core.World;
namespace AcDream.App.Rendering.Vfx; namespace AcDream.App.Rendering.Vfx;
/// <summary>
/// What the activator's resolver returns when an entity's Setup carries
/// a <c>DefaultScript</c>. Bundles the script id with the per-part
/// transforms baked from <c>Setup.PlacementFrames</c> so a single dat
/// lookup yields both pieces of state. The activator pushes the part
/// transforms into <see cref="ParticleHookSink.SetEntityPartTransforms"/>
/// before calling <see cref="PhysicsScriptRunner.Play"/>, which closes
/// the part-anchor pipeline introduced for issue #56.
/// </summary>
public sealed record ScriptActivationInfo(
uint ScriptId,
IReadOnlyList<Matrix4x4> PartTransforms);
/// <summary> /// <summary>
/// Fires <c>Setup.DefaultScript</c> through <see cref="PhysicsScriptRunner"/> /// Fires <c>Setup.DefaultScript</c> through <see cref="PhysicsScriptRunner"/>
/// when a server-spawned <see cref="WorldEntity"/> enters the world, so static /// when a <see cref="WorldEntity"/> enters the world, so static objects
/// objects (portals, chimneys, fireplaces, building details) emit their /// (portals, chimneys, fireplaces, EnvCell decorations, building details)
/// retail-faithful persistent particle effects automatically. Stops the /// emit their retail-faithful persistent particle effects automatically.
/// scripts and live emitters when the entity despawns. /// Stops the scripts and live emitters when the entity despawns.
///
/// <para>
/// Handles both server-spawned entities (<c>ServerGuid != 0</c>, keyed by
/// ServerGuid) and dat-hydrated entities (<c>ServerGuid == 0</c>, keyed by
/// <c>entity.Id</c>). The C.1.5a guard that early-returned for
/// <c>ServerGuid == 0</c> 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.
/// </para>
/// ///
/// <para> /// <para>
/// Wires alongside <c>EntitySpawnAdapter</c> in <c>GpuWorldState</c>: the /// Wires alongside <c>EntitySpawnAdapter</c> in <c>GpuWorldState</c>: the
/// adapter handles meshes + animation state, the activator handles scripts + /// adapter handles meshes + animation state, the activator handles scripts
/// particles. Both are render-thread-only. /// + particles. Both are render-thread-only. The activator is invoked from
/// four GpuWorldState fire-sites (AppendLiveEntity, AddLandblock,
/// AddEntitiesToExistingLandblock, plus the matching remove paths).
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// Retail oracle: <c>play_script_internal(setup.DefaultScript)</c> is what /// Retail oracle: <c>play_script_internal(setup.DefaultScript)</c> is what
/// retail's <c>CPhysicsObj</c> invokes at object load (see Phase C.1 plan §C.1 /// retail's <c>CPhysicsObj</c> invokes at object load (see Phase C.1 plan
/// and <c>memory/project_sky_pes_port.md</c>). C.1 already shipped the runner; /// §C.1 and <c>memory/project_sky_pes_port.md</c>). C.1 already shipped the
/// this class adds the missing fire-on-spawn call site. /// runner; this class adds the missing fire-on-spawn call site.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class EntityScriptActivator public sealed class EntityScriptActivator
{ {
private readonly PhysicsScriptRunner _scriptRunner; private readonly PhysicsScriptRunner _scriptRunner;
private readonly ParticleHookSink _particleSink; private readonly ParticleHookSink _particleSink;
private readonly Func<WorldEntity, uint> _defaultScriptResolver; private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;
/// <param name="scriptRunner">Already-shipped runner from C.1. Owns the /// <param name="scriptRunner">Already-shipped runner from C.1. Owns the
/// (scriptId, entityId) instance table and schedules hooks at their /// (scriptId, entityId) instance table and schedules hooks at their
/// <c>StartTime</c> offsets.</param> /// <c>StartTime</c> offsets.</param>
/// <param name="particleSink">Already-shipped hook sink from C.1. The /// <param name="particleSink">Already-shipped hook sink from C.1. The
/// activator only calls its <see cref="ParticleHookSink.StopAllForEntity"/> /// activator pushes per-entity rotation + part transforms here, and
/// to drop any per-entity emitter handles on despawn.</param> /// calls <see cref="ParticleHookSink.StopAllForEntity"/> to drop
/// <param name="defaultScriptResolver">Returns /// per-entity emitter handles on despawn.</param>
/// <c>entity.SourceGfxObjOrSetupId</c>'s <c>Setup.DefaultScript.DataId</c>, /// <param name="resolver">Returns
/// or <c>0</c> on miss / dat throw / missing field. Production lambda hits /// <see cref="ScriptActivationInfo"/> with the entity's
/// <see cref="DatReaderWriter.DatCollection"/>; tests pass a hand-rolled /// <c>Setup.DefaultScript.DataId</c> and per-part transforms (via
/// stub.</param> /// <c>SetupPartTransforms.Compute</c>), or <c>null</c> on dat miss /
/// throw / missing DefaultScript. Production lambda hits
/// <c>DatCollection</c>; tests pass a hand-rolled stub.</param>
public EntityScriptActivator( public EntityScriptActivator(
PhysicsScriptRunner scriptRunner, PhysicsScriptRunner scriptRunner,
ParticleHookSink particleSink, ParticleHookSink particleSink,
Func<WorldEntity, uint> defaultScriptResolver) Func<WorldEntity, ScriptActivationInfo?> resolver)
{ {
ArgumentNullException.ThrowIfNull(scriptRunner); ArgumentNullException.ThrowIfNull(scriptRunner);
ArgumentNullException.ThrowIfNull(particleSink); ArgumentNullException.ThrowIfNull(particleSink);
ArgumentNullException.ThrowIfNull(defaultScriptResolver); ArgumentNullException.ThrowIfNull(resolver);
_scriptRunner = scriptRunner; _scriptRunner = scriptRunner;
_particleSink = particleSink; _particleSink = particleSink;
_defaultScriptResolver = defaultScriptResolver; _resolver = resolver;
} }
/// <summary> /// <summary>
/// Resolve the entity's <c>Setup.DefaultScript</c> and fire it through /// Resolve the entity's <c>Setup.DefaultScript</c> and fire it through
/// the script runner. No-op if the entity has no DefaultScript /// the script runner. Keys by <c>entity.ServerGuid</c> when non-zero,
/// (resolver returns 0) or if the entity has no server guid /// otherwise by <c>entity.Id</c> (the latter handles dat-hydrated
/// (atlas-tier entities are out of scope for this activator). /// EnvCell statics + exterior stabs whose <c>entity.Id</c> lives in
/// the <c>0x40xxxxxx</c> range — collision-free with server guids).
/// No-op if the entity has no DefaultScript (resolver returns null
/// or zero-script).
/// </summary> /// </summary>
public void OnCreate(WorldEntity entity) public void OnCreate(WorldEntity entity)
{ {
ArgumentNullException.ThrowIfNull(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); var info = _resolver(entity);
if (scriptId == 0) return; if (info is null || info.ScriptId == 0) return;
// Seed the sink's per-entity rotation so CreateParticleHook.Offset.Origin // Seed the sink's per-entity rotation so CreateParticleHook.Offset.Origin
// (in entity-local frame) transforms correctly to world space when the // (in entity-local frame) transforms correctly to world space when the
// hook fires. Without this, the sink falls through to Quaternion.Identity // hook fires. C.1.5a fix: without this, the sink falls through to
// and the offset gets applied in world axes — visual symptom for portals: // Quaternion.Identity and the offset gets applied in world axes —
// swirl oriented along world XYZ instead of the portal's facing, partially // visual symptom for portals: swirl oriented along world XYZ instead
// buried because the local-Z lift becomes a world-axis offset. // of the portal's facing, partially buried.
_particleSink.SetEntityRotation(entity.ServerGuid, entity.Rotation); _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);
} }
/// <summary> /// <summary>
/// Stop every script instance the runner is tracking for this entity, and /// Stop every script instance the runner is tracking for this key, and
/// kill every live emitter the sink has attributed to it. Idempotent for /// kill every live emitter the sink has attributed to it. Caller picks
/// unknown guids (both calls no-op). /// the key (the matching ServerGuid for live entities, or
/// <c>entity.Id</c> for dat-hydrated entities — mirror whatever was
/// used at <see cref="OnCreate"/>). Idempotent for unknown keys.
/// </summary> /// </summary>
public void OnRemove(uint serverGuid) public void OnRemove(uint key)
{ {
if (serverGuid == 0) return; if (key == 0) return;
_scriptRunner.StopAllForEntity(serverGuid); _scriptRunner.StopAllForEntity(key);
_particleSink.StopAllForEntity(serverGuid, fadeOut: false); _particleSink.StopAllForEntity(key, fadeOut: false);
} }
} }

View file

@ -59,12 +59,21 @@ public sealed class EntityScriptActivatorTests
return new Pipeline(system, hookSink, runner, recording); return new Pipeline(system, hookSink, runner, recording);
} }
/// <summary>
/// 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.
/// </summary>
private static System.Func<WorldEntity, ScriptActivationInfo?> StaticResolver(uint scriptId)
=> _ => new ScriptActivationInfo(scriptId, System.Array.Empty<Matrix4x4>());
[Fact] [Fact]
public void OnCreate_WithDefaultScript_FiresRunnerWithEntityGuidAndPosition() public void OnCreate_WithDefaultScript_FiresRunnerWithEntityGuidAndPosition()
{ {
var p = BuildPipeline( var p = BuildPipeline(
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 })))); (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)); var entity = MakeEntity(serverGuid: 0xCAFEu, position: new Vector3(1, 2, 3));
activator.OnCreate(entity); activator.OnCreate(entity);
@ -80,7 +89,7 @@ public sealed class EntityScriptActivatorTests
public void OnCreate_WithoutDefaultScript_DoesNothing() public void OnCreate_WithoutDefaultScript_DoesNothing()
{ {
var p = BuildPipeline(); // no scripts registered 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); var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
activator.OnCreate(entity); activator.OnCreate(entity);
@ -143,7 +152,7 @@ public sealed class EntityScriptActivatorTests
id => table.TryGetValue(id, out var s) ? s : null, id => table.TryGetValue(id, out var s) ? s : null,
hookSink); 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. // Entity rotated 90° around world-Z (yaw left); local +X maps to world +Y.
var entityRotation = Quaternion.CreateFromAxisAngle( var entityRotation = Quaternion.CreateFromAxisAngle(
@ -191,7 +200,7 @@ public sealed class EntityScriptActivatorTests
id => table.TryGetValue(id, out var s) ? s : null, id => table.TryGetValue(id, out var s) ? s : null,
hookSink); // runner dispatches into real sink, not RecordingSink 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); var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
activator.OnCreate(entity); activator.OnCreate(entity);
@ -207,4 +216,112 @@ public sealed class EntityScriptActivatorTests
system.Tick(0.01f); system.Tick(0.01f);
Assert.Equal(0, system.ActiveEmitterCount); 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<MeshRef>(),
};
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<uint, DatPhysicsScript> { [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<uint, DatPhysicsScript> { [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<MeshRef>(),
};
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);
}
} }