Merge branch 'claude/trusting-elbakyan-633b52' — Phase C.1.5b (per-part PES transforms + EnvCell DefaultScript)
Closes #56. Two coupled slices in one phase: Slice A — ParticleHookSink honors CreateParticleHook.PartIndex. Adds new SetupPartTransforms.Compute(setup) helper (walks PlacementFrames [Resting] → [Default] → first-available, mirrors SetupMesh.Flatten's priority); ParticleHookSink gains a per-entity part-transforms side-table; SpawnFromHook now applies partTransforms[PartIndex] to the hook offset before world-space rotation. Multi-emitter scripts distribute across mesh parts instead of collapsing to entity root. Slice B — EntityScriptActivator handles dat-hydrated entities. The ServerGuid==0 early-return guard is relaxed: activator keys by ServerGuid when non-zero, else entity.Id (collision-free in 0x40xxxxxx range). Resolver delegate returns ScriptActivationInfo(ScriptId, PartTransforms) so one dat lookup yields both pieces. GpuWorldState fires the activator from 4 new dat-hydration sites (AddLandblock + AddEntitiesToExistingLandblock for OnCreate, RemoveLandblock + RemoveEntitiesFromLandblock for OnRemove). EnvCell statics + exterior stabs (inn fireplaces, cottage chimneys, building decorations) now fire their Setup.DefaultScript automatically. Reality discovery during design (spec §3): EnvCell.StaticObjects are already hydrated as WorldEntity instances by GameWindow.BuildInterior EntitiesForStreaming with stable entity.Id — handoff §4 Q1/Q2 (synthetic ID scheme, separate walker class) mooted. 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; cottage chimney smoke; spell cast on +Acdream — all match retail. 18 new + 4 updated tests, all Vfx/Meshing/Activator/Streaming green. 8 pre-existing Physics/Input failures unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
eab347d7e4
14 changed files with 2268 additions and 102 deletions
44
CLAUDE.md
44
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Setup>(...)?.DefaultScript.DataId` with defensive try/catch returning `0u` on miss. Activator also seeds `_particleSink.SetEntityRotation` so hook offsets transform from entity-local to world space correctly. **Verified at the Holtburg Town network portal**: 10-hook portal script fires end-to-end with correct color, persistence, orientation, multi-emitter dispatch. **Known limitation surfaced and filed as issue #56**: `ParticleHookSink` ignores `CreateParticleHook.PartIndex`, so the 10 emitters collapse to one root position instead of distributing across the portal Setup's parts — visually produces a compressed, partly-ground-buried swirl. Mechanism is correct; per-part transform handling is the next vfx-pipeline work (blocks slice 2 visual delight; affects every multi-emitter PES). Spec: [`docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md`](../superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md). Plan: [`docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md`](../superpowers/plans/2026-05-12-phase-c1.5a-portals.md). | Live ✓ (with #56) |
|
||||
| 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.
|
||||
|
|
|
|||
946
docs/superpowers/plans/2026-05-13-phase-c1.5b.md
Normal file
946
docs/superpowers/plans/2026-05-13-phase-c1.5b.md
Normal file
|
|
@ -0,0 +1,946 @@
|
|||
# Phase C.1.5b — issue #56 + EnvCell DefaultScript implementation plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal (slice A):** Fix [issue #56](../../ISSUES.md) — `ParticleHookSink` ignores `CreateParticleHook.PartIndex`, causing every emitter in a multi-emitter PES script (portals, fireplaces, chimneys) to collapse to the entity root. Precompute each Setup part's resting transform at activator-spawn time and apply it to the hook offset before spawning the particle.
|
||||
|
||||
**Goal (slice B):** Fire `Setup.DefaultScript` for dat-hydrated entities (EnvCell statics + exterior stabs) too — not just server-spawned ones. Drop the `EntityScriptActivator.OnCreate` ServerGuid==0 guard and wire OnCreate/OnRemove into GpuWorldState's dat-hydration paths.
|
||||
|
||||
**Architecture:** No new orchestrator classes. New helper `SetupPartTransforms.Compute(Setup)` in `AcDream.Core.Meshing`. `ParticleHookSink` grows `SetEntityPartTransforms`. `EntityScriptActivator` resolver returns a `ScriptActivationInfo` record bundling scriptId + per-part transforms. `GpuWorldState` fires the activator from four dat-hydration paths (AddLandblock, AddEntitiesToExistingLandblock, RemoveLandblock, RemoveEntitiesFromLandblock).
|
||||
|
||||
**Tech Stack:** C# / .NET 10, xUnit, Silk.NET (existing). No new dependencies.
|
||||
|
||||
**Spec:** [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../specs/2026-05-13-phase-c1.5b-design.md). Read it first.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
**Created:**
|
||||
|
||||
- `src/AcDream.Core/Meshing/SetupPartTransforms.cs` — helper that walks Setup.PlacementFrames → list of Matrix4x4 per part.
|
||||
- `tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs` — 4 tests.
|
||||
- `tests/AcDream.Core.Tests/Vfx/ParticleHookSinkPartTransformTests.cs` — 2 tests (new file because the existing tests would otherwise gain unrelated tests).
|
||||
- `tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs` — 5 integration tests for the activator wiring.
|
||||
|
||||
**Modified:**
|
||||
|
||||
- `src/AcDream.Core/Vfx/ParticleHookSink.cs` — new `_partTransformsByEntity` map; `SetEntityPartTransforms` method; `SpawnFromHook` applies the part transform; `StopAllForEntity` clears the entry.
|
||||
- `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs` — resolver signature change to `Func<WorldEntity, ScriptActivationInfo?>`; `ServerGuid==0` guard replaced with `key = ServerGuid != 0 ? ServerGuid : entity.Id`; pushes part transforms to sink; new `ScriptActivationInfo` record alongside the activator.
|
||||
- `tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs` — 4 existing tests updated for new resolver signature; 3 new tests added.
|
||||
- `src/AcDream.App/Streaming/GpuWorldState.cs` — 4 new foreach blocks (one per AddLandblock / AddEntitiesToExistingLandblock / RemoveLandblock / RemoveEntitiesFromLandblock).
|
||||
- `src/AcDream.App/Rendering/GameWindow.cs` — resolver lambda upgraded to return `ScriptActivationInfo?`.
|
||||
- `docs/plans/2026-04-11-roadmap.md` — append "Phase C.1.5b SHIPPED" row on verification pass.
|
||||
- `docs/ISSUES.md` — move #56 to Recently closed.
|
||||
- `CLAUDE.md` — update "Currently in flight" line to point to next phase post-C.1.5b.
|
||||
|
||||
Each file's responsibility:
|
||||
|
||||
- `SetupPartTransforms` — pure function; Setup → matrices. No dat lookups, no GL, no entity state.
|
||||
- `ParticleHookSink` — owns per-entity part-transform side-table (mirroring its existing `_rotationByEntity` pattern). Applies the transform inside `SpawnFromHook`.
|
||||
- `EntityScriptActivator` — keys correctly by ServerGuid OR Id; pushes both rotation + part transforms to the sink before scheduling. Knows nothing about dats.
|
||||
- `GpuWorldState` — owns the four new fire-sites. Filters out live entities on the dat-hydration paths (avoid double-fire).
|
||||
- `GameWindow` — wiring root; the resolver lambda is the only place dats touch the activator.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `SetupPartTransforms` helper + tests (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.Core/Meshing/SetupPartTransforms.cs`
|
||||
- Create: `tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs`
|
||||
|
||||
- [ ] **Step 1.1 — Write the test file with 4 failing tests**
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Meshing;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Meshing;
|
||||
|
||||
public sealed class SetupPartTransformsTests
|
||||
{
|
||||
private static AnimationFrame BuildFrame(params Frame[] frames)
|
||||
{
|
||||
var af = new AnimationFrame();
|
||||
foreach (var f in frames) af.Frames.Add(f);
|
||||
return af;
|
||||
}
|
||||
|
||||
private static Setup BuildSetup(
|
||||
int partCount,
|
||||
IReadOnlyDictionary<Placement, AnimationFrame>? placementFrames = null,
|
||||
IReadOnlyList<Vector3>? defaultScale = null)
|
||||
{
|
||||
var setup = new Setup();
|
||||
for (int i = 0; i < partCount; i++) setup.Parts.Add(0x01000001u);
|
||||
if (placementFrames is not null)
|
||||
foreach (var (k, v) in placementFrames) setup.PlacementFrames[k] = v;
|
||||
if (defaultScale is not null)
|
||||
foreach (var s in defaultScale) setup.DefaultScale.Add(s);
|
||||
return setup;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvesRestingPlacement_WhenAvailable()
|
||||
{
|
||||
var resting = BuildFrame(
|
||||
new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity },
|
||||
new Frame { Origin = new Vector3(0, 0, 1f), Orientation = Quaternion.Identity });
|
||||
|
||||
var setup = BuildSetup(partCount: 2,
|
||||
placementFrames: new Dictionary<Placement, AnimationFrame>
|
||||
{
|
||||
[Placement.Resting] = resting,
|
||||
[Placement.Default] = BuildFrame(new Frame(), new Frame()),
|
||||
});
|
||||
|
||||
var transforms = SetupPartTransforms.Compute(setup);
|
||||
|
||||
Assert.Equal(2, transforms.Count);
|
||||
var probe = Vector3.Transform(Vector3.Zero, transforms[1]);
|
||||
Assert.Equal(new Vector3(0, 0, 1f), probe);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FallsBackToDefault_WhenRestingMissing()
|
||||
{
|
||||
var defaultFrame = BuildFrame(
|
||||
new Frame { Origin = new Vector3(2f, 0, 0), Orientation = Quaternion.Identity });
|
||||
|
||||
var setup = BuildSetup(partCount: 1,
|
||||
placementFrames: new Dictionary<Placement, AnimationFrame>
|
||||
{
|
||||
[Placement.Default] = defaultFrame,
|
||||
});
|
||||
|
||||
var transforms = SetupPartTransforms.Compute(setup);
|
||||
|
||||
Assert.Single(transforms);
|
||||
var probe = Vector3.Transform(Vector3.Zero, transforms[0]);
|
||||
Assert.Equal(new Vector3(2f, 0, 0), probe);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsEmpty_WhenNoPlacementFrames()
|
||||
{
|
||||
var setup = BuildSetup(partCount: 2);
|
||||
var transforms = SetupPartTransforms.Compute(setup);
|
||||
Assert.Empty(transforms);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppliesDefaultScale_WhenPresent()
|
||||
{
|
||||
var resting = BuildFrame(
|
||||
new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity });
|
||||
|
||||
var setup = BuildSetup(partCount: 1,
|
||||
placementFrames: new Dictionary<Placement, AnimationFrame> { [Placement.Resting] = resting },
|
||||
defaultScale: new[] { new Vector3(2f, 2f, 2f) });
|
||||
|
||||
var transforms = SetupPartTransforms.Compute(setup);
|
||||
|
||||
var probe = Vector3.Transform(new Vector3(1f, 1f, 1f), transforms[0]);
|
||||
Assert.Equal(new Vector3(2f, 2f, 2f), probe);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1.2 — Implement `SetupPartTransforms`**
|
||||
|
||||
`src/AcDream.Core/Meshing/SetupPartTransforms.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Meshing;
|
||||
|
||||
/// <summary>
|
||||
/// Compute the per-part static transforms for a Setup using its
|
||||
/// PlacementFrames. For each part i, the returned matrix is the
|
||||
/// transform from part-local to setup-local space at the Setup's
|
||||
/// resting pose. Mirrors <see cref="SetupMesh.Flatten"/>'s pose-source
|
||||
/// priority: PlacementFrames[Resting] → [Default] → first available.
|
||||
/// Returns an empty list when the Setup has no PlacementFrames
|
||||
/// (caller falls back to "no part transforms applied" — equivalent to
|
||||
/// pre-C.1.5b behavior in <c>ParticleHookSink.SpawnFromHook</c>).
|
||||
/// </summary>
|
||||
public static class SetupPartTransforms
|
||||
{
|
||||
public static IReadOnlyList<Matrix4x4> Compute(Setup setup)
|
||||
{
|
||||
AnimationFrame? source = null;
|
||||
if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting))
|
||||
source = resting;
|
||||
else if (setup.PlacementFrames.TryGetValue(Placement.Default, out var def))
|
||||
source = def;
|
||||
else
|
||||
{
|
||||
foreach (var kvp in setup.PlacementFrames)
|
||||
{
|
||||
source = kvp.Value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (source is null) return System.Array.Empty<Matrix4x4>();
|
||||
|
||||
int partCount = setup.Parts.Count;
|
||||
var result = new Matrix4x4[partCount];
|
||||
for (int i = 0; i < partCount; i++)
|
||||
{
|
||||
Frame frame = i < source.Frames.Count
|
||||
? source.Frames[i]
|
||||
: new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
|
||||
Vector3 scale = i < setup.DefaultScale.Count ? setup.DefaultScale[i] : Vector3.One;
|
||||
result[i] = Matrix4x4.CreateScale(scale)
|
||||
* Matrix4x4.CreateFromQuaternion(frame.Orientation)
|
||||
* Matrix4x4.CreateTranslation(frame.Origin);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1.3 — Verify**
|
||||
|
||||
```pwsh
|
||||
dotnet test --filter "FullyQualifiedName~SetupPartTransformsTests" -c Debug
|
||||
```
|
||||
|
||||
All 4 tests pass. `dotnet build` green.
|
||||
|
||||
- [ ] **Step 1.4 — Commit**
|
||||
|
||||
```
|
||||
feat(vfx #C.1.5b): SetupPartTransforms helper for per-part anchor transforms
|
||||
|
||||
Computes Matrix4x4 per Setup part by walking PlacementFrames[Resting] →
|
||||
[Default] → first-available, matching SetupMesh.Flatten's priority.
|
||||
Foundation for #56 fix: ParticleHookSink will use these to apply the
|
||||
hook's PartIndex-relative offset to the right mesh part.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `ParticleHookSink` part-transform support + tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.Core/Vfx/ParticleHookSink.cs`
|
||||
- Create: `tests/AcDream.Core.Tests/Vfx/ParticleHookSinkPartTransformTests.cs`
|
||||
|
||||
- [ ] **Step 2.1 — Write the test file with 2 failing tests**
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Vfx;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Vfx;
|
||||
|
||||
public sealed class ParticleHookSinkPartTransformTests
|
||||
{
|
||||
private static EmitterDesc BuildPersistentEmitterDesc() => new()
|
||||
{
|
||||
DatId = 100u,
|
||||
Type = ParticleType.Still,
|
||||
EmitterKind = ParticleEmitterKind.BirthratePerSec,
|
||||
MaxParticles = 4,
|
||||
InitialParticles = 1,
|
||||
TotalParticles = 0,
|
||||
TotalDuration = 0f,
|
||||
Lifespan = 999f,
|
||||
LifetimeMin = 999f,
|
||||
LifetimeMax = 999f,
|
||||
Birthrate = 0.5f,
|
||||
StartSize = 0.5f,
|
||||
EndSize = 0.5f,
|
||||
StartAlpha = 1f,
|
||||
EndAlpha = 1f,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void AppliesPartTransform_WhenRegistered()
|
||||
{
|
||||
var partTransforms = new Matrix4x4[]
|
||||
{
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.CreateTranslation(0f, 0f, 1f),
|
||||
};
|
||||
|
||||
var registry = new EmitterDescRegistry();
|
||||
registry.Register(BuildPersistentEmitterDesc());
|
||||
var system = new ParticleSystem(registry);
|
||||
var sink = new ParticleHookSink(system);
|
||||
|
||||
sink.SetEntityRotation(0xCAFEu, Quaternion.Identity);
|
||||
sink.SetEntityPartTransforms(0xCAFEu, partTransforms);
|
||||
|
||||
sink.OnHook(0xCAFEu, Vector3.Zero, new CreateParticleHook
|
||||
{
|
||||
EmitterInfoId = 100u,
|
||||
PartIndex = 1,
|
||||
Offset = new Frame
|
||||
{
|
||||
Origin = new Vector3(1f, 0f, 0f),
|
||||
Orientation = Quaternion.Identity,
|
||||
},
|
||||
EmitterId = 0u,
|
||||
});
|
||||
|
||||
system.Tick(0.001f);
|
||||
|
||||
var live = system.EnumerateLive().FirstOrDefault();
|
||||
Assert.NotNull(live.Emitter);
|
||||
var pos = live.Emitter.Particles[live.Index].Position;
|
||||
Assert.InRange(pos.X, 0.99f, 1.01f);
|
||||
Assert.InRange(pos.Y, -0.01f, 0.01f);
|
||||
Assert.InRange(pos.Z, 0.99f, 1.01f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FallsBackToIdentity_WhenPartIndexOutOfBounds()
|
||||
{
|
||||
var partTransforms = new[] { Matrix4x4.Identity, Matrix4x4.CreateTranslation(0f, 0f, 1f) };
|
||||
|
||||
var registry = new EmitterDescRegistry();
|
||||
registry.Register(BuildPersistentEmitterDesc());
|
||||
var system = new ParticleSystem(registry);
|
||||
var sink = new ParticleHookSink(system);
|
||||
|
||||
sink.SetEntityRotation(0xCAFEu, Quaternion.Identity);
|
||||
sink.SetEntityPartTransforms(0xCAFEu, partTransforms);
|
||||
|
||||
sink.OnHook(0xCAFEu, Vector3.Zero, new CreateParticleHook
|
||||
{
|
||||
EmitterInfoId = 100u,
|
||||
PartIndex = 99,
|
||||
Offset = new Frame { Origin = new Vector3(2f, 0f, 0f), Orientation = Quaternion.Identity },
|
||||
});
|
||||
|
||||
system.Tick(0.001f);
|
||||
|
||||
var live = system.EnumerateLive().FirstOrDefault();
|
||||
Assert.NotNull(live.Emitter);
|
||||
var pos = live.Emitter.Particles[live.Index].Position;
|
||||
Assert.InRange(pos.X, 1.99f, 2.01f);
|
||||
Assert.InRange(pos.Y, -0.01f, 0.01f);
|
||||
Assert.InRange(pos.Z, -0.01f, 0.01f);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2 — Modify `ParticleHookSink`**
|
||||
|
||||
Add field next to `_rotationByEntity`:
|
||||
```csharp
|
||||
private readonly ConcurrentDictionary<uint, IReadOnlyList<Matrix4x4>> _partTransformsByEntity = new();
|
||||
```
|
||||
|
||||
Add method next to `SetEntityRotation`:
|
||||
```csharp
|
||||
public void SetEntityPartTransforms(uint entityId, IReadOnlyList<Matrix4x4> partTransforms)
|
||||
=> _partTransformsByEntity[entityId] = partTransforms;
|
||||
```
|
||||
|
||||
Add cleanup line inside `StopAllForEntity`:
|
||||
```csharp
|
||||
_partTransformsByEntity.TryRemove(entityId, out _);
|
||||
```
|
||||
|
||||
Modify `SpawnFromHook` — replace the anchor computation:
|
||||
```csharp
|
||||
var rotation = _rotationByEntity.TryGetValue(entityId, out var rot)
|
||||
? rot
|
||||
: Quaternion.Identity;
|
||||
|
||||
Vector3 partLocal = offset;
|
||||
if (_partTransformsByEntity.TryGetValue(entityId, out var pts)
|
||||
&& partIndex >= 0
|
||||
&& partIndex < pts.Count)
|
||||
{
|
||||
partLocal = Vector3.Transform(offset, pts[partIndex]);
|
||||
}
|
||||
|
||||
var anchor = worldPos + Vector3.Transform(partLocal, rotation);
|
||||
```
|
||||
|
||||
- [ ] **Step 2.3 — Verify**
|
||||
|
||||
```pwsh
|
||||
dotnet test --filter "FullyQualifiedName~ParticleHookSinkPartTransformTests" -c Debug
|
||||
dotnet test -c Debug
|
||||
```
|
||||
|
||||
Both new tests pass. Existing tests still green (the change is backwards-compatible — entities without registered part transforms fall through to identity, same as before).
|
||||
|
||||
- [ ] **Step 2.4 — Commit**
|
||||
|
||||
```
|
||||
fix(vfx #56): ParticleHookSink applies CreateParticleHook.PartIndex transform
|
||||
|
||||
Adds per-entity part-transform side-table mirroring _rotationByEntity.
|
||||
SpawnFromHook now transforms the hook offset through partTransforms[partIndex]
|
||||
before rotating to world space. Backwards-compatible: entities without
|
||||
registered part transforms fall through to identity (pre-C.1.5b behavior).
|
||||
|
||||
Closes the renderer side of #56. EntityScriptActivator wiring lands next.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `EntityScriptActivator` resolver refactor + ServerGuid relaxation + tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`
|
||||
- Modify: `tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs`
|
||||
|
||||
- [ ] **Step 3.1 — Add `ScriptActivationInfo` record and update activator**
|
||||
|
||||
In `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`, add at the top of the namespace (before the activator class):
|
||||
|
||||
```csharp
|
||||
public sealed record ScriptActivationInfo(
|
||||
uint ScriptId,
|
||||
IReadOnlyList<Matrix4x4> PartTransforms);
|
||||
```
|
||||
|
||||
Change the resolver field type:
|
||||
```csharp
|
||||
private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;
|
||||
```
|
||||
|
||||
Update ctor parameter name + type accordingly.
|
||||
|
||||
Replace `OnCreate`:
|
||||
```csharp
|
||||
public void OnCreate(WorldEntity entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
uint key = entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id;
|
||||
if (key == 0) return;
|
||||
|
||||
var info = _resolver(entity);
|
||||
if (info is null || info.ScriptId == 0) return;
|
||||
|
||||
_particleSink.SetEntityRotation(key, entity.Rotation);
|
||||
_particleSink.SetEntityPartTransforms(key, info.PartTransforms);
|
||||
_scriptRunner.Play(info.ScriptId, key, entity.Position);
|
||||
}
|
||||
```
|
||||
|
||||
Replace `OnRemove`:
|
||||
```csharp
|
||||
public void OnRemove(uint key)
|
||||
{
|
||||
if (key == 0) return;
|
||||
_scriptRunner.StopAllForEntity(key);
|
||||
_particleSink.StopAllForEntity(key, fadeOut: false);
|
||||
}
|
||||
```
|
||||
|
||||
Update doc comments to reflect the new key semantics (handles both server-spawned and dat-hydrated entities; caller picks the key for OnRemove).
|
||||
|
||||
- [ ] **Step 3.2 — Update the 4 existing tests for the new resolver signature**
|
||||
|
||||
In `EntityScriptActivatorTests.cs`, every `_ => 0xAAu` becomes:
|
||||
```csharp
|
||||
_ => new ScriptActivationInfo(0xAAu, System.Array.Empty<Matrix4x4>())
|
||||
```
|
||||
|
||||
Every `_ => 0u` becomes:
|
||||
```csharp
|
||||
_ => null
|
||||
```
|
||||
|
||||
`OnRemove(entity.ServerGuid)` calls stay correct (the public API now takes `uint key` either way).
|
||||
|
||||
- [ ] **Step 3.3 — Add 3 new tests**
|
||||
|
||||
Append to `EntityScriptActivatorTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void OnCreate_KeysByEntityId_WhenServerGuidZero()
|
||||
{
|
||||
var p = BuildPipeline(
|
||||
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 }))));
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink,
|
||||
_ => new ScriptActivationInfo(0xAAu, System.Array.Empty<Matrix4x4>()));
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 0x40A9B401u,
|
||||
ServerGuid = 0u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(5, 5, 5),
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
activator.OnCreate(entity);
|
||||
|
||||
Assert.Equal(1, p.Runner.ActiveScriptCount);
|
||||
p.Runner.Tick(0.001f);
|
||||
Assert.Single(p.Recording.Calls);
|
||||
Assert.Equal(0x40A9B401u, p.Recording.Calls[0].EntityId);
|
||||
Assert.Equal(new Vector3(5, 5, 5), p.Recording.Calls[0].Pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_PassesPartTransformsToSink()
|
||||
{
|
||||
var registry = new EmitterDescRegistry();
|
||||
registry.Register(BuildPersistentEmitterDesc());
|
||||
var system = new ParticleSystem(registry);
|
||||
var hookSink = new ParticleHookSink(system);
|
||||
|
||||
var hookOffset = new Frame { Origin = new Vector3(1f, 0, 0), Orientation = Quaternion.Identity };
|
||||
var script = BuildScript(
|
||||
(0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = hookOffset, PartIndex = 1 }));
|
||||
var table = new Dictionary<uint, DatPhysicsScript> { [0xAAu] = script };
|
||||
var runner = new PhysicsScriptRunner(
|
||||
id => table.TryGetValue(id, out var s) ? s : null,
|
||||
hookSink);
|
||||
|
||||
var partTransforms = new Matrix4x4[]
|
||||
{
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.CreateTranslation(0f, 0f, 1f),
|
||||
};
|
||||
|
||||
var activator = new EntityScriptActivator(runner, hookSink,
|
||||
_ => new ScriptActivationInfo(0xAAu, partTransforms));
|
||||
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
|
||||
|
||||
activator.OnCreate(entity);
|
||||
runner.Tick(0.001f);
|
||||
system.Tick(0.001f);
|
||||
|
||||
var live = system.EnumerateLive().FirstOrDefault();
|
||||
Assert.NotNull(live.Emitter);
|
||||
var pos = live.Emitter.Particles[live.Index].Position;
|
||||
Assert.InRange(pos.X, 0.99f, 1.01f);
|
||||
Assert.InRange(pos.Y, -0.01f, 0.01f);
|
||||
Assert.InRange(pos.Z, 0.99f, 1.01f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnRemove_StopsByGivenKey()
|
||||
{
|
||||
var registry = new EmitterDescRegistry();
|
||||
registry.Register(BuildPersistentEmitterDesc());
|
||||
var system = new ParticleSystem(registry);
|
||||
var hookSink = new ParticleHookSink(system);
|
||||
|
||||
var script = BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() }));
|
||||
var table = new Dictionary<uint, DatPhysicsScript> { [0xAAu] = script };
|
||||
var runner = new PhysicsScriptRunner(
|
||||
id => table.TryGetValue(id, out var s) ? s : null,
|
||||
hookSink);
|
||||
|
||||
var activator = new EntityScriptActivator(runner, hookSink,
|
||||
_ => new ScriptActivationInfo(0xAAu, System.Array.Empty<Matrix4x4>()));
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 0x40A9B402u,
|
||||
ServerGuid = 0u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
activator.OnCreate(entity);
|
||||
runner.Tick(0.001f);
|
||||
Assert.True(system.ActiveEmitterCount > 0);
|
||||
|
||||
activator.OnRemove(0x40A9B402u);
|
||||
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
system.Tick(0.01f);
|
||||
Assert.Equal(0, system.ActiveEmitterCount);
|
||||
}
|
||||
```
|
||||
|
||||
The existing `OnRemove_StopsScriptsAndEmitters` test continues to test the server-guid path. The new `OnRemove_StopsByGivenKey` exercises the dat-hydrated-entity path with the new key.
|
||||
|
||||
- [ ] **Step 3.4 — Verify**
|
||||
|
||||
```pwsh
|
||||
dotnet test -c Debug
|
||||
```
|
||||
|
||||
All tests green, including 4 updated + 3 new in `EntityScriptActivatorTests`, plus the 2 from Task 2 and the 4 from Task 1.
|
||||
|
||||
- [ ] **Step 3.5 — Commit**
|
||||
|
||||
```
|
||||
feat(vfx #C.1.5b): activator handles dat-hydrated entities + per-part transforms
|
||||
|
||||
Resolver returns ScriptActivationInfo(ScriptId, PartTransforms) — one
|
||||
dat lookup per spawn yields both pieces of info. Activator keys by
|
||||
ServerGuid when nonzero, else entity.Id, so dat-hydrated entities
|
||||
(EnvCell statics, exterior stabs) flow through the same code path.
|
||||
Pushes per-part transforms into the sink before scheduling.
|
||||
|
||||
Closes the activator side of #56. GpuWorldState fire-site wiring next.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `GpuWorldState` fire-site wiring + production lambda + integration tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs`
|
||||
- Create: `tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs`
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — resolver lambda
|
||||
|
||||
- [ ] **Step 4.1 — Add the 4 foreach blocks in `GpuWorldState`**
|
||||
|
||||
In `AddLandblock` (after `_loaded[landblock.LandblockId] = landblock;` and after `_wbSpawnAdapter?.OnLandblockLoaded(...)`):
|
||||
```csharp
|
||||
// C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0).
|
||||
// Live entities (ServerGuid!=0) already had OnCreate fired at
|
||||
// AppendLiveEntity; filter to avoid double-firing pending-bucket merges.
|
||||
if (_entityScriptActivator is not null)
|
||||
{
|
||||
var entities = _loaded[landblock.LandblockId].Entities;
|
||||
for (int i = 0; i < entities.Count; i++)
|
||||
{
|
||||
var e = entities[i];
|
||||
if (e.ServerGuid == 0)
|
||||
_entityScriptActivator.OnCreate(e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In `AddEntitiesToExistingLandblock` (after the merge + the `_wbSpawnAdapter` call):
|
||||
```csharp
|
||||
// C.1.5b: fire DefaultScript for each promoted dat-hydrated entity.
|
||||
// All entities arriving via this path are dat-hydrated by construction
|
||||
// (the promotion path streams in atlas-tier content).
|
||||
if (_entityScriptActivator is not null)
|
||||
{
|
||||
for (int i = 0; i < entities.Count; i++)
|
||||
_entityScriptActivator.OnCreate(entities[i]);
|
||||
}
|
||||
```
|
||||
|
||||
In `RemoveLandblock` (inside the `if (_loaded.TryGetValue(...))` block, after the rescue loop):
|
||||
```csharp
|
||||
// C.1.5b: stop DefaultScript for each dat-hydrated entity in the
|
||||
// landblock. Server-spawned entities are either being rescued (script
|
||||
// continues) or were OnRemove'd via RemoveEntityByServerGuid earlier;
|
||||
// leave them alone here.
|
||||
if (_entityScriptActivator is not null)
|
||||
{
|
||||
foreach (var entity in lb.Entities)
|
||||
{
|
||||
if (entity.ServerGuid == 0)
|
||||
_entityScriptActivator.OnRemove(entity.Id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In `RemoveEntitiesFromLandblock` (after the existing `_onLandblockUnloaded?.Invoke(canonical)`, before the entities-list replacement):
|
||||
```csharp
|
||||
// C.1.5b: stop DefaultScript for each dat-hydrated entity about to
|
||||
// be dropped. Demote-tier entities are always atlas-tier
|
||||
// (ServerGuid==0); the filter is belt-and-suspenders.
|
||||
if (_entityScriptActivator is not null)
|
||||
{
|
||||
foreach (var entity in lb.Entities)
|
||||
{
|
||||
if (entity.ServerGuid == 0)
|
||||
_entityScriptActivator.OnRemove(entity.Id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `RemoveEntityByServerGuid` site at line 290 stays the same — `OnRemove(uint key)` accepts any key.
|
||||
|
||||
- [ ] **Step 4.2 — Update the production resolver lambda in `GameWindow`**
|
||||
|
||||
At GameWindow.cs:1617-1637, replace the existing resolver lambda:
|
||||
|
||||
```csharp
|
||||
var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator(
|
||||
scriptRunner,
|
||||
particleHookSink,
|
||||
entity =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(entity.SourceGfxObjOrSetupId);
|
||||
if (setup is null) return null;
|
||||
uint scriptId = setup.DefaultScript.DataId;
|
||||
if (scriptId == 0) return null;
|
||||
var parts = AcDream.Core.Meshing.SetupPartTransforms.Compute(setup);
|
||||
return new AcDream.App.Rendering.Vfx.ScriptActivationInfo(scriptId, parts);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4.3 — Write the integration tests**
|
||||
|
||||
Create `tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Vfx;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
|
||||
|
||||
namespace AcDream.Core.Tests.Streaming;
|
||||
|
||||
public sealed class GpuWorldStateActivatorTests
|
||||
{
|
||||
private sealed class RecordingSink : IAnimationHookSink
|
||||
{
|
||||
public List<(uint EntityId, Vector3 Pos, AnimationHook Hook)> Calls = new();
|
||||
public void OnHook(uint entityId, Vector3 worldPos, AnimationHook hook)
|
||||
=> Calls.Add((entityId, worldPos, hook));
|
||||
}
|
||||
|
||||
private static (GpuWorldState State, PhysicsScriptRunner Runner, ParticleHookSink Sink, RecordingSink Recording)
|
||||
BuildState(uint scriptId)
|
||||
{
|
||||
var script = new DatPhysicsScript();
|
||||
script.ScriptData.Add(new PhysicsScriptData
|
||||
{
|
||||
StartTime = 0.0,
|
||||
Hook = new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() },
|
||||
});
|
||||
var table = new Dictionary<uint, DatPhysicsScript> { [scriptId] = script };
|
||||
|
||||
var registry = new EmitterDescRegistry();
|
||||
var system = new ParticleSystem(registry);
|
||||
var sink = new ParticleHookSink(system);
|
||||
var recording = new RecordingSink();
|
||||
var runner = new PhysicsScriptRunner(id => table.TryGetValue(id, out var s) ? s : null, recording);
|
||||
var activator = new EntityScriptActivator(runner, sink,
|
||||
_ => new ScriptActivationInfo(scriptId, System.Array.Empty<Matrix4x4>()));
|
||||
|
||||
var state = new GpuWorldState(entityScriptActivator: activator);
|
||||
return (state, runner, sink, recording);
|
||||
}
|
||||
|
||||
private static WorldEntity DatHydrated(uint id, Vector3 pos) => new()
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = 0u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = pos,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
private static WorldEntity Live(uint serverGuid, Vector3 pos) => new()
|
||||
{
|
||||
Id = serverGuid,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = pos,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void AddLandblock_FiresActivatorForDatHydrated()
|
||||
{
|
||||
var p = BuildState(scriptId: 0xAAu);
|
||||
var entity = DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero);
|
||||
var lb = new LoadedLandblock(0xA9B4FFFFu, new float[0], new[] { entity });
|
||||
|
||||
p.State.AddLandblock(lb);
|
||||
p.Runner.Tick(0.001f);
|
||||
|
||||
Assert.Single(p.Recording.Calls);
|
||||
Assert.Equal(0x40A9B401u, p.Recording.Calls[0].EntityId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddLandblock_DoesNotDoubleFire_OnPendingMerge()
|
||||
{
|
||||
var p = BuildState(scriptId: 0xAAu);
|
||||
var live = Live(serverGuid: 0xCAFEu, pos: Vector3.Zero);
|
||||
|
||||
p.State.AppendLiveEntity(0xA9B4FFFFu, live);
|
||||
|
||||
var lb = new LoadedLandblock(0xA9B4FFFFu, new float[0], System.Array.Empty<WorldEntity>());
|
||||
p.State.AddLandblock(lb);
|
||||
|
||||
p.Runner.Tick(0.001f);
|
||||
Assert.Single(p.Recording.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveLandblock_FiresOnRemoveForDatHydrated()
|
||||
{
|
||||
var p = BuildState(scriptId: 0xAAu);
|
||||
var entity = DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero);
|
||||
var lb = new LoadedLandblock(0xA9B4FFFFu, new float[0], new[] { entity });
|
||||
|
||||
p.State.AddLandblock(lb);
|
||||
p.Runner.Tick(0.001f);
|
||||
Assert.Equal(1, p.Runner.ActiveScriptCount);
|
||||
|
||||
p.State.RemoveLandblock(0xA9B4FFFFu);
|
||||
Assert.Equal(0, p.Runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddEntitiesToExistingLandblock_FiresActivator()
|
||||
{
|
||||
var p = BuildState(scriptId: 0xAAu);
|
||||
var emptyLb = new LoadedLandblock(0xA9B4FFFFu, new float[0], System.Array.Empty<WorldEntity>());
|
||||
p.State.AddLandblock(emptyLb);
|
||||
|
||||
var promoted = new[]
|
||||
{
|
||||
DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero),
|
||||
DatHydrated(id: 0x40A9B402u, pos: Vector3.UnitX),
|
||||
};
|
||||
p.State.AddEntitiesToExistingLandblock(0xA9B4FFFFu, promoted);
|
||||
|
||||
p.Runner.Tick(0.001f);
|
||||
Assert.Equal(2, p.Recording.Calls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveEntitiesFromLandblock_FiresOnRemove()
|
||||
{
|
||||
var p = BuildState(scriptId: 0xAAu);
|
||||
var entity = DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero);
|
||||
var lb = new LoadedLandblock(0xA9B4FFFFu, new float[0], new[] { entity });
|
||||
p.State.AddLandblock(lb);
|
||||
p.Runner.Tick(0.001f);
|
||||
Assert.Equal(1, p.Runner.ActiveScriptCount);
|
||||
|
||||
p.State.RemoveEntitiesFromLandblock(0xA9B4FFFFu);
|
||||
Assert.Equal(0, p.Runner.ActiveScriptCount);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4.4 — Verify build + tests**
|
||||
|
||||
```pwsh
|
||||
dotnet build -c Debug
|
||||
dotnet test -c Debug
|
||||
```
|
||||
|
||||
All tests green. No regressions in existing `GpuWorldStateTests` (if any assert on ctor arity — they shouldn't, since C.1.5a already added the optional `entityScriptActivator` param).
|
||||
|
||||
- [ ] **Step 4.5 — Commit**
|
||||
|
||||
```
|
||||
feat(vfx #C.1.5b): GpuWorldState fires activator for dat-hydrated entities
|
||||
|
||||
Wires EntityScriptActivator.OnCreate into AddLandblock,
|
||||
AddEntitiesToExistingLandblock, and OnRemove into RemoveLandblock +
|
||||
RemoveEntitiesFromLandblock. ServerGuid==0 filter avoids double-firing
|
||||
on pending-bucket merges of live entities.
|
||||
|
||||
GameWindow's resolver lambda upgraded to return ScriptActivationInfo
|
||||
(scriptId + per-part transforms from SetupPartTransforms.Compute).
|
||||
|
||||
Closes #56. Slice A (per-part transforms) + slice B (dat-hydrated
|
||||
entities) both wired end-to-end. Ready for visual verification at
|
||||
Holtburg portal + Inn fireplace + cottage chimney.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Visual verification + ship docs + merge
|
||||
|
||||
- [ ] **Step 5.1 — Launch the live client**
|
||||
|
||||
```pwsh
|
||||
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||||
$env:ACDREAM_LIVE = "1"
|
||||
$env:ACDREAM_TEST_HOST = "127.0.0.1"
|
||||
$env:ACDREAM_TEST_PORT = "9000"
|
||||
$env:ACDREAM_TEST_USER = "testaccount"
|
||||
$env:ACDREAM_TEST_PASS = "testpassword"
|
||||
$env:ACDREAM_DUMP_PLAYSCRIPT = "1"
|
||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
|
||||
Tee-Object -FilePath "launch.log"
|
||||
```
|
||||
|
||||
Run in background so the parent session keeps control of the terminal.
|
||||
|
||||
- [ ] **Step 5.2 — Visual verification with the user**
|
||||
|
||||
Four sites (per spec §6):
|
||||
1. **Holtburg Town network portal** — swirl extends vertically, no ground-burial, distinct columns visible.
|
||||
2. **Holtburg Inn fireplace** — flame particles at firebox.
|
||||
3. **Cottage chimney** — smoke column visible.
|
||||
4. **Spell cast on +Acdream** — cast-anim particles match retail.
|
||||
|
||||
Diagnostic: `Select-String "\[pes\] Play:" launch.log` — every successful Play call. If a site has no particles, the log shows whether the script fired and with what scriptId.
|
||||
|
||||
**STOP and wait for the user to confirm each site matches retail.** This is the acceptance gate.
|
||||
|
||||
- [ ] **Step 5.3 — Ship docs**
|
||||
|
||||
If all four sites pass:
|
||||
|
||||
1. **`docs/plans/2026-04-11-roadmap.md`** — append "Phase C.1.5b SHIPPED 2026-05-13 — closes #56 + EnvCell DefaultScript dispatch" entry to the shipped table.
|
||||
2. **`docs/ISSUES.md`** — move #56 from Active to Recently closed with the commit SHA from Task 4.
|
||||
3. **`CLAUDE.md`** — update the "Currently in flight" line. Drop the C.1.5b reference. Either point to the next phase the user picks, or leave a "between phases" note.
|
||||
|
||||
- [ ] **Step 5.4 — Final commit + merge**
|
||||
|
||||
```
|
||||
docs(vfx #C.1.5b): ship Phase C.1.5b — closes #56 + EnvCell DefaultScript dispatch
|
||||
|
||||
Roadmap: add SHIPPED row.
|
||||
ISSUES: #56 → Recently closed.
|
||||
CLAUDE.md: "Currently in flight" pointer updated.
|
||||
|
||||
Visual verification 2026-05-13: portal swirl matches retail extent +
|
||||
spread (no ground-burial); Holtburg Inn fireplace flames; cottage
|
||||
chimney smoke; spell cast particles all match retail.
|
||||
```
|
||||
|
||||
Then `git checkout main && git merge --no-ff claude/trusting-elbakyan-633b52` and push.
|
||||
|
||||
---
|
||||
|
||||
## Notes for the executing agent
|
||||
|
||||
- **TDD discipline:** every Task starts with a failing test, then implementation, then verify. The C.1.5a phase shipped clean because the test scaffolding caught the spawn-on-zero-guid case AND the rotation-seed case before they became visual regressions. Same discipline here for the per-part transform pipeline.
|
||||
- **Don't touch animated entities.** The `SetEntityPartTransforms` seam is keyed by entity, so a future "animated DefaultScript" phase can push fresh transforms each tick without changing this contract. Out of scope for C.1.5b.
|
||||
- **Don't touch `ParticleRenderer.cs`.** Bindless migration is N.6 slice 2.
|
||||
- **Don't invent emitter types.** Reuse existing PES data.
|
||||
- **If visual verification fails:** check `launch.log` for `[pes]` lines first. If the script DID fire but particles look wrong, the bug is in `SpawnFromHook` or the part-transform math. If the script DIDN'T fire, the bug is in the activator wiring or the resolver. Investigate via decomp + cross-reference (`docs/research/named-retail/` for the retail expectation) before guessing — the CLAUDE.md workflow.
|
||||
- **Worktree cleanup:** see handoff §9 for the post-merge cleanup command for the C.1.5a worktree at `lucid-burnell-aab524`.
|
||||
|
||||
Spec at [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../specs/2026-05-13-phase-c1.5b-design.md) is the source of truth for the architecture; this plan is the execution sequence.
|
||||
522
docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md
Normal file
522
docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
# Phase C.1.5b — issue #56 (per-part collapse) + EnvCell static DefaultScript dispatch
|
||||
|
||||
**Created:** 2026-05-13.
|
||||
**Author:** Claude (lead engineer/architect).
|
||||
**Phase:** C.1.5b (second of two slices; C.1.5a portal-PES wiring shipped 2026-05-11 in merge `88bda12`).
|
||||
**Parent plan:** [`docs/plans/2026-04-27-phase-c1-pes-particles.md`](../../plans/2026-04-27-phase-c1-pes-particles.md) §C.1.5.
|
||||
**Handoff doc:** [`docs/plans/2026-05-12-phase-c1.5b-handoff.md`](../../plans/2026-05-12-phase-c1.5b-handoff.md).
|
||||
|
||||
---
|
||||
|
||||
## §1 Goals
|
||||
|
||||
Two coupled slices in one phase, in this order:
|
||||
|
||||
**Slice A — `ParticleHookSink` honors `CreateParticleHook.PartIndex` for static
|
||||
entities.** Closes [issue #56](../../ISSUES.md). The Holtburg Town network
|
||||
portal's 10-emitter script currently collapses every emitter to the entity
|
||||
root, producing a compressed, partially-ground-buried swirl. The fix is to
|
||||
precompute each Setup part's resting transform at spawn time and apply it to
|
||||
the hook offset before spawning the particle.
|
||||
|
||||
**Slice B — `EntityScriptActivator` fires `Setup.DefaultScript` for
|
||||
dat-hydrated entities too.** Right now the activator gates on
|
||||
`entity.ServerGuid != 0`, which means EnvCell static objects (interior
|
||||
fireplaces, inn decorations, exterior stabs like cottage chimneys) — which
|
||||
have no server guid because they come from the dat file, not the network —
|
||||
never get their DefaultScript fired. Drop the guard, key by `entity.Id` when
|
||||
`ServerGuid == 0`, and wire `OnCreate` / `OnRemove` calls into GpuWorldState's
|
||||
dat-hydration paths.
|
||||
|
||||
Plus a **visual confirmation pass** for the animation-hook particle path
|
||||
(already shipped in C.1; just needs a sanity check by casting a spell on
|
||||
`+Acdream`).
|
||||
|
||||
### Acceptance
|
||||
|
||||
Visual verification at three retail-side-by-side locations in/near Holtburg:
|
||||
|
||||
1. **Town network portal** (the C.1.5a verification site): swirl extends
|
||||
vertically through the portal arch with retail-like shape; no
|
||||
ground-burial; emitters distributed across the portal Setup's parts.
|
||||
2. **Holtburg Inn fireplace** (interior, EnvCell static): flame particles
|
||||
match retail's pattern and position over the firebox.
|
||||
3. **Cottage chimney** (exterior stab — TBD which cottage): smoke
|
||||
particles match retail.
|
||||
4. **Animation-hook spell cast** on `+Acdream`: cast-anim particle effect
|
||||
matches retail.
|
||||
|
||||
## §2 Scope
|
||||
|
||||
**In:**
|
||||
|
||||
- New helper `AcDream.Core.Meshing.SetupPartTransforms.Compute(Setup)` that
|
||||
walks `PlacementFrames[Resting]` → fallback `[Default]` → first available
|
||||
and returns `IReadOnlyList<Matrix4x4>` (one transform per part).
|
||||
- `ParticleHookSink.SetEntityPartTransforms(uint entityId, IReadOnlyList<Matrix4x4> partTransforms)`
|
||||
+ a backing `_partTransformsByEntity` map cleared by `StopAllForEntity`.
|
||||
- `ParticleHookSink.SpawnFromHook` applies `partTransforms[partIndex]` to the
|
||||
hook offset before rotating to world space.
|
||||
- `EntityScriptActivator` resolver signature changes from
|
||||
`Func<WorldEntity, uint>` to `Func<WorldEntity, ScriptActivationInfo?>` so
|
||||
both `ScriptId` and `PartTransforms` come from one dat lookup.
|
||||
- `EntityScriptActivator.OnCreate` keys by `entity.ServerGuid != 0 ?
|
||||
entity.ServerGuid : entity.Id`. Same activator handles both server-spawned
|
||||
and dat-hydrated entities — no new class.
|
||||
- `EntityScriptActivator.OnRemove(uint key)` — caller picks the key.
|
||||
- `GpuWorldState` wires the activator into four more places:
|
||||
`AddLandblock` (dat-hydrated entities only — filter by `ServerGuid==0` to
|
||||
avoid double-firing pending live entities), `AddEntitiesToExistingLandblock`
|
||||
(the just-promoted entities — all dat-hydrated by construction),
|
||||
`RemoveLandblock` (dat-hydrated entities only), and
|
||||
`RemoveEntitiesFromLandblock` (dat-hydrated entities only).
|
||||
- Visual verification at the four sites in §1 Acceptance.
|
||||
|
||||
**Out:**
|
||||
|
||||
- Animated entities (NPCs, monsters, the player). Per-part transforms vary
|
||||
per animation frame and would need a per-tick refresh similar to
|
||||
`UpdateEntityAnchor`. Deferred to a future phase. The new
|
||||
`SetEntityPartTransforms` is keyed by entity, so an animated-entity path
|
||||
can later push fresh transforms each tick without changing the contract.
|
||||
- Renderer changes. `particle.frag` stays as-is; bindless migration is N.6
|
||||
slice 2.
|
||||
- WB's re-fire-after-1s loop logic — portal swirls + fireplace flames are
|
||||
persistent (`TotalParticles=0 && TotalSeconds=0`), no re-fire needed.
|
||||
- New emitter types. Reuse existing PES data.
|
||||
|
||||
## §3 Background
|
||||
|
||||
### What shipped in C.1.5a (the part we keep)
|
||||
|
||||
The mechanism is correct: `EntityScriptActivator.OnCreate` runs on every
|
||||
server-spawned `WorldEntity`, resolves `Setup.DefaultScript`, seeds
|
||||
`_particleSink.SetEntityRotation`, calls `_scriptRunner.Play(scriptId,
|
||||
entity.ServerGuid, entity.Position)`. Multi-hook scripts dispatch at their
|
||||
correct `StartTime` offsets. Despawn cleanup works.
|
||||
|
||||
### What's broken (issue #56)
|
||||
|
||||
[`ParticleHookSink.SpawnFromHook`](../../../src/AcDream.Core/Vfx/ParticleHookSink.cs)
|
||||
at lines 176-217:
|
||||
|
||||
```csharp
|
||||
var rotation = _rotationByEntity.TryGetValue(entityId, out var rot)
|
||||
? rot : Quaternion.Identity;
|
||||
var anchor = worldPos + Vector3.Transform(offset, rotation);
|
||||
```
|
||||
|
||||
The hook author intended `offset` to be in **part-local** space — i.e.,
|
||||
relative to the mesh part identified by `cph.PartIndex` — so the geometry
|
||||
retail computes is:
|
||||
|
||||
```
|
||||
anchor = entityWorldPos + entityRotation × (partFrame.Origin + partFrame.Orientation × hookOffset)
|
||||
```
|
||||
|
||||
Our sink drops the part transform multiplication. For the Holtburg portal
|
||||
(entity `0x7A9B405B`, script `0x3300126D`, 10 hooks distributed across the
|
||||
portal Setup's parts), every emitter lands at the entity root. Visible
|
||||
symptom: swirl partially buried, lateral spread compressed.
|
||||
|
||||
### Where part transforms come from (static entities)
|
||||
|
||||
For static entities, per-part transforms live in
|
||||
`setup.PlacementFrames[Placement.Resting]` (fallback `[Default]`, fallback
|
||||
first available — same priority chain
|
||||
[`SetupMesh.Flatten`](../../../src/AcDream.Core/Meshing/SetupMesh.cs) at
|
||||
lines 36-50 already uses). Per part `i`:
|
||||
|
||||
```csharp
|
||||
Matrix4x4.CreateScale(setup.DefaultScale[i])
|
||||
* Matrix4x4.CreateFromQuaternion(placementFrame.Frames[i].Orientation)
|
||||
* Matrix4x4.CreateTranslation(placementFrame.Frames[i].Origin)
|
||||
```
|
||||
|
||||
`DefaultScale` defaults to `Vector3.One` when the list is shorter than
|
||||
`Parts.Count`.
|
||||
|
||||
### Where EnvCell statics come from (slice B)
|
||||
|
||||
**Major discovery from this design pass — the handoff's §4 Q1/Q2 are mooted:**
|
||||
[`GameWindow.BuildInteriorEntitiesForStreaming`](../../../src/AcDream.App/Rendering/GameWindow.cs)
|
||||
at lines 5030-5135 already hydrates EnvCell `StaticObjects` as `WorldEntity`
|
||||
instances with stable `entity.Id` in the `0x40xxxxxx` range:
|
||||
|
||||
```csharp
|
||||
uint interiorIdBase = 0x40000000u | (landblockId & 0x00FFFF00u);
|
||||
// ... for each EnvCell, for each stab in envCell.StaticObjects ...
|
||||
var hydrated = new WorldEntity {
|
||||
Id = interiorIdBase + localCounter++,
|
||||
SourceGfxObjOrSetupId = stab.Id, // 0x02000000 → Setup-based
|
||||
Position = stab.Frame.Origin + lbOffset,
|
||||
Rotation = stab.Frame.Orientation,
|
||||
MeshRefs = meshRefs,
|
||||
ParentCellId = envCellId,
|
||||
};
|
||||
```
|
||||
|
||||
These flow into `GpuWorldState.AddLandblock` as part of `landblock.Entities`,
|
||||
sit there with `ServerGuid == 0`, and currently never have their
|
||||
`Setup.DefaultScript` fired. The activator's existing
|
||||
`ServerGuid == 0 → return;` guard intentionally skips them (atlas-tier
|
||||
exemption inherited from `EntitySpawnAdapter`).
|
||||
|
||||
Three architectural consequences:
|
||||
|
||||
1. **No synthetic ID scheme needed.** `entity.Id` is already collision-free
|
||||
with server guids (live spawns use `0x500000xx`–`0x7Fxxxxxx`), anonymous
|
||||
emitter IDs (`0x80000000u+`), and the four entity-id ranges
|
||||
(`0x40xxxxxx` interior / `0x80xxxxxx` scenery / etc) all live in disjoint
|
||||
high-byte slices.
|
||||
2. **No new `EnvCellStaticActivator` class.** The existing
|
||||
`EntityScriptActivator` handles both server-spawned and dat-hydrated
|
||||
entities once the guard is keyed-by-id-when-zero.
|
||||
3. **No new walker.** `BuildInteriorEntitiesForStreaming` is the walker —
|
||||
it already happens. We just need the OnCreate fire-site in
|
||||
GpuWorldState's `AddLandblock` / `AddEntitiesToExistingLandblock`.
|
||||
|
||||
The handoff §4 wrote three options (α piggyback / β new class / γ extend
|
||||
activator) under the assumption that EnvCell statics were NOT WorldEntities.
|
||||
This reality discovery collapses all three to a simpler answer that none of
|
||||
them anticipated.
|
||||
|
||||
## §4 Architecture
|
||||
|
||||
### Slice A: part-transform pipeline
|
||||
|
||||
```
|
||||
EntityScriptActivator.OnCreate(entity)
|
||||
├─ key = entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id
|
||||
├─ info = resolver(entity) // ScriptActivationInfo? {ScriptId, PartTransforms}
|
||||
├─ if (info is null || info.ScriptId == 0) return
|
||||
├─ _particleSink.SetEntityRotation(key, entity.Rotation)
|
||||
├─ _particleSink.SetEntityPartTransforms(key, info.PartTransforms) // NEW
|
||||
└─ _scriptRunner.Play(info.ScriptId, key, entity.Position)
|
||||
|
||||
ParticleHookSink.SpawnFromHook(entityId, worldPos, ..., partIndex, ...)
|
||||
├─ rotation = _rotationByEntity[entityId] ?? Quaternion.Identity
|
||||
├─ partTransform = (partTransforms != null && partIndex >= 0 && partIndex < Count)
|
||||
│ ? partTransforms[partIndex] : Matrix4x4.Identity
|
||||
├─ partLocal = Vector3.Transform(offset, partTransform)
|
||||
├─ anchor = worldPos + Vector3.Transform(partLocal, rotation)
|
||||
└─ _system.SpawnEmitterById(...)
|
||||
```
|
||||
|
||||
### Slice B: dat-hydration fire-sites
|
||||
|
||||
```
|
||||
GpuWorldState.AddLandblock(landblock)
|
||||
├─ merge pending live entities (existing)
|
||||
├─ _loaded[id] = landblock (existing)
|
||||
├─ _wbSpawnAdapter?.OnLandblockLoaded(...) (existing)
|
||||
├─ foreach entity in landblock.Entities where ServerGuid == 0: // NEW
|
||||
│ _entityScriptActivator?.OnCreate(entity)
|
||||
└─ RebuildFlatView() (existing)
|
||||
|
||||
GpuWorldState.AddEntitiesToExistingLandblock(landblockId, entities)
|
||||
├─ canonicalize + merge (existing)
|
||||
├─ _wbSpawnAdapter?.OnLandblockLoaded(...) (existing)
|
||||
├─ foreach entity in entities: // NEW
|
||||
│ _entityScriptActivator?.OnCreate(entity) // all dat-hydrated
|
||||
└─ RebuildFlatView() (existing)
|
||||
|
||||
GpuWorldState.RemoveLandblock(landblockId)
|
||||
├─ _wbSpawnAdapter?.OnLandblockUnloaded(...) (existing)
|
||||
├─ rescue persistent (existing)
|
||||
├─ foreach entity in lb.Entities where ServerGuid == 0: // NEW
|
||||
│ _entityScriptActivator?.OnRemove(entity.Id)
|
||||
└─ remove from _loaded, RebuildFlatView (existing)
|
||||
|
||||
GpuWorldState.RemoveEntitiesFromLandblock(landblockId)
|
||||
├─ _wbSpawnAdapter?.OnLandblockUnloaded(...) (existing)
|
||||
├─ _onLandblockUnloaded?.Invoke(canonical) (existing — Tier 1 cache sweep)
|
||||
├─ foreach entity in lb.Entities where ServerGuid == 0: // NEW
|
||||
│ _entityScriptActivator?.OnRemove(entity.Id)
|
||||
└─ replace lb.Entities with empty list, RebuildFlatView (existing)
|
||||
```
|
||||
|
||||
The `ServerGuid == 0` filter avoids double-firing OnCreate on live entities
|
||||
that came via `AppendLiveEntity` and got pending-bucket-merged in
|
||||
`AddLandblock`. Their OnCreate already fired at AppendLiveEntity time.
|
||||
|
||||
### Resolver evolution
|
||||
|
||||
C.1.5a resolver:
|
||||
|
||||
```csharp
|
||||
Func<WorldEntity, uint> defaultScriptResolver // returns scriptId or 0
|
||||
```
|
||||
|
||||
C.1.5b resolver:
|
||||
|
||||
```csharp
|
||||
Func<WorldEntity, ScriptActivationInfo?> activationResolver // returns null on miss
|
||||
```
|
||||
|
||||
Where `ScriptActivationInfo` is a small record in `AcDream.App.Rendering.Vfx`:
|
||||
|
||||
```csharp
|
||||
public sealed record ScriptActivationInfo(
|
||||
uint ScriptId,
|
||||
IReadOnlyList<Matrix4x4> PartTransforms);
|
||||
```
|
||||
|
||||
Production lambda in `GameWindow.OnLoad` (replaces the C.1.5a one):
|
||||
|
||||
```csharp
|
||||
entity =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var setup = _dats.Get<Setup>(entity.SourceGfxObjOrSetupId);
|
||||
if (setup is null) return null;
|
||||
uint scriptId = setup.DefaultScript.DataId;
|
||||
if (scriptId == 0) return null;
|
||||
var parts = AcDream.Core.Meshing.SetupPartTransforms.Compute(setup);
|
||||
return new ScriptActivationInfo(scriptId, parts);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
One dat lookup → both pieces of info. The Setup is cached by DatCollection,
|
||||
so even hot-path scenery firing with no DefaultScript stays O(1).
|
||||
|
||||
### Helper: `SetupPartTransforms.Compute`
|
||||
|
||||
New static helper in `AcDream.Core.Meshing` (next to `SetupMesh`):
|
||||
|
||||
```csharp
|
||||
public static class SetupPartTransforms
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute the per-part static transforms for a Setup using its
|
||||
/// PlacementFrames. For each part i, the returned matrix is the
|
||||
/// transform from part-local to setup-local space at the Setup's
|
||||
/// resting pose. Mirrors SetupMesh.Flatten's pose-source priority:
|
||||
/// PlacementFrames[Resting] → [Default] → first available.
|
||||
/// Returns an empty list when the Setup has no PlacementFrames
|
||||
/// (caller falls back to "no part transforms applied").
|
||||
/// </summary>
|
||||
public static IReadOnlyList<Matrix4x4> Compute(Setup setup);
|
||||
}
|
||||
```
|
||||
|
||||
This deliberately mirrors the pose-source priority in
|
||||
`SetupMesh.Flatten` so a part's particle anchor matches its visible rest
|
||||
position. (If the renderer's pose source ever diverges from this resolver,
|
||||
particles will visibly drift — keep them in lockstep.)
|
||||
|
||||
For animated entities, the renderer's `AnimatedEntityState` computes
|
||||
per-frame part transforms; a future "animated DefaultScript" path would
|
||||
publish those each tick via the same `SetEntityPartTransforms` seam. Out
|
||||
of scope for C.1.5b.
|
||||
|
||||
## §5 Data + lifecycle invariants
|
||||
|
||||
| Concern | Behavior |
|
||||
|---|---|
|
||||
| Server-spawned entity spawn | `AppendLiveEntity` → `OnCreate` (existing). Keys by `ServerGuid`. |
|
||||
| Server-spawned entity despawn | `RemoveEntityByServerGuid` → `OnRemove(serverGuid)` (existing). |
|
||||
| Dat-hydrated entity load (initial) | `AddLandblock` → `OnCreate` for each `ServerGuid==0` entity. Keys by `entity.Id`. |
|
||||
| Dat-hydrated entity load (promotion) | `AddEntitiesToExistingLandblock` → `OnCreate` for each entity in the new batch. Keys by `entity.Id`. |
|
||||
| Dat-hydrated entity unload (full LB) | `RemoveLandblock` → `OnRemove(entity.Id)` for each `ServerGuid==0` entity. |
|
||||
| Dat-hydrated entity unload (Near→Far demotion) | `RemoveEntitiesFromLandblock` → `OnRemove(entity.Id)` for each `ServerGuid==0` entity. |
|
||||
| Pending live entity merged into AddLandblock | `OnCreate` already fired at `AppendLiveEntity`; filtered out by `ServerGuid != 0`. |
|
||||
| Persistent live entity rescued from RemoveLandblock | Not unloaded; its script continues. Filtered out by `ServerGuid != 0`. |
|
||||
| PartIndex out of bounds | Sink falls back to `Matrix4x4.Identity` for that part (no part transform applied, offset stays in entity-local frame as before). |
|
||||
| Setup with empty PlacementFrames | Resolver returns empty `PartTransforms` list; sink falls back to Identity for every part. Equivalent to pre-C.1.5b behavior. |
|
||||
| Resolver throws | Lambda's try/catch returns null; activator no-ops. |
|
||||
| Same script re-fired on dedupe | `PhysicsScriptRunner.Play` replaces prior instance (existing C.1 behavior). Visual: script restarts from t=0. Avoided here because we filter dat-hydrated entities by `ServerGuid==0` — they're not double-fired. |
|
||||
|
||||
### Idempotency
|
||||
|
||||
- Duplicate `OnCreate` for same key → script restarts (existing dedupe).
|
||||
- Duplicate `OnRemove` for same key → no-op.
|
||||
- `OnRemove` for never-spawned key → no-op.
|
||||
- LB unload immediately followed by LB load → entities get fresh `entity.Id`
|
||||
(localCounter resets per-call) but the keys are computed deterministically
|
||||
from landblockId + iteration order so a re-entered LB gets identical keys.
|
||||
Script restarts cleanly because OnRemove fired during the unload.
|
||||
|
||||
## §6 Testing
|
||||
|
||||
### Unit tests — new
|
||||
|
||||
1. **`SetupPartTransforms_ResolvesRestingPlacement_WhenAvailable`** —
|
||||
Setup with `PlacementFrames[Resting]` containing 2 parts; assert returned
|
||||
list has 2 matrices matching the resting frames.
|
||||
2. **`SetupPartTransforms_FallsBackToDefault_WhenRestingMissing`** —
|
||||
Setup with only `PlacementFrames[Default]`; assert it's used.
|
||||
3. **`SetupPartTransforms_ReturnsEmpty_WhenNoPlacementFrames`** —
|
||||
Setup with empty `PlacementFrames` dict; assert empty list.
|
||||
4. **`SetupPartTransforms_AppliesDefaultScale_WhenPresent`** —
|
||||
Setup with `DefaultScale[0] = (2, 2, 2)`; assert the matrix scales by 2.
|
||||
5. **`ParticleHookSink_AppliesPartTransform_WhenRegistered`** —
|
||||
register part transforms `[Identity, Translation(0,0,1)]`; fire a
|
||||
CreateParticleHook with `PartIndex=1, Offset=(1,0,0)`; assert spawned
|
||||
particle world position is `(1, 0, 1)`.
|
||||
6. **`ParticleHookSink_FallsBackToIdentity_WhenPartIndexOutOfBounds`** —
|
||||
register 2 part transforms; fire hook with `PartIndex=99`; assert
|
||||
spawned at root + offset (no buried-by-bad-matrix).
|
||||
7. **`EntityScriptActivator_KeysByEntityId_WhenServerGuidZero`** —
|
||||
dat-hydrated entity with `ServerGuid=0, Id=0x40A9B401`; fire OnCreate;
|
||||
assert script runner saw `entityId=0x40A9B401`.
|
||||
8. **`EntityScriptActivator_PassesPartTransformsToSink`** —
|
||||
resolver returns non-empty PartTransforms; assert sink's
|
||||
`SetEntityPartTransforms` was called with the matching list.
|
||||
9. **`EntityScriptActivator_OnRemove_StopsByGivenKey`** —
|
||||
call `OnRemove(0x40A9B401)`; assert runner + sink both got that key.
|
||||
|
||||
### Unit tests — updated
|
||||
|
||||
The 4 existing `EntityScriptActivatorTests` are updated for the new
|
||||
resolver signature (`_ => 0xAAu` → `_ => new ScriptActivationInfo(0xAAu,
|
||||
Array.Empty<Matrix4x4>())`). Test names and assertions stay the same.
|
||||
|
||||
### Integration tests — GpuWorldState wiring
|
||||
|
||||
10. **`GpuWorldState_AddLandblock_FiresActivatorForDatHydrated`** —
|
||||
construct GpuWorldState with a fake activator (recording mock); add
|
||||
a landblock with one `ServerGuid==0` entity; assert OnCreate fired
|
||||
exactly once.
|
||||
11. **`GpuWorldState_AddLandblock_DoesNotDoubleFire_OnPendingMerge`** —
|
||||
AppendLiveEntity with `ServerGuid=0xCAFE` (one OnCreate); then
|
||||
AddLandblock for the same canonical id; assert OnCreate fired only
|
||||
once total for the live entity.
|
||||
12. **`GpuWorldState_RemoveLandblock_FiresOnRemoveForDatHydrated`** —
|
||||
AddLandblock with a dat-hydrated entity, then RemoveLandblock; assert
|
||||
OnRemove fired with `entity.Id`.
|
||||
13. **`GpuWorldState_AddEntitiesToExistingLandblock_FiresActivator`** —
|
||||
promotion path; assert OnCreate fires for each promoted entity.
|
||||
14. **`GpuWorldState_RemoveEntitiesFromLandblock_FiresOnRemove`** —
|
||||
demotion path; assert OnRemove fires for each removed dat-hydrated
|
||||
entity.
|
||||
|
||||
Existing `GpuWorldStateTests` may need a minor update if any assert on
|
||||
constructor arity (the resolver doesn't change shape — same 4 ctor params).
|
||||
|
||||
### Visual verification (acceptance gate)
|
||||
|
||||
Procedure (per [CLAUDE.md](../../../CLAUDE.md) "Visual verification workflow"):
|
||||
|
||||
1. `dotnet build` green.
|
||||
2. `dotnet test` green.
|
||||
3. Launch live client with `ACDREAM_DUMP_PLAYSCRIPT=1`.
|
||||
4. **Site 1 — Holtburg Town network portal** (same site as C.1.5a):
|
||||
user walks `+Acdream` to the portal arch. Compare swirl vertical
|
||||
extent + lateral spread to retail. Pass: no ground-burial, distinct
|
||||
columns of emission visible across the arch.
|
||||
5. **Site 2 — Holtburg Inn fireplace** (interior, EnvCell static):
|
||||
user walks into the inn, stands near the fireplace. Pass: flame
|
||||
particles emit from the firebox at retail-matching height/density.
|
||||
6. **Site 3 — Cottage chimney** (exterior stab): user finds a Holtburg
|
||||
cottage with smoke in retail; same cottage in acdream should now
|
||||
show smoke. Pass: smoke column matches retail.
|
||||
7. **Site 4 — Spell cast** on `+Acdream`: user casts a spell, optionally
|
||||
in a safe spot. Pass: cast-anim particles match retail.
|
||||
|
||||
Diagnostic: `ACDREAM_DUMP_PLAYSCRIPT=1` prints every `[pes] Play:` line —
|
||||
if a site doesn't show particles, check the log to see whether the script
|
||||
fired and with what scriptId.
|
||||
|
||||
## §7 Risk + rollback
|
||||
|
||||
**Slice A risks:**
|
||||
|
||||
- `SetupPartTransforms.Compute` returns a list whose length doesn't match
|
||||
`setup.Parts.Count`. **Mitigation:** sink's per-index bounds check falls
|
||||
back to Identity; no buried particles, just reverts to C.1.5a behavior
|
||||
for the over-indexed hook.
|
||||
- Wrong pose source chosen (Resting vs Default). **Mitigation:** mirror
|
||||
`SetupMesh.Flatten`'s priority chain exactly so renderer + particle
|
||||
anchor stay in lockstep. If they ever diverge, particles drift visibly;
|
||||
user spot-checks at the portal.
|
||||
|
||||
**Slice B risks:**
|
||||
|
||||
- Firing OnCreate for EVERY dat-hydrated entity (scenery counts ~thousands
|
||||
per landblock at radius=4) becomes a perf hit. **Mitigation:** resolver
|
||||
is one cached `DatCollection.Get<Setup>` per entity — already amortized.
|
||||
Most entities have `DefaultScript.DataId == 0`, resolver returns null,
|
||||
OnCreate no-ops in ~1µs. Per-landblock-load cost: tens of µs, dwarfed
|
||||
by mesh upload + RebuildFlatView. Measured if `[pes] Play:` line
|
||||
spam appears in launch.log.
|
||||
- Filter `ServerGuid==0` is too aggressive — misses some valid case.
|
||||
**Mitigation:** every entity with `ServerGuid != 0` came through
|
||||
`AppendLiveEntity` (verified by `RelocateEntity`'s
|
||||
`if (entity.ServerGuid == 0) return;` guard at GpuWorldState.cs:204),
|
||||
so they already had OnCreate fired. No miss.
|
||||
- Idempotency edge case: rapid LB load/unload cycles produce repeated
|
||||
Play → Stop → Play. **Mitigation:** existing PhysicsScriptRunner
|
||||
dedupe handles re-Play; this is the same as a server retriggering a
|
||||
PlayScript opcode.
|
||||
|
||||
**Rollback path:** revert the spec's commits; the C.1.5a `EntityScriptActivator`
|
||||
keeps working for live entities exactly as before. No data migrations.
|
||||
|
||||
## §8 Doc-drift fixes from C.1.5a (folded in)
|
||||
|
||||
The handoff §9 surfaced three trivial doc-drift items from C.1.5a. Folded
|
||||
here for the record:
|
||||
|
||||
1. C.1.5a spec §4 ("fifth optional parameter") was wrong — the activator
|
||||
is actually GpuWorldState's **fourth** optional parameter (verified at
|
||||
[GpuWorldState.cs:63](../../../src/AcDream.App/Streaming/GpuWorldState.cs):
|
||||
`wbSpawnAdapter, wbEntitySpawnAdapter, onLandblockUnloaded,
|
||||
entityScriptActivator`).
|
||||
2. C.1.5a spec §4 ("~50 lines") was an estimate; the file shipped at
|
||||
**93 lines** including doc comments. Slice A adds the part-transform
|
||||
call + slice B drops the `ServerGuid == 0` guard, so the file will
|
||||
land at ~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.
|
||||
|
|
@ -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<DatReaderWriter.DBObjs.Setup>(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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// What the activator's resolver returns when an entity's Setup carries
|
||||
/// a <c>DefaultScript</c>. Bundles the script id with the per-part
|
||||
/// transforms baked from <c>Setup.PlacementFrames</c> so a single dat
|
||||
/// lookup yields both pieces of state. The activator pushes the part
|
||||
/// transforms into <see cref="ParticleHookSink.SetEntityPartTransforms"/>
|
||||
/// before calling <see cref="PhysicsScriptRunner.Play"/>, which closes
|
||||
/// the part-anchor pipeline introduced for issue #56.
|
||||
/// </summary>
|
||||
public sealed record ScriptActivationInfo(
|
||||
uint ScriptId,
|
||||
IReadOnlyList<Matrix4x4> PartTransforms);
|
||||
|
||||
/// <summary>
|
||||
/// Fires <c>Setup.DefaultScript</c> through <see cref="PhysicsScriptRunner"/>
|
||||
/// when a server-spawned <see cref="WorldEntity"/> enters the world, so static
|
||||
/// objects (portals, chimneys, fireplaces, building details) emit their
|
||||
/// retail-faithful persistent particle effects automatically. Stops the
|
||||
/// scripts and live emitters when the entity despawns.
|
||||
/// when a <see cref="WorldEntity"/> 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.
|
||||
///
|
||||
/// <para>
|
||||
/// Handles both server-spawned entities (<c>ServerGuid != 0</c>, keyed by
|
||||
/// ServerGuid) and dat-hydrated entities (<c>ServerGuid == 0</c>, keyed by
|
||||
/// <c>entity.Id</c>). The C.1.5a guard that early-returned for
|
||||
/// <c>ServerGuid == 0</c> 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Wires alongside <c>EntitySpawnAdapter</c> in <c>GpuWorldState</c>: the
|
||||
/// adapter handles meshes + animation state, the activator handles scripts +
|
||||
/// particles. Both are render-thread-only.
|
||||
/// 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).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail oracle: <c>play_script_internal(setup.DefaultScript)</c> is what
|
||||
/// retail's <c>CPhysicsObj</c> invokes at object load (see Phase C.1 plan §C.1
|
||||
/// and <c>memory/project_sky_pes_port.md</c>). C.1 already shipped the runner;
|
||||
/// this class adds the missing fire-on-spawn call site.
|
||||
/// retail's <c>CPhysicsObj</c> invokes at object load (see Phase C.1 plan
|
||||
/// §C.1 and <c>memory/project_sky_pes_port.md</c>). C.1 already shipped the
|
||||
/// runner; this class adds the missing fire-on-spawn call site.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class EntityScriptActivator
|
||||
{
|
||||
private readonly PhysicsScriptRunner _scriptRunner;
|
||||
private readonly ParticleHookSink _particleSink;
|
||||
private readonly Func<WorldEntity, uint> _defaultScriptResolver;
|
||||
private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;
|
||||
|
||||
/// <param name="scriptRunner">Already-shipped runner from C.1. Owns the
|
||||
/// (scriptId, entityId) instance table and schedules hooks at their
|
||||
/// <c>StartTime</c> offsets.</param>
|
||||
/// <param name="particleSink">Already-shipped hook sink from C.1. The
|
||||
/// activator only calls its <see cref="ParticleHookSink.StopAllForEntity"/>
|
||||
/// to drop any per-entity emitter handles on despawn.</param>
|
||||
/// <param name="defaultScriptResolver">Returns
|
||||
/// <c>entity.SourceGfxObjOrSetupId</c>'s <c>Setup.DefaultScript.DataId</c>,
|
||||
/// or <c>0</c> on miss / dat throw / missing field. Production lambda hits
|
||||
/// <see cref="DatReaderWriter.DatCollection"/>; tests pass a hand-rolled
|
||||
/// stub.</param>
|
||||
/// activator pushes per-entity rotation + part transforms here, and
|
||||
/// calls <see cref="ParticleHookSink.StopAllForEntity"/> to drop
|
||||
/// per-entity emitter handles on despawn.</param>
|
||||
/// <param name="resolver">Returns
|
||||
/// <see cref="ScriptActivationInfo"/> with the entity's
|
||||
/// <c>Setup.DefaultScript.DataId</c> and per-part transforms (via
|
||||
/// <c>SetupPartTransforms.Compute</c>), or <c>null</c> on dat miss /
|
||||
/// throw / missing DefaultScript. Production lambda hits
|
||||
/// <c>DatCollection</c>; tests pass a hand-rolled stub.</param>
|
||||
public EntityScriptActivator(
|
||||
PhysicsScriptRunner scriptRunner,
|
||||
ParticleHookSink particleSink,
|
||||
Func<WorldEntity, uint> defaultScriptResolver)
|
||||
Func<WorldEntity, ScriptActivationInfo?> resolver)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scriptRunner);
|
||||
ArgumentNullException.ThrowIfNull(particleSink);
|
||||
ArgumentNullException.ThrowIfNull(defaultScriptResolver);
|
||||
ArgumentNullException.ThrowIfNull(resolver);
|
||||
_scriptRunner = scriptRunner;
|
||||
_particleSink = particleSink;
|
||||
_defaultScriptResolver = defaultScriptResolver;
|
||||
_resolver = resolver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the entity's <c>Setup.DefaultScript</c> and fire it through
|
||||
/// the script runner. No-op if the entity has no DefaultScript
|
||||
/// (resolver returns 0) or if the entity has no server guid
|
||||
/// (atlas-tier entities are out of scope for this activator).
|
||||
/// the script runner. Keys by <c>entity.ServerGuid</c> when non-zero,
|
||||
/// otherwise by <c>entity.Id</c> (the latter handles dat-hydrated
|
||||
/// EnvCell statics + exterior stabs whose <c>entity.Id</c> lives in
|
||||
/// the <c>0x40xxxxxx</c> range — collision-free with server guids).
|
||||
/// No-op if the entity has no DefaultScript (resolver returns null
|
||||
/// or zero-script).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop every script instance the runner is tracking for this entity, and
|
||||
/// kill every live emitter the sink has attributed to it. Idempotent for
|
||||
/// unknown guids (both calls no-op).
|
||||
/// 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
|
||||
/// <c>entity.Id</c> for dat-hydrated entities — mirror whatever was
|
||||
/// used at <see cref="OnCreate"/>). Idempotent for unknown keys.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<WorldEntity>());
|
||||
_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();
|
||||
}
|
||||
|
||||
|
|
|
|||
76
src/AcDream.Core/Meshing/SetupPartTransforms.cs
Normal file
76
src/AcDream.Core/Meshing/SetupPartTransforms.cs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Meshing;
|
||||
|
||||
/// <summary>
|
||||
/// Compute the per-part static transforms for a Setup using its
|
||||
/// PlacementFrames. For each part <c>i</c>, the returned matrix takes a
|
||||
/// point in part-local space and yields a point in setup-local space at
|
||||
/// the Setup's resting pose.
|
||||
///
|
||||
/// <para>
|
||||
/// Mirrors <see cref="SetupMesh.Flatten"/>'s pose-source priority —
|
||||
/// <c>PlacementFrames[Resting]</c> → <c>[Default]</c> → first available
|
||||
/// — so that a particle anchor at part <c>i</c> 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Returns an empty list when the Setup has no PlacementFrames. The
|
||||
/// caller (e.g. <c>ParticleHookSink.SpawnFromHook</c>) should then fall
|
||||
/// back to <see cref="Matrix4x4.Identity"/> per part, which is the
|
||||
/// pre-C.1.5b behavior.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// For animated entities, per-part transforms vary per animation frame
|
||||
/// and live in <c>AnimatedEntityState</c>; a future "animated
|
||||
/// DefaultScript" path would publish those each tick via the same
|
||||
/// <c>SetEntityPartTransforms</c> seam. Out of scope here.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class SetupPartTransforms
|
||||
{
|
||||
public static IReadOnlyList<Matrix4x4> Compute(Setup setup)
|
||||
{
|
||||
AnimationFrame? source = null;
|
||||
if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting))
|
||||
{
|
||||
source = resting;
|
||||
}
|
||||
else if (setup.PlacementFrames.TryGetValue(Placement.Default, out var def))
|
||||
{
|
||||
source = def;
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var kvp in setup.PlacementFrames)
|
||||
{
|
||||
source = kvp.Value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (source is null) return System.Array.Empty<Matrix4x4>();
|
||||
|
||||
int partCount = setup.Parts.Count;
|
||||
var result = new Matrix4x4[partCount];
|
||||
for (int i = 0; i < partCount; i++)
|
||||
{
|
||||
Frame frame = i < source.Frames.Count
|
||||
? source.Frames[i]
|
||||
: new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
|
||||
Vector3 scale = i < setup.DefaultScale.Count ? setup.DefaultScale[i] : Vector3.One;
|
||||
result[i] = Matrix4x4.CreateScale(scale)
|
||||
* Matrix4x4.CreateFromQuaternion(frame.Orientation)
|
||||
* Matrix4x4.CreateTranslation(frame.Origin);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -72,6 +72,14 @@ public sealed class ParticleHookSink : IAnimationHookSink
|
|||
private readonly ConcurrentDictionary<int, (uint EntityId, uint KeyId)> _trackingByHandle = new();
|
||||
private readonly ConcurrentDictionary<uint, ParticleRenderPass> _renderPassByEntity = new();
|
||||
private readonly ConcurrentDictionary<uint, Quaternion> _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<uint, IReadOnlyList<Matrix4x4>> _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;
|
||||
|
||||
/// <summary>
|
||||
/// Register per-part static transforms for an entity. The caller
|
||||
/// (typically <c>EntityScriptActivator</c>) precomputes one
|
||||
/// <see cref="Matrix4x4"/> per Setup part using
|
||||
/// <c>SetupPartTransforms.Compute</c> and pushes them here at spawn
|
||||
/// time. <see cref="SpawnFromHook"/> applies
|
||||
/// <c>partTransforms[hook.PartIndex]</c> to the hook offset BEFORE
|
||||
/// transforming to world space. Cleared on
|
||||
/// <see cref="StopAllForEntity"/>.
|
||||
/// </summary>
|
||||
public void SetEntityPartTransforms(uint entityId, IReadOnlyList<Matrix4x4> 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;
|
||||
|
|
|
|||
118
tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs
Normal file
118
tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -59,12 +59,21 @@ public sealed class EntityScriptActivatorTests
|
|||
return new Pipeline(system, hookSink, runner, recording);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static System.Func<WorldEntity, ScriptActivationInfo?> StaticResolver(uint scriptId)
|
||||
=> _ => new ScriptActivationInfo(scriptId, System.Array.Empty<Matrix4x4>());
|
||||
|
||||
[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<MeshRef>(),
|
||||
};
|
||||
|
||||
activator.OnCreate(entity);
|
||||
|
||||
Assert.Equal(1, p.Runner.ActiveScriptCount);
|
||||
p.Runner.Tick(0.001f);
|
||||
Assert.Single(p.Recording.Calls);
|
||||
Assert.Equal(0x40A9B401u, p.Recording.Calls[0].EntityId);
|
||||
Assert.Equal(new Vector3(5, 5, 5), p.Recording.Calls[0].Pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_PassesPartTransformsToSink()
|
||||
{
|
||||
// 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<uint, DatPhysicsScript> { [0xAAu] = script };
|
||||
var runner = new PhysicsScriptRunner(
|
||||
id => table.TryGetValue(id, out var s) ? s : null,
|
||||
hookSink);
|
||||
|
||||
var partTransforms = new Matrix4x4[]
|
||||
{
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.CreateTranslation(0f, 0f, 1f),
|
||||
};
|
||||
|
||||
var activator = new EntityScriptActivator(runner, hookSink,
|
||||
_ => new ScriptActivationInfo(0xAAu, partTransforms));
|
||||
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
|
||||
|
||||
activator.OnCreate(entity);
|
||||
runner.Tick(0.001f);
|
||||
system.Tick(0.001f);
|
||||
|
||||
var live = system.EnumerateLive().FirstOrDefault();
|
||||
Assert.NotNull(live.Emitter);
|
||||
var pos = live.Emitter.Particles[live.Index].Position;
|
||||
Assert.InRange(pos.X, 0.99f, 1.01f);
|
||||
Assert.InRange(pos.Y, -0.01f, 0.01f);
|
||||
Assert.InRange(pos.Z, 0.99f, 1.01f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnRemove_StopsByGivenKey_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<uint, DatPhysicsScript> { [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<MeshRef>(),
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Phase C.1.5b: verifies <see cref="GpuWorldState"/> fires
|
||||
/// <see cref="EntityScriptActivator.OnCreate"/> /
|
||||
/// <see cref="EntityScriptActivator.OnRemove"/> 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 <see cref="GpuWorldState.AppendLiveEntity"/>.
|
||||
/// </summary>
|
||||
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<uint, DatPhysicsScript> { [scriptId] = script };
|
||||
|
||||
var registry = new EmitterDescRegistry();
|
||||
var system = new ParticleSystem(registry);
|
||||
var sink = new ParticleHookSink(system);
|
||||
var recording = new RecordingSink();
|
||||
var runner = new PhysicsScriptRunner(id => table.TryGetValue(id, out var s) ? s : null, recording);
|
||||
var activator = new EntityScriptActivator(runner, sink,
|
||||
_ => new ScriptActivationInfo(scriptId, Array.Empty<Matrix4x4>()));
|
||||
|
||||
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<MeshRef>(),
|
||||
};
|
||||
|
||||
private static WorldEntity Live(uint serverGuid, Vector3 pos) => new()
|
||||
{
|
||||
Id = serverGuid,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = pos,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue