Merge branch 'claude/lucid-burnell-aab524' — Phase C.1.5a (portal PES wiring)
Slice 1 of Phase C.1.5: fire Setup.DefaultScript through the already- shipped PhysicsScriptRunner when a server-spawned WorldEntity enters the world. Portals and any other static object with a DefaultScript now emit their retail-faithful persistent particle effects at spawn time, mirroring retail's play_script_internal dispatch on object load. One new ~85-line orchestrator class EntityScriptActivator wires alongside EntitySpawnAdapter in GpuWorldState's spawn lifecycle (AppendLiveEntity → OnCreate; RemoveEntityByServerGuid → OnRemove). Activator construction in GameWindow uses a defensive resolver lambda over _dats.Get<Setup>(...) and also seeds _particleSink.SetEntityRotation so hook offsets transform from entity-local to world space correctly. Four xUnit tests with mutation-check verification. End-to-end: 8 commits, every per-task commit reviewed for spec compliance + code quality; final cross-task review by Opus approved. Visual verification at the Holtburg Town network portal: 10-hook portal script fires with correct color, persistence, orientation, and multi-emitter dispatch. Known limitation surfaced and filed as issue #56: ParticleHookSink ignores CreateParticleHook.PartIndex, so multi-emitter scripts collapse to one root position instead of distributing across the entity's mesh parts. Visually produces a compressed, partly-ground-buried swirl on multi-part dat objects. Mechanism is correct; per-part transform handling is the next vfx-pipeline concern and blocks slice 2 (EnvCell chimneys / fireplaces). Plan: docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md Spec: docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md Issue: docs/ISSUES.md #56 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
88bda12d98
8 changed files with 1404 additions and 2 deletions
|
|
@ -46,6 +46,37 @@ Copy this block when adding a new issue:
|
|||
|
||||
# Active issues
|
||||
|
||||
## #56 — `ParticleHookSink` ignores `CreateParticleHook.PartIndex`; multi-emitter scripts collapse to entity root
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM (every multi-emitter PES on a multi-part entity is visually wrong — portals, chimneys, fireplaces, animation-hook spell effects, anything where the dat author distributed emitters across mesh parts)
|
||||
**Filed:** 2026-05-12
|
||||
**Component:** vfx / `ParticleHookSink` (call-site contract gap with the renderer)
|
||||
|
||||
**Description:** When `EntityScriptActivator` (Phase C.1.5a) fires a multi-emitter `PhysicsScript` such as a portal's `DefaultScript`, every `CreateParticleHook` in the script spawns at `entity.Position + rotated(hook.Offset.Origin)` — ignoring the hook's `PartIndex`. The Holtburg Town network portal's script `0x3300126D` has 10 hooks (8 `CreateParticle` + sounds + sub-script calls) intended to attach to different mesh parts of the Setup (arch base, columns, apex). All 10 collapse to one point, producing a compressed, ground-buried swirl instead of the multi-tier shape retail renders.
|
||||
|
||||
Captured during C.1.5a visual verification 2026-05-12:
|
||||
- Portal A: entity `0x7A9B405B`, script `0x3300126D`, anchor `(27.33, 137.49, 66.30)`, 10 hooks
|
||||
- Portal B: entity `0x7A9B4080`, script `0x3300067A`, anchor `(14.39, 55.61, 78.20)`, 4 hooks
|
||||
User report: "It's less flat [than pre-rotation-fix] but in retail it seems to expand more in all directions. … still buried in the ground."
|
||||
|
||||
**Root cause / status:** Documented in [ParticleHookSink.cs:18-24](../src/AcDream.Core/Vfx/ParticleHookSink.cs#L18) as a known C.1 limitation: "Retail attaches to a specific mesh part; we attach to the entity's root and will refine per-part when the renderer exposes per-part world transforms." The renderer (`WbDrawDispatcher`) does compute per-part transforms each frame for the modern bindless path, but they're not surfaced to the sink. The activator passes only `entity.Position` + `entity.Rotation`; the part-relative offsets the dat author chose are lost.
|
||||
|
||||
**Files:**
|
||||
- [src/AcDream.Core/Vfx/ParticleHookSink.cs:176-217](../src/AcDream.Core/Vfx/ParticleHookSink.cs) (`SpawnFromHook` — currently `anchor = worldPos + Vector3.Transform(offset, rotation)`; missing the `part[PartIndex].Transform` multiplication).
|
||||
- [src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs](../src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs) (would need to pass per-part transforms, or arrange for the sink to query them).
|
||||
- Renderer-side: per-part transforms live in `WbDrawDispatcher` / `AnimatedEntityState`.
|
||||
|
||||
**Research:** For static entities (portals, chimneys, fireplaces), per-part offsets can be precomputed from `Setup.PlacementFrames[Resting]` at spawn time — no animation tick needed. For animated entities, the per-part transform varies per frame and the sink would need a per-tick refresh similar to how `UpdateEntityAnchor` works for AttachLocal emitters today.
|
||||
|
||||
**Acceptance:** The Holtburg Town network portal's swirl matches retail in vertical extent (no ground-burial) and lateral spread (multiple emitters at distinct part positions, not collapsed to one point). Side-by-side dual-client visual check, same procedure as the C.1.5a acceptance gate.
|
||||
|
||||
**Blocks / unblocks:**
|
||||
- Phase C.1.5b (EnvCell static chimneys + fireplaces) will visually disappoint without this fix — chimneys are multi-part dat objects with smoke emitters attached to the chimney-top part.
|
||||
- Phase C.1.5a (portal wiring) shipped without it because the mechanism is correct end-to-end and the gap is a separate concern that benefits every multi-part PES path.
|
||||
|
||||
---
|
||||
|
||||
## #55 — Static-entity slow path reports ~1.45M `meshMissing` per 5s at r4 standstill
|
||||
|
||||
**Status:** OPEN
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@
|
|||
| N.5 | Modern rendering path — lifted `WbDrawDispatcher` onto bindless textures (`GL_ARB_bindless_texture`) + `glMultiDrawElementsIndirect`. Per-frame entity rendering: 3 SSBO uploads (instance matrices @ binding=0, batch data @ binding=1, indirect commands) + 2 indirect draw calls (opaque + transparent). ~12-15 GL calls per frame regardless of group count, down from hundreds-of-per-group in N.4. CPU dispatcher: 1.23 ms/frame median at Holtburg courtyard (1662 groups, ~810 fps sustained). All textures on the WB modern path use 1-layer `Texture2DArray` + `sampler2DArray`. Legacy callers keep `Texture2D` / `sampler2D` via the parallel `TextureCache` path until N.6 retires them. Three gotchas captured in memory: texture target lock-in, bindless Dispose order (two-phase non-resident before delete), GL_TIME_ELAPSED double-buffering. **Ship amendment 2026-05-08:** legacy renderers (`InstancedMeshRenderer`, `StaticMeshRenderer`, `WbFoundationFlag`) retired within N.5 — modern path is mandatory; missing bindless throws `NotSupportedException` at startup. N.6 scope narrowed accordingly. Plan archived at `docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md`. | Live ✓ |
|
||||
| N.5b | Terrain on the modern rendering path — `TerrainModernRenderer` replaces `TerrainChunkRenderer` (the latter plus `TerrainRenderer` + `terrain.vert/.frag` deleted). Single global VBO/EBO with slot allocator (one slot per landblock), per-frame `DrawElementsIndirectCommand[]` upload + `glMultiDrawElementsIndirect`, bindless atlas handles passed as `uvec2` uniforms reconstructed via `sampler2DArray(handle)`. **Path C** chosen: mirrors WB's `TerrainRenderManager` pattern but consumes `LandblockMesh.Build` so retail's `FSplitNESW` formula is preserved (closes ISSUE #51). Path A killed by 49.98% measured divergence between WB's `CalculateSplitDirection` and retail's at addr `00531d10`; Path B (fork-patch WB) rejected for permanent maintenance burden. Perf at Holtburg radius=5 (commit `da56063`): modern 6.4-7.0 µs / 9-14 µs p95 vs legacy 1.5 µs / 3.0 µs — **modern is ~4× SLOWER on CPU at radius=5** because legacy's 16×16-LB chunking collapsed visible LBs to one `glDrawElements`. Architectural wins (zero `glBindTexture`/frame, constant-cost dispatch, per-LB frustum cull) manifest at higher radius (A.5 territory). Spec acceptance criterion 5 ("≥10% lower CPU at radius=5") amended via `docs/plans/2026-05-09-phase-n5b-perf-baseline.md`. Three gotchas captured in memory: `uniform sampler2DArray` + `glProgramUniformHandleARB` GL_INVALID_OPERATIONs on at least one driver (use `uniform uvec2` + `sampler2DArray(handle)` constructor instead — N.5's mesh_modern pattern); `MaybeFlushTerrainDiag` median-calc underflow on first sample; visual gates need actual visual confirmation, not assent. Plan archived at `docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md`. | Live ✓ |
|
||||
| N.6 slice 1 | GPU timing fix + radius=12 perf baseline. Fixed the gpu_us double-buffering bug in `WbDrawDispatcher` (ring-of-3 query slots, read-before-overwrite, vendor-neutral across AMD/NVIDIA/Intel desktop GL). Added env-gated `ACDREAM_DUMP_SURFACES=1` one-shot surface-format histogram dump in `TextureCache` for the atlas-opportunity audit. Captured authoritative baseline at Holtburg radii 4 / 8 / 12 (standstill + walking) with the now-working `gpu_us` diagnostic; baseline doc concludes CPU dominates GPU by 30–50× at every radius and recommends C.1.5 next then reduced-scope slice 2 (atlas + persistent-mapped buffers dropped). Baseline numbers at [docs/plans/2026-05-11-phase-n6-perf-baseline.md](2026-05-11-phase-n6-perf-baseline.md). Plan archived at `docs/superpowers/plans/2026-05-11-phase-n6-slice1.md`. | Live ✓ |
|
||||
| C.1.5a | Portal PES wiring — server-spawned `WorldEntity` entities now fire their `Setup.DefaultScript` through the already-shipped `PhysicsScriptRunner` on enter-world. New ~70-line [`EntityScriptActivator`](../../src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs) class wires into `GpuWorldState`'s spawn lifecycle (`AppendLiveEntity` → `OnCreate`, `RemoveEntityByServerGuid` → `OnRemove`). Resolver lambda in `GameWindow` hits `_dats.Get<Setup>(...)?.DefaultScript.DataId` with defensive try/catch returning `0u` on miss. Activator also seeds `_particleSink.SetEntityRotation` so hook offsets transform from entity-local to world space correctly. **Verified at the Holtburg Town network portal**: 10-hook portal script fires end-to-end with correct color, persistence, orientation, multi-emitter dispatch. **Known limitation surfaced and filed as issue #56**: `ParticleHookSink` ignores `CreateParticleHook.PartIndex`, so the 10 emitters collapse to one root position instead of distributing across the portal Setup's parts — visually produces a compressed, partly-ground-buried swirl. Mechanism is correct; per-part transform handling is the next vfx-pipeline work (blocks slice 2 visual delight; affects every multi-emitter PES). 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). | Live ✓ (with #56) |
|
||||
|
||||
Plus polish that doesn't get its own phase number:
|
||||
- FlyCamera default speed lowered + Shift-to-boost
|
||||
|
|
|
|||
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. ✓
|
||||
385
docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md
Normal file
385
docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
# Phase C.1.5a — Portal PES wiring (Setup.DefaultScript on entity spawn)
|
||||
|
||||
**Created:** 2026-05-12.
|
||||
**Author:** Claude (lead engineer/architect).
|
||||
**Phase:** C.1.5a (first of two slices; C.1.5b covers EnvCell statics + animation-hook verification).
|
||||
**Parent plan:** [`docs/plans/2026-04-27-phase-c1-pes-particles.md`](../../plans/2026-04-27-phase-c1-pes-particles.md) §C.1.5.
|
||||
**Baseline justification:** [`docs/plans/2026-05-11-phase-n6-perf-baseline.md`](../../plans/2026-05-11-phase-n6-perf-baseline.md) §4 — C.1.5 is the right next phase; production preset is comfortable, no perf escalation pressure.
|
||||
|
||||
---
|
||||
|
||||
## §1 Goal
|
||||
|
||||
Make server-spawned `WorldEntity` portals emit their retail-faithful particle
|
||||
effects (portal swirls) at spawn time. Implement by **firing `Setup.DefaultScript`
|
||||
through the already-shipped `PhysicsScriptRunner`** at the moment the entity
|
||||
enters the world, mirroring retail's `play_script_internal` dispatch on object
|
||||
spawn.
|
||||
|
||||
Acceptance: the user walks `+Acdream` up to the **Holtburg Town network portal**,
|
||||
opens a side-by-side comparison with a retail AC client, and confirms the portal
|
||||
swirl matches retail in color, density, motion, and persistence.
|
||||
|
||||
## §2 Scope
|
||||
|
||||
**In:**
|
||||
|
||||
- New class `EntityScriptActivator` (one file, ~50 lines).
|
||||
- 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.
|
||||
|
||||
**Out (deferred to C.1.5b):**
|
||||
|
||||
- `EnvCell.StaticObjects` walker for interior chimneys / fireplaces.
|
||||
- Animation-hook particle path verification (already wired in C.1; needs
|
||||
a confirming check, deferred so this slice stays small).
|
||||
- The WB-style "re-fire after 1 second" loop logic for non-persistent emitters
|
||||
([`ParticleEmitterRenderer.cs:119-130`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleEmitterRenderer.cs) in WB).
|
||||
Portal swirls are persistent (`TotalParticles=0 && TotalSeconds=0`) and don't
|
||||
need it. If C.1.5b discovers EnvCell static objects need it, that slice adds it.
|
||||
|
||||
**Out (out of phase entirely):**
|
||||
|
||||
- Renderer changes. `particle.frag` stays as-is; bindless migration is N.6
|
||||
slice 2 territory.
|
||||
- Performance work. Per [baseline §4](../../plans/2026-05-11-phase-n6-perf-baseline.md),
|
||||
CPU at the production preset is comfortable and there is no GPU pressure.
|
||||
- Adding `WeenieClassId` to `WorldEntity`. Trigger is "has DefaultScript",
|
||||
not "is portal" (see §4 Architecture for rationale).
|
||||
|
||||
## §3 Background
|
||||
|
||||
### Why this works today for *some* particles, not portals
|
||||
|
||||
C.1 shipped a complete particle pipeline:
|
||||
[`EmitterDescRegistry`](../../../src/AcDream.Core/Vfx/EmitterDescRegistry.cs)
|
||||
(data) → [`ParticleSystem`](../../../src/AcDream.Core/Vfx/ParticleSystem.cs) (sim)
|
||||
→ [`ParticleHookSink`](../../../src/AcDream.Core/Vfx/ParticleHookSink.cs)
|
||||
(dispatch) → [`PhysicsScriptRunner`](../../../src/AcDream.Core/Vfx/PhysicsScriptRunner.cs)
|
||||
(script scheduler) → [`ParticleRenderer`](../../../src/AcDream.App/Rendering/ParticleRenderer.cs)
|
||||
(draw).
|
||||
|
||||
The chain is end-to-end, but `PhysicsScriptRunner.Play` is only called from
|
||||
**two places today**:
|
||||
|
||||
1. The server-driven `PlayScript (0xF754)` opcode handler in `GameWindow` —
|
||||
spell casts, combat hits, emote effects.
|
||||
2. The animation-hook path inside `MotionInterpreter` — feet sparks, weapon
|
||||
trails (via `ParticleHookSink` directly, not through the runner).
|
||||
|
||||
**Nothing fires `Setup.DefaultScript` when a static entity spawns.** Retail
|
||||
does this (per the named decomp's `play_script_internal` analysis), and
|
||||
`WorldBuilder` does the equivalent at mesh-prep time
|
||||
([`ObjectMeshManager.cs:797`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs)).
|
||||
Acdream skips it — every portal lacks its swirl, every chimney lacks its smoke.
|
||||
|
||||
### Why not consume WB's staged emitters
|
||||
|
||||
WB's `ObjectMeshManager.PrepareSetupMeshData` (line 771–795) collects
|
||||
`StagedEmitter` entries from `setup.DefaultScript` and attaches them to
|
||||
`ObjectMeshData.ParticleEmitters`. Three reasons we don't consume them:
|
||||
|
||||
1. `WbMeshAdapter` calls `PrepareMeshDataAsync(id, isSetup: false)` — we go
|
||||
through the per-part GfxObj path, not the Setup path
|
||||
([`WbMeshAdapter.cs:136`](../../../src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs)).
|
||||
Flipping that breaks shipped N.4/N.5 dispatcher assumptions.
|
||||
2. WB's `CollectEmittersFromScript` drops the script's per-hook `StartTime`
|
||||
offsets — it spawns every `CreateParticleHook` immediately. Our
|
||||
`PhysicsScriptRunner` honors `StartTime` and is more retail-faithful.
|
||||
3. C.1 already shipped a runner that *is* the equivalent of retail's
|
||||
`play_script_internal`. Adding the missing call sites is cheaper and
|
||||
structurally cleaner than building a parallel emitter-staging path.
|
||||
|
||||
## §4 Architecture
|
||||
|
||||
### New class
|
||||
|
||||
`src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`. New `Vfx/`
|
||||
subdirectory under `Rendering/` — sits next to `ParticleRenderer.cs` and is
|
||||
**not** under `Wb/` because the activator drives our own `PhysicsScriptRunner`
|
||||
and has no WB dependency.
|
||||
|
||||
Constructor — mirrors `EntitySpawnAdapter`'s factory-delegate pattern so the
|
||||
activator has no `DatCollection` coupling and is fully unit-testable with
|
||||
stubs:
|
||||
|
||||
```csharp
|
||||
public EntityScriptActivator(
|
||||
PhysicsScriptRunner scriptRunner,
|
||||
ParticleHookSink particleSink,
|
||||
Func<WorldEntity, uint> defaultScriptResolver)
|
||||
```
|
||||
|
||||
The resolver returns the entity's `Setup.DefaultScript.DataId`, or `0` if the
|
||||
Setup is missing / the dat throws / the field is zero. **The resolver swallows
|
||||
exceptions; the activator stays a thin orchestrator.**
|
||||
|
||||
Public surface — two methods only:
|
||||
|
||||
```csharp
|
||||
public void OnCreate(WorldEntity entity);
|
||||
public void OnRemove(uint serverGuid);
|
||||
```
|
||||
|
||||
No state on the activator. `PhysicsScriptRunner` already tracks per-entity
|
||||
script instances by `(scriptId, entityId)`; `ParticleHookSink` already tracks
|
||||
per-entity emitter handles. The activator doesn't duplicate that bookkeeping.
|
||||
|
||||
### Trigger condition: "has DefaultScript", not "is portal"
|
||||
|
||||
`WorldEntity` carries no `WeenieClassId` / `ObjectType` field
|
||||
([`WorldEntity.cs`](../../../src/AcDream.Core/World/WorldEntity.cs)). We
|
||||
*could* add one, but the WB-faithful trigger is "this entity's Setup has a
|
||||
non-zero `DefaultScript`," which is also what retail's
|
||||
`play_script_internal(setup.DefaultScript)` does at object load.
|
||||
|
||||
Side effect of this choice: **the activator will fire DefaultScript for any
|
||||
server-spawned entity whose Setup has one**, not just portals. This is
|
||||
correct retail behavior. If a non-portal entity spawns visible unwanted
|
||||
particles in slice 1, that means our resolver is reading retail's intended
|
||||
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: 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
|
||||
// GpuWorldState.AppendLiveEntity (line ~345):
|
||||
_wbEntitySpawnAdapter?.OnCreate(entity);
|
||||
_entityScriptActivator?.OnCreate(entity); // NEW — fires DefaultScript
|
||||
|
||||
// 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:
|
||||
|
||||
```csharp
|
||||
entity =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return _dats.Get<Setup>(entity.SourceGfxObjOrSetupId)?.DefaultScript.DataId ?? 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The try/catch matches the pattern in
|
||||
[`ParticleRenderer.cs:296-318`](../../../src/AcDream.App/Rendering/ParticleRenderer.cs)
|
||||
(`ReadParticleGfxInfo`).
|
||||
|
||||
## §5 Data flow + lifecycle
|
||||
|
||||
### On spawn
|
||||
|
||||
```
|
||||
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,
|
||||
entity.ServerGuid,
|
||||
entity.Position)
|
||||
└─ PhysicsScriptRunner schedules hooks at their StartTime offsets;
|
||||
each CreateParticleHook → ParticleHookSink → ParticleSystem
|
||||
spawns the ParticleEmitter dat at the entity's anchor.
|
||||
```
|
||||
|
||||
### On despawn
|
||||
|
||||
```
|
||||
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)
|
||||
```
|
||||
|
||||
Order on both is `spawnAdapter → activator`. Symmetric.
|
||||
|
||||
### Persistence (no re-fire logic needed)
|
||||
|
||||
Portal swirls are persistent emitters: their `ParticleEmitter` dat has
|
||||
`TotalParticles=0` AND `TotalSeconds=0`.
|
||||
[`ParticleSystem.Tick`](../../../src/AcDream.Core/Vfx/ParticleSystem.cs)
|
||||
only flips `Finished` when `TotalDuration > 0` or `TotalParticles > 0`, so
|
||||
both-zero emitters never finish. They keep emitting until
|
||||
`StopAllForEntity` kills them on despawn.
|
||||
|
||||
WB's `_deadTimer` re-fire-after-1s (line 119–130 of
|
||||
[`ParticleEmitterRenderer.cs`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleEmitterRenderer.cs))
|
||||
is for non-persistent emitters that should loop (`TotalSeconds > 0`, finishes,
|
||||
1s gap, re-emit). Portals don't use it. Defer to C.1.5b if EnvCell static
|
||||
objects need it.
|
||||
|
||||
### Idempotency
|
||||
|
||||
- Duplicate `OnCreate` for same `serverGuid` — `PhysicsScriptRunner.Play`
|
||||
dedupes by `(scriptId, entityId)` and replaces the prior instance
|
||||
([`PhysicsScriptRunner.cs:136-140`](../../../src/AcDream.Core/Vfx/PhysicsScriptRunner.cs)).
|
||||
✓
|
||||
- Duplicate `OnRemove` — both `StopAllForEntity` calls no-op on unknown guid. ✓
|
||||
- `OnRemove` for never-spawned guid — same no-op behavior. ✓
|
||||
|
||||
### Position handling
|
||||
|
||||
Portals are stationary. `entity.Position` captured at spawn time is the anchor
|
||||
for all of the script's hooks. We do not refresh per-frame.
|
||||
|
||||
**Known limitation (documented, not fixed in slice 1):** if a portal is ever
|
||||
relocated via server `SetPosition`, emitters stay at the old anchor. If this
|
||||
case appears in practice we add a position-update handler — but no current
|
||||
evidence retail's portals move.
|
||||
|
||||
## §6 Error handling
|
||||
|
||||
Failure modes and behavior:
|
||||
|
||||
| Failure | Behavior | Notes |
|
||||
|---|---|---|
|
||||
| `entity.SourceGfxObjOrSetupId` references a missing Setup | resolver returns `0` | activator no-ops; standard streaming flicker handling |
|
||||
| `_dats.Get<Setup>(...)` throws | resolver returns `0` | try/catch in the resolver lambda |
|
||||
| `Setup.DefaultScript.DataId == 0` | resolver returns `0` | activator no-ops; entity has no persistent script |
|
||||
| `PhysicsScript` dat lookup misses inside `Play` | `Play` returns `false` | runner already handles; activator does nothing |
|
||||
| `EmitterDescRegistry` miss for a `CreateParticleHook.EmitterInfoId` | exception propagates through `PhysicsScriptRunner.DispatchHook` (currently uncaught) | pre-existing C.1 behavior; out of scope for this slice. File an issue if observed in verification. |
|
||||
|
||||
All failure paths are silent (no exceptions surface to the caller). Diagnostic
|
||||
visibility comes from `ACDREAM_DUMP_PLAYSCRIPT=1` — every successful `Play`
|
||||
and every fired hook prints. A missing portal swirl in verification is
|
||||
diagnosed by checking the log for the missing entity's guid.
|
||||
|
||||
## §7 Thread safety
|
||||
|
||||
All calls execute on the render thread (where `EntitySpawnAdapter` already
|
||||
runs). `PhysicsScriptRunner` is single-threaded by design.
|
||||
`ParticleHookSink` uses `ConcurrentDictionary` and is safe regardless. No
|
||||
new threading concerns introduced.
|
||||
|
||||
## §8 Testing
|
||||
|
||||
### Unit tests (slice 1's gating tests)
|
||||
|
||||
`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.
|
||||
|
||||
1. **`OnCreate_WithDefaultScript_FiresRunnerWithEntityGuidAndPosition`** —
|
||||
stub resolver returns `0x33000001`; assert `runner.Play(0x33000001,
|
||||
entity.ServerGuid, entity.Position)` was called exactly once.
|
||||
2. **`OnCreate_WithoutDefaultScript_DoesNothing`** — stub resolver returns
|
||||
`0`; assert no `Play` call.
|
||||
3. **`OnRemove_StopsScriptsAndEmitters`** — sequence an `OnCreate(entity)`
|
||||
then `OnRemove(entity.ServerGuid)`; assert `runner.StopAllForEntity` and
|
||||
`sink.StopAllForEntity` were each called once with the matching guid, and
|
||||
`sink.StopAllForEntity` was passed `fadeOut: false`.
|
||||
|
||||
### Integration tests — none for slice 1
|
||||
|
||||
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
|
||||
|
||||
Procedure (per [CLAUDE.md](../../../CLAUDE.md) "Visual verification workflow"):
|
||||
|
||||
1. `dotnet build` green.
|
||||
2. `dotnet test` green (the three new unit tests plus the existing suite).
|
||||
3. Launch live client with `ACDREAM_DUMP_PLAYSCRIPT=1` exported.
|
||||
4. Walk `+Acdream` from spawn to the **Holtburg Town network portal**.
|
||||
5. In parallel, a retail AC client viewing the same portal.
|
||||
6. **User confirms**: the portal-swirl effect in acdream matches retail in
|
||||
color, density, motion, and persistence.
|
||||
|
||||
If verification fails (e.g. portal Setup has `DefaultScript=0` in the dat),
|
||||
the diagnostic log shows whether `Play` fired and with what scriptId. We
|
||||
investigate the actual data path in retail's named decomp before iterating —
|
||||
do not blindly retry.
|
||||
|
||||
## §9 Limitations + known gaps (post-slice-1)
|
||||
|
||||
These are intentionally not fixed in slice 1; tracked here so the next slice
|
||||
or a future phase picks them up:
|
||||
|
||||
1. **`PartIndex` collapse on multi-part entities** (NEW — verified 2026-05-12
|
||||
at the Holtburg Town network portal). `ParticleHookSink.SpawnFromHook`
|
||||
ignores `CreateParticleHook.PartIndex`, so every emitter in a multi-emitter
|
||||
script collapses to `entity.Position + rotated(hook.Offset.Origin)`. Retail
|
||||
distributes the script's emitters across the entity's mesh parts (arch base,
|
||||
columns, apex). Visual symptom for the Holtburg portal: the 10-hook script
|
||||
produces a compressed swirl partially buried in the ground instead of the
|
||||
multi-tier shape retail renders. Filed as `docs/ISSUES.md` #56 with the
|
||||
captured entity guids + script ids; affects slice 2 (EnvCell chimneys /
|
||||
fireplaces are multi-part) and any future multi-emitter PES path.
|
||||
2. **Moving entities** don't re-anchor their DefaultScript emitters per
|
||||
frame. No evidence retail's portals or chimneys move; revisit if visual
|
||||
verification surfaces a regression.
|
||||
3. **WB's re-fire-after-1s loop** is not implemented. Persistent emitters
|
||||
work today; looping non-persistent emitters (if EnvCell static objects
|
||||
use them) would need it in C.1.5b.
|
||||
4. **Animation-hook particle path** (`MotionInterpreter` →
|
||||
`ParticleHookSink`) is shipped in C.1 but **not verified** by a recent
|
||||
visual test in this codebase state. Confirming this path is the second
|
||||
half of C.1.5b.
|
||||
|
||||
## §10 Slice 2 preview (C.1.5b)
|
||||
|
||||
For context, not part of this slice's work:
|
||||
|
||||
- **Walker for `EnvCell.StaticObjects`.** Each static object has a Setup
|
||||
reference; same `DefaultScript` dispatch applies. Needs a synthetic
|
||||
entity-id scheme because static objects have no `ServerGuid`. Likely:
|
||||
hash of `(landblockId, cellIndex, staticIndex)` → 32-bit synthetic id with
|
||||
a marker high bit so it doesn't collide with server guids.
|
||||
- **Verification step for animation hooks.** Cast a spell or trigger an
|
||||
emote on `+Acdream`, observe the particle effect, compare to retail.
|
||||
- **Possible: WB re-fire-after-1s logic** in `ParticleSystem` if EnvCell
|
||||
static-object PES data needs it.
|
||||
|
||||
C.1.5b spec lands after C.1.5a verification passes.
|
||||
|
||||
## §11 Implementation notes
|
||||
|
||||
- The new directory `src/AcDream.App/Rendering/Vfx/` is created by this
|
||||
slice. `ParticleRenderer.cs` stays where it is (under `Rendering/`); the
|
||||
new `Vfx/` is for spawn-time orchestration classes only.
|
||||
- Estimated effort: ~1 day. Activator is small, wiring is two lines, tests
|
||||
are three cases.
|
||||
- No CLAUDE.md updates required by this slice — the C.1.5a / C.1.5b split is
|
||||
internal to the C.1 phase plan.
|
||||
- Roadmap update: on ship, add a "Phase C.1.5a SHIPPED 2026-05-12" entry to
|
||||
[`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md).
|
||||
|
|
@ -33,6 +33,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// after <c>OnLoad</c> completes (modern path is mandatory as of N.5).</summary>
|
||||
private AcDream.App.Rendering.Wb.WbMeshAdapter? _wbMeshAdapter;
|
||||
private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter;
|
||||
private AcDream.App.Rendering.Vfx.EntityScriptActivator? _entityScriptActivator;
|
||||
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
|
||||
/// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
|
||||
/// support. Required at startup — missing bindless throws
|
||||
|
|
@ -1612,6 +1613,29 @@ public sealed class GameWindow : IDisposable
|
|||
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.
|
||||
uint ResolveDefaultScript(AcDream.Core.World.WorldEntity e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var setup = capturedDats?.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
|
||||
|
|
@ -1619,7 +1643,8 @@ public sealed class GameWindow : IDisposable
|
|||
_worldState = new AcDream.App.Streaming.GpuWorldState(
|
||||
wbSpawnAdapter,
|
||||
wbEntitySpawnAdapter,
|
||||
onLandblockUnloaded: _classificationCache.InvalidateLandblock);
|
||||
onLandblockUnloaded: _classificationCache.InvalidateLandblock,
|
||||
entityScriptActivator: entityScriptActivator);
|
||||
|
||||
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
|
||||
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
|
||||
|
|
|
|||
93
src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs
Normal file
93
src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
using System;
|
||||
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;
|
||||
|
||||
// Seed the sink's per-entity rotation so CreateParticleHook.Offset.Origin
|
||||
// (in entity-local frame) transforms correctly to world space when the
|
||||
// hook fires. Without this, the sink falls through to Quaternion.Identity
|
||||
// and the offset gets applied in world axes — visual symptom for portals:
|
||||
// swirl oriented along world XYZ instead of the portal's facing, partially
|
||||
// buried because the local-Z lift becomes a world-axis offset.
|
||||
_particleSink.SetEntityRotation(entity.ServerGuid, entity.Rotation);
|
||||
|
||||
_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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.World;
|
||||
|
||||
|
|
@ -41,6 +42,7 @@ public sealed class GpuWorldState
|
|||
{
|
||||
private readonly LandblockSpawnAdapter? _wbSpawnAdapter;
|
||||
private readonly EntitySpawnAdapter? _wbEntitySpawnAdapter;
|
||||
private readonly EntityScriptActivator? _entityScriptActivator;
|
||||
|
||||
/// <summary>
|
||||
/// Phase Post-A.5 #53 (Task 12): optional callback fired before
|
||||
|
|
@ -57,11 +59,13 @@ public sealed class GpuWorldState
|
|||
public GpuWorldState(
|
||||
LandblockSpawnAdapter? wbSpawnAdapter = null,
|
||||
EntitySpawnAdapter? wbEntitySpawnAdapter = null,
|
||||
System.Action<uint>? onLandblockUnloaded = null)
|
||||
System.Action<uint>? onLandblockUnloaded = null,
|
||||
EntityScriptActivator? entityScriptActivator = null)
|
||||
{
|
||||
_wbSpawnAdapter = wbSpawnAdapter;
|
||||
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
|
||||
_onLandblockUnloaded = onLandblockUnloaded;
|
||||
_entityScriptActivator = entityScriptActivator;
|
||||
}
|
||||
|
||||
private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
|
||||
|
|
@ -283,6 +287,7 @@ public sealed class GpuWorldState
|
|||
// Phase N.4 Task 17: release per-instance state for server-spawned
|
||||
// entities. No-op for atlas-tier entities (never registered).
|
||||
_wbEntitySpawnAdapter?.OnRemove(serverGuid);
|
||||
_entityScriptActivator?.OnRemove(serverGuid);
|
||||
|
||||
bool rebuiltLoaded = false;
|
||||
|
||||
|
|
@ -343,6 +348,7 @@ public sealed class GpuWorldState
|
|||
// per-instance adapter. Atlas-tier entities (ServerGuid == 0) are
|
||||
// skipped by OnCreate — it returns null immediately for those.
|
||||
_wbEntitySpawnAdapter?.OnCreate(entity);
|
||||
_entityScriptActivator?.OnCreate(entity);
|
||||
|
||||
uint canonicalLandblockId = (landblockId & 0xFFFF0000u) | 0xFFFFu;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,210 @@
|
|||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persistent emitter: TotalDuration=0 and TotalParticles=0 prevent
|
||||
/// auto-finish; InitialParticles=1 ensures a particle spawns at t=0
|
||||
/// without waiting for the Birthrate timer; Lifespan=999f keeps that
|
||||
/// particle alive far past the test horizon.
|
||||
/// </summary>
|
||||
private static EmitterDesc BuildPersistentEmitterDesc() => new()
|
||||
{
|
||||
DatId = 100u,
|
||||
Type = ParticleType.Still,
|
||||
EmitterKind = ParticleEmitterKind.BirthratePerSec,
|
||||
MaxParticles = 4,
|
||||
InitialParticles = 1,
|
||||
TotalParticles = 0, // 0 = no particle-count cap
|
||||
TotalDuration = 0f, // 0 = no time-based finish
|
||||
Lifespan = 999f,
|
||||
LifetimeMin = 999f,
|
||||
LifetimeMax = 999f,
|
||||
Birthrate = 0.5f,
|
||||
StartSize = 0.5f,
|
||||
EndSize = 0.5f,
|
||||
StartAlpha = 1f,
|
||||
EndAlpha = 1f,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_SetsEntityRotationForHookOffsetTransform()
|
||||
{
|
||||
// The CreateParticleHook's Offset is in entity-local frame; the sink
|
||||
// needs the entity's rotation to transform it to world space. If the
|
||||
// activator forgets SetEntityRotation, the offset goes off in world
|
||||
// axes — visual symptom: portal swirls misaligned to the portal stone.
|
||||
// This test verifies the seed happens by checking the spawned particle's
|
||||
// world position matches the rotated offset, not the unrotated offset.
|
||||
|
||||
var registry = new EmitterDescRegistry();
|
||||
registry.Register(BuildPersistentEmitterDesc());
|
||||
|
||||
var system = new ParticleSystem(registry);
|
||||
var hookSink = new ParticleHookSink(system);
|
||||
|
||||
// Hook offset = (1, 0, 0) in entity-local frame.
|
||||
var hookOffset = new Frame
|
||||
{
|
||||
Origin = new Vector3(1f, 0f, 0f),
|
||||
Orientation = Quaternion.Identity,
|
||||
};
|
||||
var script = BuildScript(
|
||||
(0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = hookOffset }));
|
||||
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, _ => 0xAAu);
|
||||
|
||||
// Entity rotated 90° around world-Z (yaw left); local +X maps to world +Y.
|
||||
var entityRotation = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ, MathF.PI / 2f);
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 0xCAFEu,
|
||||
ServerGuid = 0xCAFEu,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = entityRotation,
|
||||
MeshRefs = System.Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
activator.OnCreate(entity);
|
||||
runner.Tick(0.001f);
|
||||
system.Tick(0.001f);
|
||||
|
||||
// Find the live particle. With the rotation applied, world position of
|
||||
// the local-(1,0,0) offset should be approximately world-(0,1,0). Without
|
||||
// the rotation seed (the bug), it would be world-(1,0,0).
|
||||
var live = system.EnumerateLive().FirstOrDefault();
|
||||
Assert.NotNull(live.Emitter);
|
||||
var worldPos = live.Emitter.Particles[live.Index].Position;
|
||||
Assert.InRange(worldPos.X, -0.01f, 0.01f);
|
||||
Assert.InRange(worldPos.Y, 0.99f, 1.01f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnRemove_StopsScriptsAndEmitters()
|
||||
{
|
||||
// For this test we need the runner to dispatch into the REAL
|
||||
// ParticleHookSink so OnRemove's sink.StopAllForEntity has a live
|
||||
// emitter to kill. This is the only observable way to verify the
|
||||
// call had effect without subclassing the sealed sink.
|
||||
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); // runner dispatches into real sink, not RecordingSink
|
||||
|
||||
var activator = new EntityScriptActivator(runner, hookSink, _ => 0xAAu);
|
||||
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
|
||||
|
||||
activator.OnCreate(entity);
|
||||
runner.Tick(0.001f); // fires the CreateParticleHook → spawns emitter
|
||||
|
||||
Assert.True(system.ActiveEmitterCount > 0,
|
||||
"Setup precondition failed: emitter should be alive after the hook fires.");
|
||||
|
||||
activator.OnRemove(0xCAFEu);
|
||||
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
// sink.StopAllForEntity marks the emitter Finished; system.Tick reaps it.
|
||||
system.Tick(0.01f);
|
||||
Assert.Equal(0, system.ActiveEmitterCount);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue