acdream/src/AcDream.Core/Vfx/ParticleHookSink.cs
Erik 8d63e5c28a test(vfx): harden live missile and effect lifetimes
Add deterministic 96-owner lifecycle and renderer-resource stress gates plus an exact twelve-cycle recall/portal ownership gate. Prove zero retained records, projectiles, spatial buckets, script queues, particle/light owners, shadows, pending effects, and mesh references after churn, GUID reuse, deletion, and session reset.

Make logical teardown incarnation-specific and reentrancy-safe with lifetime epochs, atomic resource registration, generation-aware effect/teleport cleanup, projection mutation tokens, and failure-isolated visibility fan-out. Finish canonical spatial transactions before reporting observer failures and never discard superseded cleanup failures.

Synchronize architecture, roadmap, milestones, retail research, divergence bookkeeping, and durable memory. All three independent review tracks are clean; Release build and the full 5,454-pass/5-skip suite are green.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-14 15:47:00 +02:00

329 lines
12 KiB
C#

using System.Collections.Concurrent;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.Types;
namespace AcDream.Core.Vfx;
/// <summary>
/// Routes particle animation hooks through the owner's current root/part pose
/// and retains the retail logical-emitter identity rules.
/// </summary>
/// <remarks>
/// <para>
/// Retail oracle: <c>CreateParticleHook::Execute</c> (<c>0x00526EC0</c>),
/// <c>CreateBlockingParticleHook::Execute</c> (<c>0x00526EF0</c>),
/// <c>ParticleManager::CreateParticleEmitter</c> (<c>0x0051B6C0</c>), and
/// <c>CreateBlockingParticleEmitter</c> (<c>0x0051B8A0</c>).
/// </para>
/// <para>
/// The complete hook offset frame is retained on each binding for schema
/// fidelity. Retail <c>Particle::Init</c> (<c>0x0051C930</c>) transforms the
/// offset origin through the current owner frame, but does not apply the hook
/// offset quaternion to particle vectors; emitter orientation is the current
/// root/part orientation.
/// </para>
/// </remarks>
public sealed class ParticleHookSink : IAnimationHookSink
{
private readonly ParticleSystem _system;
private readonly IEntityEffectPoseSource _poses;
private readonly ConcurrentDictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new();
private readonly ConcurrentDictionary<uint, ConcurrentDictionary<int, byte>> _handlesByEntity = new();
private readonly ConcurrentDictionary<int, EmitterBinding> _bindingsByHandle = new();
private readonly ConcurrentDictionary<uint, ParticleRenderPass> _renderPassByEntity = new();
private readonly ConcurrentDictionary<uint, byte> _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<string>? DiagnosticSink { get; set; }
/// <summary>
/// Diagnostic ownership counts used by lifecycle stress gates. These count
/// the sink's retained bookkeeping, not only emitters still present in the
/// particle simulator, so teardown tests can detect a stale logical ID or
/// per-owner handle bag after the underlying emitter has gone away.
/// </summary>
public int ActiveBindingCount => _bindingsByHandle.Count;
public int LogicalEmitterCount => _handlesByKey.Count;
public int TrackedOwnerCount => _handlesByEntity.Count;
public int RenderPassOwnerCount => _renderPassByEntity.Count;
public int HiddenPresentationOwnerCount => _hiddenPresentationOwners.Count;
private void OnEmitterDied(int handle)
{
if (!_bindingsByHandle.TryRemove(handle, out EmitterBinding binding))
return;
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 blocking:
SpawnFromHook(
entityId,
(uint)blocking.EmitterInfoId,
blocking.Offset,
unchecked((int)blocking.PartIndex),
blocking.EmitterId,
isBlocking: true);
break;
case CreateParticleHook create:
SpawnFromHook(
entityId,
(uint)create.EmitterInfoId,
create.Offset,
unchecked((int)create.PartIndex),
create.EmitterId,
isBlocking: false);
break;
case CreateBlockingParticleHook:
// 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 destroy:
DestroyLogical(entityId, destroy.EmitterId, fadeOut: false);
break;
case StopParticleHook stop:
DestroyLogical(entityId, stop.EmitterId, fadeOut: true);
break;
}
}
public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass) =>
_renderPassByEntity[entityId] = renderPass;
public void ClearEntityRenderPass(uint entityId) =>
_renderPassByEntity.TryRemove(entityId, out _);
/// <summary>
/// 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.
/// </summary>
public void SetEntityPresentationVisible(uint entityId, bool visible)
{
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);
}
}
}
/// <summary>
/// Refreshes every emitter from the current root/part pose. World-released
/// particles keep their stored emission origin; AttachLocal particles read
/// the refreshed anchor when the particle system advances.
/// </summary>
public void RefreshAttachedEmitters()
{
foreach ((int handle, EmitterBinding binding) in _bindingsByHandle)
{
bool presentationVisible =
!_hiddenPresentationOwners.ContainsKey(binding.OwnerLocalId);
if (TryResolveAnchor(binding.OwnerLocalId, binding.PartIndex,
binding.HookOffsetOrigin, out Vector3 anchor, out Quaternion rotation))
{
_system.UpdateEmitterAnchor(handle, anchor, rotation);
// Keep the anchor current while spatially paused; re-entry
// resumes at the authoritative pose without generating the
// absent interval's time- or distance-driven emissions.
_system.SetEmitterPresentationVisible(handle, presentationVisible);
_system.SetEmitterSimulationEnabled(handle, presentationVisible);
}
else
_system.SetEmitterPresentationVisible(handle, false);
}
}
public void StopAllForEntity(uint entityId, bool fadeOut)
{
if (_handlesByEntity.TryRemove(entityId, out var handles))
{
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);
_bindingsByHandle.TryRemove(handle, out _);
}
}
foreach (var key in _handlesByKey.Keys)
{
if (key.EntityId == entityId)
_handlesByKey.TryRemove(key, out _);
}
ClearEntityRenderPass(entityId);
_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 ownerLocalId,
uint emitterInfoId,
Frame offset,
int partIndex,
uint logicalId,
bool isBlocking)
{
if (logicalId != 0
&& _handlesByKey.TryGetValue((ownerLocalId, logicalId), out int existing))
{
if (isBlocking && _system.IsEmitterAlive(existing))
return;
_handlesByKey.TryRemove((ownerLocalId, logicalId), out _);
_system.StopEmitter(existing, fadeOut: false);
}
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;
if (!_system.TrySpawnEmitterById(
emitterInfoId,
anchor,
rotation,
ownerLocalId,
partIndex,
renderPass,
out int handle))
{
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);
}
var binding = new EmitterBinding(
ownerLocalId,
partIndex,
offsetOrigin,
offsetOrientation,
logicalId,
renderPass);
_bindingsByHandle[handle] = binding;
_handlesByEntity
.GetOrAdd(ownerLocalId, _ => new ConcurrentDictionary<int, byte>())
.TryAdd(handle, 0);
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);
}