feat(vfx): bind effects to live animated poses

This commit is contained in:
Erik 2026-07-14 10:56:01 +02:00
parent 96ddfdf175
commit 542dcfc384
41 changed files with 3246 additions and 741 deletions

View file

@ -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 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 retail. Attached children advance through their eligible parent. Every effect
resource uses the canonical, globally unique `WorldEntity.Id`; static resource uses the canonical, globally unique `WorldEntity.Id`; static
allocators fail before their namespace can wrap. Live anchors are refreshed from the current allocators fail before their namespace can wrap. `EntityEffectPoseRegistry`
`WorldEntity` before script dispatch; Step 5 extends that root pose to exact publishes the final root and indexed rigid animated-part transforms after animation
animated part transforms for emitters and dynamic lights. 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 The remaining aggregation is primarily `_playerController`'s player-specific
movement plus the separate `WorldEntity`/animation/physics component types. movement plus the separate `WorldEntity`/animation/physics component types.

View file

@ -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-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-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-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-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-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) | | 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-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-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-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-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-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.71.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 | | TS-16 | Click picking is Stage A only: ray-vs-fixed-radius spheres (0.71.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 and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the
0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing). 0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing).
Membership Stage 2 must land TS-18 (BuildingCellId). Membership Stage 2 must land TS-18 (BuildingCellId).
The audio phase lands TS-9/TS-29; the live-pose animation-hook layer lands The audio phase lands TS-9/TS-29; the remaining live-pose animation work lands
TS-10/TS-11/TS-12/TS-14. TS-14.
--- ---

View file

@ -500,7 +500,7 @@ behavior. Estimated 1726 days focused work, 35 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 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`. - **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`. - **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 04 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 05 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 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. - **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`. - **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`.

View file

@ -471,7 +471,7 @@ include dungeons.
`PlayerDescription.Options1` preserves retail's player secure-trade `PlayerDescription.Options1` preserves retail's player secure-trade
preference. See preference. See
`docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216. `docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216.
- **M2/M3 missile, effect, and portal presentation campaign (Steps 04 - **M2/M3 missile, effect, and portal presentation campaign (Steps 05
implemented and independently reviewed 2026-07-14)** — named-retail projectile and implemented and independently reviewed 2026-07-14)** — named-retail projectile and
physics-script behavior is pinned in one oracle, the complete `PhysicsDesc` physics-script behavior is pinned in one oracle, the complete `PhysicsDesc`
and F754/F755 wire surfaces are parsed with retail timestamp gates, and 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 retain Setup defaults. Concurrent-safe DAT reads stay off the streaming lock; malformed
zero-time recursive chains fail diagnostically while valid timed weather loops zero-time recursive chains fail diagnostically while valid timed weather loops
continue. The 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. sweeps, and Hidden/UnHide portal presentation.
- **L.1c local attack receive path (implemented 2026-07-11; live gate pending)** - **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 local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted

View file

@ -740,10 +740,84 @@ Repository links:
2. Logical entity creation/destruction is separate from spatial rebucketing. 2. Logical entity creation/destruction is separate from spatial rebucketing.
3. Server Position/Vector/State/Delete packets remain authoritative. 3. Server Position/Vector/State/Delete packets remain authoritative.
4. No client-side impact, explosion, damage, or deletion is synthesized. 4. No client-side impact, explosion, damage, or deletion is synthesized.
5. Per-object frame ordering preserves retail: animation/timed object hooks, 5. Presentation frame ordering is fixed: advance animation/root motion,
movement commit and child updates, particle tick, then PhysicsScript tick. publish indexed parts, compose equipped children, drain animation hooks,
A script-created emitter begins particle simulation on the following tick. 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 6. Hidden suppresses mesh/collision/interaction but does not destroy the effect
owner. owner.
7. DAT assets determine models, colors, timing, and attachment behavior. 7. DAT assets determine models, colors, timing, and attachment behavior.
8. Every ported branch receives a conformance test and a named-retail citation. 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.

View file

@ -0,0 +1,132 @@
namespace AcDream.App.Rendering;
/// <summary>
/// Reusable parent-before-child traversal for retail's
/// <c>CPhysicsObj::UpdateChildrenInternal</c> attachment graph. Failed parent
/// projections suppress their descendants for the same frame; callers remove
/// those projections only after traversal, so dictionary enumeration remains
/// stable.
/// </summary>
internal sealed class AttachmentUpdateOrder<TChild>
{
private readonly HashSet<uint> _visiting = new();
private readonly HashSet<uint> _completed = new();
private readonly HashSet<uint> _failedSet = new();
private readonly List<uint> _failed = new();
private readonly List<uint> _subtree = new();
private readonly Queue<uint> _recoveryQueue = new();
private readonly HashSet<uint> _recoveryVisited = new();
/// <summary>
/// Updates the current attachment map without per-frame delegate or
/// enumerator boxing. The returned list is borrowed until the next call.
/// </summary>
public IReadOnlyList<uint> ForEachParentFirst(
Dictionary<uint, TChild> children,
Func<TChild, uint?> parentOf,
Func<uint, bool> 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;
}
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<uint> CollectSubtreePostOrder(
Dictionary<uint, TChild> children,
uint rootId,
Func<TChild, uint?> parentOf)
{
_subtree.Clear();
_visiting.Clear();
Collect(rootId, children, parentOf);
return _subtree;
}
/// <summary>
/// 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.
/// </summary>
public void RealizeDescendants(
uint parentId,
Func<uint, IReadOnlyList<uint>> childrenWaitingForParent,
Func<uint, bool> 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<uint> 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<uint, TChild> children,
Func<TChild, uint?> 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<uint, TChild> children,
Func<TChild, uint?> parentOf,
Func<uint, bool> 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);
}
}

View file

