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

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

View file

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

View file

@ -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,

View file

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

View file

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