diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index b14fb709..560a6e3a 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -291,9 +291,30 @@ serial FIFO per owner: duplicate plays append, owners progress independently,
not advance; a new play for an already-created cell-less object is dropped as in
retail. Attached children advance through their eligible parent. Every effect
resource uses the canonical, globally unique `WorldEntity.Id`; static
-allocators fail before their namespace can wrap. Live anchors are refreshed from the current
-`WorldEntity` before script dispatch; Step 5 extends that root pose to exact
-animated part transforms for emitters and dynamic lights.
+allocators fail before their namespace can wrap. `EntityEffectPoseRegistry`
+publishes the final root and indexed rigid animated-part transforms after animation
+and equipped-child composition. Animation hooks are captured during sequence
+advance and drained only after those poses are current; PhysicsScripts then run,
+attached emitters and object lights refresh, and particle simulation advances.
+World-released particles retain their birth positions and parent-local particles
+follow their current owner while in-world. Pending spatial projections skip
+particle updates and drawing exactly like retail's cell-less object gate without
+ending emitter state or identity; absolute creation timestamps remain unchanged,
+so elapsed particles/durations expire on re-entry without a backlog burst. Their
+anchors may still accept authoritative pose correction before that update.
+Missing emitter DAT records fail diagnostically
+without a synthesized effect. `LiveEntityLightController` keeps live light
+projection/re-entry outside `GameWindow`; `LightingHookSink` remains the Core
+hook and per-frame pose consumer. It also owns retail's Lighting-bit/
+`SetLightHook` latch so spatial withdrawal preserves logical state while a
+true state transition creates or destroys the Setup lights. Stable indexed
+poses exclude Setup visual scale and remain separate from drawable `MeshRefs`.
+Light registration follows final runtime visibility edges; equipped-child
+updates are parent-before-child with retained per-child buffers and cascade
+withdrawal when an ancestor pose disappears. A later pose publication drains
+the waiting attachment graph transitively, so A→B→C recovers B before C in the
+same parent-first pass. Recovery is edge-triggered by object/appearance/pose
+publication; permanent missing DAT or holding parts are never polled per frame.
The remaining aggregation is primarily `_playerController`'s player-specific
movement plus the separate `WorldEntity`/animation/physics component types.
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index cc17126c..b0bbebd2 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -171,7 +171,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-64 | **Wield-reject rollback assumes `InventoryServerSaveFailed 0x00A0`** — an optimistic wield rolls back only if ACE emits `0x00A0` for a `GetAndWieldItem` rejection; otherwise corrected by the next authoritative message. Gate-verify via WireMCP. | `src/AcDream.Core.Net/GameEventWiring.cs` (0x00A0 handler) | If ACE uses a different reject opcode for wield, the optimistic state is left dangling until the next full update. | Rejected wield briefly shows the item as equipped in the doll — cosmetic flicker or stuck state if `0x00A0` is not the rejection path. | `InventoryServerSaveFailed 0x00A0`; WireMCP gate-verify |
| AP-65 | **PickupEvent (0xF74A) no longer evicts the weenie from `ClientObjectTable`** — only `DeleteObject (0xF747)` evicts, matching the retail `object_table`-vs-`weenie_object_table` split. An item another player picks up near you (only ever gets `PickupEvent`, never `DeleteObject`) lingers as a data-only entry (`ContainerId 0`, not in any view) until teleport/relog `Clear()`. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (EntityDeleted handler); `src/AcDream.Core.Net/WorldSession.cs` (PickupEvent branch) | The weenie entry for nearby pickups is a harmless data ghost — no UI shows it (no container, not wielded). Retail evicts it from `weenie_object_table` when the object fully leaves interest range via `DeleteObject`; acdream defers to teleport/relog clear. | Slight memory growth in long sessions if many world items are picked up by other players near you; item data ghosts cannot cause functional issues because no UI queries `ContainerId 0`. | `CACObjectMaint::DeleteObject` / `SmartBox::HandleDeleteObject`; ACE `Player_Inventory.cs TryDequipObjectWithNetworking` |
| ~~AP-66~~ | **RETIRED 2026-07-13 — authored paperdoll empty-slot presentation.** The earlier “no silhouettes” conclusion inspected the ItemList elements' own media but missed `UIElement_ItemList::InternalCreateItem`, which clones a distinct `UIElement_UIItem` catalog prototype for each location. All 21 supported jewelry, weapon, ammo, shield, clothing, cloak, trinket, and armor lists now resolve their exact `ItemSlot_Empty` surface from live DAT; `PostInit` confirms non-armor lists remain visible while the nine armor lists toggle with Slots. | `src/AcDream.App/UI/Layout/PaperdollSlotBackgrounds.cs`; `ItemListCellTemplate.cs`; `PaperdollController.cs` | — | — | `gmPaperDollUI::GetLocationInfoFromElementID @ 0x004A37F0`; `PostInit @ 0x004A5360`; `UIElement_ItemList::InternalCreateItem @ 0x004E3570`; `LayoutDesc 0x21000037` |
-| AP-67 | Server-spawned weenie fixtures (lanterns, braziers, glowing items carrying `Setup.Lights`) register their lights at their SPAWN-frame world position via `RegisterOwnedLight` in `OnLiveEntitySpawnedLocked` (mirroring the dat-static path in `ApplyLoadedTerrainLocked`); the light does NOT follow the object if it later moves. Cell-scoped light presentation is removed by `LeaveWorldLiveEntityRuntimeComponents` and restored on world re-entry; logical teardown uses the same leave-world tail. | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLiveEntitySpawnedLocked` weenie-light block; `LeaveWorldLiveEntityRuntimeComponents`; `TearDownLiveEntityRuntimeComponents`) | Closes the prior gap where ONLY dat-baked statics registered lights, so server-placed lanterns cast nothing; the overwhelming majority of light-bearing weenies (wall lanterns, braziers, candelabra) are stationary, so spawn-frame placement matches retail; retail's `add_dynamic_light` re-places the light from the object frame each frame | A moving light-bearing weenie (e.g. a torch-carrying NPC) leaves its light at the spawn position instead of following — rare; the faithful fix is to re-place on `OnLivePositionUpdated` | retail object-borne lights `insert_light` 0x0054d1b0 / `add_dynamic_light` 0x0054d420 (per-frame object frame) |
| AP-68 | acdream keeps the 128 nearest-to-CAMERA point lights live (`MaxGlobalLights=128`, `BuildPointLightSnapshot`) and selects per cell CAMERA-INDEPENDENTLY (by the cell's own bounds), so a building interior stays lit at any distance within a town; retail keeps only the 40 nearest-to-PLAYER static lights (`Render::max_static_lights=0x28`, distance-sorted replace-farthest `insert_light`) and re-bakes a cell when its live light set changes, so distant interiors are baked dark and "light up" only as the player approaches and their torches enter the live 40. INTENTIONAL — acdream's always-lit interiors are the preferred behavior (no 1999-era light-budget pop-in); user-confirmed 2026-06-20. | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights=128`, `BuildPointLightSnapshot`, `SelectForObject`) | The retail pop-in is a fixed-function light-budget artifact, not an intended aesthetic; revert to retail by clamping the global set to 40 + distance-to-player sort if ever desired | Distant town interiors are lit in acdream where retail's are dark until approached — a deliberate, user-preferred divergence | `Render::max_static_lights` 0x28; `insert_light` 0x0054d1b0 (distance-sorted, replace-farthest); bake re-trigger `SetStaticLightingVertexColors` cache `burnedInStaticLights != num_static_lights` |
| AP-69 | On a landblock unload/reload, acdream retains the accepted live record and the same `WorldEntity`. Full unload moves non-persistent live projections to that landblock's pending bucket; Near→Far demotion drops only dat-static layers and leaves live projections resident. Neither path replays logical registration or reconstructs from a stale CreateObject. ACE will NOT re-broadcast objects whose guid remains in its per-player `KnownObjects` set, matching retail's retained object-table model. DIVERGENCE: acdream has NO retail 25 s / 384 m visibility cull — the retained record is pruned ONLY by an explicit server `DeleteObject` (0xF747) or session teardown. (#138) | `src/AcDream.App/World/LiveEntityRuntime.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs`; `GameWindow.RehydrateServerEntitiesForLandblock`; `StreamingController` (`onLandblockLoaded`, Loaded + Promoted) | Stable logical identity is retail-faithful and prevents duplicated meshes, scripts, emitters, or stale-spawn teleports while remaining independent of an ACE re-broadcast | An object the server silently stopped tracking WITHOUT a `DeleteObject` (e.g. a creature that wandered out of PVS) can reappear at its last-known position, where retail's 25 s cull would have dropped it — a stale ghost until a real `DeleteObject` or the next session. Close it by porting holtburger's 25 s/384 m `ACE_DESTRUCTION_TIMEOUT` self-cull | `CPhysicsObj::change_cell` 0x00513390; `CPhysicsObj::leave_world` 0x005155A0; ACE `ObjectMaint.KnownObjects` never cleared on teleport (`Physics/Common/ObjectMaint.cs`, `WorldObjects/Player_Tracking.cs`); holtburger client 25 s cull `liveness.rs` `ACE_DESTRUCTION_TIMEOUT_SECS` |
| AP-70 | `TeleportAnimSequencer.ComputeFadeAlpha` uses a smoothstep curve for all fade states; retail's `gmSmartBoxUI::UseTime` drives fade alpha through a 1024-entry `GetAnimLevel` lookup table whose contents are unrecovered | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`ComputeFadeAlpha`) | `GetAnimLevel` table address and contents not yet extracted via cdb; smoothstep is a perceptually-reasonable S-curve that closely approximates a typical gamma-corrected fade; the visual difference is imperceptible under normal teleport conditions | Fade timing differs subtly from retail — ramp-in or ramp-out may be slightly too fast/slow at the black-fade edges; retire by reading the `GetAnimLevel` table via cdb and replacing smoothstep with a direct 1024-entry lookup (spec §8) | `gmSmartBoxUI::UseTime` 0x004d6e30; `GetAnimLevel` 1024-entry table (address unrecovered — spec §8 cdb trace) |
@@ -228,9 +227,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 |
| TS-8 | `MagicUpdateEnchantment` (0x02C2) records carry no StatMod — mid-session buffs don't move vital max until relog (**#7/#12**) | `src/AcDream.Core/Spells/Spellbook.cs:150` | The wire parser hasn't been extended to the full ~60-64 byte Enchantment payload; PlayerDescription's block IS parsed | Vitals HUD percent reads differently from retail for the whole session after any buff cast | `EnchantAttribute` 0x00594570; holtburger magic/types.rs |
| TS-9 | MP3 (0x55) and MS-ADPCM (0x02) waves undecoded — affected sounds skipped; retail decoded both via winmm ACM | `src/AcDream.Core/Audio/WaveDecoder.cs:33` | Managed decoder (NAudio or similar) deferred; PCM covers the vast majority of ~3500 waves | Any MP3 (common for music-ish clips) or ADPCM cue plays as silence where retail plays it | winmm ACM path (r05 §2.1) |
-| TS-10 | Setup lights anchored at entity root — per-light Frames not transformed through the animated part chain | `src/AcDream.Core/Lighting/LightInfoLoader.cs:31` | Per-part world transforms aren't exposed to the lighting layer; awaiting animation hook integration | A carried torch glows from the character origin, not the hand, and doesn't track swing/idle animations | LightInfo.ViewSpaceLocation per-part Frame (r13 §1) |
-| TS-11 | Retail's complete inherited `CreateBlockingParticleHook` payload is decoded by the narrow `DatCollection`-backed compatibility reader, but `ParticleHookSink` deliberately no-ops it until the live emitter-binding owner can apply same-live-nonzero-logical-ID suppression | `src/AcDream.Core/Vfx/ParticleHookSink.cs` (`RetailCreateBlockingParticleHook` case); decoder `src/AcDream.Content/Vfx/RetailAnimationHookReader.cs` | Decoding and cursor alignment are now exact for both PhysicsScripts and Animations; executing it as an ordinary replacement before Step 5 would encode the wrong lifetime rule | Blocking-particle hooks still emit nothing until Step 5; ordinary hooks following them are no longer cursor-misaligned | `CreateBlockingParticleHook::Execute` 0x00526EF0; `ParticleManager::CreateBlockingParticleEmitter` 0x0051B8A0; r04 §6 |
-| TS-12 | Animated entities' emitters use rest-pose part transforms anchored at entity root; retail attaches to the live animated part (per-tick refresh deferred; statics fixed by C.1.5b/#56) | `src/AcDream.Core/Vfx/ParticleHookSink.cs:80` (+ :20) | The renderer doesn't expose per-part world transforms to VFX; root + precomputed matrices reproduce retail placement for everything that doesn't animate | Effects hooked to animated parts (swinging hand, nodding head) emit from the rest pose / float at spawn offsets instead of tracking motion | `ParticleEmitter::UpdateParticles` 0x0051d2d4 |
| TS-14 | Setup `Flatten` ignores ParentIndex part hierarchy (treats every placement as root-local); still in production use (GameWindow hydration, SkyRenderer) | `src/AcDream.Core/Meshing/SetupMesh.cs:15` | Most Setups are flat single-level rigs where root-local equals composed; hierarchical composition deferred ("Phase 3") | Any Setup with genuinely nested parts renders them at wrong offsets — mis-assembled multi-part objects in the Flatten paths | retail Setup ParentIndex chain composition |
| TS-15 | No distance-driven degrade (LOD): always close-detail slot 0; plus the **#47** static `Degrades[0]` swap for 34-part humanoids only (structural sentinel detector) | `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs:57` (+ `src/AcDream.App/Rendering/GameWindow.cs:2608`) | LOD plumbing doesn't exist; slot 0 is correct for player + nearby NPCs; #47 closed the visible low-detail-arms bug without porting UpdateViewerDistance | Distant objects render max-detail (perf + wrong visuals where far meshes intentionally differ/hide parts); a future 34-part non-humanoid matching the sentinel gets the wrong mesh swap | `CPhysicsPart::UpdateViewerDistance` 0x0050E030; ::Draw 0x0050D7A0; ::LoadGfxObjArray 0x0050DCF0 |
| TS-16 | Click picking is Stage A only: ray-vs-fixed-radius spheres (0.7–1.0 m) + screen rect matched to the indicator; retail's per-polygon refine deferred (**#71**); rect-over-circle is a user-approved UX divergence | `src/AcDream.Core/Selection/WorldPicker.cs:199` | Stage B only needed if visual testing surfaces Stage-A over-picks; sphere/rect + cell-BSP occlusion adequate so far | Clicks near (not on) an entity still select it; fixed radii can mis-prioritize overlapping candidates vs retail's polygon-accurate test | `CPolygon::polygon_hits_ray` 0x0054c889 |
@@ -304,8 +300,8 @@ TS-23 (PK bits), TS-25 (stance in MoveToState), TS-17 (AttackConditions),
and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the
0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing).
Membership Stage 2 must land TS-18 (BuildingCellId).
-The audio phase lands TS-9/TS-29; the live-pose animation-hook layer lands
-TS-10/TS-11/TS-12/TS-14.
+The audio phase lands TS-9/TS-29; the remaining live-pose animation work lands
+TS-14.
---
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index be4ca8ef..d600ca2e 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -500,7 +500,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
- **Wave 4 inventory drag visuals implemented — corrective live visual gate pending.** `IconComposer` now preserves retail's separate underlay-free `m_pDragIcon` for cursor tracking across inventory, paperdoll, and toolbar bindings. Physical source cells keep their full icon in place and reveal authored ghost mesh `0x0600109A` from drag begin through every release/cancel path, with the persistent selection square above the mesh; shortcut aliases remain unghosted. `ItemList_DragOver` destination states are split exactly: contents-grid placement uses the green accept circle `0x060011F9`, while side-bag/main-pack container drop-in uses the green arrow `0x060011F7`. AP-47 retired. Research: `docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md`.
- **Wave 4.6 item giving implemented 2026-07-13; starter-dungeon live gate pending.** SmartBox drag release now preserves the exact release coordinates, world-picks the target, and runs the existing retail `ItemHolder::AttemptPlaceIn3D` policy. NPC/creature targets send exact `GiveObjectRequest 0x00CD(target,item,amount)` bytes, selected partial stacks use the shared split quantity, and the object table waits for ACE's authoritative remove/stack-size response. `PlayerDescription.Options1` supplies the real `DragItemOnPlayerOpensSecureTrade` option. Issue #216; research: `docs/research/2026-07-13-retail-give-item-pseudocode.md`.
- **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`.
-- **Missile/portal VFX campaign Steps 0–4 implemented and independently reviewed 2026-07-14.** The retail oracle and packet fixtures are pinned, complete `PhysicsDesc` plus F754/F755 parsing and nine-channel gates are shipped, and App now has one canonical `LiveEntityRuntime` record per server-object incarnation. Logical register/unregister is separate from spatial rebucketing, so landblock churn and attached equipment retain identity without replaying renderer/script creation or reconstructing from stale spawn data. Canonical materialized and visible target/radar views are distinct; pickup/parent leave-world preserves owners while pausing root simulation; spawn publication is once per incarnation. `GpuWorldState` is spatial-only for live objects. Production PhysicsScript and Animation loading shares a narrow `DatCollection`-backed compatibility reader for retail's inherited blocking-particle payload, including mesh-side preloading; the proven concurrent-safe DatReaderWriter 2.1.7 read path avoids blocking update-thread effects behind streaming. Typed-table selection preserves DAT order and retail's first `intensity <= Mod` boundary, and live `PhysicsDesc` effect defaults replace rather than fall back to Setup. The Step 4 scheduler is now one serial FIFO per owner with duplicate stacking, catch-up dispatch, deterministic delayed `CallPES`, complete hook-router fan-out, update-frame clock publication, retail cell/Frozen eligibility, and structural rejection of malformed zero-time recursive DAT chains without rejecting valid timed weather loops. `EntityEffectController` retains pre-create F754/F755 in one mixed per-GUID FIFO, resolves local identity only through `LiveEntityRuntime`, replays once only after the canonical owner is fully ready and in-world, drops later plays while that existing owner is cell-less, routes attached-child updates through the eligible parent, and replaces/clears the live SoundTable on every PhysicsDesc application. Scripts, particles, lights, translucency, audio, and teardown all share canonical `WorldEntity.Id`; static allocators fail fast before namespace wrap. IA-7, AD-14, and TS-13 are retired; AD-32 now covers only the remaining non-effect, non-Parent pre-create packet families, AD-43 registers the corrupt-DAT zero-time-cycle safety boundary, and TS-11 remains Step 5's live logical-emitter suppression.
+- **Missile/portal VFX campaign Steps 0–5 implemented and independently reviewed 2026-07-14.** The retail oracle and packet fixtures are pinned, complete `PhysicsDesc` plus F754/F755 parsing and nine-channel gates are shipped, and App now has one canonical `LiveEntityRuntime` record per server-object incarnation. Logical register/unregister is separate from spatial rebucketing, so landblock churn and attached equipment retain identity without replaying renderer/script creation or reconstructing from stale spawn data. Canonical materialized and visible target/radar views are distinct; pickup/parent leave-world preserves owners while pausing root simulation; spawn publication is once per incarnation. `GpuWorldState` is spatial-only for live objects. Production PhysicsScript and Animation loading shares a narrow `DatCollection`-backed compatibility reader for retail's inherited blocking-particle payload, including mesh-side preloading; the proven concurrent-safe DatReaderWriter 2.1.7 read path avoids blocking update-thread effects behind streaming. Typed-table selection preserves DAT order and retail's first `intensity <= Mod` boundary, and live `PhysicsDesc` effect defaults replace rather than fall back to Setup. The Step 4 scheduler is now one serial FIFO per owner with duplicate stacking, catch-up dispatch, deterministic delayed `CallPES`, complete hook-router fan-out, update-frame clock publication, retail cell/Frozen eligibility, and structural rejection of malformed zero-time recursive DAT chains without rejecting valid timed weather loops. `EntityEffectController` retains pre-create F754/F755 in one mixed per-GUID FIFO, resolves local identity only through `LiveEntityRuntime`, replays once only after the canonical owner is fully ready and in-world, drops later plays while that existing owner is cell-less, routes attached-child updates through the eligible parent, and replaces/clears the live SoundTable on every PhysicsDesc application. Step 5 publishes current rigid root/indexed-part poses after animation and recursively composed held-child transforms, keeping render-only Setup scale out of particle and holding-location anchors; it then drains animation hooks, owner PhysicsScripts, moving emitters/lights, and particle simulation in fixed order. Normal/blocking/anonymous logical emitter identity is exact, including Stop retaining a blocking ID until final-particle retirement; moving and held Setup lights follow their current roots and share retail's PhysicsState Lighting/SetLight transition state; world-released versus parent-local particle behavior is preserved; missing emitter DAT produces an actionable diagnostic and no invented fallback. Drawable meshes no longer collapse stable Setup-part indices; nested attachments update parent-before-child, recursively withdraw on ancestor pose loss, and recover the complete descendant chain only on real publication edges. Loaded↔pending projection edges skip particle update/draw while retaining original absolute creation times and logical identity, and withdraw light presentation; re-entry evaluates elapsed state once without backlog emission or recreating an effect owner. Scripts, particles, lights, translucency, audio, and teardown all share canonical `WorldEntity.Id`; static allocators fail fast before namespace wrap. IA-7, AD-14, TS-10, TS-11, TS-12, TS-13, and AP-67 are retired; AD-32 now covers only the remaining non-effect, non-Parent pre-create packet families, and AD-43 registers the corrupt-DAT zero-time-cycle safety boundary.
- **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
- **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams.
- **Retail client command families implemented 2026-07-13; corrective live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets. Confirmation reuse now ports retail `DialogFactory` contexts, queue groups/priority, fresh DAT roots, property results, callbacks/close notices, and server abort handling; `/die`, gameplay request tuples, and guarded item-use prompts share its type-1 LayoutDesc `0x2100003C` presenter. The first live gate passed the family except for raw suicide-success code `0x004A` and the title-bar-only FPS presentation; the correction maps retail's text and mounts SmartBox element `0x10000047` with live two-decimal `FPS`/`DEG`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`, `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`.
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
index ec308efb..a09784cf 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -471,7 +471,7 @@ include dungeons.
`PlayerDescription.Options1` preserves retail's player secure-trade
preference. See
`docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216.
-- **M2/M3 missile, effect, and portal presentation campaign (Steps 0–4
+- **M2/M3 missile, effect, and portal presentation campaign (Steps 0–5
implemented and independently reviewed 2026-07-14)** — named-retail projectile and
physics-script behavior is pinned in one oracle, the complete `PhysicsDesc`
and F754/F755 wire surfaces are parsed with retail timestamp gates, and
@@ -495,7 +495,22 @@ include dungeons.
retain Setup defaults. Concurrent-safe DAT reads stay off the streaming lock; malformed
zero-time recursive chains fail diagnostically while valid timed weather loops
continue. The
- remaining steps port live animated attachment poses, projectile motion and
+ live-pose step now publishes exact root/indexed rigid-part transforms after
+ animation and equipped-child composition, then drains animation hooks,
+ PhysicsScripts, attached emitters/lights, and particle simulation in fixed
+ frame order. World-released particles remain at their birth positions while
+ parent-local particles follow; normal/blocking/anonymous logical emitter
+ semantics are exact; pending projections skip particle updates/draw while
+ retaining absolute creation times, the same emitter, and its logical ID, so
+ re-entry expires elapsed state once without backlog emission. Moving Setup lights follow top-level and held-object
+ roots and honor authoritative Lighting/SetLight transitions; missing emitter
+ assets diagnose without a fabricated fallback. Stable Setup-part indices do
+ not collapse around missing visuals, nested attachments update parent-first,
+ and per-child pose buffers are reused. Rigid attachment/effect frames exclude
+ visual Setup scale, attachment withdrawal cascades through descendants, and
+ later pose publication recovers the complete descendant chain parent-first;
+ light registration follows final projection visibility. AP-67, TS-10, TS-11, and TS-12 are
+ retired. The remaining steps port projectile motion and
sweeps, and Hidden/UnHide portal presentation.
- **L.1c local attack receive path (implemented 2026-07-11; live gate pending)** —
local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted
diff --git a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md
index f793c33c..86744ce8 100644
--- a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md
+++ b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md
@@ -740,10 +740,84 @@ Repository links:
2. Logical entity creation/destruction is separate from spatial rebucketing.
3. Server Position/Vector/State/Delete packets remain authoritative.
4. No client-side impact, explosion, damage, or deletion is synthesized.
-5. Per-object frame ordering preserves retail: animation/timed object hooks,
- movement commit and child updates, particle tick, then PhysicsScript tick.
- A script-created emitter begins particle simulation on the following tick.
+5. Presentation frame ordering is fixed: advance animation/root motion,
+ publish indexed parts, compose equipped children, drain animation hooks,
+ tick owner PhysicsScripts, refresh attached emitters/lights, then advance
+ particle simulation and draw.
+
+Retail stages animation hooks inside `CSequence::update`, then executes them
+from `CPhysicsObj::process_hooks` before the new root is committed. The same
+update subsequently calls `CPhysicsObj::set_frame` (`0x00514090`), which runs
+`CPartArray::SetFrame` and `UpdateChildrenInternal`; that relocates the newly
+parented emitter/held child to the final pose before `ParticleManager` advances
+or anything draws. acdream's explicit publish-then-drain barrier preserves that
+observable ordering without exposing a half-committed pose to modern sinks.
6. Hidden suppresses mesh/collision/interaction but does not destroy the effect
owner.
7. DAT assets determine models, colors, timing, and attachment behavior.
8. Every ported branch receives a conformance test and a named-retail citation.
+
+## 14. Step 5 implementation map (2026-07-14)
+
+- `EntityEffectPoseRegistry` exposes current root and indexed part-local poses
+ through `IEntityEffectPoseSource`. The indexed channel contains retail's
+ rigid `CPhysicsPart` frames, not drawable mesh scale: `UpdateParts
+ 0x005190F0` multiplies only the animation origin by object scale, while
+ `SetScaleInternal 0x00518A00` stores Setup `DefaultScale` separately on
+ `gfxobj_scale`. Animated entities publish those rigid frames beside their
+ visually scaled `MeshRef` composition; equipped children then publish
+ `holdingFrame * parentPart * parentRoot` plus the child's own indexed part
+ locals. Appearance replacement republishes immediately.
+- `AnimationHookFrameQueue` captures hooks during sequence advance and drains
+ only after equipped-child composition. `AnimationDone` and `UseTime` remain
+ paired with the sequencer even if its render pose vanished during teardown.
+- `ParticleHookSink` binds each live handle to local owner, exact part index
+ (including root `-1`), complete offset Frame, logical emitter ID, and render
+ pass. It applies the offset origin through `partLocal * rootWorld`; the
+ retained offset quaternion remains intentionally unused per
+ `Particle::Init 0x0051C930`.
+- Normal nonzero logical IDs replace, blocking nonzero IDs suppress while the
+ old emitter remains live, and zero IDs create anonymously. Natural emitter
+ retirement releases the logical ID. `StopParticleEmitter` retains the ID
+ until that retirement, while destroy removes it immediately. Missing
+ `ParticleEmitterInfo` DAT
+ records log owner + DID and create nothing; the former synthesized generic
+ cloud is removed.
+- World-released particles retain their emission origin while future emissions
+ follow the refreshed anchor. Parent-local particles consume the refreshed
+ anchor for existing particles, matching `Particle::Update 0x0051C290` and
+ `ParticleEmitter::UpdateParticles 0x0051D180`.
+ Loaded-to-pending spatial transitions skip particle update/draw under retail's
+ cell-less `update_object 0x00515D10` gate while retaining the emitter,
+ particles, original absolute creation times, and logical ID. Authoritative pose
+ correction may still refresh the anchor. Re-entry evaluates elapsed particle
+ age/emitter duration once and emits no absent-time backlog; timestamps are not
+ shifted to freeze the effect.
+- Setup lights retain their complete local `LIGHTINFO` frame.
+ `LiveEntityLightController` owns live registration/re-entry and every frame
+ `LightingHookSink` composes it through the current owner root/cell. Held
+ objects use the child root published after `CPhysicsObj::UpdateChild
+ 0x00512D50`; top-level roots follow their `WorldEntity`. PhysicsState's
+ Lighting bit and `SetLightHook` both drive the same create/destroy state,
+ matching `CPhysicsObj::set_state 0x00514DD0` and `set_lights 0x0050FCF0`.
+ This retires AP-67 and TS-10.
+ Light presentation follows `LiveEntityRuntime`'s final projection edge, so a
+ loaded-to-loaded rebucket does not replay registration and a pending owner
+ contributes no stale light. Attached lights wait for the composed-child-pose
+ barrier before their first registration.
+- Setup-part indices are retained separately from drawable `MeshRefs`; a
+ missing middle GfxObj cannot shift later hook/holding indices. Nested held
+ objects update parent-before-child and compose through the parent's already
+ published child root. The buffers are retained by the attachment owner and
+ reused each frame.
+- Logical teardown removes queues, emitters, lights, and poses exactly once;
+ rebucketing does not. Pose loss withdraws an attached projection and its
+ descendants while retaining accepted relations for valid later recovery.
+ A post-publish parent-first recovery drain realizes the complete descendant
+ chain, preventing an A→B→C graph from stranding C after B returns. The drain
+ is edge-triggered by object/appearance/pose publication, never a per-frame
+ retry loop for a permanently absent Setup or holding part.
+ Session reset clears captured hooks and live poses
+ through normal owner teardown while retaining DAT-static/synthetic poses.
+ TS-11 and TS-12 are retired with the emitter-binding and
+ live-part mechanisms.
diff --git a/src/AcDream.App/Rendering/AttachmentUpdateOrder.cs b/src/AcDream.App/Rendering/AttachmentUpdateOrder.cs
new file mode 100644
index 00000000..38a79cc3
--- /dev/null
+++ b/src/AcDream.App/Rendering/AttachmentUpdateOrder.cs
@@ -0,0 +1,132 @@
+namespace AcDream.App.Rendering;
+
+///
+/// Reusable parent-before-child traversal for retail's
+/// CPhysicsObj::UpdateChildrenInternal attachment graph. Failed parent
+/// projections suppress their descendants for the same frame; callers remove
+/// those projections only after traversal, so dictionary enumeration remains
+/// stable.
+///
+internal sealed class AttachmentUpdateOrder
+{
+ private readonly HashSet _visiting = new();
+ private readonly HashSet _completed = new();
+ private readonly HashSet _failedSet = new();
+ private readonly List _failed = new();
+ private readonly List _subtree = new();
+ private readonly Queue _recoveryQueue = new();
+ private readonly HashSet _recoveryVisited = new();
+
+ ///
+ /// Updates the current attachment map without per-frame delegate or
+ /// enumerator boxing. The returned list is borrowed until the next call.
+ ///
+ public IReadOnlyList ForEachParentFirst(
+ Dictionary children,
+ Func parentOf,
+ Func update)
+ {
+ ArgumentNullException.ThrowIfNull(children);
+ ArgumentNullException.ThrowIfNull(parentOf);
+ ArgumentNullException.ThrowIfNull(update);
+
+ _visiting.Clear();
+ _completed.Clear();
+ _failedSet.Clear();
+ _failed.Clear();
+ foreach (uint childId in children.Keys)
+ Visit(childId, children, parentOf, update);
+ return _failed;
+ }
+
+ ///
+ /// Returns descendants before ancestors so an attachment subtree can be
+ /// withdrawn without leaving a child rooted at a stale intermediate pose.
+ /// The returned list is borrowed until the next call.
+ ///
+ public IReadOnlyList CollectSubtreePostOrder(
+ Dictionary children,
+ uint rootId,
+ Func parentOf)
+ {
+ _subtree.Clear();
+ _visiting.Clear();
+ Collect(rootId, children, parentOf);
+ return _subtree;
+ }
+
+ ///
+ /// Retries every waiting child after a parent pose becomes available.
+ /// Each successfully realized child becomes a parent candidate in turn,
+ /// so A→B→C recovers in one parent-first drain.
+ ///
+ public void RealizeDescendants(
+ uint parentId,
+ Func> childrenWaitingForParent,
+ Func realize)
+ {
+ ArgumentNullException.ThrowIfNull(childrenWaitingForParent);
+ ArgumentNullException.ThrowIfNull(realize);
+
+ _recoveryQueue.Clear();
+ _recoveryVisited.Clear();
+ _recoveryQueue.Enqueue(parentId);
+ _recoveryVisited.Add(parentId);
+ while (_recoveryQueue.Count > 0)
+ {
+ uint currentParent = _recoveryQueue.Dequeue();
+ IReadOnlyList waiting = childrenWaitingForParent(currentParent);
+ for (int i = 0; i < waiting.Count; i++)
+ {
+ uint childId = waiting[i];
+ if (realize(childId) && _recoveryVisited.Add(childId))
+ _recoveryQueue.Enqueue(childId);
+ }
+ }
+ }
+
+ private void Collect(
+ uint rootId,
+ Dictionary children,
+ Func parentOf)
+ {
+ if (!_visiting.Add(rootId))
+ return;
+ foreach ((uint candidateId, TChild child) in children)
+ {
+ if (parentOf(child) == rootId)
+ Collect(candidateId, children, parentOf);
+ }
+ _subtree.Add(rootId);
+ }
+
+ private void Visit(
+ uint childId,
+ Dictionary children,
+ Func parentOf,
+ Func update)
+ {
+ if (_completed.Contains(childId)
+ || !children.TryGetValue(childId, out TChild? child))
+ {
+ return;
+ }
+ if (!_visiting.Add(childId))
+ return;
+
+ uint? parentId = parentOf(child);
+ if (parentId is { } attachedParentId && children.ContainsKey(attachedParentId))
+ Visit(attachedParentId, children, parentOf, update);
+
+ _visiting.Remove(childId);
+ if (!_completed.Add(childId))
+ return;
+
+ bool succeeded = parentId is not { } parent
+ || !_failedSet.Contains(parent);
+ if (succeeded)
+ succeeded = update(childId);
+ if (!succeeded && _failedSet.Add(childId))
+ _failed.Add(childId);
+ }
+}
diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs
index 2baa9933..49030e34 100644
--- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs
+++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs
@@ -1,5 +1,6 @@
using System.Numerics;
using AcDream.App.World;
+using AcDream.App.Rendering.Vfx;
using AcDream.Core.Items;
using AcDream.Core.Meshing;
using AcDream.Core.Net;
@@ -27,12 +28,20 @@ public sealed class EquippedChildRenderController : IDisposable
private readonly ClientObjectTable _objects;
private readonly LiveEntityRuntime _liveEntities;
private readonly Func _acceptParent;
+ private readonly EntityEffectPoseRegistry _poses;
private ParentAttachmentState Relations => _liveEntities.ParentAttachments;
/// Raised after the attached projection is fully registered.
public event Action? EntityReady;
+ /// Raised after an attached projection has its composed pose.
+ public event Action? ProjectionPoseReady;
+ /// Raised when an attached projection leaves its cell presentation.
+ public event Action? ProjectionRemoved;
private readonly Dictionary _attachedByChild = new();
+ private readonly AttachmentUpdateOrder _updateOrder = new();
+ private readonly Func _parentOfAttached;
+ private readonly Func _tickAttached;
public IEnumerable AttachedEntityIds
{
@@ -48,13 +57,17 @@ public sealed class EquippedChildRenderController : IDisposable
object datLock,
ClientObjectTable objects,
LiveEntityRuntime liveEntities,
+ EntityEffectPoseRegistry poses,
Func acceptParent)
{
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
+ _poses = poses ?? throw new ArgumentNullException(nameof(poses));
_acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent));
+ _parentOfAttached = static child => child.ParentGuid;
+ _tickAttached = TickChild;
_objects.ObjectMoved += OnObjectMoved;
_objects.MoveRolledBack += OnMoveRolledBack;
@@ -83,17 +96,11 @@ public sealed class EquippedChildRenderController : IDisposable
spawn.PositionSequence));
}
- ResolveRelations(spawn.Guid);
- TryRealize(spawn.Guid);
+ ResolveAndTryRealize(spawn.Guid);
- // ParentEvent can precede the parent's CreateObject. Revisit every
- // child waiting specifically on the object that just arrived.
- IReadOnlyList waiting = Relations.ChildrenWaitingForParent(spawn.Guid);
- for (int i = 0; i < waiting.Count; i++)
- {
- ResolveRelations(waiting[i]);
- TryRealize(waiting[i]);
- }
+ // ParentEvent can precede the parent's CreateObject. Revisit the
+ // complete descendant chain rooted at the object that arrived.
+ RetryWaitingDescendants(spawn.Guid);
}
}
@@ -107,15 +114,20 @@ public sealed class EquippedChildRenderController : IDisposable
{
lock (_datLock)
{
- IReadOnlyList waiting = Relations.ChildrenWaitingForParent(guid);
- for (int i = 0; i < waiting.Count; i++)
- {
- ResolveRelations(waiting[i]);
- TryRealize(waiting[i]);
- }
+ RetryWaitingDescendants(guid);
}
}
+ ///
+ /// Retries pose-withdrawn descendants only after their parent's new root
+ /// and indexed parts have been published.
+ ///
+ public void OnPosePublished(uint guid)
+ {
+ lock (_datLock)
+ RetryWaitingDescendants(guid);
+ }
+
///
/// Apply/queue a live ParentEvent after the live-entity owner has exact-
/// gated the parent's INSTANCE_TS and advanced the child's shared
@@ -126,8 +138,8 @@ public sealed class EquippedChildRenderController : IDisposable
lock (_datLock)
{
Relations.Enqueue(update);
- ResolveRelations(update.ChildGuid);
- TryRealize(update.ChildGuid);
+ if (ResolveAndTryRealize(update.ChildGuid))
+ RetryWaitingDescendants(update.ChildGuid);
}
}
@@ -171,68 +183,103 @@ public sealed class EquippedChildRenderController : IDisposable
/// Recompose every child after the parent's animation tick.
public void Tick()
{
- foreach (AttachedChild child in _attachedByChild.Values)
- {
- if (!_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent))
- continue;
-
- if (!EquippedChildAttachment.TryCompose(
- child.ParentSetup,
- parent.MeshRefs,
- child.ChildSetup,
- child.ParentLocation,
- child.Placement,
- child.PartTemplate,
- child.Scale,
- out IReadOnlyList parts))
- continue;
-
- child.Entity.MeshRefs = parts;
- child.Entity.SetPosition(parent.Position);
- child.Entity.Rotation = parent.Rotation;
- child.Entity.ParentCellId = parent.ParentCellId;
- if (parent.ParentCellId is { } parentCellId)
- _liveEntities.RebucketLiveEntity(child.ChildGuid, parentCellId);
- }
+ IReadOnlyList failed = _updateOrder.ForEachParentFirst(
+ _attachedByChild,
+ _parentOfAttached,
+ _tickAttached);
+ for (int i = 0; i < failed.Count; i++)
+ WithdrawForPoseLoss(failed[i]);
}
- private void TryRealize(uint childGuid)
+ private bool TickChild(uint childGuid)
+ {
+ if (!_attachedByChild.TryGetValue(childGuid, out AttachedChild? child))
+ return false;
+
+ if (_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent)
+ && _poses.TryGetRootPose(parent.Id, out Matrix4x4 parentWorld)
+ && _poses.TryGetPartPoseSnapshot(
+ parent.Id,
+ out var parentPartPoses,
+ out var parentPartAvailability)
+ && EquippedChildAttachment.TryComposePoseInto(
+ child.ParentSetup,
+ parentPartPoses,
+ parentPartAvailability,
+ child.ChildSetup,
+ child.ParentLocation,
+ child.Placement,
+ child.PartTemplate,
+ child.Scale,
+ child.PartPoseBuffer,
+ child.AttachedPartBuffer,
+ out EquippedChildPose pose))
+ {
+ child.Entity.MeshRefs = pose.AttachedParts;
+ child.Entity.SetIndexedPartPoses(pose.PartLocal, child.PartAvailability);
+ if (!ApplyParentWorldPose(child.Entity, parentWorld))
+ return false;
+ child.Entity.ParentCellId = parent.ParentCellId;
+ PublishChildPose(child.Entity, parentWorld, parent.ParentCellId, pose);
+ if (parent.ParentCellId is { } parentCellId)
+ _liveEntities.RebucketLiveEntity(child.ChildGuid, parentCellId);
+ ProjectionPoseReady?.Invoke(child.ChildGuid);
+ return true;
+ }
+ return false;
+ }
+
+ private bool TryRealize(uint childGuid)
{
if (!Relations.TryGetProjection(childGuid, out ParentAttachmentRelation pending))
- return;
+ return false;
if (!_liveEntities.TryGetWorldEntity(pending.ParentGuid, out WorldEntity parentEntity)
|| !_liveEntities.TryGetSnapshot(pending.ParentGuid, out WorldSession.EntitySpawn parentSpawn)
|| !_liveEntities.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn childSpawn))
- return;
+ return false;
if (parentSpawn.SetupTableId is not { } parentSetupId
|| childSpawn.SetupTableId is not { } childSetupId
|| parentEntity.ParentCellId is not { } parentCellId)
- return;
+ return false;
Setup? parentSetup = _dats.Get(parentSetupId);
Setup? childSetup = _dats.Get(childSetupId);
if (parentSetup is null || childSetup is null)
- return;
+ return false;
var parentLocation = (ParentLocation)pending.ParentLocation;
var placement = (Placement)pending.PlacementId;
IReadOnlyList template = BuildPartTemplate(childSetup, childSpawn);
+ bool[] childPartAvailability = BuildPartAvailability(template);
float scale = childSpawn.ObjScale is { } objScale && objScale > 0f
? objScale
: 1.0f;
+ if (!_poses.TryGetPartPoseSnapshot(
+ parentEntity.Id,
+ out var parentPartPoses,
+ out var parentPartAvailability))
+ return false;
+ if (!_poses.TryGetRootPose(parentEntity.Id, out Matrix4x4 parentWorld)
+ || !TryDecomposeWorldPose(parentWorld, out Vector3 parentPosition, out Quaternion parentRotation))
+ {
+ return false;
+ }
- if (!EquippedChildAttachment.TryCompose(
+ if (!EquippedChildAttachment.TryComposePoseInto(
parentSetup,
- parentEntity.MeshRefs,
+ parentPartPoses,
+ parentPartAvailability,
childSetup,
parentLocation,
placement,
template,
scale,
- out IReadOnlyList parts))
- return;
+ partPoseBuffer: null,
+ attachedPartBuffer: null,
+ out EquippedChildPose pose))
+ return false;
// A parented object may materialize here before the ordinary world
// hydration path sees it. Install its logical effect profile before
@@ -250,29 +297,34 @@ public sealed class EquippedChildRenderController : IDisposable
WorldEntity? entity = _liveEntities.MaterializeLiveEntity(
childGuid,
parentCellId,
- localId => new WorldEntity
+ localId =>
{
- Id = localId,
- ServerGuid = childGuid,
- SourceGfxObjOrSetupId = childSetupId,
- Position = parentEntity.Position,
- Rotation = parentEntity.Rotation,
- MeshRefs = parts,
- PaletteOverride = BuildPaletteOverride(childSpawn),
- ParentCellId = parentCellId,
+ var created = new WorldEntity
+ {
+ Id = localId,
+ ServerGuid = childGuid,
+ SourceGfxObjOrSetupId = childSetupId,
+ Position = parentPosition,
+ Rotation = parentRotation,
+ MeshRefs = pose.AttachedParts,
+ PaletteOverride = BuildPaletteOverride(childSpawn),
+ ParentCellId = parentCellId,
+ };
+ created.SetIndexedPartPoses(pose.PartLocal, childPartAvailability);
+ return created;
},
LiveEntityProjectionKind.Attached);
if (entity is null)
- return;
- entity.SetPosition(parentEntity.Position);
- entity.Rotation = parentEntity.Rotation;
+ return false;
+ ApplyParentWorldPose(entity, parentWorld);
entity.ParentCellId = parentCellId;
entity.ApplyAppearance(
- parts,
+ pose.AttachedParts,
BuildPaletteOverride(childSpawn),
childSpawn.AnimPartChanges is { Count: > 0 } changes
? changes.Select(change => new PartOverride(change.PartIndex, change.NewModelId)).ToArray()
: Array.Empty());
+ entity.SetIndexedPartPoses(pose.PartLocal, childPartAvailability);
_attachedByChild[childGuid] = new AttachedChild(
pending.ParentGuid,
childGuid,
@@ -281,13 +333,60 @@ public sealed class EquippedChildRenderController : IDisposable
parentSetup,
childSetup,
template,
+ childPartAvailability,
+ pose.PartLocal,
+ pose.AttachedParts,
scale,
entity);
+ PublishChildPose(entity, parentWorld, parentEntity.ParentCellId, pose);
Console.WriteLine(
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
$"location={parentLocation} placement={placement}");
Relations.MarkProjected(childGuid);
+ ProjectionPoseReady?.Invoke(childGuid);
EntityReady?.Invoke(childGuid);
+ return true;
+ }
+
+ private void PublishChildPose(
+ WorldEntity child,
+ Matrix4x4 parentWorld,
+ uint? parentCellId,
+ EquippedChildPose pose)
+ {
+ _poses.Publish(
+ child.Id,
+ pose.RootLocal * parentWorld,
+ pose.PartLocal,
+ parentCellId ?? 0u,
+ child.IndexedPartAvailable);
+ }
+
+ ///
+ /// Installs the immediate parent's composed world root on the child
+ /// projection. The child's MeshRefs already contain its holding and
+ /// placement transforms relative to that direct parent root.
+ ///
+ internal static bool ApplyParentWorldPose(WorldEntity child, Matrix4x4 parentWorld)
+ {
+ if (!TryDecomposeWorldPose(parentWorld, out Vector3 position, out Quaternion rotation))
+ return false;
+ child.SetPosition(position);
+ child.Rotation = rotation;
+ return true;
+ }
+
+ private static bool TryDecomposeWorldPose(
+ Matrix4x4 world,
+ out Vector3 position,
+ out Quaternion rotation)
+ {
+ position = world.Translation;
+ if (!Matrix4x4.Decompose(world, out Vector3 scale, out rotation, out _)
+ || Vector3.DistanceSquared(scale, Vector3.One) > 1e-6f)
+ return false;
+ rotation = Quaternion.Normalize(rotation);
+ return true;
}
///
@@ -337,6 +436,20 @@ public sealed class EquippedChildRenderController : IDisposable
_acceptParent);
}
+ private bool ResolveAndTryRealize(uint childGuid)
+ {
+ ResolveRelations(childGuid);
+ return TryRealize(childGuid);
+ }
+
+ private void RetryWaitingDescendants(uint parentGuid)
+ {
+ _updateOrder.RealizeDescendants(
+ parentGuid,
+ Relations.ChildrenWaitingForParent,
+ ResolveAndTryRealize);
+ }
+
private IReadOnlyList BuildPartTemplate(
Setup setup,
WorldSession.EntitySpawn spawn)
@@ -396,6 +509,14 @@ public sealed class EquippedChildRenderController : IDisposable
return result;
}
+ private bool[] BuildPartAvailability(IReadOnlyList template)
+ {
+ var available = new bool[template.Count];
+ for (int i = 0; i < template.Count; i++)
+ available[i] = _dats.Get(template[i].GfxObjId) is not null;
+ return available;
+ }
+
private static PaletteOverride? BuildPaletteOverride(WorldSession.EntitySpawn spawn)
{
if (spawn.SubPalettes is not { Count: > 0 } subPalettes)
@@ -430,27 +551,52 @@ public sealed class EquippedChildRenderController : IDisposable
if (Relations.RestoreLastAccepted(item.ObjectId))
{
lock (_datLock)
- TryRealize(item.ObjectId);
+ {
+ if (ResolveAndTryRealize(item.ObjectId))
+ RetryWaitingDescendants(item.ObjectId);
+ }
}
}
private void Remove(uint childGuid)
{
- if (!_attachedByChild.Remove(childGuid))
+ if (!_attachedByChild.Remove(childGuid, out AttachedChild? child))
return;
_liveEntities.WithdrawLiveEntityProjection(childGuid);
+ _poses.Remove(child.Entity.Id);
+ ProjectionRemoved?.Invoke(child.Entity.Id);
+ }
+
+ private void WithdrawForPoseLoss(uint childGuid)
+ {
+ if (!_attachedByChild.ContainsKey(childGuid))
+ return;
+ Remove(childGuid);
+ // MarkProjected removed the active candidate but retained the last
+ // accepted relationship. Requeue that exact accepted relation so a
+ // later parent pose/appearance registration can realize it again.
+ Relations.RestoreLastAccepted(childGuid);
}
private void TearDownObjectProjections(uint guid)
{
- Remove(guid);
-
- uint[] children = _attachedByChild.Values
- .Where(child => child.ParentGuid == guid)
- .Select(child => child.ChildGuid)
- .ToArray();
- for (int i = 0; i < children.Length; i++)
- Remove(children[i]);
+ IReadOnlyList subtree = _updateOrder.CollectSubtreePostOrder(
+ _attachedByChild,
+ guid,
+ _parentOfAttached);
+ for (int i = 0; i < subtree.Count; i++)
+ {
+ uint childGuid = subtree[i];
+ Remove(childGuid);
+ if (childGuid != guid)
+ {
+ // Retail unparents direct children. Descendant relationships
+ // can become renderable again if their immediate parent later
+ // acquires a top-level Position, so retain their accepted
+ // relationship as a pending projection candidate.
+ Relations.RestoreLastAccepted(childGuid);
+ }
+ }
}
public void Clear()
@@ -477,6 +623,9 @@ public sealed class EquippedChildRenderController : IDisposable
Setup ParentSetup,
Setup ChildSetup,
IReadOnlyList PartTemplate,
+ IReadOnlyList PartAvailability,
+ Matrix4x4[] PartPoseBuffer,
+ MeshRef[] AttachedPartBuffer,
float Scale,
WorldEntity Entity);
}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index dc25b9e8..4c787697 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -286,7 +286,8 @@ public sealed class GameWindow : IDisposable
/// from the current animation frame; only these two fields are
/// reused unchanged.
///
- public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary? SurfaceOverrides)> PartTemplate;
+ public required IReadOnlyList PartTemplate;
+ public required IReadOnlyList PartAvailability;
public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame]
public AcDream.Core.Physics.AnimationSequencer? Sequencer;
@@ -307,8 +308,15 @@ public sealed class GameWindow : IDisposable
/// in-place mutation of this list.
///
public readonly List MeshRefsScratch = new();
+ /// Indexed final part poses, including debug-hidden parts.
+ public readonly List EffectPartPosesScratch = new();
}
+ internal readonly record struct AnimatedPartTemplate(
+ uint GfxObjId,
+ IReadOnlyDictionary? SurfaceOverrides,
+ bool IsDrawable);
+
private sealed record AppearanceUpdateState(
AcDream.Core.World.WorldEntity Entity,
AnimatedEntity? Animation);
@@ -332,6 +340,8 @@ public sealed class GameWindow : IDisposable
private AcDream.Core.Vfx.EmitterDescRegistry? _emitterRegistry;
private AcDream.Core.Vfx.ParticleSystem? _particleSystem;
private AcDream.Core.Vfx.ParticleHookSink? _particleSink;
+ private readonly AcDream.App.Rendering.Vfx.EntityEffectPoseRegistry _effectPoses = new();
+ private AcDream.App.Rendering.Vfx.AnimationHookFrameQueue? _animationHookFrames;
// Phase 6 — retail PhysicsScript runtime. Receives PlayScript (0xF754)
// and typed PlayScriptType (0xF755) events through one effect owner, then
// fans every dat-defined hook to particles, audio, lights, translucency,
@@ -815,6 +825,7 @@ public sealed class GameWindow : IDisposable
// Wired into the hook router in OnLoad so SetLightHook fires
// from the animation pipeline flip the matching LightSource.IsLit.
private AcDream.Core.Lighting.LightingHookSink? _lightingSink;
+ private AcDream.App.Rendering.Vfx.LiveEntityLightController? _liveEntityLights;
// #188 — TransparentPartHook fires from the animation pipeline drive
// a per-(entity,part) translucency ramp; WbDrawDispatcher reads it
@@ -1444,7 +1455,12 @@ public sealed class GameWindow : IDisposable
// StopParticle hooks fired from motion tables produce visible
// spawns. The Tick call is driven from OnRender.
_particleSystem = new AcDream.Core.Vfx.ParticleSystem(_emitterRegistry!);
- _particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem);
+ _particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem, _effectPoses);
+ _particleSink.DiagnosticSink = message =>
+ Console.Error.WriteLine($"vfx: {message}");
+ _animationHookFrames = new AcDream.App.Rendering.Vfx.AnimationHookFrameQueue(
+ _hookRouter,
+ _effectPoses);
_hookRouter.Register(_particleSink);
// Phase 6c — PhysicsScript runner. Uses the DatCollection to
@@ -1460,7 +1476,7 @@ public sealed class GameWindow : IDisposable
// Phase G.2 lighting hooks: SetLightHook flips IsLit on
// owner-tagged lights so ignite-torch animations light up,
// extinguish-torch animations go dark.
- _lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting);
+ _lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting, _effectPoses);
_hookRouter.Register(_lightingSink);
// #188 — TransparentPartHook (per-part translucency fade, e.g.
@@ -2294,8 +2310,10 @@ public sealed class GameWindow : IDisposable
// 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
+ // transforms from the entity's exact current MeshRefs so
+ // multi-emitter scripts distribute across mesh parts (closes #56).
+ // Animated frames replace this snapshot through EntityEffectPoseRegistry.
+ // _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.
@@ -2309,12 +2327,21 @@ public sealed class GameWindow : IDisposable
e.SourceGfxObjOrSetupId);
if (setup is null) return null;
uint scriptId = setup.DefaultScript.DataId;
- var parts = AcDream.Core.Meshing.SetupPartTransforms.Compute(setup);
+ if (e.IndexedPartTransforms.Count == 0)
+ {
+ var indexed = AcDream.App.Rendering.Vfx.IndexedSetupPartPoseBuilder.Build(
+ setup,
+ e);
+ e.SetIndexedPartPoses(indexed.Poses, indexed.Available);
+ }
+ IReadOnlyList parts = e.IndexedPartTransforms;
+ IReadOnlyList availability = e.IndexedPartAvailable;
var profile = AcDream.App.Rendering.Vfx.EntityEffectProfile.CreateDatStatic(setup);
return new AcDream.App.Rendering.Vfx.ScriptActivationInfo(
scriptId,
parts,
- profile);
+ profile,
+ availability);
}
catch
{
@@ -2324,6 +2351,7 @@ public sealed class GameWindow : IDisposable
var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator(
_scriptRunner!,
_particleSink!,
+ _effectPoses,
ResolveActivation,
(ownerId, entity, profile) =>
entityEffects?.OnDatStaticEntityReady(ownerId, entity, profile),
@@ -2364,12 +2392,24 @@ public sealed class GameWindow : IDisposable
}
}),
TearDownLiveEntityRuntimeComponents);
+ _liveEntities.ProjectionVisibilityChanged += (record, visible) =>
+ {
+ if (record.WorldEntity is { } entity)
+ _particleSink!.SetEntityPresentationVisible(entity.Id, visible);
+ };
+
+ _liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController(
+ _liveEntities,
+ _effectPoses,
+ _lightingSink!,
+ setupId => _dats!.Get(setupId));
_equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController(
_dats!,
_datLock,
Objects,
_liveEntities,
+ _effectPoses,
TryAcceptParentForRender);
var tableResolver = new AcDream.Core.Vfx.PhysicsScriptTableResolver(
@@ -2378,6 +2418,7 @@ public sealed class GameWindow : IDisposable
_liveEntities,
_scriptRunner!,
tableResolver,
+ _effectPoses,
(parentLocalId, partIndex) =>
_equippedChildRenderer.FindChildLocalIdAtPart(parentLocalId, partIndex),
childLocalId => _equippedChildRenderer.FindParentLocalId(childLocalId),
@@ -2389,8 +2430,14 @@ public sealed class GameWindow : IDisposable
_entitySoundTables?.Set(ownerId, did);
});
_entityEffects = entityEffects;
+ entityEffects.DiagnosticSink = message =>
+ Console.Error.WriteLine($"vfx: {message}");
_equippedChildRenderer.EntityReady += guid =>
+ {
entityEffects.OnLiveEntityReady(guid);
+ };
+ _equippedChildRenderer.ProjectionPoseReady += guid =>
+ _liveEntityLights.OnAttachedPoseReady(guid);
_hookRouter.Register(entityEffects);
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
@@ -2619,6 +2666,7 @@ public sealed class GameWindow : IDisposable
// F754/F755 can precede CreateObject, so the mixed pending FIFO
// may contain owners with no LiveEntityRecord to tear down.
_entityEffects?.ClearNetworkState();
+ _animationHookFrames?.Clear();
}
}
@@ -3626,14 +3674,39 @@ public sealed class GameWindow : IDisposable
// creatures/characters whose size is intrinsic to the mesh).
float scale = spawn.ObjScale ?? 1.0f;
var scaleMat = System.Numerics.Matrix4x4.CreateScale(scale);
+ IReadOnlyList rigidPartTransforms =
+ AcDream.Core.Meshing.SetupPartTransforms.Compute(
+ setup,
+ idleFrame,
+ scale);
var meshRefs = new List();
+ var indexedPartTransforms = new System.Numerics.Matrix4x4[parts.Count];
+ var indexedPartAvailable = new bool[parts.Count];
+ var animatedPartTemplate = new AnimatedPartTemplate[parts.Count];
var liveBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
int dumpClothingTotalTris = 0;
for (int partIdx = 0; partIdx < parts.Count; partIdx++)
{
var mr = parts[partIdx];
+ IReadOnlyDictionary? surfaceOverrides = null;
+ if (resolvedOverridesByPart is not null && resolvedOverridesByPart.TryGetValue(partIdx, out var partOverrides))
+ surfaceOverrides = partOverrides;
+
+ // Keep the Setup index even when the visual GfxObj is absent.
+ // MeshRefs is a drawable-only list, while hooks and holding
+ // locations address the stable CPartArray slot number.
+ var transform = scale == 1.0f ? mr.PartTransform : mr.PartTransform * scaleMat;
+ indexedPartTransforms[partIdx] = partIdx < rigidPartTransforms.Count
+ ? rigidPartTransforms[partIdx]
+ : System.Numerics.Matrix4x4.Identity;
var gfx = _dats.Get(mr.GfxObjId);
+ bool isDrawable = gfx is not null;
+ indexedPartAvailable[partIdx] = isDrawable;
+ animatedPartTemplate[partIdx] = new AnimatedPartTemplate(
+ mr.GfxObjId,
+ surfaceOverrides,
+ isDrawable);
if (gfx is null)
{
if (dumpClothing)
@@ -3650,10 +3723,6 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($" EMIT part={partIdx:D2} gfx=0x{mr.GfxObjId:X8} subMeshes={subs} tris={tris}");
}
- IReadOnlyDictionary? surfaceOverrides = null;
- if (resolvedOverridesByPart is not null && resolvedOverridesByPart.TryGetValue(partIdx, out var partOverrides))
- surfaceOverrides = partOverrides;
-
// Multiplication order matches offline scenery hydration:
// `PartTransform * scaleMat`. In row-vector semantics this means
// "apply PartTransform first (which includes the part-attachment
@@ -3663,8 +3732,6 @@ public sealed class GameWindow : IDisposable
// for multi-part entities like the Nullified Statue that causes
// the parts to drift relative to each other ("distorted") and the
// base anchor to end up below the ground ("sinks into foundry").
- var transform = scale == 1.0f ? mr.PartTransform : mr.PartTransform * scaleMat;
-
// #119 follow-up: vertex-derived root-local bounds (see WorldEntity.RefreshAabb).
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) liveBounds.Add(transform, pb.Value);
@@ -3719,10 +3786,17 @@ public sealed class GameWindow : IDisposable
{
AcDream.Core.World.WorldEntity existing = visualUpdate.Entity;
existing.ApplyAppearance(meshRefs, paletteOverride, entityPartOverrides);
+ existing.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable);
if (liveBounds.TryGet(out var appearanceMin, out var appearanceMax))
existing.SetLocalBounds(appearanceMin, appearanceMax);
if (visualUpdate.Animation is { } animation)
- RebindAnimatedEntityForAppearance(animation, existing, setup, scale, meshRefs);
+ RebindAnimatedEntityForAppearance(
+ animation,
+ existing,
+ setup,
+ scale,
+ animatedPartTemplate,
+ indexedPartAvailable);
_classificationCache.InvalidateEntity(existing.Id);
if (_liveEntities!.TryGetRecord(spawn.Guid, out LiveEntityRecord record)
&& record.ProjectionKind is LiveEntityProjectionKind.Attached)
@@ -3732,6 +3806,14 @@ public sealed class GameWindow : IDisposable
// after replacing the child's visual description.
_equippedChildRenderer?.OnSpawn(spawn);
}
+ else
+ {
+ // SmartBox::UpdateVisualDesc mutates the existing PartArray.
+ // Publish those exact replacement part frames before another
+ // packet in this update can execute a part-attached hook.
+ _effectPoses.PublishMeshRefs(existing);
+ _equippedChildRenderer?.OnPosePublished(spawn.Guid);
+ }
return;
}
@@ -3762,6 +3844,7 @@ public sealed class GameWindow : IDisposable
PartOverrides = entityPartOverrides,
ParentCellId = spawn.Position.Value.LandblockId,
};
+ created.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable);
if (liveBounds.TryGet(out var createdMin, out var createdMax))
created.SetLocalBounds(createdMin, createdMax);
return created;
@@ -3777,8 +3860,10 @@ public sealed class GameWindow : IDisposable
entity.Rotation = rot;
entity.ParentCellId = spawn.Position.Value.LandblockId;
entity.ApplyAppearance(meshRefs, paletteOverride, entityPartOverrides);
+ entity.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable);
if (liveBounds.TryGet(out var retainedMin, out var retainedMax))
entity.SetLocalBounds(retainedMin, retainedMax);
+ _effectPoses.PublishMeshRefs(entity);
}
// Retail CPhysicsObj::leave_world removes cell/shadow membership but
@@ -3788,47 +3873,6 @@ public sealed class GameWindow : IDisposable
bool retainedAnimationRuntime = !createdProjection
&& _animatedEntities.TryGetValue(entity.Id, out _);
- // A7 indoor lighting: server-spawned weenie fixtures (lanterns,
- // braziers, glowing items) carry their light in Setup.Lights exactly
- // like dat-baked statics, but the static registration in
- // ApplyLoadedTerrainLocked never sees CreateObject entities — so an
- // interior's lanterns cast no light and the room reads dark. Register
- // them here, mirroring that path (GameWindow.cs ~6742). Owned by
- // entity.Id, so leave-world and logical teardown both remove their
- // cell-scoped presentation. Retail registers object-borne lights
- // regardless of static-vs-dynamic origin (insert_light 0x0054d1b0).
- // The light is placed at the spawn frame and does NOT follow a moving
- // light-bearer yet (register row AP-44) — fine for stationary fixtures,
- // which is the common case. _dats is non-null here (checked above).
- if (_lightingSink is not null
- && (entity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
- {
- var liteSetup = _dats.Get(entity.SourceGfxObjOrSetupId);
- if (liteSetup is not null && liteSetup.Lights.Count > 0)
- {
- var loaded = AcDream.Core.Lighting.LightInfoLoader.Load(
- liteSetup,
- ownerId: entity.Id,
- entityPosition: entity.Position,
- entityRotation: entity.Rotation,
- // A7 fix #2 (#176/#181): isDynamic is decided by whether the light
- // MOVES, not by weenie-vs-dat origin. Server-spawned FIXTURES
- // (lanterns, braziers) are stationary — they take retail's STATIC
- // curve (calc_point_light 0x0059c8b0: 1/d³ beyond 1 m, range×1.3,
- // per-channel colour clamp), not the D3D dynamic path (1/d,
- // range×1.5) the former #143 flag forced — which burned every
- // fixture ~10× hotter than retail at 3 m: the magenta wash that
- // zebra-striped the Facility Hub walls. Site-A lights are all
- // stationary today (AP-44: they don't follow bearers); genuinely
- // moving lights (projectiles, portal swirls) re-earn isDynamic
- // when they exist.
- isDynamic: false,
- cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility
- foreach (var ls in loaded)
- _lightingSink.RegisterOwnedLight(ls);
- }
- }
-
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
Id: entity.Id,
SourceId: entity.SourceGfxObjOrSetupId,
@@ -3895,9 +3939,7 @@ public sealed class GameWindow : IDisposable
// Snapshot per-part identity from the hydrated meshRefs so the
// tick can rebuild MeshRefs without redoing AnimPartChanges or
// texture-override resolution every frame.
- var template = new (uint, IReadOnlyDictionary?)[meshRefs.Count];
- for (int i = 0; i < meshRefs.Count; i++)
- template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
+ AnimatedPartTemplate[] template = animatedPartTemplate;
// Create an AnimationSequencer if we can load the MotionTable.
AcDream.Core.Physics.AnimationSequencer? sequencer = null;
@@ -3925,6 +3967,7 @@ public sealed class GameWindow : IDisposable
Framerate = idleCycle.Framerate,
Scale = scale,
PartTemplate = template,
+ PartAvailability = indexedPartAvailable,
CurrFrame = idleCycle.LowFrame,
Sequencer = sequencer,
};
@@ -3969,9 +4012,7 @@ public sealed class GameWindow : IDisposable
var sequencer = SpawnMotionInitializer.Create(
setup, mtable, _animLoader, spawn.MotionState);
- var template = new (uint, IReadOnlyDictionary?)[meshRefs.Count];
- for (int i = 0; i < meshRefs.Count; i++)
- template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
+ AnimatedPartTemplate[] template = animatedPartTemplate;
_animatedEntities[entity.Id] = new AnimatedEntity
{
@@ -3983,6 +4024,7 @@ public sealed class GameWindow : IDisposable
Framerate = 0f,
Scale = scale,
PartTemplate = template,
+ PartAvailability = indexedPartAvailable,
CurrFrame = 0,
Sequencer = sequencer,
};
@@ -4124,15 +4166,14 @@ public sealed class GameWindow : IDisposable
AcDream.Core.World.WorldEntity entity,
DatReaderWriter.DBObjs.Setup setup,
float scale,
- IReadOnlyList meshRefs)
+ IReadOnlyList partTemplate,
+ IReadOnlyList partAvailability)
{
animation.Entity = entity;
animation.Setup = setup;
animation.Scale = scale;
- var template = new (uint, IReadOnlyDictionary?)[meshRefs.Count];
- for (int i = 0; i < meshRefs.Count; i++)
- template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
- animation.PartTemplate = template;
+ animation.PartTemplate = partTemplate;
+ animation.PartAvailability = partAvailability;
}
///
@@ -4878,6 +4919,7 @@ public sealed class GameWindow : IDisposable
_translucencyFades.ClearEntity(existingEntity.Id); // #188
LeaveWorldLiveEntityRuntimeComponents(record);
+ _liveEntityLights?.Forget(existingEntity.Id);
}
///
@@ -4897,10 +4939,10 @@ public sealed class GameWindow : IDisposable
if (record.ServerGuid == _playerServerGuid)
_lastLocalPlayerShadow = null;
- // Object-borne lights are cell-scoped presentation. The owning Setup
- // and logical light capability remain on the live record; re-entry
- // registers the light in the new cell without duplicating it.
- _lightingSink?.UnregisterOwner(entity.Id);
+ // Pose-attached effects keep logical ownership while cell-less. A
+ // missing pose suppresses stale anchors; re-entry republishes the same
+ // WorldEntity frames without replaying create-time resources.
+ _effectPoses.Remove(entity.Id);
}
///
@@ -5625,6 +5667,8 @@ public sealed class GameWindow : IDisposable
{
if (!_liveEntities!.TryApplyState(parsed, out _)) return;
+ _liveEntityLights?.OnStateChanged(parsed.Guid);
+
if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return;
// L.2g slice 1c (2026-05-13): the server addresses entities by
@@ -6628,29 +6672,25 @@ public sealed class GameWindow : IDisposable
var key = new SkyPesKey(i, obj.PesObjectId, obj.IsPostScene);
seen.Add(key);
-
- if (_activeSkyPes.Contains(key) || _missingSkyPes.Contains(key))
- continue;
-
uint skyEntityId = SkyPesEntityId(key);
- _entityEffects?.RegisterSyntheticOwner(skyEntityId);
var renderPass = obj.IsPostScene
? AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene
: AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene;
_particleSink.SetEntityRenderPass(skyEntityId, renderPass);
var anchor = SkyPesAnchor(obj, cameraWorldPos);
var rotation = SkyPesRotation(obj, dayFraction);
- // Refresh anchor + rotation every frame so AttachLocal
- // (is_parent_local=1) particles track the camera. Retail
- // ParticleEmitter::UpdateParticles at 0x0051d2d4 reads the
- // live parent frame each tick; for sky-PES the parent IS
- // the camera. UpdateEntityAnchor is a no-op when no
- // emitters yet exist (script just spawned this frame).
- _particleSink.UpdateEntityAnchor(skyEntityId, anchor, rotation);
+ _effectPoses.Publish(
+ skyEntityId,
+ System.Numerics.Matrix4x4.CreateFromQuaternion(rotation)
+ * System.Numerics.Matrix4x4.CreateTranslation(anchor),
+ System.Array.Empty(),
+ cellId: 0u);
if (_activeSkyPes.Contains(key) || _missingSkyPes.Contains(key))
continue;
+ _entityEffects?.RegisterSyntheticOwner(skyEntityId);
+
if (_scriptRunner.Play(obj.PesObjectId, skyEntityId, anchor))
{
_activeSkyPes.Add(key);
@@ -6660,6 +6700,7 @@ public sealed class GameWindow : IDisposable
_missingSkyPes.Add(key);
_entityEffects?.UnregisterSyntheticOwner(skyEntityId);
_particleSink.ClearEntityRenderPass(skyEntityId);
+ _effectPoses.Remove(skyEntityId);
}
}
}
@@ -6673,6 +6714,7 @@ public sealed class GameWindow : IDisposable
_scriptRunner.StopAllForEntity(skyEntityId);
_entityEffects?.UnregisterSyntheticOwner(skyEntityId);
_particleSink.StopAllForEntity(skyEntityId, fadeOut: true);
+ _effectPoses.Remove(skyEntityId);
_activeSkyPes.Remove(key);
}
@@ -9110,6 +9152,7 @@ public sealed class GameWindow : IDisposable
if (_animatedEntities.Count > 0)
TickAnimations((float)deltaSeconds);
_equippedChildRenderer?.Tick();
+ _animationHookFrames?.Drain();
// #188 — advance translucency fades UNCONDITIONALLY (not gated on
// _animatedEntities.Count): a one-shot open-cycle animation can
@@ -9276,8 +9319,10 @@ public sealed class GameWindow : IDisposable
// debug-only and disabled for normal retail rendering.
if (_options.EnableSkyPesDebug)
UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell);
- _entityEffects?.RefreshLiveOwnerAnchors();
+ _entityEffects?.RefreshLiveOwnerPoses();
_scriptRunner?.Tick(_physicsScriptGameTime);
+ _particleSink?.RefreshAttachedEmitters();
+ _liveEntityLights?.Refresh();
_particleSystem?.Tick((float)deltaSeconds);
// Phase G.1/G.2: feed the sun, tick LightManager, build + upload
@@ -10369,33 +10414,13 @@ public sealed class GameWindow : IDisposable
};
}
- // Phase E.1: drain animation hooks (footstep sounds, attack
- // damage frames, particle spawns, part swaps, etc.) and fan
- // them out to registered subsystems via the hook router.
- // Mirrors ACE's PhysicsObj.add_anim_hook dispatch path.
- //
- // R2-Q4 queue-drain wiring (r2-port-plan.md §4, the G6 seam;
- // per-tick PLACEMENT provisional until R6 installs retail's
- // UpdateObjectInternal order): each drained AnimDone hook
- // advances the entity's MotionTableManager countdown
- // (retail AnimDoneHook::Execute 0x00526c20 → Hook_AnimDone
- // 0x0050fda0 → CPartArray::AnimationDone(1)), and UseTime
- // runs once per tick to sweep zero-tick queue entries
- // (retail call sites 0x00517d57/0x00517d67).
- var hooks = ae.Sequencer.ConsumePendingHooks();
- if (hooks.Count > 0)
- {
- System.Numerics.Vector3 worldPos = ae.Entity.Position;
- for (int hi = 0; hi < hooks.Count; hi++)
- {
- var hook = hooks[hi];
- if (hook is null) continue;
- _hookRouter.OnHook(ae.Entity.Id, worldPos, hook);
- if (hook is DatReaderWriter.Types.AnimationDoneHook)
- ae.Sequencer.Manager.AnimationDone(success: true);
- }
- }
- ae.Sequencer.Manager.UseTime();
+ // Capture hooks now, but deliver them only after every final
+ // part transform and equipped-child root has been published.
+ // This matches CPhysicsObj::UpdateObjectInternal followed by
+ // CPhysicsObj::UpdateChild (0x00512D50): a hand/weapon effect
+ // reads this frame's pose, never the previous frame's pose.
+ // AnimationDone/UseTime remain paired with the deferred drain.
+ _animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
}
else
{
@@ -10475,6 +10500,8 @@ public sealed class GameWindow : IDisposable
// consumer caches a stale reference or diffs list identity).
var newMeshRefs = ae.MeshRefsScratch;
newMeshRefs.Clear();
+ var effectPartPoses = ae.EffectPartPosesScratch;
+ effectPartPoses.Clear();
var scaleMat = ae.Scale == 1.0f
? System.Numerics.Matrix4x4.Identity
: System.Numerics.Matrix4x4.CreateScale(ae.Scale);
@@ -10532,7 +10559,7 @@ public sealed class GameWindow : IDisposable
? ae.Setup.DefaultScale[i]
: System.Numerics.Vector3.One;
- var partTransform =
+ var visualPartTransform =
System.Numerics.Matrix4x4.CreateScale(defaultScale) *
System.Numerics.Matrix4x4.CreateFromQuaternion(orientation) *
System.Numerics.Matrix4x4.CreateTranslation(origin);
@@ -10540,14 +10567,23 @@ public sealed class GameWindow : IDisposable
// Bake the entity's ObjScale on top, matching the hydration
// order (PartTransform * scaleMat) — see comment in OnLiveEntitySpawned.
if (ae.Scale != 1.0f)
- partTransform = partTransform * scaleMat;
+ visualPartTransform *= scaleMat;
+
+ // Retail CPartArray::UpdateParts (0x005190F0) scales only the
+ // frame origin. Setup DefaultScale is visual gfxobj_scale,
+ // not part-frame state (SetScaleInternal 0x00518A00).
+ var rigidPartTransform =
+ System.Numerics.Matrix4x4.CreateFromQuaternion(orientation) *
+ System.Numerics.Matrix4x4.CreateTranslation(origin * ae.Scale);
+ effectPartPoses.Add(rigidPartTransform);
var template = ae.PartTemplate[i];
- if (_options.HidePartIndex >= 0 && i == _options.HidePartIndex && partCount >= 10)
+ if (!template.IsDrawable
+ || (_options.HidePartIndex >= 0 && i == _options.HidePartIndex && partCount >= 10))
{
continue;
}
- newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, partTransform)
+ newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, visualPartTransform)
{
SurfaceOverrides = template.SurfaceOverrides,
});
@@ -10557,6 +10593,8 @@ public sealed class GameWindow : IDisposable
// property store) and keeps this line's shape identical to the
// pre-MP-Alloc code for anyone grepping the history.
ae.Entity.MeshRefs = newMeshRefs;
+ ae.Entity.SetIndexedPartPoses(effectPartPoses, ae.PartAvailability);
+ _effectPoses.Publish(ae.Entity, effectPartPoses, ae.PartAvailability);
}
}
@@ -14019,7 +14057,11 @@ public sealed class GameWindow : IDisposable
}
finally
{
+ _liveEntityLights?.Dispose();
+ _liveEntityLights = null;
_entityEffects?.ClearNetworkState();
+ _animationHookFrames?.Clear();
+ _effectPoses.Clear();
}
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_wbDrawDispatcher?.Dispose();
diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs
index d078e0c6..c21cbc72 100644
--- a/src/AcDream.App/Rendering/ParticleRenderer.cs
+++ b/src/AcDream.App/Rendering/ParticleRenderer.cs
@@ -198,6 +198,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
draws.Clear();
foreach (var (em, idx) in particles.EnumerateLive())
{
+ if (!em.PresentationVisible)
+ continue;
if (em.RenderPass != renderPass)
continue;
if (emitterFilter is not null && !emitterFilter(em))
diff --git a/src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs b/src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs
new file mode 100644
index 00000000..3323627b
--- /dev/null
+++ b/src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs
@@ -0,0 +1,89 @@
+using System.Numerics;
+using AcDream.Core.Physics;
+using AcDream.Core.Vfx;
+using DatReaderWriter.Types;
+
+namespace AcDream.App.Rendering.Vfx;
+
+///
+/// Defers animation-hook delivery until every entity and equipped child has
+/// published its final pose for the frame.
+///
+///
+/// Retail stages hooks during sequence advance, then the same object update
+/// commits the final part frame and runs CPhysicsObj::UpdateChild
+/// (0x00512D50) before particles advance or anything draws. Capturing
+/// here preserves that observable order and avoids firing a hand or weapon
+/// effect against the previous animation frame.
+///
+public sealed class AnimationHookFrameQueue
+{
+ private readonly AnimationHookRouter _router;
+ private readonly IEntityEffectPoseSource _poses;
+ private readonly List _entries = new();
+
+ public AnimationHookFrameQueue(
+ AnimationHookRouter router,
+ IEntityEffectPoseSource poses)
+ {
+ _router = router ?? throw new ArgumentNullException(nameof(router));
+ _poses = poses ?? throw new ArgumentNullException(nameof(poses));
+ }
+
+ public int Count => _entries.Count;
+
+ public void Capture(uint ownerLocalId, AnimationSequencer sequencer)
+ {
+ ArgumentNullException.ThrowIfNull(sequencer);
+ Capture(ownerLocalId, sequencer, sequencer.ConsumePendingHooks());
+ }
+
+ internal void Capture(
+ uint ownerLocalId,
+ AnimationSequencer sequencer,
+ IReadOnlyList hooks)
+ {
+ ArgumentNullException.ThrowIfNull(sequencer);
+ ArgumentNullException.ThrowIfNull(hooks);
+ _entries.Add(new Entry(
+ ownerLocalId,
+ sequencer,
+ hooks));
+ }
+
+ public void Drain()
+ {
+ for (int i = 0; i < _entries.Count; i++)
+ {
+ Entry entry = _entries[i];
+ bool hasRootPose = _poses.TryGetRootPose(
+ entry.OwnerLocalId,
+ out Matrix4x4 rootWorld);
+ Vector3 worldPosition = hasRootPose
+ ? rootWorld.Translation
+ : Vector3.Zero;
+ for (int hi = 0; hi < entry.Hooks.Count; hi++)
+ {
+ AnimationHook? hook = entry.Hooks[hi];
+ if (hook is null)
+ continue;
+ if (hasRootPose)
+ _router.OnHook(entry.OwnerLocalId, worldPosition, hook);
+ if (hook is AnimationDoneHook)
+ entry.Sequencer.Manager.AnimationDone(success: true);
+ }
+
+ // Retail sweeps zero-tick motion entries even on frames without a
+ // hook. It remains paired with every sequencer advanced this frame.
+ entry.Sequencer.Manager.UseTime();
+ }
+ _entries.Clear();
+ }
+
+ public void Clear() => _entries.Clear();
+
+ private readonly record struct Entry(
+ uint OwnerLocalId,
+ AnimationSequencer Sequencer,
+ IReadOnlyList Hooks);
+}
diff --git a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs
index ccedb73a..00219dae 100644
--- a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs
+++ b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs
@@ -27,6 +27,7 @@ public sealed class EntityEffectController : IAnimationHookSink
private readonly LiveEntityRuntime _liveEntities;
private readonly PhysicsScriptRunner _runner;
private readonly PhysicsScriptTableResolver _tables;
+ private readonly EntityEffectPoseRegistry _poses;
private readonly Func _childAtPart;
private readonly Func _parentOfAttachedChild;
private readonly Action _ownerUnregistered;
@@ -42,6 +43,7 @@ public sealed class EntityEffectController : IAnimationHookSink
LiveEntityRuntime liveEntities,
PhysicsScriptRunner runner,
PhysicsScriptTableResolver tables,
+ EntityEffectPoseRegistry poses,
Func? childAtPart = null,
Func? parentOfAttachedChild = null,
Action? ownerUnregistered = null,
@@ -50,6 +52,7 @@ public sealed class EntityEffectController : IAnimationHookSink
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_runner = runner ?? throw new ArgumentNullException(nameof(runner));
_tables = tables ?? throw new ArgumentNullException(nameof(tables));
+ _poses = poses ?? throw new ArgumentNullException(nameof(poses));
_childAtPart = childAtPart ?? ((_, _) => null);
_parentOfAttachedChild = parentOfAttachedChild ?? (_ => null);
_ownerUnregistered = ownerUnregistered ?? (_ => { });
@@ -201,8 +204,8 @@ public sealed class EntityEffectController : IAnimationHookSink
_pendingByServerGuid.Clear();
}
- /// Refreshes every live root anchor after animation/movement.
- public void RefreshLiveOwnerAnchors()
+ /// Refreshes every live root after animation/movement.
+ public void RefreshLiveOwnerPoses()
{
foreach (uint serverGuid in _readyServerGuids.ToArray())
{
@@ -353,7 +356,18 @@ public sealed class EntityEffectController : IAnimationHookSink
&& record.WorldEntity is { } entity
&& entity.Id == localId)
{
- _runner.SetOwnerAnchor(localId, entity.Position);
+ // Top-level roots follow the WorldEntity directly. Attached roots
+ // were already composed through the parent's current part by
+ // EquippedChildRenderController; overwriting one with the child's
+ // bookkeeping Position would collapse a weapon effect to the
+ // parent's origin.
+ if (record.ProjectionKind is LiveEntityProjectionKind.World)
+ _poses.UpdateRoot(entity);
+
+ Vector3 anchor = _poses.TryGetRootPose(localId, out Matrix4x4 rootWorld)
+ ? rootWorld.Translation
+ : entity.Position;
+ _runner.SetOwnerAnchor(localId, anchor);
}
}
diff --git a/src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs b/src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs
new file mode 100644
index 00000000..7b8d7252
--- /dev/null
+++ b/src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs
@@ -0,0 +1,216 @@
+using System.Numerics;
+using AcDream.Core.Vfx;
+using AcDream.Core.World;
+
+namespace AcDream.App.Rendering.Vfx;
+
+///
+/// Update-thread registry of final root and indexed part poses for particles,
+/// lights, and other DAT-driven entity effects.
+///
+///
+/// Retail composes particle anchors from the current physics-object/part frame
+/// in Particle::Init (0x0051C930) and refreshes parent-local
+/// particles from those frames in ParticleEmitter::UpdateParticles
+/// (0x0051D180). This registry is the modern, read-only seam exposing
+/// those same final frames without coupling Core effects to the renderer.
+///
+public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityEffectCellSource
+{
+ private sealed class PoseRecord
+ {
+ public Matrix4x4 RootWorld;
+ public Matrix4x4[] PartLocal = Array.Empty();
+ public bool[] PartAvailable = Array.Empty();
+ public uint CellId;
+ }
+
+ private readonly Dictionary _poses = new();
+
+ public int Count => _poses.Count;
+
+ public void Publish(WorldEntity entity, IReadOnlyList partLocal)
+ {
+ Publish(entity, partLocal, availability: null);
+ }
+
+ public void Publish(
+ WorldEntity entity,
+ IReadOnlyList partLocal,
+ IReadOnlyList? availability)
+ {
+ ArgumentNullException.ThrowIfNull(entity);
+ Publish(
+ entity.Id,
+ Matrix4x4.CreateFromQuaternion(entity.Rotation)
+ * Matrix4x4.CreateTranslation(entity.Position),
+ partLocal,
+ entity.ParentCellId ?? 0u,
+ availability);
+ }
+
+ ///
+ /// Publish the exact indexed part transforms already installed on an
+ /// entity. Appearance replacement uses this overload so a PhysicsScript
+ /// delivered later in the same update observes the new parts immediately.
+ ///
+ public void PublishMeshRefs(WorldEntity entity)
+ {
+ ArgumentNullException.ThrowIfNull(entity);
+ Matrix4x4 rootWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation)
+ * Matrix4x4.CreateTranslation(entity.Position);
+
+ if (!_poses.TryGetValue(entity.Id, out PoseRecord? record))
+ {
+ record = new PoseRecord();
+ _poses.Add(entity.Id, record);
+ }
+
+ record.RootWorld = rootWorld;
+ record.CellId = entity.ParentCellId ?? 0u;
+ if (entity.IndexedPartTransforms.Count > 0)
+ {
+ CopyParts(
+ record,
+ entity.IndexedPartTransforms,
+ entity.IndexedPartAvailable);
+ }
+ else
+ {
+ if (record.PartLocal.Length != entity.MeshRefs.Count)
+ record.PartLocal = new Matrix4x4[entity.MeshRefs.Count];
+ if (record.PartAvailable.Length != entity.MeshRefs.Count)
+ record.PartAvailable = new bool[entity.MeshRefs.Count];
+ for (int i = 0; i < entity.MeshRefs.Count; i++)
+ {
+ record.PartLocal[i] = entity.MeshRefs[i].PartTransform;
+ record.PartAvailable[i] = true;
+ }
+ }
+ }
+
+ public void Publish(
+ uint localEntityId,
+ Matrix4x4 rootWorld,
+ IReadOnlyList partLocal,
+ uint cellId,
+ IReadOnlyList? availability = null)
+ {
+ if (localEntityId == 0)
+ return;
+ ArgumentNullException.ThrowIfNull(partLocal);
+
+ if (!_poses.TryGetValue(localEntityId, out PoseRecord? record))
+ {
+ record = new PoseRecord();
+ _poses.Add(localEntityId, record);
+ }
+
+ record.RootWorld = rootWorld;
+ record.CellId = cellId;
+ CopyParts(record, partLocal, availability);
+ }
+
+ /// Refresh only the moving root while retaining current part poses.
+ public bool UpdateRoot(WorldEntity entity)
+ {
+ ArgumentNullException.ThrowIfNull(entity);
+ if (!_poses.TryGetValue(entity.Id, out PoseRecord? record))
+ return false;
+ record.RootWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation)
+ * Matrix4x4.CreateTranslation(entity.Position);
+ record.CellId = entity.ParentCellId ?? 0u;
+ return true;
+ }
+
+ public bool Remove(uint localEntityId) => _poses.Remove(localEntityId);
+
+ public void Clear() => _poses.Clear();
+
+ public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
+ {
+ if (_poses.TryGetValue(localEntityId, out PoseRecord? record))
+ {
+ rootWorld = record.RootWorld;
+ return true;
+ }
+ rootWorld = default;
+ return false;
+ }
+
+ public bool TryGetPartPose(uint localEntityId, int partIndex, out Matrix4x4 partLocal)
+ {
+ if (partIndex >= 0
+ && _poses.TryGetValue(localEntityId, out PoseRecord? record)
+ && partIndex < record.PartLocal.Length
+ && record.PartAvailable[partIndex])
+ {
+ partLocal = record.PartLocal[partIndex];
+ return true;
+ }
+ partLocal = default;
+ return false;
+ }
+
+ ///
+ /// Borrow the current indexed part-pose snapshot for synchronous
+ /// update-thread composition. Callers must not retain or mutate it.
+ ///
+ public bool TryGetPartPoses(
+ uint localEntityId,
+ out IReadOnlyList partLocal)
+ {
+ if (_poses.TryGetValue(localEntityId, out PoseRecord? record))
+ {
+ partLocal = record.PartLocal;
+ return true;
+ }
+ partLocal = Array.Empty();
+ return false;
+ }
+
+ public bool TryGetPartPoseSnapshot(
+ uint localEntityId,
+ out IReadOnlyList partLocal,
+ out IReadOnlyList availability)
+ {
+ if (_poses.TryGetValue(localEntityId, out PoseRecord? record))
+ {
+ partLocal = record.PartLocal;
+ availability = record.PartAvailable;
+ return true;
+ }
+ partLocal = Array.Empty();
+ availability = Array.Empty();
+ return false;
+ }
+
+ public bool TryGetCellId(uint localEntityId, out uint cellId)
+ {
+ if (_poses.TryGetValue(localEntityId, out PoseRecord? record))
+ {
+ cellId = record.CellId;
+ return true;
+ }
+ cellId = 0;
+ return false;
+ }
+
+ private static void CopyParts(
+ PoseRecord record,
+ IReadOnlyList partLocal,
+ IReadOnlyList? availability)
+ {
+ if (availability is not null && availability.Count != partLocal.Count)
+ throw new ArgumentException("Part pose and availability counts must match.");
+ if (record.PartLocal.Length != partLocal.Count)
+ record.PartLocal = new Matrix4x4[partLocal.Count];
+ if (record.PartAvailable.Length != partLocal.Count)
+ record.PartAvailable = new bool[partLocal.Count];
+ for (int i = 0; i < partLocal.Count; i++)
+ {
+ record.PartLocal[i] = partLocal[i];
+ record.PartAvailable[i] = availability is null || availability[i];
+ }
+ }
+}
diff --git a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs
index 420b1ce3..a1138a4b 100644
--- a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs
+++ b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs
@@ -1,5 +1,3 @@
-using System;
-using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
@@ -7,97 +5,57 @@ using AcDream.Core.World;
namespace AcDream.App.Rendering.Vfx;
///
-/// What the activator's resolver returns when an entity's Setup carries
-/// a DefaultScript. Bundles the script id with the per-part
-/// transforms baked from Setup.PlacementFrames so a single dat
-/// lookup yields both pieces of state. The activator pushes the part
-/// transforms into
-/// before calling , which closes
-/// the part-anchor pipeline introduced for issue #56.
+/// Setup script/profile data resolved once when a logical effect owner is
+/// registered. Part transforms are indexed and root-local.
///
public sealed record ScriptActivationInfo(
uint ScriptId,
IReadOnlyList PartTransforms,
- EntityEffectProfile? EffectProfile = null);
+ EntityEffectProfile? EffectProfile = null,
+ IReadOnlyList? PartAvailability = null);
///
-/// Fires Setup.DefaultScript through
-/// when a enters the world, so static objects
-/// (portals, chimneys, fireplaces, EnvCell decorations, building details)
-/// emit their retail-faithful persistent particle effects automatically.
-/// Stops the scripts and live emitters when the entity despawns.
-///
-///
-/// Handles both server-spawned and dat-hydrated entities. Live owners use the
-/// canonical local entity.Id. Dat-static IDs occupy disjoint, globally
-/// unique ranges for interiors, scenery, and landblock stabs. The C.1.5a guard that early-returned for
-/// ServerGuid == 0 was relaxed in C.1.5b so EnvCell static objects
-/// (which have no server guid because they come from the dat file, not
-/// the network) also fire their DefaultScript.
-///
-///
-///
-/// For live objects this is invoked by LiveEntityRuntime logical
-/// registration/teardown. GpuWorldState invokes it only for dat-static
-/// landblock load, promotion, demotion, and unload paths.
-///
-///
-///
-/// Retail oracle: play_script_internal(setup.DefaultScript) is what
-/// retail's CPhysicsObj invokes at object load (see Phase C.1 plan
-/// §C.1 and memory/project_sky_pes_port.md). C.1 already shipped the
-/// runner; this class adds the missing fire-on-spawn call site.
-///
+/// Owns create-time Setup script activation and its symmetric teardown for
+/// live and DAT-static entities.
///
+///
+/// Setup DefaultScript is a direct PES played during Setup
+/// initialization. Live registration invokes this class once per logical
+/// generation; spatial rebucketing never replays it.
+///
public sealed class EntityScriptActivator
{
private readonly PhysicsScriptRunner _scriptRunner;
private readonly ParticleHookSink _particleSink;
+ private readonly EntityEffectPoseRegistry _poses;
private readonly Func _resolver;
private readonly Action? _registerDatStaticEffectOwner;
private readonly Action? _unregisterDatStaticEffectOwner;
private readonly Dictionary _activeStaticOwners = new();
- /// Per-owner retail FIFO that schedules every
- /// hook at its absolute script StartTime.
- /// Already-shipped hook sink from C.1. The
- /// activator pushes per-entity rotation + part transforms here, and
- /// calls to drop
- /// per-entity emitter handles on despawn.
- /// Returns
- /// with the entity's
- /// Setup.DefaultScript.DataId and per-part transforms (via
- /// SetupPartTransforms.Compute), or null on dat miss /
- /// throw / missing DefaultScript. Production lambda hits
- /// DatCollection; tests pass a hand-rolled stub.
public EntityScriptActivator(
PhysicsScriptRunner scriptRunner,
ParticleHookSink particleSink,
+ EntityEffectPoseRegistry poses,
Func resolver,
Action? registerDatStaticEffectOwner = null,
Action? unregisterDatStaticEffectOwner = null)
{
- ArgumentNullException.ThrowIfNull(scriptRunner);
- ArgumentNullException.ThrowIfNull(particleSink);
- ArgumentNullException.ThrowIfNull(resolver);
- _scriptRunner = scriptRunner;
- _particleSink = particleSink;
- _resolver = resolver;
+ _scriptRunner = scriptRunner ?? throw new ArgumentNullException(nameof(scriptRunner));
+ _particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink));
+ _poses = poses ?? throw new ArgumentNullException(nameof(poses));
+ _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
_registerDatStaticEffectOwner = registerDatStaticEffectOwner;
_unregisterDatStaticEffectOwner = unregisterDatStaticEffectOwner;
}
- ///
- /// Resolve the entity's Setup.DefaultScript and fire it through
- /// the script runner under its canonical runtime identity.
- /// No-op if the entity has no DefaultScript (resolver returns null
- /// or zero-script).
- ///
public void OnCreate(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
uint key = entity.Id;
- if (key == 0) return; // malformed entity
+ if (key == 0)
+ return;
+
if (entity.ServerGuid == 0
&& _activeStaticOwners.TryGetValue(key, out WorldEntity? existing))
{
@@ -107,56 +65,43 @@ public sealed class EntityScriptActivator
$"Dat-static WorldEntity id 0x{key:X8} is already active; static allocators must be globally unique.");
}
- var info = _resolver(entity);
- if (info is null) return;
+ ScriptActivationInfo? info = _resolver(entity);
+ if (info is null)
+ 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. 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);
-
- // 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);
+ // Particle::Init (0x0051C930) samples the current root/part frames.
+ // Publish before the Setup default script can execute; effects never
+ // fall back to the camera or a cached spawn anchor.
+ _poses.Publish(entity, info.PartTransforms, info.PartAvailability);
if (entity.ServerGuid == 0)
_activeStaticOwners.Add(key, entity);
if (entity.ServerGuid == 0 && info.EffectProfile is { } profile)
- _registerDatStaticEffectOwner?.Invoke(
- key,
- entity,
- profile);
+ _registerDatStaticEffectOwner?.Invoke(key, entity, profile);
if (info.ScriptId != 0)
_scriptRunner.Play(info.ScriptId, key, entity.Position);
}
- ///
- /// Stop every script instance the runner is tracking for this key, and
- /// kill every live emitter the sink has attributed to the runtime effect
- /// owner. Idempotent for unknown entities.
- ///
public void OnRemove(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
uint key = entity.Id;
- if (key == 0) return;
+ if (key == 0)
+ return;
+
if (entity.ServerGuid == 0
&& (!_activeStaticOwners.TryGetValue(key, out WorldEntity? existing)
|| !ReferenceEquals(existing, entity)))
{
return;
}
+
_scriptRunner.StopAllForEntity(key);
_particleSink.StopAllForEntity(key, fadeOut: false);
+ _poses.Remove(key);
if (entity.ServerGuid == 0 && _activeStaticOwners.Remove(key))
_unregisterDatStaticEffectOwner?.Invoke(key);
}
-
}
diff --git a/src/AcDream.App/Rendering/Vfx/IndexedSetupPartPoseBuilder.cs b/src/AcDream.App/Rendering/Vfx/IndexedSetupPartPoseBuilder.cs
new file mode 100644
index 00000000..6e403ac8
--- /dev/null
+++ b/src/AcDream.App/Rendering/Vfx/IndexedSetupPartPoseBuilder.cs
@@ -0,0 +1,66 @@
+using System.Numerics;
+using AcDream.Core.Meshing;
+using AcDream.Core.World;
+using DatReaderWriter.DBObjs;
+
+namespace AcDream.App.Rendering.Vfx;
+
+///
+/// Reconstructs retail's stable CPartArray indices from a drawable-only
+/// MeshRef projection. Rigid part frames come from Setup data while missing
+/// DAT parts keep an unavailable placeholder at their stable Setup index.
+/// Hydration omits by GfxObj DID and otherwise preserves Setup order, so
+/// duplicate DIDs are all present or all absent; first-unconsumed matching is
+/// lossless under that invariant.
+///
+internal static class IndexedSetupPartPoseBuilder
+{
+ public static (Matrix4x4[] Poses, bool[] Available) Build(
+ Setup setup,
+ WorldEntity entity)
+ {
+ ArgumentNullException.ThrowIfNull(setup);
+ ArgumentNullException.ThrowIfNull(entity);
+
+ int partCount = setup.Parts.Count;
+ var poses = new Matrix4x4[partCount];
+ IReadOnlyList defaults = SetupPartTransforms.Compute(
+ setup,
+ objectScale: entity.Scale);
+ for (int i = 0; i < partCount; i++)
+ poses[i] = i < defaults.Count ? defaults[i] : Matrix4x4.Identity;
+
+ var expectedGfx = new uint[partCount];
+ for (int i = 0; i < partCount; i++)
+ expectedGfx[i] = (uint)setup.Parts[i];
+ for (int i = 0; i < entity.PartOverrides.Count; i++)
+ {
+ PartOverride replacement = entity.PartOverrides[i];
+ if (replacement.PartIndex < expectedGfx.Length)
+ expectedGfx[replacement.PartIndex] = replacement.GfxObjId;
+ }
+
+ var available = new bool[partCount];
+ var consumed = new bool[entity.MeshRefs.Count];
+ for (int partIndex = 0; partIndex < partCount; partIndex++)
+ {
+ for (int drawableIndex = 0; drawableIndex < entity.MeshRefs.Count; drawableIndex++)
+ {
+ if (consumed[drawableIndex]
+ || entity.MeshRefs[drawableIndex].GfxObjId != expectedGfx[partIndex])
+ {
+ continue;
+ }
+
+ consumed[drawableIndex] = true;
+ available[partIndex] = true;
+ // The drawable transform carries DefaultScale. Retail's
+ // attachment/effect frame is the rigid Setup frame computed
+ // above; matching only establishes which stable slot exists.
+ break;
+ }
+ }
+
+ return (poses, available);
+ }
+}
diff --git a/src/AcDream.App/Rendering/Vfx/LiveEntityLightController.cs b/src/AcDream.App/Rendering/Vfx/LiveEntityLightController.cs
new file mode 100644
index 00000000..9c0c4a76
--- /dev/null
+++ b/src/AcDream.App/Rendering/Vfx/LiveEntityLightController.cs
@@ -0,0 +1,176 @@
+using AcDream.App.World;
+using AcDream.Core.Lighting;
+using AcDream.Core.Physics;
+using DatReaderWriter.DBObjs;
+
+namespace AcDream.App.Rendering.Vfx;
+
+///
+/// Projects Setup lights for live top-level and attached entities, while the
+/// shared pose registry supplies their current root on each update frame.
+///
+///
+/// Retail CPartArray::SetFrame (0x00519310) calls
+/// LIGHTLIST::set_frame (0x00517C60) whenever the owning physics
+/// object's frame changes. Logical ownership remains in
+/// ; leaving the world removes only this
+/// cell-scoped presentation and re-entry registers it again.
+///
+public sealed class LiveEntityLightController : IDisposable
+{
+ private readonly LiveEntityRuntime _liveEntities;
+ private readonly EntityEffectPoseRegistry _poses;
+ private readonly LightingHookSink _lighting;
+ private readonly Func _loadSetup;
+ private readonly Dictionary _serverGuidByOwner = new();
+ private readonly HashSet _presentOwners = new();
+
+ public LiveEntityLightController(
+ LiveEntityRuntime liveEntities,
+ EntityEffectPoseRegistry poses,
+ LightingHookSink lighting,
+ Func loadSetup)
+ {
+ _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
+ _poses = poses ?? throw new ArgumentNullException(nameof(poses));
+ _lighting = lighting ?? throw new ArgumentNullException(nameof(lighting));
+ _loadSetup = loadSetup ?? throw new ArgumentNullException(nameof(loadSetup));
+ _lighting.OwnerLightingChanged += OnOwnerLightingChanged;
+ _liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
+ }
+
+ public bool Register(uint serverGuid)
+ {
+ if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
+ || record.WorldEntity is not { } entity
+ || !record.IsSpatiallyProjected
+ || !record.IsSpatiallyVisible)
+ {
+ return false;
+ }
+
+ _serverGuidByOwner[entity.Id] = serverGuid;
+ _presentOwners.Add(entity.Id);
+ _lighting.UnregisterOwner(entity.Id, forgetState: false);
+ _lighting.InitializeOwnerLighting(
+ entity.Id,
+ (record.FinalPhysicsState & PhysicsStateFlags.Lighting) != 0);
+ if ((entity.SourceGfxObjOrSetupId & 0xFF000000u) != 0x02000000u)
+ return false;
+
+ if (record.ProjectionKind is LiveEntityProjectionKind.World)
+ {
+ if (!_poses.UpdateRoot(entity))
+ _poses.PublishMeshRefs(entity);
+ }
+
+ Setup? setup = _loadSetup(entity.SourceGfxObjOrSetupId);
+ if (setup is null
+ || setup.Lights.Count == 0
+ || !_lighting.IsOwnerLightingEnabled(entity.Id))
+ return false;
+
+ bool isDynamic = (record.FinalPhysicsState & PhysicsStateFlags.Static) == 0;
+ IReadOnlyList loaded = LightInfoLoader.Load(
+ setup,
+ entity.Id,
+ entity.Position,
+ entity.Rotation,
+ isDynamic,
+ entity.ParentCellId ?? 0u,
+ tracksOwnerPose: true);
+ for (int i = 0; i < loaded.Count; i++)
+ _lighting.RegisterOwnedLight(loaded[i]);
+ return loaded.Count > 0;
+ }
+
+ /// Withdraw cell-scoped presentation but retain logical light state.
+ public void Unregister(uint ownerLocalId)
+ {
+ _presentOwners.Remove(ownerLocalId);
+ _lighting.UnregisterOwner(ownerLocalId, forgetState: false);
+ }
+
+ /// Apply a fresh authoritative PhysicsState Lighting transition.
+ public void OnStateChanged(uint serverGuid)
+ {
+ if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
+ || record.WorldEntity is not { } entity)
+ {
+ return;
+ }
+ _lighting.SetOwnerLighting(
+ entity.Id,
+ (record.FinalPhysicsState & PhysicsStateFlags.Lighting) != 0);
+ }
+
+ /// End logical ownership after leave-world presentation teardown.
+ public void Forget(uint ownerLocalId)
+ {
+ _presentOwners.Remove(ownerLocalId);
+ _serverGuidByOwner.Remove(ownerLocalId);
+ _lighting.UnregisterOwner(ownerLocalId);
+ }
+
+ public void Refresh() => _lighting.RefreshAttachedLights();
+
+ ///
+ /// Attached projection visibility can become true before its holding-part
+ /// composition is published. The attachment owner calls this barrier only
+ /// after the composed child root is current.
+ ///
+ public void OnAttachedPoseReady(uint serverGuid)
+ {
+ if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
+ || record.ProjectionKind is not LiveEntityProjectionKind.Attached
+ || record.WorldEntity is not { } entity
+ || _presentOwners.Contains(entity.Id))
+ {
+ return;
+ }
+ Register(serverGuid);
+ }
+
+ private void OnOwnerLightingChanged(uint ownerLocalId, bool enabled)
+ {
+ if (!_presentOwners.Contains(ownerLocalId)
+ || !_serverGuidByOwner.TryGetValue(ownerLocalId, out uint serverGuid))
+ {
+ return;
+ }
+
+ if (!enabled)
+ {
+ _lighting.UnregisterOwner(ownerLocalId, forgetState: false);
+ return;
+ }
+
+ Register(serverGuid);
+ }
+
+ public void Dispose()
+ {
+ _lighting.OwnerLightingChanged -= OnOwnerLightingChanged;
+ _liveEntities.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged;
+ foreach (uint ownerId in _serverGuidByOwner.Keys.ToArray())
+ Forget(ownerId);
+ }
+
+ private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
+ {
+ if (record.WorldEntity is not { } entity)
+ return;
+ if (record.ProjectionKind is LiveEntityProjectionKind.Attached)
+ {
+ // The true edge precedes attachment pose publication. False is
+ // safe and must immediately withdraw the cell-scoped light.
+ if (!visible)
+ Unregister(entity.Id);
+ return;
+ }
+ if (visible)
+ Register(record.ServerGuid);
+ else
+ Unregister(entity.Id);
+ }
+}
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
index f1ede5bf..45fd9bf9 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
@@ -1311,7 +1311,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// TransparentPartHook.PartIndex addresses — retail's CPartArray
// numbers parts by their ordinal position in the Setup's own
// part list (SetupPartTransforms.Compute is the other verified
- // consumer of this exact indexing: one Matrix4x4 per
+ // consumer of this exact indexing: one rigid pose per
// Setup.Parts[i]). NOT the outer per-MeshRef loop index — a
// MeshRef is acdream's own decomposition of top-level
// attachments (weapon/shield/etc), a different concept.
diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs
index 7fa62ff2..8b5e5b4e 100644
--- a/src/AcDream.App/World/LiveEntityRuntime.cs
+++ b/src/AcDream.App/World/LiveEntityRuntime.cs
@@ -148,6 +148,7 @@ public sealed class LiveEntityRuntime
private readonly Dictionary _guidByLocalId = new();
private readonly Dictionary _animationsByLocalId = new();
private readonly Dictionary _remoteMotionByGuid = new();
+ private uint _rebucketingGuid;
private uint _nextLocalEntityId;
public LiveEntityRuntime(
@@ -191,6 +192,12 @@ public sealed class LiveEntityRuntime
public IReadOnlyDictionary RemoteMotionRuntimes => _remoteMotionByGuid;
public ParentAttachmentState ParentAttachments { get; } = new();
+ ///
+ /// Raised after a materialized projection enters or leaves the currently
+ /// loaded spatial view. Logical resources remain owned by the record.
+ ///
+ public event Action? ProjectionVisibilityChanged;
+
public LiveEntityRegistrationResult RegisterLiveEntity(WorldSession.EntitySpawn incoming)
{
InboundCreateResult result = _inbound.AcceptCreate(incoming);
@@ -311,7 +318,21 @@ public sealed class LiveEntityRuntime
|| record.WorldEntity is not { } entity)
return false;
- _spatial.RebucketLiveEntity(entity, spatialCellOrLandblockId);
+ bool wasProjected = record.IsSpatiallyProjected;
+ bool wasVisible = record.IsSpatiallyVisible;
+ // GpuWorldState reports an intermediate false/true pair while moving
+ // between two loaded buckets. Suppress those implementation details
+ // and publish only the final logical visibility edge.
+ record.IsSpatiallyProjected = true;
+ _rebucketingGuid = serverGuid;
+ try
+ {
+ _spatial.RebucketLiveEntity(entity, spatialCellOrLandblockId);
+ }
+ finally
+ {
+ _rebucketingGuid = 0;
+ }
bool visible = _spatial.IsLiveEntityVisible(serverGuid);
record.IsSpatiallyVisible = visible;
if (record.ProjectionKind is LiveEntityProjectionKind.World)
@@ -327,7 +348,8 @@ public sealed class LiveEntityRuntime
record.CanonicalLandblockId = spatialCellOrLandblockId == 0
? 0u
: (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu;
- record.IsSpatiallyProjected = true;
+ if (!wasProjected || wasVisible != visible)
+ ProjectionVisibilityChanged?.Invoke(record, visible);
return true;
}
@@ -708,6 +730,8 @@ public sealed class LiveEntityRuntime
_visibleWorldEntitiesByGuid[serverGuid] = entity;
else
_visibleWorldEntitiesByGuid.Remove(serverGuid);
+ if (_rebucketingGuid != serverGuid)
+ ProjectionVisibilityChanged?.Invoke(record, visible);
}
private void TearDownRecord(LiveEntityRecord record)
diff --git a/src/AcDream.Core/Lighting/LightInfoLoader.cs b/src/AcDream.Core/Lighting/LightInfoLoader.cs
index b3e9d4a8..32f4a5d8 100644
--- a/src/AcDream.Core/Lighting/LightInfoLoader.cs
+++ b/src/AcDream.Core/Lighting/LightInfoLoader.cs
@@ -13,7 +13,7 @@ namespace AcDream.Core.Lighting;
///
/// Retail fields (r13 §1):
///
-/// - ViewSpaceLocation: local Frame relative to the owning part.
+/// - ViewSpaceLocation: local Frame relative to the owning PartArray root.
/// - Color: packed ARGB. Alpha is ignored; channels go through /255.
/// - Intensity: multiplies color for final diffuse.
/// - Falloff: world metres — acts as the hard cutoff.
@@ -24,13 +24,10 @@ namespace AcDream.Core.Lighting;
public static class LightInfoLoader
{
///
- /// Extract all lights from a Setup, positioned in the entity's
- /// world frame (via +
- /// ). The dat's per-light Frame is
- /// treated as a local offset relative to the entity root; acdream
- /// doesn't yet transform through the animated part chain (retail's
- /// hand-held torches), so held lights render at the entity root
- /// until the animation hook layer handles per-part placement.
+ /// Extract all lights from a Setup and retain each light's complete local
+ /// frame. Retail Setup lights belong to the PartArray root rather than an
+ /// indexed mesh part; moving held objects therefore follow their child
+ /// physics-object root after CPhysicsObj::UpdateChild composes it.
///
public static IReadOnlyList Load(
Setup setup,
@@ -38,7 +35,8 @@ public static class LightInfoLoader
Vector3 entityPosition,
Quaternion entityRotation,
bool isDynamic = false,
- uint cellId = 0)
+ uint cellId = 0,
+ bool tracksOwnerPose = false)
{
var results = new List();
if (setup?.Lights is null || setup.Lights.Count == 0) return results;
@@ -64,12 +62,15 @@ public static class LightInfoLoader
info.ViewSpaceLocation.Orientation.W);
}
- // Transform local offset into world space via the entity's
- // rotation + translation. No per-part chain yet — held
- // torches track the entity's root for now.
- Vector3 worldPos = entityPosition + Vector3.Transform(localOffset, entityRotation);
- Quaternion worldRot = entityRotation * localRot;
- Vector3 forward = Vector3.Transform(Vector3.UnitY, worldRot);
+ Matrix4x4 localPose = Matrix4x4.CreateFromQuaternion(localRot)
+ * Matrix4x4.CreateTranslation(localOffset);
+ Matrix4x4 rootWorld = Matrix4x4.CreateFromQuaternion(entityRotation)
+ * Matrix4x4.CreateTranslation(entityPosition);
+ Matrix4x4 lightWorld = localPose * rootWorld;
+ Vector3 worldPos = lightWorld.Translation;
+ Vector3 forward = Vector3.TransformNormal(Vector3.UnitY, lightWorld);
+ if (forward.LengthSquared() > 1e-8f)
+ forward = Vector3.Normalize(forward);
var light = new LightSource
{
@@ -93,6 +94,8 @@ public static class LightInfoLoader
CellId = cellId, // owning cell — scopes the per-frame visible-cell pool (A7 #176/#177)
IsLit = true,
IsDynamic = isDynamic,
+ TracksOwnerPose = tracksOwnerPose,
+ LocalPose = localPose,
};
results.Add(light);
}
diff --git a/src/AcDream.Core/Lighting/LightSource.cs b/src/AcDream.Core/Lighting/LightSource.cs
index 55676e8a..ae3e8871 100644
--- a/src/AcDream.Core/Lighting/LightSource.cs
+++ b/src/AcDream.Core/Lighting/LightSource.cs
@@ -55,6 +55,20 @@ public sealed class LightSource
public bool IsLit = true; // SetLightHook latch
public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5);
// false = static dat-baked bake (1/d³, range×1.3)
+ ///
+ /// True when this light belongs to a live physics object whose root can
+ /// move. DAT-static lights keep their hydration-time world frame and are
+ /// deliberately excluded from the per-frame pose refresh.
+ ///
+ public bool TracksOwnerPose;
+
+ ///
+ /// Setup-local light frame retained from LIGHTINFO::view_space_location.
+ /// Object-borne lights compose it with their owner's current root each
+ /// frame, matching CPartArray::SetFrame (0x00519310) calling
+ /// LIGHTLIST::set_frame (0x00517C60).
+ ///
+ public Matrix4x4 LocalPose = Matrix4x4.Identity;
// Cached each frame by LightManager.
public float DistSq;
diff --git a/src/AcDream.Core/Lighting/LightingHookSink.cs b/src/AcDream.Core/Lighting/LightingHookSink.cs
index 9a052d2f..047f8c20 100644
--- a/src/AcDream.Core/Lighting/LightingHookSink.cs
+++ b/src/AcDream.Core/Lighting/LightingHookSink.cs
@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
+using AcDream.Core.Vfx;
using DatReaderWriter.Types;
namespace AcDream.Core.Lighting;
@@ -24,14 +25,27 @@ namespace AcDream.Core.Lighting;
public sealed class LightingHookSink : IAnimationHookSink
{
private readonly LightManager _lights;
+ private readonly IEntityEffectPoseSource _poses;
+ private readonly IEntityEffectCellSource? _cells;
// Index owner → the set of LightSource instances they registered.
// Maintained lazily — populated on first RegisterLight for that owner.
private readonly Dictionary> _byOwner = new();
+ private readonly Dictionary> _trackedByOwner = new();
+ private readonly Dictionary _enabledByOwner = new();
- public LightingHookSink(LightManager lights)
+ ///
+ /// Raised after retail's set_lights state changes. App-layer live
+ /// owners use this to create/destroy their Setup light presentation;
+ /// DAT-static owners are already registered and simply consume IsLit.
+ ///
+ public event Action? OwnerLightingChanged;
+
+ public LightingHookSink(LightManager lights, IEntityEffectPoseSource poses)
{
_lights = lights ?? throw new System.ArgumentNullException(nameof(lights));
+ _poses = poses ?? throw new System.ArgumentNullException(nameof(poses));
+ _cells = poses as IEntityEffectCellSource;
}
///
@@ -41,6 +55,8 @@ public sealed class LightingHookSink : IAnimationHookSink
public void RegisterOwnedLight(LightSource light)
{
System.ArgumentNullException.ThrowIfNull(light);
+ if (_enabledByOwner.TryGetValue(light.OwnerId, out bool enabled))
+ light.IsLit = enabled;
_lights.Register(light);
if (!_byOwner.TryGetValue(light.OwnerId, out var list))
{
@@ -48,14 +64,54 @@ public sealed class LightingHookSink : IAnimationHookSink
_byOwner[light.OwnerId] = list;
}
list.Add(light);
+ if (light.TracksOwnerPose)
+ {
+ if (!_trackedByOwner.TryGetValue(light.OwnerId, out List? tracked))
+ {
+ tracked = new List();
+ _trackedByOwner[light.OwnerId] = tracked;
+ }
+ tracked.Add(light);
+ }
}
/// Drop every light tagged to this owner (despawn / unload).
- public void UnregisterOwner(uint ownerId)
+ public void UnregisterOwner(uint ownerId, bool forgetState = true)
{
- if (!_byOwner.TryGetValue(ownerId, out var list)) return;
- foreach (var l in list) _lights.Unregister(l);
- _byOwner.Remove(ownerId);
+ if (_byOwner.TryGetValue(ownerId, out var list))
+ {
+ foreach (var l in list) _lights.Unregister(l);
+ _byOwner.Remove(ownerId);
+ }
+ _trackedByOwner.Remove(ownerId);
+ if (forgetState)
+ _enabledByOwner.Remove(ownerId);
+ }
+
+ public void InitializeOwnerLighting(uint ownerId, bool enabled) =>
+ _enabledByOwner.TryAdd(ownerId, enabled);
+
+ public bool IsOwnerLightingEnabled(uint ownerId) =>
+ !_enabledByOwner.TryGetValue(ownerId, out bool enabled) || enabled;
+
+ ///
+ /// Mirrors CPhysicsObj::set_lights (0x0050FCF0): update the owner's
+ /// Lighting state and its currently materialized lights as one operation.
+ ///
+ public void SetOwnerLighting(uint ownerId, bool enabled)
+ {
+ if (_enabledByOwner.TryGetValue(ownerId, out bool current)
+ && current == enabled)
+ {
+ return;
+ }
+ _enabledByOwner[ownerId] = enabled;
+ if (_byOwner.TryGetValue(ownerId, out var list))
+ {
+ foreach (LightSource light in list)
+ light.IsLit = enabled;
+ }
+ OwnerLightingChanged?.Invoke(ownerId, enabled);
}
///
@@ -67,12 +123,36 @@ public sealed class LightingHookSink : IAnimationHookSink
return _byOwner.TryGetValue(ownerId, out var list) ? list : null;
}
+ ///
+ /// Recompose every object-borne light from the current physics-object root.
+ /// Setup LIGHTINFO has no part index; equipped-object animation is
+ /// represented by publishing that child's composed root.
+ ///
+ public void RefreshAttachedLights()
+ {
+ foreach ((uint ownerId, List lights) in _trackedByOwner)
+ {
+ if (!_poses.TryGetRootPose(ownerId, out Matrix4x4 rootWorld))
+ continue;
+
+ uint cellId = 0;
+ _cells?.TryGetCellId(ownerId, out cellId);
+ for (int i = 0; i < lights.Count; i++)
+ {
+ LightSource light = lights[i];
+ Matrix4x4 lightWorld = light.LocalPose * rootWorld;
+ light.WorldPosition = lightWorld.Translation;
+ Vector3 forward = Vector3.TransformNormal(Vector3.UnitY, lightWorld);
+ if (forward.LengthSquared() > 1e-8f)
+ light.WorldForward = Vector3.Normalize(forward);
+ light.CellId = cellId;
+ }
+ }
+ }
+
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
{
if (hook is not SetLightHook slh) return;
- if (!_byOwner.TryGetValue(entityId, out var list)) return;
-
- foreach (var light in list)
- light.IsLit = slh.LightsOn;
+ SetOwnerLighting(entityId, slh.LightsOn);
}
}
diff --git a/src/AcDream.Core/Meshing/EquippedChildAttachment.cs b/src/AcDream.Core/Meshing/EquippedChildAttachment.cs
index 344dbcde..5e64bab4 100644
--- a/src/AcDream.Core/Meshing/EquippedChildAttachment.cs
+++ b/src/AcDream.Core/Meshing/EquippedChildAttachment.cs
@@ -6,6 +6,12 @@ using DatReaderWriter.Types;
namespace AcDream.Core.Meshing;
+/// Final child root and indexed part poses in the parent-root frame.
+public readonly record struct EquippedChildPose(
+ Matrix4x4 RootLocal,
+ Matrix4x4[] PartLocal,
+ MeshRef[] AttachedParts);
+
///
/// Retail held-object transform composition. A weapon is a separate child
/// physics object: the parent's selects
@@ -28,6 +34,90 @@ public static class EquippedChildAttachment
IReadOnlyList childPartTemplate,
float childScale,
out IReadOnlyList attachedParts)
+ {
+ bool composed = TryComposePose(
+ parentSetup,
+ currentParentPose,
+ childSetup,
+ parentLocation,
+ placement,
+ childPartTemplate,
+ childScale,
+ out EquippedChildPose pose);
+ attachedParts = composed ? pose.AttachedParts : Array.Empty();
+ return composed;
+ }
+
+ public static bool TryComposePose(
+ Setup parentSetup,
+ IReadOnlyList currentParentPose,
+ Setup childSetup,
+ ParentLocation parentLocation,
+ Placement placement,
+ IReadOnlyList childPartTemplate,
+ float childScale,
+ out EquippedChildPose pose)
+ {
+ ArgumentNullException.ThrowIfNull(currentParentPose);
+ var parentParts = new Matrix4x4[currentParentPose.Count];
+ for (int i = 0; i < parentParts.Length; i++)
+ parentParts[i] = currentParentPose[i].PartTransform;
+ return TryComposePose(
+ parentSetup,
+ parentParts,
+ childSetup,
+ parentLocation,
+ placement,
+ childPartTemplate,
+ childScale,
+ out pose);
+ }
+
+ ///
+ /// Compose from the parent's indexed final animation poses. The renderer
+ /// exposes these independently of drawable MeshRefs so a hidden or missing
+ /// visual part cannot shift the retail holding-location index.
+ ///
+ public static bool TryComposePose(
+ Setup parentSetup,
+ IReadOnlyList currentParentPose,
+ Setup childSetup,
+ ParentLocation parentLocation,
+ Placement placement,
+ IReadOnlyList childPartTemplate,
+ float childScale,
+ out EquippedChildPose pose)
+ {
+ return TryComposePoseInto(
+ parentSetup,
+ currentParentPose,
+ parentPartAvailability: null,
+ childSetup,
+ parentLocation,
+ placement,
+ childPartTemplate,
+ childScale,
+ partPoseBuffer: null,
+ attachedPartBuffer: null,
+ out pose);
+ }
+
+ ///
+ /// Allocation-free update path for an already realized child. Buffers are
+ /// reused by the App-layer attachment owner after their first composition.
+ ///
+ public static bool TryComposePoseInto(
+ Setup parentSetup,
+ IReadOnlyList currentParentPose,
+ IReadOnlyList? parentPartAvailability,
+ Setup childSetup,
+ ParentLocation parentLocation,
+ Placement placement,
+ IReadOnlyList childPartTemplate,
+ float childScale,
+ Matrix4x4[]? partPoseBuffer,
+ MeshRef[]? attachedPartBuffer,
+ out EquippedChildPose pose)
{
ArgumentNullException.ThrowIfNull(parentSetup);
ArgumentNullException.ThrowIfNull(currentParentPose);
@@ -36,14 +126,32 @@ public static class EquippedChildAttachment
if (!parentSetup.HoldingLocations.TryGetValue(parentLocation, out LocationType? holding))
{
- attachedParts = Array.Empty();
+ pose = new EquippedChildPose(
+ Matrix4x4.Identity,
+ Array.Empty(),
+ Array.Empty());
return false;
}
- Matrix4x4 parentPart = holding.PartId >= 0
- && holding.PartId < currentParentPose.Count
- ? currentParentPose[holding.PartId].PartTransform
- : Matrix4x4.Identity;
+ Matrix4x4 parentPart;
+ if (holding.PartId >= 0 && holding.PartId < currentParentPose.Count)
+ {
+ if (parentPartAvailability is not null
+ && (holding.PartId >= parentPartAvailability.Count
+ || !parentPartAvailability[(int)holding.PartId]))
+ {
+ pose = default;
+ return false;
+ }
+ parentPart = currentParentPose[(int)holding.PartId];
+ }
+ else
+ {
+ // Retail CPhysicsObj::UpdateChild (0x00512D50) uses the parent
+ // root for every part number >= num_parts. This includes the
+ // canonical 0xFFFFFFFF root sentinel and malformed positives.
+ parentPart = Matrix4x4.Identity;
+ }
Matrix4x4 holdingFrame = ToMatrix(holding.Frame);
Matrix4x4 childRoot = holdingFrame * parentPart;
@@ -54,28 +162,40 @@ public static class EquippedChildAttachment
childSetup.PlacementFrames.TryGetValue(Placement.Default, out placementFrame);
int partCount = Math.Min(childSetup.Parts.Count, childPartTemplate.Count);
- var result = new MeshRef[partCount];
+ MeshRef[] result = attachedPartBuffer is { Length: var attachedLength }
+ && attachedLength == partCount
+ ? attachedPartBuffer
+ : new MeshRef[partCount];
+ Matrix4x4[] childPartPoses = partPoseBuffer is { Length: var poseLength }
+ && poseLength == partCount
+ ? partPoseBuffer
+ : new Matrix4x4[partCount];
for (int i = 0; i < partCount; i++)
{
Frame partFrame = placementFrame is not null && i < placementFrame.Frames.Count
? placementFrame.Frames[i]
: new Frame { Orientation = Quaternion.Identity };
- Vector3 scale = i < childSetup.DefaultScale.Count
+ Vector3 defaultScale = i < childSetup.DefaultScale.Count
? childSetup.DefaultScale[i]
: Vector3.One;
- Matrix4x4 childPart = Matrix4x4.CreateScale(scale)
+ Matrix4x4 visualChildPart = Matrix4x4.CreateScale(defaultScale)
* ToMatrix(partFrame);
if (childScale != 1.0f)
- childPart *= Matrix4x4.CreateScale(childScale);
+ visualChildPart *= Matrix4x4.CreateScale(childScale);
+
+ Matrix4x4 rigidChildPart = Matrix4x4.CreateFromQuaternion(partFrame.Orientation)
+ * Matrix4x4.CreateTranslation(partFrame.Origin * childScale);
+
+ childPartPoses[i] = rigidChildPart;
MeshRef template = childPartTemplate[i];
- result[i] = new MeshRef(template.GfxObjId, childPart * childRoot)
+ result[i] = new MeshRef(template.GfxObjId, visualChildPart * childRoot)
{
SurfaceOverrides = template.SurfaceOverrides,
};
}
- attachedParts = result;
+ pose = new EquippedChildPose(childRoot, childPartPoses, result);
return true;
}
diff --git a/src/AcDream.Core/Meshing/SetupPartTransforms.cs b/src/AcDream.Core/Meshing/SetupPartTransforms.cs
index 3bffbd50..85d62d16 100644
--- a/src/AcDream.Core/Meshing/SetupPartTransforms.cs
+++ b/src/AcDream.Core/Meshing/SetupPartTransforms.cs
@@ -7,48 +7,35 @@ using DatReaderWriter.Types;
namespace AcDream.Core.Meshing;
///
-/// Compute the per-part static transforms for a Setup using its
-/// PlacementFrames. For each part i, the returned matrix takes a
-/// point in part-local space and yields a point in setup-local space at
-/// the Setup's resting pose.
-///
-///
-/// Mirrors 's pose-source priority —
-/// PlacementFrames[Resting] → [Default] → first available
-/// — so that a particle anchor at part i matches the part's
-/// visible rest position. If renderer and resolver ever drift on this
-/// priority, particles will visibly drift relative to their parent
-/// mesh; keep them in lockstep.
-///
-///
-///
-/// Returns an empty list when the Setup has no PlacementFrames. The
-/// caller (e.g. ParticleHookSink.SpawnFromHook) should then fall
-/// back to per part, which is the
-/// pre-C.1.5b behavior.
-///
-///
-///
-/// For animated entities, per-part transforms vary per animation frame
-/// and live in AnimatedEntityState; a future "animated
-/// DefaultScript" path would publish those each tick via the same
-/// SetEntityPartTransforms seam. Out of scope here.
-///
+/// Computes retail's rigid per-part frames for attachments and effects.
+/// Visual GfxObj scale is deliberately excluded.
///
+///
+/// Pose selection matches : an explicit motion
+/// frame, then Resting, Default, then the first placement frame. Retail
+/// CPartArray::UpdateParts (0x005190F0) scales only the frame
+/// origin by the object's scale. CPartArray::SetScaleInternal
+/// (0x00518A00) stores Setup DefaultScale separately on the visual
+/// CPhysicsPart::gfxobj_scale, so it never enters this rigid frame.
+///
public static class SetupPartTransforms
{
- public static IReadOnlyList Compute(Setup setup)
+ public static IReadOnlyList Compute(
+ Setup setup,
+ AnimationFrame? motionFrameOverride = null,
+ float objectScale = 1.0f)
{
- AnimationFrame? source = null;
- if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting))
+ ArgumentNullException.ThrowIfNull(setup);
+ AnimationFrame? source = motionFrameOverride;
+ if (source is null && setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting))
{
source = resting;
}
- else if (setup.PlacementFrames.TryGetValue(Placement.Default, out var def))
+ else if (source is null && setup.PlacementFrames.TryGetValue(Placement.Default, out var def))
{
source = def;
}
- else
+ else if (source is null)
{
foreach (var kvp in setup.PlacementFrames)
{
@@ -57,7 +44,8 @@ public static class SetupPartTransforms
}
}
- if (source is null) return System.Array.Empty();
+ if (source is null)
+ return Array.Empty();
int partCount = setup.Parts.Count;
var result = new Matrix4x4[partCount];
@@ -66,10 +54,8 @@ public static class SetupPartTransforms
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);
+ result[i] = Matrix4x4.CreateFromQuaternion(frame.Orientation)
+ * Matrix4x4.CreateTranslation(frame.Origin * objectScale);
}
return result;
}
diff --git a/src/AcDream.Core/Vfx/EmitterDescLoader.cs b/src/AcDream.Core/Vfx/EmitterDescLoader.cs
index 86235247..384b1b33 100644
--- a/src/AcDream.Core/Vfx/EmitterDescLoader.cs
+++ b/src/AcDream.Core/Vfx/EmitterDescLoader.cs
@@ -14,8 +14,6 @@ namespace AcDream.Core.Vfx;
///
public sealed class EmitterDescRegistry
{
- private const uint FallbackEmitterId = 0xFFFFFFFFu;
-
private readonly Func? _resolver;
private readonly ConcurrentDictionary _byId = new();
@@ -32,7 +30,6 @@ public sealed class EmitterDescRegistry
public EmitterDescRegistry(Func? resolver)
{
_resolver = resolver;
- Register(BuildFallback());
}
public void Register(EmitterDesc desc)
@@ -43,9 +40,16 @@ public sealed class EmitterDescRegistry
public EmitterDesc Get(uint emitterId)
{
- if (_byId.TryGetValue(emitterId, out var desc))
+ if (TryGet(emitterId, out EmitterDesc? desc))
return desc;
+ throw new KeyNotFoundException(
+ $"ParticleEmitterInfo 0x{emitterId:X8} was not found.");
+ }
+ public bool TryGet(uint emitterId, out EmitterDesc desc)
+ {
+ if (_byId.TryGetValue(emitterId, out desc!))
+ return true;
if (_resolver is not null)
{
var dat = _resolver(emitterId);
@@ -53,14 +57,11 @@ public sealed class EmitterDescRegistry
{
desc = FromDat(emitterId, dat);
_byId[emitterId] = desc;
- return desc;
+ return true;
}
}
-
- if (_byId.TryGetValue(FallbackEmitterId, out var fallback))
- return fallback;
-
- throw new InvalidOperationException("No default emitter registered in registry.");
+ desc = null!;
+ return false;
}
public int Count => _byId.Count;
@@ -145,36 +146,6 @@ public sealed class EmitterDescRegistry
}
}
- private static EmitterDesc BuildFallback() => new()
- {
- DatId = FallbackEmitterId,
- Type = ParticleType.LocalVelocity,
- EmitterKind = ParticleEmitterKind.BirthratePerSec,
- Flags = EmitterFlags.Billboard | EmitterFlags.FaceCamera,
- Birthrate = 0.1f,
- EmitRate = 10f,
- MaxParticles = 32,
- LifetimeMin = 0.6f,
- LifetimeMax = 1.2f,
- Lifespan = 0.9f,
- LifespanRand = 0.3f,
- OffsetDir = new Vector3(0, 0, 1),
- MinOffset = 0f,
- MaxOffset = 0.1f,
- SpawnDiskRadius = 0.1f,
- InitialVelocity = new Vector3(0, 0, 0.5f),
- VelocityJitter = 0.3f,
- A = new Vector3(0, 0, 0.5f),
- MinA = 1f,
- MaxA = 1f,
- B = Vector3.Zero,
- C = Vector3.Zero,
- StartSize = 0.25f,
- EndSize = 0.6f,
- StartAlpha = 0.85f,
- EndAlpha = 0f,
- };
-
private static ParticleEmitterKind MapEmitterKind(DatEmitterType type) => type switch
{
DatEmitterType.BirthratePerSec => ParticleEmitterKind.BirthratePerSec,
diff --git a/src/AcDream.Core/Vfx/IEntityEffectPoseSource.cs b/src/AcDream.Core/Vfx/IEntityEffectPoseSource.cs
new file mode 100644
index 00000000..0e7b8ff3
--- /dev/null
+++ b/src/AcDream.Core/Vfx/IEntityEffectPoseSource.cs
@@ -0,0 +1,24 @@
+using System.Numerics;
+
+namespace AcDream.Core.Vfx;
+
+///
+/// Read-only view of the final pose used by entity-attached effects.
+/// Implementations publish poses on the update/render thread after root motion,
+/// animation, and equipped-child composition have completed.
+///
+public interface IEntityEffectPoseSource
+{
+ bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld);
+
+ bool TryGetPartPose(uint localEntityId, int partIndex, out Matrix4x4 partLocal);
+}
+
+///
+/// Optional spatial companion for consumers, such as object lights, whose
+/// render registration is scoped to the owner's current AC cell.
+///
+public interface IEntityEffectCellSource
+{
+ bool TryGetCellId(uint localEntityId, out uint cellId);
+}
diff --git a/src/AcDream.Core/Vfx/ParticleHookSink.cs b/src/AcDream.Core/Vfx/ParticleHookSink.cs
index f1755a41..ce23f3ac 100644
--- a/src/AcDream.Core/Vfx/ParticleHookSink.cs
+++ b/src/AcDream.Core/Vfx/ParticleHookSink.cs
@@ -1,191 +1,177 @@
-using System;
using System.Collections.Concurrent;
using System.Numerics;
-using System.Threading;
using AcDream.Core.Physics;
using DatReaderWriter.Types;
namespace AcDream.Core.Vfx;
///
-/// that translates particle-bearing
-/// animation hooks into spawn / stop calls.
-///
-///
-/// Hook types handled (r04 §6):
-///
-/// -
-/// — spawn an emitter from the
-/// hook's EmitterInfoId at the entity's world pose plus the
-/// hook offset. 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.
-///
-/// -
-/// — has the same payload
-/// as CreateParticleHook, but suppresses creation while the same nonzero
-/// logical emitter ID is live. It does not pause animation. Production DAT
-/// loading substitutes so
-/// the inherited payload is retained despite the dependency's header-only
-/// model. Suppression itself lands with live emitter bindings in Step 5.
-///
-/// -
-/// — stop the most-recent emitter
-/// matching the hook's EmitterId. Deferred to a future pass
-/// when we retain per-entity emitter-id → handle maps.
-///
-/// -
-/// — pause all emitters on the
-/// entity (fade out).
-///
-/// -
-/// /
-/// — trigger the entity's DefaultScriptId PhysicsScript.
-/// Requires PhysicsScript table; deferred.
-///
-/// -
-/// — fire a PhysicsScript by id.
-/// Deferred until DRW exposes PhysicsScript dat.
-///
-///
-///
-///
-///
-/// Per-entity emitter handle tracking is kept here so DestroyParticle /
-/// StopParticle can target the right emitter when a server-sent
-/// PlayEffect fires.
-///
+/// Routes particle animation hooks through the owner's current root/part pose
+/// and retains the retail logical-emitter identity rules.
///
+///
+///
+/// Retail oracle: CreateParticleHook::Execute (0x00526EC0),
+/// CreateBlockingParticleHook::Execute (0x00526EF0),
+/// ParticleManager::CreateParticleEmitter (0x0051B6C0), and
+/// CreateBlockingParticleEmitter (0x0051B8A0).
+///
+///
+/// The complete hook offset frame is retained on each binding for schema
+/// fidelity. Retail Particle::Init (0x0051C930) transforms the
+/// offset origin through the current owner frame, but does not apply the hook
+/// offset quaternion to particle vectors; emitter orientation is the current
+/// root/part orientation.
+///
+///
public sealed class ParticleHookSink : IAnimationHookSink
{
private readonly ParticleSystem _system;
-
- // entityId → most-recently-spawned emitter handle per emitterId.
- // DestroyParticleHook.EmitterId is effectively an application-layer
- // key ("the smoke trail I spawned 2 seconds ago"), so we track by
- // (entity, emitterId).
+ private readonly IEntityEffectPoseSource _poses;
private readonly ConcurrentDictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new();
- // entityId → set of live emitter handles. Dictionary-as-set so we can
- // remove individual handles when their emitter dies (M4 fix —
- // ConcurrentBag couldn't drop entries, so handles for naturally-expired
- // emitters used to leak).
private readonly ConcurrentDictionary> _handlesByEntity = new();
- // Reverse lookup: handle → (entity, key) for O(1) cleanup on EmitterDied.
- private readonly ConcurrentDictionary _trackingByHandle = new();
+ private readonly ConcurrentDictionary _bindingsByHandle = new();
private readonly ConcurrentDictionary _renderPassByEntity = new();
- private readonly ConcurrentDictionary _rotationByEntity = new();
- // C.1.5b #56: per-entity static part transforms (PlacementFrames[Resting]
- // baked into a Matrix4x4 per Setup part). When set, SpawnFromHook applies
- // partTransforms[hook.PartIndex] to the hook offset BEFORE rotating to
- // world space. Without this, every emitter in a multi-part Setup
- // collapses to the entity root (the bug). Cleared by StopAllForEntity.
- // For ANIMATED entities this map would need a per-tick refresh similar
- // to UpdateEntityAnchor — deferred to a future phase.
- private readonly ConcurrentDictionary> _partTransformsByEntity = new();
- private int _anonymousEmitterSerial;
-
- public ParticleHookSink(ParticleSystem system)
+ private readonly ConcurrentDictionary _hiddenPresentationOwners = new();
+ public ParticleHookSink(ParticleSystem system, IEntityEffectPoseSource poses)
{
_system = system ?? throw new ArgumentNullException(nameof(system));
+ _poses = poses ?? throw new ArgumentNullException(nameof(poses));
_system.EmitterDied += OnEmitterDied;
}
+ public Action? DiagnosticSink { get; set; }
+
private void OnEmitterDied(int handle)
{
- if (!_trackingByHandle.TryRemove(handle, out var t))
+ if (!_bindingsByHandle.TryRemove(handle, out EmitterBinding binding))
return;
- _handlesByKey.TryRemove((t.EntityId, t.KeyId), out _);
- if (_handlesByEntity.TryGetValue(t.EntityId, out var bag))
- bag.TryRemove(handle, out _);
+
+ if (binding.LogicalId != 0
+ && _handlesByKey.TryGetValue((binding.OwnerLocalId, binding.LogicalId), out int current)
+ && current == handle)
+ {
+ _handlesByKey.TryRemove((binding.OwnerLocalId, binding.LogicalId), out _);
+ }
+ if (_handlesByEntity.TryGetValue(binding.OwnerLocalId, out var handles))
+ {
+ handles.TryRemove(handle, out _);
+ if (handles.IsEmpty)
+ _handlesByEntity.TryRemove(binding.OwnerLocalId, out _);
+ }
}
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
{
switch (hook)
{
- case RetailCreateBlockingParticleHook:
- // The centralized content reader now preserves the complete
- // retail payload. Step 5 owns live logical-ID suppression and
- // attachment semantics; do not silently treat blocking as a
- // normal replacement before that mechanism lands.
+ case RetailCreateBlockingParticleHook blocking:
+ SpawnFromHook(
+ entityId,
+ (uint)blocking.EmitterInfoId,
+ blocking.Offset,
+ unchecked((int)blocking.PartIndex),
+ blocking.EmitterId,
+ isBlocking: true);
break;
- case CreateParticleHook cph:
- SpawnFromHook(entityId, entityWorldPosition,
- emitterInfoId: (uint)cph.EmitterInfoId,
- offset: cph.Offset.Origin,
- partIndex: (int)cph.PartIndex,
- logicalId: cph.EmitterId);
+ case CreateParticleHook create:
+ SpawnFromHook(
+ entityId,
+ (uint)create.EmitterInfoId,
+ create.Offset,
+ unchecked((int)create.PartIndex),
+ create.EmitterId,
+ isBlocking: false);
break;
case CreateBlockingParticleHook:
- // Defensive package-decoder shape. Production raw loaders
- // replace this header-only model with the retail subclass.
+ // The upstream package exposes only the common header for this
+ // type. Production loaders replace it with the retail payload
+ // above; a header-only instance cannot create an emitter.
+ DiagnosticSink?.Invoke(
+ $"CreateBlockingParticle for owner 0x{entityId:X8} has no retail payload.");
break;
- case DestroyParticleHook dph:
- if (_handlesByKey.TryRemove((entityId, dph.EmitterId), out var handleToDestroy))
- _system.StopEmitter(handleToDestroy, fadeOut: false);
+ case DestroyParticleHook destroy:
+ DestroyLogical(entityId, destroy.EmitterId, fadeOut: false);
break;
- case StopParticleHook sph:
- if (_handlesByKey.TryGetValue((entityId, sph.EmitterId), out var handleToStop))
- _system.StopEmitter(handleToStop, fadeOut: true);
+ case StopParticleHook stop:
+ DestroyLogical(entityId, stop.EmitterId, fadeOut: true);
break;
-
- // DefaultScript / CallPES are routed by EntityEffectController.
- // ParticleHookSink intentionally owns particles only.
}
}
- public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass)
- => _renderPassByEntity[entityId] = renderPass;
+ public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass) =>
+ _renderPassByEntity[entityId] = renderPass;
- public void SetEntityRotation(uint entityId, Quaternion rotation)
- => _rotationByEntity[entityId] = rotation;
+ public void ClearEntityRenderPass(uint entityId) =>
+ _renderPassByEntity.TryRemove(entityId, out _);
///
- /// Register per-part static transforms for an entity. The caller
- /// (typically EntityScriptActivator) precomputes one
- /// per Setup part using
- /// SetupPartTransforms.Compute and pushes them here at spawn
- /// time. applies
- /// partTransforms[hook.PartIndex] to the hook offset BEFORE
- /// transforming to world space. Cleared on
- /// .
+ /// Mirrors live cell membership without ending emitter lifetime. Retail
+ /// pauses the owner's particle manager while cell-less, then resumes the
+ /// same particles and logical IDs without catch-up emission on re-entry.
///
- public void SetEntityPartTransforms(uint entityId, IReadOnlyList partTransforms)
- => _partTransformsByEntity[entityId] = partTransforms;
-
- public void ClearEntityRenderPass(uint entityId)
- => _renderPassByEntity.TryRemove(entityId, out _);
-
- ///
- /// Refresh every live emitter on this entity to a new world anchor +
- /// rotation. The owning subsystem (sky-PES driver, animation tick)
- /// drives this each frame for AttachLocal emitters so they track their
- /// moving parent — retail-faithful via
- /// ParticleEmitter::UpdateParticles at 0x0051d2d4, which
- /// re-reads the parent frame each tick when is_parent_local != 0.
- /// Safe to call for entities with no live emitters (no-op).
- ///
- public void UpdateEntityAnchor(uint entityId, Vector3 anchor, Quaternion rotation)
+ public void SetEntityPresentationVisible(uint entityId, bool visible)
{
- _rotationByEntity[entityId] = rotation;
- if (!_handlesByEntity.TryGetValue(entityId, out var bag))
- return;
- foreach (var handle in bag.Keys)
- _system.UpdateEmitterAnchor(handle, anchor, rotation);
+ if (visible)
+ _hiddenPresentationOwners.TryRemove(entityId, out _);
+ else
+ _hiddenPresentationOwners[entityId] = 0;
+
+ // Withdrawal is immediate. Re-entry is enabled by the next pose
+ // refresh so an attached owner cannot flash for one frame at its old
+ // anchor before the composed child pose is published.
+ if (!visible && _handlesByEntity.TryGetValue(entityId, out var handles))
+ {
+ foreach (int handle in handles.Keys)
+ {
+ _system.SetEmitterPresentationVisible(handle, false);
+ _system.SetEmitterSimulationEnabled(handle, false);
+ }
+ }
+ }
+
+ ///
+ /// Refreshes every emitter from the current root/part pose. World-released
+ /// particles keep their stored emission origin; AttachLocal particles read
+ /// the refreshed anchor when the particle system advances.
+ ///
+ public void RefreshAttachedEmitters()
+ {
+ foreach ((int handle, EmitterBinding binding) in _bindingsByHandle)
+ {
+ bool presentationVisible =
+ !_hiddenPresentationOwners.ContainsKey(binding.OwnerLocalId);
+ if (TryResolveAnchor(binding.OwnerLocalId, binding.PartIndex,
+ binding.HookOffsetOrigin, out Vector3 anchor, out Quaternion rotation))
+ {
+ _system.UpdateEmitterAnchor(handle, anchor, rotation);
+ // Keep the anchor current while spatially paused; re-entry
+ // resumes at the authoritative pose without generating the
+ // absent interval's time- or distance-driven emissions.
+ _system.SetEmitterPresentationVisible(handle, presentationVisible);
+ _system.SetEmitterSimulationEnabled(handle, presentationVisible);
+ }
+ else
+ _system.SetEmitterPresentationVisible(handle, false);
+ }
}
public void StopAllForEntity(uint entityId, bool fadeOut)
{
if (_handlesByEntity.TryRemove(entityId, out var handles))
{
- foreach (var handle in handles.Keys)
+ foreach (int handle in handles.Keys)
{
+ // A fading emitter needs its clock to retire naturally. A
+ // hard destroy is removed synchronously by ParticleSystem.
+ if (fadeOut)
+ _system.SetEmitterSimulationEnabled(handle, true);
_system.StopEmitter(handle, fadeOut);
- _trackingByHandle.TryRemove(handle, out _);
+ _bindingsByHandle.TryRemove(handle, out _);
}
}
@@ -194,61 +180,138 @@ public sealed class ParticleHookSink : IAnimationHookSink
if (key.EntityId == entityId)
_handlesByKey.TryRemove(key, out _);
}
-
ClearEntityRenderPass(entityId);
- _rotationByEntity.TryRemove(entityId, out _);
- _partTransformsByEntity.TryRemove(entityId, out _);
+ _hiddenPresentationOwners.TryRemove(entityId, out _);
+ }
+
+ private void DestroyLogical(uint ownerLocalId, uint logicalId, bool fadeOut)
+ {
+ if (logicalId == 0
+ || !_handlesByKey.TryGetValue((ownerLocalId, logicalId), out int handle))
+ {
+ return;
+ }
+
+ // Retail StopParticleEmitter (0x0051B7B0) only marks the emitter as
+ // stopped; it remains in particle_table until its final particle dies.
+ // A blocking create with the same logical ID must therefore continue
+ // to see it. DestroyParticleEmitter (0x0051B770) removes it at once.
+ if (!fadeOut)
+ _handlesByKey.TryRemove((ownerLocalId, logicalId), out _);
+ if (fadeOut)
+ _system.SetEmitterSimulationEnabled(handle, true);
+ _system.StopEmitter(handle, fadeOut);
}
private void SpawnFromHook(
- uint entityId,
- Vector3 worldPos,
+ uint ownerLocalId,
uint emitterInfoId,
- Vector3 offset,
+ Frame offset,
int partIndex,
- uint logicalId)
+ uint logicalId,
+ bool isBlocking)
{
- // 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;
- Vector3 partLocal = offset;
- if (_partTransformsByEntity.TryGetValue(entityId, out var partTransforms)
- && partIndex >= 0
- && partIndex < partTransforms.Count)
+ if (logicalId != 0
+ && _handlesByKey.TryGetValue((ownerLocalId, logicalId), out int existing))
{
- partLocal = Vector3.Transform(offset, partTransforms[partIndex]);
+ if (isBlocking && _system.IsEmitterAlive(existing))
+ return;
+
+ _handlesByKey.TryRemove((ownerLocalId, logicalId), out _);
+ _system.StopEmitter(existing, fadeOut: false);
}
- var anchor = worldPos + Vector3.Transform(partLocal, rotation);
- var renderPass = _renderPassByEntity.TryGetValue(entityId, out var pass)
+
+ Vector3 offsetOrigin = offset?.Origin ?? Vector3.Zero;
+ Quaternion offsetOrientation = offset?.Orientation ?? Quaternion.Identity;
+ if (!TryResolveAnchor(ownerLocalId, partIndex, offsetOrigin,
+ out Vector3 anchor, out Quaternion rotation))
+ {
+ DiagnosticSink?.Invoke(
+ $"No live effect pose for owner 0x{ownerLocalId:X8}, part {partIndex}; " +
+ $"emitter 0x{emitterInfoId:X8} was not created.");
+ return;
+ }
+
+ ParticleRenderPass renderPass = _renderPassByEntity.TryGetValue(ownerLocalId, out var pass)
? pass
: ParticleRenderPass.Scene;
-
- int handle = _system.SpawnEmitterById(
- emitterId: emitterInfoId,
- anchor: anchor,
- rot: rotation,
- attachedObjectId: entityId,
- attachedPartIndex: partIndex,
- renderPass: renderPass);
-
- uint keyId = logicalId != 0
- ? logicalId
- : 0x80000000u | (uint)Interlocked.Increment(ref _anonymousEmitterSerial);
- if (logicalId != 0 && _handlesByKey.TryRemove((entityId, keyId), out var oldHandle))
+ if (!_system.TrySpawnEmitterById(
+ emitterInfoId,
+ anchor,
+ rotation,
+ ownerLocalId,
+ partIndex,
+ renderPass,
+ out int handle))
{
- _system.StopEmitter(oldHandle, fadeOut: false);
- _trackingByHandle.TryRemove(oldHandle, out _);
+ DiagnosticSink?.Invoke(
+ $"ParticleEmitterInfo 0x{emitterInfoId:X8} for owner " +
+ $"0x{ownerLocalId:X8} was not found; no fallback was created.");
+ return;
+ }
+ if (_hiddenPresentationOwners.ContainsKey(ownerLocalId))
+ {
+ _system.SetEmitterPresentationVisible(handle, false);
+ _system.SetEmitterSimulationEnabled(handle, false);
}
- _handlesByKey[(entityId, keyId)] = handle;
+ var binding = new EmitterBinding(
+ ownerLocalId,
+ partIndex,
+ offsetOrigin,
+ offsetOrientation,
+ logicalId,
+ renderPass);
+ _bindingsByHandle[handle] = binding;
_handlesByEntity
- .GetOrAdd(entityId, _ => new ConcurrentDictionary())
+ .GetOrAdd(ownerLocalId, _ => new ConcurrentDictionary())
.TryAdd(handle, 0);
- _trackingByHandle[handle] = (entityId, keyId);
+ if (logicalId != 0)
+ _handlesByKey[(ownerLocalId, logicalId)] = handle;
}
+
+ private bool TryResolveAnchor(
+ uint ownerLocalId,
+ int partIndex,
+ Vector3 offsetOrigin,
+ out Vector3 anchor,
+ out Quaternion rotation)
+ {
+ if (!_poses.TryGetRootPose(ownerLocalId, out Matrix4x4 rootWorld))
+ {
+ anchor = default;
+ rotation = default;
+ return false;
+ }
+
+ Matrix4x4 ownerFrame = rootWorld;
+ if (partIndex != -1)
+ {
+ if (!_poses.TryGetPartPose(ownerLocalId, partIndex, out Matrix4x4 partLocal))
+ {
+ anchor = default;
+ rotation = default;
+ return false;
+ }
+ ownerFrame = partLocal * rootWorld;
+ }
+
+ anchor = Vector3.Transform(offsetOrigin, ownerFrame);
+ if (!Matrix4x4.Decompose(ownerFrame, out _, out rotation, out _))
+ {
+ anchor = default;
+ rotation = default;
+ return false;
+ }
+ rotation = Quaternion.Normalize(rotation);
+ return true;
+ }
+
+ private readonly record struct EmitterBinding(
+ uint OwnerLocalId,
+ int PartIndex,
+ Vector3 HookOffsetOrigin,
+ Quaternion HookOffsetOrientation,
+ uint LogicalId,
+ ParticleRenderPass RenderPass);
}
diff --git a/src/AcDream.Core/Vfx/ParticleSystem.cs b/src/AcDream.Core/Vfx/ParticleSystem.cs
index 40090d92..9b8d3f3b 100644
--- a/src/AcDream.Core/Vfx/ParticleSystem.cs
+++ b/src/AcDream.Core/Vfx/ParticleSystem.cs
@@ -75,6 +75,31 @@ public sealed class ParticleSystem : IParticleSystem
return SpawnEmitter(desc, anchor, rot, attachedObjectId, attachedPartIndex, renderPass);
}
+ public bool TrySpawnEmitterById(
+ uint emitterId,
+ Vector3 anchor,
+ Quaternion? rot,
+ uint attachedObjectId,
+ int attachedPartIndex,
+ ParticleRenderPass renderPass,
+ out int handle)
+ {
+ if (!_registry.TryGet(emitterId, out EmitterDesc? desc))
+ {
+ handle = 0;
+ return false;
+ }
+
+ handle = SpawnEmitter(
+ desc,
+ anchor,
+ rot,
+ attachedObjectId,
+ attachedPartIndex,
+ renderPass);
+ return true;
+ }
+
public void PlayScript(uint scriptId, uint targetObjectId, float modifier = 1f)
{
// Full PhysicsScript scheduling lives in PhysicsScriptRunner.
@@ -90,6 +115,12 @@ public sealed class ParticleSystem : IParticleSystem
{
for (int i = 0; i < em.Particles.Length; i++)
em.Particles[i].Alive = false;
+ // Retail DestroyParticleEmitter removes the table entry now; it
+ // does not wait for the next update. This is also required for a
+ // hard-stopped emitter whose cell-less simulation is paused.
+ _byHandle.Remove(handle);
+ _handleOrder.Remove(handle);
+ EmitterDied?.Invoke(handle);
}
}
@@ -107,10 +138,50 @@ public sealed class ParticleSystem : IParticleSystem
if (!_byHandle.TryGetValue(handle, out var em))
return;
em.AnchorPos = anchor;
+ if (!em.SimulationEnabled)
+ em.LastEmitOffset = anchor;
if (rot.HasValue)
em.AnchorRot = rot.Value;
}
+ ///
+ /// Changes only render presentation. Logical lifetime is unaffected.
+ ///
+ public void SetEmitterPresentationVisible(int handle, bool visible)
+ {
+ if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
+ emitter.PresentationVisible = visible;
+ }
+
+ ///
+ /// Applies retail's in-cell update gate without ending emitter ownership.
+ /// Retail retains absolute creation timestamps while cell-less; the next
+ /// update observes the elapsed wall-clock interval and expires old state.
+ /// Only acdream's legacy rate accumulator is rebased to prevent a synthetic
+ /// multi-particle catch-up burst that retail's one-shot emission path lacks.
+ ///
+ public void SetEmitterSimulationEnabled(int handle, bool enabled)
+ {
+ if (!_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)
+ || emitter.SimulationEnabled == enabled)
+ {
+ return;
+ }
+
+ if (!enabled)
+ {
+ emitter.SimulationEnabled = false;
+ return;
+ }
+
+ if (emitter.Desc.Birthrate <= 0f && emitter.Desc.EmitRate > 0f)
+ {
+ emitter.LastEmitTime = _time;
+ emitter.EmittedAccumulator = 0f;
+ }
+ emitter.SimulationEnabled = true;
+ }
+
/// True when the given handle still maps to a live emitter.
public bool IsEmitterAlive(int handle) => _byHandle.ContainsKey(handle);
@@ -136,6 +207,8 @@ public sealed class ParticleSystem : IParticleSystem
int handle = _handleOrder[i];
if (!_byHandle.TryGetValue(handle, out var em))
continue;
+ if (!em.SimulationEnabled)
+ continue;
AdvanceEmitter(em);
int live = CountAlive(em);
diff --git a/src/AcDream.Core/Vfx/VfxModel.cs b/src/AcDream.Core/Vfx/VfxModel.cs
index 56974314..73c97ae1 100644
--- a/src/AcDream.Core/Vfx/VfxModel.cs
+++ b/src/AcDream.Core/Vfx/VfxModel.cs
@@ -180,6 +180,15 @@ public sealed class ParticleEmitter
public Vector3 AnchorPos { get; set; }
public Quaternion AnchorRot { get; set; } = Quaternion.Identity;
public uint AttachedObjectId { get; set; }
+ ///
+ /// Spatial presentation latch. A cell-less owner submits no particles.
+ ///
+ public bool PresentationVisible { get; set; } = true;
+ ///
+ /// Retail cell-membership update gate. Paused emitters retain their exact
+ /// particles, logical identity, and elapsed-time position until re-entry.
+ ///
+ public bool SimulationEnabled { get; set; } = true;
public int AttachedPartIndex { get; set; } = -1;
public Particle[] Particles { get; init; } = null!;
public ParticleRenderPass RenderPass { get; init; }
diff --git a/src/AcDream.Core/World/WorldEntity.cs b/src/AcDream.Core/World/WorldEntity.cs
index a5151f05..33b8159a 100644
--- a/src/AcDream.Core/World/WorldEntity.cs
+++ b/src/AcDream.Core/World/WorldEntity.cs
@@ -30,6 +30,30 @@ public sealed class WorldEntity
///
public required IReadOnlyList MeshRefs { get; set; }
+ ///
+ /// Stable Setup-part-indexed poses used by attachment and effect hooks.
+ /// Unlike , this array never compacts around a DAT
+ /// part whose GfxObj could not be loaded.
+ /// distinguishes a real indexed part from a retained placeholder pose.
+ ///
+ public IReadOnlyList IndexedPartTransforms { get; private set; } =
+ Array.Empty();
+
+ public IReadOnlyList IndexedPartAvailable { get; private set; } =
+ Array.Empty();
+
+ public void SetIndexedPartPoses(
+ IReadOnlyList transforms,
+ IReadOnlyList available)
+ {
+ ArgumentNullException.ThrowIfNull(transforms);
+ ArgumentNullException.ThrowIfNull(available);
+ if (transforms.Count != available.Count)
+ throw new ArgumentException("Indexed part pose and availability counts must match.");
+ IndexedPartTransforms = transforms;
+ IndexedPartAvailable = available;
+ }
+
///
/// Optional per-entity palette override (server-specified base +
/// subpalette overlays). When non-null, applies to every palette-
diff --git a/tests/AcDream.App.Tests/Rendering/AttachmentUpdateOrderTests.cs b/tests/AcDream.App.Tests/Rendering/AttachmentUpdateOrderTests.cs
new file mode 100644
index 00000000..937e1c17
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/AttachmentUpdateOrderTests.cs
@@ -0,0 +1,177 @@
+using System.Numerics;
+using AcDream.App.Rendering;
+
+namespace AcDream.App.Tests.Rendering;
+
+public sealed class AttachmentUpdateOrderTests
+{
+ [Fact]
+ public void ReinsertedParentStillUpdatesBeforeGrandchild()
+ {
+ const uint parent = 10u;
+ const uint grandchild = 20u;
+ // Dictionary order models B being removed/re-added while C remained.
+ uint[] insertionOrder = [grandchild, parent];
+ var parents = new Dictionary
+ {
+ [grandchild] = parent,
+ [parent] = null,
+ };
+ var calls = new List();
+
+ var children = insertionOrder.ToDictionary(id => id, id => parents[id]);
+ new AttachmentUpdateOrder().ForEachParentFirst(
+ children,
+ static parentId => parentId,
+ id =>
+ {
+ calls.Add(id);
+ return true;
+ });
+
+ Assert.Equal([parent, grandchild], calls);
+ }
+
+ [Fact]
+ public void FailedParentSuppressesAndReportsDescendantsAfterTraversal()
+ {
+ const uint parent = 10u;
+ const uint grandchild = 20u;
+ var children = new Dictionary
+ {
+ [grandchild] = parent,
+ [parent] = null,
+ };
+ var calls = new List();
+
+ IReadOnlyList failed = new AttachmentUpdateOrder().ForEachParentFirst(
+ children,
+ static parentId => parentId,
+ id =>
+ {
+ calls.Add(id);
+ return id != parent;
+ });
+
+ Assert.Equal([parent], calls);
+ Assert.Equal([parent, grandchild], failed);
+ Assert.Equal(2, children.Count);
+ }
+
+ [Fact]
+ public void NestedDrawableUsesImmediateParentsComposedWorldRoot()
+ {
+ Quaternion turn = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f);
+ Matrix4x4 parentWorld = Matrix4x4.CreateFromQuaternion(turn)
+ * Matrix4x4.CreateTranslation(100f, 0f, 0f);
+ var child = new AcDream.Core.World.WorldEntity
+ {
+ Id = 3u,
+ SourceGfxObjOrSetupId = 0x02000003u,
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ MeshRefs =
+ [
+ new AcDream.Core.World.MeshRef(
+ 0x01000003u,
+ Matrix4x4.CreateTranslation(2f, 0f, 0f)),
+ ],
+ };
+
+ Assert.True(EquippedChildRenderController.ApplyParentWorldPose(child, parentWorld));
+ Matrix4x4 installedRoot = Matrix4x4.CreateFromQuaternion(child.Rotation)
+ * Matrix4x4.CreateTranslation(child.Position);
+ Vector3 renderedOrigin = Vector3.Transform(
+ Vector3.Zero,
+ child.MeshRefs[0].PartTransform * installedRoot);
+
+ Assert.Equal(new Vector3(100f, 2f, 0f), renderedOrigin, new Vector3ToleranceComparer());
+ }
+
+ [Fact]
+ public void AncestorTeardownCollectsEntireAttachmentSubtreePostOrder()
+ {
+ var children = new Dictionary
+ {
+ [20u] = 10u,
+ [30u] = 20u,
+ };
+ var order = new AttachmentUpdateOrder().CollectSubtreePostOrder(
+ children,
+ 10u,
+ static parentId => parentId);
+
+ Assert.Equal([30u, 20u, 10u], order);
+ }
+
+ [Fact]
+ public void PoseRecoveryRealizesCompleteDescendantChainParentFirst()
+ {
+ var waitingByParent = new Dictionary>
+ {
+ [10u] = [20u],
+ [20u] = [30u],
+ };
+ var realized = new List();
+
+ new AttachmentUpdateOrder().RealizeDescendants(
+ 10u,
+ parent => waitingByParent.TryGetValue(parent, out var waiting)
+ ? waiting
+ : Array.Empty(),
+ child =>
+ {
+ realized.Add(child);
+ return true;
+ });
+
+ Assert.Equal([20u, 30u], realized);
+ }
+
+ [Fact]
+ public void PoseRecoveryDoesNotTraverseThroughUnrealizedIntermediateParent()
+ {
+ var waitingByParent = new Dictionary>
+ {
+ [10u] = [20u],
+ [20u] = [30u],
+ };
+ var attempted = new List();
+
+ new AttachmentUpdateOrder().RealizeDescendants(
+ 10u,
+ parent => waitingByParent.TryGetValue(parent, out var waiting)
+ ? waiting
+ : Array.Empty(),
+ child =>
+ {
+ attempted.Add(child);
+ return false;
+ });
+
+ Assert.Equal([20u], attempted);
+ }
+
+ [Fact]
+ public void DrawableRootRejectsVisualScaleInRigidPoseChannel()
+ {
+ var child = new AcDream.Core.World.WorldEntity
+ {
+ Id = 4u,
+ SourceGfxObjOrSetupId = 0x02000004u,
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ MeshRefs = Array.Empty(),
+ };
+
+ Assert.False(EquippedChildRenderController.ApplyParentWorldPose(
+ child,
+ Matrix4x4.CreateScale(2f) * Matrix4x4.CreateTranslation(10, 0, 0)));
+ }
+
+ private sealed class Vector3ToleranceComparer : IEqualityComparer
+ {
+ public bool Equals(Vector3 x, Vector3 y) => Vector3.DistanceSquared(x, y) < 1e-8f;
+ public int GetHashCode(Vector3 obj) => obj.GetHashCode();
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs b/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs
index 92f5fc59..37fc47ce 100644
--- a/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs
@@ -24,7 +24,8 @@ public sealed class LiveAppearanceAnimationTests
HighFrame = 9,
Framerate = 30f,
Scale = 1f,
- PartTemplate = [(0x01000001u, null)],
+ PartTemplate = [new GameWindow.AnimatedPartTemplate(0x01000001u, null, true)],
+ PartAvailability = [true],
CurrFrame = 6.5f,
Sequencer = sequencer,
};
@@ -36,7 +37,15 @@ public sealed class LiveAppearanceAnimationTests
oldEntity.ApplyAppearance(dressedParts, paletteOverride: null, partOverrides: []);
GameWindow.RebindAnimatedEntityForAppearance(
- state, oldEntity, setup, 1.25f, dressedParts);
+ state,
+ oldEntity,
+ setup,
+ 1.25f,
+ [
+ new GameWindow.AnimatedPartTemplate(0x01000002u, null, true),
+ new GameWindow.AnimatedPartTemplate(0x01000003u, null, true),
+ ],
+ [true, true]);
Assert.Same(oldEntity, state.Entity);
Assert.Same(dressedParts, state.Entity.MeshRefs);
diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs
index df87949b..a5db86c4 100644
--- a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs
@@ -70,6 +70,7 @@ public sealed class EntityEffectControllerTests
new PhysicsScriptTableResolver(
tableLoader
?? (id => _tables.TryGetValue(id, out PhysicsScriptTable? table) ? table : null)),
+ Poses,
childAtPart,
parentOfAttachedChild,
ownerUnregistered,
@@ -88,6 +89,7 @@ public sealed class EntityEffectControllerTests
public AnimationHookRouter Router { get; } = new();
public PhysicsScriptRunner Runner { get; }
public EntityEffectController Controller { get; }
+ public EntityEffectPoseRegistry Poses { get; } = new();
public void AddScript(uint did, uint emitterId, double startTime = 0.0)
{
@@ -246,7 +248,7 @@ public sealed class EntityEffectControllerTests
0x0202FFFFu,
new LandBlock(),
Array.Empty()));
- fixture.Controller.RefreshLiveOwnerAnchors();
+ fixture.Controller.RefreshLiveOwnerPoses();
fixture.Runner.Tick(0.0);
Assert.Equal(0, fixture.Controller.PendingPacketCount);
@@ -549,6 +551,34 @@ public sealed class EntityEffectControllerTests
Assert.False(fixture.Controller.PlayDefault(childLocalId));
}
+ [Fact]
+ public void RefreshLiveOwnerPoses_PreservesComposedAttachedRootAndUsesItAsScriptAnchor()
+ {
+ const uint childGuid = 0x70000002u;
+ var fixture = new Fixture();
+ WorldEntity child = fixture.ReadyLive(childGuid);
+ WorldSession.EntitySpawn spawn = Spawn(childGuid, generation: 1);
+ fixture.Runtime.MaterializeLiveEntity(
+ childGuid,
+ spawn.Position!.Value.LandblockId,
+ _ => throw new InvalidOperationException("existing projection must be reused"),
+ LiveEntityProjectionKind.Attached);
+ Matrix4x4 attachedRoot = Matrix4x4.CreateTranslation(7, 8, 9);
+ fixture.Poses.Publish(
+ child.Id,
+ attachedRoot,
+ Array.Empty(),
+ spawn.Position.Value.LandblockId);
+
+ fixture.Controller.RefreshLiveOwnerPoses();
+ fixture.Controller.HandleDirect(new PlayPhysicsScript(childGuid, DirectDid));
+ fixture.Runner.Tick(0.0);
+
+ Assert.True(fixture.Poses.TryGetRootPose(child.Id, out Matrix4x4 retained));
+ Assert.Equal(new Vector3(7, 8, 9), retained.Translation);
+ Assert.Equal(new Vector3(7, 8, 9), Assert.Single(fixture.Sink.Calls).Position);
+ }
+
[Fact]
public void MissingDirectScriptReportsOwnerAndDid()
{
@@ -627,7 +657,7 @@ public sealed class EntityEffectControllerTests
}
[Fact]
- public void RefreshLiveOwnerAnchorsUsesCurrentWorldPositionAtDispatch()
+ public void RefreshLiveOwnerPosesUsesCurrentWorldPositionAtDispatch()
{
var fixture = new Fixture();
fixture.AddScript(DirectDid, 1u, startTime: 1.0);
@@ -635,7 +665,7 @@ public sealed class EntityEffectControllerTests
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
entity.SetPosition(new Vector3(10, 20, 30));
- fixture.Controller.RefreshLiveOwnerAnchors();
+ fixture.Controller.RefreshLiveOwnerPoses();
fixture.Runner.Tick(1.0);
Assert.Equal(new Vector3(10, 20, 30), Assert.Single(fixture.Sink.Calls).Position);
diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectPoseRegistryTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectPoseRegistryTests.cs
new file mode 100644
index 00000000..d3a8632b
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectPoseRegistryTests.cs
@@ -0,0 +1,166 @@
+using System.Numerics;
+using AcDream.App.Rendering.Vfx;
+using AcDream.Core.Physics;
+using AcDream.Core.World;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Types;
+
+namespace AcDream.App.Tests.Rendering.Vfx;
+
+public sealed class EntityEffectPoseRegistryTests
+{
+ [Fact]
+ public void Publish_PreservesIndexedParts_AndUpdateRootDoesNotReplaceThem()
+ {
+ var poses = new EntityEffectPoseRegistry();
+ WorldEntity entity = Entity(7u, new Vector3(1, 2, 3));
+ poses.Publish(entity, new[]
+ {
+ Matrix4x4.Identity,
+ Matrix4x4.CreateTranslation(0, 0, 4),
+ });
+
+ entity.SetPosition(new Vector3(10, 20, 30));
+ Assert.True(poses.UpdateRoot(entity));
+
+ Assert.True(poses.TryGetRootPose(7u, out Matrix4x4 root));
+ Assert.Equal(new Vector3(10, 20, 30), root.Translation);
+ Assert.True(poses.TryGetPartPose(7u, 1, out Matrix4x4 part));
+ Assert.Equal(new Vector3(0, 0, 4), part.Translation);
+ Assert.False(poses.TryGetPartPose(7u, -1, out _));
+ }
+
+ [Fact]
+ public void PublishMeshRefs_ReplacesPartSnapshotImmediately()
+ {
+ var poses = new EntityEffectPoseRegistry();
+ WorldEntity entity = Entity(8u, Vector3.Zero);
+ poses.Publish(entity, new[] { Matrix4x4.Identity });
+ entity.MeshRefs = new[]
+ {
+ new MeshRef(0x01000001u, Matrix4x4.CreateTranslation(1, 2, 3)),
+ new MeshRef(0x01000002u, Matrix4x4.CreateTranslation(4, 5, 6)),
+ };
+
+ poses.PublishMeshRefs(entity);
+
+ Assert.True(poses.TryGetPartPoses(8u, out var parts));
+ Assert.Equal(2, parts.Count);
+ Assert.Equal(new Vector3(4, 5, 6), parts[1].Translation);
+ }
+
+ [Fact]
+ public void Publish_MissingMiddlePartRetainsIndicesButCannotBeResolved()
+ {
+ var poses = new EntityEffectPoseRegistry();
+ WorldEntity entity = Entity(81u, Vector3.Zero);
+
+ poses.Publish(
+ entity,
+ [
+ Matrix4x4.CreateTranslation(1, 0, 0),
+ Matrix4x4.CreateTranslation(2, 0, 0),
+ Matrix4x4.CreateTranslation(3, 0, 0),
+ ],
+ [true, false, true]);
+
+ Assert.False(poses.TryGetPartPose(81u, 1, out _));
+ Assert.True(poses.TryGetPartPose(81u, 2, out Matrix4x4 third));
+ Assert.Equal(3f, third.Translation.X);
+ }
+
+ [Fact]
+ public void PublishMeshRefs_PrefersStableIndexedEntityPosesOverCompactedDrawables()
+ {
+ var poses = new EntityEffectPoseRegistry();
+ WorldEntity entity = Entity(82u, Vector3.Zero);
+ entity.MeshRefs =
+ [
+ new MeshRef(1u, Matrix4x4.CreateTranslation(1, 0, 0)),
+ new MeshRef(3u, Matrix4x4.CreateTranslation(3, 0, 0)),
+ ];
+ entity.SetIndexedPartPoses(
+ [
+ Matrix4x4.CreateTranslation(1, 0, 0),
+ Matrix4x4.CreateTranslation(2, 0, 0),
+ Matrix4x4.CreateTranslation(3, 0, 0),
+ ],
+ [true, false, true]);
+
+ poses.PublishMeshRefs(entity);
+
+ Assert.False(poses.TryGetPartPose(82u, 1, out _));
+ Assert.True(poses.TryGetPartPose(82u, 2, out Matrix4x4 third));
+ Assert.Equal(3f, third.Translation.X);
+ }
+
+ [Fact]
+ public void AnimationHookQueue_DrainsAgainstPosePublishedAfterCapture()
+ {
+ var poses = new EntityEffectPoseRegistry();
+ WorldEntity entity = Entity(9u, Vector3.Zero);
+ poses.Publish(entity, Array.Empty());
+ var router = new AnimationHookRouter();
+ var sink = new RecordingSink();
+ router.Register(sink);
+ var queue = new AnimationHookFrameQueue(router, poses);
+ var sequencer = new AnimationSequencer(
+ new Setup(),
+ new MotionTable(),
+ new NullAnimationLoader());
+
+ queue.Capture(9u, sequencer, new AnimationHook[] { new SoundHook() });
+ entity.SetPosition(new Vector3(4, 5, 6));
+ poses.UpdateRoot(entity);
+ queue.Drain();
+
+ Assert.Single(sink.Calls);
+ Assert.Equal(new Vector3(4, 5, 6), sink.Calls[0].Position);
+ Assert.Equal(0, queue.Count);
+ }
+
+ [Fact]
+ public void AnimationHookQueue_CompletesMotionStateEvenWhenPoseWasRemoved()
+ {
+ var poses = new EntityEffectPoseRegistry();
+ var queue = new AnimationHookFrameQueue(new AnimationHookRouter(), poses);
+ var sequencer = new AnimationSequencer(
+ new Setup(),
+ new MotionTable(),
+ new NullAnimationLoader());
+ sequencer.Manager.AddToQueue(0x10000001u, ticks: 1u);
+
+ queue.Capture(
+ 11u,
+ sequencer,
+ new AnimationHook[] { new AnimationDoneHook() });
+ queue.Drain();
+
+ Assert.Empty(sequencer.Manager.PendingAnimations);
+ Assert.Equal(0, queue.Count);
+ }
+
+ private static WorldEntity Entity(uint id, Vector3 position) => new()
+ {
+ Id = id,
+ ServerGuid = id,
+ SourceGfxObjOrSetupId = 0x02000001u,
+ Position = position,
+ Rotation = Quaternion.Identity,
+ MeshRefs = Array.Empty(),
+ ParentCellId = 0x01010001u,
+ };
+
+ private sealed class RecordingSink : IAnimationHookSink
+ {
+ public List<(uint Id, Vector3 Position)> Calls { get; } = new();
+
+ public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook) =>
+ Calls.Add((entityId, entityWorldPosition));
+ }
+
+ private sealed class NullAnimationLoader : IAnimationLoader
+ {
+ public Animation? LoadAnimation(uint animationId) => null;
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs
index a0a830d1..45f0981d 100644
--- a/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs
@@ -43,6 +43,7 @@ public sealed class EntityScriptActivatorTests
private record Pipeline(
ParticleSystem System,
ParticleHookSink Sink,
+ EntityEffectPoseRegistry Poses,
PhysicsScriptRunner Runner,
RecordingSink Recording);
@@ -50,14 +51,15 @@ public sealed class EntityScriptActivatorTests
{
var registry = new EmitterDescRegistry();
var system = new ParticleSystem(registry);
- var hookSink = new ParticleHookSink(system); // for activator's StopAllForEntity
+ var poses = new EntityEffectPoseRegistry();
+ var hookSink = new ParticleHookSink(system, poses); // for activator's StopAllForEntity
var recording = new RecordingSink(); // for runner's hook dispatch
var table = new Dictionary();
foreach (var (id, s) in scripts) table[id] = s;
var runner = new PhysicsScriptRunner(
id => table.TryGetValue(id, out var s) ? s : null,
recording);
- return new Pipeline(system, hookSink, runner, recording);
+ return new Pipeline(system, hookSink, poses, runner, recording);
}
///
@@ -74,7 +76,7 @@ public sealed class EntityScriptActivatorTests
{
var p = BuildPipeline(
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 }))));
- var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
+ var activator = new EntityScriptActivator(p.Runner, p.Sink, p.Poses, StaticResolver(0xAAu));
var entity = MakeEntity(serverGuid: 0xCAFEu, position: new Vector3(1, 2, 3));
activator.OnCreate(entity);
@@ -90,7 +92,7 @@ public sealed class EntityScriptActivatorTests
public void OnCreate_WithoutDefaultScript_DoesNothing()
{
var p = BuildPipeline(); // no scripts registered
- var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => null);
+ var activator = new EntityScriptActivator(p.Runner, p.Sink, p.Poses, _ => null);
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
activator.OnCreate(entity);
@@ -125,11 +127,11 @@ public sealed class EntityScriptActivatorTests
};
[Fact]
- public void OnCreate_SetsEntityRotationForHookOffsetTransform()
+ public void OnCreate_PublishesEntityRotationForHookOffsetTransform()
{
// The CreateParticleHook's Offset is in entity-local frame; the sink
- // needs the entity's rotation to transform it to world space. If the
- // activator forgets SetEntityRotation, the offset goes off in world
+ // reads the published entity root to transform it to world space. If
+ // the activator omits that pose, the offset goes off in world
// axes — visual symptom: portal swirls misaligned to the portal stone.
// This test verifies the seed happens by checking the spawned particle's
// world position matches the rotated offset, not the unrotated offset.
@@ -138,7 +140,8 @@ public sealed class EntityScriptActivatorTests
registry.Register(BuildPersistentEmitterDesc());
var system = new ParticleSystem(registry);
- var hookSink = new ParticleHookSink(system);
+ var poses = new EntityEffectPoseRegistry();
+ var hookSink = new ParticleHookSink(system, poses);
// Hook offset = (1, 0, 0) in entity-local frame.
var hookOffset = new Frame
@@ -147,13 +150,18 @@ public sealed class EntityScriptActivatorTests
Orientation = Quaternion.Identity,
};
var script = BuildScript(
- (0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = hookOffset }));
+ (0.0, new CreateParticleHook
+ {
+ EmitterInfoId = 100u,
+ Offset = hookOffset,
+ PartIndex = uint.MaxValue,
+ }));
var table = new Dictionary { [0xAAu] = script };
var runner = new PhysicsScriptRunner(
id => table.TryGetValue(id, out var s) ? s : null,
hookSink);
- var activator = new EntityScriptActivator(runner, hookSink, StaticResolver(0xAAu));
+ var activator = new EntityScriptActivator(runner, hookSink, poses, StaticResolver(0xAAu));
// Entity rotated 90° around world-Z (yaw left); local +X maps to world +Y.
var entityRotation = Quaternion.CreateFromAxisAngle(
@@ -193,15 +201,21 @@ public sealed class EntityScriptActivatorTests
registry.Register(BuildPersistentEmitterDesc());
var system = new ParticleSystem(registry);
- var hookSink = new ParticleHookSink(system);
+ var poses = new EntityEffectPoseRegistry();
+ var hookSink = new ParticleHookSink(system, poses);
- var script = BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() }));
+ var script = BuildScript((0.0, new CreateParticleHook
+ {
+ EmitterInfoId = 100u,
+ Offset = new Frame(),
+ PartIndex = uint.MaxValue,
+ }));
var table = new Dictionary { [0xAAu] = script };
var runner = new PhysicsScriptRunner(
id => table.TryGetValue(id, out var s) ? s : null,
hookSink); // runner dispatches into real sink, not RecordingSink
- var activator = new EntityScriptActivator(runner, hookSink, StaticResolver(0xAAu));
+ var activator = new EntityScriptActivator(runner, hookSink, poses, StaticResolver(0xAAu));
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
activator.OnCreate(entity);
@@ -226,7 +240,7 @@ public sealed class EntityScriptActivatorTests
// 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 activator = new EntityScriptActivator(p.Runner, p.Sink, p.Poses, StaticResolver(0xAAu));
var entity = new WorldEntity
{
Id = 0x40A9B401u, // dat-hydrated interior id
@@ -251,7 +265,7 @@ public sealed class EntityScriptActivatorTests
{
var p = BuildPipeline(
(0xAAu, BuildScript((1.0, new CreateParticleHook { EmitterInfoId = 100 }))));
- var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
+ var activator = new EntityScriptActivator(p.Runner, p.Sink, p.Poses, StaticResolver(0xAAu));
WorldEntity first = MakeDatStatic(0x80A9B400u, new Vector3(1, 0, 0));
WorldEntity second = MakeDatStatic(0x80A9B400u, new Vector3(2, 0, 0));
@@ -268,7 +282,7 @@ public sealed class EntityScriptActivatorTests
public void LiveParticleFilterUsesCanonicalLocalEffectOwnerNotServerGuid()
{
var p = BuildPipeline();
- var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => null);
+ var activator = new EntityScriptActivator(p.Runner, p.Sink, p.Poses, _ => null);
var entity = new WorldEntity
{
Id = 1_000_123u,
@@ -285,6 +299,34 @@ public sealed class EntityScriptActivatorTests
Assert.NotEqual(entity.ServerGuid, GameWindow.ParticleEntityKey(entity));
}
+ [Fact]
+ public void RemovingLiveOwner_DoesNotClearIndependentDatStaticPose()
+ {
+ var p = BuildPipeline();
+ var activator = new EntityScriptActivator(
+ p.Runner,
+ p.Sink,
+ p.Poses,
+ StaticResolver(0));
+ WorldEntity datStatic = MakeDatStatic(0x40A9B410u, Vector3.One);
+ var live = new WorldEntity
+ {
+ Id = 1_000_410u,
+ ServerGuid = 0x70000410u,
+ SourceGfxObjOrSetupId = 0x02000001u,
+ Position = new Vector3(2, 2, 2),
+ Rotation = Quaternion.Identity,
+ MeshRefs = Array.Empty(),
+ };
+ activator.OnCreate(datStatic);
+ activator.OnCreate(live);
+
+ activator.OnRemove(live);
+
+ Assert.True(p.Poses.TryGetRootPose(datStatic.Id, out _));
+ Assert.False(p.Poses.TryGetRootPose(live.Id, out _));
+ }
+
[Fact]
public void OnCreate_PassesPartTransformsToSink()
{
@@ -295,7 +337,8 @@ public sealed class EntityScriptActivatorTests
var registry = new EmitterDescRegistry();
registry.Register(BuildPersistentEmitterDesc());
var system = new ParticleSystem(registry);
- var hookSink = new ParticleHookSink(system);
+ var poses = new EntityEffectPoseRegistry();
+ var hookSink = new ParticleHookSink(system, poses);
var hookOffset = new Frame { Origin = new Vector3(1f, 0, 0), Orientation = Quaternion.Identity };
var script = BuildScript(
@@ -311,7 +354,7 @@ public sealed class EntityScriptActivatorTests
Matrix4x4.CreateTranslation(0f, 0f, 1f),
};
- var activator = new EntityScriptActivator(runner, hookSink,
+ var activator = new EntityScriptActivator(runner, hookSink, poses,
_ => new ScriptActivationInfo(0xAAu, partTransforms));
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
@@ -335,15 +378,21 @@ public sealed class EntityScriptActivatorTests
var registry = new EmitterDescRegistry();
registry.Register(BuildPersistentEmitterDesc());
var system = new ParticleSystem(registry);
- var hookSink = new ParticleHookSink(system);
+ var poses = new EntityEffectPoseRegistry();
+ var hookSink = new ParticleHookSink(system, poses);
- var script = BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() }));
+ var script = BuildScript((0.0, new CreateParticleHook
+ {
+ EmitterInfoId = 100u,
+ Offset = new Frame(),
+ PartIndex = uint.MaxValue,
+ }));
var table = new Dictionary { [0xAAu] = script };
var runner = new PhysicsScriptRunner(
id => table.TryGetValue(id, out var s) ? s : null,
hookSink);
- var activator = new EntityScriptActivator(runner, hookSink, StaticResolver(0xAAu));
+ var activator = new EntityScriptActivator(runner, hookSink, poses, StaticResolver(0xAAu));
var entity = new WorldEntity
{
Id = 0x40A9B402u,
diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/IndexedSetupPartPoseBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/IndexedSetupPartPoseBuilderTests.cs
new file mode 100644
index 00000000..8f14eaf1
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Vfx/IndexedSetupPartPoseBuilderTests.cs
@@ -0,0 +1,36 @@
+using System.Numerics;
+using AcDream.App.Rendering.Vfx;
+using AcDream.Core.World;
+using DatReaderWriter.DBObjs;
+
+namespace AcDream.App.Tests.Rendering.Vfx;
+
+public sealed class IndexedSetupPartPoseBuilderTests
+{
+ [Fact]
+ public void Build_DoesNotCollapseMissingMiddleDrawable()
+ {
+ var setup = new Setup
+ {
+ Parts = { 0x01000001u, 0x01000002u, 0x01000003u },
+ };
+ var entity = new WorldEntity
+ {
+ Id = 7u,
+ SourceGfxObjOrSetupId = 0x02000001u,
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ MeshRefs =
+ [
+ new MeshRef(0x01000001u, Matrix4x4.CreateTranslation(1, 0, 0)),
+ new MeshRef(0x01000003u, Matrix4x4.CreateTranslation(3, 0, 0)),
+ ],
+ };
+
+ var indexed = IndexedSetupPartPoseBuilder.Build(setup, entity);
+
+ Assert.Equal([true, false, true], indexed.Available);
+ Assert.Equal(Matrix4x4.Identity, indexed.Poses[0]);
+ Assert.Equal(Matrix4x4.Identity, indexed.Poses[2]);
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/LiveEntityLightControllerTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/LiveEntityLightControllerTests.cs
new file mode 100644
index 00000000..c76039fa
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Vfx/LiveEntityLightControllerTests.cs
@@ -0,0 +1,244 @@
+using System.Numerics;
+using AcDream.App.Rendering.Vfx;
+using AcDream.App.Streaming;
+using AcDream.App.World;
+using AcDream.Core.Lighting;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+using AcDream.Core.World;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Types;
+
+namespace AcDream.App.Tests.Rendering.Vfx;
+
+public sealed class LiveEntityLightControllerTests
+{
+ [Fact]
+ public void Refresh_FollowsCurrentTopLevelRootAndCell()
+ {
+ var fixture = new Fixture();
+ WorldEntity entity = fixture.Materialize(LiveEntityProjectionKind.World);
+ LightSource light = Assert.Single(fixture.Sink.GetOwnedLights(entity.Id)!);
+
+ entity.SetPosition(new Vector3(20, 30, 40));
+ entity.ParentCellId = 0x01010002u;
+ Assert.True(fixture.Poses.UpdateRoot(entity));
+ fixture.Controller.Refresh();
+
+ Assert.Equal(new Vector3(21, 30, 40), light.WorldPosition);
+ Assert.Equal(0x01010002u, light.CellId);
+ Assert.True(light.IsDynamic);
+ }
+
+ [Fact]
+ public void Register_AttachedOwnerPreservesComposedChildRoot()
+ {
+ var fixture = new Fixture();
+ WorldEntity entity = fixture.Materialize(LiveEntityProjectionKind.Attached);
+ fixture.Poses.Publish(
+ entity.Id,
+ Matrix4x4.CreateTranslation(50, 60, 70),
+ Array.Empty(),
+ 0x01010001u);
+
+ fixture.Controller.OnAttachedPoseReady(Fixture.Guid);
+ fixture.Controller.Refresh();
+
+ LightSource light = Assert.Single(fixture.Sink.GetOwnedLights(entity.Id)!);
+ Assert.Equal(new Vector3(51, 60, 70), light.WorldPosition);
+ }
+
+ [Fact]
+ public void AuthoritativeLightingTransitions_DestroyAndRecreateSetupLights()
+ {
+ var fixture = new Fixture();
+ WorldEntity entity = fixture.Materialize(LiveEntityProjectionKind.World);
+ Assert.Equal(1, fixture.Lights.RegisteredCount);
+
+ Assert.True(fixture.Runtime.TryApplyState(
+ new SetState.Parsed(
+ Fixture.Guid,
+ (uint)PhysicsStateFlags.ReportCollisions,
+ 1,
+ 2),
+ out _));
+ fixture.Controller.OnStateChanged(Fixture.Guid);
+ Assert.Equal(0, fixture.Lights.RegisteredCount);
+
+ Assert.True(fixture.Runtime.TryApplyState(
+ new SetState.Parsed(
+ Fixture.Guid,
+ (uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Lighting),
+ 1,
+ 3),
+ out _));
+ fixture.Controller.OnStateChanged(Fixture.Guid);
+ Assert.Equal(1, fixture.Lights.RegisteredCount);
+ Assert.Single(fixture.Sink.GetOwnedLights(entity.Id)!);
+ }
+
+ [Fact]
+ public void SetLightHookStateSurvivesProjectionWithdrawal()
+ {
+ var fixture = new Fixture();
+ WorldEntity entity = fixture.Materialize(LiveEntityProjectionKind.World);
+
+ fixture.Sink.OnHook(
+ entity.Id,
+ entity.Position,
+ new SetLightHook { LightsOn = false });
+ Assert.Equal(0, fixture.Lights.RegisteredCount);
+
+ fixture.Controller.Unregister(entity.Id);
+ Assert.False(fixture.Controller.Register(Fixture.Guid));
+ Assert.Equal(0, fixture.Lights.RegisteredCount);
+ }
+
+ [Fact]
+ public void LoadedPendingLoadedTransitionsOwnLightPresentationExactlyOnce()
+ {
+ var fixture = new Fixture();
+ fixture.Materialize(LiveEntityProjectionKind.World);
+ Assert.Equal(1, fixture.Lights.RegisteredCount);
+
+ fixture.Spatial.RemoveLandblock(0x0101FFFFu);
+ Assert.Equal(0, fixture.Lights.RegisteredCount);
+
+ fixture.Spatial.AddLandblock(new LoadedLandblock(
+ 0x0101FFFFu,
+ new LandBlock(),
+ Array.Empty()));
+ Assert.Equal(1, fixture.Lights.RegisteredCount);
+ }
+
+ [Fact]
+ public void InitialVisibleProjectionLoadsSetupLightsOnce()
+ {
+ var fixture = new Fixture();
+
+ fixture.Materialize(LiveEntityProjectionKind.World);
+
+ Assert.Equal(1, fixture.SetupLoadCount);
+ Assert.Equal(1, fixture.Lights.RegisteredCount);
+ }
+
+ [Fact]
+ public void LoadedToLoadedRebucketDoesNotRecreateLightPresentation()
+ {
+ var fixture = new Fixture();
+ fixture.Spatial.AddLandblock(new LoadedLandblock(
+ 0x0202FFFFu,
+ new LandBlock(),
+ Array.Empty()));
+ fixture.Materialize(LiveEntityProjectionKind.World);
+ Assert.Equal(1, fixture.SetupLoadCount);
+
+ Assert.True(fixture.Runtime.RebucketLiveEntity(Fixture.Guid, 0x02020001u));
+
+ Assert.Equal(1, fixture.SetupLoadCount);
+ Assert.Equal(1, fixture.Lights.RegisteredCount);
+ }
+
+ private sealed class Fixture
+ {
+ public const uint Guid = 0x70000001u;
+ private readonly Setup _setup = BuildSetup();
+
+ public Fixture()
+ {
+ Spatial.AddLandblock(new LoadedLandblock(
+ 0x0101FFFFu,
+ new LandBlock(),
+ Array.Empty()));
+ Runtime = new LiveEntityRuntime(Spatial, new NoopResources(), _ => { });
+ Sink = new LightingHookSink(Lights, Poses);
+ Controller = new LiveEntityLightController(
+ Runtime,
+ Poses,
+ Sink,
+ setupId =>
+ {
+ SetupLoadCount++;
+ return setupId == 0x02000001u ? _setup : null;
+ });
+ }
+
+ public GpuWorldState Spatial { get; } = new();
+ public LiveEntityRuntime Runtime { get; }
+ public EntityEffectPoseRegistry Poses { get; } = new();
+ public LightManager Lights { get; } = new();
+ public LightingHookSink Sink { get; }
+ public LiveEntityLightController Controller { get; }
+ public int SetupLoadCount { get; private set; }
+
+ public WorldEntity Materialize(LiveEntityProjectionKind kind)
+ {
+ WorldSession.EntitySpawn spawn = Spawn();
+ Runtime.RegisterLiveEntity(spawn);
+ WorldEntity entity = Runtime.MaterializeLiveEntity(
+ Guid,
+ spawn.Position!.Value.LandblockId,
+ id => new WorldEntity
+ {
+ Id = id,
+ ServerGuid = Guid,
+ SourceGfxObjOrSetupId = 0x02000001u,
+ Position = new Vector3(10, 10, 5),
+ Rotation = Quaternion.Identity,
+ ParentCellId = 0x01010001u,
+ MeshRefs = Array.Empty(),
+ },
+ kind)!;
+ Poses.PublishMeshRefs(entity);
+ return entity;
+ }
+
+ private static Setup BuildSetup()
+ {
+ var setup = new Setup();
+ setup.Lights[0] = new LightInfo
+ {
+ ViewSpaceLocation = new Frame
+ {
+ Origin = Vector3.UnitX,
+ Orientation = Quaternion.Identity,
+ },
+ Color = new ColorARGB { Red = 255, Green = 255, Blue = 255, Alpha = 255 },
+ Intensity = 1f,
+ Falloff = 8f,
+ };
+ return setup;
+ }
+
+ private static WorldSession.EntitySpawn Spawn()
+ {
+ var position = new CreateObject.ServerPosition(
+ 0x01010001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
+ return new WorldSession.EntitySpawn(
+ Guid,
+ position,
+ 0x02000001u,
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ null,
+ null,
+ "fixture",
+ null,
+ null,
+ null,
+ PhysicsState: (uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Lighting),
+ InstanceSequence: 1,
+ MovementSequence: 1,
+ ServerControlSequence: 1,
+ PositionSequence: 1);
+ }
+ }
+
+ private sealed class NoopResources : ILiveEntityResourceLifecycle
+ {
+ public void Register(WorldEntity entity) { }
+ public void Unregister(WorldEntity entity) { }
+ }
+}
diff --git a/tests/AcDream.App.Tests/Streaming/GpuWorldStateActivatorTests.cs b/tests/AcDream.App.Tests/Streaming/GpuWorldStateActivatorTests.cs
index e263ff07..a210a5f6 100644
--- a/tests/AcDream.App.Tests/Streaming/GpuWorldStateActivatorTests.cs
+++ b/tests/AcDream.App.Tests/Streaming/GpuWorldStateActivatorTests.cs
@@ -49,10 +49,11 @@ public sealed class GpuWorldStateActivatorTests
var registry = new EmitterDescRegistry();
var system = new ParticleSystem(registry);
- var sink = new ParticleHookSink(system);
+ var poses = new EntityEffectPoseRegistry();
+ var sink = new ParticleHookSink(system, poses);
var recording = new RecordingSink();
var runner = new PhysicsScriptRunner(id => table.TryGetValue(id, out var s) ? s : null, recording);
- var activator = new EntityScriptActivator(runner, sink,
+ var activator = new EntityScriptActivator(runner, sink, poses,
_ => new ScriptActivationInfo(scriptId, Array.Empty()));
var state = new GpuWorldState(entityScriptActivator: activator);
diff --git a/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs b/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs
index 676155bf..9ca3d840 100644
--- a/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs
+++ b/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs
@@ -1,5 +1,6 @@
using System.Numerics;
using AcDream.Core.Lighting;
+using AcDream.Core.Vfx;
using DatReaderWriter.Types;
using Xunit;
@@ -11,7 +12,7 @@ public sealed class LightingHookSinkTests
public void SetLightHook_FlipsOwnedLights()
{
var mgr = new LightManager();
- var sink = new LightingHookSink(mgr);
+ var sink = new LightingHookSink(mgr, new MutablePoseSource());
var light1 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true };
var light2 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true };
@@ -32,7 +33,7 @@ public sealed class LightingHookSinkTests
public void UnregisterOwner_RemovesAllOwnedLights()
{
var mgr = new LightManager();
- var sink = new LightingHookSink(mgr);
+ var sink = new LightingHookSink(mgr, new MutablePoseSource());
sink.RegisterOwnedLight(new LightSource { OwnerId = 7 });
sink.RegisterOwnedLight(new LightSource { OwnerId = 7 });
@@ -46,7 +47,7 @@ public sealed class LightingHookSinkTests
public void UnrelatedHook_Ignored()
{
var mgr = new LightManager();
- var sink = new LightingHookSink(mgr);
+ var sink = new LightingHookSink(mgr, new MutablePoseSource());
var light = new LightSource { OwnerId = 1, IsLit = true };
sink.RegisterOwnedLight(light);
@@ -56,6 +57,69 @@ public sealed class LightingHookSinkTests
Assert.True(light.IsLit);
}
+
+ [Fact]
+ public void RefreshAttachedLights_ComposesLocalFrameWithCurrentRootAndCell()
+ {
+ var mgr = new LightManager();
+ var poses = new MutablePoseSource();
+ poses.Publish(42u,
+ Matrix4x4.CreateRotationZ(MathF.PI / 2f)
+ * Matrix4x4.CreateTranslation(10, 20, 30),
+ cellId: 0x01010002u);
+ var sink = new LightingHookSink(mgr, poses);
+ var light = new LightSource
+ {
+ OwnerId = 42u,
+ LocalPose = Matrix4x4.CreateTranslation(1, 0, 2),
+ TracksOwnerPose = true,
+ };
+ sink.RegisterOwnedLight(light);
+
+ sink.RefreshAttachedLights();
+
+ Assert.InRange(light.WorldPosition.X, 9.99f, 10.01f);
+ Assert.InRange(light.WorldPosition.Y, 20.99f, 21.01f);
+ Assert.InRange(light.WorldPosition.Z, 31.99f, 32.01f);
+ Assert.Equal(0x01010002u, light.CellId);
+ }
+
+ [Fact]
+ public void RefreshAttachedLights_DoesNotRecomposeDatStaticLight()
+ {
+ var mgr = new LightManager();
+ var poses = new MutablePoseSource();
+ poses.Publish(42u, Matrix4x4.CreateTranslation(100, 0, 0));
+ var sink = new LightingHookSink(mgr, poses);
+ var light = new LightSource
+ {
+ OwnerId = 42u,
+ WorldPosition = new Vector3(7, 8, 9),
+ LocalPose = Matrix4x4.CreateTranslation(1, 0, 0),
+ TracksOwnerPose = false,
+ };
+ sink.RegisterOwnedLight(light);
+
+ sink.RefreshAttachedLights();
+
+ Assert.Equal(new Vector3(7, 8, 9), light.WorldPosition);
+ Assert.Equal(0, poses.RootPoseQueryCount);
+ }
+
+ [Fact]
+ public void UnregisterPresentation_PreservesSetLightStateForReregistration()
+ {
+ var mgr = new LightManager();
+ var sink = new LightingHookSink(mgr, new MutablePoseSource());
+ sink.InitializeOwnerLighting(7u, enabled: true);
+ sink.SetOwnerLighting(7u, enabled: false);
+ sink.UnregisterOwner(7u, forgetState: false);
+ var replacement = new LightSource { OwnerId = 7u };
+
+ sink.RegisterOwnedLight(replacement);
+
+ Assert.False(replacement.IsLit);
+ }
}
public sealed class LightInfoLoaderTests
@@ -96,9 +160,33 @@ public sealed class LightInfoLoaderTests
Assert.Equal(10.4f, light.Range, 3); // Falloff 8 × static_light_factor 1.3 (calc_point_light 0x00820e24)
Assert.Equal(0.8f, light.Intensity);
Assert.Equal(new Vector3(101, 202, 303), light.WorldPosition);
+ Assert.Equal(new Vector3(1, 2, 3), light.LocalPose.Translation);
Assert.InRange(light.ColorLinear.X, 0.99f, 1.01f);
}
+ [Fact]
+ public void Load_DynamicLight_UsesRetailHardwareRangeFactor()
+ {
+ var setup = new DatReaderWriter.DBObjs.Setup();
+ setup.Lights[0] = new LightInfo
+ {
+ ViewSpaceLocation = new Frame { Orientation = Quaternion.Identity },
+ Color = new ColorARGB { Red = 255, Green = 255, Blue = 255, Alpha = 255 },
+ Intensity = 1f,
+ Falloff = 8f,
+ };
+
+ var light = Assert.Single(LightInfoLoader.Load(
+ setup,
+ ownerId: 77u,
+ entityPosition: Vector3.Zero,
+ entityRotation: Quaternion.Identity,
+ isDynamic: true));
+
+ Assert.True(light.IsDynamic);
+ Assert.Equal(12f, light.Range, 3);
+ }
+
[Fact]
public void Load_NonZeroConeAngle_ProducesSpot()
{
@@ -117,3 +205,42 @@ public sealed class LightInfoLoaderTests
Assert.Equal(0.5f, result[0].ConeAngle);
}
}
+
+file sealed class MutablePoseSource : IEntityEffectPoseSource, IEntityEffectCellSource
+{
+ private readonly Dictionary _poses = new();
+
+ public void Publish(uint id, Matrix4x4 root, uint cellId = 0) =>
+ _poses[id] = (root, cellId);
+
+ public int RootPoseQueryCount { get; private set; }
+
+ public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
+ {
+ RootPoseQueryCount++;
+ if (_poses.TryGetValue(localEntityId, out var pose))
+ {
+ rootWorld = pose.Root;
+ return true;
+ }
+ rootWorld = default;
+ return false;
+ }
+
+ public bool TryGetPartPose(uint localEntityId, int partIndex, out Matrix4x4 partLocal)
+ {
+ partLocal = default;
+ return false;
+ }
+
+ public bool TryGetCellId(uint localEntityId, out uint cellId)
+ {
+ if (_poses.TryGetValue(localEntityId, out var pose))
+ {
+ cellId = pose.Cell;
+ return true;
+ }
+ cellId = 0;
+ return false;
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Meshing/EquippedChildAttachmentTests.cs b/tests/AcDream.Core.Tests/Meshing/EquippedChildAttachmentTests.cs
index 3c8dd1de..e69da76a 100644
--- a/tests/AcDream.Core.Tests/Meshing/EquippedChildAttachmentTests.cs
+++ b/tests/AcDream.Core.Tests/Meshing/EquippedChildAttachmentTests.cs
@@ -25,8 +25,8 @@ public sealed class EquippedChildAttachmentTests
};
var parentPose = new[]
{
- new MeshRef(1, Matrix4x4.Identity),
- new MeshRef(2, Matrix4x4.CreateTranslation(10, 0, 0)),
+ Matrix4x4.Identity,
+ Matrix4x4.CreateTranslation(10, 0, 0),
};
var child = new Setup
{
@@ -42,12 +42,14 @@ public sealed class EquippedChildAttachmentTests
};
var template = new[] { new MeshRef(0x01000001u, Matrix4x4.Identity) };
- bool ok = EquippedChildAttachment.TryCompose(
+ bool ok = EquippedChildAttachment.TryComposePose(
parent, parentPose, child, ParentLocation.LeftHand,
- Placement.LeftHand, template, 1.0f, out var result);
+ Placement.LeftHand, template, 1.0f, out EquippedChildPose pose);
Assert.True(ok);
- Assert.Equal(15f, Assert.Single(result).PartTransform.Translation.X);
+ Assert.Equal(12f, pose.RootLocal.Translation.X);
+ Assert.Equal(3f, Assert.Single(pose.PartLocal).Translation.X);
+ Assert.Equal(15f, Assert.Single(pose.AttachedParts).PartTransform.Translation.X);
}
[Fact]
@@ -88,6 +90,115 @@ public sealed class EquippedChildAttachmentTests
Assert.Empty(result);
}
+ [Fact]
+ public void TryComposePoseInto_UnavailableValidPartRejectsWithoutRootFallback()
+ {
+ var parent = ParentWithRightHand(partId: 1, FrameAt(4, 0, 0));
+ var child = ChildWithOnePart();
+
+ bool ok = EquippedChildAttachment.TryComposePoseInto(
+ parent,
+ [Matrix4x4.Identity, Matrix4x4.CreateTranslation(10, 0, 0)],
+ [true, false],
+ child,
+ ParentLocation.RightHand,
+ Placement.Default,
+ [new MeshRef(0x01000001u, Matrix4x4.Identity)],
+ 1f,
+ null,
+ null,
+ out _);
+
+ Assert.False(ok);
+ }
+
+ [Fact]
+ public void TryComposePoseInto_OutOfRangePartUsesRetailRootAndReusesBuffers()
+ {
+ var parent = ParentWithRightHand(partId: 99, FrameAt(4, 0, 0));
+ var child = ChildWithOnePart();
+ var poseBuffer = new Matrix4x4[1];
+ var meshBuffer = new MeshRef[1];
+
+ bool ok = EquippedChildAttachment.TryComposePoseInto(
+ parent,
+ [Matrix4x4.CreateTranslation(10, 0, 0)],
+ [true],
+ child,
+ ParentLocation.RightHand,
+ Placement.Default,
+ [new MeshRef(0x01000001u, Matrix4x4.Identity)],
+ 1f,
+ poseBuffer,
+ meshBuffer,
+ out EquippedChildPose pose);
+
+ Assert.True(ok);
+ Assert.Same(poseBuffer, pose.PartLocal);
+ Assert.Same(meshBuffer, pose.AttachedParts);
+ Assert.Equal(4f, pose.RootLocal.Translation.X);
+ }
+
+ [Fact]
+ public void NestedChildRootsComposeThroughImmediateParentWorldPose()
+ {
+ Matrix4x4 topLevelWorld = Matrix4x4.CreateTranslation(100, 0, 0);
+ Matrix4x4 childRootLocal = Matrix4x4.CreateTranslation(10, 0, 0);
+ Matrix4x4 grandchildRootLocal = Matrix4x4.CreateTranslation(2, 0, 0);
+
+ Matrix4x4 childWorld = childRootLocal * topLevelWorld;
+ Matrix4x4 grandchildWorld = grandchildRootLocal * childWorld;
+
+ Assert.Equal(112f, grandchildWorld.Translation.X);
+ }
+
+ [Fact]
+ public void ChildRigidPoseExcludesDefaultScaleAndScalesOnlyOrigin()
+ {
+ var parent = ParentWithRightHand(partId: -1, FrameAt(4, 0, 0));
+ var child = new Setup
+ {
+ Parts = { 0x01000001u },
+ DefaultScale = { new Vector3(5f, 6f, 7f) },
+ PlacementFrames =
+ {
+ [Placement.Default] = new AnimationFrame(1)
+ {
+ Frames = { FrameAt(3, 0, 0) },
+ },
+ },
+ };
+
+ Assert.True(EquippedChildAttachment.TryComposePose(
+ parent,
+ Array.Empty(),
+ child,
+ ParentLocation.RightHand,
+ Placement.Default,
+ [new MeshRef(0x01000001u, Matrix4x4.Identity)],
+ childScale: 2f,
+ out EquippedChildPose pose));
+
+ Matrix4x4 rigid = Assert.Single(pose.PartLocal);
+ Assert.Equal(new Vector3(6f, 0f, 0f), rigid.Translation);
+ Assert.Equal(Vector3.One, new Vector3(rigid.M11, rigid.M22, rigid.M33));
+ Matrix4x4 visual = Assert.Single(pose.AttachedParts).PartTransform;
+ Assert.Equal(new Vector3(10f, 12f, 14f), new Vector3(visual.M11, visual.M22, visual.M33));
+ }
+
+ private static Setup ChildWithOnePart() => new()
+ {
+ Parts = { 0x01000001u },
+ DefaultScale = { Vector3.One },
+ PlacementFrames =
+ {
+ [Placement.Default] = new AnimationFrame(1)
+ {
+ Frames = { FrameAt(1, 0, 0) },
+ },
+ },
+ };
+
private static Setup ParentWithRightHand(int partId, Frame frame) => new()
{
HoldingLocations =
diff --git a/tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs b/tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs
index dfcb6e96..c9a3c1dc 100644
--- a/tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs
+++ b/tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs
@@ -88,7 +88,7 @@ public class SetupPartTransformsTests
}
[Fact]
- public void Compute_AppliesDefaultScale_WhenPresent()
+ public void Compute_ExcludesVisualDefaultScale_FromRigidPartFrame()
{
// DefaultScale = (2,2,2) on part 0. An input (1,1,1) should
// come out (2,2,2) after the part transform — confirms the
@@ -113,6 +113,37 @@ public class SetupPartTransformsTests
Assert.Single(transforms);
var probe = Vector3.Transform(new Vector3(1f, 1f, 1f), transforms[0]);
- Assert.Equal(new Vector3(2f, 2f, 2f), probe);
+ Assert.Equal(new Vector3(1f, 1f, 1f), probe);
+ }
+
+ [Fact]
+ public void Compute_ObjectScaleMultipliesOriginButNotOrientationAxes()
+ {
+ var setup = new Setup
+ {
+ Parts = { 0x01000100u },
+ DefaultScale = { new Vector3(7f, 8f, 9f) },
+ PlacementFrames =
+ {
+ [Placement.Resting] = new AnimationFrame(1)
+ {
+ Frames =
+ {
+ new Frame
+ {
+ Origin = new Vector3(2f, 0f, 0f),
+ Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f),
+ },
+ },
+ },
+ },
+ };
+
+ Matrix4x4 rigid = Assert.Single(SetupPartTransforms.Compute(setup, objectScale: 3f));
+
+ Assert.Equal(new Vector3(6f, 0f, 0f), rigid.Translation);
+ Vector3 rotatedAxis = Vector3.TransformNormal(Vector3.UnitX, rigid);
+ Assert.InRange(rotatedAxis.X, -0.0001f, 0.0001f);
+ Assert.InRange(rotatedAxis.Y, 0.9999f, 1.0001f);
}
}
diff --git a/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs b/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs
index 2fbf8396..2568ce21 100644
--- a/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs
+++ b/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs
@@ -7,9 +7,14 @@ namespace AcDream.Core.Tests.Vfx;
public sealed class ParticleHookSinkTests
{
- private static EmitterDesc MakeDesc(uint id, bool attachLocal, int totalParticles = 0)
- {
- return new EmitterDesc
+ private const uint Owner = 0xCAFEu;
+
+ private static EmitterDesc MakeDesc(
+ uint id,
+ bool attachLocal,
+ int totalParticles = 0,
+ float totalDuration = 0f) =>
+ new()
{
DatId = id,
Type = ParticleType.Still,
@@ -18,158 +23,350 @@ public sealed class ParticleHookSinkTests
MaxParticles = 4,
InitialParticles = 1,
TotalParticles = totalParticles,
- LifetimeMin = 0.05f, LifetimeMax = 0.05f, Lifespan = 0.05f,
- StartSize = 1f, EndSize = 1f,
- StartAlpha = 1f, EndAlpha = 1f,
- Birthrate = 1000f, // effectively never re-emit
+ TotalDuration = totalDuration,
+ LifetimeMin = 0.05f,
+ LifetimeMax = 0.05f,
+ Lifespan = 0.05f,
+ StartSize = 1f,
+ EndSize = 1f,
+ StartAlpha = 1f,
+ EndAlpha = 1f,
+ Birthrate = 1000f,
};
+
+ private static (ParticleSystem System, ParticleHookSink Sink, MutablePoseSource Poses) Harness(
+ uint emitterId,
+ bool attachLocal = false,
+ int totalParticles = 0,
+ float totalDuration = 0f)
+ {
+ var registry = new EmitterDescRegistry();
+ registry.Register(MakeDesc(emitterId, attachLocal, totalParticles, totalDuration));
+ var system = new ParticleSystem(registry, new Random(42));
+ var poses = new MutablePoseSource();
+ poses.Publish(Owner, Matrix4x4.Identity, Matrix4x4.Identity);
+ return (system, new ParticleHookSink(system, poses), poses);
+ }
+
+ private static CreateParticleHook Create(uint emitterInfoId, uint logicalId, int partIndex = 0) =>
+ new()
+ {
+ EmitterInfoId = emitterInfoId,
+ EmitterId = logicalId,
+ PartIndex = unchecked((uint)partIndex),
+ Offset = new Frame(),
+ };
+
+ [Fact]
+ public void RefreshAttachedEmitters_WithAttachLocal_MovesExistingParticleToCurrentPose()
+ {
+ const uint emitterId = 0x32000010u;
+ var (system, sink, poses) = Harness(emitterId, attachLocal: true);
+ sink.OnHook(Owner, Vector3.Zero, Create(emitterId, 0));
+ system.Tick(0.01f);
+ Assert.Equal(Vector3.Zero, system.EnumerateLive().Single().Emitter.Particles[0].Position);
+
+ poses.Publish(Owner, Matrix4x4.CreateTranslation(5, 7, 0), Matrix4x4.Identity);
+ sink.RefreshAttachedEmitters();
+ system.Tick(0.01f);
+
+ Assert.Equal(new Vector3(5, 7, 0),
+ system.EnumerateLive().Single().Emitter.Particles[0].Position);
}
[Fact]
- public void UpdateEntityAnchor_WithAttachLocal_MovesParticleToLiveAnchor()
+ public void WorldReleasedParticles_KeepBirthPosition_WhileNewAnchorMoves()
{
- var registry = new EmitterDescRegistry();
- registry.Register(MakeDesc(0x32000010u, attachLocal: true));
- var sys = new ParticleSystem(registry, new System.Random(42));
- var sink = new ParticleHookSink(sys);
+ const uint emitterId = 0x32000011u;
+ var (system, sink, poses) = Harness(emitterId, attachLocal: false);
+ sink.OnHook(Owner, Vector3.Zero, Create(emitterId, 0));
+ system.Tick(0.01f);
- var hook = new CreateParticleHook
+ poses.Publish(Owner, Matrix4x4.CreateTranslation(9, 0, 0), Matrix4x4.Identity);
+ sink.RefreshAttachedEmitters();
+ system.Tick(0.01f);
+
+ Assert.Equal(Vector3.Zero,
+ system.EnumerateLive().Single().Emitter.Particles[0].Position);
+ }
+
+ [Fact]
+ public void Spawn_UsesCurrentIndexedPartAndRoot_WithRowVectorComposition()
+ {
+ const uint emitterId = 0x32000030u;
+ var (system, sink, poses) = Harness(emitterId);
+ Matrix4x4 root = Matrix4x4.CreateRotationZ(MathF.PI / 2f)
+ * Matrix4x4.CreateTranslation(10, 20, 30);
+ poses.Publish(
+ Owner,
+ root,
+ Matrix4x4.Identity,
+ Matrix4x4.CreateTranslation(0, 0, 2));
+
+ var hook = Create(emitterId, 0, partIndex: 1);
+ hook.Offset = new Frame
{
- EmitterInfoId = 0x32000010u,
+ Origin = new Vector3(1, 0, 0),
+ // Retail retains but does not consume this quaternion in
+ // Particle::Init 0x0051C930.
+ Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1.1f),
+ };
+ sink.OnHook(Owner, Vector3.Zero, hook);
+ system.Tick(0.001f);
+
+ Vector3 position = system.EnumerateLive().Single().Emitter.Particles[0].Position;
+ Assert.InRange(position.X, 9.99f, 10.01f);
+ Assert.InRange(position.Y, 20.99f, 21.01f);
+ Assert.InRange(position.Z, 31.99f, 32.01f);
+ }
+
+ [Fact]
+ public void PartMinusOne_UsesRoot_AndInvalidIndexedPartDoesNotFallback()
+ {
+ const uint emitterId = 0x32000031u;
+ var (system, sink, poses) = Harness(emitterId);
+ poses.Publish(
+ Owner,
+ Matrix4x4.CreateTranslation(3, 4, 5),
+ Matrix4x4.CreateTranslation(50, 0, 0));
+
+ sink.OnHook(Owner, Vector3.Zero, Create(emitterId, 0, partIndex: -1));
+ sink.OnHook(Owner, Vector3.Zero, Create(emitterId, 0, partIndex: 99));
+ system.Tick(0.001f);
+
+ Assert.Single(system.EnumerateLive());
+ Assert.Equal(new Vector3(3, 4, 5),
+ system.EnumerateLive().Single().Emitter.Particles[0].Position);
+ }
+
+ [Fact]
+ public void NormalLogicalId_Replaces_BlockingSuppresses_ThenCreatesAfterRetirement()
+ {
+ const uint emitterId = 0x32000040u;
+ const uint logicalId = 0x1234u;
+ var (system, sink, _) = Harness(emitterId);
+ var normal = Create(emitterId, logicalId);
+ var blocking = new RetailCreateBlockingParticleHook
+ {
+ EmitterInfoId = emitterId,
+ EmitterId = logicalId,
+ PartIndex = 0,
+ Offset = new Frame(),
+ };
+
+ sink.OnHook(Owner, Vector3.Zero, normal);
+ sink.OnHook(Owner, Vector3.Zero, blocking);
+ Assert.Equal(1, system.ActiveEmitterCount);
+
+ sink.OnHook(Owner, Vector3.Zero, normal);
+ system.Tick(0.001f);
+ Assert.Equal(1, system.ActiveEmitterCount);
+
+ sink.OnHook(Owner, Vector3.Zero,
+ new DestroyParticleHook { EmitterId = logicalId });
+ system.Tick(0.001f);
+ Assert.Equal(0, system.ActiveEmitterCount);
+
+ sink.OnHook(Owner, Vector3.Zero, blocking);
+ Assert.Equal(1, system.ActiveEmitterCount);
+ }
+
+ [Fact]
+ public void BlockingLogicalIdZero_AlwaysCreatesAnonymousEmitter()
+ {
+ const uint emitterId = 0x32000041u;
+ var (system, sink, _) = Harness(emitterId);
+ var blocking = new RetailCreateBlockingParticleHook
+ {
+ EmitterInfoId = emitterId,
EmitterId = 0,
PartIndex = 0,
Offset = new Frame(),
};
- // First spawn at world origin.
- sink.OnHook(entityId: 0xCAFEu, entityWorldPosition: Vector3.Zero, hook);
- sys.Tick(0.01f);
- var live1 = System.Linq.Enumerable.Single(sys.EnumerateLive());
- Assert.Equal(Vector3.Zero, live1.Emitter.Particles[live1.Index].Position);
-
- // Move the parent to (5, 7, 0) — UpdateEntityAnchor must propagate.
- sink.UpdateEntityAnchor(0xCAFEu, new Vector3(5, 7, 0), Quaternion.Identity);
- sys.Tick(0.01f);
-
- var live2 = System.Linq.Enumerable.Single(sys.EnumerateLive());
- Assert.Equal(new Vector3(5, 7, 0), live2.Emitter.Particles[live2.Index].Position);
+ sink.OnHook(Owner, Vector3.Zero, blocking);
+ sink.OnHook(Owner, Vector3.Zero, blocking);
+ Assert.Equal(2, system.ActiveEmitterCount);
}
[Fact]
- public void EmitterDied_PrunesPerEntityHandleTracking()
+ public void StopLogicalEmitter_RemainsBlockingUntilFinalParticleRetires()
{
- // M4: ConcurrentBag couldn't drop entries when a particle
- // emitter expired naturally, so per-entity tracking grew without
- // bound. The sink now subscribes to ParticleSystem.EmitterDied
- // and prunes both the (entity,key) map and the per-entity set.
- var registry = new EmitterDescRegistry();
- registry.Register(MakeDesc(0x32000020u, attachLocal: false, totalParticles: 1));
- var sys = new ParticleSystem(registry, new System.Random(42));
- var sink = new ParticleHookSink(sys);
-
- var hook = new CreateParticleHook
+ const uint emitterId = 0x32000042u;
+ const uint logicalId = 0x777u;
+ var (system, sink, _) = Harness(emitterId);
+ var blocking = new RetailCreateBlockingParticleHook
{
- EmitterInfoId = 0x32000020u,
- EmitterId = 0xABCDu, // logical key
+ EmitterInfoId = emitterId,
+ EmitterId = logicalId,
PartIndex = 0,
Offset = new Frame(),
};
- sink.OnHook(0xCAFEu, Vector3.Zero, hook);
- Assert.Equal(1, sys.ActiveEmitterCount);
- // TotalParticles=1 cap hit immediately by the InitialParticles spawn,
- // so the emitter Finishes once its single particle expires (0.05s
- // lifetime). After this, EmitterDied has fired and tracking is pruned.
- for (int i = 0; i < 5; i++) sys.Tick(0.05f);
- Assert.Equal(0, sys.ActiveEmitterCount);
+ sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId));
+ sink.OnHook(Owner, Vector3.Zero, new StopParticleHook { EmitterId = logicalId });
+ sink.OnHook(Owner, Vector3.Zero, blocking);
- // A fresh spawn for the same (entity, key) succeeds and is the only
- // live emitter — i.e., the previous handle was pruned cleanly.
- sink.OnHook(0xCAFEu, Vector3.Zero, hook);
- Assert.Equal(1, sys.ActiveEmitterCount);
-
- sink.StopAllForEntity(0xCAFEu, fadeOut: false);
- sys.Tick(0.01f);
- Assert.Equal(0, sys.ActiveEmitterCount);
+ Assert.Equal(1, system.ActiveEmitterCount);
}
[Fact]
- public void SpawnFromHook_AppliesPartTransform_WhenRegistered()
+ public void StopLogicalEmitter_NormalCreateStillReplacesIt()
{
- // 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);
+ const uint emitterId = 0x32000043u;
+ const uint logicalId = 0x778u;
+ var (system, sink, _) = Harness(emitterId);
- var partTransforms = new Matrix4x4[]
- {
- Matrix4x4.Identity,
- Matrix4x4.CreateTranslation(0f, 0f, 1f),
- };
- sink.SetEntityRotation(0xCAFEu, Quaternion.Identity);
- sink.SetEntityPartTransforms(0xCAFEu, partTransforms);
+ sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId));
+ sink.OnHook(Owner, Vector3.Zero, new StopParticleHook { EmitterId = logicalId });
+ sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId));
+ system.Tick(0.001f);
- 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);
+ Assert.Equal(1, system.ActiveEmitterCount);
}
[Fact]
- public void SpawnFromHook_FallsBackToIdentity_WhenPartIndexOutOfBounds()
+ public void NaturalRetirement_PrunesLogicalIdForBlockingReuse()
{
- // 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[]
+ const uint emitterId = 0x32000020u;
+ const uint logicalId = 0xABCDu;
+ var (system, sink, _) = Harness(emitterId, totalParticles: 1);
+ var blocking = new RetailCreateBlockingParticleHook
{
- Matrix4x4.Identity,
- Matrix4x4.CreateTranslation(0f, 0f, 1f),
+ EmitterInfoId = emitterId,
+ EmitterId = logicalId,
+ PartIndex = 0,
+ Offset = new Frame(),
};
- sink.SetEntityRotation(0xCAFEu, Quaternion.Identity);
- sink.SetEntityPartTransforms(0xCAFEu, partTransforms);
+ sink.OnHook(Owner, Vector3.Zero, blocking);
+ for (int i = 0; i < 5; i++)
+ system.Tick(0.05f);
+ Assert.Equal(0, system.ActiveEmitterCount);
- sink.OnHook(0xCAFEu, Vector3.Zero, new CreateParticleHook
+ sink.OnHook(Owner, Vector3.Zero, blocking);
+ Assert.Equal(1, system.ActiveEmitterCount);
+ }
+
+ [Fact]
+ public void MissingEmitterAsset_ReportsDiagnosticWithoutFabricatingEffect()
+ {
+ const uint registeredId = 0x32000020u;
+ const uint missingId = 0x32DEAD00u;
+ var (system, sink, _) = Harness(registeredId);
+ var diagnostics = new List();
+ sink.DiagnosticSink = diagnostics.Add;
+
+ sink.OnHook(Owner, Vector3.Zero, Create(missingId, logicalId: 7u));
+
+ Assert.Equal(0, system.ActiveEmitterCount);
+ string diagnostic = Assert.Single(diagnostics);
+ Assert.Contains($"0x{missingId:X8}", diagnostic, StringComparison.Ordinal);
+ Assert.Contains($"0x{Owner:X8}", diagnostic, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void MissingLivePose_HidesPresentationWithoutEndingEmitterLifetime()
+ {
+ const uint emitterId = 0x32000055u;
+ var (system, sink, poses) = Harness(emitterId);
+ sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId: 11u));
+ ParticleEmitter emitter = system.EnumerateLive().Single().Emitter;
+ Assert.True(emitter.PresentationVisible);
+
+ poses.Remove(Owner);
+ sink.RefreshAttachedEmitters();
+
+ Assert.Equal(1, system.ActiveEmitterCount);
+ Assert.False(emitter.PresentationVisible);
+
+ poses.Publish(Owner, Matrix4x4.CreateTranslation(8, 0, 0), Matrix4x4.Identity);
+ sink.RefreshAttachedEmitters();
+ Assert.True(emitter.PresentationVisible);
+ }
+
+ [Fact]
+ public void PendingProjection_HidesPresentationWhileContinuingLiveAnchorRefresh()
+ {
+ const uint emitterId = 0x32000056u;
+ var (system, sink, poses) = Harness(emitterId, totalDuration: 0.05f);
+ sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId: 12u));
+ ParticleEmitter emitter = system.EnumerateLive().Single().Emitter;
+ int emittedBeforePause = emitter.TotalEmitted;
+ float spawnedAt = emitter.Particles[0].SpawnedAt;
+
+ sink.SetEntityPresentationVisible(Owner, false);
+ poses.Publish(
+ Owner,
+ Matrix4x4.CreateTranslation(12, 3, 0),
+ Matrix4x4.Identity);
+ sink.RefreshAttachedEmitters();
+ system.Tick(1f);
+
+ Assert.Equal(1, system.ActiveEmitterCount);
+ Assert.Equal(new Vector3(12, 3, 0), emitter.AnchorPos);
+ Assert.Equal(emittedBeforePause, emitter.TotalEmitted);
+ Assert.Equal(spawnedAt, emitter.Particles[0].SpawnedAt);
+ Assert.Equal(new Vector3(12, 3, 0), emitter.LastEmitOffset);
+ Assert.False(emitter.SimulationEnabled);
+ Assert.False(emitter.PresentationVisible);
+
+ sink.SetEntityPresentationVisible(Owner, true);
+ sink.RefreshAttachedEmitters();
+ Assert.True(emitter.SimulationEnabled);
+ Assert.True(emitter.PresentationVisible);
+ Assert.Equal(spawnedAt, emitter.Particles[0].SpawnedAt);
+
+ system.Tick(0.01f);
+ Assert.Equal(0, system.ActiveEmitterCount);
+ }
+
+ [Fact]
+ public void LogicalTeardown_RemovesEmitterThatWasSpatiallyPaused()
+ {
+ const uint emitterId = 0x32000057u;
+ var (system, sink, _) = Harness(emitterId);
+ sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId: 13u));
+ sink.SetEntityPresentationVisible(Owner, false);
+ int deathNotices = 0;
+ system.EmitterDied += _ => deathNotices++;
+
+ sink.StopAllForEntity(Owner, fadeOut: false);
+
+ Assert.Equal(0, system.ActiveEmitterCount);
+ Assert.Equal(1, deathNotices);
+ }
+
+ private sealed class MutablePoseSource : IEntityEffectPoseSource
+ {
+ private readonly Dictionary _poses = new();
+
+ public void Publish(uint id, Matrix4x4 root, params Matrix4x4[] parts) =>
+ _poses[id] = (root, parts);
+
+ public void Remove(uint id) => _poses.Remove(id);
+
+ public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
{
- EmitterInfoId = 0x32000031u,
- EmitterId = 0,
- PartIndex = 99, // way past the 2-part array
- Offset = new Frame
+ if (_poses.TryGetValue(localEntityId, out var pose))
{
- Origin = new Vector3(2f, 0f, 0f),
- Orientation = Quaternion.Identity,
- },
- });
- sys.Tick(0.001f);
+ rootWorld = pose.Root;
+ return true;
+ }
+ rootWorld = default;
+ return false;
+ }
- 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);
+ public bool TryGetPartPose(uint localEntityId, int partIndex, out Matrix4x4 partLocal)
+ {
+ if (_poses.TryGetValue(localEntityId, out var pose)
+ && partIndex >= 0
+ && partIndex < pose.Parts.Length)
+ {
+ partLocal = pose.Parts[partIndex];
+ return true;
+ }
+ partLocal = default;
+ return false;
+ }
}
}
diff --git a/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs b/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs
index edc213f8..3ae2c8f5 100644
--- a/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs
+++ b/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs
@@ -239,12 +239,11 @@ public sealed class ParticleSystemTests
}
[Fact]
- public void EmitterDescRegistry_FallsBackToDefault_ForUnknownId()
+ public void EmitterDescRegistry_RejectsUnknownIdWithoutInventingFallback()
{
var reg = new EmitterDescRegistry();
- var desc = reg.Get(0xDEADBEEFu); // not registered
- Assert.NotNull(desc);
- Assert.Equal(0xFFFFFFFFu, desc.DatId); // matches default sentinel
+ Assert.False(reg.TryGet(0xDEADBEEFu, out _));
+ Assert.Throws(() => reg.Get(0xDEADBEEFu));
}
[Fact]