acdream/docs/superpowers/plans/2026-05-13-phase-c1.5b.md
Erik 1e3c33b4db docs(vfx #C.1.5b): design + plan for issue #56 + EnvCell DefaultScript
Two-slice phase:
- Slice A: ParticleHookSink applies CreateParticleHook.PartIndex via
  SetupPartTransforms.Compute(setup.PlacementFrames). Closes #56.
- Slice B: drop EntityScriptActivator's ServerGuid==0 guard so
  dat-hydrated EnvCell statics + exterior stabs fire DefaultScript.

Key reality discovery folded into the spec §3: EnvCell.StaticObjects
are already WorldEntities (via GameWindow.BuildInteriorEntitiesForStreaming),
so no synthetic-ID scheme + no new walker class needed — the handoff's
§4 Q1/Q2 options are mooted by entity.Id being collision-free.

Doc-drift fixes from C.1.5a folded into §8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:51:44 +02:00

34 KiB

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 #56ParticleHookSink 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. 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<WorldEntity, ScriptActivationInfo?>; 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

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<Placement, AnimationFrame>? placementFrames = null,
        IReadOnlyList<Vector3>? 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, AnimationFrame>
            {
                [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, AnimationFrame>
            {
                [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, AnimationFrame> { [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:

using System.Collections.Generic;
using System.Numerics;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;

namespace AcDream.Core.Meshing;

/// <summary>
/// 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 <see cref="SetupMesh.Flatten"/>'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 <c>ParticleHookSink.SpawnFromHook</c>).
/// </summary>
public static class SetupPartTransforms
{
    public static IReadOnlyList<Matrix4x4> 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<Matrix4x4>();

        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
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

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:

private readonly ConcurrentDictionary<uint, IReadOnlyList<Matrix4x4>> _partTransformsByEntity = new();

Add method next to SetEntityRotation:

public void SetEntityPartTransforms(uint entityId, IReadOnlyList<Matrix4x4> partTransforms)
    => _partTransformsByEntity[entityId] = partTransforms;

Add cleanup line inside StopAllForEntity:

_partTransformsByEntity.TryRemove(entityId, out _);

Modify SpawnFromHook — replace the anchor computation:

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
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):

public sealed record ScriptActivationInfo(
    uint ScriptId,
    IReadOnlyList<Matrix4x4> PartTransforms);

Change the resolver field type:

private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;

Update ctor parameter name + type accordingly.

Replace OnCreate:

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:

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:

_ => new ScriptActivationInfo(0xAAu, System.Array.Empty<Matrix4x4>())

Every _ => 0u becomes:

_ => 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:

[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<Matrix4x4>()));
    var entity = new WorldEntity
    {
        Id = 0x40A9B401u,
        ServerGuid = 0u,
        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()
{
    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()
{
    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,
        _ => new ScriptActivationInfo(0xAAu, System.Array.Empty<Matrix4x4>()));
    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);

    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
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(...)):

// 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):

// 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):

// 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):

// 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:

var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator(
    scriptRunner,
    particleHookSink,
    entity =>
    {
        try
        {
            var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(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:

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<uint, DatPhysicsScript> { [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<Matrix4x4>()));

        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<MeshRef>(),
    };

    private static WorldEntity Live(uint serverGuid, Vector3 pos) => new()
    {
        Id = serverGuid,
        ServerGuid = serverGuid,
        SourceGfxObjOrSetupId = 0x02000001u,
        Position = pos,
        Rotation = Quaternion.Identity,
        MeshRefs = System.Array.Empty<MeshRef>(),
    };

    [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<WorldEntity>());
        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<WorldEntity>());
        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
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
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 is the source of truth for the architecture; this plan is the execution sequence.