diff --git a/CLAUDE.md b/CLAUDE.md index 194e375..f883018 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -526,23 +526,43 @@ acdream's plan lives in two files committed to the repo: acceptance criteria. Do not drift from the spec without explicit user approval. -**Currently in flight: Phase C.1.5b — issue #56 (per-part transforms for -multi-emitter PES) + EnvCell static-object `DefaultScript` walker.** -Pickup at [`docs/plans/2026-05-12-phase-c1.5b-handoff.md`](docs/plans/2026-05-12-phase-c1.5b-handoff.md). -#56 first per the C.1.5a final reviewer — every multi-emitter PES on a -multi-part entity (portals, chimneys, fireplaces) currently collapses -its emitters to entity root because `ParticleHookSink` ignores -`CreateParticleHook.PartIndex`. The mechanism is correct end-to-end; -the part-relative transform application is the gap. +**Currently between phases.** Phase C.1.5b just shipped; next phase is +the user's call from the candidate list below. + +**Phase C.1.5b (per-part PES transforms + dat-hydrated entity DefaultScript) +shipped 2026-05-12.** Closes issue #56. `SetupPartTransforms.Compute(setup)` +walks `PlacementFrames[Resting]` → `[Default]` → first-available and +returns one `Matrix4x4` per Setup part; `ParticleHookSink.SpawnFromHook` +now transforms each `CreateParticleHook.Offset` through +`partTransforms[PartIndex]` before applying entity rotation, so +multi-emitter scripts distribute across mesh parts instead of collapsing +to entity root. The `EntityScriptActivator.OnCreate` `ServerGuid==0` +guard was relaxed: it now keys by `entity.ServerGuid` when non-zero, else +`entity.Id` (the `0x40xxxxxx` interior-entity range is collision-free +with server guids, so no synthetic-ID scheme is needed). `GpuWorldState` +fires the activator from 4 new sites — `AddLandblock` + +`AddEntitiesToExistingLandblock` (Far→Near promotion) for OnCreate, +`RemoveLandblock` + `RemoveEntitiesFromLandblock` (Near→Far demotion) +for OnRemove — so dat-hydrated EnvCell statics (inn fireplaces, building +decorations) and exterior stabs (cottage chimneys) now activate their +`Setup.DefaultScript` automatically. **Reality discovery during design +(folded into spec §3):** EnvCell `StaticObjects` are already hydrated as +`WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming` +with stable `entity.Id` in `0x40xxxxxx` — the handoff's §4 Q1/Q2 +(synthetic ID scheme, separate walker class) were mooted by this. +**Visual-verified 2026-05-12** at Holtburg Town network portal (no +ground-burial, distributed swirl), Inn fireplace flames, cottage +chimney smoke, and a spell cast on `+Acdream`. Plan archived at +[`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](docs/superpowers/plans/2026-05-13-phase-c1.5b.md). **Phase C.1.5a (portal PES wiring) shipped 2026-05-11** (merge `88bda12`). Server-spawned `WorldEntity` entities fire their `Setup.DefaultScript` -through `PhysicsScriptRunner` on enter-world via the new ~70-line +through `PhysicsScriptRunner` on enter-world via the `EntityScriptActivator` ([src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs](src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs)). Visual-verified at the Holtburg Town network portal: 10-hook portal script fires end-to-end with correct color, persistence, orientation, -multi-emitter dispatch. Known visual gap filed as #56 (per-part -transform handling); resolution is slice A of C.1.5b. Plan archived at +multi-emitter dispatch. Filed #56 for per-part transform handling +(resolved in C.1.5b above). Plan archived at [`docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md`](docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md). **Phase N.6 slice 1 (gpu_us fix + radius=12 perf baseline) shipped @@ -564,7 +584,7 @@ issues closed: #52 (lifestone, `e40159f`), #54 (JobKind, `bf31e59`), together comprise the streaming + rendering perf foundation for the project. -**After C.1.5b, candidate next phases (in rough preference order):** +**Next phase candidates (in rough preference order):** - **Triage the chronic open-issue list** in `docs/ISSUES.md` — #2 (lightning), #4 (sky horizon-glow), #28 (aurora), #29 (cloud thinness), #37 (humanoid coat), #50 (stray tree), #41 (remote-motion blips) have been open since diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 4d34adb..cea28d6 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,37 +46,6 @@ 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 @@ -1817,6 +1786,26 @@ Unverified. The likely culprits, ranked by suspected probability: # Recently closed +## #56 — [DONE 2026-05-12 · 8735c39] `ParticleHookSink` ignores `CreateParticleHook.PartIndex`; multi-emitter scripts collapse to entity root + +**Closed:** 2026-05-12 +**Commit chain (newest first):** +- `8735c39` — feat(vfx #C.1.5b): GpuWorldState fires activator for dat-hydrated entities (4 new fire-sites + 5 integration tests; also picks up EnvCell statics & exterior stabs as a side-effect of the activator-guard relaxation) +- `5ca5827` — feat(vfx #C.1.5b): activator handles dat-hydrated entities + per-part transforms (resolver returns `ScriptActivationInfo(ScriptId, PartTransforms)`; keys by ServerGuid OR entity.Id; GameWindow resolver lambda upgraded; 4 existing + 3 new tests) +- `11521f4` — fix(vfx #56): `ParticleHookSink` applies `CreateParticleHook.PartIndex` transform (new `_partTransformsByEntity` side-table; `SpawnFromHook` transforms offset through `partTransforms[PartIndex]` before applying entity rotation; 2 new tests + 2 existing pass) +- `f3bc15e` — feat(vfx #C.1.5b): `SetupPartTransforms` helper for per-part anchor transforms (walks `PlacementFrames[Resting]` → `[Default]` → first-available; 4 tests) +- `1e3c33b` — docs(vfx #C.1.5b): design + plan for issue #56 + EnvCell DefaultScript + +**Component:** vfx / `ParticleHookSink` + `EntityScriptActivator` + `GpuWorldState` + `SetupPartTransforms` + +**Resolution.** Two-slice fix that also folded in slice 2 of the C.1.5 phase work. **Slice A (the #56 fix proper)**: precomputed per-part `Matrix4x4` array at activator-spawn time via the new `SetupPartTransforms.Compute(setup)` helper, threaded through `EntityScriptActivator` → `ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` (mirrors the existing `_rotationByEntity` side-table pattern), applied inside `SpawnFromHook` as `partLocal = Transform(offset, partTransforms[PartIndex])` before the existing world-rotation step. Backwards-compatible: entities without registered part transforms fall through to identity (pre-fix behavior). **Slice B (folded in same phase, makes the fix matter for slice 2 visual gates)**: dropped the activator's `ServerGuid==0` early-return guard. Activator now keys by `entity.ServerGuid` when non-zero, else `entity.Id` — collision-free because dat-hydrated entity IDs live in the `0x40xxxxxx` (interior) / `0x80xxxxxx` (scenery) / `0xC0xxxxxx` ranges, all disjoint from server guids. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (Far→Near promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (Near→Far demotion) for OnRemove. Live entities are filtered out by `ServerGuid != 0` on the `AddLandblock` path so pending-bucket merges don't double-fire OnCreate. + +**Reality discovery folded into spec §3:** the handoff doc's §4 Q1/Q2 (synthetic-ID scheme + new walker class) were mooted by finding that `GameWindow.BuildInteriorEntitiesForStreaming` already hydrates EnvCell `StaticObjects` as `WorldEntity` instances with stable `entity.Id`. No new walker, no synthetic IDs. + +**Verification.** Build green. 77 Vfx+Meshing+Activator+Streaming tests pass (4 new for SetupPartTransforms + 2 new for ParticleHookSink + 4 updated + 3 new for activator + 5 new for GpuWorldState integration). 8 pre-existing Physics/Input failures unchanged (verified by stash-and-rerun on Task 4). **Visual verification 2026-05-12**: Holtburg Town network portal (entity `0x7A9B405B`, script `0x3300126D`) — swirl no longer ground-buried, emitters distributed across the arch; Holtburg Inn fireplace flames over the firebox; cottage chimney smoke; spell cast on `+Acdream` cast-anim particles — all match retail. + +**Acceptance reproducer:** the C.1.5a verification log captured portal A entity `0x7A9B405B` swirl compressed to a partly-ground-buried point. Post-fix at the same portal, the swirl extends through the arch in retail-matching shape. + ## #53 — [DONE 2026-05-11 · f928e66] A.5/tier1-redo: entity-classification cache retry **Closed:** 2026-05-11 diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 2256a84..1c52f9e 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1,6 +1,6 @@ # acdream — strategic roadmap -**Status:** Living document. Updated 2026-05-11. **In flight: Phase C.1.5b** (issue #56 per-part transforms for multi-emitter PES + EnvCell static-object `DefaultScript` walker); pickup at [`docs/plans/2026-05-12-phase-c1.5b-handoff.md`](2026-05-12-phase-c1.5b-handoff.md). **Since the last header update:** post-A.5 polish completed (#52 lifestone, #54 JobKind, #53 Tier 1 cache); N.6 slice 1 shipped (gpu_us fix + radius=12 perf baseline, conclusion CPU dominates GPU 30–50×); C.1.5a shipped (portal PES wiring; surfaced #56 for C.1.5b to resolve). +**Status:** Living document. Updated 2026-05-12. **Between phases.** **Since the last header update:** C.1.5b shipped (issue #56 per-part transforms for multi-emitter PES + `EntityScriptActivator` extended to dat-hydrated EnvCell statics & exterior stabs — portal swirl, inn fireplace flames, cottage chimney smoke, spell-cast particles all match retail). **Earlier this week:** post-A.5 polish completed (#52 lifestone, #54 JobKind, #53 Tier 1 cache); N.6 slice 1 shipped (gpu_us fix + radius=12 perf baseline, conclusion CPU dominates GPU 30–50×); C.1.5a shipped (portal PES wiring; surfaced #56 → resolved in C.1.5b). **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. --- @@ -65,6 +65,7 @@ | 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(...)?.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) | +| C.1.5b | Per-part PES transforms + dat-hydrated entity DefaultScript dispatch. Closes issue #56. Shipped 2026-05-12 across 5 commits (`1e3c33b` docs+plan, `f3bc15e` SetupPartTransforms helper, `11521f4` ParticleHookSink applies `CreateParticleHook.PartIndex`, `5ca5827` activator refactor + GameWindow resolver lambda, `8735c39` GpuWorldState 4 new fire-sites). **Slice A** — new [`SetupPartTransforms.Compute(setup)`](../../src/AcDream.Core/Meshing/SetupPartTransforms.cs) walks `PlacementFrames[Resting]` → `[Default]` → first-available (mirrors `SetupMesh.Flatten` priority) and returns `Matrix4x4` per part; new `ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` mirrors the existing `_rotationByEntity` pattern; `SpawnFromHook` now transforms hook offset through `partTransforms[partIndex]` before applying entity rotation. **Slice B** — activator's `ServerGuid==0` guard relaxed: keys by `entity.ServerGuid` when non-zero, else `entity.Id` (collision-free with server guids in the `0x40xxxxxx` interior / `0x80xxxxxx` scenery / `0xC0xxxxxx` ranges). Resolver delegate refactored to return `ScriptActivationInfo(ScriptId, PartTransforms)` so one dat lookup yields both pieces. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (Far→Near promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (Near→Far demotion) for OnRemove. ServerGuid==0 filter on AddLandblock avoids double-firing pending-bucket merges. **Reality discovery folded into spec §3**: EnvCell `StaticObjects` are already hydrated as `WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming` (with stable `entity.Id` in `0x40xxxxxx`) — no synthetic-ID scheme or separate walker class needed (handoff §4 Q1/Q2 mooted). **Visual verification 2026-05-12**: Holtburg Town network portal swirl distributes across the arch (no ground-burial), Inn fireplace flames render over the firebox, cottage chimney smoke columns render, spell-cast animation-hook particles all match retail. 18 new + 4 updated tests, all Vfx/Meshing/Streaming/Activator green. Spec: [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../superpowers/specs/2026-05-13-phase-c1.5b-design.md). Plan: [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md). | Live ✓ | Plus polish that doesn't get its own phase number: - FlyCamera default speed lowered + Shift-to-boost @@ -124,7 +125,7 @@ Plus polish that doesn't get its own phase number: - **✓ SHIPPED — C.1 — VFX / particle system + sky-pass refinements.** Retail-faithful `ParticleEmitterInfo` runtime + 13-type motion integrator port + `PhysicsScript` runner + instanced billboard renderer with material-derived blend + global back-to-front sort + AttachLocal live-parent follow. Sky-pass refinements: Translucent+ClipMap alpha-blend, raw-Additive fog-skip, per-keyframe SkyObjectReplace divide-by-100, sampler-object wrap selection (ported from WorldBuilder), gated post-scene Z-offset. Sky-PES disabled by default — named-retail decomp proves `GameSky` drops `pes_id`. **Portal swirls, chimney smoke, fireplace flames** still need a Phase C.1.5 follow-up to wire entity-attached emitters to retail effect IDs (the data layer is ready; only the wiring is deferred). Lands as merge `feat(vfx): Phase C.1 — PES particle renderer + post-review fixes` (`ec1bbb4`) + `refactor(sky): replace per-frame wrap-mode mutation with persistent samplers` (`3d21c13`). - **C.1.5 — entity-attached PES wiring (sliced).** Three sub-slices wiring `PhysicsScript` / `DefaultScript` dispatch to the entity lifecycle so portals, chimneys, fireplaces, and sky effects animate per retail: - **✓ SHIPPED — C.1.5a (portals)** — 2026-05-11 (merge `88bda12`). `EntityScriptActivator` fires `Setup.DefaultScript` on every server-spawned `WorldEntity` via `PhysicsScriptRunner`. Visual-verified at Holtburg Town network portal. Surfaced known limitation as issue #56 (per-part transform handling) — addressed in C.1.5b. Plan archived at [`docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md`](../superpowers/plans/2026-05-12-phase-c1.5a-portals.md). - - **IN FLIGHT — C.1.5b (per-part transforms + EnvCell static walker).** Fix issue #56 first (every multi-emitter PES on a multi-part entity — portals, chimneys, fireplaces — currently collapses to entity root because `ParticleHookSink` ignores `CreateParticleHook.PartIndex`), then add EnvCell static-object `DefaultScript` walker for chimney smoke + fireplace flames. Pickup at [`docs/plans/2026-05-12-phase-c1.5b-handoff.md`](2026-05-12-phase-c1.5b-handoff.md). + - **✓ SHIPPED — C.1.5b (per-part transforms + EnvCell statics)** — 2026-05-12. Closes #56. `SetupPartTransforms.Compute` + `ParticleHookSink.SetEntityPartTransforms` + `SpawnFromHook` part-transform application — multi-emitter scripts now distribute across mesh parts. `EntityScriptActivator` `ServerGuid==0` guard relaxed (keys by `entity.Id` when ServerGuid is zero) + 4 new `GpuWorldState` fire-sites pick up dat-hydrated entities (EnvCell statics + exterior stabs) — fireplaces and chimneys now fire their `DefaultScript` automatically. Reality discovery during design: EnvCell statics are already hydrated as `WorldEntity` items by `BuildInteriorEntitiesForStreaming`, so no synthetic-ID scheme or separate walker was needed. Visual-verified at Holtburg portal + Inn fireplace + cottage chimney + spell cast. Plan archived at [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md). - **PLANNED — C.1.5c (sky-PES dispatch chain).** Promoted from former issue #36 (2026-05-11 triage). Ports retail's persistent-emitter creation on celestial / sky objects + the PES timeline driver (`CallPESHook::Execute` → `CPhysicsObj::CallPES` → `create_particle_emitter`) that drives them ~150×/min. Decomp anchors + live-trace evidence + 6-step impl outline in closed issue [#36](../ISSUES.md#36). **Closes #2 (lightning), #28 (aurora), #29 (cloud thinness) when shipped.** Does NOT close #4 (sky horizon-glow fog) — that's shader work, not PES. - **C.2 — Dynamic point lights.** Fireplaces and lamps need local lighting; small upgrade to the mesh shader to accumulate N (e.g., 4) nearest point lights per draw. Uniform-buffer or UBO-friendly layout. - **C.3 — Palette range tuning.** Small per-range offset/length tweaks to match retail skin/hair/eye colors. Mostly diff and verify work, no architecture change. diff --git a/docs/superpowers/plans/2026-05-13-phase-c1.5b.md b/docs/superpowers/plans/2026-05-13-phase-c1.5b.md new file mode 100644 index 0000000..15ade22 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-phase-c1.5b.md @@ -0,0 +1,946 @@ +# Phase C.1.5b — issue #56 + EnvCell DefaultScript implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal (slice A):** Fix [issue #56](../../ISSUES.md) — `ParticleHookSink` ignores `CreateParticleHook.PartIndex`, causing every emitter in a multi-emitter PES script (portals, fireplaces, chimneys) to collapse to the entity root. Precompute each Setup part's resting transform at activator-spawn time and apply it to the hook offset before spawning the particle. + +**Goal (slice B):** Fire `Setup.DefaultScript` for dat-hydrated entities (EnvCell statics + exterior stabs) too — not just server-spawned ones. Drop the `EntityScriptActivator.OnCreate` ServerGuid==0 guard and wire OnCreate/OnRemove into GpuWorldState's dat-hydration paths. + +**Architecture:** No new orchestrator classes. New helper `SetupPartTransforms.Compute(Setup)` in `AcDream.Core.Meshing`. `ParticleHookSink` grows `SetEntityPartTransforms`. `EntityScriptActivator` resolver returns a `ScriptActivationInfo` record bundling scriptId + per-part transforms. `GpuWorldState` fires the activator from four dat-hydration paths (AddLandblock, AddEntitiesToExistingLandblock, RemoveLandblock, RemoveEntitiesFromLandblock). + +**Tech Stack:** C# / .NET 10, xUnit, Silk.NET (existing). No new dependencies. + +**Spec:** [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../specs/2026-05-13-phase-c1.5b-design.md). Read it first. + +--- + +## File structure + +**Created:** + +- `src/AcDream.Core/Meshing/SetupPartTransforms.cs` — helper that walks Setup.PlacementFrames → list of Matrix4x4 per part. +- `tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs` — 4 tests. +- `tests/AcDream.Core.Tests/Vfx/ParticleHookSinkPartTransformTests.cs` — 2 tests (new file because the existing tests would otherwise gain unrelated tests). +- `tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs` — 5 integration tests for the activator wiring. + +**Modified:** + +- `src/AcDream.Core/Vfx/ParticleHookSink.cs` — new `_partTransformsByEntity` map; `SetEntityPartTransforms` method; `SpawnFromHook` applies the part transform; `StopAllForEntity` clears the entry. +- `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs` — resolver signature change to `Func`; `ServerGuid==0` guard replaced with `key = ServerGuid != 0 ? ServerGuid : entity.Id`; pushes part transforms to sink; new `ScriptActivationInfo` record alongside the activator. +- `tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs` — 4 existing tests updated for new resolver signature; 3 new tests added. +- `src/AcDream.App/Streaming/GpuWorldState.cs` — 4 new foreach blocks (one per AddLandblock / AddEntitiesToExistingLandblock / RemoveLandblock / RemoveEntitiesFromLandblock). +- `src/AcDream.App/Rendering/GameWindow.cs` — resolver lambda upgraded to return `ScriptActivationInfo?`. +- `docs/plans/2026-04-11-roadmap.md` — append "Phase C.1.5b SHIPPED" row on verification pass. +- `docs/ISSUES.md` — move #56 to Recently closed. +- `CLAUDE.md` — update "Currently in flight" line to point to next phase post-C.1.5b. + +Each file's responsibility: + +- `SetupPartTransforms` — pure function; Setup → matrices. No dat lookups, no GL, no entity state. +- `ParticleHookSink` — owns per-entity part-transform side-table (mirroring its existing `_rotationByEntity` pattern). Applies the transform inside `SpawnFromHook`. +- `EntityScriptActivator` — keys correctly by ServerGuid OR Id; pushes both rotation + part transforms to the sink before scheduling. Knows nothing about dats. +- `GpuWorldState` — owns the four new fire-sites. Filters out live entities on the dat-hydration paths (avoid double-fire). +- `GameWindow` — wiring root; the resolver lambda is the only place dats touch the activator. + +--- + +## Task 1: `SetupPartTransforms` helper + tests (TDD) + +**Files:** +- Create: `src/AcDream.Core/Meshing/SetupPartTransforms.cs` +- Create: `tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs` + +- [ ] **Step 1.1 — Write the test file with 4 failing tests** + +```csharp +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Meshing; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Meshing; + +public sealed class SetupPartTransformsTests +{ + private static AnimationFrame BuildFrame(params Frame[] frames) + { + var af = new AnimationFrame(); + foreach (var f in frames) af.Frames.Add(f); + return af; + } + + private static Setup BuildSetup( + int partCount, + IReadOnlyDictionary? placementFrames = null, + IReadOnlyList? defaultScale = null) + { + var setup = new Setup(); + for (int i = 0; i < partCount; i++) setup.Parts.Add(0x01000001u); + if (placementFrames is not null) + foreach (var (k, v) in placementFrames) setup.PlacementFrames[k] = v; + if (defaultScale is not null) + foreach (var s in defaultScale) setup.DefaultScale.Add(s); + return setup; + } + + [Fact] + public void ResolvesRestingPlacement_WhenAvailable() + { + var resting = BuildFrame( + new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }, + new Frame { Origin = new Vector3(0, 0, 1f), Orientation = Quaternion.Identity }); + + var setup = BuildSetup(partCount: 2, + placementFrames: new Dictionary + { + [Placement.Resting] = resting, + [Placement.Default] = BuildFrame(new Frame(), new Frame()), + }); + + var transforms = SetupPartTransforms.Compute(setup); + + Assert.Equal(2, transforms.Count); + var probe = Vector3.Transform(Vector3.Zero, transforms[1]); + Assert.Equal(new Vector3(0, 0, 1f), probe); + } + + [Fact] + public void FallsBackToDefault_WhenRestingMissing() + { + var defaultFrame = BuildFrame( + new Frame { Origin = new Vector3(2f, 0, 0), Orientation = Quaternion.Identity }); + + var setup = BuildSetup(partCount: 1, + placementFrames: new Dictionary + { + [Placement.Default] = defaultFrame, + }); + + var transforms = SetupPartTransforms.Compute(setup); + + Assert.Single(transforms); + var probe = Vector3.Transform(Vector3.Zero, transforms[0]); + Assert.Equal(new Vector3(2f, 0, 0), probe); + } + + [Fact] + public void ReturnsEmpty_WhenNoPlacementFrames() + { + var setup = BuildSetup(partCount: 2); + var transforms = SetupPartTransforms.Compute(setup); + Assert.Empty(transforms); + } + + [Fact] + public void AppliesDefaultScale_WhenPresent() + { + var resting = BuildFrame( + new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }); + + var setup = BuildSetup(partCount: 1, + placementFrames: new Dictionary { [Placement.Resting] = resting }, + defaultScale: new[] { new Vector3(2f, 2f, 2f) }); + + var transforms = SetupPartTransforms.Compute(setup); + + var probe = Vector3.Transform(new Vector3(1f, 1f, 1f), transforms[0]); + Assert.Equal(new Vector3(2f, 2f, 2f), probe); + } +} +``` + +- [ ] **Step 1.2 — Implement `SetupPartTransforms`** + +`src/AcDream.Core/Meshing/SetupPartTransforms.cs`: + +```csharp +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.Core.Meshing; + +/// +/// Compute the per-part static transforms for a Setup using its +/// PlacementFrames. For each part i, the returned matrix is the +/// transform from part-local to setup-local space at the Setup's +/// resting pose. Mirrors 's pose-source +/// priority: PlacementFrames[Resting] → [Default] → first available. +/// Returns an empty list when the Setup has no PlacementFrames +/// (caller falls back to "no part transforms applied" — equivalent to +/// pre-C.1.5b behavior in ParticleHookSink.SpawnFromHook). +/// +public static class SetupPartTransforms +{ + public static IReadOnlyList Compute(Setup setup) + { + AnimationFrame? source = null; + if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting)) + source = resting; + else if (setup.PlacementFrames.TryGetValue(Placement.Default, out var def)) + source = def; + else + { + foreach (var kvp in setup.PlacementFrames) + { + source = kvp.Value; + break; + } + } + + if (source is null) return System.Array.Empty(); + + int partCount = setup.Parts.Count; + var result = new Matrix4x4[partCount]; + for (int i = 0; i < partCount; i++) + { + Frame frame = i < source.Frames.Count + ? source.Frames[i] + : new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; + Vector3 scale = i < setup.DefaultScale.Count ? setup.DefaultScale[i] : Vector3.One; + result[i] = Matrix4x4.CreateScale(scale) + * Matrix4x4.CreateFromQuaternion(frame.Orientation) + * Matrix4x4.CreateTranslation(frame.Origin); + } + return result; + } +} +``` + +- [ ] **Step 1.3 — Verify** + +```pwsh +dotnet test --filter "FullyQualifiedName~SetupPartTransformsTests" -c Debug +``` + +All 4 tests pass. `dotnet build` green. + +- [ ] **Step 1.4 — Commit** + +``` +feat(vfx #C.1.5b): SetupPartTransforms helper for per-part anchor transforms + +Computes Matrix4x4 per Setup part by walking PlacementFrames[Resting] → +[Default] → first-available, matching SetupMesh.Flatten's priority. +Foundation for #56 fix: ParticleHookSink will use these to apply the +hook's PartIndex-relative offset to the right mesh part. +``` + +--- + +## Task 2: `ParticleHookSink` part-transform support + tests + +**Files:** +- Modify: `src/AcDream.Core/Vfx/ParticleHookSink.cs` +- Create: `tests/AcDream.Core.Tests/Vfx/ParticleHookSinkPartTransformTests.cs` + +- [ ] **Step 2.1 — Write the test file with 2 failing tests** + +```csharp +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.Core.Vfx; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Vfx; + +public sealed class ParticleHookSinkPartTransformTests +{ + private static EmitterDesc BuildPersistentEmitterDesc() => new() + { + DatId = 100u, + Type = ParticleType.Still, + EmitterKind = ParticleEmitterKind.BirthratePerSec, + MaxParticles = 4, + InitialParticles = 1, + TotalParticles = 0, + TotalDuration = 0f, + Lifespan = 999f, + LifetimeMin = 999f, + LifetimeMax = 999f, + Birthrate = 0.5f, + StartSize = 0.5f, + EndSize = 0.5f, + StartAlpha = 1f, + EndAlpha = 1f, + }; + + [Fact] + public void AppliesPartTransform_WhenRegistered() + { + var partTransforms = new Matrix4x4[] + { + Matrix4x4.Identity, + Matrix4x4.CreateTranslation(0f, 0f, 1f), + }; + + var registry = new EmitterDescRegistry(); + registry.Register(BuildPersistentEmitterDesc()); + var system = new ParticleSystem(registry); + var sink = new ParticleHookSink(system); + + sink.SetEntityRotation(0xCAFEu, Quaternion.Identity); + sink.SetEntityPartTransforms(0xCAFEu, partTransforms); + + sink.OnHook(0xCAFEu, Vector3.Zero, new CreateParticleHook + { + EmitterInfoId = 100u, + PartIndex = 1, + Offset = new Frame + { + Origin = new Vector3(1f, 0f, 0f), + Orientation = Quaternion.Identity, + }, + EmitterId = 0u, + }); + + system.Tick(0.001f); + + var live = system.EnumerateLive().FirstOrDefault(); + Assert.NotNull(live.Emitter); + var pos = live.Emitter.Particles[live.Index].Position; + Assert.InRange(pos.X, 0.99f, 1.01f); + Assert.InRange(pos.Y, -0.01f, 0.01f); + Assert.InRange(pos.Z, 0.99f, 1.01f); + } + + [Fact] + public void FallsBackToIdentity_WhenPartIndexOutOfBounds() + { + var partTransforms = new[] { Matrix4x4.Identity, Matrix4x4.CreateTranslation(0f, 0f, 1f) }; + + var registry = new EmitterDescRegistry(); + registry.Register(BuildPersistentEmitterDesc()); + var system = new ParticleSystem(registry); + var sink = new ParticleHookSink(system); + + sink.SetEntityRotation(0xCAFEu, Quaternion.Identity); + sink.SetEntityPartTransforms(0xCAFEu, partTransforms); + + sink.OnHook(0xCAFEu, Vector3.Zero, new CreateParticleHook + { + EmitterInfoId = 100u, + PartIndex = 99, + Offset = new Frame { Origin = new Vector3(2f, 0f, 0f), Orientation = Quaternion.Identity }, + }); + + system.Tick(0.001f); + + var live = system.EnumerateLive().FirstOrDefault(); + Assert.NotNull(live.Emitter); + var pos = live.Emitter.Particles[live.Index].Position; + Assert.InRange(pos.X, 1.99f, 2.01f); + Assert.InRange(pos.Y, -0.01f, 0.01f); + Assert.InRange(pos.Z, -0.01f, 0.01f); + } +} +``` + +- [ ] **Step 2.2 — Modify `ParticleHookSink`** + +Add field next to `_rotationByEntity`: +```csharp +private readonly ConcurrentDictionary> _partTransformsByEntity = new(); +``` + +Add method next to `SetEntityRotation`: +```csharp +public void SetEntityPartTransforms(uint entityId, IReadOnlyList partTransforms) + => _partTransformsByEntity[entityId] = partTransforms; +``` + +Add cleanup line inside `StopAllForEntity`: +```csharp +_partTransformsByEntity.TryRemove(entityId, out _); +``` + +Modify `SpawnFromHook` — replace the anchor computation: +```csharp +var rotation = _rotationByEntity.TryGetValue(entityId, out var rot) + ? rot + : Quaternion.Identity; + +Vector3 partLocal = offset; +if (_partTransformsByEntity.TryGetValue(entityId, out var pts) + && partIndex >= 0 + && partIndex < pts.Count) +{ + partLocal = Vector3.Transform(offset, pts[partIndex]); +} + +var anchor = worldPos + Vector3.Transform(partLocal, rotation); +``` + +- [ ] **Step 2.3 — Verify** + +```pwsh +dotnet test --filter "FullyQualifiedName~ParticleHookSinkPartTransformTests" -c Debug +dotnet test -c Debug +``` + +Both new tests pass. Existing tests still green (the change is backwards-compatible — entities without registered part transforms fall through to identity, same as before). + +- [ ] **Step 2.4 — Commit** + +``` +fix(vfx #56): ParticleHookSink applies CreateParticleHook.PartIndex transform + +Adds per-entity part-transform side-table mirroring _rotationByEntity. +SpawnFromHook now transforms the hook offset through partTransforms[partIndex] +before rotating to world space. Backwards-compatible: entities without +registered part transforms fall through to identity (pre-C.1.5b behavior). + +Closes the renderer side of #56. EntityScriptActivator wiring lands next. +``` + +--- + +## Task 3: `EntityScriptActivator` resolver refactor + ServerGuid relaxation + tests + +**Files:** +- Modify: `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs` +- Modify: `tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs` + +- [ ] **Step 3.1 — Add `ScriptActivationInfo` record and update activator** + +In `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`, add at the top of the namespace (before the activator class): + +```csharp +public sealed record ScriptActivationInfo( + uint ScriptId, + IReadOnlyList PartTransforms); +``` + +Change the resolver field type: +```csharp +private readonly Func _resolver; +``` + +Update ctor parameter name + type accordingly. + +Replace `OnCreate`: +```csharp +public void OnCreate(WorldEntity entity) +{ + ArgumentNullException.ThrowIfNull(entity); + uint key = entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id; + if (key == 0) return; + + var info = _resolver(entity); + if (info is null || info.ScriptId == 0) return; + + _particleSink.SetEntityRotation(key, entity.Rotation); + _particleSink.SetEntityPartTransforms(key, info.PartTransforms); + _scriptRunner.Play(info.ScriptId, key, entity.Position); +} +``` + +Replace `OnRemove`: +```csharp +public void OnRemove(uint key) +{ + if (key == 0) return; + _scriptRunner.StopAllForEntity(key); + _particleSink.StopAllForEntity(key, fadeOut: false); +} +``` + +Update doc comments to reflect the new key semantics (handles both server-spawned and dat-hydrated entities; caller picks the key for OnRemove). + +- [ ] **Step 3.2 — Update the 4 existing tests for the new resolver signature** + +In `EntityScriptActivatorTests.cs`, every `_ => 0xAAu` becomes: +```csharp +_ => new ScriptActivationInfo(0xAAu, System.Array.Empty()) +``` + +Every `_ => 0u` becomes: +```csharp +_ => null +``` + +`OnRemove(entity.ServerGuid)` calls stay correct (the public API now takes `uint key` either way). + +- [ ] **Step 3.3 — Add 3 new tests** + +Append to `EntityScriptActivatorTests.cs`: + +```csharp +[Fact] +public void OnCreate_KeysByEntityId_WhenServerGuidZero() +{ + var p = BuildPipeline( + (0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 })))); + var activator = new EntityScriptActivator(p.Runner, p.Sink, + _ => new ScriptActivationInfo(0xAAu, System.Array.Empty())); + var entity = new WorldEntity + { + Id = 0x40A9B401u, + ServerGuid = 0u, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(5, 5, 5), + Rotation = Quaternion.Identity, + MeshRefs = System.Array.Empty(), + }; + + activator.OnCreate(entity); + + Assert.Equal(1, p.Runner.ActiveScriptCount); + p.Runner.Tick(0.001f); + Assert.Single(p.Recording.Calls); + Assert.Equal(0x40A9B401u, p.Recording.Calls[0].EntityId); + Assert.Equal(new Vector3(5, 5, 5), p.Recording.Calls[0].Pos); +} + +[Fact] +public void OnCreate_PassesPartTransformsToSink() +{ + var registry = new EmitterDescRegistry(); + registry.Register(BuildPersistentEmitterDesc()); + var system = new ParticleSystem(registry); + var hookSink = new ParticleHookSink(system); + + var hookOffset = new Frame { Origin = new Vector3(1f, 0, 0), Orientation = Quaternion.Identity }; + var script = BuildScript( + (0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = hookOffset, PartIndex = 1 })); + var table = new Dictionary { [0xAAu] = script }; + var runner = new PhysicsScriptRunner( + id => table.TryGetValue(id, out var s) ? s : null, + hookSink); + + var partTransforms = new Matrix4x4[] + { + Matrix4x4.Identity, + Matrix4x4.CreateTranslation(0f, 0f, 1f), + }; + + var activator = new EntityScriptActivator(runner, hookSink, + _ => new ScriptActivationInfo(0xAAu, partTransforms)); + var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero); + + activator.OnCreate(entity); + runner.Tick(0.001f); + system.Tick(0.001f); + + var live = system.EnumerateLive().FirstOrDefault(); + Assert.NotNull(live.Emitter); + var pos = live.Emitter.Particles[live.Index].Position; + Assert.InRange(pos.X, 0.99f, 1.01f); + Assert.InRange(pos.Y, -0.01f, 0.01f); + Assert.InRange(pos.Z, 0.99f, 1.01f); +} + +[Fact] +public void OnRemove_StopsByGivenKey() +{ + var registry = new EmitterDescRegistry(); + registry.Register(BuildPersistentEmitterDesc()); + var system = new ParticleSystem(registry); + var hookSink = new ParticleHookSink(system); + + var script = BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() })); + var table = new Dictionary { [0xAAu] = script }; + var runner = new PhysicsScriptRunner( + id => table.TryGetValue(id, out var s) ? s : null, + hookSink); + + var activator = new EntityScriptActivator(runner, hookSink, + _ => new ScriptActivationInfo(0xAAu, System.Array.Empty())); + var entity = new WorldEntity + { + Id = 0x40A9B402u, + ServerGuid = 0u, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = System.Array.Empty(), + }; + + activator.OnCreate(entity); + runner.Tick(0.001f); + Assert.True(system.ActiveEmitterCount > 0); + + activator.OnRemove(0x40A9B402u); + + Assert.Equal(0, runner.ActiveScriptCount); + system.Tick(0.01f); + Assert.Equal(0, system.ActiveEmitterCount); +} +``` + +The existing `OnRemove_StopsScriptsAndEmitters` test continues to test the server-guid path. The new `OnRemove_StopsByGivenKey` exercises the dat-hydrated-entity path with the new key. + +- [ ] **Step 3.4 — Verify** + +```pwsh +dotnet test -c Debug +``` + +All tests green, including 4 updated + 3 new in `EntityScriptActivatorTests`, plus the 2 from Task 2 and the 4 from Task 1. + +- [ ] **Step 3.5 — Commit** + +``` +feat(vfx #C.1.5b): activator handles dat-hydrated entities + per-part transforms + +Resolver returns ScriptActivationInfo(ScriptId, PartTransforms) — one +dat lookup per spawn yields both pieces of info. Activator keys by +ServerGuid when nonzero, else entity.Id, so dat-hydrated entities +(EnvCell statics, exterior stabs) flow through the same code path. +Pushes per-part transforms into the sink before scheduling. + +Closes the activator side of #56. GpuWorldState fire-site wiring next. +``` + +--- + +## Task 4: `GpuWorldState` fire-site wiring + production lambda + integration tests + +**Files:** +- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs` +- Create: `tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs` +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — resolver lambda + +- [ ] **Step 4.1 — Add the 4 foreach blocks in `GpuWorldState`** + +In `AddLandblock` (after `_loaded[landblock.LandblockId] = landblock;` and after `_wbSpawnAdapter?.OnLandblockLoaded(...)`): +```csharp +// C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0). +// Live entities (ServerGuid!=0) already had OnCreate fired at +// AppendLiveEntity; filter to avoid double-firing pending-bucket merges. +if (_entityScriptActivator is not null) +{ + var entities = _loaded[landblock.LandblockId].Entities; + for (int i = 0; i < entities.Count; i++) + { + var e = entities[i]; + if (e.ServerGuid == 0) + _entityScriptActivator.OnCreate(e); + } +} +``` + +In `AddEntitiesToExistingLandblock` (after the merge + the `_wbSpawnAdapter` call): +```csharp +// C.1.5b: fire DefaultScript for each promoted dat-hydrated entity. +// All entities arriving via this path are dat-hydrated by construction +// (the promotion path streams in atlas-tier content). +if (_entityScriptActivator is not null) +{ + for (int i = 0; i < entities.Count; i++) + _entityScriptActivator.OnCreate(entities[i]); +} +``` + +In `RemoveLandblock` (inside the `if (_loaded.TryGetValue(...))` block, after the rescue loop): +```csharp +// C.1.5b: stop DefaultScript for each dat-hydrated entity in the +// landblock. Server-spawned entities are either being rescued (script +// continues) or were OnRemove'd via RemoveEntityByServerGuid earlier; +// leave them alone here. +if (_entityScriptActivator is not null) +{ + foreach (var entity in lb.Entities) + { + if (entity.ServerGuid == 0) + _entityScriptActivator.OnRemove(entity.Id); + } +} +``` + +In `RemoveEntitiesFromLandblock` (after the existing `_onLandblockUnloaded?.Invoke(canonical)`, before the entities-list replacement): +```csharp +// C.1.5b: stop DefaultScript for each dat-hydrated entity about to +// be dropped. Demote-tier entities are always atlas-tier +// (ServerGuid==0); the filter is belt-and-suspenders. +if (_entityScriptActivator is not null) +{ + foreach (var entity in lb.Entities) + { + if (entity.ServerGuid == 0) + _entityScriptActivator.OnRemove(entity.Id); + } +} +``` + +The `RemoveEntityByServerGuid` site at line 290 stays the same — `OnRemove(uint key)` accepts any key. + +- [ ] **Step 4.2 — Update the production resolver lambda in `GameWindow`** + +At GameWindow.cs:1617-1637, replace the existing resolver lambda: + +```csharp +var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator( + scriptRunner, + particleHookSink, + entity => + { + try + { + var setup = _dats.Get(entity.SourceGfxObjOrSetupId); + if (setup is null) return null; + uint scriptId = setup.DefaultScript.DataId; + if (scriptId == 0) return null; + var parts = AcDream.Core.Meshing.SetupPartTransforms.Compute(setup); + return new AcDream.App.Rendering.Vfx.ScriptActivationInfo(scriptId, parts); + } + catch + { + return null; + } + }); +``` + +- [ ] **Step 4.3 — Write the integration tests** + +Create `tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs`: + +```csharp +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.Core.Physics; +using AcDream.Core.Vfx; +using AcDream.Core.World; +using DatReaderWriter.Types; +using Xunit; +using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; + +namespace AcDream.Core.Tests.Streaming; + +public sealed class GpuWorldStateActivatorTests +{ + private sealed class RecordingSink : IAnimationHookSink + { + public List<(uint EntityId, Vector3 Pos, AnimationHook Hook)> Calls = new(); + public void OnHook(uint entityId, Vector3 worldPos, AnimationHook hook) + => Calls.Add((entityId, worldPos, hook)); + } + + private static (GpuWorldState State, PhysicsScriptRunner Runner, ParticleHookSink Sink, RecordingSink Recording) + BuildState(uint scriptId) + { + var script = new DatPhysicsScript(); + script.ScriptData.Add(new PhysicsScriptData + { + StartTime = 0.0, + Hook = new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() }, + }); + var table = new Dictionary { [scriptId] = script }; + + var registry = new EmitterDescRegistry(); + var system = new ParticleSystem(registry); + var sink = new ParticleHookSink(system); + var recording = new RecordingSink(); + var runner = new PhysicsScriptRunner(id => table.TryGetValue(id, out var s) ? s : null, recording); + var activator = new EntityScriptActivator(runner, sink, + _ => new ScriptActivationInfo(scriptId, System.Array.Empty())); + + var state = new GpuWorldState(entityScriptActivator: activator); + return (state, runner, sink, recording); + } + + private static WorldEntity DatHydrated(uint id, Vector3 pos) => new() + { + Id = id, + ServerGuid = 0u, + SourceGfxObjOrSetupId = 0x02000001u, + Position = pos, + Rotation = Quaternion.Identity, + MeshRefs = System.Array.Empty(), + }; + + private static WorldEntity Live(uint serverGuid, Vector3 pos) => new() + { + Id = serverGuid, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = pos, + Rotation = Quaternion.Identity, + MeshRefs = System.Array.Empty(), + }; + + [Fact] + public void AddLandblock_FiresActivatorForDatHydrated() + { + var p = BuildState(scriptId: 0xAAu); + var entity = DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero); + var lb = new LoadedLandblock(0xA9B4FFFFu, new float[0], new[] { entity }); + + p.State.AddLandblock(lb); + p.Runner.Tick(0.001f); + + Assert.Single(p.Recording.Calls); + Assert.Equal(0x40A9B401u, p.Recording.Calls[0].EntityId); + } + + [Fact] + public void AddLandblock_DoesNotDoubleFire_OnPendingMerge() + { + var p = BuildState(scriptId: 0xAAu); + var live = Live(serverGuid: 0xCAFEu, pos: Vector3.Zero); + + p.State.AppendLiveEntity(0xA9B4FFFFu, live); + + var lb = new LoadedLandblock(0xA9B4FFFFu, new float[0], System.Array.Empty()); + p.State.AddLandblock(lb); + + p.Runner.Tick(0.001f); + Assert.Single(p.Recording.Calls); + } + + [Fact] + public void RemoveLandblock_FiresOnRemoveForDatHydrated() + { + var p = BuildState(scriptId: 0xAAu); + var entity = DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero); + var lb = new LoadedLandblock(0xA9B4FFFFu, new float[0], new[] { entity }); + + p.State.AddLandblock(lb); + p.Runner.Tick(0.001f); + Assert.Equal(1, p.Runner.ActiveScriptCount); + + p.State.RemoveLandblock(0xA9B4FFFFu); + Assert.Equal(0, p.Runner.ActiveScriptCount); + } + + [Fact] + public void AddEntitiesToExistingLandblock_FiresActivator() + { + var p = BuildState(scriptId: 0xAAu); + var emptyLb = new LoadedLandblock(0xA9B4FFFFu, new float[0], System.Array.Empty()); + p.State.AddLandblock(emptyLb); + + var promoted = new[] + { + DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero), + DatHydrated(id: 0x40A9B402u, pos: Vector3.UnitX), + }; + p.State.AddEntitiesToExistingLandblock(0xA9B4FFFFu, promoted); + + p.Runner.Tick(0.001f); + Assert.Equal(2, p.Recording.Calls.Count); + } + + [Fact] + public void RemoveEntitiesFromLandblock_FiresOnRemove() + { + var p = BuildState(scriptId: 0xAAu); + var entity = DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero); + var lb = new LoadedLandblock(0xA9B4FFFFu, new float[0], new[] { entity }); + p.State.AddLandblock(lb); + p.Runner.Tick(0.001f); + Assert.Equal(1, p.Runner.ActiveScriptCount); + + p.State.RemoveEntitiesFromLandblock(0xA9B4FFFFu); + Assert.Equal(0, p.Runner.ActiveScriptCount); + } +} +``` + +- [ ] **Step 4.4 — Verify build + tests** + +```pwsh +dotnet build -c Debug +dotnet test -c Debug +``` + +All tests green. No regressions in existing `GpuWorldStateTests` (if any assert on ctor arity — they shouldn't, since C.1.5a already added the optional `entityScriptActivator` param). + +- [ ] **Step 4.5 — Commit** + +``` +feat(vfx #C.1.5b): GpuWorldState fires activator for dat-hydrated entities + +Wires EntityScriptActivator.OnCreate into AddLandblock, +AddEntitiesToExistingLandblock, and OnRemove into RemoveLandblock + +RemoveEntitiesFromLandblock. ServerGuid==0 filter avoids double-firing +on pending-bucket merges of live entities. + +GameWindow's resolver lambda upgraded to return ScriptActivationInfo +(scriptId + per-part transforms from SetupPartTransforms.Compute). + +Closes #56. Slice A (per-part transforms) + slice B (dat-hydrated +entities) both wired end-to-end. Ready for visual verification at +Holtburg portal + Inn fireplace + cottage chimney. +``` + +--- + +## Task 5: Visual verification + ship docs + merge + +- [ ] **Step 5.1 — Launch the live client** + +```pwsh +Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force +Start-Sleep -Seconds 3 + +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_LIVE = "1" +$env:ACDREAM_TEST_HOST = "127.0.0.1" +$env:ACDREAM_TEST_PORT = "9000" +$env:ACDREAM_TEST_USER = "testaccount" +$env:ACDREAM_TEST_PASS = "testpassword" +$env:ACDREAM_DUMP_PLAYSCRIPT = "1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | + Tee-Object -FilePath "launch.log" +``` + +Run in background so the parent session keeps control of the terminal. + +- [ ] **Step 5.2 — Visual verification with the user** + +Four sites (per spec §6): +1. **Holtburg Town network portal** — swirl extends vertically, no ground-burial, distinct columns visible. +2. **Holtburg Inn fireplace** — flame particles at firebox. +3. **Cottage chimney** — smoke column visible. +4. **Spell cast on +Acdream** — cast-anim particles match retail. + +Diagnostic: `Select-String "\[pes\] Play:" launch.log` — every successful Play call. If a site has no particles, the log shows whether the script fired and with what scriptId. + +**STOP and wait for the user to confirm each site matches retail.** This is the acceptance gate. + +- [ ] **Step 5.3 — Ship docs** + +If all four sites pass: + +1. **`docs/plans/2026-04-11-roadmap.md`** — append "Phase C.1.5b SHIPPED 2026-05-13 — closes #56 + EnvCell DefaultScript dispatch" entry to the shipped table. +2. **`docs/ISSUES.md`** — move #56 from Active to Recently closed with the commit SHA from Task 4. +3. **`CLAUDE.md`** — update the "Currently in flight" line. Drop the C.1.5b reference. Either point to the next phase the user picks, or leave a "between phases" note. + +- [ ] **Step 5.4 — Final commit + merge** + +``` +docs(vfx #C.1.5b): ship Phase C.1.5b — closes #56 + EnvCell DefaultScript dispatch + +Roadmap: add SHIPPED row. +ISSUES: #56 → Recently closed. +CLAUDE.md: "Currently in flight" pointer updated. + +Visual verification 2026-05-13: portal swirl matches retail extent + +spread (no ground-burial); Holtburg Inn fireplace flames; cottage +chimney smoke; spell cast particles all match retail. +``` + +Then `git checkout main && git merge --no-ff claude/trusting-elbakyan-633b52` and push. + +--- + +## Notes for the executing agent + +- **TDD discipline:** every Task starts with a failing test, then implementation, then verify. The C.1.5a phase shipped clean because the test scaffolding caught the spawn-on-zero-guid case AND the rotation-seed case before they became visual regressions. Same discipline here for the per-part transform pipeline. +- **Don't touch animated entities.** The `SetEntityPartTransforms` seam is keyed by entity, so a future "animated DefaultScript" phase can push fresh transforms each tick without changing this contract. Out of scope for C.1.5b. +- **Don't touch `ParticleRenderer.cs`.** Bindless migration is N.6 slice 2. +- **Don't invent emitter types.** Reuse existing PES data. +- **If visual verification fails:** check `launch.log` for `[pes]` lines first. If the script DID fire but particles look wrong, the bug is in `SpawnFromHook` or the part-transform math. If the script DIDN'T fire, the bug is in the activator wiring or the resolver. Investigate via decomp + cross-reference (`docs/research/named-retail/` for the retail expectation) before guessing — the CLAUDE.md workflow. +- **Worktree cleanup:** see handoff §9 for the post-merge cleanup command for the C.1.5a worktree at `lucid-burnell-aab524`. + +Spec at [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../specs/2026-05-13-phase-c1.5b-design.md) is the source of truth for the architecture; this plan is the execution sequence. diff --git a/docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md b/docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md new file mode 100644 index 0000000..1a89654 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md @@ -0,0 +1,522 @@ +# Phase C.1.5b — issue #56 (per-part collapse) + EnvCell static DefaultScript dispatch + +**Created:** 2026-05-13. +**Author:** Claude (lead engineer/architect). +**Phase:** C.1.5b (second of two slices; C.1.5a portal-PES wiring shipped 2026-05-11 in merge `88bda12`). +**Parent plan:** [`docs/plans/2026-04-27-phase-c1-pes-particles.md`](../../plans/2026-04-27-phase-c1-pes-particles.md) §C.1.5. +**Handoff doc:** [`docs/plans/2026-05-12-phase-c1.5b-handoff.md`](../../plans/2026-05-12-phase-c1.5b-handoff.md). + +--- + +## §1 Goals + +Two coupled slices in one phase, in this order: + +**Slice A — `ParticleHookSink` honors `CreateParticleHook.PartIndex` for static +entities.** Closes [issue #56](../../ISSUES.md). The Holtburg Town network +portal's 10-emitter script currently collapses every emitter to the entity +root, producing a compressed, partially-ground-buried swirl. The fix is to +precompute each Setup part's resting transform at spawn time and apply it to +the hook offset before spawning the particle. + +**Slice B — `EntityScriptActivator` fires `Setup.DefaultScript` for +dat-hydrated entities too.** Right now the activator gates on +`entity.ServerGuid != 0`, which means EnvCell static objects (interior +fireplaces, inn decorations, exterior stabs like cottage chimneys) — which +have no server guid because they come from the dat file, not the network — +never get their DefaultScript fired. Drop the guard, key by `entity.Id` when +`ServerGuid == 0`, and wire `OnCreate` / `OnRemove` calls into GpuWorldState's +dat-hydration paths. + +Plus a **visual confirmation pass** for the animation-hook particle path +(already shipped in C.1; just needs a sanity check by casting a spell on +`+Acdream`). + +### Acceptance + +Visual verification at three retail-side-by-side locations in/near Holtburg: + +1. **Town network portal** (the C.1.5a verification site): swirl extends + vertically through the portal arch with retail-like shape; no + ground-burial; emitters distributed across the portal Setup's parts. +2. **Holtburg Inn fireplace** (interior, EnvCell static): flame particles + match retail's pattern and position over the firebox. +3. **Cottage chimney** (exterior stab — TBD which cottage): smoke + particles match retail. +4. **Animation-hook spell cast** on `+Acdream`: cast-anim particle effect + matches retail. + +## §2 Scope + +**In:** + +- New helper `AcDream.Core.Meshing.SetupPartTransforms.Compute(Setup)` that + walks `PlacementFrames[Resting]` → fallback `[Default]` → first available + and returns `IReadOnlyList` (one transform per part). +- `ParticleHookSink.SetEntityPartTransforms(uint entityId, IReadOnlyList partTransforms)` + + a backing `_partTransformsByEntity` map cleared by `StopAllForEntity`. +- `ParticleHookSink.SpawnFromHook` applies `partTransforms[partIndex]` to the + hook offset before rotating to world space. +- `EntityScriptActivator` resolver signature changes from + `Func` to `Func` so + both `ScriptId` and `PartTransforms` come from one dat lookup. +- `EntityScriptActivator.OnCreate` keys by `entity.ServerGuid != 0 ? + entity.ServerGuid : entity.Id`. Same activator handles both server-spawned + and dat-hydrated entities — no new class. +- `EntityScriptActivator.OnRemove(uint key)` — caller picks the key. +- `GpuWorldState` wires the activator into four more places: + `AddLandblock` (dat-hydrated entities only — filter by `ServerGuid==0` to + avoid double-firing pending live entities), `AddEntitiesToExistingLandblock` + (the just-promoted entities — all dat-hydrated by construction), + `RemoveLandblock` (dat-hydrated entities only), and + `RemoveEntitiesFromLandblock` (dat-hydrated entities only). +- Visual verification at the four sites in §1 Acceptance. + +**Out:** + +- Animated entities (NPCs, monsters, the player). Per-part transforms vary + per animation frame and would need a per-tick refresh similar to + `UpdateEntityAnchor`. Deferred to a future phase. The new + `SetEntityPartTransforms` is keyed by entity, so an animated-entity path + can later push fresh transforms each tick without changing the contract. +- Renderer changes. `particle.frag` stays as-is; bindless migration is N.6 + slice 2. +- WB's re-fire-after-1s loop logic — portal swirls + fireplace flames are + persistent (`TotalParticles=0 && TotalSeconds=0`), no re-fire needed. +- New emitter types. Reuse existing PES data. + +## §3 Background + +### What shipped in C.1.5a (the part we keep) + +The mechanism is correct: `EntityScriptActivator.OnCreate` runs on every +server-spawned `WorldEntity`, resolves `Setup.DefaultScript`, seeds +`_particleSink.SetEntityRotation`, calls `_scriptRunner.Play(scriptId, +entity.ServerGuid, entity.Position)`. Multi-hook scripts dispatch at their +correct `StartTime` offsets. Despawn cleanup works. + +### What's broken (issue #56) + +[`ParticleHookSink.SpawnFromHook`](../../../src/AcDream.Core/Vfx/ParticleHookSink.cs) +at lines 176-217: + +```csharp +var rotation = _rotationByEntity.TryGetValue(entityId, out var rot) + ? rot : Quaternion.Identity; +var anchor = worldPos + Vector3.Transform(offset, rotation); +``` + +The hook author intended `offset` to be in **part-local** space — i.e., +relative to the mesh part identified by `cph.PartIndex` — so the geometry +retail computes is: + +``` +anchor = entityWorldPos + entityRotation × (partFrame.Origin + partFrame.Orientation × hookOffset) +``` + +Our sink drops the part transform multiplication. For the Holtburg portal +(entity `0x7A9B405B`, script `0x3300126D`, 10 hooks distributed across the +portal Setup's parts), every emitter lands at the entity root. Visible +symptom: swirl partially buried, lateral spread compressed. + +### Where part transforms come from (static entities) + +For static entities, per-part transforms live in +`setup.PlacementFrames[Placement.Resting]` (fallback `[Default]`, fallback +first available — same priority chain +[`SetupMesh.Flatten`](../../../src/AcDream.Core/Meshing/SetupMesh.cs) at +lines 36-50 already uses). Per part `i`: + +```csharp +Matrix4x4.CreateScale(setup.DefaultScale[i]) + * Matrix4x4.CreateFromQuaternion(placementFrame.Frames[i].Orientation) + * Matrix4x4.CreateTranslation(placementFrame.Frames[i].Origin) +``` + +`DefaultScale` defaults to `Vector3.One` when the list is shorter than +`Parts.Count`. + +### Where EnvCell statics come from (slice B) + +**Major discovery from this design pass — the handoff's §4 Q1/Q2 are mooted:** +[`GameWindow.BuildInteriorEntitiesForStreaming`](../../../src/AcDream.App/Rendering/GameWindow.cs) +at lines 5030-5135 already hydrates EnvCell `StaticObjects` as `WorldEntity` +instances with stable `entity.Id` in the `0x40xxxxxx` range: + +```csharp +uint interiorIdBase = 0x40000000u | (landblockId & 0x00FFFF00u); +// ... for each EnvCell, for each stab in envCell.StaticObjects ... +var hydrated = new WorldEntity { + Id = interiorIdBase + localCounter++, + SourceGfxObjOrSetupId = stab.Id, // 0x02000000 → Setup-based + Position = stab.Frame.Origin + lbOffset, + Rotation = stab.Frame.Orientation, + MeshRefs = meshRefs, + ParentCellId = envCellId, +}; +``` + +These flow into `GpuWorldState.AddLandblock` as part of `landblock.Entities`, +sit there with `ServerGuid == 0`, and currently never have their +`Setup.DefaultScript` fired. The activator's existing +`ServerGuid == 0 → return;` guard intentionally skips them (atlas-tier +exemption inherited from `EntitySpawnAdapter`). + +Three architectural consequences: + +1. **No synthetic ID scheme needed.** `entity.Id` is already collision-free + with server guids (live spawns use `0x500000xx`–`0x7Fxxxxxx`), anonymous + emitter IDs (`0x80000000u+`), and the four entity-id ranges + (`0x40xxxxxx` interior / `0x80xxxxxx` scenery / etc) all live in disjoint + high-byte slices. +2. **No new `EnvCellStaticActivator` class.** The existing + `EntityScriptActivator` handles both server-spawned and dat-hydrated + entities once the guard is keyed-by-id-when-zero. +3. **No new walker.** `BuildInteriorEntitiesForStreaming` is the walker — + it already happens. We just need the OnCreate fire-site in + GpuWorldState's `AddLandblock` / `AddEntitiesToExistingLandblock`. + +The handoff §4 wrote three options (α piggyback / β new class / γ extend +activator) under the assumption that EnvCell statics were NOT WorldEntities. +This reality discovery collapses all three to a simpler answer that none of +them anticipated. + +## §4 Architecture + +### Slice A: part-transform pipeline + +``` +EntityScriptActivator.OnCreate(entity) +├─ key = entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id +├─ info = resolver(entity) // ScriptActivationInfo? {ScriptId, PartTransforms} +├─ if (info is null || info.ScriptId == 0) return +├─ _particleSink.SetEntityRotation(key, entity.Rotation) +├─ _particleSink.SetEntityPartTransforms(key, info.PartTransforms) // NEW +└─ _scriptRunner.Play(info.ScriptId, key, entity.Position) + +ParticleHookSink.SpawnFromHook(entityId, worldPos, ..., partIndex, ...) +├─ rotation = _rotationByEntity[entityId] ?? Quaternion.Identity +├─ partTransform = (partTransforms != null && partIndex >= 0 && partIndex < Count) +│ ? partTransforms[partIndex] : Matrix4x4.Identity +├─ partLocal = Vector3.Transform(offset, partTransform) +├─ anchor = worldPos + Vector3.Transform(partLocal, rotation) +└─ _system.SpawnEmitterById(...) +``` + +### Slice B: dat-hydration fire-sites + +``` +GpuWorldState.AddLandblock(landblock) +├─ merge pending live entities (existing) +├─ _loaded[id] = landblock (existing) +├─ _wbSpawnAdapter?.OnLandblockLoaded(...) (existing) +├─ foreach entity in landblock.Entities where ServerGuid == 0: // NEW +│ _entityScriptActivator?.OnCreate(entity) +└─ RebuildFlatView() (existing) + +GpuWorldState.AddEntitiesToExistingLandblock(landblockId, entities) +├─ canonicalize + merge (existing) +├─ _wbSpawnAdapter?.OnLandblockLoaded(...) (existing) +├─ foreach entity in entities: // NEW +│ _entityScriptActivator?.OnCreate(entity) // all dat-hydrated +└─ RebuildFlatView() (existing) + +GpuWorldState.RemoveLandblock(landblockId) +├─ _wbSpawnAdapter?.OnLandblockUnloaded(...) (existing) +├─ rescue persistent (existing) +├─ foreach entity in lb.Entities where ServerGuid == 0: // NEW +│ _entityScriptActivator?.OnRemove(entity.Id) +└─ remove from _loaded, RebuildFlatView (existing) + +GpuWorldState.RemoveEntitiesFromLandblock(landblockId) +├─ _wbSpawnAdapter?.OnLandblockUnloaded(...) (existing) +├─ _onLandblockUnloaded?.Invoke(canonical) (existing — Tier 1 cache sweep) +├─ foreach entity in lb.Entities where ServerGuid == 0: // NEW +│ _entityScriptActivator?.OnRemove(entity.Id) +└─ replace lb.Entities with empty list, RebuildFlatView (existing) +``` + +The `ServerGuid == 0` filter avoids double-firing OnCreate on live entities +that came via `AppendLiveEntity` and got pending-bucket-merged in +`AddLandblock`. Their OnCreate already fired at AppendLiveEntity time. + +### Resolver evolution + +C.1.5a resolver: + +```csharp +Func defaultScriptResolver // returns scriptId or 0 +``` + +C.1.5b resolver: + +```csharp +Func activationResolver // returns null on miss +``` + +Where `ScriptActivationInfo` is a small record in `AcDream.App.Rendering.Vfx`: + +```csharp +public sealed record ScriptActivationInfo( + uint ScriptId, + IReadOnlyList PartTransforms); +``` + +Production lambda in `GameWindow.OnLoad` (replaces the C.1.5a one): + +```csharp +entity => +{ + try + { + var setup = _dats.Get(entity.SourceGfxObjOrSetupId); + if (setup is null) return null; + uint scriptId = setup.DefaultScript.DataId; + if (scriptId == 0) return null; + var parts = AcDream.Core.Meshing.SetupPartTransforms.Compute(setup); + return new ScriptActivationInfo(scriptId, parts); + } + catch + { + return null; + } +} +``` + +One dat lookup → both pieces of info. The Setup is cached by DatCollection, +so even hot-path scenery firing with no DefaultScript stays O(1). + +### Helper: `SetupPartTransforms.Compute` + +New static helper in `AcDream.Core.Meshing` (next to `SetupMesh`): + +```csharp +public static class SetupPartTransforms +{ + /// + /// Compute the per-part static transforms for a Setup using its + /// PlacementFrames. For each part i, the returned matrix is the + /// transform from part-local to setup-local space at the Setup's + /// resting pose. Mirrors SetupMesh.Flatten's pose-source priority: + /// PlacementFrames[Resting] → [Default] → first available. + /// Returns an empty list when the Setup has no PlacementFrames + /// (caller falls back to "no part transforms applied"). + /// + public static IReadOnlyList Compute(Setup setup); +} +``` + +This deliberately mirrors the pose-source priority in +`SetupMesh.Flatten` so a part's particle anchor matches its visible rest +position. (If the renderer's pose source ever diverges from this resolver, +particles will visibly drift — keep them in lockstep.) + +For animated entities, the renderer's `AnimatedEntityState` computes +per-frame part transforms; a future "animated DefaultScript" path would +publish those each tick via the same `SetEntityPartTransforms` seam. Out +of scope for C.1.5b. + +## §5 Data + lifecycle invariants + +| Concern | Behavior | +|---|---| +| Server-spawned entity spawn | `AppendLiveEntity` → `OnCreate` (existing). Keys by `ServerGuid`. | +| Server-spawned entity despawn | `RemoveEntityByServerGuid` → `OnRemove(serverGuid)` (existing). | +| Dat-hydrated entity load (initial) | `AddLandblock` → `OnCreate` for each `ServerGuid==0` entity. Keys by `entity.Id`. | +| Dat-hydrated entity load (promotion) | `AddEntitiesToExistingLandblock` → `OnCreate` for each entity in the new batch. Keys by `entity.Id`. | +| Dat-hydrated entity unload (full LB) | `RemoveLandblock` → `OnRemove(entity.Id)` for each `ServerGuid==0` entity. | +| Dat-hydrated entity unload (Near→Far demotion) | `RemoveEntitiesFromLandblock` → `OnRemove(entity.Id)` for each `ServerGuid==0` entity. | +| Pending live entity merged into AddLandblock | `OnCreate` already fired at `AppendLiveEntity`; filtered out by `ServerGuid != 0`. | +| Persistent live entity rescued from RemoveLandblock | Not unloaded; its script continues. Filtered out by `ServerGuid != 0`. | +| PartIndex out of bounds | Sink falls back to `Matrix4x4.Identity` for that part (no part transform applied, offset stays in entity-local frame as before). | +| Setup with empty PlacementFrames | Resolver returns empty `PartTransforms` list; sink falls back to Identity for every part. Equivalent to pre-C.1.5b behavior. | +| Resolver throws | Lambda's try/catch returns null; activator no-ops. | +| Same script re-fired on dedupe | `PhysicsScriptRunner.Play` replaces prior instance (existing C.1 behavior). Visual: script restarts from t=0. Avoided here because we filter dat-hydrated entities by `ServerGuid==0` — they're not double-fired. | + +### Idempotency + +- Duplicate `OnCreate` for same key → script restarts (existing dedupe). +- Duplicate `OnRemove` for same key → no-op. +- `OnRemove` for never-spawned key → no-op. +- LB unload immediately followed by LB load → entities get fresh `entity.Id` + (localCounter resets per-call) but the keys are computed deterministically + from landblockId + iteration order so a re-entered LB gets identical keys. + Script restarts cleanly because OnRemove fired during the unload. + +## §6 Testing + +### Unit tests — new + +1. **`SetupPartTransforms_ResolvesRestingPlacement_WhenAvailable`** — + Setup with `PlacementFrames[Resting]` containing 2 parts; assert returned + list has 2 matrices matching the resting frames. +2. **`SetupPartTransforms_FallsBackToDefault_WhenRestingMissing`** — + Setup with only `PlacementFrames[Default]`; assert it's used. +3. **`SetupPartTransforms_ReturnsEmpty_WhenNoPlacementFrames`** — + Setup with empty `PlacementFrames` dict; assert empty list. +4. **`SetupPartTransforms_AppliesDefaultScale_WhenPresent`** — + Setup with `DefaultScale[0] = (2, 2, 2)`; assert the matrix scales by 2. +5. **`ParticleHookSink_AppliesPartTransform_WhenRegistered`** — + register part transforms `[Identity, Translation(0,0,1)]`; fire a + CreateParticleHook with `PartIndex=1, Offset=(1,0,0)`; assert spawned + particle world position is `(1, 0, 1)`. +6. **`ParticleHookSink_FallsBackToIdentity_WhenPartIndexOutOfBounds`** — + register 2 part transforms; fire hook with `PartIndex=99`; assert + spawned at root + offset (no buried-by-bad-matrix). +7. **`EntityScriptActivator_KeysByEntityId_WhenServerGuidZero`** — + dat-hydrated entity with `ServerGuid=0, Id=0x40A9B401`; fire OnCreate; + assert script runner saw `entityId=0x40A9B401`. +8. **`EntityScriptActivator_PassesPartTransformsToSink`** — + resolver returns non-empty PartTransforms; assert sink's + `SetEntityPartTransforms` was called with the matching list. +9. **`EntityScriptActivator_OnRemove_StopsByGivenKey`** — + call `OnRemove(0x40A9B401)`; assert runner + sink both got that key. + +### Unit tests — updated + +The 4 existing `EntityScriptActivatorTests` are updated for the new +resolver signature (`_ => 0xAAu` → `_ => new ScriptActivationInfo(0xAAu, +Array.Empty())`). Test names and assertions stay the same. + +### Integration tests — GpuWorldState wiring + +10. **`GpuWorldState_AddLandblock_FiresActivatorForDatHydrated`** — + construct GpuWorldState with a fake activator (recording mock); add + a landblock with one `ServerGuid==0` entity; assert OnCreate fired + exactly once. +11. **`GpuWorldState_AddLandblock_DoesNotDoubleFire_OnPendingMerge`** — + AppendLiveEntity with `ServerGuid=0xCAFE` (one OnCreate); then + AddLandblock for the same canonical id; assert OnCreate fired only + once total for the live entity. +12. **`GpuWorldState_RemoveLandblock_FiresOnRemoveForDatHydrated`** — + AddLandblock with a dat-hydrated entity, then RemoveLandblock; assert + OnRemove fired with `entity.Id`. +13. **`GpuWorldState_AddEntitiesToExistingLandblock_FiresActivator`** — + promotion path; assert OnCreate fires for each promoted entity. +14. **`GpuWorldState_RemoveEntitiesFromLandblock_FiresOnRemove`** — + demotion path; assert OnRemove fires for each removed dat-hydrated + entity. + +Existing `GpuWorldStateTests` may need a minor update if any assert on +constructor arity (the resolver doesn't change shape — same 4 ctor params). + +### Visual verification (acceptance gate) + +Procedure (per [CLAUDE.md](../../../CLAUDE.md) "Visual verification workflow"): + +1. `dotnet build` green. +2. `dotnet test` green. +3. Launch live client with `ACDREAM_DUMP_PLAYSCRIPT=1`. +4. **Site 1 — Holtburg Town network portal** (same site as C.1.5a): + user walks `+Acdream` to the portal arch. Compare swirl vertical + extent + lateral spread to retail. Pass: no ground-burial, distinct + columns of emission visible across the arch. +5. **Site 2 — Holtburg Inn fireplace** (interior, EnvCell static): + user walks into the inn, stands near the fireplace. Pass: flame + particles emit from the firebox at retail-matching height/density. +6. **Site 3 — Cottage chimney** (exterior stab): user finds a Holtburg + cottage with smoke in retail; same cottage in acdream should now + show smoke. Pass: smoke column matches retail. +7. **Site 4 — Spell cast** on `+Acdream`: user casts a spell, optionally + in a safe spot. Pass: cast-anim particles match retail. + +Diagnostic: `ACDREAM_DUMP_PLAYSCRIPT=1` prints every `[pes] Play:` line — +if a site doesn't show particles, check the log to see whether the script +fired and with what scriptId. + +## §7 Risk + rollback + +**Slice A risks:** + +- `SetupPartTransforms.Compute` returns a list whose length doesn't match + `setup.Parts.Count`. **Mitigation:** sink's per-index bounds check falls + back to Identity; no buried particles, just reverts to C.1.5a behavior + for the over-indexed hook. +- Wrong pose source chosen (Resting vs Default). **Mitigation:** mirror + `SetupMesh.Flatten`'s priority chain exactly so renderer + particle + anchor stay in lockstep. If they ever diverge, particles drift visibly; + user spot-checks at the portal. + +**Slice B risks:** + +- Firing OnCreate for EVERY dat-hydrated entity (scenery counts ~thousands + per landblock at radius=4) becomes a perf hit. **Mitigation:** resolver + is one cached `DatCollection.Get` per entity — already amortized. + Most entities have `DefaultScript.DataId == 0`, resolver returns null, + OnCreate no-ops in ~1µs. Per-landblock-load cost: tens of µs, dwarfed + by mesh upload + RebuildFlatView. Measured if `[pes] Play:` line + spam appears in launch.log. +- Filter `ServerGuid==0` is too aggressive — misses some valid case. + **Mitigation:** every entity with `ServerGuid != 0` came through + `AppendLiveEntity` (verified by `RelocateEntity`'s + `if (entity.ServerGuid == 0) return;` guard at GpuWorldState.cs:204), + so they already had OnCreate fired. No miss. +- Idempotency edge case: rapid LB load/unload cycles produce repeated + Play → Stop → Play. **Mitigation:** existing PhysicsScriptRunner + dedupe handles re-Play; this is the same as a server retriggering a + PlayScript opcode. + +**Rollback path:** revert the spec's commits; the C.1.5a `EntityScriptActivator` +keeps working for live entities exactly as before. No data migrations. + +## §8 Doc-drift fixes from C.1.5a (folded in) + +The handoff §9 surfaced three trivial doc-drift items from C.1.5a. Folded +here for the record: + +1. C.1.5a spec §4 ("fifth optional parameter") was wrong — the activator + is actually GpuWorldState's **fourth** optional parameter (verified at + [GpuWorldState.cs:63](../../../src/AcDream.App/Streaming/GpuWorldState.cs): + `wbSpawnAdapter, wbEntitySpawnAdapter, onLandblockUnloaded, + entityScriptActivator`). +2. C.1.5a spec §4 ("~50 lines") was an estimate; the file shipped at + **93 lines** including doc comments. Slice A adds the part-transform + call + slice B drops the `ServerGuid == 0` guard, so the file will + land at ~100–110 lines after this phase. +3. `GpuWorldState.AddEntitiesToExistingLandblock` *will* fire the activator + in slice B (the handoff said it currently doesn't and noted "no-op + today because promotion-tier entities are atlas-tier"). With slice B, + atlas-tier entities WITH `DefaultScript` set will now activate. Per + the architecture comment at GpuWorldState.cs:384-391, this path + handles dat-static stabs/buildings — exactly the case slice B targets. + +## §9 Implementation notes + +- **File touches:** `ParticleHookSink.cs` (+~30 lines), `EntityScriptActivator.cs` + (+~10 lines, -~5 lines), `GpuWorldState.cs` (+~12 lines, 4 fire-sites), + `GameWindow.cs` (resolver lambda update, ~10 lines), new + `SetupPartTransforms.cs` (~50 lines), updated `EntityScriptActivatorTests.cs` + (4 ctor-signature updates + new tests), new `SetupPartTransformsTests.cs` + (~80 lines, 4 tests), new `ParticleHookSinkTests.cs` additions or new file + (~60 lines, 2 tests), new `GpuWorldStateActivatorTests.cs` (~120 lines, + 5 integration tests). +- **Estimated effort:** ~1 day. +- **Commit cadence:** four commits land this phase cleanly — + (1) `SetupPartTransforms` helper + tests, (2) `ParticleHookSink` part-transform + support + tests, (3) `EntityScriptActivator` resolver refactor + ServerGuid + guard relaxation + tests, (4) `GpuWorldState` fire-site wiring + tests + + production lambda update + the C.1.5a doc-drift comment for + `AddEntitiesToExistingLandblock`. Each commit `dotnet test` green. + Visual verification after all four land. +- **Roadmap update:** on ship, add a "Phase C.1.5b SHIPPED 2026-05-13" + entry to [`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md); + move #56 to "Recently closed" in `docs/ISSUES.md`. +- **CLAUDE.md update:** the "Currently in flight" line at the top of the + project-instructions block changes from C.1.5b to the next phase, with + the handoff doc reference dropped. Decide the next-phase pointer at + verification time. + +## §10 What's next (post-C.1.5b) + +Pending user direction. The roadmap candidate list from +[`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md): + +- Triage the chronic open-issue list — #2 (lightning), #4 (sky horizon-glow), + #28 (aurora), #29 (cloud thinness), #37 (humanoid coat), #50 (stray tree), + #41 (remote-motion blips) — link each to a future phase or downgrade. +- More Phase C visual-fidelity work (C.2 dynamic point lights, C.3 palette + tuning, C.4 double-sided translucent polys). +- N.6 slice 2 at reduced scope (atlas opportunities only). +- Perf tiers 2/3 only if sustained 500+ FPS becomes a requirement. + +Verification will surface which option the user picks. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e679b92..e079bbd 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1614,26 +1614,32 @@ public sealed class GameWindow : IDisposable _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) + // Phase C.1.5a/b: construct EntityScriptActivator so static entities + // (server-spawned AND dat-hydrated) fire Setup.DefaultScript through + // the PhysicsScriptRunner on enter-world. C.1.5b adds per-part + // transforms via SetupPartTransforms.Compute so multi-emitter scripts + // distribute across mesh parts (closes #56). _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. + AcDream.App.Rendering.Vfx.ScriptActivationInfo? ResolveActivation(AcDream.Core.World.WorldEntity e) { try { var setup = capturedDats?.Get(e.SourceGfxObjOrSetupId); - return setup?.DefaultScript.DataId ?? 0u; + if (setup is null) return null; + uint scriptId = setup.DefaultScript.DataId; + if (scriptId == 0) return null; + var parts = AcDream.Core.Meshing.SetupPartTransforms.Compute(setup); + return new AcDream.App.Rendering.Vfx.ScriptActivationInfo(scriptId, parts); } catch { - return 0u; + return null; } } var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator( - _scriptRunner!, _particleSink!, ResolveDefaultScript); + _scriptRunner!, _particleSink!, ResolveActivation); _entityScriptActivator = entityScriptActivator; // Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock diff --git a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs index ad14615..a8b0d2b 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs @@ -1,93 +1,133 @@ using System; +using System.Collections.Generic; +using System.Numerics; using AcDream.Core.Vfx; using AcDream.Core.World; namespace AcDream.App.Rendering.Vfx; +/// +/// What the activator's resolver returns when an entity's Setup carries +/// a DefaultScript. Bundles the script id with the per-part +/// transforms baked from Setup.PlacementFrames so a single dat +/// lookup yields both pieces of state. The activator pushes the part +/// transforms into +/// before calling , which closes +/// the part-anchor pipeline introduced for issue #56. +/// +public sealed record ScriptActivationInfo( + uint ScriptId, + IReadOnlyList PartTransforms); + /// /// Fires Setup.DefaultScript through -/// when a server-spawned 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. +/// when a enters the world, so static objects +/// (portals, chimneys, fireplaces, EnvCell decorations, building details) +/// emit their retail-faithful persistent particle effects automatically. +/// Stops the scripts and live emitters when the entity despawns. +/// +/// +/// Handles both server-spawned entities (ServerGuid != 0, keyed by +/// ServerGuid) and dat-hydrated entities (ServerGuid == 0, keyed by +/// entity.Id). The C.1.5a guard that early-returned for +/// ServerGuid == 0 was relaxed in C.1.5b so EnvCell static objects +/// (which have no server guid because they come from the dat file, not +/// the network) also fire their DefaultScript. +/// /// /// /// Wires alongside EntitySpawnAdapter in GpuWorldState: the -/// adapter handles meshes + animation state, the activator handles scripts + -/// particles. Both are render-thread-only. +/// adapter handles meshes + animation state, the activator handles scripts +/// + particles. Both are render-thread-only. The activator is invoked from +/// four GpuWorldState fire-sites (AppendLiveEntity, AddLandblock, +/// AddEntitiesToExistingLandblock, plus the matching remove paths). /// /// /// /// Retail oracle: play_script_internal(setup.DefaultScript) is what -/// retail's CPhysicsObj invokes at object load (see Phase C.1 plan §C.1 -/// and memory/project_sky_pes_port.md). C.1 already shipped the runner; -/// this class adds the missing fire-on-spawn call site. +/// retail's CPhysicsObj invokes at object load (see Phase C.1 plan +/// §C.1 and memory/project_sky_pes_port.md). C.1 already shipped the +/// runner; this class adds the missing fire-on-spawn call site. /// /// public sealed class EntityScriptActivator { private readonly PhysicsScriptRunner _scriptRunner; private readonly ParticleHookSink _particleSink; - private readonly Func _defaultScriptResolver; + private readonly Func _resolver; /// Already-shipped runner from C.1. Owns the /// (scriptId, entityId) instance table and schedules hooks at their /// StartTime offsets. /// Already-shipped hook sink from C.1. The - /// activator only calls its - /// to drop any per-entity emitter handles on despawn. - /// Returns - /// entity.SourceGfxObjOrSetupId's Setup.DefaultScript.DataId, - /// or 0 on miss / dat throw / missing field. Production lambda hits - /// ; tests pass a hand-rolled - /// stub. + /// activator pushes per-entity rotation + part transforms here, and + /// calls to drop + /// per-entity emitter handles on despawn. + /// Returns + /// with the entity's + /// Setup.DefaultScript.DataId and per-part transforms (via + /// SetupPartTransforms.Compute), or null on dat miss / + /// throw / missing DefaultScript. Production lambda hits + /// DatCollection; tests pass a hand-rolled stub. public EntityScriptActivator( PhysicsScriptRunner scriptRunner, ParticleHookSink particleSink, - Func defaultScriptResolver) + Func resolver) { ArgumentNullException.ThrowIfNull(scriptRunner); ArgumentNullException.ThrowIfNull(particleSink); - ArgumentNullException.ThrowIfNull(defaultScriptResolver); + ArgumentNullException.ThrowIfNull(resolver); _scriptRunner = scriptRunner; _particleSink = particleSink; - _defaultScriptResolver = defaultScriptResolver; + _resolver = resolver; } /// /// Resolve the entity's Setup.DefaultScript 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). + /// the script runner. Keys by entity.ServerGuid when non-zero, + /// otherwise by entity.Id (the latter handles dat-hydrated + /// EnvCell statics + exterior stabs whose entity.Id lives in + /// the 0x40xxxxxx range — collision-free with server guids). + /// No-op if the entity has no DefaultScript (resolver returns null + /// or zero-script). /// public void OnCreate(WorldEntity entity) { ArgumentNullException.ThrowIfNull(entity); - if (entity.ServerGuid == 0) return; + uint key = entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id; + if (key == 0) return; // malformed entity - uint scriptId = _defaultScriptResolver(entity); - if (scriptId == 0) return; + var info = _resolver(entity); + if (info is null || info.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); + // hook fires. C.1.5a fix: 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. + _particleSink.SetEntityRotation(key, entity.Rotation); - _scriptRunner.Play(scriptId, entity.ServerGuid, entity.Position); + // C.1.5b #56: seed the sink's per-entity part transforms so + // CreateParticleHook.PartIndex routes the hook offset through the + // right mesh part's resting transform. Without this, every emitter + // in a multi-part Setup collapses to the entity root. + _particleSink.SetEntityPartTransforms(key, info.PartTransforms); + + _scriptRunner.Play(info.ScriptId, key, entity.Position); } /// - /// 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). + /// Stop every script instance the runner is tracking for this key, and + /// kill every live emitter the sink has attributed to it. Caller picks + /// the key (the matching ServerGuid for live entities, or + /// entity.Id for dat-hydrated entities — mirror whatever was + /// used at ). Idempotent for unknown keys. /// - public void OnRemove(uint serverGuid) + public void OnRemove(uint key) { - if (serverGuid == 0) return; - _scriptRunner.StopAllForEntity(serverGuid); - _particleSink.StopAllForEntity(serverGuid, fadeOut: false); + if (key == 0) return; + _scriptRunner.StopAllForEntity(key); + _particleSink.StopAllForEntity(key, fadeOut: false); } } diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index b0524ed..90472f6 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -180,6 +180,21 @@ public sealed class GpuWorldState _loaded[landblock.LandblockId] = landblock; if (_wbSpawnAdapter is not null) _wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]); + + // C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0). + // Live entities (ServerGuid!=0) already had OnCreate fired at + // AppendLiveEntity; the filter avoids double-firing pending-bucket merges. + if (_entityScriptActivator is not null) + { + var loadedEntities = _loaded[landblock.LandblockId].Entities; + for (int i = 0; i < loadedEntities.Count; i++) + { + var e = loadedEntities[i]; + if (e.ServerGuid == 0) + _entityScriptActivator.OnCreate(e); + } + } + RebuildFlatView(); } @@ -245,6 +260,19 @@ public sealed class GpuWorldState _persistentRescued.Add(entity); } } + + // C.1.5b: stop DefaultScript for each dat-hydrated entity in + // the landblock. Server-spawned entities are either being + // rescued (script continues at the new LB) or were OnRemove'd + // via RemoveEntityByServerGuid earlier; leave them alone here. + if (_entityScriptActivator is not null) + { + foreach (var entity in lb.Entities) + { + if (entity.ServerGuid == 0) + _entityScriptActivator.OnRemove(entity.Id); + } + } } _pendingByLandblock.Remove(landblockId); @@ -408,6 +436,18 @@ public sealed class GpuWorldState // canonicalized). Null when the cache isn't wired (tests). Per spec §5.3 W3b. _onLandblockUnloaded?.Invoke(canonical); + // C.1.5b: stop DefaultScript for each dat-hydrated entity about to + // be dropped. Demote-tier entities are always atlas-tier (ServerGuid==0 + // per this method's class doc-comment); the filter is belt-and-suspenders. + if (_entityScriptActivator is not null) + { + foreach (var entity in lb.Entities) + { + if (entity.ServerGuid == 0) + _entityScriptActivator.OnRemove(entity.Id); + } + } + _loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty()); _pendingByLandblock.Remove(canonical); RebuildFlatView(); @@ -447,6 +487,17 @@ public sealed class GpuWorldState _loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged); if (_wbSpawnAdapter is not null) _wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical]); + + // C.1.5b: fire DefaultScript for each promoted dat-hydrated entity. + // All entities arriving via this path are atlas-tier by construction + // (the promotion path streams in dat-static scenery + EnvCell statics + // + stabs per the method's class doc-comment). + if (_entityScriptActivator is not null) + { + for (int i = 0; i < entities.Count; i++) + _entityScriptActivator.OnCreate(entities[i]); + } + RebuildFlatView(); } diff --git a/src/AcDream.Core/Meshing/SetupPartTransforms.cs b/src/AcDream.Core/Meshing/SetupPartTransforms.cs new file mode 100644 index 0000000..3bffbd5 --- /dev/null +++ b/src/AcDream.Core/Meshing/SetupPartTransforms.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.Core.Meshing; + +/// +/// Compute the per-part static transforms for a Setup using its +/// PlacementFrames. For each part i, the returned matrix takes a +/// point in part-local space and yields a point in setup-local space at +/// the Setup's resting pose. +/// +/// +/// Mirrors 's pose-source priority — +/// PlacementFrames[Resting][Default] → first available +/// — so that a particle anchor at part i matches the part's +/// visible rest position. If renderer and resolver ever drift on this +/// priority, particles will visibly drift relative to their parent +/// mesh; keep them in lockstep. +/// +/// +/// +/// Returns an empty list when the Setup has no PlacementFrames. The +/// caller (e.g. ParticleHookSink.SpawnFromHook) should then fall +/// back to per part, which is the +/// pre-C.1.5b behavior. +/// +/// +/// +/// For animated entities, per-part transforms vary per animation frame +/// and live in AnimatedEntityState; a future "animated +/// DefaultScript" path would publish those each tick via the same +/// SetEntityPartTransforms seam. Out of scope here. +/// +/// +public static class SetupPartTransforms +{ + public static IReadOnlyList Compute(Setup setup) + { + AnimationFrame? source = null; + if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting)) + { + source = resting; + } + else if (setup.PlacementFrames.TryGetValue(Placement.Default, out var def)) + { + source = def; + } + else + { + foreach (var kvp in setup.PlacementFrames) + { + source = kvp.Value; + break; + } + } + + if (source is null) return System.Array.Empty(); + + int partCount = setup.Parts.Count; + var result = new Matrix4x4[partCount]; + for (int i = 0; i < partCount; i++) + { + Frame frame = i < source.Frames.Count + ? source.Frames[i] + : new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; + Vector3 scale = i < setup.DefaultScale.Count ? setup.DefaultScale[i] : Vector3.One; + result[i] = Matrix4x4.CreateScale(scale) + * Matrix4x4.CreateFromQuaternion(frame.Orientation) + * Matrix4x4.CreateTranslation(frame.Origin); + } + return result; + } +} diff --git a/src/AcDream.Core/Vfx/ParticleHookSink.cs b/src/AcDream.Core/Vfx/ParticleHookSink.cs index bfb47e1..737b9dc 100644 --- a/src/AcDream.Core/Vfx/ParticleHookSink.cs +++ b/src/AcDream.Core/Vfx/ParticleHookSink.cs @@ -72,6 +72,14 @@ public sealed class ParticleHookSink : IAnimationHookSink private readonly ConcurrentDictionary _trackingByHandle = new(); private readonly ConcurrentDictionary _renderPassByEntity = new(); private readonly ConcurrentDictionary _rotationByEntity = new(); + // C.1.5b #56: per-entity static part transforms (PlacementFrames[Resting] + // baked into a Matrix4x4 per Setup part). When set, SpawnFromHook applies + // partTransforms[hook.PartIndex] to the hook offset BEFORE rotating to + // world space. Without this, every emitter in a multi-part Setup + // collapses to the entity root (the bug). Cleared by StopAllForEntity. + // For ANIMATED entities this map would need a per-tick refresh similar + // to UpdateEntityAnchor — deferred to a future phase. + private readonly ConcurrentDictionary> _partTransformsByEntity = new(); private int _anonymousEmitterSerial; public ParticleHookSink(ParticleSystem system) @@ -131,6 +139,19 @@ public sealed class ParticleHookSink : IAnimationHookSink public void SetEntityRotation(uint entityId, Quaternion rotation) => _rotationByEntity[entityId] = rotation; + /// + /// Register per-part static transforms for an entity. The caller + /// (typically EntityScriptActivator) precomputes one + /// per Setup part using + /// SetupPartTransforms.Compute and pushes them here at spawn + /// time. applies + /// partTransforms[hook.PartIndex] to the hook offset BEFORE + /// transforming to world space. Cleared on + /// . + /// + public void SetEntityPartTransforms(uint entityId, IReadOnlyList partTransforms) + => _partTransformsByEntity[entityId] = partTransforms; + public void ClearEntityRenderPass(uint entityId) => _renderPassByEntity.TryRemove(entityId, out _); @@ -171,6 +192,7 @@ public sealed class ParticleHookSink : IAnimationHookSink ClearEntityRenderPass(entityId); _rotationByEntity.TryRemove(entityId, out _); + _partTransformsByEntity.TryRemove(entityId, out _); } private void SpawnFromHook( @@ -181,13 +203,22 @@ public sealed class ParticleHookSink : IAnimationHookSink int partIndex, uint logicalId) { - // Spawn position: entity pose + hook offset. PartIndex will be - // used when the renderer passes per-part transforms through; for - // now, fold it into the root pos. + // Spawn position: entity pose + hook offset, with the hook + // offset first passed through the per-part transform when + // available (C.1.5b #56 fix). Without the per-part transform, + // every emitter in a multi-emitter PES script collapses to the + // entity root — visible symptom: ground-buried portal swirls. var rotation = _rotationByEntity.TryGetValue(entityId, out var rot) ? rot : Quaternion.Identity; - var anchor = worldPos + Vector3.Transform(offset, rotation); + Vector3 partLocal = offset; + if (_partTransformsByEntity.TryGetValue(entityId, out var partTransforms) + && partIndex >= 0 + && partIndex < partTransforms.Count) + { + partLocal = Vector3.Transform(offset, partTransforms[partIndex]); + } + var anchor = worldPos + Vector3.Transform(partLocal, rotation); var renderPass = _renderPassByEntity.TryGetValue(entityId, out var pass) ? pass : ParticleRenderPass.Scene; diff --git a/tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs b/tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs new file mode 100644 index 0000000..dfcb6e9 --- /dev/null +++ b/tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs @@ -0,0 +1,118 @@ +using System.Numerics; +using AcDream.Core.Meshing; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.Core.Tests.Meshing; + +public class SetupPartTransformsTests +{ + [Fact] + public void Compute_PrefersRestingPlacement_OverDefault() + { + // Resting lifts part 1 by +Z=1; Default has zero lift on every part. + // Compute must pick Resting (matches SetupMesh.Flatten priority). + var setup = new Setup + { + Parts = { 0x01000100u, 0x01000101u }, + DefaultScale = { Vector3.One, Vector3.One }, + PlacementFrames = + { + [Placement.Resting] = new AnimationFrame(2) + { + Frames = + { + new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }, + new Frame { Origin = new Vector3(0, 0, 1f), Orientation = Quaternion.Identity }, + }, + }, + [Placement.Default] = new AnimationFrame(2) + { + Frames = + { + new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }, + new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }, + }, + }, + }, + }; + + var transforms = SetupPartTransforms.Compute(setup); + + Assert.Equal(2, transforms.Count); + var probe = Vector3.Transform(Vector3.Zero, transforms[1]); + Assert.Equal(new Vector3(0, 0, 1f), probe); + } + + [Fact] + public void Compute_FallsBackToDefault_WhenRestingMissing() + { + var setup = new Setup + { + Parts = { 0x01000100u }, + DefaultScale = { Vector3.One }, + PlacementFrames = + { + [Placement.Default] = new AnimationFrame(1) + { + Frames = + { + new Frame { Origin = new Vector3(2f, 0, 0), Orientation = Quaternion.Identity }, + }, + }, + }, + }; + + var transforms = SetupPartTransforms.Compute(setup); + + Assert.Single(transforms); + var probe = Vector3.Transform(Vector3.Zero, transforms[0]); + Assert.Equal(new Vector3(2f, 0, 0), probe); + } + + [Fact] + public void Compute_ReturnsEmpty_WhenNoPlacementFrames() + { + // Setup with parts but no PlacementFrames — caller's + // ParticleHookSink falls back to Identity per part (pre-C.1.5b + // behavior). Returning empty signals "no per-part data available". + var setup = new Setup + { + Parts = { 0x01000100u, 0x01000101u }, + }; + + var transforms = SetupPartTransforms.Compute(setup); + + Assert.Empty(transforms); + } + + [Fact] + public void Compute_AppliesDefaultScale_WhenPresent() + { + // DefaultScale = (2,2,2) on part 0. An input (1,1,1) should + // come out (2,2,2) after the part transform — confirms the + // CreateScale factor is present in the matrix. + var setup = new Setup + { + Parts = { 0x01000100u }, + DefaultScale = { new Vector3(2f, 2f, 2f) }, + PlacementFrames = + { + [Placement.Resting] = new AnimationFrame(1) + { + Frames = + { + new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }, + }, + }, + }, + }; + + var transforms = SetupPartTransforms.Compute(setup); + + Assert.Single(transforms); + var probe = Vector3.Transform(new Vector3(1f, 1f, 1f), transforms[0]); + Assert.Equal(new Vector3(2f, 2f, 2f), probe); + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs b/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs index e1e75f9..4a5bd3f 100644 --- a/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs @@ -59,12 +59,21 @@ public sealed class EntityScriptActivatorTests return new Pipeline(system, hookSink, runner, recording); } + /// + /// Convenience: a resolver that always returns the given scriptId with + /// an empty part-transforms list (the C.1.5a-equivalent — no per-part + /// math). Useful for tests that exercise the scheduler without caring + /// about #56's per-part pipeline. + /// + private static System.Func StaticResolver(uint scriptId) + => _ => new ScriptActivationInfo(scriptId, System.Array.Empty()); + [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 activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu)); var entity = MakeEntity(serverGuid: 0xCAFEu, position: new Vector3(1, 2, 3)); activator.OnCreate(entity); @@ -80,7 +89,7 @@ public sealed class EntityScriptActivatorTests public void OnCreate_WithoutDefaultScript_DoesNothing() { var p = BuildPipeline(); // no scripts registered - var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0u); + var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => null); var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero); activator.OnCreate(entity); @@ -143,7 +152,7 @@ public sealed class EntityScriptActivatorTests id => table.TryGetValue(id, out var s) ? s : null, hookSink); - var activator = new EntityScriptActivator(runner, hookSink, _ => 0xAAu); + var activator = new EntityScriptActivator(runner, hookSink, StaticResolver(0xAAu)); // Entity rotated 90° around world-Z (yaw left); local +X maps to world +Y. var entityRotation = Quaternion.CreateFromAxisAngle( @@ -191,7 +200,7 @@ public sealed class EntityScriptActivatorTests 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 activator = new EntityScriptActivator(runner, hookSink, StaticResolver(0xAAu)); var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero); activator.OnCreate(entity); @@ -207,4 +216,112 @@ public sealed class EntityScriptActivatorTests system.Tick(0.01f); Assert.Equal(0, system.ActiveEmitterCount); } + + [Fact] + public void OnCreate_KeysByEntityId_WhenServerGuidZero() + { + // C.1.5b: dat-hydrated EnvCell statics + exterior stabs have + // ServerGuid == 0 but a stable entity.Id in the 0x40xxxxxx range. + // OnCreate must use entity.Id as the key (not skip). + var p = BuildPipeline( + (0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 })))); + var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu)); + var entity = new WorldEntity + { + Id = 0x40A9B401u, // dat-hydrated interior id + ServerGuid = 0u, // no server guid + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(5, 5, 5), + Rotation = Quaternion.Identity, + MeshRefs = System.Array.Empty(), + }; + + activator.OnCreate(entity); + + Assert.Equal(1, p.Runner.ActiveScriptCount); + p.Runner.Tick(0.001f); + Assert.Single(p.Recording.Calls); + Assert.Equal(0x40A9B401u, p.Recording.Calls[0].EntityId); + Assert.Equal(new Vector3(5, 5, 5), p.Recording.Calls[0].Pos); + } + + [Fact] + public void OnCreate_PassesPartTransformsToSink() + { + // C.1.5b #56: end-to-end test that the activator pushes the + // resolver's PartTransforms into the sink, and the sink applies + // them. Part 1 lifted +Z=1; hookOffset (1,0,0) with PartIndex=1 + // + identity rotation → expected world (1, 0, 1). + var registry = new EmitterDescRegistry(); + registry.Register(BuildPersistentEmitterDesc()); + var system = new ParticleSystem(registry); + var hookSink = new ParticleHookSink(system); + + var hookOffset = new Frame { Origin = new Vector3(1f, 0, 0), Orientation = Quaternion.Identity }; + var script = BuildScript( + (0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = hookOffset, PartIndex = 1 })); + var table = new Dictionary { [0xAAu] = script }; + var runner = new PhysicsScriptRunner( + id => table.TryGetValue(id, out var s) ? s : null, + hookSink); + + var partTransforms = new Matrix4x4[] + { + Matrix4x4.Identity, + Matrix4x4.CreateTranslation(0f, 0f, 1f), + }; + + var activator = new EntityScriptActivator(runner, hookSink, + _ => new ScriptActivationInfo(0xAAu, partTransforms)); + var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero); + + activator.OnCreate(entity); + runner.Tick(0.001f); + system.Tick(0.001f); + + var live = system.EnumerateLive().FirstOrDefault(); + Assert.NotNull(live.Emitter); + var pos = live.Emitter.Particles[live.Index].Position; + Assert.InRange(pos.X, 0.99f, 1.01f); + Assert.InRange(pos.Y, -0.01f, 0.01f); + Assert.InRange(pos.Z, 0.99f, 1.01f); + } + + [Fact] + public void OnRemove_StopsByGivenKey_ForDatHydratedEntity() + { + // C.1.5b: caller passes the entity.Id as the key for dat-hydrated + // entities (not ServerGuid). OnRemove must clean up correctly. + 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 { [0xAAu] = script }; + var runner = new PhysicsScriptRunner( + id => table.TryGetValue(id, out var s) ? s : null, + hookSink); + + var activator = new EntityScriptActivator(runner, hookSink, StaticResolver(0xAAu)); + var entity = new WorldEntity + { + Id = 0x40A9B402u, + ServerGuid = 0u, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = System.Array.Empty(), + }; + + activator.OnCreate(entity); + runner.Tick(0.001f); + Assert.True(system.ActiveEmitterCount > 0); + + activator.OnRemove(0x40A9B402u); // caller passes the entity.Id key + + Assert.Equal(0, runner.ActiveScriptCount); + system.Tick(0.01f); + Assert.Equal(0, system.ActiveEmitterCount); + } } diff --git a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs new file mode 100644 index 0000000..e5a2034 --- /dev/null +++ b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.Core.Physics; +using AcDream.Core.Vfx; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using Xunit; +using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; + +namespace AcDream.Core.Tests.Streaming; + +/// +/// Phase C.1.5b: verifies fires +/// / +/// from the four +/// dat-hydration paths (AddLandblock, AddEntitiesToExistingLandblock, +/// RemoveLandblock, RemoveEntitiesFromLandblock), and that the +/// pending-bucket merge in AddLandblock does NOT double-fire for live +/// entities that already had OnCreate at . +/// +public sealed class GpuWorldStateActivatorTests +{ + private sealed class RecordingSink : IAnimationHookSink + { + public List<(uint EntityId, Vector3 Pos, AnimationHook Hook)> Calls = new(); + public void OnHook(uint entityId, Vector3 worldPos, AnimationHook hook) + => Calls.Add((entityId, worldPos, hook)); + } + + private sealed record Pipeline( + GpuWorldState State, + PhysicsScriptRunner Runner, + ParticleHookSink Sink, + RecordingSink Recording); + + private static Pipeline BuildPipeline(uint scriptId) + { + var script = new DatPhysicsScript(); + script.ScriptData.Add(new PhysicsScriptData + { + StartTime = 0.0, + Hook = new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() }, + }); + var table = new Dictionary { [scriptId] = script }; + + var registry = new EmitterDescRegistry(); + var system = new ParticleSystem(registry); + var sink = new ParticleHookSink(system); + var recording = new RecordingSink(); + var runner = new PhysicsScriptRunner(id => table.TryGetValue(id, out var s) ? s : null, recording); + var activator = new EntityScriptActivator(runner, sink, + _ => new ScriptActivationInfo(scriptId, Array.Empty())); + + var state = new GpuWorldState(entityScriptActivator: activator); + return new Pipeline(state, runner, sink, recording); + } + + private static LoadedLandblock MakeStubLandblock(uint canonicalId, params WorldEntity[] entities) + => new(canonicalId, new LandBlock(), entities); + + private static WorldEntity DatHydrated(uint id, Vector3 pos) => new() + { + Id = id, + ServerGuid = 0u, + SourceGfxObjOrSetupId = 0x02000001u, + Position = pos, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + private static WorldEntity Live(uint serverGuid, Vector3 pos) => new() + { + Id = serverGuid, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = pos, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + [Fact] + public void AddLandblock_FiresActivatorForDatHydratedEntity() + { + var p = BuildPipeline(scriptId: 0xAAu); + var entity = DatHydrated(id: 0x40A9B401u, pos: new Vector3(1, 2, 3)); + var lb = MakeStubLandblock(0xA9B4FFFFu, entity); + + p.State.AddLandblock(lb); + + // Tick fires the CreateParticleHook into RecordingSink. + p.Runner.Tick(0.001f); + Assert.Single(p.Recording.Calls); + Assert.Equal(0x40A9B401u, p.Recording.Calls[0].EntityId); + Assert.Equal(new Vector3(1, 2, 3), p.Recording.Calls[0].Pos); + } + + [Fact] + public void AddLandblock_DoesNotDoubleFire_OnPendingMerge() + { + // Live entity (ServerGuid!=0) arrives via AppendLiveEntity first — + // OnCreate fires once at that point. Then AddLandblock for the + // same canonical id pulls the pending entity into the loaded list. + // The new fire-site MUST NOT call OnCreate again (the live entity + // is filtered out by ServerGuid != 0). + var p = BuildPipeline(scriptId: 0xAAu); + var live = Live(serverGuid: 0xCAFEu, pos: Vector3.Zero); + + p.State.AppendLiveEntity(0xA9B4FFFFu, live); + var emptyLb = MakeStubLandblock(0xA9B4FFFFu); + p.State.AddLandblock(emptyLb); + + p.Runner.Tick(0.001f); + Assert.Single(p.Recording.Calls); // exactly one — no double-fire + Assert.Equal(0xCAFEu, p.Recording.Calls[0].EntityId); + } + + [Fact] + public void RemoveLandblock_FiresOnRemoveForDatHydratedEntity() + { + var p = BuildPipeline(scriptId: 0xAAu); + var entity = DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero); + var lb = MakeStubLandblock(0xA9B4FFFFu, entity); + + p.State.AddLandblock(lb); + // Don't Tick: Play queued the script in _active immediately; ticking + // would fire its single StartTime=0 hook and self-remove the script + // before we can observe RemoveLandblock cleaning it up. + Assert.Equal(1, p.Runner.ActiveScriptCount); + + p.State.RemoveLandblock(0xA9B4FFFFu); + Assert.Equal(0, p.Runner.ActiveScriptCount); + } + + [Fact] + public void AddEntitiesToExistingLandblock_FiresActivatorForEachPromoted() + { + var p = BuildPipeline(scriptId: 0xAAu); + var emptyLb = MakeStubLandblock(0xA9B4FFFFu); + p.State.AddLandblock(emptyLb); + + var promoted = new[] + { + DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero), + DatHydrated(id: 0x40A9B402u, pos: Vector3.UnitX), + }; + p.State.AddEntitiesToExistingLandblock(0xA9B4FFFFu, promoted); + + p.Runner.Tick(0.001f); + Assert.Equal(2, p.Recording.Calls.Count); + } + + [Fact] + public void RemoveEntitiesFromLandblock_FiresOnRemoveForDatHydratedEntities() + { + var p = BuildPipeline(scriptId: 0xAAu); + var entity = DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero); + var lb = MakeStubLandblock(0xA9B4FFFFu, entity); + p.State.AddLandblock(lb); + // Don't Tick: see comment in RemoveLandblock_FiresOnRemoveForDatHydratedEntity. + Assert.Equal(1, p.Runner.ActiveScriptCount); + + p.State.RemoveEntitiesFromLandblock(0xA9B4FFFFu); + Assert.Equal(0, p.Runner.ActiveScriptCount); + } +} diff --git a/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs b/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs index 1fc53e6..2fbf839 100644 --- a/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs +++ b/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs @@ -92,4 +92,84 @@ public sealed class ParticleHookSinkTests sys.Tick(0.01f); Assert.Equal(0, sys.ActiveEmitterCount); } + + [Fact] + public void SpawnFromHook_AppliesPartTransform_WhenRegistered() + { + // C.1.5b #56: when SetEntityPartTransforms has been called for + // entityId, SpawnFromHook must transform the hook offset through + // the part-local matrix before applying entity rotation. + // Part 1 is lifted +Z=1; hook offset = (1, 0, 0), PartIndex=1. + // Expected world position: (1, 0, 1) with identity rotation. + var registry = new EmitterDescRegistry(); + registry.Register(MakeDesc(0x32000030u, attachLocal: false)); + var sys = new ParticleSystem(registry, new System.Random(42)); + var sink = new ParticleHookSink(sys); + + var partTransforms = new Matrix4x4[] + { + Matrix4x4.Identity, + Matrix4x4.CreateTranslation(0f, 0f, 1f), + }; + sink.SetEntityRotation(0xCAFEu, Quaternion.Identity); + sink.SetEntityPartTransforms(0xCAFEu, partTransforms); + + sink.OnHook(0xCAFEu, Vector3.Zero, new CreateParticleHook + { + EmitterInfoId = 0x32000030u, + EmitterId = 0, + PartIndex = 1, + Offset = new Frame + { + Origin = new Vector3(1f, 0f, 0f), + Orientation = Quaternion.Identity, + }, + }); + sys.Tick(0.001f); + + var live = System.Linq.Enumerable.Single(sys.EnumerateLive()); + var pos = live.Emitter.Particles[live.Index].Position; + Assert.InRange(pos.X, 0.99f, 1.01f); + Assert.InRange(pos.Y, -0.01f, 0.01f); + Assert.InRange(pos.Z, 0.99f, 1.01f); + } + + [Fact] + public void SpawnFromHook_FallsBackToIdentity_WhenPartIndexOutOfBounds() + { + // Out-of-bounds PartIndex must NOT crash and must NOT apply a + // wrong matrix; falls back to no part transform (Identity), so + // the offset is applied in entity-local space as pre-C.1.5b. + var registry = new EmitterDescRegistry(); + registry.Register(MakeDesc(0x32000031u, attachLocal: false)); + var sys = new ParticleSystem(registry, new System.Random(42)); + var sink = new ParticleHookSink(sys); + + var partTransforms = new Matrix4x4[] + { + Matrix4x4.Identity, + Matrix4x4.CreateTranslation(0f, 0f, 1f), + }; + sink.SetEntityRotation(0xCAFEu, Quaternion.Identity); + sink.SetEntityPartTransforms(0xCAFEu, partTransforms); + + sink.OnHook(0xCAFEu, Vector3.Zero, new CreateParticleHook + { + EmitterInfoId = 0x32000031u, + EmitterId = 0, + PartIndex = 99, // way past the 2-part array + Offset = new Frame + { + Origin = new Vector3(2f, 0f, 0f), + Orientation = Quaternion.Identity, + }, + }); + sys.Tick(0.001f); + + var live = System.Linq.Enumerable.Single(sys.EnumerateLive()); + var pos = live.Emitter.Particles[live.Index].Position; + Assert.InRange(pos.X, 1.99f, 2.01f); + Assert.InRange(pos.Y, -0.01f, 0.01f); + Assert.InRange(pos.Z, -0.01f, 0.01f); + } }