feat(vfx): bind effects to live animated poses
This commit is contained in:
parent
96ddfdf175
commit
542dcfc384
41 changed files with 3246 additions and 741 deletions
177
tests/AcDream.App.Tests/Rendering/AttachmentUpdateOrderTests.cs
Normal file
177
tests/AcDream.App.Tests/Rendering/AttachmentUpdateOrderTests.cs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class AttachmentUpdateOrderTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReinsertedParentStillUpdatesBeforeGrandchild()
|
||||
{
|
||||
const uint parent = 10u;
|
||||
const uint grandchild = 20u;
|
||||
// Dictionary order models B being removed/re-added while C remained.
|
||||
uint[] insertionOrder = [grandchild, parent];
|
||||
var parents = new Dictionary<uint, uint?>
|
||||
{
|
||||
[grandchild] = parent,
|
||||
[parent] = null,
|
||||
};
|
||||
var calls = new List<uint>();
|
||||
|
||||
var children = insertionOrder.ToDictionary(id => id, id => parents[id]);
|
||||
new AttachmentUpdateOrder<uint?>().ForEachParentFirst(
|
||||
children,
|
||||
static parentId => parentId,
|
||||
id =>
|
||||
{
|
||||
calls.Add(id);
|
||||
return true;
|
||||
});
|
||||
|
||||
Assert.Equal([parent, grandchild], calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedParentSuppressesAndReportsDescendantsAfterTraversal()
|
||||
{
|
||||
const uint parent = 10u;
|
||||
const uint grandchild = 20u;
|
||||
var children = new Dictionary<uint, uint?>
|
||||
{
|
||||
[grandchild] = parent,
|
||||
[parent] = null,
|
||||
};
|
||||
var calls = new List<uint>();
|
||||
|
||||
IReadOnlyList<uint> failed = new AttachmentUpdateOrder<uint?>().ForEachParentFirst(
|
||||
children,
|
||||
static parentId => parentId,
|
||||
id =>
|
||||
{
|
||||
calls.Add(id);
|
||||
return id != parent;
|
||||
});
|
||||
|
||||
Assert.Equal([parent], calls);
|
||||
Assert.Equal([parent, grandchild], failed);
|
||||
Assert.Equal(2, children.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedDrawableUsesImmediateParentsComposedWorldRoot()
|
||||
{
|
||||
Quaternion turn = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f);
|
||||
Matrix4x4 parentWorld = Matrix4x4.CreateFromQuaternion(turn)
|
||||
* Matrix4x4.CreateTranslation(100f, 0f, 0f);
|
||||
var child = new AcDream.Core.World.WorldEntity
|
||||
{
|
||||
Id = 3u,
|
||||
SourceGfxObjOrSetupId = 0x02000003u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs =
|
||||
[
|
||||
new AcDream.Core.World.MeshRef(
|
||||
0x01000003u,
|
||||
Matrix4x4.CreateTranslation(2f, 0f, 0f)),
|
||||
],
|
||||
};
|
||||
|
||||
Assert.True(EquippedChildRenderController.ApplyParentWorldPose(child, parentWorld));
|
||||
Matrix4x4 installedRoot = Matrix4x4.CreateFromQuaternion(child.Rotation)
|
||||
* Matrix4x4.CreateTranslation(child.Position);
|
||||
Vector3 renderedOrigin = Vector3.Transform(
|
||||
Vector3.Zero,
|
||||
child.MeshRefs[0].PartTransform * installedRoot);
|
||||
|
||||
Assert.Equal(new Vector3(100f, 2f, 0f), renderedOrigin, new Vector3ToleranceComparer());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AncestorTeardownCollectsEntireAttachmentSubtreePostOrder()
|
||||
{
|
||||
var children = new Dictionary<uint, uint?>
|
||||
{
|
||||
[20u] = 10u,
|
||||
[30u] = 20u,
|
||||
};
|
||||
var order = new AttachmentUpdateOrder<uint?>().CollectSubtreePostOrder(
|
||||
children,
|
||||
10u,
|
||||
static parentId => parentId);
|
||||
|
||||
Assert.Equal([30u, 20u, 10u], order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PoseRecoveryRealizesCompleteDescendantChainParentFirst()
|
||||
{
|
||||
var waitingByParent = new Dictionary<uint, IReadOnlyList<uint>>
|
||||
{
|
||||
[10u] = [20u],
|
||||
[20u] = [30u],
|
||||
};
|
||||
var realized = new List<uint>();
|
||||
|
||||
new AttachmentUpdateOrder<uint?>().RealizeDescendants(
|
||||
10u,
|
||||
parent => waitingByParent.TryGetValue(parent, out var waiting)
|
||||
? waiting
|
||||
: Array.Empty<uint>(),
|
||||
child =>
|
||||
{
|
||||
realized.Add(child);
|
||||
return true;
|
||||
});
|
||||
|
||||
Assert.Equal([20u, 30u], realized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PoseRecoveryDoesNotTraverseThroughUnrealizedIntermediateParent()
|
||||
{
|
||||
var waitingByParent = new Dictionary<uint, IReadOnlyList<uint>>
|
||||
{
|
||||
[10u] = [20u],
|
||||
[20u] = [30u],
|
||||
};
|
||||
var attempted = new List<uint>();
|
||||
|
||||
new AttachmentUpdateOrder<uint?>().RealizeDescendants(
|
||||
10u,
|
||||
parent => waitingByParent.TryGetValue(parent, out var waiting)
|
||||
? waiting
|
||||
: Array.Empty<uint>(),
|
||||
child =>
|
||||
{
|
||||
attempted.Add(child);
|
||||
return false;
|
||||
});
|
||||
|
||||
Assert.Equal([20u], attempted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawableRootRejectsVisualScaleInRigidPoseChannel()
|
||||
{
|
||||
var child = new AcDream.Core.World.WorldEntity
|
||||
{
|
||||
Id = 4u,
|
||||
SourceGfxObjOrSetupId = 0x02000004u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<AcDream.Core.World.MeshRef>(),
|
||||
};
|
||||
|
||||
Assert.False(EquippedChildRenderController.ApplyParentWorldPose(
|
||||
child,
|
||||
Matrix4x4.CreateScale(2f) * Matrix4x4.CreateTranslation(10, 0, 0)));
|
||||
}
|
||||
|
||||
private sealed class Vector3ToleranceComparer : IEqualityComparer<Vector3>
|
||||
{
|
||||
public bool Equals(Vector3 x, Vector3 y) => Vector3.DistanceSquared(x, y) < 1e-8f;
|
||||
public int GetHashCode(Vector3 obj) => obj.GetHashCode();
|
||||
}
|
||||
}
|
||||
|
|
@ -24,7 +24,8 @@ public sealed class LiveAppearanceAnimationTests
|
|||
HighFrame = 9,
|
||||
Framerate = 30f,
|
||||
Scale = 1f,
|
||||
PartTemplate = [(0x01000001u, null)],
|
||||
PartTemplate = [new GameWindow.AnimatedPartTemplate(0x01000001u, null, true)],
|
||||
PartAvailability = [true],
|
||||
CurrFrame = 6.5f,
|
||||
Sequencer = sequencer,
|
||||
};
|
||||
|
|
@ -36,7 +37,15 @@ public sealed class LiveAppearanceAnimationTests
|
|||
|
||||
oldEntity.ApplyAppearance(dressedParts, paletteOverride: null, partOverrides: []);
|
||||
GameWindow.RebindAnimatedEntityForAppearance(
|
||||
state, oldEntity, setup, 1.25f, dressedParts);
|
||||
state,
|
||||
oldEntity,
|
||||
setup,
|
||||
1.25f,
|
||||
[
|
||||
new GameWindow.AnimatedPartTemplate(0x01000002u, null, true),
|
||||
new GameWindow.AnimatedPartTemplate(0x01000003u, null, true),
|
||||
],
|
||||
[true, true]);
|
||||
|
||||
Assert.Same(oldEntity, state.Entity);
|
||||
Assert.Same(dressedParts, state.Entity.MeshRefs);
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ public sealed class EntityEffectControllerTests
|
|||
new PhysicsScriptTableResolver(
|
||||
tableLoader
|
||||
?? (id => _tables.TryGetValue(id, out PhysicsScriptTable? table) ? table : null)),
|
||||
Poses,
|
||||
childAtPart,
|
||||
parentOfAttachedChild,
|
||||
ownerUnregistered,
|
||||
|
|
@ -88,6 +89,7 @@ public sealed class EntityEffectControllerTests
|
|||
public AnimationHookRouter Router { get; } = new();
|
||||
public PhysicsScriptRunner Runner { get; }
|
||||
public EntityEffectController Controller { get; }
|
||||
public EntityEffectPoseRegistry Poses { get; } = new();
|
||||
|
||||
public void AddScript(uint did, uint emitterId, double startTime = 0.0)
|
||||
{
|
||||
|
|
@ -246,7 +248,7 @@ public sealed class EntityEffectControllerTests
|
|||
0x0202FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
fixture.Controller.RefreshLiveOwnerAnchors();
|
||||
fixture.Controller.RefreshLiveOwnerPoses();
|
||||
fixture.Runner.Tick(0.0);
|
||||
|
||||
Assert.Equal(0, fixture.Controller.PendingPacketCount);
|
||||
|
|
@ -549,6 +551,34 @@ public sealed class EntityEffectControllerTests
|
|||
Assert.False(fixture.Controller.PlayDefault(childLocalId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefreshLiveOwnerPoses_PreservesComposedAttachedRootAndUsesItAsScriptAnchor()
|
||||
{
|
||||
const uint childGuid = 0x70000002u;
|
||||
var fixture = new Fixture();
|
||||
WorldEntity child = fixture.ReadyLive(childGuid);
|
||||
WorldSession.EntitySpawn spawn = Spawn(childGuid, generation: 1);
|
||||
fixture.Runtime.MaterializeLiveEntity(
|
||||
childGuid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
_ => throw new InvalidOperationException("existing projection must be reused"),
|
||||
LiveEntityProjectionKind.Attached);
|
||||
Matrix4x4 attachedRoot = Matrix4x4.CreateTranslation(7, 8, 9);
|
||||
fixture.Poses.Publish(
|
||||
child.Id,
|
||||
attachedRoot,
|
||||
Array.Empty<Matrix4x4>(),
|
||||
spawn.Position.Value.LandblockId);
|
||||
|
||||
fixture.Controller.RefreshLiveOwnerPoses();
|
||||
fixture.Controller.HandleDirect(new PlayPhysicsScript(childGuid, DirectDid));
|
||||
fixture.Runner.Tick(0.0);
|
||||
|
||||
Assert.True(fixture.Poses.TryGetRootPose(child.Id, out Matrix4x4 retained));
|
||||
Assert.Equal(new Vector3(7, 8, 9), retained.Translation);
|
||||
Assert.Equal(new Vector3(7, 8, 9), Assert.Single(fixture.Sink.Calls).Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingDirectScriptReportsOwnerAndDid()
|
||||
{
|
||||
|
|
@ -627,7 +657,7 @@ public sealed class EntityEffectControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void RefreshLiveOwnerAnchorsUsesCurrentWorldPositionAtDispatch()
|
||||
public void RefreshLiveOwnerPosesUsesCurrentWorldPositionAtDispatch()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.AddScript(DirectDid, 1u, startTime: 1.0);
|
||||
|
|
@ -635,7 +665,7 @@ public sealed class EntityEffectControllerTests
|
|||
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
|
||||
entity.SetPosition(new Vector3(10, 20, 30));
|
||||
|
||||
fixture.Controller.RefreshLiveOwnerAnchors();
|
||||
fixture.Controller.RefreshLiveOwnerPoses();
|
||||
fixture.Runner.Tick(1.0);
|
||||
|
||||
Assert.Equal(new Vector3(10, 20, 30), Assert.Single(fixture.Sink.Calls).Position);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Vfx;
|
||||
|
||||
public sealed class EntityEffectPoseRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Publish_PreservesIndexedParts_AndUpdateRootDoesNotReplaceThem()
|
||||
{
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
WorldEntity entity = Entity(7u, new Vector3(1, 2, 3));
|
||||
poses.Publish(entity, new[]
|
||||
{
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.CreateTranslation(0, 0, 4),
|
||||
});
|
||||
|
||||
entity.SetPosition(new Vector3(10, 20, 30));
|
||||
Assert.True(poses.UpdateRoot(entity));
|
||||
|
||||
Assert.True(poses.TryGetRootPose(7u, out Matrix4x4 root));
|
||||
Assert.Equal(new Vector3(10, 20, 30), root.Translation);
|
||||
Assert.True(poses.TryGetPartPose(7u, 1, out Matrix4x4 part));
|
||||
Assert.Equal(new Vector3(0, 0, 4), part.Translation);
|
||||
Assert.False(poses.TryGetPartPose(7u, -1, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishMeshRefs_ReplacesPartSnapshotImmediately()
|
||||
{
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
WorldEntity entity = Entity(8u, Vector3.Zero);
|
||||
poses.Publish(entity, new[] { Matrix4x4.Identity });
|
||||
entity.MeshRefs = new[]
|
||||
{
|
||||
new MeshRef(0x01000001u, Matrix4x4.CreateTranslation(1, 2, 3)),
|
||||
new MeshRef(0x01000002u, Matrix4x4.CreateTranslation(4, 5, 6)),
|
||||
};
|
||||
|
||||
poses.PublishMeshRefs(entity);
|
||||
|
||||
Assert.True(poses.TryGetPartPoses(8u, out var parts));
|
||||
Assert.Equal(2, parts.Count);
|
||||
Assert.Equal(new Vector3(4, 5, 6), parts[1].Translation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Publish_MissingMiddlePartRetainsIndicesButCannotBeResolved()
|
||||
{
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
WorldEntity entity = Entity(81u, Vector3.Zero);
|
||||
|
||||
poses.Publish(
|
||||
entity,
|
||||
[
|
||||
Matrix4x4.CreateTranslation(1, 0, 0),
|
||||
Matrix4x4.CreateTranslation(2, 0, 0),
|
||||
Matrix4x4.CreateTranslation(3, 0, 0),
|
||||
],
|
||||
[true, false, true]);
|
||||
|
||||
Assert.False(poses.TryGetPartPose(81u, 1, out _));
|
||||
Assert.True(poses.TryGetPartPose(81u, 2, out Matrix4x4 third));
|
||||
Assert.Equal(3f, third.Translation.X);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishMeshRefs_PrefersStableIndexedEntityPosesOverCompactedDrawables()
|
||||
{
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
WorldEntity entity = Entity(82u, Vector3.Zero);
|
||||
entity.MeshRefs =
|
||||
[
|
||||
new MeshRef(1u, Matrix4x4.CreateTranslation(1, 0, 0)),
|
||||
new MeshRef(3u, Matrix4x4.CreateTranslation(3, 0, 0)),
|
||||
];
|
||||
entity.SetIndexedPartPoses(
|
||||
[
|
||||
Matrix4x4.CreateTranslation(1, 0, 0),
|
||||
Matrix4x4.CreateTranslation(2, 0, 0),
|
||||
Matrix4x4.CreateTranslation(3, 0, 0),
|
||||
],
|
||||
[true, false, true]);
|
||||
|
||||
poses.PublishMeshRefs(entity);
|
||||
|
||||
Assert.False(poses.TryGetPartPose(82u, 1, out _));
|
||||
Assert.True(poses.TryGetPartPose(82u, 2, out Matrix4x4 third));
|
||||
Assert.Equal(3f, third.Translation.X);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnimationHookQueue_DrainsAgainstPosePublishedAfterCapture()
|
||||
{
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
WorldEntity entity = Entity(9u, Vector3.Zero);
|
||||
poses.Publish(entity, Array.Empty<Matrix4x4>());
|
||||
var router = new AnimationHookRouter();
|
||||
var sink = new RecordingSink();
|
||||
router.Register(sink);
|
||||
var queue = new AnimationHookFrameQueue(router, poses);
|
||||
var sequencer = new AnimationSequencer(
|
||||
new Setup(),
|
||||
new MotionTable(),
|
||||
new NullAnimationLoader());
|
||||
|
||||
queue.Capture(9u, sequencer, new AnimationHook[] { new SoundHook() });
|
||||
entity.SetPosition(new Vector3(4, 5, 6));
|
||||
poses.UpdateRoot(entity);
|
||||
queue.Drain();
|
||||
|
||||
Assert.Single(sink.Calls);
|
||||
Assert.Equal(new Vector3(4, 5, 6), sink.Calls[0].Position);
|
||||
Assert.Equal(0, queue.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnimationHookQueue_CompletesMotionStateEvenWhenPoseWasRemoved()
|
||||
{
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
var queue = new AnimationHookFrameQueue(new AnimationHookRouter(), poses);
|
||||
var sequencer = new AnimationSequencer(
|
||||
new Setup(),
|
||||
new MotionTable(),
|
||||
new NullAnimationLoader());
|
||||
sequencer.Manager.AddToQueue(0x10000001u, ticks: 1u);
|
||||
|
||||
queue.Capture(
|
||||
11u,
|
||||
sequencer,
|
||||
new AnimationHook[] { new AnimationDoneHook() });
|
||||
queue.Drain();
|
||||
|
||||
Assert.Empty(sequencer.Manager.PendingAnimations);
|
||||
Assert.Equal(0, queue.Count);
|
||||
}
|
||||
|
||||
private static WorldEntity Entity(uint id, Vector3 position) => new()
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = id,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = position,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
ParentCellId = 0x01010001u,
|
||||
};
|
||||
|
||||
private sealed class RecordingSink : IAnimationHookSink
|
||||
{
|
||||
public List<(uint Id, Vector3 Position)> Calls { get; } = new();
|
||||
|
||||
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook) =>
|
||||
Calls.Add((entityId, entityWorldPosition));
|
||||
}
|
||||
|
||||
private sealed class NullAnimationLoader : IAnimationLoader
|
||||
{
|
||||
public Animation? LoadAnimation(uint animationId) => null;
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ public sealed class EntityScriptActivatorTests
|
|||
private record Pipeline(
|
||||
ParticleSystem System,
|
||||
ParticleHookSink Sink,
|
||||
EntityEffectPoseRegistry Poses,
|
||||
PhysicsScriptRunner Runner,
|
||||
RecordingSink Recording);
|
||||
|
||||
|
|
@ -50,14 +51,15 @@ public sealed class EntityScriptActivatorTests
|
|||
{
|
||||
var registry = new EmitterDescRegistry();
|
||||
var system = new ParticleSystem(registry);
|
||||
var hookSink = new ParticleHookSink(system); // for activator's StopAllForEntity
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
var hookSink = new ParticleHookSink(system, poses); // for activator's StopAllForEntity
|
||||
var recording = new RecordingSink(); // for runner's hook dispatch
|
||||
var table = new Dictionary<uint, DatPhysicsScript>();
|
||||
foreach (var (id, s) in scripts) table[id] = s;
|
||||
var runner = new PhysicsScriptRunner(
|
||||
id => table.TryGetValue(id, out var s) ? s : null,
|
||||
recording);
|
||||
return new Pipeline(system, hookSink, runner, recording);
|
||||
return new Pipeline(system, hookSink, poses, runner, recording);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -74,7 +76,7 @@ public sealed class EntityScriptActivatorTests
|
|||
{
|
||||
var p = BuildPipeline(
|
||||
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 }))));
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, p.Poses, StaticResolver(0xAAu));
|
||||
var entity = MakeEntity(serverGuid: 0xCAFEu, position: new Vector3(1, 2, 3));
|
||||
|
||||
activator.OnCreate(entity);
|
||||
|
|
@ -90,7 +92,7 @@ public sealed class EntityScriptActivatorTests
|
|||
public void OnCreate_WithoutDefaultScript_DoesNothing()
|
||||
{
|
||||
var p = BuildPipeline(); // no scripts registered
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => null);
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, p.Poses, _ => null);
|
||||
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
|
||||
|
||||
activator.OnCreate(entity);
|
||||
|
|
@ -125,11 +127,11 @@ public sealed class EntityScriptActivatorTests
|
|||
};
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_SetsEntityRotationForHookOffsetTransform()
|
||||
public void OnCreate_PublishesEntityRotationForHookOffsetTransform()
|
||||
{
|
||||
// The CreateParticleHook's Offset is in entity-local frame; the sink
|
||||
// needs the entity's rotation to transform it to world space. If the
|
||||
// activator forgets SetEntityRotation, the offset goes off in world
|
||||
// reads the published entity root to transform it to world space. If
|
||||
// the activator omits that pose, the offset goes off in world
|
||||
// axes — visual symptom: portal swirls misaligned to the portal stone.
|
||||
// This test verifies the seed happens by checking the spawned particle's
|
||||
// world position matches the rotated offset, not the unrotated offset.
|
||||
|
|
@ -138,7 +140,8 @@ public sealed class EntityScriptActivatorTests
|
|||
registry.Register(BuildPersistentEmitterDesc());
|
||||
|
||||
var system = new ParticleSystem(registry);
|
||||
var hookSink = new ParticleHookSink(system);
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
var hookSink = new ParticleHookSink(system, poses);
|
||||
|
||||
// Hook offset = (1, 0, 0) in entity-local frame.
|
||||
var hookOffset = new Frame
|
||||
|
|
@ -147,13 +150,18 @@ public sealed class EntityScriptActivatorTests
|
|||
Orientation = Quaternion.Identity,
|
||||
};
|
||||
var script = BuildScript(
|
||||
(0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = hookOffset }));
|
||||
(0.0, new CreateParticleHook
|
||||
{
|
||||
EmitterInfoId = 100u,
|
||||
Offset = hookOffset,
|
||||
PartIndex = uint.MaxValue,
|
||||
}));
|
||||
var table = new Dictionary<uint, DatPhysicsScript> { [0xAAu] = script };
|
||||
var runner = new PhysicsScriptRunner(
|
||||
id => table.TryGetValue(id, out var s) ? s : null,
|
||||
hookSink);
|
||||
|
||||
var activator = new EntityScriptActivator(runner, hookSink, StaticResolver(0xAAu));
|
||||
var activator = new EntityScriptActivator(runner, hookSink, poses, StaticResolver(0xAAu));
|
||||
|
||||
// Entity rotated 90° around world-Z (yaw left); local +X maps to world +Y.
|
||||
var entityRotation = Quaternion.CreateFromAxisAngle(
|
||||
|
|
@ -193,15 +201,21 @@ public sealed class EntityScriptActivatorTests
|
|||
registry.Register(BuildPersistentEmitterDesc());
|
||||
|
||||
var system = new ParticleSystem(registry);
|
||||
var hookSink = new ParticleHookSink(system);
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
var hookSink = new ParticleHookSink(system, poses);
|
||||
|
||||
var script = BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() }));
|
||||
var script = BuildScript((0.0, new CreateParticleHook
|
||||
{
|
||||
EmitterInfoId = 100u,
|
||||
Offset = new Frame(),
|
||||
PartIndex = uint.MaxValue,
|
||||
}));
|
||||
var table = new Dictionary<uint, DatPhysicsScript> { [0xAAu] = script };
|
||||
var runner = new PhysicsScriptRunner(
|
||||
id => table.TryGetValue(id, out var s) ? s : null,
|
||||
hookSink); // runner dispatches into real sink, not RecordingSink
|
||||
|
||||
var activator = new EntityScriptActivator(runner, hookSink, StaticResolver(0xAAu));
|
||||
var activator = new EntityScriptActivator(runner, hookSink, poses, StaticResolver(0xAAu));
|
||||
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
|
||||
|
||||
activator.OnCreate(entity);
|
||||
|
|
@ -226,7 +240,7 @@ public sealed class EntityScriptActivatorTests
|
|||
// OnCreate must use entity.Id as the key (not skip).
|
||||
var p = BuildPipeline(
|
||||
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 }))));
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, p.Poses, StaticResolver(0xAAu));
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 0x40A9B401u, // dat-hydrated interior id
|
||||
|
|
@ -251,7 +265,7 @@ public sealed class EntityScriptActivatorTests
|
|||
{
|
||||
var p = BuildPipeline(
|
||||
(0xAAu, BuildScript((1.0, new CreateParticleHook { EmitterInfoId = 100 }))));
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, p.Poses, StaticResolver(0xAAu));
|
||||
WorldEntity first = MakeDatStatic(0x80A9B400u, new Vector3(1, 0, 0));
|
||||
WorldEntity second = MakeDatStatic(0x80A9B400u, new Vector3(2, 0, 0));
|
||||
|
||||
|
|
@ -268,7 +282,7 @@ public sealed class EntityScriptActivatorTests
|
|||
public void LiveParticleFilterUsesCanonicalLocalEffectOwnerNotServerGuid()
|
||||
{
|
||||
var p = BuildPipeline();
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => null);
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, p.Poses, _ => null);
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 1_000_123u,
|
||||
|
|
@ -285,6 +299,34 @@ public sealed class EntityScriptActivatorTests
|
|||
Assert.NotEqual(entity.ServerGuid, GameWindow.ParticleEntityKey(entity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemovingLiveOwner_DoesNotClearIndependentDatStaticPose()
|
||||
{
|
||||
var p = BuildPipeline();
|
||||
var activator = new EntityScriptActivator(
|
||||
p.Runner,
|
||||
p.Sink,
|
||||
p.Poses,
|
||||
StaticResolver(0));
|
||||
WorldEntity datStatic = MakeDatStatic(0x40A9B410u, Vector3.One);
|
||||
var live = new WorldEntity
|
||||
{
|
||||
Id = 1_000_410u,
|
||||
ServerGuid = 0x70000410u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(2, 2, 2),
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
activator.OnCreate(datStatic);
|
||||
activator.OnCreate(live);
|
||||
|
||||
activator.OnRemove(live);
|
||||
|
||||
Assert.True(p.Poses.TryGetRootPose(datStatic.Id, out _));
|
||||
Assert.False(p.Poses.TryGetRootPose(live.Id, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_PassesPartTransformsToSink()
|
||||
{
|
||||
|
|
@ -295,7 +337,8 @@ public sealed class EntityScriptActivatorTests
|
|||
var registry = new EmitterDescRegistry();
|
||||
registry.Register(BuildPersistentEmitterDesc());
|
||||
var system = new ParticleSystem(registry);
|
||||
var hookSink = new ParticleHookSink(system);
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
var hookSink = new ParticleHookSink(system, poses);
|
||||
|
||||
var hookOffset = new Frame { Origin = new Vector3(1f, 0, 0), Orientation = Quaternion.Identity };
|
||||
var script = BuildScript(
|
||||
|
|
@ -311,7 +354,7 @@ public sealed class EntityScriptActivatorTests
|
|||
Matrix4x4.CreateTranslation(0f, 0f, 1f),
|
||||
};
|
||||
|
||||
var activator = new EntityScriptActivator(runner, hookSink,
|
||||
var activator = new EntityScriptActivator(runner, hookSink, poses,
|
||||
_ => new ScriptActivationInfo(0xAAu, partTransforms));
|
||||
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
|
||||
|
||||
|
|
@ -335,15 +378,21 @@ public sealed class EntityScriptActivatorTests
|
|||
var registry = new EmitterDescRegistry();
|
||||
registry.Register(BuildPersistentEmitterDesc());
|
||||
var system = new ParticleSystem(registry);
|
||||
var hookSink = new ParticleHookSink(system);
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
var hookSink = new ParticleHookSink(system, poses);
|
||||
|
||||
var script = BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() }));
|
||||
var script = BuildScript((0.0, new CreateParticleHook
|
||||
{
|
||||
EmitterInfoId = 100u,
|
||||
Offset = new Frame(),
|
||||
PartIndex = uint.MaxValue,
|
||||
}));
|
||||
var table = new Dictionary<uint, DatPhysicsScript> { [0xAAu] = script };
|
||||
var runner = new PhysicsScriptRunner(
|
||||
id => table.TryGetValue(id, out var s) ? s : null,
|
||||
hookSink);
|
||||
|
||||
var activator = new EntityScriptActivator(runner, hookSink, StaticResolver(0xAAu));
|
||||
var activator = new EntityScriptActivator(runner, hookSink, poses, StaticResolver(0xAAu));
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 0x40A9B402u,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Vfx;
|
||||
|
||||
public sealed class IndexedSetupPartPoseBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_DoesNotCollapseMissingMiddleDrawable()
|
||||
{
|
||||
var setup = new Setup
|
||||
{
|
||||
Parts = { 0x01000001u, 0x01000002u, 0x01000003u },
|
||||
};
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 7u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs =
|
||||
[
|
||||
new MeshRef(0x01000001u, Matrix4x4.CreateTranslation(1, 0, 0)),
|
||||
new MeshRef(0x01000003u, Matrix4x4.CreateTranslation(3, 0, 0)),
|
||||
],
|
||||
};
|
||||
|
||||
var indexed = IndexedSetupPartPoseBuilder.Build(setup, entity);
|
||||
|
||||
Assert.Equal([true, false, true], indexed.Available);
|
||||
Assert.Equal(Matrix4x4.Identity, indexed.Poses[0]);
|
||||
Assert.Equal(Matrix4x4.Identity, indexed.Poses[2]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Lighting;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Vfx;
|
||||
|
||||
public sealed class LiveEntityLightControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Refresh_FollowsCurrentTopLevelRootAndCell()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
WorldEntity entity = fixture.Materialize(LiveEntityProjectionKind.World);
|
||||
LightSource light = Assert.Single(fixture.Sink.GetOwnedLights(entity.Id)!);
|
||||
|
||||
entity.SetPosition(new Vector3(20, 30, 40));
|
||||
entity.ParentCellId = 0x01010002u;
|
||||
Assert.True(fixture.Poses.UpdateRoot(entity));
|
||||
fixture.Controller.Refresh();
|
||||
|
||||
Assert.Equal(new Vector3(21, 30, 40), light.WorldPosition);
|
||||
Assert.Equal(0x01010002u, light.CellId);
|
||||
Assert.True(light.IsDynamic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_AttachedOwnerPreservesComposedChildRoot()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
WorldEntity entity = fixture.Materialize(LiveEntityProjectionKind.Attached);
|
||||
fixture.Poses.Publish(
|
||||
entity.Id,
|
||||
Matrix4x4.CreateTranslation(50, 60, 70),
|
||||
Array.Empty<Matrix4x4>(),
|
||||
0x01010001u);
|
||||
|
||||
fixture.Controller.OnAttachedPoseReady(Fixture.Guid);
|
||||
fixture.Controller.Refresh();
|
||||
|
||||
LightSource light = Assert.Single(fixture.Sink.GetOwnedLights(entity.Id)!);
|
||||
Assert.Equal(new Vector3(51, 60, 70), light.WorldPosition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoritativeLightingTransitions_DestroyAndRecreateSetupLights()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
WorldEntity entity = fixture.Materialize(LiveEntityProjectionKind.World);
|
||||
Assert.Equal(1, fixture.Lights.RegisteredCount);
|
||||
|
||||
Assert.True(fixture.Runtime.TryApplyState(
|
||||
new SetState.Parsed(
|
||||
Fixture.Guid,
|
||||
(uint)PhysicsStateFlags.ReportCollisions,
|
||||
1,
|
||||
2),
|
||||
out _));
|
||||
fixture.Controller.OnStateChanged(Fixture.Guid);
|
||||
Assert.Equal(0, fixture.Lights.RegisteredCount);
|
||||
|
||||
Assert.True(fixture.Runtime.TryApplyState(
|
||||
new SetState.Parsed(
|
||||
Fixture.Guid,
|
||||
(uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Lighting),
|
||||
1,
|
||||
3),
|
||||
out _));
|
||||
fixture.Controller.OnStateChanged(Fixture.Guid);
|
||||
Assert.Equal(1, fixture.Lights.RegisteredCount);
|
||||
Assert.Single(fixture.Sink.GetOwnedLights(entity.Id)!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetLightHookStateSurvivesProjectionWithdrawal()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
WorldEntity entity = fixture.Materialize(LiveEntityProjectionKind.World);
|
||||
|
||||
fixture.Sink.OnHook(
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
new SetLightHook { LightsOn = false });
|
||||
Assert.Equal(0, fixture.Lights.RegisteredCount);
|
||||
|
||||
fixture.Controller.Unregister(entity.Id);
|
||||
Assert.False(fixture.Controller.Register(Fixture.Guid));
|
||||
Assert.Equal(0, fixture.Lights.RegisteredCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadedPendingLoadedTransitionsOwnLightPresentationExactlyOnce()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.Materialize(LiveEntityProjectionKind.World);
|
||||
Assert.Equal(1, fixture.Lights.RegisteredCount);
|
||||
|
||||
fixture.Spatial.RemoveLandblock(0x0101FFFFu);
|
||||
Assert.Equal(0, fixture.Lights.RegisteredCount);
|
||||
|
||||
fixture.Spatial.AddLandblock(new LoadedLandblock(
|
||||
0x0101FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
Assert.Equal(1, fixture.Lights.RegisteredCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitialVisibleProjectionLoadsSetupLightsOnce()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
|
||||
fixture.Materialize(LiveEntityProjectionKind.World);
|
||||
|
||||
Assert.Equal(1, fixture.SetupLoadCount);
|
||||
Assert.Equal(1, fixture.Lights.RegisteredCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadedToLoadedRebucketDoesNotRecreateLightPresentation()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.Spatial.AddLandblock(new LoadedLandblock(
|
||||
0x0202FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
fixture.Materialize(LiveEntityProjectionKind.World);
|
||||
Assert.Equal(1, fixture.SetupLoadCount);
|
||||
|
||||
Assert.True(fixture.Runtime.RebucketLiveEntity(Fixture.Guid, 0x02020001u));
|
||||
|
||||
Assert.Equal(1, fixture.SetupLoadCount);
|
||||
Assert.Equal(1, fixture.Lights.RegisteredCount);
|
||||
}
|
||||
|
||||
private sealed class Fixture
|
||||
{
|
||||
public const uint Guid = 0x70000001u;
|
||||
private readonly Setup _setup = BuildSetup();
|
||||
|
||||
public Fixture()
|
||||
{
|
||||
Spatial.AddLandblock(new LoadedLandblock(
|
||||
0x0101FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
Runtime = new LiveEntityRuntime(Spatial, new NoopResources(), _ => { });
|
||||
Sink = new LightingHookSink(Lights, Poses);
|
||||
Controller = new LiveEntityLightController(
|
||||
Runtime,
|
||||
Poses,
|
||||
Sink,
|
||||
setupId =>
|
||||
{
|
||||
SetupLoadCount++;
|
||||
return setupId == 0x02000001u ? _setup : null;
|
||||
});
|
||||
}
|
||||
|
||||
public GpuWorldState Spatial { get; } = new();
|
||||
public LiveEntityRuntime Runtime { get; }
|
||||
public EntityEffectPoseRegistry Poses { get; } = new();
|
||||
public LightManager Lights { get; } = new();
|
||||
public LightingHookSink Sink { get; }
|
||||
public LiveEntityLightController Controller { get; }
|
||||
public int SetupLoadCount { get; private set; }
|
||||
|
||||
public WorldEntity Materialize(LiveEntityProjectionKind kind)
|
||||
{
|
||||
WorldSession.EntitySpawn spawn = Spawn();
|
||||
Runtime.RegisterLiveEntity(spawn);
|
||||
WorldEntity entity = Runtime.MaterializeLiveEntity(
|
||||
Guid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = Guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(10, 10, 5),
|
||||
Rotation = Quaternion.Identity,
|
||||
ParentCellId = 0x01010001u,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
},
|
||||
kind)!;
|
||||
Poses.PublishMeshRefs(entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
private static Setup BuildSetup()
|
||||
{
|
||||
var setup = new Setup();
|
||||
setup.Lights[0] = new LightInfo
|
||||
{
|
||||
ViewSpaceLocation = new Frame
|
||||
{
|
||||
Origin = Vector3.UnitX,
|
||||
Orientation = Quaternion.Identity,
|
||||
},
|
||||
Color = new ColorARGB { Red = 255, Green = 255, Blue = 255, Alpha = 255 },
|
||||
Intensity = 1f,
|
||||
Falloff = 8f,
|
||||
};
|
||||
return setup;
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn()
|
||||
{
|
||||
var position = new CreateObject.ServerPosition(
|
||||
0x01010001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
|
||||
return new WorldSession.EntitySpawn(
|
||||
Guid,
|
||||
position,
|
||||
0x02000001u,
|
||||
Array.Empty<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
null,
|
||||
null,
|
||||
"fixture",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
PhysicsState: (uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Lighting),
|
||||
InstanceSequence: 1,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopResources : ILiveEntityResourceLifecycle
|
||||
{
|
||||
public void Register(WorldEntity entity) { }
|
||||
public void Unregister(WorldEntity entity) { }
|
||||
}
|
||||
}
|
||||
|
|
@ -49,10 +49,11 @@ public sealed class GpuWorldStateActivatorTests
|
|||
|
||||
var registry = new EmitterDescRegistry();
|
||||
var system = new ParticleSystem(registry);
|
||||
var sink = new ParticleHookSink(system);
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
var sink = new ParticleHookSink(system, poses);
|
||||
var recording = new RecordingSink();
|
||||
var runner = new PhysicsScriptRunner(id => table.TryGetValue(id, out var s) ? s : null, recording);
|
||||
var activator = new EntityScriptActivator(runner, sink,
|
||||
var activator = new EntityScriptActivator(runner, sink, poses,
|
||||
_ => new ScriptActivationInfo(scriptId, Array.Empty<Matrix4x4>()));
|
||||
|
||||
var state = new GpuWorldState(entityScriptActivator: activator);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue