Resolve DAT-authored particle ranges from the hardware GfxObj, apply retail distance and completed-cell visibility gates, and preserve the exact finite/infinite off-view update semantics. This removes dense-world simulation work without shortening terrain, entity, fog, or streaming distance. Publish doorway-clipped outdoor cells through a focused frame controller, retain effect cell identity for outdoor statics, reject hidden emitters before particle-slot scans, and offer an explicit opt-in Extended particle range. Release build succeeds and all 5,857 tests pass with five intentional skips. Retail-conformance, architecture, and adversarial review cycles are clean; connected Aerlinthe visual/performance gate pending. Co-authored-by: OpenAI Codex <codex@openai.com>
401 lines
15 KiB
C#
401 lines
15 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 IEntityEffectCellSource? _cells;
|
|
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> _examinationOwners = 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));
|
|
_cells = poses as IEntityEffectCellSource;
|
|
_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 ExaminationOwnerCount => _examinationOwners.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;
|
|
RefreshOwnerVisibilityPolicy(entityId);
|
|
}
|
|
|
|
public void ClearEntityRenderPass(uint entityId)
|
|
{
|
|
_renderPassByEntity.TryRemove(entityId, out _);
|
|
RefreshOwnerVisibilityPolicy(entityId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mirrors retail <c>CPhysicsObj::m_bExaminationObject</c>. Examination
|
|
/// previews bypass world distance and cell visibility without conflating
|
|
/// that policy with attachment identity.
|
|
/// </summary>
|
|
public void SetEntityExaminationObject(uint entityId, bool isExaminationObject)
|
|
{
|
|
if (isExaminationObject)
|
|
_examinationOwners[entityId] = 0;
|
|
else
|
|
_examinationOwners.TryRemove(entityId, out _);
|
|
|
|
RefreshOwnerVisibilityPolicy(entityId);
|
|
}
|
|
|
|
/// <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,
|
|
out Vector3 ownerPosition))
|
|
{
|
|
_system.UpdateEmitterAnchor(handle, anchor, rotation);
|
|
_system.UpdateEmitterOwnerPosition(handle, ownerPosition);
|
|
_system.UpdateEmitterOwnerCell(
|
|
handle,
|
|
_cells is not null && _cells.TryGetCellId(binding.OwnerLocalId, out uint cellId)
|
|
? cellId
|
|
: 0u);
|
|
// 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);
|
|
_examinationOwners.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 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,
|
|
out Vector3 ownerPosition))
|
|
{
|
|
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;
|
|
ParticleVisibilityPolicy visibilityPolicy = ResolveVisibilityPolicy(ownerLocalId);
|
|
if (!_system.TrySpawnEmitterById(
|
|
emitterInfoId,
|
|
anchor,
|
|
rotation,
|
|
ownerLocalId,
|
|
partIndex,
|
|
renderPass,
|
|
visibilityPolicy,
|
|
out int handle))
|
|
{
|
|
DiagnosticSink?.Invoke(
|
|
$"ParticleEmitterInfo 0x{emitterInfoId:X8} for owner " +
|
|
$"0x{ownerLocalId:X8} was not found; no fallback was created.");
|
|
return;
|
|
}
|
|
_system.UpdateEmitterOwnerPosition(handle, ownerPosition);
|
|
_system.UpdateEmitterOwnerCell(
|
|
handle,
|
|
_cells is not null && _cells.TryGetCellId(ownerLocalId, out uint cellId)
|
|
? cellId
|
|
: 0u);
|
|
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 ParticleVisibilityPolicy ResolveVisibilityPolicy(uint ownerLocalId)
|
|
{
|
|
if (_examinationOwners.ContainsKey(ownerLocalId))
|
|
return ParticleVisibilityPolicy.Examination;
|
|
|
|
return _renderPassByEntity.TryGetValue(ownerLocalId, out ParticleRenderPass pass)
|
|
&& pass != ParticleRenderPass.Scene
|
|
? ParticleVisibilityPolicy.PassOwned
|
|
: ParticleVisibilityPolicy.World;
|
|
}
|
|
|
|
private void RefreshOwnerVisibilityPolicy(uint ownerLocalId)
|
|
{
|
|
if (!_handlesByEntity.TryGetValue(ownerLocalId, out var handles))
|
|
return;
|
|
|
|
ParticleVisibilityPolicy policy = ResolveVisibilityPolicy(ownerLocalId);
|
|
foreach (int handle in handles.Keys)
|
|
_system.SetEmitterVisibilityPolicy(handle, policy);
|
|
}
|
|
|
|
private bool TryResolveAnchor(
|
|
uint ownerLocalId,
|
|
int partIndex,
|
|
Vector3 offsetOrigin,
|
|
out Vector3 anchor,
|
|
out Quaternion rotation,
|
|
out Vector3 ownerPosition)
|
|
{
|
|
if (!_poses.TryGetRootPose(ownerLocalId, out Matrix4x4 rootWorld))
|
|
{
|
|
anchor = default;
|
|
rotation = default;
|
|
ownerPosition = default;
|
|
return false;
|
|
}
|
|
|
|
ownerPosition = new Vector3(rootWorld.M41, rootWorld.M42, rootWorld.M43);
|
|
|
|
Matrix4x4 ownerFrame = rootWorld;
|
|
if (partIndex != -1)
|
|
{
|
|
if (!_poses.TryGetPartPose(ownerLocalId, partIndex, out Matrix4x4 partLocal))
|
|
{
|
|
anchor = default;
|
|
rotation = default;
|
|
ownerPosition = default;
|
|
return false;
|
|
}
|
|
ownerFrame = partLocal * rootWorld;
|
|
}
|
|
|
|
anchor = Vector3.Transform(offsetOrigin, ownerFrame);
|
|
if (!Matrix4x4.Decompose(ownerFrame, out _, out rotation, out _))
|
|
{
|
|
anchor = default;
|
|
rotation = default;
|
|
ownerPosition = 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);
|
|
}
|