@ -1,5 +1,6 @@
using System.Numerics; using System.Numerics;
using AcDream.App.World; using AcDream.App.World;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Meshing; using AcDream.Core.Meshing;
using AcDream.Core.Net; using AcDream.Core.Net;
@ -27,12 +28,20 @@ public sealed class EquippedChildRenderController : IDisposable
private readonly ClientObjectTable _objects; private readonly ClientObjectTable _objects;
private readonly LiveEntityRuntime _liveEntities; private readonly LiveEntityRuntime _liveEntities;
private readonly Func<ParentEvent.Parsed, bool> _acceptParent; private readonly Func<ParentEvent.Parsed, bool> _acceptParent;
private readonly EntityEffectPoseRegistry _poses;
private ParentAttachmentState Relations => _liveEntities.ParentAttachments; private ParentAttachmentState Relations => _liveEntities.ParentAttachments;
/// <summary>Raised after the attached projection is fully registered.</summary> /// <summary>Raised after the attached projection is fully registered.</summary>
public event Action<uint>? EntityReady; public event Action<uint>? EntityReady;
/// <summary>Raised after an attached projection has its composed pose.</summary>
public event Action<uint>? ProjectionPoseReady;
/// <summary>Raised when an attached projection leaves its cell presentation.</summary>
public event Action<uint>? ProjectionRemoved;
private readonly Dictionary<uint, AttachedChild> _attachedByChild = new(); private readonly Dictionary<uint, AttachedChild> _attachedByChild = new();
private readonly AttachmentUpdateOrder<AttachedChild> _updateOrder = new();
private readonly Func<AttachedChild, uint?> _parentOfAttached;
private readonly Func<uint, bool> _tickAttached;
public IEnumerable<uint> AttachedEntityIds public IEnumerable<uint> AttachedEntityIds
{ {
@ -48,13 +57,17 @@ public sealed class EquippedChildRenderController : IDisposable
object datLock, object datLock,
ClientObjectTable objects, ClientObjectTable objects,
LiveEntityRuntime liveEntities, LiveEntityRuntime liveEntities,
EntityEffectPoseRegistry poses,
Func<ParentEvent.Parsed, bool> acceptParent) Func<ParentEvent.Parsed, bool> acceptParent)
{ {
_dats = dats ?? throw new ArgumentNullException(nameof(dats)); _dats = dats ?? throw new ArgumentNullException(nameof(dats));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
_objects = objects ?? throw new ArgumentNullException(nameof(objects)); _objects = objects ?? throw new ArgumentNullException(nameof(objects));
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
_acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent)); _acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent));
_parentOfAttached = static child => child.ParentGuid;
_tickAttached = TickChild;
_objects.ObjectMoved += OnObjectMoved; _objects.ObjectMoved += OnObjectMoved;
_objects.MoveRolledBack += OnMoveRolledBack; _objects.MoveRolledBack += OnMoveRolledBack;
@ -83,17 +96,11 @@ public sealed class EquippedChildRenderController : IDisposable
spawn.PositionSequence)); spawn.PositionSequence));
} }
ResolveRelations(spawn.Guid); ResolveAndTryRealize(spawn.Guid);
TryRealize(spawn.Guid);
// ParentEvent can precede the parent's CreateObject. Revisit every // ParentEvent can precede the parent's CreateObject. Revisit the
// child waiting specifically on the object that just arrived. // complete descendant chain rooted at the object that arrived.
IReadOnlyList<uint> waiting = Relations.ChildrenWaitingForParent(spawn.Guid); RetryWaitingDescendants(spawn.Guid);
for (int i = 0; i < waiting.Count; i++)
{
ResolveRelations(waiting[i]);
TryRealize(waiting[i]);
}
} }
} }
@ -107,15 +114,20 @@ public sealed class EquippedChildRenderController : IDisposable
{ {
lock (_datLock) lock (_datLock)
{ {
IReadOnlyList<uint> waiting = Relations.ChildrenWaitingForParent(guid); RetryWaitingDescendants(guid);
for (int i = 0; i < waiting.Count; i++)
{
ResolveRelations(waiting[i]);
TryRealize(waiting[i]);
}
} }
} }
/// <summary>
/// Retries pose-withdrawn descendants only after their parent's new root
/// and indexed parts have been published.
/// </summary>
public void OnPosePublished(uint guid)
{
lock (_datLock)
RetryWaitingDescendants(guid);
}
/// <summary> /// <summary>
/// Apply/queue a live ParentEvent after the live-entity owner has exact- /// Apply/queue a live ParentEvent after the live-entity owner has exact-
/// gated the parent's INSTANCE_TS and advanced the child's shared /// gated the parent's INSTANCE_TS and advanced the child's shared
@ -126,8 +138,8 @@ public sealed class EquippedChildRenderController : IDisposable
lock (_datLock) lock (_datLock)
{ {
Relations.Enqueue(update); Relations.Enqueue(update);
ResolveRelations(update.ChildGuid); if (ResolveAndTryRealize(update.ChildGuid))
TryRealize(update.ChildGuid); RetryWaitingDescendants(update.ChildGuid);
} }
} }
@ -171,68 +183,103 @@ public sealed class EquippedChildRenderController : IDisposable
/// <summary>Recompose every child after the parent's animation tick.</summary> /// <summary>Recompose every child after the parent's animation tick.</summary>
public void Tick() public void Tick()
{ {
foreach (AttachedChild child in _attachedByChild.Values) IReadOnlyList<uint> failed = _updateOrder.ForEachParentFirst(
{ _attachedByChild,
if (!_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent)) _parentOfAttached,
continue; _tickAttached);
for (int i = 0; i < failed.Count; i++)
if (!EquippedChildAttachment.TryCompose( WithdrawForPoseLoss(failed[i]);
child.ParentSetup,
parent.MeshRefs,
child.ChildSetup,
child.ParentLocation,
child.Placement,
child.PartTemplate,
child.Scale,
out IReadOnlyList<MeshRef> 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);
}
} }
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)) if (!Relations.TryGetProjection(childGuid, out ParentAttachmentRelation pending))
return; return false;
if (!_liveEntities.TryGetWorldEntity(pending.ParentGuid, out WorldEntity parentEntity) if (!_liveEntities.TryGetWorldEntity(pending.ParentGuid, out WorldEntity parentEntity)
|| !_liveEntities.TryGetSnapshot(pending.ParentGuid, out WorldSession.EntitySpawn parentSpawn) || !_liveEntities.TryGetSnapshot(pending.ParentGuid, out WorldSession.EntitySpawn parentSpawn)
|| !_liveEntities.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn childSpawn)) || !_liveEntities.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn childSpawn))
return; return false;
if (parentSpawn.SetupTableId is not { } parentSetupId if (parentSpawn.SetupTableId is not { } parentSetupId
|| childSpawn.SetupTableId is not { } childSetupId || childSpawn.SetupTableId is not { } childSetupId
|| parentEntity.ParentCellId is not { } parentCellId) || parentEntity.ParentCellId is not { } parentCellId)
return; return false;
Setup? parentSetup = _dats.Get<Setup>(parentSetupId); Setup? parentSetup = _dats.Get<Setup>(parentSetupId);
Setup? childSetup = _dats.Get<Setup>(childSetupId); Setup? childSetup = _dats.Get<Setup>(childSetupId);
if (parentSetup is null || childSetup is null) if (parentSetup is null || childSetup is null)
return; return false;
var parentLocation = (ParentLocation)pending.ParentLocation; var parentLocation = (ParentLocation)pending.ParentLocation;
var placement = (Placement)pending.PlacementId; var placement = (Placement)pending.PlacementId;
IReadOnlyList<MeshRef> template = BuildPartTemplate(childSetup, childSpawn); IReadOnlyList<MeshRef> template = BuildPartTemplate(childSetup, childSpawn);
bool[] childPartAvailability = BuildPartAvailability(template);
float scale = childSpawn.ObjScale is { } objScale && objScale > 0f float scale = childSpawn.ObjScale is { } objScale && objScale > 0f
? objScale ? objScale
: 1.0f; : 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, parentSetup,
parentEntity.MeshRefs, parentPartPoses,
parentPartAvailability,
childSetup, childSetup,
parentLocation, parentLocation,
placement, placement,
template, template,
scale, scale,
out IReadOnlyList<MeshRef> parts)) partPoseBuffer: null,
return; attachedPartBuffer: null,
out EquippedChildPose pose))
return false;
// A parented object may materialize here before the ordinary world // A parented object may materialize here before the ordinary world
// hydration path sees it. Install its logical effect profile before // hydration path sees it. Install its logical effect profile before
@ -250,29 +297,34 @@ public sealed class EquippedChildRenderController : IDisposable
WorldEntity? entity = _liveEntities.MaterializeLiveEntity( WorldEntity? entity = _liveEntities.MaterializeLiveEntity(
childGuid, childGuid,
parentCellId, parentCellId,
localId => new WorldEntity localId =>
{ {
Id = localId, var created = new WorldEntity
ServerGuid = childGuid, {
SourceGfxObjOrSetupId = childSetupId, Id = localId,
Position = parentEntity.Position, ServerGuid = childGuid,
Rotation = parentEntity.Rotation, SourceGfxObjOrSetupId = childSetupId,
MeshRefs = parts, Position = parentPosition,
PaletteOverride = BuildPaletteOverride(childSpawn), Rotation = parentRotation,
ParentCellId = parentCellId, MeshRefs = pose.AttachedParts,
PaletteOverride = BuildPaletteOverride(childSpawn),
ParentCellId = parentCellId,
};
created.SetIndexedPartPoses(pose.PartLocal, childPartAvailability);
return created;
}, },
LiveEntityProjectionKind.Attached); LiveEntityProjectionKind.Attached);
if (entity is null) if (entity is null)
return; return false;
entity.SetPosition(parentEntity.Position); ApplyParentWorldPose(entity, parentWorld);
entity.Rotation = parentEntity.Rotation;
entity.ParentCellId = parentCellId; entity.ParentCellId = parentCellId;
entity.ApplyAppearance( entity.ApplyAppearance(
parts, pose.AttachedParts,
BuildPaletteOverride(childSpawn), BuildPaletteOverride(childSpawn),
childSpawn.AnimPartChanges is { Count: > 0 } changes childSpawn.AnimPartChanges is { Count: > 0 } changes
? changes.Select(change => new PartOverride(change.PartIndex, change.NewModelId)).ToArray() ? changes.Select(change => new PartOverride(change.PartIndex, change.NewModelId)).ToArray()
: Array.Empty<PartOverride>()); : Array.Empty<PartOverride>());
entity.SetIndexedPartPoses(pose.PartLocal, childPartAvailability);
_attachedByChild[childGuid] = new AttachedChild( _attachedByChild[childGuid] = new AttachedChild(
pending.ParentGuid, pending.ParentGuid,
childGuid, childGuid,
@ -281,13 +333,60 @@ public sealed class EquippedChildRenderController : IDisposable
parentSetup, parentSetup,
childSetup, childSetup,
template, template,
childPartAvailability,
pose.PartLocal,
pose.AttachedParts,
scale, scale,
entity); entity);
PublishChildPose(entity, parentWorld, parentEntity.ParentCellId, pose);
Console.WriteLine( Console.WriteLine(
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " + $"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
$"location={parentLocation} placement={placement}"); $"location={parentLocation} placement={placement}");
Relations.MarkProjected(childGuid); Relations.MarkProjected(childGuid);
ProjectionPoseReady?.Invoke(childGuid);
EntityReady?.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);
}
/// <summary>
/// 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.
/// </summary>
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;
} }
/// <summary> /// <summary>
@ -337,6 +436,20 @@ public sealed class EquippedChildRenderController : IDisposable
_acceptParent); _acceptParent);
} }
private bool ResolveAndTryRealize(uint childGuid)
{
ResolveRelations(childGuid);
return TryRealize(childGuid);
}
private void RetryWaitingDescendants(uint parentGuid)
{
_updateOrder.RealizeDescendants(
parentGuid,
Relations.ChildrenWaitingForParent,
ResolveAndTryRealize);
}
private IReadOnlyList<MeshRef> BuildPartTemplate( private IReadOnlyList<MeshRef> BuildPartTemplate(
Setup setup, Setup setup,
WorldSession.EntitySpawn spawn) WorldSession.EntitySpawn spawn)
@ -396,6 +509,14 @@ public sealed class EquippedChildRenderController : IDisposable
return result; return result;
} }
private bool[] BuildPartAvailability(IReadOnlyList<MeshRef> template)
{
var available = new bool[template.Count];
for (int i = 0; i < template.Count; i++)
available[i] = _dats.Get<GfxObj>(template[i].GfxObjId) is not null;
return available;
}
private static PaletteOverride? BuildPaletteOverride(WorldSession.EntitySpawn spawn) private static PaletteOverride? BuildPaletteOverride(WorldSession.EntitySpawn spawn)
{ {
if (spawn.SubPalettes is not { Count: > 0 } subPalettes) if (spawn.SubPalettes is not { Count: > 0 } subPalettes)
@ -430,27 +551,52 @@ public sealed class EquippedChildRenderController : IDisposable
if (Relations.RestoreLastAccepted(item.ObjectId)) if (Relations.RestoreLastAccepted(item.ObjectId))
{ {
lock (_datLock) lock (_datLock)
TryRealize(item.ObjectId); {
if (ResolveAndTryRealize(item.ObjectId))
RetryWaitingDescendants(item.ObjectId);
}
} }
} }
private void Remove(uint childGuid) private void Remove(uint childGuid)
{ {
if (!_attachedByChild.Remove(childGuid)) if (!_attachedByChild.Remove(childGuid, out AttachedChild? child))
return; return;
_liveEntities.WithdrawLiveEntityProjection(childGuid); _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) private void TearDownObjectProjections(uint guid)
{ {
Remove(guid); IReadOnlyList<uint> subtree = _updateOrder.CollectSubtreePostOrder(
_attachedByChild,
uint[] children = _attachedByChild.Values guid,
.Where(child => child.ParentGuid == guid) _parentOfAttached);
.Select(child => child.ChildGuid) for (int i = 0; i < subtree.Count; i++)
.ToArray(); {
for (int i = 0; i < children.Length; i++) uint childGuid = subtree[i];
Remove(children[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() public void Clear()
@ -477,6 +623,9 @@ public sealed class EquippedChildRenderController : IDisposable
Setup ParentSetup, Setup ParentSetup,
Setup ChildSetup, Setup ChildSetup,
IReadOnlyList<MeshRef> PartTemplate, IReadOnlyList<MeshRef> PartTemplate,
IReadOnlyList<bool> PartAvailability,
Matrix4x4[] PartPoseBuffer,
MeshRef[] AttachedPartBuffer,
float Scale, float Scale,
WorldEntity Entity); WorldEntity Entity);
} }

View file

@ -286,7 +286,8 @@ public sealed class GameWindow : IDisposable
/// from the current animation frame; only these two fields are /// from the current animation frame; only these two fields are
/// reused unchanged. /// reused unchanged.
/// </summary> /// </summary>
public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary<uint, uint>? SurfaceOverrides)> PartTemplate; public required IReadOnlyList<AnimatedPartTemplate> PartTemplate;
public required IReadOnlyList<bool> PartAvailability;
public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame] public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame]
public AcDream.Core.Physics.AnimationSequencer? Sequencer; public AcDream.Core.Physics.AnimationSequencer? Sequencer;
@ -307,8 +308,15 @@ public sealed class GameWindow : IDisposable
/// in-place mutation of this list. /// in-place mutation of this list.
/// </summary> /// </summary>
public readonly List<AcDream.Core.World.MeshRef> MeshRefsScratch = new(); public readonly List<AcDream.Core.World.MeshRef> MeshRefsScratch = new();
/// <summary>Indexed final part poses, including debug-hidden parts.</summary>
public readonly List<System.Numerics.Matrix4x4> EffectPartPosesScratch = new();
} }
internal readonly record struct AnimatedPartTemplate(
uint GfxObjId,
IReadOnlyDictionary<uint, uint>? SurfaceOverrides,
bool IsDrawable);
private sealed record AppearanceUpdateState( private sealed record AppearanceUpdateState(
AcDream.Core.World.WorldEntity Entity, AcDream.Core.World.WorldEntity Entity,
AnimatedEntity? Animation); AnimatedEntity? Animation);
@ -332,6 +340,8 @@ public sealed class GameWindow : IDisposable
private AcDream.Core.Vfx.EmitterDescRegistry? _emitterRegistry; private AcDream.Core.Vfx.EmitterDescRegistry? _emitterRegistry;
private AcDream.Core.Vfx.ParticleSystem? _particleSystem; private AcDream.Core.Vfx.ParticleSystem? _particleSystem;
private AcDream.Core.Vfx.ParticleHookSink? _particleSink; 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) // Phase 6 — retail PhysicsScript runtime. Receives PlayScript (0xF754)
// and typed PlayScriptType (0xF755) events through one effect owner, then // and typed PlayScriptType (0xF755) events through one effect owner, then
// fans every dat-defined hook to particles, audio, lights, translucency, // 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 // Wired into the hook router in OnLoad so SetLightHook fires
// from the animation pipeline flip the matching LightSource.IsLit. // from the animation pipeline flip the matching LightSource.IsLit.
private AcDream.Core.Lighting.LightingHookSink? _lightingSink; private AcDream.Core.Lighting.LightingHookSink? _lightingSink;
private AcDream.App.Rendering.Vfx.LiveEntityLightController? _liveEntityLights;
// #188 — TransparentPartHook fires from the animation pipeline drive // #188 — TransparentPartHook fires from the animation pipeline drive
// a per-(entity,part) translucency ramp; WbDrawDispatcher reads it // 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 // StopParticle hooks fired from motion tables produce visible
// spawns. The Tick call is driven from OnRender. // spawns. The Tick call is driven from OnRender.
_particleSystem = new AcDream.Core.Vfx.ParticleSystem(_emitterRegistry!); _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); _hookRouter.Register(_particleSink);
// Phase 6c — PhysicsScript runner. Uses the DatCollection to // 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 // Phase G.2 lighting hooks: SetLightHook flips IsLit on
// owner-tagged lights so ignite-torch animations light up, // owner-tagged lights so ignite-torch animations light up,
// extinguish-torch animations go dark. // extinguish-torch animations go dark.
_lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting); _lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting, _effectPoses);
_hookRouter.Register(_lightingSink); _hookRouter.Register(_lightingSink);
// #188 — TransparentPartHook (per-part translucency fade, e.g. // #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 // Phase C.1.5a/b: construct EntityScriptActivator so static entities
// (server-spawned AND dat-hydrated) fire Setup.DefaultScript through // (server-spawned AND dat-hydrated) fire Setup.DefaultScript through
// the PhysicsScriptRunner on enter-world. C.1.5b adds per-part // the PhysicsScriptRunner on enter-world. C.1.5b adds per-part
// transforms via SetupPartTransforms.Compute so multi-emitter scripts // transforms from the entity's exact current MeshRefs so
// distribute across mesh parts (closes #56). _scriptRunner and // 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 // _particleSink are initialised earlier in OnLoad (line ~1083); both
// are non-null here. The resolver lambda captures _dats and swallows // are non-null here. The resolver lambda captures _dats and swallows
// dat-lookup throws — see C.1.5a spec §6 (error handling) for rationale. // dat-lookup throws — see C.1.5a spec §6 (error handling) for rationale.
@ -2309,12 +2327,21 @@ public sealed class GameWindow : IDisposable
e.SourceGfxObjOrSetupId); e.SourceGfxObjOrSetupId);
if (setup is null) return null; if (setup is null) return null;
uint scriptId = setup.DefaultScript.DataId; 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<System.Numerics.Matrix4x4> parts = e.IndexedPartTransforms;
IReadOnlyList<bool> availability = e.IndexedPartAvailable;
var profile = AcDream.App.Rendering.Vfx.EntityEffectProfile.CreateDatStatic(setup); var profile = AcDream.App.Rendering.Vfx.EntityEffectProfile.CreateDatStatic(setup);
return new AcDream.App.Rendering.Vfx.ScriptActivationInfo( return new AcDream.App.Rendering.Vfx.ScriptActivationInfo(
scriptId, scriptId,
parts, parts,
profile); profile,
availability);
} }
catch catch
{ {
@ -2324,6 +2351,7 @@ public sealed class GameWindow : IDisposable
var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator( var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator(
_scriptRunner!, _scriptRunner!,
_particleSink!, _particleSink!,
_effectPoses,
ResolveActivation, ResolveActivation,
(ownerId, entity, profile) => (ownerId, entity, profile) =>
entityEffects?.OnDatStaticEntityReady(ownerId, entity, profile), entityEffects?.OnDatStaticEntityReady(ownerId, entity, profile),
@ -2364,12 +2392,24 @@ public sealed class GameWindow : IDisposable
} }
}), }),
TearDownLiveEntityRuntimeComponents); 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<DatReaderWriter.DBObjs.Setup>(setupId));
_equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController( _equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController(
_dats!, _dats!,
_datLock, _datLock,
Objects, Objects,
_liveEntities, _liveEntities,
_effectPoses,
TryAcceptParentForRender); TryAcceptParentForRender);
var tableResolver = new AcDream.Core.Vfx.PhysicsScriptTableResolver( var tableResolver = new AcDream.Core.Vfx.PhysicsScriptTableResolver(
@ -2378,6 +2418,7 @@ public sealed class GameWindow : IDisposable
_liveEntities, _liveEntities,
_scriptRunner!, _scriptRunner!,
tableResolver, tableResolver,
_effectPoses,
(parentLocalId, partIndex) => (parentLocalId, partIndex) =>
_equippedChildRenderer.FindChildLocalIdAtPart(parentLocalId, partIndex), _equippedChildRenderer.FindChildLocalIdAtPart(parentLocalId, partIndex),
childLocalId => _equippedChildRenderer.FindParentLocalId(childLocalId), childLocalId => _equippedChildRenderer.FindParentLocalId(childLocalId),
@ -2389,8 +2430,14 @@ public sealed class GameWindow : IDisposable
_entitySoundTables?.Set(ownerId, did); _entitySoundTables?.Set(ownerId, did);
}); });
_entityEffects = entityEffects; _entityEffects = entityEffects;
entityEffects.DiagnosticSink = message =>
Console.Error.WriteLine($"vfx: {message}");
_equippedChildRenderer.EntityReady += guid => _equippedChildRenderer.EntityReady += guid =>
{
entityEffects.OnLiveEntityReady(guid); entityEffects.OnLiveEntityReady(guid);
};
_equippedChildRenderer.ProjectionPoseReady += guid =>
_liveEntityLights.OnAttachedPoseReady(guid);
_hookRouter.Register(entityEffects); _hookRouter.Register(entityEffects);
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher( _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 // F754/F755 can precede CreateObject, so the mixed pending FIFO
// may contain owners with no LiveEntityRecord to tear down. // may contain owners with no LiveEntityRecord to tear down.
_entityEffects?.ClearNetworkState(); _entityEffects?.ClearNetworkState();
_animationHookFrames?.Clear();
} }
} }
@ -3626,14 +3674,39 @@ public sealed class GameWindow : IDisposable
// creatures/characters whose size is intrinsic to the mesh). // creatures/characters whose size is intrinsic to the mesh).
float scale = spawn.ObjScale ?? 1.0f; float scale = spawn.ObjScale ?? 1.0f;
var scaleMat = System.Numerics.Matrix4x4.CreateScale(scale); var scaleMat = System.Numerics.Matrix4x4.CreateScale(scale);
IReadOnlyList<System.Numerics.Matrix4x4> rigidPartTransforms =
AcDream.Core.Meshing.SetupPartTransforms.Compute(
setup,
idleFrame,
scale);
var meshRefs = new List<AcDream.Core.World.MeshRef>(); var meshRefs = new List<AcDream.Core.World.MeshRef>();
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(); var liveBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
int dumpClothingTotalTris = 0; int dumpClothingTotalTris = 0;
for (int partIdx = 0; partIdx < parts.Count; partIdx++) for (int partIdx = 0; partIdx < parts.Count; partIdx++)
{ {
var mr = parts[partIdx]; var mr = parts[partIdx];
IReadOnlyDictionary<uint, uint>? 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<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId); var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
bool isDrawable = gfx is not null;
indexedPartAvailable[partIdx] = isDrawable;
animatedPartTemplate[partIdx] = new AnimatedPartTemplate(
mr.GfxObjId,
surfaceOverrides,
isDrawable);
if (gfx is null) if (gfx is null)
{ {
if (dumpClothing) 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}"); Console.WriteLine($" EMIT part={partIdx:D2} gfx=0x{mr.GfxObjId:X8} subMeshes={subs} tris={tris}");
} }
IReadOnlyDictionary<uint, uint>? surfaceOverrides = null;
if (resolvedOverridesByPart is not null && resolvedOverridesByPart.TryGetValue(partIdx, out var partOverrides))
surfaceOverrides = partOverrides;
// Multiplication order matches offline scenery hydration: // Multiplication order matches offline scenery hydration:
// `PartTransform * scaleMat`. In row-vector semantics this means // `PartTransform * scaleMat`. In row-vector semantics this means
// "apply PartTransform first (which includes the part-attachment // "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 // for multi-part entities like the Nullified Statue that causes
// the parts to drift relative to each other ("distorted") and the // the parts to drift relative to each other ("distorted") and the
// base anchor to end up below the ground ("sinks into foundry"). // 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). // #119 follow-up: vertex-derived root-local bounds (see WorldEntity.RefreshAabb).
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) liveBounds.Add(transform, pb.Value); 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; AcDream.Core.World.WorldEntity existing = visualUpdate.Entity;
existing.ApplyAppearance(meshRefs, paletteOverride, entityPartOverrides); existing.ApplyAppearance(meshRefs, paletteOverride, entityPartOverrides);
existing.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable);
if (liveBounds.TryGet(out var appearanceMin, out var appearanceMax)) if (liveBounds.TryGet(out var appearanceMin, out var appearanceMax))
existing.SetLocalBounds(appearanceMin, appearanceMax); existing.SetLocalBounds(appearanceMin, appearanceMax);
if (visualUpdate.Animation is { } animation) if (visualUpdate.Animation is { } animation)
RebindAnimatedEntityForAppearance(animation, existing, setup, scale, meshRefs); RebindAnimatedEntityForAppearance(
animation,
existing,
setup,
scale,
animatedPartTemplate,
indexedPartAvailable);
_classificationCache.InvalidateEntity(existing.Id); _classificationCache.InvalidateEntity(existing.Id);
if (_liveEntities!.TryGetRecord(spawn.Guid, out LiveEntityRecord record) if (_liveEntities!.TryGetRecord(spawn.Guid, out LiveEntityRecord record)
&& record.ProjectionKind is LiveEntityProjectionKind.Attached) && record.ProjectionKind is LiveEntityProjectionKind.Attached)
@ -3732,6 +3806,14 @@ public sealed class GameWindow : IDisposable
// after replacing the child's visual description. // after replacing the child's visual description.
_equippedChildRenderer?.OnSpawn(spawn); _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; return;
} }
@ -3762,6 +3844,7 @@ public sealed class GameWindow : IDisposable
PartOverrides = entityPartOverrides, PartOverrides = entityPartOverrides,
ParentCellId = spawn.Position.Value.LandblockId, ParentCellId = spawn.Position.Value.LandblockId,
}; };
created.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable);
if (liveBounds.TryGet(out var createdMin, out var createdMax)) if (liveBounds.TryGet(out var createdMin, out var createdMax))
created.SetLocalBounds(createdMin, createdMax); created.SetLocalBounds(createdMin, createdMax);
return created; return created;
@ -3777,8 +3860,10 @@ public sealed class GameWindow : IDisposable
entity.Rotation = rot; entity.Rotation = rot;
entity.ParentCellId = spawn.Position.Value.LandblockId; entity.ParentCellId = spawn.Position.Value.LandblockId;
entity.ApplyAppearance(meshRefs, paletteOverride, entityPartOverrides); entity.ApplyAppearance(meshRefs, paletteOverride, entityPartOverrides);
entity.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable);
if (liveBounds.TryGet(out var retainedMin, out var retainedMax)) if (liveBounds.TryGet(out var retainedMin, out var retainedMax))
entity.SetLocalBounds(retainedMin, retainedMax); entity.SetLocalBounds(retainedMin, retainedMax);
_effectPoses.PublishMeshRefs(entity);
} }
// Retail CPhysicsObj::leave_world removes cell/shadow membership but // Retail CPhysicsObj::leave_world removes cell/shadow membership but
@ -3788,47 +3873,6 @@ public sealed class GameWindow : IDisposable
bool retainedAnimationRuntime = !createdProjection bool retainedAnimationRuntime = !createdProjection
&& _animatedEntities.TryGetValue(entity.Id, out _); && _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<DatReaderWriter.DBObjs.Setup>(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( var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
Id: entity.Id, Id: entity.Id,
SourceId: entity.SourceGfxObjOrSetupId, SourceId: entity.SourceGfxObjOrSetupId,
@ -3895,9 +3939,7 @@ public sealed class GameWindow : IDisposable
// Snapshot per-part identity from the hydrated meshRefs so the // Snapshot per-part identity from the hydrated meshRefs so the
// tick can rebuild MeshRefs without redoing AnimPartChanges or // tick can rebuild MeshRefs without redoing AnimPartChanges or
// texture-override resolution every frame. // texture-override resolution every frame.
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[meshRefs.Count]; AnimatedPartTemplate[] template = animatedPartTemplate;
for (int i = 0; i < meshRefs.Count; i++)
template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
// Create an AnimationSequencer if we can load the MotionTable. // Create an AnimationSequencer if we can load the MotionTable.
AcDream.Core.Physics.AnimationSequencer? sequencer = null; AcDream.Core.Physics.AnimationSequencer? sequencer = null;
@ -3925,6 +3967,7 @@ public sealed class GameWindow : IDisposable
Framerate = idleCycle.Framerate, Framerate = idleCycle.Framerate,
Scale = scale, Scale = scale,
PartTemplate = template, PartTemplate = template,
PartAvailability = indexedPartAvailable,
CurrFrame = idleCycle.LowFrame, CurrFrame = idleCycle.LowFrame,
Sequencer = sequencer, Sequencer = sequencer,
}; };
@ -3969,9 +4012,7 @@ public sealed class GameWindow : IDisposable
var sequencer = SpawnMotionInitializer.Create( var sequencer = SpawnMotionInitializer.Create(
setup, mtable, _animLoader, spawn.MotionState); setup, mtable, _animLoader, spawn.MotionState);
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[meshRefs.Count]; AnimatedPartTemplate[] template = animatedPartTemplate;
for (int i = 0; i < meshRefs.Count; i++)
template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
_animatedEntities[entity.Id] = new AnimatedEntity _animatedEntities[entity.Id] = new AnimatedEntity
{ {
@ -3983,6 +4024,7 @@ public sealed class GameWindow : IDisposable
Framerate = 0f, Framerate = 0f,
Scale = scale, Scale = scale,
PartTemplate = template, PartTemplate = template,
PartAvailability = indexedPartAvailable,
CurrFrame = 0, CurrFrame = 0,
Sequencer = sequencer, Sequencer = sequencer,
}; };
@ -4124,15 +4166,14 @@ public sealed class GameWindow : IDisposable
AcDream.Core.World.WorldEntity entity, AcDream.Core.World.WorldEntity entity,
DatReaderWriter.DBObjs.Setup setup, DatReaderWriter.DBObjs.Setup setup,
float scale, float scale,
IReadOnlyList<AcDream.Core.World.MeshRef> meshRefs) IReadOnlyList<AnimatedPartTemplate> partTemplate,
IReadOnlyList<bool> partAvailability)
{ {
animation.Entity = entity; animation.Entity = entity;
animation.Setup = setup; animation.Setup = setup;
animation.Scale = scale; animation.Scale = scale;
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[meshRefs.Count]; animation.PartTemplate = partTemplate;
for (int i = 0; i < meshRefs.Count; i++) animation.PartAvailability = partAvailability;
template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
animation.PartTemplate = template;
} }
/// <summary> /// <summary>
@ -4878,6 +4919,7 @@ public sealed class GameWindow : IDisposable
_translucencyFades.ClearEntity(existingEntity.Id); // #188 _translucencyFades.ClearEntity(existingEntity.Id); // #188
LeaveWorldLiveEntityRuntimeComponents(record); LeaveWorldLiveEntityRuntimeComponents(record);
_liveEntityLights?.Forget(existingEntity.Id);
} }
/// <summary> /// <summary>
@ -4897,10 +4939,10 @@ public sealed class GameWindow : IDisposable
if (record.ServerGuid == _playerServerGuid) if (record.ServerGuid == _playerServerGuid)
_lastLocalPlayerShadow = null; _lastLocalPlayerShadow = null;
// Object-borne lights are cell-scoped presentation. The owning Setup // Pose-attached effects keep logical ownership while cell-less. A
// and logical light capability remain on the live record; re-entry // missing pose suppresses stale anchors; re-entry republishes the same
// registers the light in the new cell without duplicating it. // WorldEntity frames without replaying create-time resources.
_lightingSink?.UnregisterOwner(entity.Id); _effectPoses.Remove(entity.Id);
} }
/// <summary> /// <summary>
@ -5625,6 +5667,8 @@ public sealed class GameWindow : IDisposable
{ {
if (!_liveEntities!.TryApplyState(parsed, out _)) return; if (!_liveEntities!.TryApplyState(parsed, out _)) return;
_liveEntityLights?.OnStateChanged(parsed.Guid);
if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return; if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return;
// L.2g slice 1c (2026-05-13): the server addresses entities by // 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); var key = new SkyPesKey(i, obj.PesObjectId, obj.IsPostScene);
seen.Add(key); seen.Add(key);
if (_activeSkyPes.Contains(key) || _missingSkyPes.Contains(key))
continue;
uint skyEntityId = SkyPesEntityId(key); uint skyEntityId = SkyPesEntityId(key);
_entityEffects?.RegisterSyntheticOwner(skyEntityId);
var renderPass = obj.IsPostScene var renderPass = obj.IsPostScene
? AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene ? AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene
: AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene; : AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene;
_particleSink.SetEntityRenderPass(skyEntityId, renderPass); _particleSink.SetEntityRenderPass(skyEntityId, renderPass);
var anchor = SkyPesAnchor(obj, cameraWorldPos); var anchor = SkyPesAnchor(obj, cameraWorldPos);
var rotation = SkyPesRotation(obj, dayFraction); var rotation = SkyPesRotation(obj, dayFraction);
// Refresh anchor + rotation every frame so AttachLocal _effectPoses.Publish(
// (is_parent_local=1) particles track the camera. Retail skyEntityId,
// ParticleEmitter::UpdateParticles at 0x0051d2d4 reads the System.Numerics.Matrix4x4.CreateFromQuaternion(rotation)
// live parent frame each tick; for sky-PES the parent IS * System.Numerics.Matrix4x4.CreateTranslation(anchor),
// the camera. UpdateEntityAnchor is a no-op when no System.Array.Empty<System.Numerics.Matrix4x4>(),
// emitters yet exist (script just spawned this frame). cellId: 0u);
_particleSink.UpdateEntityAnchor(skyEntityId, anchor, rotation);
if (_activeSkyPes.Contains(key) || _missingSkyPes.Contains(key)) if (_activeSkyPes.Contains(key) || _missingSkyPes.Contains(key))
continue; continue;
_entityEffects?.RegisterSyntheticOwner(skyEntityId);
if (_scriptRunner.Play(obj.PesObjectId, skyEntityId, anchor)) if (_scriptRunner.Play(obj.PesObjectId, skyEntityId, anchor))
{ {
_activeSkyPes.Add(key); _activeSkyPes.Add(key);
@ -6660,6 +6700,7 @@ public sealed class GameWindow : IDisposable
_missingSkyPes.Add(key); _missingSkyPes.Add(key);
_entityEffects?.UnregisterSyntheticOwner(skyEntityId); _entityEffects?.UnregisterSyntheticOwner(skyEntityId);
_particleSink.ClearEntityRenderPass(skyEntityId); _particleSink.ClearEntityRenderPass(skyEntityId);
_effectPoses.Remove(skyEntityId);
} }
} }
} }
@ -6673,6 +6714,7 @@ public sealed class GameWindow : IDisposable
_scriptRunner.StopAllForEntity(skyEntityId); _scriptRunner.StopAllForEntity(skyEntityId);
_entityEffects?.UnregisterSyntheticOwner(skyEntityId); _entityEffects?.UnregisterSyntheticOwner(skyEntityId);
_particleSink.StopAllForEntity(skyEntityId, fadeOut: true); _particleSink.StopAllForEntity(skyEntityId, fadeOut: true);
_effectPoses.Remove(skyEntityId);
_activeSkyPes.Remove(key); _activeSkyPes.Remove(key);
} }
@ -9110,6 +9152,7 @@ public sealed class GameWindow : IDisposable
if (_animatedEntities.Count > 0) if (_animatedEntities.Count > 0)
TickAnimations((float)deltaSeconds); TickAnimations((float)deltaSeconds);
_equippedChildRenderer?.Tick(); _equippedChildRenderer?.Tick();
_animationHookFrames?.Drain();
// #188 — advance translucency fades UNCONDITIONALLY (not gated on // #188 — advance translucency fades UNCONDITIONALLY (not gated on
// _animatedEntities.Count): a one-shot open-cycle animation can // _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. // debug-only and disabled for normal retail rendering.
if (_options.EnableSkyPesDebug) if (_options.EnableSkyPesDebug)
UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell); UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell);
_entityEffects?.RefreshLiveOwnerAnchors(); _entityEffects?.RefreshLiveOwnerPoses();
_scriptRunner?.Tick(_physicsScriptGameTime); _scriptRunner?.Tick(_physicsScriptGameTime);
_particleSink?.RefreshAttachedEmitters();
_liveEntityLights?.Refresh();
_particleSystem?.Tick((float)deltaSeconds); _particleSystem?.Tick((float)deltaSeconds);
// Phase G.1/G.2: feed the sun, tick LightManager, build + upload // 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 // Capture hooks now, but deliver them only after every final
// damage frames, particle spawns, part swaps, etc.) and fan // part transform and equipped-child root has been published.
// them out to registered subsystems via the hook router. // This matches CPhysicsObj::UpdateObjectInternal followed by
// Mirrors ACE's PhysicsObj.add_anim_hook dispatch path. // CPhysicsObj::UpdateChild (0x00512D50): a hand/weapon effect
// // reads this frame's pose, never the previous frame's pose.
// R2-Q4 queue-drain wiring (r2-port-plan.md §4, the G6 seam; // AnimationDone/UseTime remain paired with the deferred drain.
// per-tick PLACEMENT provisional until R6 installs retail's _animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
// 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();
} }
else else
{ {
@ -10475,6 +10500,8 @@ public sealed class GameWindow : IDisposable
// consumer caches a stale reference or diffs list identity). // consumer caches a stale reference or diffs list identity).
var newMeshRefs = ae.MeshRefsScratch; var newMeshRefs = ae.MeshRefsScratch;
newMeshRefs.Clear(); newMeshRefs.Clear();
var effectPartPoses = ae.EffectPartPosesScratch;
effectPartPoses.Clear();
var scaleMat = ae.Scale == 1.0f var scaleMat = ae.Scale == 1.0f
? System.Numerics.Matrix4x4.Identity ? System.Numerics.Matrix4x4.Identity
: System.Numerics.Matrix4x4.CreateScale(ae.Scale); : System.Numerics.Matrix4x4.CreateScale(ae.Scale);
@ -10532,7 +10559,7 @@ public sealed class GameWindow : IDisposable
? ae.Setup.DefaultScale[i] ? ae.Setup.DefaultScale[i]
: System.Numerics.Vector3.One; : System.Numerics.Vector3.One;
var partTransform = var visualPartTransform =
System.Numerics.Matrix4x4.CreateScale(defaultScale) * System.Numerics.Matrix4x4.CreateScale(defaultScale) *
System.Numerics.Matrix4x4.CreateFromQuaternion(orientation) * System.Numerics.Matrix4x4.CreateFromQuaternion(orientation) *
System.Numerics.Matrix4x4.CreateTranslation(origin); System.Numerics.Matrix4x4.CreateTranslation(origin);
@ -10540,14 +10567,23 @@ public sealed class GameWindow : IDisposable
// Bake the entity's ObjScale on top, matching the hydration // Bake the entity's ObjScale on top, matching the hydration
// order (PartTransform * scaleMat) — see comment in OnLiveEntitySpawned. // order (PartTransform * scaleMat) — see comment in OnLiveEntitySpawned.
if (ae.Scale != 1.0f) 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]; 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; continue;
} }
newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, partTransform) newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, visualPartTransform)
{ {
SurfaceOverrides = template.SurfaceOverrides, SurfaceOverrides = template.SurfaceOverrides,
}); });
@ -10557,6 +10593,8 @@ public sealed class GameWindow : IDisposable
// property store) and keeps this line's shape identical to the // property store) and keeps this line's shape identical to the
// pre-MP-Alloc code for anyone grepping the history. // pre-MP-Alloc code for anyone grepping the history.
ae.Entity.MeshRefs = newMeshRefs; 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 finally
{ {
_liveEntityLights?.Dispose();
_liveEntityLights = null;
_entityEffects?.ClearNetworkState(); _entityEffects?.ClearNetworkState();
_animationHookFrames?.Clear();
_effectPoses.Clear();
} }
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context _audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_wbDrawDispatcher?.Dispose(); _wbDrawDispatcher?.Dispose();

View file

@ -198,6 +198,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
draws.Clear(); draws.Clear();
foreach (var (em, idx) in particles.EnumerateLive()) foreach (var (em, idx) in particles.EnumerateLive())
{ {
if (!em.PresentationVisible)
continue;
if (em.RenderPass != renderPass) if (em.RenderPass != renderPass)
continue; continue;
if (emitterFilter is not null && !emitterFilter(em)) if (emitterFilter is not null && !emitterFilter(em))

View file

@ -0,0 +1,89 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using DatReaderWriter.Types;
namespace AcDream.App.Rendering.Vfx;
/// <summary>
/// Defers animation-hook delivery until every entity and equipped child has
/// published its final pose for the frame.
/// </summary>
/// <remarks>
/// Retail stages hooks during sequence advance, then the same object update
/// commits the final part frame and runs <c>CPhysicsObj::UpdateChild</c>
/// (<c>0x00512D50</c>) 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.
/// </remarks>
public sealed class AnimationHookFrameQueue
{
private readonly AnimationHookRouter _router;
private readonly IEntityEffectPoseSource _poses;
private readonly List<Entry> _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<AnimationHook> 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<AnimationHook> Hooks);
}

View file

@ -27,6 +27,7 @@ public sealed class EntityEffectController : IAnimationHookSink
private readonly LiveEntityRuntime _liveEntities; private readonly LiveEntityRuntime _liveEntities;
private readonly PhysicsScriptRunner _runner; private readonly PhysicsScriptRunner _runner;
private readonly PhysicsScriptTableResolver _tables; private readonly PhysicsScriptTableResolver _tables;
private readonly EntityEffectPoseRegistry _poses;
private readonly Func<uint, uint, uint?> _childAtPart; private readonly Func<uint, uint, uint?> _childAtPart;
private readonly Func<uint, uint?> _parentOfAttachedChild; private readonly Func<uint, uint?> _parentOfAttachedChild;
private readonly Action<uint> _ownerUnregistered; private readonly Action<uint> _ownerUnregistered;
@ -42,6 +43,7 @@ public sealed class EntityEffectController : IAnimationHookSink
LiveEntityRuntime liveEntities, LiveEntityRuntime liveEntities,
PhysicsScriptRunner runner, PhysicsScriptRunner runner,
PhysicsScriptTableResolver tables, PhysicsScriptTableResolver tables,
EntityEffectPoseRegistry poses,
Func<uint, uint, uint?>? childAtPart = null, Func<uint, uint, uint?>? childAtPart = null,
Func<uint, uint?>? parentOfAttachedChild = null, Func<uint, uint?>? parentOfAttachedChild = null,
Action<uint>? ownerUnregistered = null, Action<uint>? ownerUnregistered = null,
@ -50,6 +52,7 @@ public sealed class EntityEffectController : IAnimationHookSink
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_runner = runner ?? throw new ArgumentNullException(nameof(runner)); _runner = runner ?? throw new ArgumentNullException(nameof(runner));
_tables = tables ?? throw new ArgumentNullException(nameof(tables)); _tables = tables ?? throw new ArgumentNullException(nameof(tables));
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
_childAtPart = childAtPart ?? ((_, _) => null); _childAtPart = childAtPart ?? ((_, _) => null);
_parentOfAttachedChild = parentOfAttachedChild ?? (_ => null); _parentOfAttachedChild = parentOfAttachedChild ?? (_ => null);
_ownerUnregistered = ownerUnregistered ?? (_ => { }); _ownerUnregistered = ownerUnregistered ?? (_ => { });
@ -201,8 +204,8 @@ public sealed class EntityEffectController : IAnimationHookSink
_pendingByServerGuid.Clear(); _pendingByServerGuid.Clear();
} }
/// <summary>Refreshes every live root anchor after animation/movement.</summary> /// <summary>Refreshes every live root after animation/movement.</summary>
public void RefreshLiveOwnerAnchors() public void RefreshLiveOwnerPoses()
{ {
foreach (uint serverGuid in _readyServerGuids.ToArray()) foreach (uint serverGuid in _readyServerGuids.ToArray())
{ {
@ -353,7 +356,18 @@ public sealed class EntityEffectController : IAnimationHookSink
&& record.WorldEntity is { } entity && record.WorldEntity is { } entity
&& entity.Id == localId) && 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);
} }
} }

View file

@ -0,0 +1,216 @@
using System.Numerics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Vfx;
/// <summary>
/// Update-thread registry of final root and indexed part poses for particles,
/// lights, and other DAT-driven entity effects.
/// </summary>
/// <remarks>
/// Retail composes particle anchors from the current physics-object/part frame
/// in <c>Particle::Init</c> (<c>0x0051C930</c>) and refreshes parent-local
/// particles from those frames in <c>ParticleEmitter::UpdateParticles</c>
/// (<c>0x0051D180</c>). This registry is the modern, read-only seam exposing
/// those same final frames without coupling Core effects to the renderer.
/// </remarks>
public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityEffectCellSource
{
private sealed class PoseRecord
{
public Matrix4x4 RootWorld;
public Matrix4x4[] PartLocal = Array.Empty<Matrix4x4>();
public bool[] PartAvailable = Array.Empty<bool>();
public uint CellId;
}
private readonly Dictionary<uint, PoseRecord> _poses = new();
public int Count => _poses.Count;
public void Publish(WorldEntity entity, IReadOnlyList<Matrix4x4> partLocal)
{
Publish(entity, partLocal, availability: null);
}
public void Publish(
WorldEntity entity,
IReadOnlyList<Matrix4x4> partLocal,
IReadOnlyList<bool>? availability)
{
ArgumentNullException.ThrowIfNull(entity);
Publish(
entity.Id,
Matrix4x4.CreateFromQuaternion(entity.Rotation)
* Matrix4x4.CreateTranslation(entity.Position),
partLocal,
entity.ParentCellId ?? 0u,
availability);
}
/// <summary>
/// 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.
/// </summary>
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<Matrix4x4> partLocal,
uint cellId,
IReadOnlyList<bool>? 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);
}
/// <summary>Refresh only the moving root while retaining current part poses.</summary>
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;
}
/// <summary>
/// Borrow the current indexed part-pose snapshot for synchronous
/// update-thread composition. Callers must not retain or mutate it.
/// </summary>
public bool TryGetPartPoses(
uint localEntityId,
out IReadOnlyList<Matrix4x4> partLocal)
{
if (_poses.TryGetValue(localEntityId, out PoseRecord? record))
{
partLocal = record.PartLocal;
return true;
}
partLocal = Array.Empty<Matrix4x4>();
return false;
}
public bool TryGetPartPoseSnapshot(
uint localEntityId,
out IReadOnlyList<Matrix4x4> partLocal,
out IReadOnlyList<bool> availability)
{
if (_poses.TryGetValue(localEntityId, out PoseRecord? record))
{
partLocal = record.PartLocal;
availability = record.PartAvailable;
return true;
}
partLocal = Array.Empty<Matrix4x4>();
availability = Array.Empty<bool>();
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<Matrix4x4> partLocal,
IReadOnlyList<bool>? 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];
}
}
}

View file

@ -1,5 +1,3 @@
using System;
using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AcDream.Core.Vfx; using AcDream.Core.Vfx;
using AcDream.Core.World; using AcDream.Core.World;
@ -7,97 +5,57 @@ using AcDream.Core.World;
namespace AcDream.App.Rendering.Vfx; namespace AcDream.App.Rendering.Vfx;
/// <summary> /// <summary>
/// What the activator's resolver returns when an entity's Setup carries /// Setup script/profile data resolved once when a logical effect owner is
/// a <c>DefaultScript</c>. Bundles the script id with the per-part /// registered. Part transforms are indexed and root-local.
/// transforms baked from <c>Setup.PlacementFrames</c> so a single dat
/// lookup yields both pieces of state. The activator pushes the part
/// transforms into <see cref="ParticleHookSink.SetEntityPartTransforms"/>
/// before calling <see cref="PhysicsScriptRunner.Play"/>, which closes
/// the part-anchor pipeline introduced for issue #56.
/// </summary> /// </summary>
public sealed record ScriptActivationInfo( public sealed record ScriptActivationInfo(
uint ScriptId, uint ScriptId,
IReadOnlyList<Matrix4x4> PartTransforms, IReadOnlyList<Matrix4x4> PartTransforms,
EntityEffectProfile? EffectProfile = null); EntityEffectProfile? EffectProfile = null,
IReadOnlyList<bool>? PartAvailability = null);
/// <summary> /// <summary>
/// Fires <c>Setup.DefaultScript</c> through <see cref="PhysicsScriptRunner"/> /// Owns create-time Setup script activation and its symmetric teardown for
/// when a <see cref="WorldEntity"/> enters the world, so static objects /// live and DAT-static entities.
/// (portals, chimneys, fireplaces, EnvCell decorations, building details)
/// emit their retail-faithful persistent particle effects automatically.
/// Stops the scripts and live emitters when the entity despawns.
///
/// <para>
/// Handles both server-spawned and dat-hydrated entities. Live owners use the
/// canonical local <c>entity.Id</c>. Dat-static IDs occupy disjoint, globally
/// unique ranges for interiors, scenery, and landblock stabs. The C.1.5a guard that early-returned for
/// <c>ServerGuid == 0</c> was relaxed in C.1.5b so EnvCell static objects
/// (which have no server guid because they come from the dat file, not
/// the network) also fire their DefaultScript.
/// </para>
///
/// <para>
/// For live objects this is invoked by <c>LiveEntityRuntime</c> logical
/// registration/teardown. <c>GpuWorldState</c> invokes it only for dat-static
/// landblock load, promotion, demotion, and unload paths.
/// </para>
///
/// <para>
/// Retail oracle: <c>play_script_internal(setup.DefaultScript)</c> is what
/// retail's <c>CPhysicsObj</c> invokes at object load (see Phase C.1 plan
/// §C.1 and <c>memory/project_sky_pes_port.md</c>). C.1 already shipped the
/// runner; this class adds the missing fire-on-spawn call site.
/// </para>
/// </summary> /// </summary>
/// <remarks>
/// Setup <c>DefaultScript</c> is a direct PES played during Setup
/// initialization. Live registration invokes this class once per logical
/// generation; spatial rebucketing never replays it.
/// </remarks>
public sealed class EntityScriptActivator public sealed class EntityScriptActivator
{ {
private readonly PhysicsScriptRunner _scriptRunner; private readonly PhysicsScriptRunner _scriptRunner;
private readonly ParticleHookSink _particleSink; private readonly ParticleHookSink _particleSink;
private readonly EntityEffectPoseRegistry _poses;
private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver; private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;
private readonly Action<uint, WorldEntity, EntityEffectProfile>? _registerDatStaticEffectOwner; private readonly Action<uint, WorldEntity, EntityEffectProfile>? _registerDatStaticEffectOwner;
private readonly Action<uint>? _unregisterDatStaticEffectOwner; private readonly Action<uint>? _unregisterDatStaticEffectOwner;
private readonly Dictionary<uint, WorldEntity> _activeStaticOwners = new(); private readonly Dictionary<uint, WorldEntity> _activeStaticOwners = new();
/// <param name="scriptRunner">Per-owner retail FIFO that schedules every
/// hook at its absolute script <c>StartTime</c>.</param>
/// <param name="particleSink">Already-shipped hook sink from C.1. The
/// activator pushes per-entity rotation + part transforms here, and
/// calls <see cref="ParticleHookSink.StopAllForEntity"/> to drop
/// per-entity emitter handles on despawn.</param>
/// <param name="resolver">Returns
/// <see cref="ScriptActivationInfo"/> with the entity's
/// <c>Setup.DefaultScript.DataId</c> and per-part transforms (via
/// <c>SetupPartTransforms.Compute</c>), or <c>null</c> on dat miss /
/// throw / missing DefaultScript. Production lambda hits
/// <c>DatCollection</c>; tests pass a hand-rolled stub.</param>
public EntityScriptActivator( public EntityScriptActivator(
PhysicsScriptRunner scriptRunner, PhysicsScriptRunner scriptRunner,
ParticleHookSink particleSink, ParticleHookSink particleSink,
EntityEffectPoseRegistry poses,
Func<WorldEntity, ScriptActivationInfo?> resolver, Func<WorldEntity, ScriptActivationInfo?> resolver,
Action<uint, WorldEntity, EntityEffectProfile>? registerDatStaticEffectOwner = null, Action<uint, WorldEntity, EntityEffectProfile>? registerDatStaticEffectOwner = null,
Action<uint>? unregisterDatStaticEffectOwner = null) Action<uint>? unregisterDatStaticEffectOwner = null)
{ {
ArgumentNullException.ThrowIfNull(scriptRunner); _scriptRunner = scriptRunner ?? throw new ArgumentNullException(nameof(scriptRunner));
ArgumentNullException.ThrowIfNull(particleSink); _particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink));
ArgumentNullException.ThrowIfNull(resolver); _poses = poses ?? throw new ArgumentNullException(nameof(poses));
_scriptRunner = scriptRunner; _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
_particleSink = particleSink;
_resolver = resolver;
_registerDatStaticEffectOwner = registerDatStaticEffectOwner; _registerDatStaticEffectOwner = registerDatStaticEffectOwner;
_unregisterDatStaticEffectOwner = unregisterDatStaticEffectOwner; _unregisterDatStaticEffectOwner = unregisterDatStaticEffectOwner;
} }
/// <summary>
/// Resolve the entity's <c>Setup.DefaultScript</c> 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).
/// </summary>
public void OnCreate(WorldEntity entity) public void OnCreate(WorldEntity entity)
{ {
ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(entity);
uint key = entity.Id; uint key = entity.Id;
if (key == 0) return; // malformed entity if (key == 0)
return;
if (entity.ServerGuid == 0 if (entity.ServerGuid == 0
&& _activeStaticOwners.TryGetValue(key, out WorldEntity? existing)) && _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."); $"Dat-static WorldEntity id 0x{key:X8} is already active; static allocators must be globally unique.");
} }
var info = _resolver(entity); ScriptActivationInfo? info = _resolver(entity);
if (info is null) return; if (info is null)
return;
// Seed the sink's per-entity rotation so CreateParticleHook.Offset.Origin // Particle::Init (0x0051C930) samples the current root/part frames.
// (in entity-local frame) transforms correctly to world space when the // Publish before the Setup default script can execute; effects never
// hook fires. C.1.5a fix: without this, the sink falls through to // fall back to the camera or a cached spawn anchor.
// Quaternion.Identity and the offset gets applied in world axes — _poses.Publish(entity, info.PartTransforms, info.PartAvailability);
// 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);
if (entity.ServerGuid == 0) if (entity.ServerGuid == 0)
_activeStaticOwners.Add(key, entity); _activeStaticOwners.Add(key, entity);
if (entity.ServerGuid == 0 && info.EffectProfile is { } profile) if (entity.ServerGuid == 0 && info.EffectProfile is { } profile)
_registerDatStaticEffectOwner?.Invoke( _registerDatStaticEffectOwner?.Invoke(key, entity, profile);
key,
entity,
profile);
if (info.ScriptId != 0) if (info.ScriptId != 0)
_scriptRunner.Play(info.ScriptId, key, entity.Position); _scriptRunner.Play(info.ScriptId, key, entity.Position);
} }
/// <summary>
/// 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.
/// </summary>
public void OnRemove(WorldEntity entity) public void OnRemove(WorldEntity entity)
{ {
ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(entity);
uint key = entity.Id; uint key = entity.Id;
if (key == 0) return; if (key == 0)
return;
if (entity.ServerGuid == 0 if (entity.ServerGuid == 0
&& (!_activeStaticOwners.TryGetValue(key, out WorldEntity? existing) && (!_activeStaticOwners.TryGetValue(key, out WorldEntity? existing)
|| !ReferenceEquals(existing, entity))) || !ReferenceEquals(existing, entity)))
{ {
return; return;
} }
_scriptRunner.StopAllForEntity(key); _scriptRunner.StopAllForEntity(key);
_particleSink.StopAllForEntity(key, fadeOut: false); _particleSink.StopAllForEntity(key, fadeOut: false);
_poses.Remove(key);
if (entity.ServerGuid == 0 && _activeStaticOwners.Remove(key)) if (entity.ServerGuid == 0 && _activeStaticOwners.Remove(key))
_unregisterDatStaticEffectOwner?.Invoke(key); _unregisterDatStaticEffectOwner?.Invoke(key);
} }
} }

View file

@ -0,0 +1,66 @@
using System.Numerics;
using AcDream.Core.Meshing;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Rendering.Vfx;
/// <summary>
/// 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.
/// </summary>
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<Matrix4x4> 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);
}
}

View file

@ -0,0 +1,176 @@
using AcDream.App.World;
using AcDream.Core.Lighting;
using AcDream.Core.Physics;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Rendering.Vfx;
/// <summary>
/// Projects Setup lights for live top-level and attached entities, while the
/// shared pose registry supplies their current root on each update frame.
/// </summary>
/// <remarks>
/// Retail <c>CPartArray::SetFrame</c> (<c>0x00519310</c>) calls
/// <c>LIGHTLIST::set_frame</c> (<c>0x00517C60</c>) whenever the owning physics
/// object's frame changes. Logical ownership remains in
/// <see cref="LiveEntityRuntime"/>; leaving the world removes only this
/// cell-scoped presentation and re-entry registers it again.
/// </remarks>
public sealed class LiveEntityLightController : IDisposable
{
private readonly LiveEntityRuntime _liveEntities;
private readonly EntityEffectPoseRegistry _poses;
private readonly LightingHookSink _lighting;
private readonly Func<uint, Setup?> _loadSetup;
private readonly Dictionary<uint, uint> _serverGuidByOwner = new();
private readonly HashSet<uint> _presentOwners = new();
public LiveEntityLightController(
LiveEntityRuntime liveEntities,
EntityEffectPoseRegistry poses,
LightingHookSink lighting,
Func<uint, Setup?> 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<LightSource> 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;
}
/// <summary>Withdraw cell-scoped presentation but retain logical light state.</summary>
public void Unregister(uint ownerLocalId)
{
_presentOwners.Remove(ownerLocalId);
_lighting.UnregisterOwner(ownerLocalId, forgetState: false);
}
/// <summary>Apply a fresh authoritative PhysicsState Lighting transition.</summary>
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);
}
/// <summary>End logical ownership after leave-world presentation teardown.</summary>
public void Forget(uint ownerLocalId)
{
_presentOwners.Remove(ownerLocalId);
_serverGuidByOwner.Remove(ownerLocalId);
_lighting.UnregisterOwner(ownerLocalId);
}
public void Refresh() => _lighting.RefreshAttachedLights();
/// <summary>
/// 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.
/// </summary>
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);
}
}

View file

@ -1311,7 +1311,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// TransparentPartHook.PartIndex addresses — retail's CPartArray // TransparentPartHook.PartIndex addresses — retail's CPartArray
// numbers parts by their ordinal position in the Setup's own // numbers parts by their ordinal position in the Setup's own
// part list (SetupPartTransforms.Compute is the other verified // 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 // Setup.Parts[i]). NOT the outer per-MeshRef loop index — a
// MeshRef is acdream's own decomposition of top-level // MeshRef is acdream's own decomposition of top-level
// attachments (weapon/shield/etc), a different concept. // attachments (weapon/shield/etc), a different concept.

View file

@ -148,6 +148,7 @@ public sealed class LiveEntityRuntime
private readonly Dictionary<uint, uint> _guidByLocalId = new(); private readonly Dictionary<uint, uint> _guidByLocalId = new();
private readonly Dictionary<uint, ILiveEntityAnimationRuntime> _animationsByLocalId = new(); private readonly Dictionary<uint, ILiveEntityAnimationRuntime> _animationsByLocalId = new();
private readonly Dictionary<uint, ILiveEntityRemoteMotionRuntime> _remoteMotionByGuid = new(); private readonly Dictionary<uint, ILiveEntityRemoteMotionRuntime> _remoteMotionByGuid = new();
private uint _rebucketingGuid;
private uint _nextLocalEntityId; private uint _nextLocalEntityId;
public LiveEntityRuntime( public LiveEntityRuntime(
@ -191,6 +192,12 @@ public sealed class LiveEntityRuntime
public IReadOnlyDictionary<uint, ILiveEntityRemoteMotionRuntime> RemoteMotionRuntimes => _remoteMotionByGuid; public IReadOnlyDictionary<uint, ILiveEntityRemoteMotionRuntime> RemoteMotionRuntimes => _remoteMotionByGuid;
public ParentAttachmentState ParentAttachments { get; } = new(); public ParentAttachmentState ParentAttachments { get; } = new();
/// <summary>
/// Raised after a materialized projection enters or leaves the currently
/// loaded spatial view. Logical resources remain owned by the record.
/// </summary>
public event Action<LiveEntityRecord, bool>? ProjectionVisibilityChanged;
public LiveEntityRegistrationResult RegisterLiveEntity(WorldSession.EntitySpawn incoming) public LiveEntityRegistrationResult RegisterLiveEntity(WorldSession.EntitySpawn incoming)
{ {
InboundCreateResult result = _inbound.AcceptCreate(incoming); InboundCreateResult result = _inbound.AcceptCreate(incoming);
@ -311,7 +318,21 @@ public sealed class LiveEntityRuntime
|| record.WorldEntity is not { } entity) || record.WorldEntity is not { } entity)
return false; 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); bool visible = _spatial.IsLiveEntityVisible(serverGuid);
record.IsSpatiallyVisible = visible; record.IsSpatiallyVisible = visible;
if (record.ProjectionKind is LiveEntityProjectionKind.World) if (record.ProjectionKind is LiveEntityProjectionKind.World)
@ -327,7 +348,8 @@ public sealed class LiveEntityRuntime
record.CanonicalLandblockId = spatialCellOrLandblockId == 0 record.CanonicalLandblockId = spatialCellOrLandblockId == 0
? 0u ? 0u
: (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu; : (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu;
record.IsSpatiallyProjected = true; if (!wasProjected || wasVisible != visible)
ProjectionVisibilityChanged?.Invoke(record, visible);
return true; return true;
} }
@ -708,6 +730,8 @@ public sealed class LiveEntityRuntime
_visibleWorldEntitiesByGuid[serverGuid] = entity; _visibleWorldEntitiesByGuid[serverGuid] = entity;
else else
_visibleWorldEntitiesByGuid.Remove(serverGuid); _visibleWorldEntitiesByGuid.Remove(serverGuid);
if (_rebucketingGuid != serverGuid)
ProjectionVisibilityChanged?.Invoke(record, visible);
} }
private void TearDownRecord(LiveEntityRecord record) private void TearDownRecord(LiveEntityRecord record)

View file

@ -13,7 +13,7 @@ namespace AcDream.Core.Lighting;
/// <para> /// <para>
/// Retail <see cref="LightInfo"/> fields (r13 §1): /// Retail <see cref="LightInfo"/> fields (r13 §1):
/// <list type="bullet"> /// <list type="bullet">
/// <item><description><c>ViewSpaceLocation</c>: local Frame relative to the owning part.</description></item> /// <item><description><c>ViewSpaceLocation</c>: local Frame relative to the owning PartArray root.</description></item>
/// <item><description><c>Color</c>: packed ARGB. Alpha is ignored; channels go through <c>/255</c>.</description></item> /// <item><description><c>Color</c>: packed ARGB. Alpha is ignored; channels go through <c>/255</c>.</description></item>
/// <item><description><c>Intensity</c>: multiplies color for final diffuse.</description></item> /// <item><description><c>Intensity</c>: multiplies color for final diffuse.</description></item>
/// <item><description><c>Falloff</c>: world metres — acts as the <see cref="LightSource.Range"/> hard cutoff.</description></item> /// <item><description><c>Falloff</c>: world metres — acts as the <see cref="LightSource.Range"/> hard cutoff.</description></item>
@ -24,13 +24,10 @@ namespace AcDream.Core.Lighting;
public static class LightInfoLoader public static class LightInfoLoader
{ {
/// <summary> /// <summary>
/// Extract all lights from a Setup, positioned in the entity's /// Extract all lights from a Setup and retain each light's complete local
/// world frame (via <paramref name="entityPosition"/> + /// frame. Retail Setup lights belong to the PartArray root rather than an
/// <paramref name="entityRotation"/>). The dat's per-light Frame is /// indexed mesh part; moving held objects therefore follow their child
/// treated as a local offset relative to the entity root; acdream /// physics-object root after <c>CPhysicsObj::UpdateChild</c> composes it.
/// 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.
/// </summary> /// </summary>
public static IReadOnlyList<LightSource> Load( public static IReadOnlyList<LightSource> Load(
Setup setup, Setup setup,
@ -38,7 +35,8 @@ public static class LightInfoLoader
Vector3 entityPosition, Vector3 entityPosition,
Quaternion entityRotation, Quaternion entityRotation,
bool isDynamic = false, bool isDynamic = false,
uint cellId = 0) uint cellId = 0,
bool tracksOwnerPose = false)
{ {
var results = new List<LightSource>(); var results = new List<LightSource>();
if (setup?.Lights is null || setup.Lights.Count == 0) return results; if (setup?.Lights is null || setup.Lights.Count == 0) return results;
@ -64,12 +62,15 @@ public static class LightInfoLoader
info.ViewSpaceLocation.Orientation.W); info.ViewSpaceLocation.Orientation.W);
} }
// Transform local offset into world space via the entity's Matrix4x4 localPose = Matrix4x4.CreateFromQuaternion(localRot)
// rotation + translation. No per-part chain yet — held * Matrix4x4.CreateTranslation(localOffset);
// torches track the entity's root for now. Matrix4x4 rootWorld = Matrix4x4.CreateFromQuaternion(entityRotation)
Vector3 worldPos = entityPosition + Vector3.Transform(localOffset, entityRotation); * Matrix4x4.CreateTranslation(entityPosition);
Quaternion worldRot = entityRotation * localRot; Matrix4x4 lightWorld = localPose * rootWorld;
Vector3 forward = Vector3.Transform(Vector3.UnitY, worldRot); Vector3 worldPos = lightWorld.Translation;
Vector3 forward = Vector3.TransformNormal(Vector3.UnitY, lightWorld);
if (forward.LengthSquared() > 1e-8f)
forward = Vector3.Normalize(forward);
var light = new LightSource 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) CellId = cellId, // owning cell — scopes the per-frame visible-cell pool (A7 #176/#177)
IsLit = true, IsLit = true,
IsDynamic = isDynamic, IsDynamic = isDynamic,
TracksOwnerPose = tracksOwnerPose,
LocalPose = localPose,
}; };
results.Add(light); results.Add(light);
} }

View file

@ -55,6 +55,20 @@ public sealed class LightSource
public bool IsLit = true; // SetLightHook latch public bool IsLit = true; // SetLightHook latch
public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5); public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5);
// false = static dat-baked bake (1/d³, range×1.3) // false = static dat-baked bake (1/d³, range×1.3)
/// <summary>
/// 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.
/// </summary>
public bool TracksOwnerPose;
/// <summary>
/// Setup-local light frame retained from <c>LIGHTINFO::view_space_location</c>.
/// Object-borne lights compose it with their owner's current root each
/// frame, matching <c>CPartArray::SetFrame</c> (<c>0x00519310</c>) calling
/// <c>LIGHTLIST::set_frame</c> (<c>0x00517C60</c>).
/// </summary>
public Matrix4x4 LocalPose = Matrix4x4.Identity;
// Cached each frame by LightManager. // Cached each frame by LightManager.
public float DistSq; public float DistSq;

View file

@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using DatReaderWriter.Types; using DatReaderWriter.Types;
namespace AcDream.Core.Lighting; namespace AcDream.Core.Lighting;
@ -24,14 +25,27 @@ namespace AcDream.Core.Lighting;
public sealed class LightingHookSink : IAnimationHookSink public sealed class LightingHookSink : IAnimationHookSink
{ {
private readonly LightManager _lights; private readonly LightManager _lights;
private readonly IEntityEffectPoseSource _poses;
private readonly IEntityEffectCellSource? _cells;
// Index owner → the set of LightSource instances they registered. // Index owner → the set of LightSource instances they registered.
// Maintained lazily — populated on first RegisterLight for that owner. // Maintained lazily — populated on first RegisterLight for that owner.
private readonly Dictionary<uint, List<LightSource>> _byOwner = new(); private readonly Dictionary<uint, List<LightSource>> _byOwner = new();
private readonly Dictionary<uint, List<LightSource>> _trackedByOwner = new();
private readonly Dictionary<uint, bool> _enabledByOwner = new();
public LightingHookSink(LightManager lights) /// <summary>
/// Raised after retail's <c>set_lights</c> 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.
/// </summary>
public event Action<uint, bool>? OwnerLightingChanged;
public LightingHookSink(LightManager lights, IEntityEffectPoseSource poses)
{ {
_lights = lights ?? throw new System.ArgumentNullException(nameof(lights)); _lights = lights ?? throw new System.ArgumentNullException(nameof(lights));
_poses = poses ?? throw new System.ArgumentNullException(nameof(poses));
_cells = poses as IEntityEffectCellSource;
} }
/// <summary> /// <summary>
@ -41,6 +55,8 @@ public sealed class LightingHookSink : IAnimationHookSink
public void RegisterOwnedLight(LightSource light) public void RegisterOwnedLight(LightSource light)
{ {
System.ArgumentNullException.ThrowIfNull(light); System.ArgumentNullException.ThrowIfNull(light);
if (_enabledByOwner.TryGetValue(light.OwnerId, out bool enabled))
light.IsLit = enabled;
_lights.Register(light); _lights.Register(light);
if (!_byOwner.TryGetValue(light.OwnerId, out var list)) if (!_byOwner.TryGetValue(light.OwnerId, out var list))
{ {
@ -48,14 +64,54 @@ public sealed class LightingHookSink : IAnimationHookSink
_byOwner[light.OwnerId] = list; _byOwner[light.OwnerId] = list;
} }
list.Add(light); list.Add(light);
if (light.TracksOwnerPose)
{
if (!_trackedByOwner.TryGetValue(light.OwnerId, out List<LightSource>? tracked))
{
tracked = new List<LightSource>();
_trackedByOwner[light.OwnerId] = tracked;
}
tracked.Add(light);
}
} }
/// <summary>Drop every light tagged to this owner (despawn / unload).</summary> /// <summary>Drop every light tagged to this owner (despawn / unload).</summary>
public void UnregisterOwner(uint ownerId) public void UnregisterOwner(uint ownerId, bool forgetState = true)
{ {
if (!_byOwner.TryGetValue(ownerId, out var list)) return; if (_byOwner.TryGetValue(ownerId, out var list))
foreach (var l in list) _lights.Unregister(l); {
_byOwner.Remove(ownerId); 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;
/// <summary>
/// Mirrors <c>CPhysicsObj::set_lights</c> (0x0050FCF0): update the owner's
/// Lighting state and its currently materialized lights as one operation.
/// </summary>
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);
} }
/// <summary> /// <summary>
@ -67,12 +123,36 @@ public sealed class LightingHookSink : IAnimationHookSink
return _byOwner.TryGetValue(ownerId, out var list) ? list : null; return _byOwner.TryGetValue(ownerId, out var list) ? list : null;
} }
/// <summary>
/// Recompose every object-borne light from the current physics-object root.
/// Setup <c>LIGHTINFO</c> has no part index; equipped-object animation is
/// represented by publishing that child's composed root.
/// </summary>
public void RefreshAttachedLights()
{
foreach ((uint ownerId, List<LightSource> 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) public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
{ {
if (hook is not SetLightHook slh) return; if (hook is not SetLightHook slh) return;
if (!_byOwner.TryGetValue(entityId, out var list)) return; SetOwnerLighting(entityId, slh.LightsOn);
foreach (var light in list)
light.IsLit = slh.LightsOn;
} }
} }

View file

@ -6,6 +6,12 @@ using DatReaderWriter.Types;
namespace AcDream.Core.Meshing; namespace AcDream.Core.Meshing;
/// <summary>Final child root and indexed part poses in the parent-root frame.</summary>
public readonly record struct EquippedChildPose(
Matrix4x4 RootLocal,
Matrix4x4[] PartLocal,
MeshRef[] AttachedParts);
/// <summary> /// <summary>
/// Retail held-object transform composition. A weapon is a separate child /// Retail held-object transform composition. A weapon is a separate child
/// physics object: the parent's <see cref="Setup.HoldingLocations"/> selects /// physics object: the parent's <see cref="Setup.HoldingLocations"/> selects
@ -28,6 +34,90 @@ public static class EquippedChildAttachment
IReadOnlyList<MeshRef> childPartTemplate, IReadOnlyList<MeshRef> childPartTemplate,
float childScale, float childScale,
out IReadOnlyList<MeshRef> attachedParts) out IReadOnlyList<MeshRef> attachedParts)
{
bool composed = TryComposePose(
parentSetup,
currentParentPose,
childSetup,
parentLocation,
placement,
childPartTemplate,
childScale,
out EquippedChildPose pose);
attachedParts = composed ? pose.AttachedParts : Array.Empty<MeshRef>();
return composed;
}
public static bool TryComposePose(
Setup parentSetup,
IReadOnlyList<MeshRef> currentParentPose,
Setup childSetup,
ParentLocation parentLocation,
Placement placement,
IReadOnlyList<MeshRef> 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);
}
/// <summary>
/// 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.
/// </summary>
public static bool TryComposePose(
Setup parentSetup,
IReadOnlyList<Matrix4x4> currentParentPose,
Setup childSetup,
ParentLocation parentLocation,
Placement placement,
IReadOnlyList<MeshRef> childPartTemplate,
float childScale,
out EquippedChildPose pose)
{
return TryComposePoseInto(
parentSetup,
currentParentPose,
parentPartAvailability: null,
childSetup,
parentLocation,
placement,
childPartTemplate,
childScale,
partPoseBuffer: null,
attachedPartBuffer: null,
out pose);
}
/// <summary>
/// Allocation-free update path for an already realized child. Buffers are
/// reused by the App-layer attachment owner after their first composition.
/// </summary>
public static bool TryComposePoseInto(
Setup parentSetup,
IReadOnlyList<Matrix4x4> currentParentPose,
IReadOnlyList<bool>? parentPartAvailability,
Setup childSetup,
ParentLocation parentLocation,
Placement placement,
IReadOnlyList<MeshRef> childPartTemplate,
float childScale,
Matrix4x4[]? partPoseBuffer,
MeshRef[]? attachedPartBuffer,
out EquippedChildPose pose)
{ {
ArgumentNullException.ThrowIfNull(parentSetup); ArgumentNullException.ThrowIfNull(parentSetup);
ArgumentNullException.ThrowIfNull(currentParentPose); ArgumentNullException.ThrowIfNull(currentParentPose);
@ -36,14 +126,32 @@ public static class EquippedChildAttachment
if (!parentSetup.HoldingLocations.TryGetValue(parentLocation, out LocationType? holding)) if (!parentSetup.HoldingLocations.TryGetValue(parentLocation, out LocationType? holding))
{ {
attachedParts = Array.Empty<MeshRef>(); pose = new EquippedChildPose(
Matrix4x4.Identity,
Array.Empty<Matrix4x4>(),
Array.Empty<MeshRef>());
return false; return false;
} }
Matrix4x4 parentPart = holding.PartId >= 0 Matrix4x4 parentPart;
&& holding.PartId < currentParentPose.Count if (holding.PartId >= 0 && holding.PartId < currentParentPose.Count)
? currentParentPose[holding.PartId].PartTransform {
: Matrix4x4.Identity; 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 holdingFrame = ToMatrix(holding.Frame);
Matrix4x4 childRoot = holdingFrame * parentPart; Matrix4x4 childRoot = holdingFrame * parentPart;
@ -54,28 +162,40 @@ public static class EquippedChildAttachment
childSetup.PlacementFrames.TryGetValue(Placement.Default, out placementFrame); childSetup.PlacementFrames.TryGetValue(Placement.Default, out placementFrame);
int partCount = Math.Min(childSetup.Parts.Count, childPartTemplate.Count); 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++) for (int i = 0; i < partCount; i++)
{ {
Frame partFrame = placementFrame is not null && i < placementFrame.Frames.Count Frame partFrame = placementFrame is not null && i < placementFrame.Frames.Count
? placementFrame.Frames[i] ? placementFrame.Frames[i]
: new Frame { Orientation = Quaternion.Identity }; : new Frame { Orientation = Quaternion.Identity };
Vector3 scale = i < childSetup.DefaultScale.Count Vector3 defaultScale = i < childSetup.DefaultScale.Count
? childSetup.DefaultScale[i] ? childSetup.DefaultScale[i]
: Vector3.One; : Vector3.One;
Matrix4x4 childPart = Matrix4x4.CreateScale(scale) Matrix4x4 visualChildPart = Matrix4x4.CreateScale(defaultScale)
* ToMatrix(partFrame); * ToMatrix(partFrame);
if (childScale != 1.0f) 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]; MeshRef template = childPartTemplate[i];
result[i] = new MeshRef(template.GfxObjId, childPart * childRoot) result[i] = new MeshRef(template.GfxObjId, visualChildPart * childRoot)
{ {
SurfaceOverrides = template.SurfaceOverrides, SurfaceOverrides = template.SurfaceOverrides,
}; };
} }
attachedParts = result; pose = new EquippedChildPose(childRoot, childPartPoses, result);
return true; return true;
} }

View file

