using System.Collections.Concurrent;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.Types;
namespace AcDream.Core.Vfx;
///
/// 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;
private readonly IEntityEffectPoseSource _poses;
private readonly ConcurrentDictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new();
private readonly ConcurrentDictionary> _handlesByEntity = new();
private readonly ConcurrentDictionary _bindingsByHandle = new();
private readonly ConcurrentDictionary _renderPassByEntity = new();
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; }
///
/// 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.
///
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 _);
///
/// 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 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);
}
}
}
///
/// 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 (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())
.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);
}