Two-slice phase: - Slice A: ParticleHookSink applies CreateParticleHook.PartIndex via SetupPartTransforms.Compute(setup.PlacementFrames). Closes #56. - Slice B: drop EntityScriptActivator's ServerGuid==0 guard so dat-hydrated EnvCell statics + exterior stabs fire DefaultScript. Key reality discovery folded into the spec §3: EnvCell.StaticObjects are already WorldEntities (via GameWindow.BuildInteriorEntitiesForStreaming), so no synthetic-ID scheme + no new walker class needed — the handoff's §4 Q1/Q2 options are mooted by entity.Id being collision-free. Doc-drift fixes from C.1.5a folded into §8. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
24 KiB
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 §C.1.5.
Handoff doc: docs/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. 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:
- 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.
- Holtburg Inn fireplace (interior, EnvCell static): flame particles match retail's pattern and position over the firebox.
- Cottage chimney (exterior stab — TBD which cottage): smoke particles match retail.
- Animation-hook spell cast on
+Acdream: cast-anim particle effect matches retail.
§2 Scope
In:
- New helper
AcDream.Core.Meshing.SetupPartTransforms.Compute(Setup)that walksPlacementFrames[Resting]→ fallback[Default]→ first available and returnsIReadOnlyList<Matrix4x4>(one transform per part). ParticleHookSink.SetEntityPartTransforms(uint entityId, IReadOnlyList<Matrix4x4> partTransforms)- a backing
_partTransformsByEntitymap cleared byStopAllForEntity.
- a backing
ParticleHookSink.SpawnFromHookappliespartTransforms[partIndex]to the hook offset before rotating to world space.EntityScriptActivatorresolver signature changes fromFunc<WorldEntity, uint>toFunc<WorldEntity, ScriptActivationInfo?>so bothScriptIdandPartTransformscome from one dat lookup.EntityScriptActivator.OnCreatekeys byentity.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.GpuWorldStatewires the activator into four more places:AddLandblock(dat-hydrated entities only — filter byServerGuid==0to avoid double-firing pending live entities),AddEntitiesToExistingLandblock(the just-promoted entities — all dat-hydrated by construction),RemoveLandblock(dat-hydrated entities only), andRemoveEntitiesFromLandblock(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 newSetEntityPartTransformsis keyed by entity, so an animated-entity path can later push fresh transforms each tick without changing the contract. - Renderer changes.
particle.fragstays 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
at lines 176-217:
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 at
lines 36-50 already uses). Per part i:
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
at lines 5030-5135 already hydrates EnvCell StaticObjects as WorldEntity
instances with stable entity.Id in the 0x40xxxxxx range:
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:
- No synthetic ID scheme needed.
entity.Idis already collision-free with server guids (live spawns use0x500000xx–0x7Fxxxxxx), anonymous emitter IDs (0x80000000u+), and the four entity-id ranges (0x40xxxxxxinterior /0x80xxxxxxscenery / etc) all live in disjoint high-byte slices. - No new
EnvCellStaticActivatorclass. The existingEntityScriptActivatorhandles both server-spawned and dat-hydrated entities once the guard is keyed-by-id-when-zero. - No new walker.
BuildInteriorEntitiesForStreamingis the walker — it already happens. We just need the OnCreate fire-site in GpuWorldState'sAddLandblock/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:
Func<WorldEntity, uint> defaultScriptResolver // returns scriptId or 0
C.1.5b resolver:
Func<WorldEntity, ScriptActivationInfo?> activationResolver // returns null on miss
Where ScriptActivationInfo is a small record in AcDream.App.Rendering.Vfx:
public sealed record ScriptActivationInfo(
uint ScriptId,
IReadOnlyList<Matrix4x4> PartTransforms);
Production lambda in GameWindow.OnLoad (replaces the C.1.5a one):
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):
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
OnCreatefor same key → script restarts (existing dedupe). - Duplicate
OnRemovefor same key → no-op. OnRemovefor 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
SetupPartTransforms_ResolvesRestingPlacement_WhenAvailable— Setup withPlacementFrames[Resting]containing 2 parts; assert returned list has 2 matrices matching the resting frames.SetupPartTransforms_FallsBackToDefault_WhenRestingMissing— Setup with onlyPlacementFrames[Default]; assert it's used.SetupPartTransforms_ReturnsEmpty_WhenNoPlacementFrames— Setup with emptyPlacementFramesdict; assert empty list.SetupPartTransforms_AppliesDefaultScale_WhenPresent— Setup withDefaultScale[0] = (2, 2, 2); assert the matrix scales by 2.ParticleHookSink_AppliesPartTransform_WhenRegistered— register part transforms[Identity, Translation(0,0,1)]; fire a CreateParticleHook withPartIndex=1, Offset=(1,0,0); assert spawned particle world position is(1, 0, 1).ParticleHookSink_FallsBackToIdentity_WhenPartIndexOutOfBounds— register 2 part transforms; fire hook withPartIndex=99; assert spawned at root + offset (no buried-by-bad-matrix).EntityScriptActivator_KeysByEntityId_WhenServerGuidZero— dat-hydrated entity withServerGuid=0, Id=0x40A9B401; fire OnCreate; assert script runner sawentityId=0x40A9B401.EntityScriptActivator_PassesPartTransformsToSink— resolver returns non-empty PartTransforms; assert sink'sSetEntityPartTransformswas called with the matching list.EntityScriptActivator_OnRemove_StopsByGivenKey— callOnRemove(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
GpuWorldState_AddLandblock_FiresActivatorForDatHydrated— construct GpuWorldState with a fake activator (recording mock); add a landblock with oneServerGuid==0entity; assert OnCreate fired exactly once.GpuWorldState_AddLandblock_DoesNotDoubleFire_OnPendingMerge— AppendLiveEntity withServerGuid=0xCAFE(one OnCreate); then AddLandblock for the same canonical id; assert OnCreate fired only once total for the live entity.GpuWorldState_RemoveLandblock_FiresOnRemoveForDatHydrated— AddLandblock with a dat-hydrated entity, then RemoveLandblock; assert OnRemove fired withentity.Id.GpuWorldState_AddEntitiesToExistingLandblock_FiresActivator— promotion path; assert OnCreate fires for each promoted entity.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 "Visual verification workflow"):
dotnet buildgreen.dotnet testgreen.- Launch live client with
ACDREAM_DUMP_PLAYSCRIPT=1. - Site 1 — Holtburg Town network portal (same site as C.1.5a):
user walks
+Acdreamto the portal arch. Compare swirl vertical extent + lateral spread to retail. Pass: no ground-burial, distinct columns of emission visible across the arch. - 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.
- 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.
- 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.Computereturns a list whose length doesn't matchsetup.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 haveDefaultScript.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==0is too aggressive — misses some valid case. Mitigation: every entity withServerGuid != 0came throughAppendLiveEntity(verified byRelocateEntity'sif (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:
- C.1.5a spec §4 ("fifth optional parameter") was wrong — the activator
is actually GpuWorldState's fourth optional parameter (verified at
GpuWorldState.cs:63:
wbSpawnAdapter, wbEntitySpawnAdapter, onLandblockUnloaded, entityScriptActivator). - 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 == 0guard, so the file will land at ~100–110 lines after this phase. GpuWorldState.AddEntitiesToExistingLandblockwill 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 WITHDefaultScriptset 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), newSetupPartTransforms.cs(~50 lines), updatedEntityScriptActivatorTests.cs(4 ctor-signature updates + new tests), newSetupPartTransformsTests.cs(~80 lines, 4 tests), newParticleHookSinkTests.csadditions or new file (~60 lines, 2 tests), newGpuWorldStateActivatorTests.cs(~120 lines, 5 integration tests). - Estimated effort: ~1 day.
- Commit cadence: four commits land this phase cleanly —
(1)
SetupPartTransformshelper + tests, (2)ParticleHookSinkpart-transform support + tests, (3)EntityScriptActivatorresolver refactor + ServerGuid guard relaxation + tests, (4)GpuWorldStatefire-site wiring + tests + production lambda update + the C.1.5a doc-drift comment forAddEntitiesToExistingLandblock. Each commitdotnet testgreen. 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; move #56 to "Recently closed" indocs/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:
- 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.