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

@ -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<uint, (Matrix4x4 Root, uint Cell)> _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;
}
}

View file

@ -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<Matrix4x4>(),
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 =

View file

@ -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);
}
}

View file

@ -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<int> 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<string>();
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<uint, (Matrix4x4 Root, Matrix4x4[] Parts)> _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;
}
}
}

View file

@ -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<KeyNotFoundException>(() => reg.Get(0xDEADBEEFu));
}
[Fact]