# Phase C.1.5b — issue #56 + EnvCell DefaultScript implementation plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal (slice A):** Fix [issue #56](../../ISSUES.md) — `ParticleHookSink` ignores `CreateParticleHook.PartIndex`, causing every emitter in a multi-emitter PES script (portals, fireplaces, chimneys) to collapse to the entity root. Precompute each Setup part's resting transform at activator-spawn time and apply it to the hook offset before spawning the particle. **Goal (slice B):** Fire `Setup.DefaultScript` for dat-hydrated entities (EnvCell statics + exterior stabs) too — not just server-spawned ones. Drop the `EntityScriptActivator.OnCreate` ServerGuid==0 guard and wire OnCreate/OnRemove into GpuWorldState's dat-hydration paths. **Architecture:** No new orchestrator classes. New helper `SetupPartTransforms.Compute(Setup)` in `AcDream.Core.Meshing`. `ParticleHookSink` grows `SetEntityPartTransforms`. `EntityScriptActivator` resolver returns a `ScriptActivationInfo` record bundling scriptId + per-part transforms. `GpuWorldState` fires the activator from four dat-hydration paths (AddLandblock, AddEntitiesToExistingLandblock, RemoveLandblock, RemoveEntitiesFromLandblock). **Tech Stack:** C# / .NET 10, xUnit, Silk.NET (existing). No new dependencies. **Spec:** [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../specs/2026-05-13-phase-c1.5b-design.md). Read it first. --- ## File structure **Created:** - `src/AcDream.Core/Meshing/SetupPartTransforms.cs` — helper that walks Setup.PlacementFrames → list of Matrix4x4 per part. - `tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs` — 4 tests. - `tests/AcDream.Core.Tests/Vfx/ParticleHookSinkPartTransformTests.cs` — 2 tests (new file because the existing tests would otherwise gain unrelated tests). - `tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs` — 5 integration tests for the activator wiring. **Modified:** - `src/AcDream.Core/Vfx/ParticleHookSink.cs` — new `_partTransformsByEntity` map; `SetEntityPartTransforms` method; `SpawnFromHook` applies the part transform; `StopAllForEntity` clears the entry. - `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs` — resolver signature change to `Func`; `ServerGuid==0` guard replaced with `key = ServerGuid != 0 ? ServerGuid : entity.Id`; pushes part transforms to sink; new `ScriptActivationInfo` record alongside the activator. - `tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs` — 4 existing tests updated for new resolver signature; 3 new tests added. - `src/AcDream.App/Streaming/GpuWorldState.cs` — 4 new foreach blocks (one per AddLandblock / AddEntitiesToExistingLandblock / RemoveLandblock / RemoveEntitiesFromLandblock). - `src/AcDream.App/Rendering/GameWindow.cs` — resolver lambda upgraded to return `ScriptActivationInfo?`. - `docs/plans/2026-04-11-roadmap.md` — append "Phase C.1.5b SHIPPED" row on verification pass. - `docs/ISSUES.md` — move #56 to Recently closed. - `CLAUDE.md` — update "Currently in flight" line to point to next phase post-C.1.5b. Each file's responsibility: - `SetupPartTransforms` — pure function; Setup → matrices. No dat lookups, no GL, no entity state. - `ParticleHookSink` — owns per-entity part-transform side-table (mirroring its existing `_rotationByEntity` pattern). Applies the transform inside `SpawnFromHook`. - `EntityScriptActivator` — keys correctly by ServerGuid OR Id; pushes both rotation + part transforms to the sink before scheduling. Knows nothing about dats. - `GpuWorldState` — owns the four new fire-sites. Filters out live entities on the dat-hydration paths (avoid double-fire). - `GameWindow` — wiring root; the resolver lambda is the only place dats touch the activator. --- ## Task 1: `SetupPartTransforms` helper + tests (TDD) **Files:** - Create: `src/AcDream.Core/Meshing/SetupPartTransforms.cs` - Create: `tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs` - [ ] **Step 1.1 — Write the test file with 4 failing tests** ```csharp using System.Collections.Generic; using System.Numerics; using AcDream.Core.Meshing; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; using Xunit; namespace AcDream.Core.Tests.Meshing; public sealed class SetupPartTransformsTests { private static AnimationFrame BuildFrame(params Frame[] frames) { var af = new AnimationFrame(); foreach (var f in frames) af.Frames.Add(f); return af; } private static Setup BuildSetup( int partCount, IReadOnlyDictionary? placementFrames = null, IReadOnlyList? defaultScale = null) { var setup = new Setup(); for (int i = 0; i < partCount; i++) setup.Parts.Add(0x01000001u); if (placementFrames is not null) foreach (var (k, v) in placementFrames) setup.PlacementFrames[k] = v; if (defaultScale is not null) foreach (var s in defaultScale) setup.DefaultScale.Add(s); return setup; } [Fact] public void ResolvesRestingPlacement_WhenAvailable() { var resting = BuildFrame( new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }, new Frame { Origin = new Vector3(0, 0, 1f), Orientation = Quaternion.Identity }); var setup = BuildSetup(partCount: 2, placementFrames: new Dictionary { [Placement.Resting] = resting, [Placement.Default] = BuildFrame(new Frame(), new Frame()), }); var transforms = SetupPartTransforms.Compute(setup); Assert.Equal(2, transforms.Count); var probe = Vector3.Transform(Vector3.Zero, transforms[1]); Assert.Equal(new Vector3(0, 0, 1f), probe); } [Fact] public void FallsBackToDefault_WhenRestingMissing() { var defaultFrame = BuildFrame( new Frame { Origin = new Vector3(2f, 0, 0), Orientation = Quaternion.Identity }); var setup = BuildSetup(partCount: 1, placementFrames: new Dictionary { [Placement.Default] = defaultFrame, }); var transforms = SetupPartTransforms.Compute(setup); Assert.Single(transforms); var probe = Vector3.Transform(Vector3.Zero, transforms[0]); Assert.Equal(new Vector3(2f, 0, 0), probe); } [Fact] public void ReturnsEmpty_WhenNoPlacementFrames() { var setup = BuildSetup(partCount: 2); var transforms = SetupPartTransforms.Compute(setup); Assert.Empty(transforms); } [Fact] public void AppliesDefaultScale_WhenPresent() { var resting = BuildFrame( new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }); var setup = BuildSetup(partCount: 1, placementFrames: new Dictionary { [Placement.Resting] = resting }, defaultScale: new[] { new Vector3(2f, 2f, 2f) }); var transforms = SetupPartTransforms.Compute(setup); var probe = Vector3.Transform(new Vector3(1f, 1f, 1f), transforms[0]); Assert.Equal(new Vector3(2f, 2f, 2f), probe); } } ``` - [ ] **Step 1.2 — Implement `SetupPartTransforms`** `src/AcDream.Core/Meshing/SetupPartTransforms.cs`: ```csharp using System.Collections.Generic; using System.Numerics; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; namespace AcDream.Core.Meshing; /// /// Compute the per-part static transforms for a Setup using its /// PlacementFrames. For each part i, the returned matrix is the /// transform from part-local to setup-local space at the Setup's /// resting pose. Mirrors 's pose-source /// priority: PlacementFrames[Resting] → [Default] → first available. /// Returns an empty list when the Setup has no PlacementFrames /// (caller falls back to "no part transforms applied" — equivalent to /// pre-C.1.5b behavior in ParticleHookSink.SpawnFromHook). /// public static class SetupPartTransforms { public static IReadOnlyList Compute(Setup setup) { AnimationFrame? source = null; if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting)) source = resting; else if (setup.PlacementFrames.TryGetValue(Placement.Default, out var def)) source = def; else { foreach (var kvp in setup.PlacementFrames) { source = kvp.Value; break; } } if (source is null) return System.Array.Empty(); int partCount = setup.Parts.Count; var result = new Matrix4x4[partCount]; for (int i = 0; i < partCount; i++) { Frame frame = i < source.Frames.Count ? source.Frames[i] : new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; Vector3 scale = i < setup.DefaultScale.Count ? setup.DefaultScale[i] : Vector3.One; result[i] = Matrix4x4.CreateScale(scale) * Matrix4x4.CreateFromQuaternion(frame.Orientation) * Matrix4x4.CreateTranslation(frame.Origin); } return result; } } ``` - [ ] **Step 1.3 — Verify** ```pwsh dotnet test --filter "FullyQualifiedName~SetupPartTransformsTests" -c Debug ``` All 4 tests pass. `dotnet build` green. - [ ] **Step 1.4 — Commit** ``` feat(vfx #C.1.5b): SetupPartTransforms helper for per-part anchor transforms Computes Matrix4x4 per Setup part by walking PlacementFrames[Resting] → [Default] → first-available, matching SetupMesh.Flatten's priority. Foundation for #56 fix: ParticleHookSink will use these to apply the hook's PartIndex-relative offset to the right mesh part. ``` --- ## Task 2: `ParticleHookSink` part-transform support + tests **Files:** - Modify: `src/AcDream.Core/Vfx/ParticleHookSink.cs` - Create: `tests/AcDream.Core.Tests/Vfx/ParticleHookSinkPartTransformTests.cs` - [ ] **Step 2.1 — Write the test file with 2 failing tests** ```csharp using System.Collections.Generic; using System.Linq; using System.Numerics; using AcDream.Core.Vfx; using DatReaderWriter.Types; using Xunit; namespace AcDream.Core.Tests.Vfx; public sealed class ParticleHookSinkPartTransformTests { private static EmitterDesc BuildPersistentEmitterDesc() => new() { DatId = 100u, Type = ParticleType.Still, EmitterKind = ParticleEmitterKind.BirthratePerSec, MaxParticles = 4, InitialParticles = 1, TotalParticles = 0, TotalDuration = 0f, Lifespan = 999f, LifetimeMin = 999f, LifetimeMax = 999f, Birthrate = 0.5f, StartSize = 0.5f, EndSize = 0.5f, StartAlpha = 1f, EndAlpha = 1f, }; [Fact] public void AppliesPartTransform_WhenRegistered() { var partTransforms = new Matrix4x4[] { Matrix4x4.Identity, Matrix4x4.CreateTranslation(0f, 0f, 1f), }; var registry = new EmitterDescRegistry(); registry.Register(BuildPersistentEmitterDesc()); var system = new ParticleSystem(registry); var sink = new ParticleHookSink(system); sink.SetEntityRotation(0xCAFEu, Quaternion.Identity); sink.SetEntityPartTransforms(0xCAFEu, partTransforms); sink.OnHook(0xCAFEu, Vector3.Zero, new CreateParticleHook { EmitterInfoId = 100u, PartIndex = 1, Offset = new Frame { Origin = new Vector3(1f, 0f, 0f), Orientation = Quaternion.Identity, }, EmitterId = 0u, }); 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 FallsBackToIdentity_WhenPartIndexOutOfBounds() { var partTransforms = new[] { Matrix4x4.Identity, Matrix4x4.CreateTranslation(0f, 0f, 1f) }; var registry = new EmitterDescRegistry(); registry.Register(BuildPersistentEmitterDesc()); var system = new ParticleSystem(registry); var sink = new ParticleHookSink(system); sink.SetEntityRotation(0xCAFEu, Quaternion.Identity); sink.SetEntityPartTransforms(0xCAFEu, partTransforms); sink.OnHook(0xCAFEu, Vector3.Zero, new CreateParticleHook { EmitterInfoId = 100u, PartIndex = 99, Offset = new Frame { Origin = new Vector3(2f, 0f, 0f), Orientation = Quaternion.Identity }, }); 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, 1.99f, 2.01f); Assert.InRange(pos.Y, -0.01f, 0.01f); Assert.InRange(pos.Z, -0.01f, 0.01f); } } ``` - [ ] **Step 2.2 — Modify `ParticleHookSink`** Add field next to `_rotationByEntity`: ```csharp private readonly ConcurrentDictionary> _partTransformsByEntity = new(); ``` Add method next to `SetEntityRotation`: ```csharp public void SetEntityPartTransforms(uint entityId, IReadOnlyList partTransforms) => _partTransformsByEntity[entityId] = partTransforms; ``` Add cleanup line inside `StopAllForEntity`: ```csharp _partTransformsByEntity.TryRemove(entityId, out _); ``` Modify `SpawnFromHook` — replace the anchor computation: ```csharp var rotation = _rotationByEntity.TryGetValue(entityId, out var rot) ? rot : Quaternion.Identity; Vector3 partLocal = offset; if (_partTransformsByEntity.TryGetValue(entityId, out var pts) && partIndex >= 0 && partIndex < pts.Count) { partLocal = Vector3.Transform(offset, pts[partIndex]); } var anchor = worldPos + Vector3.Transform(partLocal, rotation); ``` - [ ] **Step 2.3 — Verify** ```pwsh dotnet test --filter "FullyQualifiedName~ParticleHookSinkPartTransformTests" -c Debug dotnet test -c Debug ``` Both new tests pass. Existing tests still green (the change is backwards-compatible — entities without registered part transforms fall through to identity, same as before). - [ ] **Step 2.4 — Commit** ``` fix(vfx #56): ParticleHookSink applies CreateParticleHook.PartIndex transform Adds per-entity part-transform side-table mirroring _rotationByEntity. SpawnFromHook now transforms the hook offset through partTransforms[partIndex] before rotating to world space. Backwards-compatible: entities without registered part transforms fall through to identity (pre-C.1.5b behavior). Closes the renderer side of #56. EntityScriptActivator wiring lands next. ``` --- ## Task 3: `EntityScriptActivator` resolver refactor + ServerGuid relaxation + tests **Files:** - Modify: `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs` - Modify: `tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs` - [ ] **Step 3.1 — Add `ScriptActivationInfo` record and update activator** In `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`, add at the top of the namespace (before the activator class): ```csharp public sealed record ScriptActivationInfo( uint ScriptId, IReadOnlyList PartTransforms); ``` Change the resolver field type: ```csharp private readonly Func _resolver; ``` Update ctor parameter name + type accordingly. Replace `OnCreate`: ```csharp public void OnCreate(WorldEntity entity) { ArgumentNullException.ThrowIfNull(entity); uint key = entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id; if (key == 0) return; var info = _resolver(entity); if (info is null || info.ScriptId == 0) return; _particleSink.SetEntityRotation(key, entity.Rotation); _particleSink.SetEntityPartTransforms(key, info.PartTransforms); _scriptRunner.Play(info.ScriptId, key, entity.Position); } ``` Replace `OnRemove`: ```csharp public void OnRemove(uint key) { if (key == 0) return; _scriptRunner.StopAllForEntity(key); _particleSink.StopAllForEntity(key, fadeOut: false); } ``` Update doc comments to reflect the new key semantics (handles both server-spawned and dat-hydrated entities; caller picks the key for OnRemove). - [ ] **Step 3.2 — Update the 4 existing tests for the new resolver signature** In `EntityScriptActivatorTests.cs`, every `_ => 0xAAu` becomes: ```csharp _ => new ScriptActivationInfo(0xAAu, System.Array.Empty()) ``` Every `_ => 0u` becomes: ```csharp _ => null ``` `OnRemove(entity.ServerGuid)` calls stay correct (the public API now takes `uint key` either way). - [ ] **Step 3.3 — Add 3 new tests** Append to `EntityScriptActivatorTests.cs`: ```csharp [Fact] public void OnCreate_KeysByEntityId_WhenServerGuidZero() { var p = BuildPipeline( (0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 })))); var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => new ScriptActivationInfo(0xAAu, System.Array.Empty())); var entity = new WorldEntity { Id = 0x40A9B401u, ServerGuid = 0u, 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() { 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() { 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, _ => new ScriptActivationInfo(0xAAu, System.Array.Empty())); 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); Assert.Equal(0, runner.ActiveScriptCount); system.Tick(0.01f); Assert.Equal(0, system.ActiveEmitterCount); } ``` The existing `OnRemove_StopsScriptsAndEmitters` test continues to test the server-guid path. The new `OnRemove_StopsByGivenKey` exercises the dat-hydrated-entity path with the new key. - [ ] **Step 3.4 — Verify** ```pwsh dotnet test -c Debug ``` All tests green, including 4 updated + 3 new in `EntityScriptActivatorTests`, plus the 2 from Task 2 and the 4 from Task 1. - [ ] **Step 3.5 — Commit** ``` 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. Activator keys by ServerGuid when nonzero, else entity.Id, so dat-hydrated entities (EnvCell statics, exterior stabs) flow through the same code path. Pushes per-part transforms into the sink before scheduling. Closes the activator side of #56. GpuWorldState fire-site wiring next. ``` --- ## Task 4: `GpuWorldState` fire-site wiring + production lambda + integration tests **Files:** - Modify: `src/AcDream.App/Streaming/GpuWorldState.cs` - Create: `tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs` - Modify: `src/AcDream.App/Rendering/GameWindow.cs` — resolver lambda - [ ] **Step 4.1 — Add the 4 foreach blocks in `GpuWorldState`** In `AddLandblock` (after `_loaded[landblock.LandblockId] = landblock;` and after `_wbSpawnAdapter?.OnLandblockLoaded(...)`): ```csharp // C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0). // Live entities (ServerGuid!=0) already had OnCreate fired at // AppendLiveEntity; filter to avoid double-firing pending-bucket merges. if (_entityScriptActivator is not null) { var entities = _loaded[landblock.LandblockId].Entities; for (int i = 0; i < entities.Count; i++) { var e = entities[i]; if (e.ServerGuid == 0) _entityScriptActivator.OnCreate(e); } } ``` In `AddEntitiesToExistingLandblock` (after the merge + the `_wbSpawnAdapter` call): ```csharp // C.1.5b: fire DefaultScript for each promoted dat-hydrated entity. // All entities arriving via this path are dat-hydrated by construction // (the promotion path streams in atlas-tier content). if (_entityScriptActivator is not null) { for (int i = 0; i < entities.Count; i++) _entityScriptActivator.OnCreate(entities[i]); } ``` In `RemoveLandblock` (inside the `if (_loaded.TryGetValue(...))` block, after the rescue loop): ```csharp // C.1.5b: stop DefaultScript for each dat-hydrated entity in the // landblock. Server-spawned entities are either being rescued (script // continues) or were OnRemove'd via RemoveEntityByServerGuid earlier; // leave them alone here. if (_entityScriptActivator is not null) { foreach (var entity in lb.Entities) { if (entity.ServerGuid == 0) _entityScriptActivator.OnRemove(entity.Id); } } ``` In `RemoveEntitiesFromLandblock` (after the existing `_onLandblockUnloaded?.Invoke(canonical)`, before the entities-list replacement): ```csharp // C.1.5b: stop DefaultScript for each dat-hydrated entity about to // be dropped. Demote-tier entities are always atlas-tier // (ServerGuid==0); the filter is belt-and-suspenders. if (_entityScriptActivator is not null) { foreach (var entity in lb.Entities) { if (entity.ServerGuid == 0) _entityScriptActivator.OnRemove(entity.Id); } } ``` The `RemoveEntityByServerGuid` site at line 290 stays the same — `OnRemove(uint key)` accepts any key. - [ ] **Step 4.2 — Update the production resolver lambda in `GameWindow`** At GameWindow.cs:1617-1637, replace the existing resolver lambda: ```csharp var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator( scriptRunner, particleHookSink, entity => { try { var setup = _dats.Get(entity.SourceGfxObjOrSetupId); 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 null; } }); ``` - [ ] **Step 4.3 — Write the integration tests** Create `tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs`: ```csharp using System.Collections.Generic; using System.Numerics; using AcDream.App.Rendering.Vfx; using AcDream.App.Streaming; 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.Streaming; public sealed class GpuWorldStateActivatorTests { 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 (GpuWorldState State, PhysicsScriptRunner Runner, ParticleHookSink Sink, RecordingSink Recording) BuildState(uint scriptId) { var script = new DatPhysicsScript(); script.ScriptData.Add(new PhysicsScriptData { StartTime = 0.0, Hook = new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() }, }); var table = new Dictionary { [scriptId] = script }; var registry = new EmitterDescRegistry(); var system = new ParticleSystem(registry); var sink = new ParticleHookSink(system); var recording = new RecordingSink(); var runner = new PhysicsScriptRunner(id => table.TryGetValue(id, out var s) ? s : null, recording); var activator = new EntityScriptActivator(runner, sink, _ => new ScriptActivationInfo(scriptId, System.Array.Empty())); var state = new GpuWorldState(entityScriptActivator: activator); return (state, runner, sink, recording); } private static WorldEntity DatHydrated(uint id, Vector3 pos) => new() { Id = id, ServerGuid = 0u, SourceGfxObjOrSetupId = 0x02000001u, Position = pos, Rotation = Quaternion.Identity, MeshRefs = System.Array.Empty(), }; private static WorldEntity Live(uint serverGuid, Vector3 pos) => new() { Id = serverGuid, ServerGuid = serverGuid, SourceGfxObjOrSetupId = 0x02000001u, Position = pos, Rotation = Quaternion.Identity, MeshRefs = System.Array.Empty(), }; [Fact] public void AddLandblock_FiresActivatorForDatHydrated() { var p = BuildState(scriptId: 0xAAu); var entity = DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero); var lb = new LoadedLandblock(0xA9B4FFFFu, new float[0], new[] { entity }); p.State.AddLandblock(lb); p.Runner.Tick(0.001f); Assert.Single(p.Recording.Calls); Assert.Equal(0x40A9B401u, p.Recording.Calls[0].EntityId); } [Fact] public void AddLandblock_DoesNotDoubleFire_OnPendingMerge() { var p = BuildState(scriptId: 0xAAu); var live = Live(serverGuid: 0xCAFEu, pos: Vector3.Zero); p.State.AppendLiveEntity(0xA9B4FFFFu, live); var lb = new LoadedLandblock(0xA9B4FFFFu, new float[0], System.Array.Empty()); p.State.AddLandblock(lb); p.Runner.Tick(0.001f); Assert.Single(p.Recording.Calls); } [Fact] public void RemoveLandblock_FiresOnRemoveForDatHydrated() { var p = BuildState(scriptId: 0xAAu); var entity = DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero); var lb = new LoadedLandblock(0xA9B4FFFFu, new float[0], new[] { entity }); p.State.AddLandblock(lb); p.Runner.Tick(0.001f); Assert.Equal(1, p.Runner.ActiveScriptCount); p.State.RemoveLandblock(0xA9B4FFFFu); Assert.Equal(0, p.Runner.ActiveScriptCount); } [Fact] public void AddEntitiesToExistingLandblock_FiresActivator() { var p = BuildState(scriptId: 0xAAu); var emptyLb = new LoadedLandblock(0xA9B4FFFFu, new float[0], System.Array.Empty()); p.State.AddLandblock(emptyLb); var promoted = new[] { DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero), DatHydrated(id: 0x40A9B402u, pos: Vector3.UnitX), }; p.State.AddEntitiesToExistingLandblock(0xA9B4FFFFu, promoted); p.Runner.Tick(0.001f); Assert.Equal(2, p.Recording.Calls.Count); } [Fact] public void RemoveEntitiesFromLandblock_FiresOnRemove() { var p = BuildState(scriptId: 0xAAu); var entity = DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero); var lb = new LoadedLandblock(0xA9B4FFFFu, new float[0], new[] { entity }); p.State.AddLandblock(lb); p.Runner.Tick(0.001f); Assert.Equal(1, p.Runner.ActiveScriptCount); p.State.RemoveEntitiesFromLandblock(0xA9B4FFFFu); Assert.Equal(0, p.Runner.ActiveScriptCount); } } ``` - [ ] **Step 4.4 — Verify build + tests** ```pwsh dotnet build -c Debug dotnet test -c Debug ``` All tests green. No regressions in existing `GpuWorldStateTests` (if any assert on ctor arity — they shouldn't, since C.1.5a already added the optional `entityScriptActivator` param). - [ ] **Step 4.5 — Commit** ``` feat(vfx #C.1.5b): GpuWorldState fires activator for dat-hydrated entities Wires EntityScriptActivator.OnCreate into AddLandblock, AddEntitiesToExistingLandblock, and OnRemove into RemoveLandblock + RemoveEntitiesFromLandblock. ServerGuid==0 filter avoids double-firing on pending-bucket merges of live entities. GameWindow's resolver lambda upgraded to return ScriptActivationInfo (scriptId + per-part transforms from SetupPartTransforms.Compute). Closes #56. Slice A (per-part transforms) + slice B (dat-hydrated entities) both wired end-to-end. Ready for visual verification at Holtburg portal + Inn fireplace + cottage chimney. ``` --- ## Task 5: Visual verification + ship docs + merge - [ ] **Step 5.1 — Launch the live client** ```pwsh Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force Start-Sleep -Seconds 3 $env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" $env:ACDREAM_LIVE = "1" $env:ACDREAM_TEST_HOST = "127.0.0.1" $env:ACDREAM_TEST_PORT = "9000" $env:ACDREAM_TEST_USER = "testaccount" $env:ACDREAM_TEST_PASS = "testpassword" $env:ACDREAM_DUMP_PLAYSCRIPT = "1" dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log" ``` Run in background so the parent session keeps control of the terminal. - [ ] **Step 5.2 — Visual verification with the user** Four sites (per spec §6): 1. **Holtburg Town network portal** — swirl extends vertically, no ground-burial, distinct columns visible. 2. **Holtburg Inn fireplace** — flame particles at firebox. 3. **Cottage chimney** — smoke column visible. 4. **Spell cast on +Acdream** — cast-anim particles match retail. Diagnostic: `Select-String "\[pes\] Play:" launch.log` — every successful Play call. If a site has no particles, the log shows whether the script fired and with what scriptId. **STOP and wait for the user to confirm each site matches retail.** This is the acceptance gate. - [ ] **Step 5.3 — Ship docs** If all four sites pass: 1. **`docs/plans/2026-04-11-roadmap.md`** — append "Phase C.1.5b SHIPPED 2026-05-13 — closes #56 + EnvCell DefaultScript dispatch" entry to the shipped table. 2. **`docs/ISSUES.md`** — move #56 from Active to Recently closed with the commit SHA from Task 4. 3. **`CLAUDE.md`** — update the "Currently in flight" line. Drop the C.1.5b reference. Either point to the next phase the user picks, or leave a "between phases" note. - [ ] **Step 5.4 — Final commit + merge** ``` docs(vfx #C.1.5b): ship Phase C.1.5b — closes #56 + EnvCell DefaultScript dispatch Roadmap: add SHIPPED row. ISSUES: #56 → Recently closed. CLAUDE.md: "Currently in flight" pointer updated. Visual verification 2026-05-13: portal swirl matches retail extent + spread (no ground-burial); Holtburg Inn fireplace flames; cottage chimney smoke; spell cast particles all match retail. ``` Then `git checkout main && git merge --no-ff claude/trusting-elbakyan-633b52` and push. --- ## Notes for the executing agent - **TDD discipline:** every Task starts with a failing test, then implementation, then verify. The C.1.5a phase shipped clean because the test scaffolding caught the spawn-on-zero-guid case AND the rotation-seed case before they became visual regressions. Same discipline here for the per-part transform pipeline. - **Don't touch animated entities.** The `SetEntityPartTransforms` seam is keyed by entity, so a future "animated DefaultScript" phase can push fresh transforms each tick without changing this contract. Out of scope for C.1.5b. - **Don't touch `ParticleRenderer.cs`.** Bindless migration is N.6 slice 2. - **Don't invent emitter types.** Reuse existing PES data. - **If visual verification fails:** check `launch.log` for `[pes]` lines first. If the script DID fire but particles look wrong, the bug is in `SpawnFromHook` or the part-transform math. If the script DIDN'T fire, the bug is in the activator wiring or the resolver. Investigate via decomp + cross-reference (`docs/research/named-retail/` for the retail expectation) before guessing — the CLAUDE.md workflow. - **Worktree cleanup:** see handoff §9 for the post-merge cleanup command for the C.1.5a worktree at `lucid-burnell-aab524`. Spec at [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../specs/2026-05-13-phase-c1.5b-design.md) is the source of truth for the architecture; this plan is the execution sequence.