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