@ -7,48 +7,35 @@ using DatReaderWriter.Types;
namespace AcDream.Core.Meshing; namespace AcDream.Core.Meshing;
/// <summary> /// <summary>
/// Compute the per-part static transforms for a Setup using its /// Computes retail's rigid per-part frames for attachments and effects.
/// PlacementFrames. For each part <c>i</c>, the returned matrix takes a /// Visual GfxObj scale is deliberately excluded.
/// point in part-local space and yields a point in setup-local space at
/// the Setup's resting pose.
///
/// <para>
/// Mirrors <see cref="SetupMesh.Flatten"/>'s pose-source priority —
/// <c>PlacementFrames[Resting]</c> → <c>[Default]</c> → first available
/// — so that a particle anchor at part <c>i</c> matches the part's
/// visible rest position. If renderer and resolver ever drift on this
/// priority, particles will visibly drift relative to their parent
/// mesh; keep them in lockstep.
/// </para>
///
/// <para>
/// Returns an empty list when the Setup has no PlacementFrames. The
/// caller (e.g. <c>ParticleHookSink.SpawnFromHook</c>) should then fall
/// back to <see cref="Matrix4x4.Identity"/> per part, which is the
/// pre-C.1.5b behavior.
/// </para>
///
/// <para>
/// For animated entities, per-part transforms vary per animation frame
/// and live in <c>AnimatedEntityState</c>; a future "animated
/// DefaultScript" path would publish those each tick via the same
/// <c>SetEntityPartTransforms</c> seam. Out of scope here.
/// </para>
/// </summary> /// </summary>
/// <remarks>
/// Pose selection matches <see cref="SetupMesh.Flatten"/>: an explicit motion
/// frame, then Resting, Default, then the first placement frame. Retail
/// <c>CPartArray::UpdateParts</c> (<c>0x005190F0</c>) scales only the frame
/// origin by the object's scale. <c>CPartArray::SetScaleInternal</c>
/// (<c>0x00518A00</c>) stores Setup DefaultScale separately on the visual
/// <c>CPhysicsPart::gfxobj_scale</c>, so it never enters this rigid frame.
/// </remarks>
public static class SetupPartTransforms public static class SetupPartTransforms
{ {
public static IReadOnlyList<Matrix4x4> Compute(Setup setup) public static IReadOnlyList<Matrix4x4> Compute(
Setup setup,
AnimationFrame? motionFrameOverride = null,
float objectScale = 1.0f)
{ {
AnimationFrame? source = null; ArgumentNullException.ThrowIfNull(setup);
if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting)) AnimationFrame? source = motionFrameOverride;
if (source is null && setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting))
{ {
source = 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; source = def;
} }
else else if (source is null)
{ {
foreach (var kvp in setup.PlacementFrames) foreach (var kvp in setup.PlacementFrames)
{ {
@ -57,7 +44,8 @@ public static class SetupPartTransforms
} }
} }
if (source is null) return System.Array.Empty<Matrix4x4>(); if (source is null)
return Array.Empty<Matrix4x4>();
int partCount = setup.Parts.Count; int partCount = setup.Parts.Count;
var result = new Matrix4x4[partCount]; var result = new Matrix4x4[partCount];
@ -66,10 +54,8 @@ public static class SetupPartTransforms
Frame frame = i < source.Frames.Count Frame frame = i < source.Frames.Count
? source.Frames[i] ? source.Frames[i]
: new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; : new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
Vector3 scale = i < setup.DefaultScale.Count ? setup.DefaultScale[i] : Vector3.One; result[i] = Matrix4x4.CreateFromQuaternion(frame.Orientation)
result[i] = Matrix4x4.CreateScale(scale) * Matrix4x4.CreateTranslation(frame.Origin * objectScale);
* Matrix4x4.CreateFromQuaternion(frame.Orientation)
* Matrix4x4.CreateTranslation(frame.Origin);
} }
return result; return result;
} }

View file

@ -14,8 +14,6 @@ namespace AcDream.Core.Vfx;
/// </summary> /// </summary>
public sealed class EmitterDescRegistry public sealed class EmitterDescRegistry
{ {
private const uint FallbackEmitterId = 0xFFFFFFFFu;
private readonly Func<uint, DatParticleEmitter?>? _resolver; private readonly Func<uint, DatParticleEmitter?>? _resolver;
private readonly ConcurrentDictionary<uint, EmitterDesc> _byId = new(); private readonly ConcurrentDictionary<uint, EmitterDesc> _byId = new();
@ -32,7 +30,6 @@ public sealed class EmitterDescRegistry
public EmitterDescRegistry(Func<uint, DatParticleEmitter?>? resolver) public EmitterDescRegistry(Func<uint, DatParticleEmitter?>? resolver)
{ {
_resolver = resolver; _resolver = resolver;
Register(BuildFallback());
} }
public void Register(EmitterDesc desc) public void Register(EmitterDesc desc)
@ -43,9 +40,16 @@ public sealed class EmitterDescRegistry
public EmitterDesc Get(uint emitterId) public EmitterDesc Get(uint emitterId)
{ {
if (_byId.TryGetValue(emitterId, out var desc)) if (TryGet(emitterId, out EmitterDesc? desc))
return 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) if (_resolver is not null)
{ {
var dat = _resolver(emitterId); var dat = _resolver(emitterId);
@ -53,14 +57,11 @@ public sealed class EmitterDescRegistry
{ {
desc = FromDat(emitterId, dat); desc = FromDat(emitterId, dat);
_byId[emitterId] = desc; _byId[emitterId] = desc;
return desc; return true;
} }
} }
desc = null!;
if (_byId.TryGetValue(FallbackEmitterId, out var fallback)) return false;
return fallback;
throw new InvalidOperationException("No default emitter registered in registry.");
} }
public int Count => _byId.Count; 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 private static ParticleEmitterKind MapEmitterKind(DatEmitterType type) => type switch
{ {
DatEmitterType.BirthratePerSec => ParticleEmitterKind.BirthratePerSec, DatEmitterType.BirthratePerSec => ParticleEmitterKind.BirthratePerSec,

View file

@ -0,0 +1,24 @@
using System.Numerics;
namespace AcDream.Core.Vfx;
/// <summary>
/// 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.
/// </summary>
public interface IEntityEffectPoseSource
{
bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld);
bool TryGetPartPose(uint localEntityId, int partIndex, out Matrix4x4 partLocal);
}
/// <summary>
/// Optional spatial companion for consumers, such as object lights, whose
/// render registration is scoped to the owner's current AC cell.
/// </summary>
public interface IEntityEffectCellSource
{
bool TryGetCellId(uint localEntityId, out uint cellId);
}

View file

@ -1,191 +1,177 @@
using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Numerics; using System.Numerics;
using System.Threading;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using DatReaderWriter.Types; using DatReaderWriter.Types;
namespace AcDream.Core.Vfx; namespace AcDream.Core.Vfx;
/// <summary> /// <summary>
/// <see cref="IAnimationHookSink"/> that translates particle-bearing /// Routes particle animation hooks through the owner's current root/part pose
/// animation hooks into <see cref="ParticleSystem"/> spawn / stop calls. /// and retains the retail logical-emitter identity rules.
///
/// <para>
/// Hook types handled (r04 §6):
/// <list type="bullet">
/// <item><description>
/// <see cref="CreateParticleHook"/> — spawn an emitter from the
/// hook's <c>EmitterInfoId</c> 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.
/// </description></item>
/// <item><description>
/// <see cref="CreateBlockingParticleHook"/> — 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 <see cref="RetailCreateBlockingParticleHook"/> so
/// the inherited payload is retained despite the dependency's header-only
/// model. Suppression itself lands with live emitter bindings in Step 5.
/// </description></item>
/// <item><description>
/// <see cref="DestroyParticleHook"/> — stop the most-recent emitter
/// matching the hook's <c>EmitterId</c>. Deferred to a future pass
/// when we retain per-entity emitter-id → handle maps.
/// </description></item>
/// <item><description>
/// <see cref="StopParticleHook"/> — pause all emitters on the
/// entity (fade out).
/// </description></item>
/// <item><description>
/// <see cref="DefaultScriptHook"/> / <see cref="DefaultScriptPartHook"/>
/// — trigger the entity's <c>DefaultScriptId</c> PhysicsScript.
/// Requires PhysicsScript table; deferred.
/// </description></item>
/// <item><description>
/// <see cref="CallPESHook"/> — fire a PhysicsScript by id.
/// Deferred until DRW exposes PhysicsScript dat.
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// Per-entity emitter handle tracking is kept here so DestroyParticle /
/// StopParticle can target the right emitter when a server-sent
/// <c>PlayEffect</c> fires.
/// </para>
/// </summary> /// </summary>
/// <remarks>
/// <para>
/// Retail oracle: <c>CreateParticleHook::Execute</c> (<c>0x00526EC0</c>),
/// <c>CreateBlockingParticleHook::Execute</c> (<c>0x00526EF0</c>),
/// <c>ParticleManager::CreateParticleEmitter</c> (<c>0x0051B6C0</c>), and
/// <c>CreateBlockingParticleEmitter</c> (<c>0x0051B8A0</c>).
/// </para>
/// <para>
/// The complete hook offset frame is retained on each binding for schema
/// fidelity. Retail <c>Particle::Init</c> (<c>0x0051C930</c>) 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.
/// </para>
/// </remarks>
public sealed class ParticleHookSink : IAnimationHookSink public sealed class ParticleHookSink : IAnimationHookSink
{ {
private readonly ParticleSystem _system; private readonly ParticleSystem _system;
private readonly IEntityEffectPoseSource _poses;
// 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 ConcurrentDictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new(); 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<uint, ConcurrentDictionary<int, byte>> _handlesByEntity = new(); private readonly ConcurrentDictionary<uint, ConcurrentDictionary<int, byte>> _handlesByEntity = new();
// Reverse lookup: handle → (entity, key) for O(1) cleanup on EmitterDied. private readonly ConcurrentDictionary<int, EmitterBinding> _bindingsByHandle = new();
private readonly ConcurrentDictionary<int, (uint EntityId, uint KeyId)> _trackingByHandle = new();
private readonly ConcurrentDictionary<uint, ParticleRenderPass> _renderPassByEntity = new(); private readonly ConcurrentDictionary<uint, ParticleRenderPass> _renderPassByEntity = new();
private readonly ConcurrentDictionary<uint, Quaternion> _rotationByEntity = new(); private readonly ConcurrentDictionary<uint, byte> _hiddenPresentationOwners = new();
// C.1.5b #56: per-entity static part transforms (PlacementFrames[Resting] public ParticleHookSink(ParticleSystem system, IEntityEffectPoseSource poses)
// baked into a Matrix4x4 per Setup part). When set, SpawnFromHook applies
// partTransforms[hook.PartIndex] to the hook offset BEFORE rotating to
// world space. Without this, every emitter in a multi-part Setup
// collapses to the entity root (the bug). Cleared by StopAllForEntity.
// For ANIMATED entities this map would need a per-tick refresh similar
// to UpdateEntityAnchor — deferred to a future phase.
private readonly ConcurrentDictionary<uint, IReadOnlyList<Matrix4x4>> _partTransformsByEntity = new();
private int _anonymousEmitterSerial;
public ParticleHookSink(ParticleSystem system)
{ {
_system = system ?? throw new ArgumentNullException(nameof(system)); _system = system ?? throw new ArgumentNullException(nameof(system));
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
_system.EmitterDied += OnEmitterDied; _system.EmitterDied += OnEmitterDied;
} }
public Action<string>? DiagnosticSink { get; set; }
private void OnEmitterDied(int handle) private void OnEmitterDied(int handle)
{ {
if (!_trackingByHandle.TryRemove(handle, out var t)) if (!_bindingsByHandle.TryRemove(handle, out EmitterBinding binding))
return; return;
_handlesByKey.TryRemove((t.EntityId, t.KeyId), out _);
if (_handlesByEntity.TryGetValue(t.EntityId, out var bag)) if (binding.LogicalId != 0
bag.TryRemove(handle, out _); && _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) public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
{ {
switch (hook) switch (hook)
{ {
case RetailCreateBlockingParticleHook: case RetailCreateBlockingParticleHook blocking:
// The centralized content reader now preserves the complete SpawnFromHook(
// retail payload. Step 5 owns live logical-ID suppression and entityId,
// attachment semantics; do not silently treat blocking as a (uint)blocking.EmitterInfoId,
// normal replacement before that mechanism lands. blocking.Offset,
unchecked((int)blocking.PartIndex),
blocking.EmitterId,
isBlocking: true);
break; break;
case CreateParticleHook cph: case CreateParticleHook create:
SpawnFromHook(entityId, entityWorldPosition, SpawnFromHook(
emitterInfoId: (uint)cph.EmitterInfoId, entityId,
offset: cph.Offset.Origin, (uint)create.EmitterInfoId,
partIndex: (int)cph.PartIndex, create.Offset,
logicalId: cph.EmitterId); unchecked((int)create.PartIndex),
create.EmitterId,
isBlocking: false);
break; break;
case CreateBlockingParticleHook: case CreateBlockingParticleHook:
// Defensive package-decoder shape. Production raw loaders // The upstream package exposes only the common header for this
// replace this header-only model with the retail subclass. // 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; break;
case DestroyParticleHook dph: case DestroyParticleHook destroy:
if (_handlesByKey.TryRemove((entityId, dph.EmitterId), out var handleToDestroy)) DestroyLogical(entityId, destroy.EmitterId, fadeOut: false);
_system.StopEmitter(handleToDestroy, fadeOut: false);
break; break;
case StopParticleHook sph: case StopParticleHook stop:
if (_handlesByKey.TryGetValue((entityId, sph.EmitterId), out var handleToStop)) DestroyLogical(entityId, stop.EmitterId, fadeOut: true);
_system.StopEmitter(handleToStop, fadeOut: true);
break; break;
// DefaultScript / CallPES are routed by EntityEffectController.
// ParticleHookSink intentionally owns particles only.
} }
} }
public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass) public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass) =>
=> _renderPassByEntity[entityId] = renderPass; _renderPassByEntity[entityId] = renderPass;
public void SetEntityRotation(uint entityId, Quaternion rotation) public void ClearEntityRenderPass(uint entityId) =>
=> _rotationByEntity[entityId] = rotation; _renderPassByEntity.TryRemove(entityId, out _);
/// <summary> /// <summary>
/// Register per-part static transforms for an entity. The caller /// Mirrors live cell membership without ending emitter lifetime. Retail
/// (typically <c>EntityScriptActivator</c>) precomputes one /// pauses the owner's particle manager while cell-less, then resumes the
/// <see cref="Matrix4x4"/> per Setup part using /// same particles and logical IDs without catch-up emission on re-entry.
/// <c>SetupPartTransforms.Compute</c> and pushes them here at spawn
/// time. <see cref="SpawnFromHook"/> applies
/// <c>partTransforms[hook.PartIndex]</c> to the hook offset BEFORE
/// transforming to world space. Cleared on
/// <see cref="StopAllForEntity"/>.
/// </summary> /// </summary>
public void SetEntityPartTransforms(uint entityId, IReadOnlyList<Matrix4x4> partTransforms) public void SetEntityPresentationVisible(uint entityId, bool visible)
=> _partTransformsByEntity[entityId] = partTransforms;
public void ClearEntityRenderPass(uint entityId)
=> _renderPassByEntity.TryRemove(entityId, out _);
/// <summary>
/// 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
/// <c>ParticleEmitter::UpdateParticles</c> at <c>0x0051d2d4</c>, which
/// re-reads the parent frame each tick when <c>is_parent_local != 0</c>.
/// Safe to call for entities with no live emitters (no-op).
/// </summary>
public void UpdateEntityAnchor(uint entityId, Vector3 anchor, Quaternion rotation)
{ {
_rotationByEntity[entityId] = rotation; if (visible)
if (!_handlesByEntity.TryGetValue(entityId, out var bag)) _hiddenPresentationOwners.TryRemove(entityId, out _);
return; else
foreach (var handle in bag.Keys) _hiddenPresentationOwners[entityId] = 0;
_system.UpdateEmitterAnchor(handle, anchor, rotation);
// 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);
}
}
}
/// <summary>
/// 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.
/// </summary>
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) public void StopAllForEntity(uint entityId, bool fadeOut)
{ {
if (_handlesByEntity.TryRemove(entityId, out var handles)) 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); _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) if (key.EntityId == entityId)
_handlesByKey.TryRemove(key, out _); _handlesByKey.TryRemove(key, out _);
} }
ClearEntityRenderPass(entityId); ClearEntityRenderPass(entityId);
_rotationByEntity.TryRemove(entityId, out _); _hiddenPresentationOwners.TryRemove(entityId, out _);
_partTransformsByEntity.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( private void SpawnFromHook(
uint entityId, uint ownerLocalId,
Vector3 worldPos,
uint emitterInfoId, uint emitterInfoId,
Vector3 offset, Frame offset,
int partIndex, int partIndex,
uint logicalId) uint logicalId,
bool isBlocking)
{ {
// Spawn position: entity pose + hook offset, with the hook if (logicalId != 0
// offset first passed through the per-part transform when && _handlesByKey.TryGetValue((ownerLocalId, logicalId), out int existing))
// 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)
{ {
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 ? pass
: ParticleRenderPass.Scene; : ParticleRenderPass.Scene;
if (!_system.TrySpawnEmitterById(
int handle = _system.SpawnEmitterById( emitterInfoId,
emitterId: emitterInfoId, anchor,
anchor: anchor, rotation,
rot: rotation, ownerLocalId,
attachedObjectId: entityId, partIndex,
attachedPartIndex: partIndex, renderPass,
renderPass: renderPass); out int handle))
uint keyId = logicalId != 0
? logicalId
: 0x80000000u | (uint)Interlocked.Increment(ref _anonymousEmitterSerial);
if (logicalId != 0 && _handlesByKey.TryRemove((entityId, keyId), out var oldHandle))
{ {
_system.StopEmitter(oldHandle, fadeOut: false); DiagnosticSink?.Invoke(
_trackingByHandle.TryRemove(oldHandle, out _); $"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 _handlesByEntity
.GetOrAdd(entityId, _ => new ConcurrentDictionary<int, byte>()) .GetOrAdd(ownerLocalId, _ => new ConcurrentDictionary<int, byte>())
.TryAdd(handle, 0); .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);
} }

View file

@ -75,6 +75,31 @@ public sealed class ParticleSystem : IParticleSystem
return SpawnEmitter(desc, anchor, rot, attachedObjectId, attachedPartIndex, renderPass); 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) public void PlayScript(uint scriptId, uint targetObjectId, float modifier = 1f)
{ {
// Full PhysicsScript scheduling lives in PhysicsScriptRunner. // Full PhysicsScript scheduling lives in PhysicsScriptRunner.
@ -90,6 +115,12 @@ public sealed class ParticleSystem : IParticleSystem
{ {
for (int i = 0; i < em.Particles.Length; i++) for (int i = 0; i < em.Particles.Length; i++)
em.Particles[i].Alive = false; 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)) if (!_byHandle.TryGetValue(handle, out var em))
return; return;
em.AnchorPos = anchor; em.AnchorPos = anchor;
if (!em.SimulationEnabled)
em.LastEmitOffset = anchor;
if (rot.HasValue) if (rot.HasValue)
em.AnchorRot = rot.Value; em.AnchorRot = rot.Value;
} }
/// <summary>
/// Changes only render presentation. Logical lifetime is unaffected.
/// </summary>
public void SetEmitterPresentationVisible(int handle, bool visible)
{
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
emitter.PresentationVisible = visible;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>True when the given handle still maps to a live emitter.</summary> /// <summary>True when the given handle still maps to a live emitter.</summary>
public bool IsEmitterAlive(int handle) => _byHandle.ContainsKey(handle); public bool IsEmitterAlive(int handle) => _byHandle.ContainsKey(handle);
@ -136,6 +207,8 @@ public sealed class ParticleSystem : IParticleSystem
int handle = _handleOrder[i]; int handle = _handleOrder[i];
if (!_byHandle.TryGetValue(handle, out var em)) if (!_byHandle.TryGetValue(handle, out var em))
continue; continue;
if (!em.SimulationEnabled)
continue;
AdvanceEmitter(em); AdvanceEmitter(em);
int live = CountAlive(em); int live = CountAlive(em);

View file

@ -180,6 +180,15 @@ public sealed class ParticleEmitter
public Vector3 AnchorPos { get; set; } public Vector3 AnchorPos { get; set; }
public Quaternion AnchorRot { get; set; } = Quaternion.Identity; public Quaternion AnchorRot { get; set; } = Quaternion.Identity;
public uint AttachedObjectId { get; set; } public uint AttachedObjectId { get; set; }
/// <summary>
/// Spatial presentation latch. A cell-less owner submits no particles.
/// </summary>
public bool PresentationVisible { get; set; } = true;
/// <summary>
/// Retail cell-membership update gate. Paused emitters retain their exact
/// particles, logical identity, and elapsed-time position until re-entry.
/// </summary>
public bool SimulationEnabled { get; set; } = true;
public int AttachedPartIndex { get; set; } = -1; public int AttachedPartIndex { get; set; } = -1;
public Particle[] Particles { get; init; } = null!; public Particle[] Particles { get; init; } = null!;
public ParticleRenderPass RenderPass { get; init; } public ParticleRenderPass RenderPass { get; init; }

View file

@ -30,6 +30,30 @@ public sealed class WorldEntity
/// </summary> /// </summary>
public required IReadOnlyList<MeshRef> MeshRefs { get; set; } public required IReadOnlyList<MeshRef> MeshRefs { get; set; }
/// <summary>
/// Stable Setup-part-indexed poses used by attachment and effect hooks.
/// Unlike <see cref="MeshRefs"/>, this array never compacts around a DAT
/// part whose GfxObj could not be loaded. <see cref="IndexedPartAvailable"/>
/// distinguishes a real indexed part from a retained placeholder pose.
/// </summary>
public IReadOnlyList<Matrix4x4> IndexedPartTransforms { get; private set; } =
Array.Empty<Matrix4x4>();
public IReadOnlyList<bool> IndexedPartAvailable { get; private set; } =
Array.Empty<bool>();
public void SetIndexedPartPoses(
IReadOnlyList<Matrix4x4> transforms,
IReadOnlyList<bool> 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;
}
/// <summary> /// <summary>
/// Optional per-entity palette override (server-specified base + /// Optional per-entity palette override (server-specified base +
/// subpalette overlays). When non-null, applies to every palette- /// subpalette overlays). When non-null, applies to every palette-

View file

@ -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<uint, uint?>
{
[grandchild] = parent,
[parent] = null,
};
var calls = new List<uint>();
var children = insertionOrder.ToDictionary(id => id, id => parents[id]);
new AttachmentUpdateOrder<uint?>().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<uint, uint?>
{
[grandchild] = parent,
[parent] = null,
};
var calls = new List<uint>();
IReadOnlyList<uint> failed = new AttachmentUpdateOrder<uint?>().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<uint, uint?>
{
[20u] = 10u,
[30u] = 20u,
};
var order = new AttachmentUpdateOrder<uint?>().CollectSubtreePostOrder(
children,
10u,
static parentId => parentId);
Assert.Equal([30u, 20u, 10u], order);
}
[Fact]
public void PoseRecoveryRealizesCompleteDescendantChainParentFirst()
{
var waitingByParent = new Dictionary<uint, IReadOnlyList<uint>>
{
[10u] = [20u],
[20u] = [30u],
};
var realized = new List<uint>();
new AttachmentUpdateOrder<uint?>().RealizeDescendants(
10u,
parent => waitingByParent.TryGetValue(parent, out var waiting)
? waiting
: Array.Empty<uint>(),
child =>
{
realized.Add(child);
return true;
});
Assert.Equal([20u, 30u], realized);
}
[Fact]
public void PoseRecoveryDoesNotTraverseThroughUnrealizedIntermediateParent()
{
var waitingByParent = new Dictionary<uint, IReadOnlyList<uint>>
{
[10u] = [20u],
[20u] = [30u],
};
var attempted = new List<uint>();
new AttachmentUpdateOrder<uint?>().RealizeDescendants(
10u,
parent => waitingByParent.TryGetValue(parent, out var waiting)
? waiting
: Array.Empty<uint>(),
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<AcDream.Core.World.MeshRef>(),
};
Assert.False(EquippedChildRenderController.ApplyParentWorldPose(
child,
Matrix4x4.CreateScale(2f) * Matrix4x4.CreateTranslation(10, 0, 0)));
}
private sealed class Vector3ToleranceComparer : IEqualityComparer<Vector3>
{
public bool Equals(Vector3 x, Vector3 y) => Vector3.DistanceSquared(x, y) < 1e-8f;
public int GetHashCode(Vector3 obj) => obj.GetHashCode();
}
}

View file

@ -24,7 +24,8 @@ public sealed class LiveAppearanceAnimationTests
HighFrame = 9, HighFrame = 9,
Framerate = 30f, Framerate = 30f,
Scale = 1f, Scale = 1f,
PartTemplate = [(0x01000001u, null)], PartTemplate = [new GameWindow.AnimatedPartTemplate(0x01000001u, null, true)],
PartAvailability = [true],
CurrFrame = 6.5f, CurrFrame = 6.5f,
Sequencer = sequencer, Sequencer = sequencer,
}; };
@ -36,7 +37,15 @@ public sealed class LiveAppearanceAnimationTests
oldEntity.ApplyAppearance(dressedParts, paletteOverride: null, partOverrides: []); oldEntity.ApplyAppearance(dressedParts, paletteOverride: null, partOverrides: []);
GameWindow.RebindAnimatedEntityForAppearance( 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(oldEntity, state.Entity);
Assert.Same(dressedParts, state.Entity.MeshRefs); Assert.Same(dressedParts, state.Entity.MeshRefs);

View file

@ -70,6 +70,7 @@ public sealed class EntityEffectControllerTests
new PhysicsScriptTableResolver( new PhysicsScriptTableResolver(
tableLoader tableLoader
?? (id => _tables.TryGetValue(id, out PhysicsScriptTable? table) ? table : null)), ?? (id => _tables.TryGetValue(id, out PhysicsScriptTable? table) ? table : null)),
Poses,
childAtPart, childAtPart,
parentOfAttachedChild, parentOfAttachedChild,
ownerUnregistered, ownerUnregistered,
@ -88,6 +89,7 @@ public sealed class EntityEffectControllerTests
public AnimationHookRouter Router { get; } = new(); public AnimationHookRouter Router { get; } = new();
public PhysicsScriptRunner Runner { get; } public PhysicsScriptRunner Runner { get; }
public EntityEffectController Controller { get; } public EntityEffectController Controller { get; }
public EntityEffectPoseRegistry Poses { get; } = new();
public void AddScript(uint did, uint emitterId, double startTime = 0.0) public void AddScript(uint did, uint emitterId, double startTime = 0.0)
{ {
@ -246,7 +248,7 @@ public sealed class EntityEffectControllerTests
0x0202FFFFu, 0x0202FFFFu,
new LandBlock(), new LandBlock(),
Array.Empty<WorldEntity>())); Array.Empty<WorldEntity>()));
fixture.Controller.RefreshLiveOwnerAnchors(); fixture.Controller.RefreshLiveOwnerPoses();
fixture.Runner.Tick(0.0); fixture.Runner.Tick(0.0);
Assert.Equal(0, fixture.Controller.PendingPacketCount); Assert.Equal(0, fixture.Controller.PendingPacketCount);
@ -549,6 +551,34 @@ public sealed class EntityEffectControllerTests
Assert.False(fixture.Controller.PlayDefault(childLocalId)); 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<Matrix4x4>(),
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] [Fact]
public void MissingDirectScriptReportsOwnerAndDid() public void MissingDirectScriptReportsOwnerAndDid()
{ {
@ -627,7 +657,7 @@ public sealed class EntityEffectControllerTests
} }
[Fact] [Fact]
public void RefreshLiveOwnerAnchorsUsesCurrentWorldPositionAtDispatch() public void RefreshLiveOwnerPosesUsesCurrentWorldPositionAtDispatch()
{ {
var fixture = new Fixture(); var fixture = new Fixture();
fixture.AddScript(DirectDid, 1u, startTime: 1.0); fixture.AddScript(DirectDid, 1u, startTime: 1.0);
@ -635,7 +665,7 @@ public sealed class EntityEffectControllerTests
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid)); fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
entity.SetPosition(new Vector3(10, 20, 30)); entity.SetPosition(new Vector3(10, 20, 30));
fixture.Controller.RefreshLiveOwnerAnchors(); fixture.Controller.RefreshLiveOwnerPoses();
fixture.Runner.Tick(1.0); fixture.Runner.Tick(1.0);
Assert.Equal(new Vector3(10, 20, 30), Assert.Single(fixture.Sink.Calls).Position); Assert.Equal(new Vector3(10, 20, 30), Assert.Single(fixture.Sink.Calls).Position);

View file

@ -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<Matrix4x4>());
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<MeshRef>(),
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;
}
}

View file

@ -43,6 +43,7 @@ public sealed class EntityScriptActivatorTests
private record Pipeline( private record Pipeline(
ParticleSystem System, ParticleSystem System,
ParticleHookSink Sink, ParticleHookSink Sink,
EntityEffectPoseRegistry Poses,
PhysicsScriptRunner Runner, PhysicsScriptRunner Runner,
RecordingSink Recording); RecordingSink Recording);
@ -50,14 +51,15 @@ public sealed class EntityScriptActivatorTests
{ {
var registry = new EmitterDescRegistry(); var registry = new EmitterDescRegistry();
var system = new ParticleSystem(registry); 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 recording = new RecordingSink(); // for runner's hook dispatch
var table = new Dictionary<uint, DatPhysicsScript>(); var table = new Dictionary<uint, DatPhysicsScript>();
foreach (var (id, s) in scripts) table[id] = s; foreach (var (id, s) in scripts) table[id] = s;
var runner = new PhysicsScriptRunner( var runner = new PhysicsScriptRunner(
id => table.TryGetValue(id, out var s) ? s : null, id => table.TryGetValue(id, out var s) ? s : null,
recording); recording);
return new Pipeline(system, hookSink, runner, recording); return new Pipeline(system, hookSink, poses, runner, recording);
} }
/// <summary> /// <summary>
@ -74,7 +76,7 @@ public sealed class EntityScriptActivatorTests
{ {
var p = BuildPipeline( var p = BuildPipeline(
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 })))); (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)); var entity = MakeEntity(serverGuid: 0xCAFEu, position: new Vector3(1, 2, 3));
activator.OnCreate(entity); activator.OnCreate(entity);
@ -90,7 +92,7 @@ public sealed class EntityScriptActivatorTests
public void OnCreate_WithoutDefaultScript_DoesNothing() public void OnCreate_WithoutDefaultScript_DoesNothing()
{ {
var p = BuildPipeline(); // no scripts registered 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); var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
activator.OnCreate(entity); activator.OnCreate(entity);
@ -125,11 +127,11 @@ public sealed class EntityScriptActivatorTests
}; };
[Fact] [Fact]
public void OnCreate_SetsEntityRotationForHookOffsetTransform() public void OnCreate_PublishesEntityRotationForHookOffsetTransform()
{ {
// The CreateParticleHook's Offset is in entity-local frame; the sink // The CreateParticleHook's Offset is in entity-local frame; the sink
// needs the entity's rotation to transform it to world space. If the // reads the published entity root to transform it to world space. If
// activator forgets SetEntityRotation, the offset goes off in world // the activator omits that pose, the offset goes off in world
// axes — visual symptom: portal swirls misaligned to the portal stone. // axes — visual symptom: portal swirls misaligned to the portal stone.
// This test verifies the seed happens by checking the spawned particle's // This test verifies the seed happens by checking the spawned particle's
// world position matches the rotated offset, not the unrotated offset. // world position matches the rotated offset, not the unrotated offset.
@ -138,7 +140,8 @@ public sealed class EntityScriptActivatorTests
registry.Register(BuildPersistentEmitterDesc()); registry.Register(BuildPersistentEmitterDesc());
var system = new ParticleSystem(registry); 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. // Hook offset = (1, 0, 0) in entity-local frame.
var hookOffset = new Frame var hookOffset = new Frame
@ -147,13 +150,18 @@ public sealed class EntityScriptActivatorTests
Orientation = Quaternion.Identity, Orientation = Quaternion.Identity,
}; };
var script = BuildScript( 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<uint, DatPhysicsScript> { [0xAAu] = script }; var table = new Dictionary<uint, DatPhysicsScript> { [0xAAu] = script };
var runner = new PhysicsScriptRunner( var runner = new PhysicsScriptRunner(
id => table.TryGetValue(id, out var s) ? s : null, id => table.TryGetValue(id, out var s) ? s : null,
hookSink); 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. // Entity rotated 90° around world-Z (yaw left); local +X maps to world +Y.
var entityRotation = Quaternion.CreateFromAxisAngle( var entityRotation = Quaternion.CreateFromAxisAngle(
@ -193,15 +201,21 @@ public sealed class EntityScriptActivatorTests
registry.Register(BuildPersistentEmitterDesc()); registry.Register(BuildPersistentEmitterDesc());
var system = new ParticleSystem(registry); 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<uint, DatPhysicsScript> { [0xAAu] = script }; var table = new Dictionary<uint, DatPhysicsScript> { [0xAAu] = script };
var runner = new PhysicsScriptRunner( var runner = new PhysicsScriptRunner(
id => table.TryGetValue(id, out var s) ? s : null, id => table.TryGetValue(id, out var s) ? s : null,
hookSink); // runner dispatches into real sink, not RecordingSink 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); var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
activator.OnCreate(entity); activator.OnCreate(entity);
@ -226,7 +240,7 @@ public sealed class EntityScriptActivatorTests
// OnCreate must use entity.Id as the key (not skip). // OnCreate must use entity.Id as the key (not skip).
var p = BuildPipeline( var p = BuildPipeline(
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 })))); (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 var entity = new WorldEntity
{ {
Id = 0x40A9B401u, // dat-hydrated interior id Id = 0x40A9B401u, // dat-hydrated interior id
@ -251,7 +265,7 @@ public sealed class EntityScriptActivatorTests
{ {
var p = BuildPipeline( var p = BuildPipeline(
(0xAAu, BuildScript((1.0, new CreateParticleHook { EmitterInfoId = 100 })))); (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 first = MakeDatStatic(0x80A9B400u, new Vector3(1, 0, 0));
WorldEntity second = MakeDatStatic(0x80A9B400u, new Vector3(2, 0, 0)); WorldEntity second = MakeDatStatic(0x80A9B400u, new Vector3(2, 0, 0));
@ -268,7 +282,7 @@ public sealed class EntityScriptActivatorTests
public void LiveParticleFilterUsesCanonicalLocalEffectOwnerNotServerGuid() public void LiveParticleFilterUsesCanonicalLocalEffectOwnerNotServerGuid()
{ {
var p = BuildPipeline(); 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 var entity = new WorldEntity
{ {
Id = 1_000_123u, Id = 1_000_123u,
@ -285,6 +299,34 @@ public sealed class EntityScriptActivatorTests
Assert.NotEqual(entity.ServerGuid, GameWindow.ParticleEntityKey(entity)); 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<MeshRef>(),
};
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] [Fact]
public void OnCreate_PassesPartTransformsToSink() public void OnCreate_PassesPartTransformsToSink()
{ {
@ -295,7 +337,8 @@ public sealed class EntityScriptActivatorTests
var registry = new EmitterDescRegistry(); var registry = new EmitterDescRegistry();
registry.Register(BuildPersistentEmitterDesc()); registry.Register(BuildPersistentEmitterDesc());
var system = new ParticleSystem(registry); 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 hookOffset = new Frame { Origin = new Vector3(1f, 0, 0), Orientation = Quaternion.Identity };
var script = BuildScript( var script = BuildScript(
@ -311,7 +354,7 @@ public sealed class EntityScriptActivatorTests
Matrix4x4.CreateTranslation(0f, 0f, 1f), Matrix4x4.CreateTranslation(0f, 0f, 1f),
}; };
var activator = new EntityScriptActivator(runner, hookSink, var activator = new EntityScriptActivator(runner, hookSink, poses,
_ => new ScriptActivationInfo(0xAAu, partTransforms)); _ => new ScriptActivationInfo(0xAAu, partTransforms));
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero); var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
@ -335,15 +378,21 @@ public sealed class EntityScriptActivatorTests
var registry = new EmitterDescRegistry(); var registry = new EmitterDescRegistry();
registry.Register(BuildPersistentEmitterDesc()); registry.Register(BuildPersistentEmitterDesc());
var system = new ParticleSystem(registry); 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<uint, DatPhysicsScript> { [0xAAu] = script }; var table = new Dictionary<uint, DatPhysicsScript> { [0xAAu] = script };
var runner = new PhysicsScriptRunner( var runner = new PhysicsScriptRunner(
id => table.TryGetValue(id, out var s) ? s : null, id => table.TryGetValue(id, out var s) ? s : null,
hookSink); hookSink);
var activator = new EntityScriptActivator(runner, hookSink, StaticResolver(0xAAu)); var activator = new EntityScriptActivator(runner, hookSink, poses, StaticResolver(0xAAu));
var entity = new WorldEntity var entity = new WorldEntity
{ {
Id = 0x40A9B402u, Id = 0x40A9B402u,

View file

@ -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]);
}
}

View file

@ -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<Matrix4x4>(),
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<WorldEntity>()));
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<WorldEntity>()));
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<WorldEntity>()));
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<MeshRef>(),
},
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<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
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) { }
}
}

View file

@ -49,10 +49,11 @@ public sealed class GpuWorldStateActivatorTests
var registry = new EmitterDescRegistry(); var registry = new EmitterDescRegistry();
var system = new ParticleSystem(registry); 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 recording = new RecordingSink();
var runner = new PhysicsScriptRunner(id => table.TryGetValue(id, out var s) ? s : null, recording); 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<Matrix4x4>())); _ => new ScriptActivationInfo(scriptId, Array.Empty<Matrix4x4>()));
var state = new GpuWorldState(entityScriptActivator: activator); var state = new GpuWorldState(entityScriptActivator: activator);

View file

@ -1,5 +1,6 @@
using System.Numerics; using System.Numerics;
using AcDream.Core.Lighting; using AcDream.Core.Lighting;
using AcDream.Core.Vfx;
using DatReaderWriter.Types; using DatReaderWriter.Types;
using Xunit; using Xunit;
@ -11,7 +12,7 @@ public sealed class LightingHookSinkTests
public void SetLightHook_FlipsOwnedLights() public void SetLightHook_FlipsOwnedLights()
{ {
var mgr = new LightManager(); 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 light1 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true };
var light2 = 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() public void UnregisterOwner_RemovesAllOwnedLights()
{ {
var mgr = new LightManager(); 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 });
sink.RegisterOwnedLight(new LightSource { OwnerId = 7 }); sink.RegisterOwnedLight(new LightSource { OwnerId = 7 });
@ -46,7 +47,7 @@ public sealed class LightingHookSinkTests
public void UnrelatedHook_Ignored() public void UnrelatedHook_Ignored()
{ {
var mgr = new LightManager(); var mgr = new LightManager();
var sink = new LightingHookSink(mgr); var sink = new LightingHookSink(mgr, new MutablePoseSource());
var light = new LightSource { OwnerId = 1, IsLit = true }; var light = new LightSource { OwnerId = 1, IsLit = true };
sink.RegisterOwnedLight(light); sink.RegisterOwnedLight(light);
@ -56,6 +57,69 @@ public sealed class LightingHookSinkTests
Assert.True(light.IsLit); 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 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(10.4f, light.Range, 3); // Falloff 8 × static_light_factor 1.3 (calc_point_light 0x00820e24)
Assert.Equal(0.8f, light.Intensity); Assert.Equal(0.8f, light.Intensity);
Assert.Equal(new Vector3(101, 202, 303), light.WorldPosition); 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); 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] [Fact]
public void Load_NonZeroConeAngle_ProducesSpot() public void Load_NonZeroConeAngle_ProducesSpot()
{ {
@ -117,3 +205,42 @@ public sealed class LightInfoLoaderTests
Assert.Equal(0.5f, result[0].ConeAngle); Assert.Equal(0.5f, result[0].ConeAngle);
} }
} }
file sealed class MutablePoseSource : IEntityEffectPoseSource, IEntityEffectCellSource
{
private readonly Dictionary<uint, (Matrix4x4 Root, uint Cell)> _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;
}
}

View file

@ -25,8 +25,8 @@ public sealed class EquippedChildAttachmentTests
}; };
var parentPose = new[] var parentPose = new[]
{ {
new MeshRef(1, Matrix4x4.Identity), Matrix4x4.Identity,
new MeshRef(2, Matrix4x4.CreateTranslation(10, 0, 0)), Matrix4x4.CreateTranslation(10, 0, 0),
}; };
var child = new Setup var child = new Setup
{ {
@ -42,12 +42,14 @@ public sealed class EquippedChildAttachmentTests
}; };
var template = new[] { new MeshRef(0x01000001u, Matrix4x4.Identity) }; var template = new[] { new MeshRef(0x01000001u, Matrix4x4.Identity) };
bool ok = EquippedChildAttachment.TryCompose( bool ok = EquippedChildAttachment.TryComposePose(
parent, parentPose, child, ParentLocation.LeftHand, 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.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] [Fact]
@ -88,6 +90,115 @@ public sealed class EquippedChildAttachmentTests
Assert.Empty(result); 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<Matrix4x4>(),
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() private static Setup ParentWithRightHand(int partId, Frame frame) => new()
{ {
HoldingLocations = HoldingLocations =

View file

@ -88,7 +88,7 @@ public class SetupPartTransformsTests
} }
[Fact] [Fact]
public void Compute_AppliesDefaultScale_WhenPresent() public void Compute_ExcludesVisualDefaultScale_FromRigidPartFrame()
{ {
// DefaultScale = (2,2,2) on part 0. An input (1,1,1) should // DefaultScale = (2,2,2) on part 0. An input (1,1,1) should
// come out (2,2,2) after the part transform — confirms the // come out (2,2,2) after the part transform — confirms the
@ -113,6 +113,37 @@ public class SetupPartTransformsTests
Assert.Single(transforms); Assert.Single(transforms);
var probe = Vector3.Transform(new Vector3(1f, 1f, 1f), transforms[0]); 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);
} }
} }

View file

@ -7,9 +7,14 @@ namespace AcDream.Core.Tests.Vfx;
public sealed class ParticleHookSinkTests public sealed class ParticleHookSinkTests
{ {
private static EmitterDesc MakeDesc(uint id, bool attachLocal, int totalParticles = 0) private const uint Owner = 0xCAFEu;
{
return new EmitterDesc private static EmitterDesc MakeDesc(
uint id,
bool attachLocal,
int totalParticles = 0,
float totalDuration = 0f) =>
new()
{ {
DatId = id, DatId = id,
Type = ParticleType.Still, Type = ParticleType.Still,
@ -18,158 +23,350 @@ public sealed class ParticleHookSinkTests
MaxParticles = 4, MaxParticles = 4,
InitialParticles = 1, InitialParticles = 1,
TotalParticles = totalParticles, TotalParticles = totalParticles,
LifetimeMin = 0.05f, LifetimeMax = 0.05f, Lifespan = 0.05f, TotalDuration = totalDuration,
StartSize = 1f, EndSize = 1f, LifetimeMin = 0.05f,
StartAlpha = 1f, EndAlpha = 1f, LifetimeMax = 0.05f,
Birthrate = 1000f, // effectively never re-emit 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] [Fact]
public void UpdateEntityAnchor_WithAttachLocal_MovesParticleToLiveAnchor() public void WorldReleasedParticles_KeepBirthPosition_WhileNewAnchorMoves()
{ {
var registry = new EmitterDescRegistry(); const uint emitterId = 0x32000011u;
registry.Register(MakeDesc(0x32000010u, attachLocal: true)); var (system, sink, poses) = Harness(emitterId, attachLocal: false);
var sys = new ParticleSystem(registry, new System.Random(42)); sink.OnHook(Owner, Vector3.Zero, Create(emitterId, 0));
var sink = new ParticleHookSink(sys); 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, EmitterId = 0,
PartIndex = 0, PartIndex = 0,
Offset = new Frame(), 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()); sink.OnHook(Owner, Vector3.Zero, blocking);
Assert.Equal(Vector3.Zero, live1.Emitter.Particles[live1.Index].Position); sink.OnHook(Owner, Vector3.Zero, blocking);
Assert.Equal(2, system.ActiveEmitterCount);
// 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);
} }
[Fact] [Fact]
public void EmitterDied_PrunesPerEntityHandleTracking() public void StopLogicalEmitter_RemainsBlockingUntilFinalParticleRetires()
{ {
// M4: ConcurrentBag<int> couldn't drop entries when a particle const uint emitterId = 0x32000042u;
// emitter expired naturally, so per-entity tracking grew without const uint logicalId = 0x777u;
// bound. The sink now subscribes to ParticleSystem.EmitterDied var (system, sink, _) = Harness(emitterId);
// and prunes both the (entity,key) map and the per-entity set. var blocking = new RetailCreateBlockingParticleHook
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
{ {
EmitterInfoId = 0x32000020u, EmitterInfoId = emitterId,
EmitterId = 0xABCDu, // logical key EmitterId = logicalId,
PartIndex = 0, PartIndex = 0,
Offset = new Frame(), Offset = new Frame(),
}; };
sink.OnHook(0xCAFEu, Vector3.Zero, hook);
Assert.Equal(1, sys.ActiveEmitterCount);
// TotalParticles=1 cap hit immediately by the InitialParticles spawn, sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId));
// so the emitter Finishes once its single particle expires (0.05s sink.OnHook(Owner, Vector3.Zero, new StopParticleHook { EmitterId = logicalId });
// lifetime). After this, EmitterDied has fired and tracking is pruned. sink.OnHook(Owner, Vector3.Zero, blocking);
for (int i = 0; i < 5; i++) sys.Tick(0.05f);
Assert.Equal(0, sys.ActiveEmitterCount);
// A fresh spawn for the same (entity, key) succeeds and is the only Assert.Equal(1, system.ActiveEmitterCount);
// 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);
} }
[Fact] [Fact]
public void SpawnFromHook_AppliesPartTransform_WhenRegistered() public void StopLogicalEmitter_NormalCreateStillReplacesIt()
{ {
// C.1.5b #56: when SetEntityPartTransforms has been called for const uint emitterId = 0x32000043u;
// entityId, SpawnFromHook must transform the hook offset through const uint logicalId = 0x778u;
// the part-local matrix before applying entity rotation. var (system, sink, _) = Harness(emitterId);
// Part 1 is lifted +Z=1; hook offset = (1, 0, 0), PartIndex=1.
// Expected world position: (1, 0, 1) with identity rotation.
var registry = new EmitterDescRegistry();
registry.Register(MakeDesc(0x32000030u, attachLocal: false));
var sys = new ParticleSystem(registry, new System.Random(42));
var sink = new ParticleHookSink(sys);
var partTransforms = new Matrix4x4[] sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId));
{ sink.OnHook(Owner, Vector3.Zero, new StopParticleHook { EmitterId = logicalId });
Matrix4x4.Identity, sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId));
Matrix4x4.CreateTranslation(0f, 0f, 1f), system.Tick(0.001f);
};
sink.SetEntityRotation(0xCAFEu, Quaternion.Identity);
sink.SetEntityPartTransforms(0xCAFEu, partTransforms);
sink.OnHook(0xCAFEu, Vector3.Zero, new CreateParticleHook Assert.Equal(1, system.ActiveEmitterCount);
{
EmitterInfoId = 0x32000030u,
EmitterId = 0,
PartIndex = 1,
Offset = new Frame
{
Origin = new Vector3(1f, 0f, 0f),
Orientation = Quaternion.Identity,
},
});
sys.Tick(0.001f);
var live = System.Linq.Enumerable.Single(sys.EnumerateLive());
var pos = live.Emitter.Particles[live.Index].Position;
Assert.InRange(pos.X, 0.99f, 1.01f);
Assert.InRange(pos.Y, -0.01f, 0.01f);
Assert.InRange(pos.Z, 0.99f, 1.01f);
} }
[Fact] [Fact]
public void SpawnFromHook_FallsBackToIdentity_WhenPartIndexOutOfBounds() public void NaturalRetirement_PrunesLogicalIdForBlockingReuse()
{ {
// Out-of-bounds PartIndex must NOT crash and must NOT apply a const uint emitterId = 0x32000020u;
// wrong matrix; falls back to no part transform (Identity), so const uint logicalId = 0xABCDu;
// the offset is applied in entity-local space as pre-C.1.5b. var (system, sink, _) = Harness(emitterId, totalParticles: 1);
var registry = new EmitterDescRegistry(); var blocking = new RetailCreateBlockingParticleHook
registry.Register(MakeDesc(0x32000031u, attachLocal: false));
var sys = new ParticleSystem(registry, new System.Random(42));
var sink = new ParticleHookSink(sys);
var partTransforms = new Matrix4x4[]
{ {
Matrix4x4.Identity, EmitterInfoId = emitterId,
Matrix4x4.CreateTranslation(0f, 0f, 1f), EmitterId = logicalId,
PartIndex = 0,
Offset = new Frame(),
}; };
sink.SetEntityRotation(0xCAFEu, Quaternion.Identity); sink.OnHook(Owner, Vector3.Zero, blocking);
sink.SetEntityPartTransforms(0xCAFEu, partTransforms); 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<string>();
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<uint, (Matrix4x4 Root, Matrix4x4[] Parts)> _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, if (_poses.TryGetValue(localEntityId, out var pose))
EmitterId = 0,
PartIndex = 99, // way past the 2-part array
Offset = new Frame
{ {
Origin = new Vector3(2f, 0f, 0f), rootWorld = pose.Root;
Orientation = Quaternion.Identity, return true;
}, }
}); rootWorld = default;
sys.Tick(0.001f); return false;
}
var live = System.Linq.Enumerable.Single(sys.EnumerateLive()); public bool TryGetPartPose(uint localEntityId, int partIndex, out Matrix4x4 partLocal)
var pos = live.Emitter.Particles[live.Index].Position; {
Assert.InRange(pos.X, 1.99f, 2.01f); if (_poses.TryGetValue(localEntityId, out var pose)
Assert.InRange(pos.Y, -0.01f, 0.01f); && partIndex >= 0
Assert.InRange(pos.Z, -0.01f, 0.01f); && partIndex < pose.Parts.Length)
{
partLocal = pose.Parts[partIndex];
return true;
}
partLocal = default;
return false;
}
} }
} }

View file

@ -239,12 +239,11 @@ public sealed class ParticleSystemTests
} }
[Fact] [Fact]
public void EmitterDescRegistry_FallsBackToDefault_ForUnknownId() public void EmitterDescRegistry_RejectsUnknownIdWithoutInventingFallback()
{ {
var reg = new EmitterDescRegistry(); var reg = new EmitterDescRegistry();
var desc = reg.Get(0xDEADBEEFu); // not registered Assert.False(reg.TryGet(0xDEADBEEFu, out _));
Assert.NotNull(desc); Assert.Throws<KeyNotFoundException>(() => reg.Get(0xDEADBEEFu));
Assert.Equal(0xFFFFFFFFu, desc.DatId); // matches default sentinel
} }
[Fact] [Fact]