feat(vfx): port retail effect scheduling and delivery

This commit is contained in:
Erik 2026-07-14 09:25:44 +02:00
parent 363e046112
commit 96ddfdf175
28 changed files with 2473 additions and 691 deletions

View file

@ -0,0 +1,746 @@
using System.Numerics;
using AcDream.App.Audio;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Meshing;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.App.Tests.Rendering.Vfx;
public sealed class EntityEffectControllerTests
{
private const uint Guid = 0x70000001u;
private const uint DirectDid = 0x33000011u;
private const uint TypedDid = 0x33000022u;
private const uint TableDid = 0x34000033u;
private const uint RawType = 0x16u;
private sealed class NoopResources : ILiveEntityResourceLifecycle
{
public void Register(WorldEntity entity) { }
public void Unregister(WorldEntity entity) { }
}
private sealed class RecordingSink : IAnimationHookSink
{
public List<(uint EntityId, Vector3 Position, AnimationHook Hook)> Calls { get; } = new();
public void OnHook(uint entityId, Vector3 worldPosition, AnimationHook hook) =>
Calls.Add((entityId, worldPosition, hook));
}
private sealed class Fixture
{
private readonly Dictionary<uint, DatPhysicsScript> _scripts = new();
private readonly Dictionary<uint, PhysicsScriptTable> _tables = new();
private EntityEffectController? _controller;
public Fixture(
Func<uint, uint, uint?>? childAtPart = null,
Func<uint, uint?>? parentOfAttachedChild = null,
Func<uint, PhysicsScriptTable?>? tableLoader = null,
Action<uint>? ownerUnregistered = null,
Action<uint, uint?>? ownerSoundTableChanged = null)
{
Spatial.AddLandblock(new LoadedLandblock(
0x0101FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
Runtime = new LiveEntityRuntime(
Spatial,
new NoopResources(),
record => _controller?.OnLiveEntityUnregistered(record));
Runner = new PhysicsScriptRunner(
id => _scripts.TryGetValue(id, out DatPhysicsScript? script) ? script : null,
Router,
randomUnit: () => 0.5,
canAdvanceOwner: ownerId => _controller?.CanAdvanceOwner(ownerId) ?? true);
Controller = new EntityEffectController(
Runtime,
Runner,
new PhysicsScriptTableResolver(
tableLoader
?? (id => _tables.TryGetValue(id, out PhysicsScriptTable? table) ? table : null)),
childAtPart,
parentOfAttachedChild,
ownerUnregistered,
ownerSoundTableChanged);
_controller = Controller;
Router.Register(Sink);
Router.Register(Controller);
AddScript(DirectDid, 1u);
AddScript(TypedDid, 2u);
_tables.Add(TableDid, BuildTable(TableDid, RawType, (1f, TypedDid)));
}
public GpuWorldState Spatial { get; } = new();
public LiveEntityRuntime Runtime { get; }
public RecordingSink Sink { get; } = new();
public AnimationHookRouter Router { get; } = new();
public PhysicsScriptRunner Runner { get; }
public EntityEffectController Controller { get; }
public void AddScript(uint did, uint emitterId, double startTime = 0.0)
{
var script = new DatPhysicsScript();
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = startTime,
Hook = new CreateParticleHook { EmitterInfoId = emitterId },
});
_scripts[did] = script;
}
public void AddScript(uint did, AnimationHook hook, double startTime = 0.0)
{
var script = new DatPhysicsScript();
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = startTime,
Hook = hook,
});
_scripts[did] = script;
}
public WorldEntity ReadyLive(
uint guid = Guid,
ushort generation = 1,
EntityEffectProfile? profile = null)
{
WorldSession.EntitySpawn spawn = Spawn(guid, generation);
Runtime.RegisterLiveEntity(spawn);
Runtime.SetEffectProfile(guid, profile ?? LiveProfile());
WorldEntity entity = Runtime.MaterializeLiveEntity(
guid,
spawn.Position!.Value.LandblockId,
id => Entity(id, guid))!;
Assert.True(Controller.OnLiveEntityReady(guid));
return entity;
}
public static EntityEffectProfile LiveProfile(
uint tableDid = TableDid,
uint rawDefaultType = RawType,
float defaultIntensity = 0.5f,
uint? soundTableDid = null) =>
EntityEffectProfile.CreateLive(
new Setup(),
default(PhysicsSpawnData) with
{
PhysicsScriptTableId = tableDid,
SoundTableId = soundTableDid,
DefaultScriptType = rawDefaultType,
DefaultScriptIntensity = defaultIntensity,
});
}
[Fact]
public void PreMaterializationDirectAndTypedPacketsReplayOnceInMixedOrder()
{
var fixture = new Fixture();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Controller.HandleTyped(new PlayPhysicsScriptType(Guid, RawType, 0.5f));
Assert.Equal(2, fixture.Controller.PendingPacketCount);
WorldEntity entity = fixture.ReadyLive();
Assert.Equal(0, fixture.Controller.PendingPacketCount);
Assert.Equal(2, fixture.Runner.ActiveScriptCount);
// A duplicate readiness notification is idempotent and cannot replay.
Assert.True(fixture.Controller.OnLiveEntityReady(Guid));
fixture.Runner.Tick(0.0);
Assert.Equal([1u, 2u], EmitterIds(fixture.Sink));
Assert.All(fixture.Sink.Calls, call => Assert.Equal(entity.Id, call.EntityId));
Assert.DoesNotContain(fixture.Sink.Calls, call => call.EntityId == Guid);
}
[Fact]
public void ReadyOwnerReceivesDirectAndTypedPacketsImmediately()
{
var fixture = new Fixture();
fixture.ReadyLive();
fixture.Controller.HandleTyped(new PlayPhysicsScriptType(Guid, RawType, 0.5f));
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Runner.Tick(0.0);
Assert.Equal([2u, 1u], EmitterIds(fixture.Sink));
Assert.Equal(0, fixture.Controller.PendingPacketCount);
}
[Fact]
public void CelllessOwnerRejectsNewPacketAndPausesExistingQueueUntilReentry()
{
var fixture = new Fixture();
fixture.AddScript(DirectDid, 1u, startTime: 1.0);
fixture.ReadyLive();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Runner.Tick(0.5);
Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(Guid));
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Runner.Tick(2.0);
Assert.Empty(fixture.Sink.Calls);
Assert.Equal(1, fixture.Runner.ActiveScriptCount);
Assert.True(fixture.Runtime.RebucketLiveEntity(Guid, 0x01010001u));
fixture.Runner.Tick(2.0);
Assert.Equal([1u], EmitterIds(fixture.Sink));
}
[Fact]
public void FrozenOwnerPausesQueueUntilFreshStateUnfreezesIt()
{
var fixture = new Fixture();
fixture.AddScript(DirectDid, 1u, startTime: 1.0);
fixture.ReadyLive();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
Assert.True(fixture.Runtime.TryApplyState(
new SetState.Parsed(
Guid,
(uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Frozen),
1,
2),
out _));
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Runner.Tick(2.0);
Assert.Empty(fixture.Sink.Calls);
Assert.Equal(2, fixture.Runner.ActiveScriptCount);
Assert.True(fixture.Runtime.TryApplyState(
new SetState.Parsed(Guid, (uint)PhysicsStateFlags.ReportCollisions, 1, 3),
out _));
fixture.Runner.Tick(2.0);
Assert.Equal([1u, 1u], EmitterIds(fixture.Sink));
}
[Fact]
public void PreCreatePacketWaitsForProjectionButPostCreateCelllessPacketIsDropped()
{
var fixture = new Fixture();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
WorldSession.EntitySpawn spawn = Spawn(Guid, 1);
fixture.Runtime.RegisterLiveEntity(spawn);
fixture.Runtime.SetEffectProfile(Guid, Fixture.LiveProfile());
WorldEntity entity = fixture.Runtime.MaterializeLiveEntity(
Guid,
0x02020001u,
id => Entity(id, Guid))!;
Assert.True(fixture.Controller.OnLiveEntityReady(Guid));
Assert.Equal(1, fixture.Controller.PendingPacketCount);
fixture.Controller.HandleTyped(new PlayPhysicsScriptType(Guid, RawType, 0.5f));
Assert.Equal(1, fixture.Controller.PendingPacketCount);
fixture.Spatial.AddLandblock(new LoadedLandblock(
0x0202FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
fixture.Controller.RefreshLiveOwnerAnchors();
fixture.Runner.Tick(0.0);
Assert.Equal(0, fixture.Controller.PendingPacketCount);
Assert.Equal([1u], EmitterIds(fixture.Sink));
Assert.All(fixture.Sink.Calls, call => Assert.Equal(entity.Id, call.EntityId));
}
[Fact]
public void LoadedToPendingMovePausesActiveQueueUntilLandblockLoads()
{
var fixture = new Fixture();
fixture.AddScript(DirectDid, 1u, startTime: 1.0);
fixture.ReadyLive();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Runner.Tick(0.5);
Assert.True(fixture.Runtime.RebucketLiveEntity(Guid, 0x02020001u));
fixture.Runner.Tick(2.0);
Assert.Empty(fixture.Sink.Calls);
Assert.Equal(1, fixture.Runner.ActiveScriptCount);
fixture.Spatial.AddLandblock(new LoadedLandblock(
0x0202FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
fixture.Runner.Tick(2.0);
Assert.Equal([1u], EmitterIds(fixture.Sink));
}
[Fact]
public void PendingInvalidTypedPacketDoesNotCorruptFollowingDirectPacket()
{
var fixture = new Fixture();
fixture.Controller.HandleTyped(new PlayPhysicsScriptType(Guid, RawType, float.NaN));
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.ReadyLive();
fixture.Runner.Tick(0.0);
Assert.Equal([1u], EmitterIds(fixture.Sink));
Assert.Equal(0, fixture.Controller.PendingPacketCount);
}
[Fact]
public void PacketNeverUsesServerGuidOrFallbackAnchorBeforeOwnerIsReady()
{
var fixture = new Fixture();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Runner.Tick(10.0);
Assert.Empty(fixture.Sink.Calls);
Assert.Equal(0, fixture.Runner.ActiveScriptCount);
Assert.Equal(1, fixture.Controller.PendingPacketCount);
}
[Fact]
public void AcceptedDeleteClearsPendingGenerationBeforeGuidReuse()
{
var fixture = new Fixture();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
WorldSession.EntitySpawn first = Spawn(Guid, generation: 1);
fixture.Runtime.RegisterLiveEntity(first);
Assert.True(fixture.Runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(Guid, 1),
isLocalPlayer: false));
Assert.Equal(0, fixture.Controller.PendingPacketCount);
fixture.ReadyLive(Guid, generation: 2);
fixture.Runner.Tick(0.0);
Assert.Empty(fixture.Sink.Calls);
}
[Fact]
public void StaleDeleteCannotClearCurrentGenerationQueue()
{
var fixture = new Fixture();
WorldSession.EntitySpawn current = Spawn(Guid, generation: 2);
fixture.Runtime.RegisterLiveEntity(current);
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
Assert.False(fixture.Runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(Guid, 1),
isLocalPlayer: false));
Assert.Equal(1, fixture.Controller.PendingPacketCount);
fixture.Runtime.SetEffectProfile(Guid, Fixture.LiveProfile());
fixture.Runtime.MaterializeLiveEntity(
Guid,
current.Position!.Value.LandblockId,
id => Entity(id, Guid));
Assert.True(fixture.Controller.OnLiveEntityReady(Guid));
fixture.Runner.Tick(0.0);
Assert.Equal([1u], EmitterIds(fixture.Sink));
}
[Fact]
public void UnknownDeleteClearsQueueThatNeverReceivedCreateObject()
{
var fixture = new Fixture();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Controller.ForgetUnknownOwner(Guid);
Assert.Equal(0, fixture.Controller.PendingPacketCount);
}
[Fact]
public void LogicalTeardownStopsQueuedScriptsAndClearsReadyIdentity()
{
var fixture = new Fixture();
fixture.AddScript(DirectDid, 1u, startTime: 60.0);
fixture.ReadyLive();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
Assert.Equal(1, fixture.Runner.ActiveScriptCount);
Assert.True(fixture.Runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(Guid, 1),
isLocalPlayer: false));
Assert.Equal(0, fixture.Runner.ActiveScriptCount);
Assert.Equal(0, fixture.Controller.ReadyOwnerCount);
Assert.Equal(0, fixture.Controller.PendingPacketCount);
}
[Fact]
public void LogicalTeardownReleasesOwnerScopedResourcesBeforeGuidReuse()
{
var removedOwners = new List<uint>();
var soundTables = new DictionaryEntitySoundTable();
var fixture = new Fixture(
ownerUnregistered: ownerId =>
{
removedOwners.Add(ownerId);
soundTables.Remove(ownerId);
},
ownerSoundTableChanged: (ownerId, soundTableDid) =>
{
soundTables.Remove(ownerId);
if (soundTableDid is { } did)
soundTables.Set(ownerId, did);
});
uint firstLocalId = fixture.ReadyLive(
Guid,
generation: 1,
Fixture.LiveProfile(soundTableDid: 0x200000EEu)).Id;
Assert.Equal(0x200000EEu, soundTables.GetSoundTableId(firstLocalId));
Assert.True(fixture.Runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(Guid, 1),
isLocalPlayer: false));
uint replacementLocalId = fixture.ReadyLive(Guid, generation: 2).Id;
Assert.Equal([firstLocalId], removedOwners);
Assert.Equal(0u, soundTables.GetSoundTableId(firstLocalId));
Assert.Equal(0u, soundTables.GetSoundTableId(replacementLocalId));
Assert.NotEqual(firstLocalId, replacementLocalId);
}
[Fact]
public void ReadyStaticRestOwnerPublishesNetworkSoundOverrideWithoutSetupFallback()
{
var changed = new List<(uint OwnerId, uint? SoundTableDid)>();
var fixture = new Fixture(
ownerSoundTableChanged: (ownerId, soundTableDid) =>
changed.Add((ownerId, soundTableDid)));
var setup = new Setup { DefaultSoundTable = 0x200000DDu };
EntityEffectProfile networkOverride = EntityEffectProfile.CreateLive(
setup,
default(PhysicsSpawnData) with { SoundTableId = 0x200000EEu });
WorldEntity first = fixture.ReadyLive(profile: networkOverride);
Assert.False(fixture.Runtime.TryGetAnimationRuntime(first.Id, out _));
Assert.Equal([(first.Id, (uint?)0x200000EEu)], changed);
changed.Clear();
EntityEffectProfile networkAbsent = EntityEffectProfile.CreateLive(
setup,
default);
WorldEntity second = fixture.ReadyLive(
guid: 0x70000002u,
profile: networkAbsent);
Assert.Equal([(second.Id, (uint?)null)], changed);
}
[Fact]
public void SameGenerationDescriptionRefreshReplacesAndClearsLiveSoundTable()
{
var changed = new List<(uint OwnerId, uint? SoundTableDid)>();
var fixture = new Fixture(
ownerSoundTableChanged: (ownerId, soundTableDid) =>
changed.Add((ownerId, soundTableDid)));
EntityEffectProfile profile = Fixture.LiveProfile(
soundTableDid: 0x200000EEu);
WorldEntity entity = fixture.ReadyLive(profile: profile);
changed.Clear();
profile.ApplyNetworkDescription(default(PhysicsSpawnData) with
{
PhysicsScriptTableId = TableDid,
SoundTableId = 0x200000EFu,
});
Assert.True(fixture.Controller.OnLiveEntityDescriptionChanged(Guid));
profile.ApplyNetworkDescription(default(PhysicsSpawnData) with
{
PhysicsScriptTableId = TableDid,
SoundTableId = 0u,
});
Assert.True(fixture.Controller.OnLiveEntityDescriptionChanged(Guid));
Assert.Equal(
[
(entity.Id, (uint?)0x200000EFu),
(entity.Id, (uint?)null),
],
changed);
}
[Fact]
public void CallPesAndDefaultHooksEnqueueThroughOwningProfiles()
{
const uint nestedDid = 0x33000044u;
var fixture = new Fixture();
fixture.AddScript(nestedDid, 4u);
WorldEntity entity = fixture.ReadyLive();
fixture.Controller.OnHook(
entity.Id,
entity.Position,
new DefaultScriptHook());
fixture.Controller.OnHook(
entity.Id,
entity.Position,
new CallPESHook { PES = nestedDid, Pause = 0f });
fixture.Runner.Tick(0.0);
Assert.Equal([2u, 4u], EmitterIds(fixture.Sink));
}
[Fact]
public void RunnerRouterControllerPathExecutesNestedCallPes()
{
const uint outerDid = 0x33000043u;
const uint nestedDid = 0x33000044u;
var fixture = new Fixture();
fixture.AddScript(outerDid, new CallPESHook { PES = nestedDid, Pause = 0f });
fixture.AddScript(nestedDid, 4u);
fixture.ReadyLive();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, outerDid));
fixture.Runner.Tick(0.0);
Assert.Collection(
fixture.Sink.Calls,
call => Assert.IsType<CallPESHook>(call.Hook),
call => Assert.Equal(4u, Assert.IsType<CreateParticleHook>(call.Hook).EmitterInfoId.DataId));
}
[Fact]
public void DefaultScriptPartResolvesAttachedChildAndUsesChildProfile()
{
uint parentLocalId = 0;
uint childLocalId = 0;
var fixture = new Fixture((parent, part) =>
parent == parentLocalId && part == 7u ? childLocalId : null);
parentLocalId = fixture.ReadyLive(Guid, generation: 1).Id;
childLocalId = fixture.ReadyLive(0x70000002u, generation: 1).Id;
fixture.Controller.OnHook(
parentLocalId,
Vector3.Zero,
new DefaultScriptPartHook { PartIndex = 7u });
fixture.Runner.Tick(0.0);
var call = Assert.Single(fixture.Sink.Calls);
Assert.Equal(childLocalId, call.EntityId);
Assert.Equal(2u, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId);
}
[Fact]
public void AttachedChildAdvancesThroughEligibleParentWhileCellless()
{
uint parentLocalId = 0;
uint childLocalId = 0;
var fixture = new Fixture(
parentOfAttachedChild: child => child == childLocalId ? parentLocalId : null);
parentLocalId = fixture.ReadyLive(Guid).Id;
childLocalId = fixture.ReadyLive(0x70000002u).Id;
fixture.Runtime.WithdrawLiveEntityProjection(0x70000002u);
Assert.True(fixture.Controller.PlayDefault(childLocalId));
fixture.Runner.Tick(0.0);
Assert.Equal(childLocalId, Assert.Single(fixture.Sink.Calls).EntityId);
fixture.Runtime.WithdrawLiveEntityProjection(Guid);
Assert.False(fixture.Controller.PlayDefault(childLocalId));
}
[Fact]
public void MissingDirectScriptReportsOwnerAndDid()
{
const uint missingDid = 0x3300DEADu;
var fixture = new Fixture();
WorldEntity entity = fixture.ReadyLive();
var diagnostics = new List<string>();
fixture.Controller.DiagnosticSink = diagnostics.Add;
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, missingDid));
string message = Assert.Single(diagnostics);
Assert.Contains($"0x{entity.Id:X8}", message, StringComparison.Ordinal);
Assert.Contains($"0x{missingDid:X8}", message, StringComparison.Ordinal);
}
[Fact]
public void TypedTableLoadFailureReportsOwnerTableAndException()
{
var fixture = new Fixture(
tableLoader: _ => throw new InvalidDataException("fixture corrupt table"));
WorldEntity entity = fixture.ReadyLive();
var diagnostics = new List<string>();
fixture.Controller.DiagnosticSink = diagnostics.Add;
fixture.Controller.HandleTyped(
new PlayPhysicsScriptType(Guid, RawType, 0.5f));
string message = Assert.Single(diagnostics);
Assert.Contains($"0x{entity.Id:X8}", message, StringComparison.Ordinal);
Assert.Contains($"0x{TableDid:X8}", message, StringComparison.Ordinal);
Assert.Contains(nameof(InvalidDataException), message, StringComparison.Ordinal);
Assert.Contains("fixture corrupt table", message, StringComparison.Ordinal);
}
[Fact]
public void StaticOwnerRetainsSetupTableAndIsRemovedSymmetrically()
{
var changed = new List<(uint OwnerId, uint? SoundTableDid)>();
var fixture = new Fixture(
ownerSoundTableChanged: (ownerId, soundTableDid) =>
changed.Add((ownerId, soundTableDid)));
var setup = new Setup
{
DefaultScriptTable = TableDid,
DefaultSoundTable = 0x200000DDu,
};
WorldEntity entity = Entity(55u, 0u);
fixture.Controller.OnDatStaticEntityReady(
entity.Id,
entity,
EntityEffectProfile.CreateDatStatic(setup));
Assert.Equal([(entity.Id, (uint?)0x200000DDu)], changed);
Assert.True(fixture.Controller.PlayTyped(entity.Id, RawType, 0.5f));
fixture.Runner.Tick(0.0);
Assert.Equal([2u], EmitterIds(fixture.Sink));
fixture.Controller.OnDatStaticEntityRemoved(entity.Id);
Assert.False(fixture.Controller.PlayTyped(entity.Id, RawType, 0.5f));
}
[Fact]
public void RegisteredSyntheticOwnerAdvancesThroughGlobalRunnerEligibility()
{
const uint skyOwnerId = 0xF0000042u;
var fixture = new Fixture();
fixture.Controller.RegisterSyntheticOwner(skyOwnerId);
Assert.True(fixture.Runner.PlayDirect(skyOwnerId, DirectDid));
fixture.Runner.Tick(0.0);
Assert.Equal(skyOwnerId, Assert.Single(fixture.Sink.Calls).EntityId);
fixture.Controller.UnregisterSyntheticOwner(skyOwnerId);
Assert.False(fixture.Controller.CanAdvanceOwner(skyOwnerId));
}
[Fact]
public void RefreshLiveOwnerAnchorsUsesCurrentWorldPositionAtDispatch()
{
var fixture = new Fixture();
fixture.AddScript(DirectDid, 1u, startTime: 1.0);
WorldEntity entity = fixture.ReadyLive();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
entity.SetPosition(new Vector3(10, 20, 30));
fixture.Controller.RefreshLiveOwnerAnchors();
fixture.Runner.Tick(1.0);
Assert.Equal(new Vector3(10, 20, 30), Assert.Single(fixture.Sink.Calls).Position);
}
[Fact]
public void ClearNetworkStatePreservesDatStaticProfiles()
{
var fixture = new Fixture();
fixture.ReadyLive();
WorldEntity staticEntity = Entity(55u, 0u);
fixture.Controller.OnDatStaticEntityReady(
staticEntity.Id,
staticEntity,
EntityEffectProfile.CreateDatStatic(
new Setup { DefaultScriptTable = TableDid }));
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Controller.ClearNetworkState();
Assert.Equal(0, fixture.Controller.PendingPacketCount);
Assert.Equal(1, fixture.Controller.ReadyOwnerCount);
Assert.True(fixture.Controller.PlayTyped(staticEntity.Id, RawType, 0.5f));
}
private static PhysicsScriptTable BuildTable(
uint tableDid,
uint rawType,
params (float Mod, uint ScriptDid)[] entries)
{
var data = new PhysicsScriptTableData();
foreach ((float mod, uint scriptDid) in entries)
{
data.Scripts.Add(new ScriptAndModData
{
Mod = mod,
ScriptId = scriptDid,
});
}
var table = new PhysicsScriptTable { Id = tableDid };
table.ScriptTable.Add(unchecked((PlayScript)rawType), data);
return table;
}
private static WorldEntity Entity(uint id, uint guid) => new()
{
Id = id,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = new Vector3(1, 2, 3),
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
private static WorldSession.EntitySpawn Spawn(uint guid, ushort generation)
{
var position = new CreateObject.ServerPosition(
0x01010001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
var timestamps = new PhysicsTimestamps(
1, 1, 1, 1, 0, 1, 0, 1, generation);
var physics = new PhysicsSpawnData(
RawState: (uint)PhysicsStateFlags.ReportCollisions,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: 0x09000001u,
SoundTableId: null,
PhysicsScriptTableId: TableDid,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: RawType,
DefaultScriptIntensity: 0.5f,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
guid,
position,
0x02000001u,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
"fixture",
null,
null,
0x09000001u,
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
InstanceSequence: generation,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
private static uint[] EmitterIds(RecordingSink sink) =>
sink.Calls
.Select(call => ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)
.ToArray();
}

View file

@ -15,6 +15,7 @@ public sealed class EntityEffectProfileTests
Assert.Equal(0x330000AAu, profile.SetupDefaultScriptDid);
Assert.Equal(0x340000BBu, profile.CurrentPhysicsScriptTableDid);
Assert.Equal(0x200000DDu, profile.CurrentSoundTableDid);
Assert.False(profile.HasNetworkDescription);
}
@ -27,6 +28,7 @@ public sealed class EntityEffectProfileTests
Assert.Equal(0x330000AAu, profile.SetupDefaultScriptDid);
Assert.Null(profile.CurrentPhysicsScriptTableDid);
Assert.Null(profile.CurrentSoundTableDid);
Assert.Equal(0u, profile.RawDefaultScriptType);
Assert.Equal(0f, profile.DefaultScriptIntensity);
Assert.True(profile.HasNetworkDescription);
@ -38,6 +40,7 @@ public sealed class EntityEffectProfileTests
PhysicsSpawnData physics = default(PhysicsSpawnData) with
{
PhysicsScriptTableId = 0x340000CCu,
SoundTableId = 0x200000EEu,
DefaultScriptType = 0xA5A5A5A5u,
DefaultScriptIntensity = 0.75f,
};
@ -47,6 +50,7 @@ public sealed class EntityEffectProfileTests
physics);
Assert.Equal(0x340000CCu, profile.CurrentPhysicsScriptTableDid);
Assert.Equal(0x200000EEu, profile.CurrentSoundTableDid);
Assert.Equal(0xA5A5A5A5u, profile.RawDefaultScriptType);
Assert.Equal(0.75f, profile.DefaultScriptIntensity);
}
@ -66,11 +70,13 @@ public sealed class EntityEffectProfileTests
profile.ApplyNetworkDescription(default(PhysicsSpawnData) with
{
PhysicsScriptTableId = 0u,
SoundTableId = 0u,
DefaultScriptType = 0u,
DefaultScriptIntensity = 0f,
});
Assert.Null(profile.CurrentPhysicsScriptTableDid);
Assert.Null(profile.CurrentSoundTableDid);
Assert.Equal(0u, profile.RawDefaultScriptType);
Assert.Equal(0f, profile.DefaultScriptIntensity);
}
@ -79,5 +85,6 @@ public sealed class EntityEffectProfileTests
{
DefaultScript = 0x330000AAu,
DefaultScriptTable = 0x340000BBu,
DefaultSoundTable = 0x200000DDu,
};
}

View file

@ -0,0 +1,378 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using DatReaderWriter.Types;
using Xunit;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.App.Tests.Rendering.Vfx;
public sealed class EntityScriptActivatorTests
{
/// <summary>Recording sink so we can assert which hooks the runner fires.</summary>
private sealed class RecordingSink : IAnimationHookSink
{
public List<(uint EntityId, Vector3 Pos, AnimationHook Hook)> Calls = new();
public void OnHook(uint entityId, Vector3 worldPos, AnimationHook hook)
=> Calls.Add((entityId, worldPos, hook));
}
private static DatPhysicsScript BuildScript(params (double time, AnimationHook hook)[] items)
{
var script = new DatPhysicsScript();
foreach (var (t, h) in items)
script.ScriptData.Add(new PhysicsScriptData { StartTime = t, Hook = h });
return script;
}
private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) =>
new()
{
Id = serverGuid,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = position,
Rotation = Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>(),
};
private record Pipeline(
ParticleSystem System,
ParticleHookSink Sink,
PhysicsScriptRunner Runner,
RecordingSink Recording);
private static Pipeline BuildPipeline(params (uint id, DatPhysicsScript script)[] scripts)
{
var registry = new EmitterDescRegistry();
var system = new ParticleSystem(registry);
var hookSink = new ParticleHookSink(system); // 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);
}
/// <summary>
/// Convenience: a resolver that always returns the given scriptId with
/// an empty part-transforms list (the C.1.5a-equivalent — no per-part
/// math). Useful for tests that exercise the scheduler without caring
/// about #56's per-part pipeline.
/// </summary>
private static System.Func<WorldEntity, ScriptActivationInfo?> StaticResolver(uint scriptId)
=> _ => new ScriptActivationInfo(scriptId, System.Array.Empty<Matrix4x4>());
[Fact]
public void OnCreate_WithDefaultScript_FiresRunnerWithEntityGuidAndPosition()
{
var p = BuildPipeline(
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 }))));
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
var entity = MakeEntity(serverGuid: 0xCAFEu, position: new Vector3(1, 2, 3));
activator.OnCreate(entity);
Assert.Equal(1, p.Runner.ActiveScriptCount);
p.Runner.Tick(0.001f);
Assert.Single(p.Recording.Calls);
Assert.Equal(0xCAFEu, p.Recording.Calls[0].EntityId);
Assert.Equal(new Vector3(1, 2, 3), p.Recording.Calls[0].Pos);
}
[Fact]
public void OnCreate_WithoutDefaultScript_DoesNothing()
{
var p = BuildPipeline(); // no scripts registered
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => null);
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
activator.OnCreate(entity);
Assert.Equal(0, p.Runner.ActiveScriptCount);
Assert.Empty(p.Recording.Calls);
}
/// <summary>
/// Persistent emitter: TotalDuration=0 and TotalParticles=0 prevent
/// auto-finish; InitialParticles=1 ensures a particle spawns at t=0
/// without waiting for the Birthrate timer; Lifespan=999f keeps that
/// particle alive far past the test horizon.
/// </summary>
private static EmitterDesc BuildPersistentEmitterDesc() => new()
{
DatId = 100u,
Type = ParticleType.Still,
EmitterKind = ParticleEmitterKind.BirthratePerSec,
MaxParticles = 4,
InitialParticles = 1,
TotalParticles = 0, // 0 = no particle-count cap
TotalDuration = 0f, // 0 = no time-based finish
Lifespan = 999f,
LifetimeMin = 999f,
LifetimeMax = 999f,
Birthrate = 0.5f,
StartSize = 0.5f,
EndSize = 0.5f,
StartAlpha = 1f,
EndAlpha = 1f,
};
[Fact]
public void OnCreate_SetsEntityRotationForHookOffsetTransform()
{
// 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
// 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.
var registry = new EmitterDescRegistry();
registry.Register(BuildPersistentEmitterDesc());
var system = new ParticleSystem(registry);
var hookSink = new ParticleHookSink(system);
// Hook offset = (1, 0, 0) in entity-local frame.
var hookOffset = new Frame
{
Origin = new Vector3(1f, 0f, 0f),
Orientation = Quaternion.Identity,
};
var script = BuildScript(
(0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = hookOffset }));
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));
// Entity rotated 90° around world-Z (yaw left); local +X maps to world +Y.
var entityRotation = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ, MathF.PI / 2f);
var entity = new WorldEntity
{
Id = 0xCAFEu,
ServerGuid = 0xCAFEu,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = entityRotation,
MeshRefs = System.Array.Empty<MeshRef>(),
};
activator.OnCreate(entity);
runner.Tick(0.001f);
system.Tick(0.001f);
// Find the live particle. With the rotation applied, world position of
// the local-(1,0,0) offset should be approximately world-(0,1,0). Without
// the rotation seed (the bug), it would be world-(1,0,0).
var live = system.EnumerateLive().FirstOrDefault();
Assert.NotNull(live.Emitter);
var worldPos = live.Emitter.Particles[live.Index].Position;
Assert.InRange(worldPos.X, -0.01f, 0.01f);
Assert.InRange(worldPos.Y, 0.99f, 1.01f);
}
[Fact]
public void OnRemove_StopsScriptsAndEmitters()
{
// For this test we need the runner to dispatch into the REAL
// ParticleHookSink so OnRemove's sink.StopAllForEntity has a live
// emitter to kill. This is the only observable way to verify the
// call had effect without subclassing the sealed sink.
var registry = new EmitterDescRegistry();
registry.Register(BuildPersistentEmitterDesc());
var system = new ParticleSystem(registry);
var hookSink = new ParticleHookSink(system);
var script = BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() }));
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 entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
activator.OnCreate(entity);
runner.Tick(0.001f); // fires the CreateParticleHook → spawns emitter
Assert.True(system.ActiveEmitterCount > 0,
"Setup precondition failed: emitter should be alive after the hook fires.");
activator.OnRemove(entity);
Assert.Equal(0, runner.ActiveScriptCount);
// sink.StopAllForEntity marks the emitter Finished; system.Tick reaps it.
system.Tick(0.01f);
Assert.Equal(0, system.ActiveEmitterCount);
}
[Fact]
public void OnCreate_UsesCanonicalStaticIdentity_WhenServerGuidZero()
{
// C.1.5b: dat-hydrated EnvCell statics + exterior stabs have
// ServerGuid == 0 but a stable entity.Id in the 0x40xxxxxx range.
// 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 entity = new WorldEntity
{
Id = 0x40A9B401u, // dat-hydrated interior id
ServerGuid = 0u, // no server guid
SourceGfxObjOrSetupId = 0x02000001u,
Position = new Vector3(5, 5, 5),
Rotation = Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>(),
};
activator.OnCreate(entity);
Assert.Equal(1, p.Runner.ActiveScriptCount);
p.Runner.Tick(0.001f);
Assert.Single(p.Recording.Calls);
Assert.Equal(entity.Id, p.Recording.Calls[0].EntityId);
Assert.Equal(new Vector3(5, 5, 5), p.Recording.Calls[0].Pos);
}
[Fact]
public void CollidingDatEntityIds_FailFastAtAllocatorBoundary()
{
var p = BuildPipeline(
(0xAAu, BuildScript((1.0, new CreateParticleHook { EmitterInfoId = 100 }))));
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
WorldEntity first = MakeDatStatic(0x80A9B400u, new Vector3(1, 0, 0));
WorldEntity second = MakeDatStatic(0x80A9B400u, new Vector3(2, 0, 0));
activator.OnCreate(first);
InvalidOperationException error = Assert.Throws<InvalidOperationException>(
() => activator.OnCreate(second));
Assert.Contains("globally unique", error.Message, StringComparison.Ordinal);
Assert.Equal(1, p.Runner.ActiveOwnerCount);
Assert.Equal(first.Id, GameWindow.ParticleEntityKey(first));
}
[Fact]
public void LiveParticleFilterUsesCanonicalLocalEffectOwnerNotServerGuid()
{
var p = BuildPipeline();
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => null);
var entity = new WorldEntity
{
Id = 1_000_123u,
ServerGuid = 0x70000001u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
activator.OnCreate(entity);
Assert.Equal(entity.Id, GameWindow.ParticleEntityKey(entity));
Assert.NotEqual(entity.ServerGuid, GameWindow.ParticleEntityKey(entity));
}
[Fact]
public void OnCreate_PassesPartTransformsToSink()
{
// C.1.5b #56: end-to-end test that the activator pushes the
// resolver's PartTransforms into the sink, and the sink applies
// them. Part 1 lifted +Z=1; hookOffset (1,0,0) with PartIndex=1
// + identity rotation → expected world (1, 0, 1).
var registry = new EmitterDescRegistry();
registry.Register(BuildPersistentEmitterDesc());
var system = new ParticleSystem(registry);
var hookSink = new ParticleHookSink(system);
var hookOffset = new Frame { Origin = new Vector3(1f, 0, 0), Orientation = Quaternion.Identity };
var script = BuildScript(
(0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = hookOffset, PartIndex = 1 }));
var table = new Dictionary<uint, DatPhysicsScript> { [0xAAu] = script };
var runner = new PhysicsScriptRunner(
id => table.TryGetValue(id, out var s) ? s : null,
hookSink);
var partTransforms = new Matrix4x4[]
{
Matrix4x4.Identity,
Matrix4x4.CreateTranslation(0f, 0f, 1f),
};
var activator = new EntityScriptActivator(runner, hookSink,
_ => new ScriptActivationInfo(0xAAu, partTransforms));
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
activator.OnCreate(entity);
runner.Tick(0.001f);
system.Tick(0.001f);
var live = system.EnumerateLive().FirstOrDefault();
Assert.NotNull(live.Emitter);
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);
}
[Fact]
public void OnRemove_StopsReservedOwner_ForDatHydratedEntity()
{
// C.1.5b: caller passes the entity.Id as the key for dat-hydrated
// entities (not ServerGuid). OnRemove must clean up correctly.
var registry = new EmitterDescRegistry();
registry.Register(BuildPersistentEmitterDesc());
var system = new ParticleSystem(registry);
var hookSink = new ParticleHookSink(system);
var script = BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() }));
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 entity = new WorldEntity
{
Id = 0x40A9B402u,
ServerGuid = 0u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>(),
};
activator.OnCreate(entity);
runner.Tick(0.001f);
Assert.True(system.ActiveEmitterCount > 0);
activator.OnRemove(entity);
Assert.Equal(0, runner.ActiveScriptCount);
system.Tick(0.01f);
Assert.Equal(0, system.ActiveEmitterCount);
}
private static WorldEntity MakeDatStatic(uint id, Vector3 position) => new()
{
Id = id,
ServerGuid = 0,
SourceGfxObjOrSetupId = 0x02000001u,
Position = position,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
}