docs(vfx #C.1.5a): implementation plan + spec wiring-location fixes
Plan: docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md. Four
tasks, TDD-style, each task is a single commit boundary:
1. EntityScriptActivator class + three xUnit tests
2. Wire into GpuWorldState (new optional ctor param + two ?. calls)
3. Construct in GameWindow with resolver lambda
4. Visual verification at Holtburg Town network portal + roadmap update
Spec amendments correct an inaccuracy in the 2026-05-12 commit
(06d7fbd): the activator's call sites live in GpuWorldState
(AppendLiveEntity / RemoveEntityByServerGuid), not directly in
GameWindow as the original spec described. Also fixes the test file
path: tests/AcDream.Core.Tests/... not AcDream.App.Tests/... per the
existing test-project convention. No design changes — same activator,
same trigger condition, same lifecycle ordering.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
06d7fbd5ef
commit
ed5335b81e
2 changed files with 694 additions and 20 deletions
651
docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md
Normal file
651
docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md
Normal file
|
|
@ -0,0 +1,651 @@
|
|||
# Phase C.1.5a — Portal PES wiring 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:** Fire `Setup.DefaultScript` through the already-shipped `PhysicsScriptRunner` when a server-spawned `WorldEntity` enters the world, so portals emit their retail-faithful persistent particle effects automatically.
|
||||
|
||||
**Architecture:** One new ~50-line class `EntityScriptActivator` under `src/AcDream.App/Rendering/Vfx/`. Wired into `GpuWorldState`'s `AppendLiveEntity` (calls `OnCreate`) and `RemoveEntityByServerGuid` (calls `OnRemove`), immediately after the matching `_wbEntitySpawnAdapter` calls. Activator is constructed in `GameWindow` (alongside the existing entity-spawn adapter) and passed into `GpuWorldState` as a new optional ctor parameter.
|
||||
|
||||
**Tech Stack:** C# / .NET 10, xUnit, Silk.NET (existing). No new dependencies.
|
||||
|
||||
**Spec:** [`docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md`](../specs/2026-05-12-phase-c1.5a-portals-design.md). Read it first.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
**Created:**
|
||||
|
||||
- `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs` — the new orchestrator class.
|
||||
- `tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs` — three xUnit tests covering OnCreate-fires, OnCreate-no-op, OnRemove-cleanup.
|
||||
|
||||
**Modified:**
|
||||
|
||||
- `src/AcDream.App/Streaming/GpuWorldState.cs` — new optional ctor parameter; two `?.` call sites added.
|
||||
- `src/AcDream.App/Rendering/GameWindow.cs` — construct the activator alongside `_wbEntitySpawnAdapter` (~line 1614) and pass it into the `GpuWorldState` ctor (~line 1619). One field declaration added.
|
||||
- `docs/plans/2026-04-11-roadmap.md` — append "Phase C.1.5a SHIPPED" entry on verification pass (Task 4 only).
|
||||
|
||||
Each file has one clear responsibility:
|
||||
- `EntityScriptActivator` — orchestrates DefaultScript fire-on-spawn / stop-on-despawn. Knows nothing about dats or GL.
|
||||
- `GpuWorldState` — owns spawn lifecycle. The activator is one more `?.` collaborator alongside the existing adapter.
|
||||
- `GameWindow` — wiring root. Constructs the resolver lambda where `_dats` is in scope; everything else is plumbing.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Build `EntityScriptActivator` with tests (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`
|
||||
- Create: `tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs`
|
||||
|
||||
- [ ] **Step 1.1 — Write the test file with three failing tests + helpers**
|
||||
|
||||
Create `tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
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.Rendering.Vfx;
|
||||
|
||||
public sealed class EntityScriptActivatorTests
|
||||
{
|
||||
/// <summary>Recording sink so we can assert which hooks the runner fires.</summary>
|
||||
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 DatPhysicsScript BuildScript(params (double time, AnimationHook hook)[] items)
|
||||
{
|
||||
var script = new DatPhysicsScript();
|
||||
foreach (var (t, h) in items)
|
||||
script.ScriptData.Add(new PhysicsScriptData { StartTime = t, Hook = h });
|
||||
return script;
|
||||
}
|
||||
|
||||
private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) =>
|
||||
new()
|
||||
{
|
||||
Id = serverGuid,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = position,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
private record Pipeline(
|
||||
ParticleSystem System,
|
||||
ParticleHookSink Sink,
|
||||
PhysicsScriptRunner Runner,
|
||||
RecordingSink Recording);
|
||||
|
||||
private static Pipeline BuildPipeline(params (uint id, DatPhysicsScript script)[] scripts)
|
||||
{
|
||||
var registry = new EmitterDescRegistry();
|
||||
var system = new ParticleSystem(registry);
|
||||
var hookSink = new ParticleHookSink(system); // for activator's StopAllForEntity
|
||||
var recording = new RecordingSink(); // for runner's hook dispatch
|
||||
var table = new Dictionary<uint, DatPhysicsScript>();
|
||||
foreach (var (id, s) in scripts) table[id] = s;
|
||||
var runner = new PhysicsScriptRunner(
|
||||
id => table.TryGetValue(id, out var s) ? s : null,
|
||||
recording);
|
||||
return new Pipeline(system, hookSink, runner, recording);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_WithDefaultScript_FiresRunnerWithEntityGuidAndPosition()
|
||||
{
|
||||
var p = BuildPipeline(
|
||||
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 }))));
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0xAAu);
|
||||
var entity = MakeEntity(serverGuid: 0xCAFEu, position: new Vector3(1, 2, 3));
|
||||
|
||||
activator.OnCreate(entity);
|
||||
|
||||
Assert.Equal(1, p.Runner.ActiveScriptCount);
|
||||
p.Runner.Tick(0.001f);
|
||||
Assert.Single(p.Recording.Calls);
|
||||
Assert.Equal(0xCAFEu, p.Recording.Calls[0].EntityId);
|
||||
Assert.Equal(new Vector3(1, 2, 3), p.Recording.Calls[0].Pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_WithoutDefaultScript_DoesNothing()
|
||||
{
|
||||
var p = BuildPipeline(); // no scripts registered
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0u);
|
||||
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
|
||||
|
||||
activator.OnCreate(entity);
|
||||
|
||||
Assert.Equal(0, p.Runner.ActiveScriptCount);
|
||||
Assert.Empty(p.Recording.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnRemove_StopsScriptsAndEmitters()
|
||||
{
|
||||
var p = BuildPipeline(
|
||||
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 }))));
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0xAAu);
|
||||
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
|
||||
|
||||
activator.OnCreate(entity);
|
||||
Assert.Equal(1, p.Runner.ActiveScriptCount);
|
||||
|
||||
activator.OnRemove(0xCAFEu);
|
||||
|
||||
Assert.Equal(0, p.Runner.ActiveScriptCount);
|
||||
// Tick after Remove must not surface any further hook fires.
|
||||
p.Runner.Tick(1.0f);
|
||||
Assert.Empty(p.Recording.Calls);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1.2 — Run the tests, confirm they fail with "type not found"**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~EntityScriptActivatorTests"`
|
||||
|
||||
Expected: compile error — `AcDream.App.Rendering.Vfx.EntityScriptActivator` does not exist. (This is the failing red-bar that drives the next step.)
|
||||
|
||||
- [ ] **Step 1.3 — Create the `Vfx/` directory and the activator file**
|
||||
|
||||
Create `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Vfx;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering.Vfx;
|
||||
|
||||
/// <summary>
|
||||
/// Fires <c>Setup.DefaultScript</c> through <see cref="PhysicsScriptRunner"/>
|
||||
/// when a server-spawned <see cref="WorldEntity"/> enters the world, so static
|
||||
/// objects (portals, chimneys, fireplaces, building details) emit their
|
||||
/// retail-faithful persistent particle effects automatically. Stops the
|
||||
/// scripts and live emitters when the entity despawns.
|
||||
///
|
||||
/// <para>
|
||||
/// Wires alongside <c>EntitySpawnAdapter</c> in <c>GpuWorldState</c>: the
|
||||
/// adapter handles meshes + animation state, the activator handles scripts +
|
||||
/// particles. Both are render-thread-only.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail oracle: <c>play_script_internal(setup.DefaultScript)</c> is what
|
||||
/// retail's <c>CPhysicsObj</c> invokes at object load (see Phase C.1 plan §C.1
|
||||
/// and <c>memory/project_sky_pes_port.md</c>). C.1 already shipped the runner;
|
||||
/// this class adds the missing fire-on-spawn call site.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class EntityScriptActivator
|
||||
{
|
||||
private readonly PhysicsScriptRunner _scriptRunner;
|
||||
private readonly ParticleHookSink _particleSink;
|
||||
private readonly Func<WorldEntity, uint> _defaultScriptResolver;
|
||||
|
||||
/// <param name="scriptRunner">Already-shipped runner from C.1. Owns the
|
||||
/// (scriptId, entityId) instance table and schedules hooks at their
|
||||
/// <c>StartTime</c> offsets.</param>
|
||||
/// <param name="particleSink">Already-shipped hook sink from C.1. The
|
||||
/// activator only calls its <see cref="ParticleHookSink.StopAllForEntity"/>
|
||||
/// to drop any per-entity emitter handles on despawn.</param>
|
||||
/// <param name="defaultScriptResolver">Returns
|
||||
/// <c>entity.SourceGfxObjOrSetupId</c>'s <c>Setup.DefaultScript.DataId</c>,
|
||||
/// or <c>0</c> on miss / dat throw / missing field. Production lambda hits
|
||||
/// <see cref="DatReaderWriter.DatCollection"/>; tests pass a hand-rolled
|
||||
/// stub.</param>
|
||||
public EntityScriptActivator(
|
||||
PhysicsScriptRunner scriptRunner,
|
||||
ParticleHookSink particleSink,
|
||||
Func<WorldEntity, uint> defaultScriptResolver)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scriptRunner);
|
||||
ArgumentNullException.ThrowIfNull(particleSink);
|
||||
ArgumentNullException.ThrowIfNull(defaultScriptResolver);
|
||||
_scriptRunner = scriptRunner;
|
||||
_particleSink = particleSink;
|
||||
_defaultScriptResolver = defaultScriptResolver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the entity's <c>Setup.DefaultScript</c> and fire it through
|
||||
/// the script runner. No-op if the entity has no DefaultScript
|
||||
/// (resolver returns 0) or if the entity has no server guid
|
||||
/// (atlas-tier entities are out of scope for this activator).
|
||||
/// </summary>
|
||||
public void OnCreate(WorldEntity entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
if (entity.ServerGuid == 0) return;
|
||||
|
||||
uint scriptId = _defaultScriptResolver(entity);
|
||||
if (scriptId == 0) return;
|
||||
|
||||
_scriptRunner.Play(scriptId, entity.ServerGuid, entity.Position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop every script instance the runner is tracking for this entity, and
|
||||
/// kill every live emitter the sink has attributed to it. Idempotent for
|
||||
/// unknown guids (both calls no-op).
|
||||
/// </summary>
|
||||
public void OnRemove(uint serverGuid)
|
||||
{
|
||||
if (serverGuid == 0) return;
|
||||
_scriptRunner.StopAllForEntity(serverGuid);
|
||||
_particleSink.StopAllForEntity(serverGuid, fadeOut: false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1.4 — Run the tests, confirm all three pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~EntityScriptActivatorTests"`
|
||||
|
||||
Expected: 3 passed, 0 failed.
|
||||
|
||||
If a test fails: re-read the assertion against the implementation. The most likely failure is `RecordingSink.Calls` empty after `Runner.Tick` — that means the `Play` call didn't queue the script. Check that `entity.ServerGuid != 0` in `MakeEntity`.
|
||||
|
||||
- [ ] **Step 1.5 — Run the full test suite for the test project**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`
|
||||
|
||||
Expected: all existing tests still pass plus the new 3.
|
||||
|
||||
- [ ] **Step 1.6 — Commit Task 1**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(vfx #C.1.5a): add EntityScriptActivator (no wiring yet)
|
||||
|
||||
New ~50-line orchestrator that fires Setup.DefaultScript through the
|
||||
already-shipped PhysicsScriptRunner on entity spawn and stops scripts +
|
||||
live emitters on despawn. Resolver delegate avoids DatCollection coupling
|
||||
so the class is fully unit-testable with stubs.
|
||||
|
||||
Three xUnit tests cover the three branches: fire-with-script,
|
||||
no-op-without-script, stop-on-remove. No wiring into the live spawn path
|
||||
yet — that lands in the next commit.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Wire activator into `GpuWorldState`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs:42-65` (field + constructor)
|
||||
- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs:285` (OnRemove call site)
|
||||
- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs:345` (OnCreate call site)
|
||||
|
||||
- [ ] **Step 2.1 — Add `using` for the new namespace**
|
||||
|
||||
Open `src/AcDream.App/Streaming/GpuWorldState.cs`. The existing `using` block at the top (line ~4) imports `AcDream.App.Rendering.Wb;`. Add a second line below it:
|
||||
|
||||
```csharp
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2 — Add the field**
|
||||
|
||||
Around line 43 there is:
|
||||
|
||||
```csharp
|
||||
private readonly EntitySpawnAdapter? _wbEntitySpawnAdapter;
|
||||
```
|
||||
|
||||
Add immediately below:
|
||||
|
||||
```csharp
|
||||
private readonly EntityScriptActivator? _entityScriptActivator;
|
||||
```
|
||||
|
||||
- [ ] **Step 2.3 — Extend the constructor**
|
||||
|
||||
Replace the existing constructor (lines 57–65) with:
|
||||
|
||||
```csharp
|
||||
public GpuWorldState(
|
||||
LandblockSpawnAdapter? wbSpawnAdapter = null,
|
||||
EntitySpawnAdapter? wbEntitySpawnAdapter = null,
|
||||
System.Action<uint>? onLandblockUnloaded = null,
|
||||
EntityScriptActivator? entityScriptActivator = null)
|
||||
{
|
||||
_wbSpawnAdapter = wbSpawnAdapter;
|
||||
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
|
||||
_onLandblockUnloaded = onLandblockUnloaded;
|
||||
_entityScriptActivator = entityScriptActivator;
|
||||
}
|
||||
```
|
||||
|
||||
The new parameter is optional and last — existing callers (production and tests) compile unchanged.
|
||||
|
||||
- [ ] **Step 2.4 — Add the `OnCreate` call in `AppendLiveEntity`**
|
||||
|
||||
At line 345 the existing call is:
|
||||
|
||||
```csharp
|
||||
_wbEntitySpawnAdapter?.OnCreate(entity);
|
||||
```
|
||||
|
||||
Add immediately below:
|
||||
|
||||
```csharp
|
||||
_entityScriptActivator?.OnCreate(entity);
|
||||
```
|
||||
|
||||
- [ ] **Step 2.5 — Add the `OnRemove` call in `RemoveEntityByServerGuid`**
|
||||
|
||||
At line 285 the existing call is:
|
||||
|
||||
```csharp
|
||||
_wbEntitySpawnAdapter?.OnRemove(serverGuid);
|
||||
```
|
||||
|
||||
Add immediately below:
|
||||
|
||||
```csharp
|
||||
_entityScriptActivator?.OnRemove(serverGuid);
|
||||
```
|
||||
|
||||
- [ ] **Step 2.6 — Run the build to confirm GpuWorldState compiles**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj`
|
||||
|
||||
Expected: build succeeds. `GameWindow.cs` still calls the old 3-arg constructor; the new parameter is optional so this compiles fine.
|
||||
|
||||
- [ ] **Step 2.7 — Run the test suite to confirm GpuWorldStateTests still pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~GpuWorldStateTests"`
|
||||
|
||||
Expected: all pass. Existing tests construct `GpuWorldState` with positional args; they don't pass the new optional parameter so behavior is unchanged.
|
||||
|
||||
If a test fails because it asserts something about per-entity-lifecycle ordering: read the assertion. The new `?.OnCreate(entity)` after `_wbEntitySpawnAdapter?.OnCreate(entity)` is a no-op when no activator is injected, so tests that don't inject one should not see new behavior.
|
||||
|
||||
- [ ] **Step 2.8 — Commit Task 2**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Streaming/GpuWorldState.cs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(vfx #C.1.5a): wire EntityScriptActivator into GpuWorldState lifecycle
|
||||
|
||||
GpuWorldState grows a fourth optional ctor parameter for the activator,
|
||||
paralleling how EntitySpawnAdapter is plumbed. AppendLiveEntity calls
|
||||
OnCreate after the existing _wbEntitySpawnAdapter?.OnCreate;
|
||||
RemoveEntityByServerGuid calls OnRemove after the existing OnRemove.
|
||||
Symmetric, same order, null-safe.
|
||||
|
||||
GameWindow still passes the old 3-arg ctor — activator construction +
|
||||
wire-through lands in the next commit.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Construct activator in `GameWindow` and pass through to `GpuWorldState`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs:35` (field declaration block)
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs:1612-1622` (activator construction + GpuWorldState ctor call)
|
||||
|
||||
- [ ] **Step 3.1 — Add the field declaration**
|
||||
|
||||
Around line 35 in `GameWindow.cs` there is:
|
||||
|
||||
```csharp
|
||||
private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter;
|
||||
```
|
||||
|
||||
Add immediately below:
|
||||
|
||||
```csharp
|
||||
private AcDream.App.Rendering.Vfx.EntityScriptActivator? _entityScriptActivator;
|
||||
```
|
||||
|
||||
- [ ] **Step 3.2 — Build the resolver lambda and construct the activator**
|
||||
|
||||
In the block starting at line 1612 (where `wbEntitySpawnAdapter` is constructed and assigned), the current code is:
|
||||
|
||||
```csharp
|
||||
var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter(
|
||||
_textureCache!, SequencerFactory, _wbMeshAdapter!);
|
||||
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
|
||||
// Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock
|
||||
// so Tier 1 cache entries get swept on LB demote (Near to Far) and unload.
|
||||
// Per spec §5.3 W3b. The callback receives the canonical landblock id
|
||||
// matching the LandblockHint stored at Populate time.
|
||||
_worldState = new AcDream.App.Streaming.GpuWorldState(
|
||||
wbSpawnAdapter,
|
||||
wbEntitySpawnAdapter,
|
||||
onLandblockUnloaded: _classificationCache.InvalidateLandblock);
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```csharp
|
||||
var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter(
|
||||
_textureCache!, SequencerFactory, _wbMeshAdapter!);
|
||||
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
|
||||
|
||||
// Phase C.1.5a: construct EntityScriptActivator so server-spawned static
|
||||
// entities (portals first) fire Setup.DefaultScript through the
|
||||
// PhysicsScriptRunner on enter-world. _scriptRunner and _particleSink
|
||||
// are initialised earlier in OnLoad (line ~1083); both are non-null
|
||||
// here. The resolver lambda captures _dats and swallows dat-lookup
|
||||
// throws — see C.1.5a spec §6 (error handling) for rationale.
|
||||
var capturedDatsForActivator = _dats;
|
||||
uint ResolveDefaultScript(AcDream.Core.World.WorldEntity e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var setup = capturedDatsForActivator?.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
|
||||
return setup?.DefaultScript.DataId ?? 0u;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0u;
|
||||
}
|
||||
}
|
||||
var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator(
|
||||
_scriptRunner!, _particleSink!, ResolveDefaultScript);
|
||||
_entityScriptActivator = entityScriptActivator;
|
||||
|
||||
// Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock
|
||||
// so Tier 1 cache entries get swept on LB demote (Near to Far) and unload.
|
||||
// Per spec §5.3 W3b. The callback receives the canonical landblock id
|
||||
// matching the LandblockHint stored at Populate time.
|
||||
_worldState = new AcDream.App.Streaming.GpuWorldState(
|
||||
wbSpawnAdapter,
|
||||
wbEntitySpawnAdapter,
|
||||
onLandblockUnloaded: _classificationCache.InvalidateLandblock,
|
||||
entityScriptActivator: entityScriptActivator);
|
||||
```
|
||||
|
||||
Two changes: (1) inline construction of activator + resolver between `_wbEntitySpawnAdapter` assignment and the `_worldState =` line, (2) add the `entityScriptActivator: entityScriptActivator` named argument to the `GpuWorldState` constructor call.
|
||||
|
||||
- [ ] **Step 3.3 — Build the project**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj`
|
||||
|
||||
Expected: build succeeds. If you get an "_scriptRunner is null here" warning at the activator construction site, it's a nullable-flow false positive — the runner is built at line 1083 inside the same `OnLoad` method which executes before this block. Use `_scriptRunner!` and `_particleSink!` (already shown above).
|
||||
|
||||
- [ ] **Step 3.4 — Run the full test suite**
|
||||
|
||||
Run: `dotnet test`
|
||||
|
||||
Expected: all tests pass. No new tests added in this task — verification of the wiring is the visual step in Task 4.
|
||||
|
||||
- [ ] **Step 3.5 — Commit Task 3**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(vfx #C.1.5a): construct EntityScriptActivator in GameWindow
|
||||
|
||||
Wires the activator into the production lifecycle:
|
||||
- Construct alongside _wbEntitySpawnAdapter using _scriptRunner +
|
||||
_particleSink (both built earlier in OnLoad).
|
||||
- Production resolver lambda hits _dats.Get<Setup>(...) wrapped in
|
||||
try/catch returning 0 on miss/throw — matches ParticleRenderer's
|
||||
defensive read pattern.
|
||||
- Pass into GpuWorldState's new optional ctor parameter.
|
||||
|
||||
Closes the wiring half of C.1.5a. Visual verification at the Holtburg
|
||||
Town network portal is the acceptance gate.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Visual verification + roadmap update
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/plans/2026-04-11-roadmap.md` (append a "Phase C.1.5a SHIPPED" entry)
|
||||
|
||||
This is a manual verification task with a user-in-the-loop step. Do not mark the slice "done" until the user confirms the portal swirl visually matches retail.
|
||||
|
||||
- [ ] **Step 4.1 — Build green, tests green**
|
||||
|
||||
Run sequentially:
|
||||
|
||||
```powershell
|
||||
dotnet build
|
||||
dotnet test
|
||||
```
|
||||
|
||||
Expected: both green. If either fails: stop and fix before launching.
|
||||
|
||||
- [ ] **Step 4.2 — Kill any stale acdream process from a prior session**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
Start-Sleep -Seconds 3
|
||||
```
|
||||
|
||||
Per [CLAUDE.md](../../../CLAUDE.md) "Logout-before-reconnect" — ACE keeps a session alive briefly after disconnect; relaunching within ~3 s causes a handshake failure that looks like a code bug but isn't.
|
||||
|
||||
- [ ] **Step 4.3 — Launch the live client with PES diagnostics**
|
||||
|
||||
```powershell
|
||||
$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 "c1.5a-verify.log"
|
||||
```
|
||||
|
||||
(Use the Bash tool's `run_in_background: true` parameter so the launch doesn't block the agent on the user's testing.)
|
||||
|
||||
- [ ] **Step 4.4 — Hand off to the user for visual verification**
|
||||
|
||||
Once the client reaches in-world state (~8 s after launch), tell the user:
|
||||
|
||||
> "Client launched with PES diagnostics. Walk `+Acdream` to the Holtburg Town network portal and compare side-by-side with retail. Confirm the portal swirl matches in color, density, motion, and persistence. Reply 'pass' if it matches or describe what differs."
|
||||
|
||||
Wait for the user's response. If they reply with anything other than confirmation, stop and investigate; do NOT proceed to Step 4.5.
|
||||
|
||||
- [ ] **Step 4.5 — On user confirmation: update the roadmap**
|
||||
|
||||
Open `docs/plans/2026-04-11-roadmap.md`. Find the section listing recently-shipped phases (look for "Phase N.6 slice 1" or similar recent entries). Add a new entry above the earlier shipped phases:
|
||||
|
||||
```markdown
|
||||
**Phase C.1.5a (Portal PES wiring) shipped 2026-05-12.** Server-spawned
|
||||
`WorldEntity` entities now fire their `Setup.DefaultScript` through the
|
||||
shipped `PhysicsScriptRunner` on enter-world. New
|
||||
[`EntityScriptActivator`](../../src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs)
|
||||
class wires into `GpuWorldState`'s spawn lifecycle. Visual verification
|
||||
passed at the Holtburg Town network portal. Slice 2 (C.1.5b — EnvCell
|
||||
static objects + animation-hook verification) is the natural next step.
|
||||
Spec: [`docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md`](../superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md).
|
||||
Plan: [`docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md`](../superpowers/plans/2026-05-12-phase-c1.5a-portals.md).
|
||||
```
|
||||
|
||||
If the roadmap has a "Currently in flight" line that mentions C.1.5 or
|
||||
similar, update it: change "in flight" to "Phase C.1.5b (EnvCell statics
|
||||
+ verification) — see [C.1.5a spec §10 slice 2 preview](../superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md)".
|
||||
|
||||
- [ ] **Step 4.6 — Commit the roadmap update**
|
||||
|
||||
```bash
|
||||
git add docs/plans/2026-04-11-roadmap.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs(roadmap #C.1.5a): mark Phase C.1.5a shipped
|
||||
|
||||
Portal PES wiring landed and visually verified at the Holtburg Town
|
||||
network portal. EntityScriptActivator fires Setup.DefaultScript through
|
||||
the shipped PhysicsScriptRunner on entity spawn. C.1.5b (EnvCell static
|
||||
objects + animation-hook verification) is the next slice.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 4.7 — Close the loop**
|
||||
|
||||
Report to the user:
|
||||
- C.1.5a shipped, three commits: activator, wiring, roadmap.
|
||||
- Tests green; visual verification passed.
|
||||
- Suggest brainstorming C.1.5b as the next step (or take a break / pick something else).
|
||||
|
||||
---
|
||||
|
||||
## Self-review against the spec
|
||||
|
||||
Run through the spec's section list and confirm each requirement maps to a plan task:
|
||||
|
||||
- **§2 Scope "in":**
|
||||
- New `EntityScriptActivator` class → Task 1.3 ✓
|
||||
- Wiring in `GpuWorldState` → Task 2.4, 2.5 ✓
|
||||
- Activator constructed in `GameWindow`, passed into `GpuWorldState` → Task 3.2 ✓
|
||||
- Three unit tests → Task 1.1 ✓
|
||||
- Visual verification at Holtburg Town network portal → Task 4.3, 4.4 ✓
|
||||
- **§4 Architecture — file placement under `Rendering/Vfx/`** → Task 1.3 (creates the directory implicitly via the file path) ✓
|
||||
- **§4 Architecture — resolver delegate pattern** → Tests use stubs (Task 1.1); production uses the lambda in `GameWindow` (Task 3.2) ✓
|
||||
- **§4 Trigger condition "has DefaultScript, not is portal"** → Resolver returns `Setup.DefaultScript.DataId ?? 0`; activator gates `if (scriptId == 0) return` (Task 1.3) ✓
|
||||
- **§5 Lifecycle ordering: spawnAdapter → activator** → Task 2.4, 2.5 add the activator call immediately after the existing adapter call ✓
|
||||
- **§6 Error handling — resolver swallows exceptions** → Task 3.2 wraps `_dats.Get<Setup>(...)` in try/catch returning 0 ✓
|
||||
- **§7 Thread safety** → All calls on render thread; no new synchronization needed (covered by inheriting `GpuWorldState`'s existing single-thread contract) ✓
|
||||
- **§8 Three named tests** → Task 1.1 ✓
|
||||
- **§8 Visual verification procedure** → Task 4.3–4.5 ✓
|
||||
|
||||
No gaps.
|
||||
|
||||
Type / name consistency check:
|
||||
- `EntityScriptActivator` is the class name in tests (Task 1.1), the file (Task 1.3), the field in `GpuWorldState` (Task 2.2), the parameter (Task 2.3), and the field + construction in `GameWindow` (Task 3.1, 3.2). Consistent.
|
||||
- `OnCreate(WorldEntity)` / `OnRemove(uint)` signatures match across tests and implementation. ✓
|
||||
- Constructor signature `(PhysicsScriptRunner, ParticleHookSink, Func<WorldEntity, uint>)` matches between tests, implementation, and production wiring. ✓
|
||||
- `ResolveDefaultScript` lambda (Task 3.2) returns `uint` — matches the `Func<WorldEntity, uint>` declared on the activator. ✓
|
||||
|
|
@ -25,9 +25,12 @@ swirl matches retail in color, density, motion, and persistence.
|
|||
**In:**
|
||||
|
||||
- New class `EntityScriptActivator` (one file, ~50 lines).
|
||||
- Wiring of activator's `OnCreate` / `OnRemove` calls into `GameWindow`'s
|
||||
spawn-lifecycle handlers, immediately after the matching `EntitySpawnAdapter`
|
||||
calls.
|
||||
- Wiring of activator's `OnCreate` / `OnRemove` calls into `GpuWorldState`'s
|
||||
spawn-lifecycle methods (`AppendLiveEntity` / `RemoveEntityByServerGuid`),
|
||||
immediately after the matching `EntitySpawnAdapter` calls. The activator is
|
||||
constructed in `GameWindow` (where `_dats`, `_scriptRunner`, and
|
||||
`_particleSink` are in scope) and passed into `GpuWorldState`'s constructor
|
||||
as a new optional parameter, paralleling how `EntitySpawnAdapter` is wired.
|
||||
- Three unit tests covering the activator's three branches
|
||||
(fire / no-op-on-zero / cleanup-on-remove).
|
||||
- Visual verification at the Holtburg Town network portal.
|
||||
|
|
@ -145,18 +148,32 @@ data faithfully and the visual is what retail shows. If retail does NOT show
|
|||
those particles and we do, that's evidence of a different gate retail
|
||||
applies — to be investigated when seen.
|
||||
|
||||
### Wiring point: GameWindow
|
||||
### Wiring point: GpuWorldState
|
||||
|
||||
Live entity spawn / despawn already flows through
|
||||
[`GpuWorldState`](../../../src/AcDream.App/Streaming/GpuWorldState.cs) on the
|
||||
render thread — the network layer pushes spawns into
|
||||
`AppendLiveEntity(landblockId, entity)`, the server's `RemoveObject` opcode
|
||||
routes through `RemoveEntityByServerGuid(serverGuid)`. The existing
|
||||
`EntitySpawnAdapter` lifecycle hooks live at those two call sites
|
||||
(line 345 `OnCreate`, line 285 `OnRemove`). The activator hooks fire
|
||||
immediately after, in the same order:
|
||||
|
||||
```csharp
|
||||
// In GameWindow.OnCreateObject(WorldEntity entity):
|
||||
_entitySpawnAdapter.OnCreate(entity);
|
||||
_entityScriptActivator.OnCreate(entity); // NEW — runs after meshes are registered
|
||||
// GpuWorldState.AppendLiveEntity (line ~345):
|
||||
_wbEntitySpawnAdapter?.OnCreate(entity);
|
||||
_entityScriptActivator?.OnCreate(entity); // NEW — fires DefaultScript
|
||||
|
||||
// In GameWindow.OnRemoveObject(uint serverGuid):
|
||||
_entitySpawnAdapter.OnRemove(serverGuid);
|
||||
_entityScriptActivator.OnRemove(serverGuid); // NEW — runs in same order as create
|
||||
// GpuWorldState.RemoveEntityByServerGuid (line ~285):
|
||||
_wbEntitySpawnAdapter?.OnRemove(serverGuid);
|
||||
_entityScriptActivator?.OnRemove(serverGuid); // NEW — stops scripts + emitters
|
||||
```
|
||||
|
||||
`GpuWorldState`'s constructor grows a fifth (optional) parameter for the
|
||||
activator, paralleling how `EntitySpawnAdapter` is plumbed today. `GameWindow`
|
||||
constructs the activator alongside `_wbEntitySpawnAdapter` and passes it
|
||||
through.
|
||||
|
||||
Production resolver lambda, constructed in `GameWindow` where `_dats` is in
|
||||
scope:
|
||||
|
||||
|
|
@ -183,9 +200,9 @@ The try/catch matches the pattern in
|
|||
### On spawn
|
||||
|
||||
```
|
||||
GameWindow.OnCreateObject(entity)
|
||||
├─ _entitySpawnAdapter.OnCreate(entity) // meshes ref-counted, animation state built
|
||||
└─ _entityScriptActivator.OnCreate(entity)
|
||||
GpuWorldState.AppendLiveEntity(landblockId, entity)
|
||||
├─ _wbEntitySpawnAdapter?.OnCreate(entity) // meshes ref-counted, animation state built
|
||||
└─ _entityScriptActivator?.OnCreate(entity)
|
||||
├─ scriptId = resolver(entity) // Setup.DefaultScript.DataId, or 0 on miss/throw
|
||||
├─ if (scriptId == 0) return // no DefaultScript → no-op
|
||||
└─ _scriptRunner.Play(scriptId,
|
||||
|
|
@ -199,9 +216,9 @@ GameWindow.OnCreateObject(entity)
|
|||
### On despawn
|
||||
|
||||
```
|
||||
GameWindow.OnRemoveObject(serverGuid)
|
||||
├─ _entitySpawnAdapter.OnRemove(serverGuid) // meshes ref-decremented, state cleared
|
||||
└─ _entityScriptActivator.OnRemove(serverGuid)
|
||||
GpuWorldState.RemoveEntityByServerGuid(serverGuid)
|
||||
├─ _wbEntitySpawnAdapter?.OnRemove(serverGuid) // meshes ref-decremented, state cleared
|
||||
└─ _entityScriptActivator?.OnRemove(serverGuid)
|
||||
├─ _scriptRunner.StopAllForEntity(serverGuid) // drop pending hooks
|
||||
└─ _particleSink.StopAllForEntity(serverGuid, false) // kill live emitters (no fade)
|
||||
```
|
||||
|
|
@ -270,7 +287,11 @@ new threading concerns introduced.
|
|||
|
||||
### Unit tests (slice 1's gating tests)
|
||||
|
||||
`tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs`. Uses
|
||||
`tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs` (test
|
||||
project convention: production code lives under `src/AcDream.App/...` but tests
|
||||
go in `AcDream.Core.Tests` — mirrors the existing
|
||||
[`EntitySpawnAdapterTests`](../../../tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs)
|
||||
location). Uses
|
||||
hand-built `PhysicsScriptRunner` + capturing `ParticleHookSink` (or a thin
|
||||
test double). No dats, no GL.
|
||||
|
||||
|
|
@ -286,9 +307,11 @@ test double). No dats, no GL.
|
|||
|
||||
### Integration tests — none for slice 1
|
||||
|
||||
The `GameWindow` wiring is two added lines (one in `OnCreateObject`, one in
|
||||
`OnRemoveObject`). An integration test would require booting GL + dats +
|
||||
network. Coverage is the visual verification gate instead.
|
||||
The `GpuWorldState` wiring is two added lines (one in `AppendLiveEntity`, one
|
||||
in `RemoveEntityByServerGuid`) plus a constructor parameter. An integration
|
||||
test would require booting GL + dats + network. Coverage is the visual
|
||||
verification gate instead. Existing `GpuWorldStateTests` will need a minor
|
||||
update if they assert constructor arity; we extend them if so.
|
||||
|
||||
### Visual verification — the acceptance criterion
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue