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>
This commit is contained in:
Erik 2026-05-11 23:51:44 +02:00
parent 2e222ee553
commit 1e3c33b4db
2 changed files with 1468 additions and 0 deletions

View file

@ -0,0 +1,946 @@
# 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<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**
```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<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`:
```csharp
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**
```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<uint, IReadOnlyList<Matrix4x4>> _partTransformsByEntity = new();
```
Add method next to `SetEntityRotation`:
```csharp
public void SetEntityPartTransforms(uint entityId, IReadOnlyList<Matrix4x4> 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<Matrix4x4> PartTransforms);
```
Change the resolver field type:
```csharp
private readonly Func<WorldEntity, ScriptActivationInfo?> _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<Matrix4x4>())
```
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<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**
```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<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`:
```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<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**
```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.

View file

@ -0,0 +1,522 @@
# Phase C.1.5b — issue #56 (per-part collapse) + EnvCell static DefaultScript dispatch
**Created:** 2026-05-13.
**Author:** Claude (lead engineer/architect).
**Phase:** C.1.5b (second of two slices; C.1.5a portal-PES wiring shipped 2026-05-11 in merge `88bda12`).
**Parent plan:** [`docs/plans/2026-04-27-phase-c1-pes-particles.md`](../../plans/2026-04-27-phase-c1-pes-particles.md) §C.1.5.
**Handoff doc:** [`docs/plans/2026-05-12-phase-c1.5b-handoff.md`](../../plans/2026-05-12-phase-c1.5b-handoff.md).
---
## §1 Goals
Two coupled slices in one phase, in this order:
**Slice A — `ParticleHookSink` honors `CreateParticleHook.PartIndex` for static
entities.** Closes [issue #56](../../ISSUES.md). The Holtburg Town network
portal's 10-emitter script currently collapses every emitter to the entity
root, producing a compressed, partially-ground-buried swirl. The fix is to
precompute each Setup part's resting transform at spawn time and apply it to
the hook offset before spawning the particle.
**Slice B — `EntityScriptActivator` fires `Setup.DefaultScript` for
dat-hydrated entities too.** Right now the activator gates on
`entity.ServerGuid != 0`, which means EnvCell static objects (interior
fireplaces, inn decorations, exterior stabs like cottage chimneys) — which
have no server guid because they come from the dat file, not the network —
never get their DefaultScript fired. Drop the guard, key by `entity.Id` when
`ServerGuid == 0`, and wire `OnCreate` / `OnRemove` calls into GpuWorldState's
dat-hydration paths.
Plus a **visual confirmation pass** for the animation-hook particle path
(already shipped in C.1; just needs a sanity check by casting a spell on
`+Acdream`).
### Acceptance
Visual verification at three retail-side-by-side locations in/near Holtburg:
1. **Town network portal** (the C.1.5a verification site): swirl extends
vertically through the portal arch with retail-like shape; no
ground-burial; emitters distributed across the portal Setup's parts.
2. **Holtburg Inn fireplace** (interior, EnvCell static): flame particles
match retail's pattern and position over the firebox.
3. **Cottage chimney** (exterior stab — TBD which cottage): smoke
particles match retail.
4. **Animation-hook spell cast** on `+Acdream`: cast-anim particle effect
matches retail.
## §2 Scope
**In:**
- New helper `AcDream.Core.Meshing.SetupPartTransforms.Compute(Setup)` that
walks `PlacementFrames[Resting]` → fallback `[Default]` → first available
and returns `IReadOnlyList<Matrix4x4>` (one transform per part).
- `ParticleHookSink.SetEntityPartTransforms(uint entityId, IReadOnlyList<Matrix4x4> partTransforms)`
+ a backing `_partTransformsByEntity` map cleared by `StopAllForEntity`.
- `ParticleHookSink.SpawnFromHook` applies `partTransforms[partIndex]` to the
hook offset before rotating to world space.
- `EntityScriptActivator` resolver signature changes from
`Func<WorldEntity, uint>` to `Func<WorldEntity, ScriptActivationInfo?>` so
both `ScriptId` and `PartTransforms` come from one dat lookup.
- `EntityScriptActivator.OnCreate` keys by `entity.ServerGuid != 0 ?
entity.ServerGuid : entity.Id`. Same activator handles both server-spawned
and dat-hydrated entities — no new class.
- `EntityScriptActivator.OnRemove(uint key)` — caller picks the key.
- `GpuWorldState` wires the activator into four more places:
`AddLandblock` (dat-hydrated entities only — filter by `ServerGuid==0` to
avoid double-firing pending live entities), `AddEntitiesToExistingLandblock`
(the just-promoted entities — all dat-hydrated by construction),
`RemoveLandblock` (dat-hydrated entities only), and
`RemoveEntitiesFromLandblock` (dat-hydrated entities only).
- Visual verification at the four sites in §1 Acceptance.
**Out:**
- Animated entities (NPCs, monsters, the player). Per-part transforms vary
per animation frame and would need a per-tick refresh similar to
`UpdateEntityAnchor`. Deferred to a future phase. The new
`SetEntityPartTransforms` is keyed by entity, so an animated-entity path
can later push fresh transforms each tick without changing the contract.
- Renderer changes. `particle.frag` stays as-is; bindless migration is N.6
slice 2.
- WB's re-fire-after-1s loop logic — portal swirls + fireplace flames are
persistent (`TotalParticles=0 && TotalSeconds=0`), no re-fire needed.
- New emitter types. Reuse existing PES data.
## §3 Background
### What shipped in C.1.5a (the part we keep)
The mechanism is correct: `EntityScriptActivator.OnCreate` runs on every
server-spawned `WorldEntity`, resolves `Setup.DefaultScript`, seeds
`_particleSink.SetEntityRotation`, calls `_scriptRunner.Play(scriptId,
entity.ServerGuid, entity.Position)`. Multi-hook scripts dispatch at their
correct `StartTime` offsets. Despawn cleanup works.
### What's broken (issue #56)
[`ParticleHookSink.SpawnFromHook`](../../../src/AcDream.Core/Vfx/ParticleHookSink.cs)
at lines 176-217:
```csharp
var rotation = _rotationByEntity.TryGetValue(entityId, out var rot)
? rot : Quaternion.Identity;
var anchor = worldPos + Vector3.Transform(offset, rotation);
```
The hook author intended `offset` to be in **part-local** space — i.e.,
relative to the mesh part identified by `cph.PartIndex` — so the geometry
retail computes is:
```
anchor = entityWorldPos + entityRotation × (partFrame.Origin + partFrame.Orientation × hookOffset)
```
Our sink drops the part transform multiplication. For the Holtburg portal
(entity `0x7A9B405B`, script `0x3300126D`, 10 hooks distributed across the
portal Setup's parts), every emitter lands at the entity root. Visible
symptom: swirl partially buried, lateral spread compressed.
### Where part transforms come from (static entities)
For static entities, per-part transforms live in
`setup.PlacementFrames[Placement.Resting]` (fallback `[Default]`, fallback
first available — same priority chain
[`SetupMesh.Flatten`](../../../src/AcDream.Core/Meshing/SetupMesh.cs) at
lines 36-50 already uses). Per part `i`:
```csharp
Matrix4x4.CreateScale(setup.DefaultScale[i])
* Matrix4x4.CreateFromQuaternion(placementFrame.Frames[i].Orientation)
* Matrix4x4.CreateTranslation(placementFrame.Frames[i].Origin)
```
`DefaultScale` defaults to `Vector3.One` when the list is shorter than
`Parts.Count`.
### Where EnvCell statics come from (slice B)
**Major discovery from this design pass — the handoff's §4 Q1/Q2 are mooted:**
[`GameWindow.BuildInteriorEntitiesForStreaming`](../../../src/AcDream.App/Rendering/GameWindow.cs)
at lines 5030-5135 already hydrates EnvCell `StaticObjects` as `WorldEntity`
instances with stable `entity.Id` in the `0x40xxxxxx` range:
```csharp
uint interiorIdBase = 0x40000000u | (landblockId & 0x00FFFF00u);
// ... for each EnvCell, for each stab in envCell.StaticObjects ...
var hydrated = new WorldEntity {
Id = interiorIdBase + localCounter++,
SourceGfxObjOrSetupId = stab.Id, // 0x02000000 → Setup-based
Position = stab.Frame.Origin + lbOffset,
Rotation = stab.Frame.Orientation,
MeshRefs = meshRefs,
ParentCellId = envCellId,
};
```
These flow into `GpuWorldState.AddLandblock` as part of `landblock.Entities`,
sit there with `ServerGuid == 0`, and currently never have their
`Setup.DefaultScript` fired. The activator's existing
`ServerGuid == 0 → return;` guard intentionally skips them (atlas-tier
exemption inherited from `EntitySpawnAdapter`).
Three architectural consequences:
1. **No synthetic ID scheme needed.** `entity.Id` is already collision-free
with server guids (live spawns use `0x500000xx``0x7Fxxxxxx`), anonymous
emitter IDs (`0x80000000u+`), and the four entity-id ranges
(`0x40xxxxxx` interior / `0x80xxxxxx` scenery / etc) all live in disjoint
high-byte slices.
2. **No new `EnvCellStaticActivator` class.** The existing
`EntityScriptActivator` handles both server-spawned and dat-hydrated
entities once the guard is keyed-by-id-when-zero.
3. **No new walker.** `BuildInteriorEntitiesForStreaming` is the walker —
it already happens. We just need the OnCreate fire-site in
GpuWorldState's `AddLandblock` / `AddEntitiesToExistingLandblock`.
The handoff §4 wrote three options (α piggyback / β new class / γ extend
activator) under the assumption that EnvCell statics were NOT WorldEntities.
This reality discovery collapses all three to a simpler answer that none of
them anticipated.
## §4 Architecture
### Slice A: part-transform pipeline
```
EntityScriptActivator.OnCreate(entity)
├─ key = entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id
├─ info = resolver(entity) // ScriptActivationInfo? {ScriptId, PartTransforms}
├─ if (info is null || info.ScriptId == 0) return
├─ _particleSink.SetEntityRotation(key, entity.Rotation)
├─ _particleSink.SetEntityPartTransforms(key, info.PartTransforms) // NEW
└─ _scriptRunner.Play(info.ScriptId, key, entity.Position)
ParticleHookSink.SpawnFromHook(entityId, worldPos, ..., partIndex, ...)
├─ rotation = _rotationByEntity[entityId] ?? Quaternion.Identity
├─ partTransform = (partTransforms != null && partIndex >= 0 && partIndex < Count)
│ ? partTransforms[partIndex] : Matrix4x4.Identity
├─ partLocal = Vector3.Transform(offset, partTransform)
├─ anchor = worldPos + Vector3.Transform(partLocal, rotation)
└─ _system.SpawnEmitterById(...)
```
### Slice B: dat-hydration fire-sites
```
GpuWorldState.AddLandblock(landblock)
├─ merge pending live entities (existing)
├─ _loaded[id] = landblock (existing)
├─ _wbSpawnAdapter?.OnLandblockLoaded(...) (existing)
├─ foreach entity in landblock.Entities where ServerGuid == 0: // NEW
│ _entityScriptActivator?.OnCreate(entity)
└─ RebuildFlatView() (existing)
GpuWorldState.AddEntitiesToExistingLandblock(landblockId, entities)
├─ canonicalize + merge (existing)
├─ _wbSpawnAdapter?.OnLandblockLoaded(...) (existing)
├─ foreach entity in entities: // NEW
│ _entityScriptActivator?.OnCreate(entity) // all dat-hydrated
└─ RebuildFlatView() (existing)
GpuWorldState.RemoveLandblock(landblockId)
├─ _wbSpawnAdapter?.OnLandblockUnloaded(...) (existing)
├─ rescue persistent (existing)
├─ foreach entity in lb.Entities where ServerGuid == 0: // NEW
│ _entityScriptActivator?.OnRemove(entity.Id)
└─ remove from _loaded, RebuildFlatView (existing)
GpuWorldState.RemoveEntitiesFromLandblock(landblockId)
├─ _wbSpawnAdapter?.OnLandblockUnloaded(...) (existing)
├─ _onLandblockUnloaded?.Invoke(canonical) (existing — Tier 1 cache sweep)
├─ foreach entity in lb.Entities where ServerGuid == 0: // NEW
│ _entityScriptActivator?.OnRemove(entity.Id)
└─ replace lb.Entities with empty list, RebuildFlatView (existing)
```
The `ServerGuid == 0` filter avoids double-firing OnCreate on live entities
that came via `AppendLiveEntity` and got pending-bucket-merged in
`AddLandblock`. Their OnCreate already fired at AppendLiveEntity time.
### Resolver evolution
C.1.5a resolver:
```csharp
Func<WorldEntity, uint> defaultScriptResolver // returns scriptId or 0
```
C.1.5b resolver:
```csharp
Func<WorldEntity, ScriptActivationInfo?> activationResolver // returns null on miss
```
Where `ScriptActivationInfo` is a small record in `AcDream.App.Rendering.Vfx`:
```csharp
public sealed record ScriptActivationInfo(
uint ScriptId,
IReadOnlyList<Matrix4x4> PartTransforms);
```
Production lambda in `GameWindow.OnLoad` (replaces the C.1.5a one):
```csharp
entity =>
{
try
{
var setup = _dats.Get<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 ScriptActivationInfo(scriptId, parts);
}
catch
{
return null;
}
}
```
One dat lookup → both pieces of info. The Setup is cached by DatCollection,
so even hot-path scenery firing with no DefaultScript stays O(1).
### Helper: `SetupPartTransforms.Compute`
New static helper in `AcDream.Core.Meshing` (next to `SetupMesh`):
```csharp
public static class SetupPartTransforms
{
/// <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 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").
/// </summary>
public static IReadOnlyList<Matrix4x4> Compute(Setup setup);
}
```
This deliberately mirrors the pose-source priority in
`SetupMesh.Flatten` so a part's particle anchor matches its visible rest
position. (If the renderer's pose source ever diverges from this resolver,
particles will visibly drift — keep them in lockstep.)
For animated entities, the renderer's `AnimatedEntityState` computes
per-frame part transforms; a future "animated DefaultScript" path would
publish those each tick via the same `SetEntityPartTransforms` seam. Out
of scope for C.1.5b.
## §5 Data + lifecycle invariants
| Concern | Behavior |
|---|---|
| Server-spawned entity spawn | `AppendLiveEntity``OnCreate` (existing). Keys by `ServerGuid`. |
| Server-spawned entity despawn | `RemoveEntityByServerGuid``OnRemove(serverGuid)` (existing). |
| Dat-hydrated entity load (initial) | `AddLandblock``OnCreate` for each `ServerGuid==0` entity. Keys by `entity.Id`. |
| Dat-hydrated entity load (promotion) | `AddEntitiesToExistingLandblock``OnCreate` for each entity in the new batch. Keys by `entity.Id`. |
| Dat-hydrated entity unload (full LB) | `RemoveLandblock``OnRemove(entity.Id)` for each `ServerGuid==0` entity. |
| Dat-hydrated entity unload (Near→Far demotion) | `RemoveEntitiesFromLandblock``OnRemove(entity.Id)` for each `ServerGuid==0` entity. |
| Pending live entity merged into AddLandblock | `OnCreate` already fired at `AppendLiveEntity`; filtered out by `ServerGuid != 0`. |
| Persistent live entity rescued from RemoveLandblock | Not unloaded; its script continues. Filtered out by `ServerGuid != 0`. |
| PartIndex out of bounds | Sink falls back to `Matrix4x4.Identity` for that part (no part transform applied, offset stays in entity-local frame as before). |
| Setup with empty PlacementFrames | Resolver returns empty `PartTransforms` list; sink falls back to Identity for every part. Equivalent to pre-C.1.5b behavior. |
| Resolver throws | Lambda's try/catch returns null; activator no-ops. |
| Same script re-fired on dedupe | `PhysicsScriptRunner.Play` replaces prior instance (existing C.1 behavior). Visual: script restarts from t=0. Avoided here because we filter dat-hydrated entities by `ServerGuid==0` — they're not double-fired. |
### Idempotency
- Duplicate `OnCreate` for same key → script restarts (existing dedupe).
- Duplicate `OnRemove` for same key → no-op.
- `OnRemove` for never-spawned key → no-op.
- LB unload immediately followed by LB load → entities get fresh `entity.Id`
(localCounter resets per-call) but the keys are computed deterministically
from landblockId + iteration order so a re-entered LB gets identical keys.
Script restarts cleanly because OnRemove fired during the unload.
## §6 Testing
### Unit tests — new
1. **`SetupPartTransforms_ResolvesRestingPlacement_WhenAvailable`** —
Setup with `PlacementFrames[Resting]` containing 2 parts; assert returned
list has 2 matrices matching the resting frames.
2. **`SetupPartTransforms_FallsBackToDefault_WhenRestingMissing`** —
Setup with only `PlacementFrames[Default]`; assert it's used.
3. **`SetupPartTransforms_ReturnsEmpty_WhenNoPlacementFrames`** —
Setup with empty `PlacementFrames` dict; assert empty list.
4. **`SetupPartTransforms_AppliesDefaultScale_WhenPresent`** —
Setup with `DefaultScale[0] = (2, 2, 2)`; assert the matrix scales by 2.
5. **`ParticleHookSink_AppliesPartTransform_WhenRegistered`** —
register part transforms `[Identity, Translation(0,0,1)]`; fire a
CreateParticleHook with `PartIndex=1, Offset=(1,0,0)`; assert spawned
particle world position is `(1, 0, 1)`.
6. **`ParticleHookSink_FallsBackToIdentity_WhenPartIndexOutOfBounds`** —
register 2 part transforms; fire hook with `PartIndex=99`; assert
spawned at root + offset (no buried-by-bad-matrix).
7. **`EntityScriptActivator_KeysByEntityId_WhenServerGuidZero`** —
dat-hydrated entity with `ServerGuid=0, Id=0x40A9B401`; fire OnCreate;
assert script runner saw `entityId=0x40A9B401`.
8. **`EntityScriptActivator_PassesPartTransformsToSink`** —
resolver returns non-empty PartTransforms; assert sink's
`SetEntityPartTransforms` was called with the matching list.
9. **`EntityScriptActivator_OnRemove_StopsByGivenKey`** —
call `OnRemove(0x40A9B401)`; assert runner + sink both got that key.
### Unit tests — updated
The 4 existing `EntityScriptActivatorTests` are updated for the new
resolver signature (`_ => 0xAAu` → `_ => new ScriptActivationInfo(0xAAu,
Array.Empty<Matrix4x4>())`). Test names and assertions stay the same.
### Integration tests — GpuWorldState wiring
10. **`GpuWorldState_AddLandblock_FiresActivatorForDatHydrated`** —
construct GpuWorldState with a fake activator (recording mock); add
a landblock with one `ServerGuid==0` entity; assert OnCreate fired
exactly once.
11. **`GpuWorldState_AddLandblock_DoesNotDoubleFire_OnPendingMerge`** —
AppendLiveEntity with `ServerGuid=0xCAFE` (one OnCreate); then
AddLandblock for the same canonical id; assert OnCreate fired only
once total for the live entity.
12. **`GpuWorldState_RemoveLandblock_FiresOnRemoveForDatHydrated`** —
AddLandblock with a dat-hydrated entity, then RemoveLandblock; assert
OnRemove fired with `entity.Id`.
13. **`GpuWorldState_AddEntitiesToExistingLandblock_FiresActivator`** —
promotion path; assert OnCreate fires for each promoted entity.
14. **`GpuWorldState_RemoveEntitiesFromLandblock_FiresOnRemove`** —
demotion path; assert OnRemove fires for each removed dat-hydrated
entity.
Existing `GpuWorldStateTests` may need a minor update if any assert on
constructor arity (the resolver doesn't change shape — same 4 ctor params).
### Visual verification (acceptance gate)
Procedure (per [CLAUDE.md](../../../CLAUDE.md) "Visual verification workflow"):
1. `dotnet build` green.
2. `dotnet test` green.
3. Launch live client with `ACDREAM_DUMP_PLAYSCRIPT=1`.
4. **Site 1 — Holtburg Town network portal** (same site as C.1.5a):
user walks `+Acdream` to the portal arch. Compare swirl vertical
extent + lateral spread to retail. Pass: no ground-burial, distinct
columns of emission visible across the arch.
5. **Site 2 — Holtburg Inn fireplace** (interior, EnvCell static):
user walks into the inn, stands near the fireplace. Pass: flame
particles emit from the firebox at retail-matching height/density.
6. **Site 3 — Cottage chimney** (exterior stab): user finds a Holtburg
cottage with smoke in retail; same cottage in acdream should now
show smoke. Pass: smoke column matches retail.
7. **Site 4 — Spell cast** on `+Acdream`: user casts a spell, optionally
in a safe spot. Pass: cast-anim particles match retail.
Diagnostic: `ACDREAM_DUMP_PLAYSCRIPT=1` prints every `[pes] Play:` line —
if a site doesn't show particles, check the log to see whether the script
fired and with what scriptId.
## §7 Risk + rollback
**Slice A risks:**
- `SetupPartTransforms.Compute` returns a list whose length doesn't match
`setup.Parts.Count`. **Mitigation:** sink's per-index bounds check falls
back to Identity; no buried particles, just reverts to C.1.5a behavior
for the over-indexed hook.
- Wrong pose source chosen (Resting vs Default). **Mitigation:** mirror
`SetupMesh.Flatten`'s priority chain exactly so renderer + particle
anchor stay in lockstep. If they ever diverge, particles drift visibly;
user spot-checks at the portal.
**Slice B risks:**
- Firing OnCreate for EVERY dat-hydrated entity (scenery counts ~thousands
per landblock at radius=4) becomes a perf hit. **Mitigation:** resolver
is one cached `DatCollection.Get<Setup>` per entity — already amortized.
Most entities have `DefaultScript.DataId == 0`, resolver returns null,
OnCreate no-ops in ~1µs. Per-landblock-load cost: tens of µs, dwarfed
by mesh upload + RebuildFlatView. Measured if `[pes] Play:` line
spam appears in launch.log.
- Filter `ServerGuid==0` is too aggressive — misses some valid case.
**Mitigation:** every entity with `ServerGuid != 0` came through
`AppendLiveEntity` (verified by `RelocateEntity`'s
`if (entity.ServerGuid == 0) return;` guard at GpuWorldState.cs:204),
so they already had OnCreate fired. No miss.
- Idempotency edge case: rapid LB load/unload cycles produce repeated
Play → Stop → Play. **Mitigation:** existing PhysicsScriptRunner
dedupe handles re-Play; this is the same as a server retriggering a
PlayScript opcode.
**Rollback path:** revert the spec's commits; the C.1.5a `EntityScriptActivator`
keeps working for live entities exactly as before. No data migrations.
## §8 Doc-drift fixes from C.1.5a (folded in)
The handoff §9 surfaced three trivial doc-drift items from C.1.5a. Folded
here for the record:
1. C.1.5a spec §4 ("fifth optional parameter") was wrong — the activator
is actually GpuWorldState's **fourth** optional parameter (verified at
[GpuWorldState.cs:63](../../../src/AcDream.App/Streaming/GpuWorldState.cs):
`wbSpawnAdapter, wbEntitySpawnAdapter, onLandblockUnloaded,
entityScriptActivator`).
2. C.1.5a spec §4 ("~50 lines") was an estimate; the file shipped at
**93 lines** including doc comments. Slice A adds the part-transform
call + slice B drops the `ServerGuid == 0` guard, so the file will
land at ~100110 lines after this phase.
3. `GpuWorldState.AddEntitiesToExistingLandblock` *will* fire the activator
in slice B (the handoff said it currently doesn't and noted "no-op
today because promotion-tier entities are atlas-tier"). With slice B,
atlas-tier entities WITH `DefaultScript` set will now activate. Per
the architecture comment at GpuWorldState.cs:384-391, this path
handles dat-static stabs/buildings — exactly the case slice B targets.
## §9 Implementation notes
- **File touches:** `ParticleHookSink.cs` (+~30 lines), `EntityScriptActivator.cs`
(+~10 lines, -~5 lines), `GpuWorldState.cs` (+~12 lines, 4 fire-sites),
`GameWindow.cs` (resolver lambda update, ~10 lines), new
`SetupPartTransforms.cs` (~50 lines), updated `EntityScriptActivatorTests.cs`
(4 ctor-signature updates + new tests), new `SetupPartTransformsTests.cs`
(~80 lines, 4 tests), new `ParticleHookSinkTests.cs` additions or new file
(~60 lines, 2 tests), new `GpuWorldStateActivatorTests.cs` (~120 lines,
5 integration tests).
- **Estimated effort:** ~1 day.
- **Commit cadence:** four commits land this phase cleanly —
(1) `SetupPartTransforms` helper + tests, (2) `ParticleHookSink` part-transform
support + tests, (3) `EntityScriptActivator` resolver refactor + ServerGuid
guard relaxation + tests, (4) `GpuWorldState` fire-site wiring + tests +
production lambda update + the C.1.5a doc-drift comment for
`AddEntitiesToExistingLandblock`. Each commit `dotnet test` green.
Visual verification after all four land.
- **Roadmap update:** on ship, add a "Phase C.1.5b SHIPPED 2026-05-13"
entry to [`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md);
move #56 to "Recently closed" in `docs/ISSUES.md`.
- **CLAUDE.md update:** the "Currently in flight" line at the top of the
project-instructions block changes from C.1.5b to the next phase, with
the handoff doc reference dropped. Decide the next-phase pointer at
verification time.
## §10 What's next (post-C.1.5b)
Pending user direction. The roadmap candidate list from
[`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md):
- Triage the chronic open-issue list — #2 (lightning), #4 (sky horizon-glow),
#28 (aurora), #29 (cloud thinness), #37 (humanoid coat), #50 (stray tree),
#41 (remote-motion blips) — link each to a future phase or downgrade.
- More Phase C visual-fidelity work (C.2 dynamic point lights, C.3 palette
tuning, C.4 double-sided translucent polys).
- N.6 slice 2 at reduced scope (atlas opportunities only).
- Perf tiers 2/3 only if sustained 500+ FPS becomes a requirement.
Verification will surface which option the user picks.