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:
parent
2e222ee553
commit
1e3c33b4db
2 changed files with 1468 additions and 0 deletions
946
docs/superpowers/plans/2026-05-13-phase-c1.5b.md
Normal file
946
docs/superpowers/plans/2026-05-13-phase-c1.5b.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue