feat(vfx): bind effects to live animated poses

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

View file

@ -13,7 +13,7 @@ namespace AcDream.Core.Lighting;
/// <para>
/// Retail <see cref="LightInfo"/> fields (r13 §1):
/// <list type="bullet">
/// <item><description><c>ViewSpaceLocation</c>: local Frame relative to the owning part.</description></item>
/// <item><description><c>ViewSpaceLocation</c>: local Frame relative to the owning PartArray root.</description></item>
/// <item><description><c>Color</c>: packed ARGB. Alpha is ignored; channels go through <c>/255</c>.</description></item>
/// <item><description><c>Intensity</c>: multiplies color for final diffuse.</description></item>
/// <item><description><c>Falloff</c>: world metres — acts as the <see cref="LightSource.Range"/> hard cutoff.</description></item>
@ -24,13 +24,10 @@ namespace AcDream.Core.Lighting;
public static class LightInfoLoader
{
/// <summary>
/// Extract all lights from a Setup, positioned in the entity's
/// world frame (via <paramref name="entityPosition"/> +
/// <paramref name="entityRotation"/>). 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 <c>CPhysicsObj::UpdateChild</c> composes it.
/// </summary>
public static IReadOnlyList<LightSource> 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<LightSource>();
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);
}

View file

@ -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)
/// <summary>
/// True when this light belongs to a live physics object whose root can
/// move. DAT-static lights keep their hydration-time world frame and are
/// deliberately excluded from the per-frame pose refresh.
/// </summary>
public bool TracksOwnerPose;
/// <summary>
/// Setup-local light frame retained from <c>LIGHTINFO::view_space_location</c>.
/// Object-borne lights compose it with their owner's current root each
/// frame, matching <c>CPartArray::SetFrame</c> (<c>0x00519310</c>) calling
/// <c>LIGHTLIST::set_frame</c> (<c>0x00517C60</c>).
/// </summary>
public Matrix4x4 LocalPose = Matrix4x4.Identity;
// Cached each frame by LightManager.
public float DistSq;

View file

@ -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<uint, List<LightSource>> _byOwner = new();
private readonly Dictionary<uint, List<LightSource>> _trackedByOwner = new();
private readonly Dictionary<uint, bool> _enabledByOwner = new();
public LightingHookSink(LightManager lights)
/// <summary>
/// Raised after retail's <c>set_lights</c> state changes. App-layer live
/// owners use this to create/destroy their Setup light presentation;
/// DAT-static owners are already registered and simply consume IsLit.
/// </summary>
public event Action<uint, bool>? OwnerLightingChanged;
public LightingHookSink(LightManager lights, IEntityEffectPoseSource poses)
{
_lights = lights ?? throw new System.ArgumentNullException(nameof(lights));
_poses = poses ?? throw new System.ArgumentNullException(nameof(poses));
_cells = poses as IEntityEffectCellSource;
}
/// <summary>
@ -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<LightSource>? tracked))
{
tracked = new List<LightSource>();
_trackedByOwner[light.OwnerId] = tracked;
}
tracked.Add(light);
}
}
/// <summary>Drop every light tagged to this owner (despawn / unload).</summary>
public void UnregisterOwner(uint ownerId)
public void UnregisterOwner(uint ownerId, bool forgetState = true)
{
if (!_byOwner.TryGetValue(ownerId, out var list)) return;
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;
/// <summary>
/// Mirrors <c>CPhysicsObj::set_lights</c> (0x0050FCF0): update the owner's
/// Lighting state and its currently materialized lights as one operation.
/// </summary>
public void SetOwnerLighting(uint ownerId, bool enabled)
{
if (_enabledByOwner.TryGetValue(ownerId, out bool current)
&& current == enabled)
{
return;
}
_enabledByOwner[ownerId] = enabled;
if (_byOwner.TryGetValue(ownerId, out var list))
{
foreach (LightSource light in list)
light.IsLit = enabled;
}
OwnerLightingChanged?.Invoke(ownerId, enabled);
}
/// <summary>
@ -67,12 +123,36 @@ public sealed class LightingHookSink : IAnimationHookSink
return _byOwner.TryGetValue(ownerId, out var list) ? list : null;
}
/// <summary>
/// Recompose every object-borne light from the current physics-object root.
/// Setup <c>LIGHTINFO</c> has no part index; equipped-object animation is
/// represented by publishing that child's composed root.
/// </summary>
public void RefreshAttachedLights()
{
foreach ((uint ownerId, List<LightSource> lights) in _trackedByOwner)
{
if (!_poses.TryGetRootPose(ownerId, out Matrix4x4 rootWorld))
continue;
uint cellId = 0;
_cells?.TryGetCellId(ownerId, out cellId);
for (int i = 0; i < lights.Count; i++)
{
LightSource light = lights[i];
Matrix4x4 lightWorld = light.LocalPose * rootWorld;
light.WorldPosition = lightWorld.Translation;
Vector3 forward = Vector3.TransformNormal(Vector3.UnitY, lightWorld);
if (forward.LengthSquared() > 1e-8f)
light.WorldForward = Vector3.Normalize(forward);
light.CellId = cellId;
}
}
}
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
{
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);
}
}

View file

@ -6,6 +6,12 @@ using DatReaderWriter.Types;
namespace AcDream.Core.Meshing;
/// <summary>Final child root and indexed part poses in the parent-root frame.</summary>
public readonly record struct EquippedChildPose(
Matrix4x4 RootLocal,
Matrix4x4[] PartLocal,
MeshRef[] AttachedParts);
/// <summary>
/// Retail held-object transform composition. A weapon is a separate child
/// physics object: the parent's <see cref="Setup.HoldingLocations"/> selects
@ -28,6 +34,90 @@ public static class EquippedChildAttachment
IReadOnlyList<MeshRef> childPartTemplate,
float childScale,
out IReadOnlyList<MeshRef> attachedParts)
{
bool composed = TryComposePose(
parentSetup,
currentParentPose,
childSetup,
parentLocation,
placement,
childPartTemplate,
childScale,
out EquippedChildPose pose);
attachedParts = composed ? pose.AttachedParts : Array.Empty<MeshRef>();
return composed;
}
public static bool TryComposePose(
Setup parentSetup,
IReadOnlyList<MeshRef> currentParentPose,
Setup childSetup,
ParentLocation parentLocation,
Placement placement,
IReadOnlyList<MeshRef> childPartTemplate,
float childScale,
out EquippedChildPose pose)
{
ArgumentNullException.ThrowIfNull(currentParentPose);
var parentParts = new Matrix4x4[currentParentPose.Count];
for (int i = 0; i < parentParts.Length; i++)
parentParts[i] = currentParentPose[i].PartTransform;
return TryComposePose(
parentSetup,
parentParts,
childSetup,
parentLocation,
placement,
childPartTemplate,
childScale,
out pose);
}
/// <summary>
/// Compose from the parent's indexed final animation poses. The renderer
/// exposes these independently of drawable MeshRefs so a hidden or missing
/// visual part cannot shift the retail holding-location index.
/// </summary>
public static bool TryComposePose(
Setup parentSetup,
IReadOnlyList<Matrix4x4> currentParentPose,
Setup childSetup,
ParentLocation parentLocation,
Placement placement,
IReadOnlyList<MeshRef> childPartTemplate,
float childScale,
out EquippedChildPose pose)
{
return TryComposePoseInto(
parentSetup,
currentParentPose,
parentPartAvailability: null,
childSetup,
parentLocation,
placement,
childPartTemplate,
childScale,
partPoseBuffer: null,
attachedPartBuffer: null,
out pose);
}
/// <summary>
/// Allocation-free update path for an already realized child. Buffers are
/// reused by the App-layer attachment owner after their first composition.
/// </summary>
public static bool TryComposePoseInto(
Setup parentSetup,
IReadOnlyList<Matrix4x4> currentParentPose,
IReadOnlyList<bool>? parentPartAvailability,
Setup childSetup,
ParentLocation parentLocation,
Placement placement,
IReadOnlyList<MeshRef> childPartTemplate,
float childScale,
Matrix4x4[]? partPoseBuffer,
MeshRef[]? attachedPartBuffer,
out EquippedChildPose pose)
{
ArgumentNullException.ThrowIfNull(parentSetup);
ArgumentNullException.ThrowIfNull(currentParentPose);
@ -36,14 +126,32 @@ public static class EquippedChildAttachment
if (!parentSetup.HoldingLocations.TryGetValue(parentLocation, out LocationType? holding))
{
attachedParts = Array.Empty<MeshRef>();
pose = new EquippedChildPose(
Matrix4x4.Identity,
Array.Empty<Matrix4x4>(),
Array.Empty<MeshRef>());
return false;
}
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;
}

View file

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

View file

@ -14,8 +14,6 @@ namespace AcDream.Core.Vfx;
/// </summary>
public sealed class EmitterDescRegistry
{
private const uint FallbackEmitterId = 0xFFFFFFFFu;
private readonly Func<uint, DatParticleEmitter?>? _resolver;
private readonly ConcurrentDictionary<uint, EmitterDesc> _byId = new();
@ -32,7 +30,6 @@ public sealed class EmitterDescRegistry
public EmitterDescRegistry(Func<uint, DatParticleEmitter?>? 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,

View file

@ -0,0 +1,24 @@
using System.Numerics;
namespace AcDream.Core.Vfx;
/// <summary>
/// Read-only view of the final pose used by entity-attached effects.
/// Implementations publish poses on the update/render thread after root motion,
/// animation, and equipped-child composition have completed.
/// </summary>
public interface IEntityEffectPoseSource
{
bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld);
bool TryGetPartPose(uint localEntityId, int partIndex, out Matrix4x4 partLocal);
}
/// <summary>
/// Optional spatial companion for consumers, such as object lights, whose
/// render registration is scoped to the owner's current AC cell.
/// </summary>
public interface IEntityEffectCellSource
{
bool TryGetCellId(uint localEntityId, out uint cellId);
}

View file

@ -1,191 +1,177 @@
using System;
using System.Collections.Concurrent;
using System.Numerics;
using System.Threading;
using AcDream.Core.Physics;
using DatReaderWriter.Types;
namespace AcDream.Core.Vfx;
/// <summary>
/// <see cref="IAnimationHookSink"/> that translates particle-bearing
/// animation hooks into <see cref="ParticleSystem"/> spawn / stop calls.
///
/// <para>
/// Hook types handled (r04 §6):
/// <list type="bullet">
/// <item><description>
/// <see cref="CreateParticleHook"/> — spawn an emitter from the
/// hook's <c>EmitterInfoId</c> at the entity's world pose plus the
/// hook offset. Retail attaches to a specific mesh part; we
/// attach to the entity's root and will refine per-part when the
/// renderer exposes per-part world transforms.
/// </description></item>
/// <item><description>
/// <see cref="CreateBlockingParticleHook"/> — has the same payload
/// as CreateParticleHook, but suppresses creation while the same nonzero
/// logical emitter ID is live. It does not pause animation. Production DAT
/// loading substitutes <see cref="RetailCreateBlockingParticleHook"/> so
/// the inherited payload is retained despite the dependency's header-only
/// model. Suppression itself lands with live emitter bindings in Step 5.
/// </description></item>
/// <item><description>
/// <see cref="DestroyParticleHook"/> — stop the most-recent emitter
/// matching the hook's <c>EmitterId</c>. Deferred to a future pass
/// when we retain per-entity emitter-id → handle maps.
/// </description></item>
/// <item><description>
/// <see cref="StopParticleHook"/> — pause all emitters on the
/// entity (fade out).
/// </description></item>
/// <item><description>
/// <see cref="DefaultScriptHook"/> / <see cref="DefaultScriptPartHook"/>
/// — trigger the entity's <c>DefaultScriptId</c> PhysicsScript.
/// Requires PhysicsScript table; deferred.
/// </description></item>
/// <item><description>
/// <see cref="CallPESHook"/> — fire a PhysicsScript by id.
/// Deferred until DRW exposes PhysicsScript dat.
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// Per-entity emitter handle tracking is kept here so DestroyParticle /
/// StopParticle can target the right emitter when a server-sent
/// <c>PlayEffect</c> fires.
/// </para>
/// 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;
// 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<uint, ConcurrentDictionary<int, byte>> _handlesByEntity = new();
// Reverse lookup: handle → (entity, key) for O(1) cleanup on EmitterDied.
private readonly ConcurrentDictionary<int, (uint EntityId, uint KeyId)> _trackingByHandle = new();
private readonly ConcurrentDictionary<int, EmitterBinding> _bindingsByHandle = new();
private readonly ConcurrentDictionary<uint, ParticleRenderPass> _renderPassByEntity = new();
private readonly ConcurrentDictionary<uint, Quaternion> _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<uint, IReadOnlyList<Matrix4x4>> _partTransformsByEntity = new();
private int _anonymousEmitterSerial;
public ParticleHookSink(ParticleSystem system)
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; }
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 _);
/// <summary>
/// Register per-part static transforms for an entity. The caller
/// (typically <c>EntityScriptActivator</c>) precomputes one
/// <see cref="Matrix4x4"/> per Setup part using
/// <c>SetupPartTransforms.Compute</c> and pushes them here at spawn
/// time. <see cref="SpawnFromHook"/> applies
/// <c>partTransforms[hook.PartIndex]</c> to the hook offset BEFORE
/// transforming to world space. Cleared on
/// <see cref="StopAllForEntity"/>.
/// 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 SetEntityPartTransforms(uint entityId, IReadOnlyList<Matrix4x4> partTransforms)
=> _partTransformsByEntity[entityId] = partTransforms;
public void ClearEntityRenderPass(uint entityId)
=> _renderPassByEntity.TryRemove(entityId, out _);
/// <summary>
/// Refresh every live emitter on this entity to a new world anchor +
/// rotation. The owning subsystem (sky-PES driver, animation tick)
/// drives this each frame for AttachLocal emitters so they track their
/// moving parent — retail-faithful via
/// <c>ParticleEmitter::UpdateParticles</c> at <c>0x0051d2d4</c>, which
/// re-reads the parent frame each tick when <c>is_parent_local != 0</c>.
/// Safe to call for entities with no live emitters (no-op).
/// </summary>
public void UpdateEntityAnchor(uint entityId, Vector3 anchor, Quaternion rotation)
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);
}
}
}
/// <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 (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<int, byte>())
.GetOrAdd(ownerLocalId, _ => new ConcurrentDictionary<int, byte>())
.TryAdd(handle, 0);
_trackingByHandle[handle] = (entityId, keyId);
if (logicalId != 0)
_handlesByKey[(ownerLocalId, logicalId)] = handle;
}
private bool TryResolveAnchor(
uint ownerLocalId,
int partIndex,
Vector3 offsetOrigin,
out Vector3 anchor,
out Quaternion rotation)
{
if (!_poses.TryGetRootPose(ownerLocalId, out Matrix4x4 rootWorld))
{
anchor = default;
rotation = default;
return false;
}
Matrix4x4 ownerFrame = rootWorld;
if (partIndex != -1)
{
if (!_poses.TryGetPartPose(ownerLocalId, partIndex, out Matrix4x4 partLocal))
{
anchor = default;
rotation = default;
return false;
}
ownerFrame = partLocal * rootWorld;
}
anchor = Vector3.Transform(offsetOrigin, ownerFrame);
if (!Matrix4x4.Decompose(ownerFrame, out _, out rotation, out _))
{
anchor = default;
rotation = default;
return false;
}
rotation = Quaternion.Normalize(rotation);
return true;
}
private readonly record struct EmitterBinding(
uint OwnerLocalId,
int PartIndex,
Vector3 HookOffsetOrigin,
Quaternion HookOffsetOrientation,
uint LogicalId,
ParticleRenderPass RenderPass);
}

View file

@ -75,6 +75,31 @@ public sealed class ParticleSystem : IParticleSystem
return SpawnEmitter(desc, anchor, rot, attachedObjectId, attachedPartIndex, renderPass);
}
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;
}
/// <summary>
/// Changes only render presentation. Logical lifetime is unaffected.
/// </summary>
public void SetEmitterPresentationVisible(int handle, bool visible)
{
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
emitter.PresentationVisible = visible;
}
/// <summary>
/// Applies retail's in-cell update gate without ending emitter ownership.
/// Retail retains absolute creation timestamps while cell-less; the next
/// update observes the elapsed wall-clock interval and expires old state.
/// Only acdream's legacy rate accumulator is rebased to prevent a synthetic
/// multi-particle catch-up burst that retail's one-shot emission path lacks.
/// </summary>
public void SetEmitterSimulationEnabled(int handle, bool enabled)
{
if (!_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)
|| emitter.SimulationEnabled == enabled)
{
return;
}
if (!enabled)
{
emitter.SimulationEnabled = false;
return;
}
if (emitter.Desc.Birthrate <= 0f && emitter.Desc.EmitRate > 0f)
{
emitter.LastEmitTime = _time;
emitter.EmittedAccumulator = 0f;
}
emitter.SimulationEnabled = true;
}
/// <summary>True when the given handle still maps to a live emitter.</summary>
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);

View file

@ -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; }
/// <summary>
/// Spatial presentation latch. A cell-less owner submits no particles.
/// </summary>
public bool PresentationVisible { get; set; } = true;
/// <summary>
/// Retail cell-membership update gate. Paused emitters retain their exact
/// particles, logical identity, and elapsed-time position until re-entry.
/// </summary>
public bool SimulationEnabled { get; set; } = true;
public int AttachedPartIndex { get; set; } = -1;
public Particle[] Particles { get; init; } = null!;
public ParticleRenderPass RenderPass { get; init; }

View file

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