feat(vfx): port retail effect scheduling and delivery
This commit is contained in:
parent
363e046112
commit
96ddfdf175
28 changed files with 2473 additions and 691 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Vfx;
|
||||
|
|
@ -8,7 +9,7 @@ using DatReaderWriter.Types;
|
|||
using Xunit;
|
||||
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering.Vfx;
|
||||
namespace AcDream.App.Tests.Rendering.Vfx;
|
||||
|
||||
public sealed class EntityScriptActivatorTests
|
||||
{
|
||||
|
|
@ -209,7 +210,7 @@ public sealed class EntityScriptActivatorTests
|
|||
Assert.True(system.ActiveEmitterCount > 0,
|
||||
"Setup precondition failed: emitter should be alive after the hook fires.");
|
||||
|
||||
activator.OnRemove(0xCAFEu);
|
||||
activator.OnRemove(entity);
|
||||
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
// sink.StopAllForEntity marks the emitter Finished; system.Tick reaps it.
|
||||
|
|
@ -218,7 +219,7 @@ public sealed class EntityScriptActivatorTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_KeysByEntityId_WhenServerGuidZero()
|
||||
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.
|
||||
|
|
@ -241,10 +242,49 @@ public sealed class EntityScriptActivatorTests
|
|||
Assert.Equal(1, p.Runner.ActiveScriptCount);
|
||||
p.Runner.Tick(0.001f);
|
||||
Assert.Single(p.Recording.Calls);
|
||||
Assert.Equal(0x40A9B401u, p.Recording.Calls[0].EntityId);
|
||||
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()
|
||||
{
|
||||
|
|
@ -288,7 +328,7 @@ public sealed class EntityScriptActivatorTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void OnRemove_StopsByGivenKey_ForDatHydratedEntity()
|
||||
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.
|
||||
|
|
@ -318,48 +358,21 @@ public sealed class EntityScriptActivatorTests
|
|||
runner.Tick(0.001f);
|
||||
Assert.True(system.ActiveEmitterCount > 0);
|
||||
|
||||
activator.OnRemove(0x40A9B402u); // caller passes the entity.Id key
|
||||
activator.OnRemove(entity);
|
||||
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
system.Tick(0.01f);
|
||||
Assert.Equal(0, system.ActiveEmitterCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnRemoveLegacyOwner_StopsPreMaterializationF754ServerGuidAlias()
|
||||
private static WorldEntity MakeDatStatic(uint id, Vector3 position) => new()
|
||||
{
|
||||
const uint serverGuid = 0x7000CAFEu;
|
||||
const uint localId = 0x00100001u;
|
||||
var script = BuildScript((60.0, new CreateParticleHook { EmitterInfoId = 100u }));
|
||||
var p = BuildPipeline((0xAAu, script));
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
|
||||
Id = id,
|
||||
ServerGuid = 0,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = position,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
// This is the current AD-32 pre-materialization F754 path: no local ID
|
||||
// exists yet, so the direct PES is temporarily owned by server GUID.
|
||||
Assert.True(activator.PlayLegacyPending(serverGuid, 0xAAu, Vector3.Zero));
|
||||
Assert.Equal(1, p.Runner.ActiveScriptCount);
|
||||
Assert.Equal(1, activator.LegacyPendingOwnerCount);
|
||||
|
||||
activator.OnRemoveLegacyOwner(serverGuid, localId);
|
||||
|
||||
Assert.Equal(0, p.Runner.ActiveScriptCount);
|
||||
Assert.Equal(0, activator.LegacyPendingOwnerCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearLegacyPendingOwners_StopsF754AliasesWithoutLiveRecords()
|
||||
{
|
||||
var script = BuildScript((60.0, new CreateParticleHook { EmitterInfoId = 100u }));
|
||||
var p = BuildPipeline((0xAAu, script));
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
|
||||
Assert.True(activator.PlayLegacyPending(0x70000001u, 0xAAu, Vector3.Zero));
|
||||
Assert.True(activator.PlayLegacyPending(0x70000002u, 0xAAu, Vector3.Zero));
|
||||
Assert.Equal(2, p.Runner.ActiveScriptCount);
|
||||
Assert.Equal(2, activator.LegacyPendingOwnerCount);
|
||||
|
||||
activator.ClearLegacyPendingOwners();
|
||||
|
||||
Assert.Equal(0, p.Runner.ActiveScriptCount);
|
||||
Assert.Equal(0, activator.LegacyPendingOwnerCount);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ using DatReaderWriter.Types;
|
|||
using Xunit;
|
||||
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
|
||||
|
||||
namespace AcDream.Core.Tests.Streaming;
|
||||
namespace AcDream.App.Tests.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Phase C.1.5b: verifies <see cref="GpuWorldState"/> fires
|
||||
|
|
@ -94,7 +94,7 @@ public sealed class GpuWorldStateActivatorTests
|
|||
// Tick fires the CreateParticleHook into RecordingSink.
|
||||
p.Runner.Tick(0.001f);
|
||||
Assert.Single(p.Recording.Calls);
|
||||
Assert.Equal(0x40A9B401u, p.Recording.Calls[0].EntityId);
|
||||
Assert.Equal(entity.Id, p.Recording.Calls[0].EntityId);
|
||||
Assert.Equal(new Vector3(1, 2, 3), p.Recording.Calls[0].Pos);
|
||||
}
|
||||
|
||||
|
|
@ -460,6 +460,46 @@ public sealed class LiveEntityRuntimeTests
|
|||
Assert.Equal(0, resources.UnregisterCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RootRuntimePausesWhilePendingOrFrozenAndResumesInVisibleCell()
|
||||
{
|
||||
const uint guid = 0x70000027u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, guid));
|
||||
|
||||
Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
|
||||
Assert.True(runtime.RebucketLiveEntity(guid, 0x02020001u));
|
||||
Assert.False(runtime.ShouldAdvanceRootRuntime(guid));
|
||||
|
||||
spatial.AddLandblock(EmptyLandblock(0x0202FFFFu));
|
||||
Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
|
||||
|
||||
Assert.True(runtime.TryApplyState(
|
||||
new SetState.Parsed(
|
||||
guid,
|
||||
(uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Frozen),
|
||||
1,
|
||||
2),
|
||||
out _));
|
||||
Assert.False(runtime.ShouldAdvanceRootRuntime(guid));
|
||||
|
||||
Assert.True(runtime.TryApplyState(
|
||||
new SetState.Parsed(
|
||||
guid,
|
||||
(uint)PhysicsStateFlags.ReportCollisions,
|
||||
1,
|
||||
3),
|
||||
out _));
|
||||
Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LandblockUnload_HidesTopLevelViewAndReloadRestoresSameIdentity()
|
||||
{
|
||||
|
|
@ -701,109 +741,6 @@ public sealed class LiveEntityRuntimeTests
|
|||
firstLocalEntityId: uint.MaxValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_StopsScriptsOwnedByCanonicalLocalId_NotServerGuid()
|
||||
{
|
||||
const uint serverGuid = 0x70000013u;
|
||||
const uint scriptId = 0x33000001u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
|
||||
var script = new DatPhysicsScript();
|
||||
script.ScriptData.Add(new PhysicsScriptData
|
||||
{
|
||||
StartTime = 60d,
|
||||
Hook = new CreateParticleHook { EmitterInfoId = 0x32000001u },
|
||||
});
|
||||
var particleSystem = new ParticleSystem(new EmitterDescRegistry());
|
||||
var particleSink = new ParticleHookSink(particleSystem);
|
||||
var runner = new PhysicsScriptRunner(
|
||||
id => id == scriptId ? script : null,
|
||||
particleSink);
|
||||
var activator = new EntityScriptActivator(
|
||||
runner,
|
||||
particleSink,
|
||||
_ => new ScriptActivationInfo(scriptId, Array.Empty<System.Numerics.Matrix4x4>()));
|
||||
var runtime = new LiveEntityRuntime(
|
||||
spatial,
|
||||
new DelegateLiveEntityResourceLifecycle(
|
||||
activator.OnCreate,
|
||||
entity => activator.OnRemove(entity.Id)),
|
||||
record => activator.OnRemoveLegacyOwner(
|
||||
record.ServerGuid,
|
||||
record.LocalEntityId ?? 0u));
|
||||
WorldSession.EntitySpawn spawn = Spawn(serverGuid, 7, 1, 0x01010001u);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
|
||||
// Current AD-32 behavior before Step 4's pending FIFO: an F754 that
|
||||
// precedes materialization temporarily starts under server GUID.
|
||||
Assert.True(activator.PlayLegacyPending(
|
||||
serverGuid,
|
||||
scriptId,
|
||||
System.Numerics.Vector3.Zero));
|
||||
Assert.Equal(1, runner.ActiveScriptCount);
|
||||
|
||||
WorldEntity entity = runtime.MaterializeLiveEntity(
|
||||
serverGuid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, serverGuid))!;
|
||||
|
||||
Assert.NotEqual(serverGuid, entity.Id);
|
||||
Assert.Equal(2, runner.ActiveScriptCount);
|
||||
|
||||
Assert.True(runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(serverGuid, spawn.InstanceSequence),
|
||||
isLocalPlayer: false));
|
||||
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleDelete_DoesNotStopCurrentGenerationPreMaterializationF754Alias()
|
||||
{
|
||||
const uint serverGuid = 0x70000027u;
|
||||
const uint scriptId = 0x33000001u;
|
||||
var script = new DatPhysicsScript();
|
||||
script.ScriptData.Add(new PhysicsScriptData
|
||||
{
|
||||
StartTime = 60d,
|
||||
Hook = new CreateParticleHook { EmitterInfoId = 0x32000001u },
|
||||
});
|
||||
var particleSink = new ParticleHookSink(
|
||||
new ParticleSystem(new EmitterDescRegistry()));
|
||||
var runner = new PhysicsScriptRunner(
|
||||
id => id == scriptId ? script : null,
|
||||
particleSink);
|
||||
var activator = new EntityScriptActivator(
|
||||
runner,
|
||||
particleSink,
|
||||
_ => null);
|
||||
var runtime = new LiveEntityRuntime(
|
||||
new GpuWorldState(),
|
||||
new RecordingResources(),
|
||||
record => activator.OnRemoveLegacyOwner(
|
||||
record.ServerGuid,
|
||||
record.LocalEntityId ?? 0u));
|
||||
WorldSession.EntitySpawn spawn = Spawn(serverGuid, 10, 1, 0x01010001u);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
Assert.True(activator.PlayLegacyPending(
|
||||
serverGuid,
|
||||
scriptId,
|
||||
System.Numerics.Vector3.Zero));
|
||||
|
||||
Assert.False(runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(serverGuid, 9),
|
||||
isLocalPlayer: false));
|
||||
Assert.Equal(1, runner.ActiveScriptCount);
|
||||
Assert.Equal(1, activator.LegacyPendingOwnerCount);
|
||||
|
||||
Assert.True(runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(serverGuid, 10),
|
||||
isLocalPlayer: false));
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
Assert.Equal(0, activator.LegacyPendingOwnerCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_CleansLogicalRecordEvenWhenPreTeardownCallbackFails()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -18,9 +18,15 @@ public sealed class RetailDatLoaderTests
|
|||
private sealed class RawDatabase : IDatDatabase
|
||||
{
|
||||
private readonly Dictionary<uint, byte[]> _entries = new();
|
||||
private int _activeReads;
|
||||
private int _maxConcurrentReads;
|
||||
private int _totalReads;
|
||||
|
||||
public DatDatabase Db => null!;
|
||||
public int Iteration => 0;
|
||||
public int ReadDelayMilliseconds { get; set; }
|
||||
public int MaxConcurrentReads => Volatile.Read(ref _maxConcurrentReads);
|
||||
public int TotalReads => Volatile.Read(ref _totalReads);
|
||||
public void Add(uint id, byte[] bytes) => _entries[id] = bytes;
|
||||
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj => _entries.Keys;
|
||||
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj
|
||||
|
|
@ -28,8 +34,28 @@ public sealed class RetailDatLoaderTests
|
|||
value = default;
|
||||
return false;
|
||||
}
|
||||
public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) =>
|
||||
_entries.TryGetValue(fileId, out value);
|
||||
public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value)
|
||||
{
|
||||
Interlocked.Increment(ref _totalReads);
|
||||
int active = Interlocked.Increment(ref _activeReads);
|
||||
int observed;
|
||||
do
|
||||
{
|
||||
observed = Volatile.Read(ref _maxConcurrentReads);
|
||||
}
|
||||
while (active > observed
|
||||
&& Interlocked.CompareExchange(ref _maxConcurrentReads, active, observed) != observed);
|
||||
try
|
||||
{
|
||||
if (ReadDelayMilliseconds > 0)
|
||||
Thread.Sleep(ReadDelayMilliseconds);
|
||||
return _entries.TryGetValue(fileId, out value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref _activeReads);
|
||||
}
|
||||
}
|
||||
public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead)
|
||||
{
|
||||
if (!_entries.TryGetValue(fileId, out byte[]? value))
|
||||
|
|
@ -176,6 +202,47 @@ public sealed class RetailDatLoaderTests
|
|||
Assert.Throws<InvalidDataException>(() => animations.LoadAnimation(wrongAnimationKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PhysicsScriptLoader_AllowsConcurrentFirstReads()
|
||||
{
|
||||
const uint firstDid = 0x33010010u;
|
||||
const uint secondDid = 0x33010011u;
|
||||
var portal = new RawDatabase { ReadDelayMilliseconds = 40 };
|
||||
byte[] first = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
|
||||
byte[] second = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(first, firstDid);
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(second, secondDid);
|
||||
portal.Add(firstDid, first);
|
||||
portal.Add(secondDid, second);
|
||||
var loader = new RetailPhysicsScriptLoader(portal);
|
||||
|
||||
await Task.WhenAll(
|
||||
Task.Run(() => loader.LoadPhysicsScript(firstDid)),
|
||||
Task.Run(() => loader.LoadPhysicsScript(secondDid)));
|
||||
|
||||
Assert.Equal(2, portal.MaxConcurrentReads);
|
||||
Assert.Equal(2, portal.TotalReads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PhysicsScriptLoader_CoalescesConcurrentReadsForTheSameDid()
|
||||
{
|
||||
const uint scriptDid = 0x33010012u;
|
||||
var portal = new RawDatabase { ReadDelayMilliseconds = 40 };
|
||||
byte[] bytes = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(bytes, scriptDid);
|
||||
portal.Add(scriptDid, bytes);
|
||||
var loader = new RetailPhysicsScriptLoader(portal);
|
||||
|
||||
DatPhysicsScript?[] loaded = await Task.WhenAll(
|
||||
Task.Run(() => loader.LoadPhysicsScript(scriptDid)),
|
||||
Task.Run(() => loader.LoadPhysicsScript(scriptDid)));
|
||||
|
||||
Assert.All(loaded, Assert.NotNull);
|
||||
Assert.Same(loaded[0], loaded[1]);
|
||||
Assert.Equal(1, portal.TotalReads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parsers_RejectTrailingOrTruncatedEntries()
|
||||
{
|
||||
|
|
@ -192,6 +259,17 @@ public sealed class RetailDatLoaderTests
|
|||
RetailAnimationLoader.Parse(animation.Concat(new byte[] { 0 }).ToArray()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhysicsScriptParserRejectsNonFiniteHookTime()
|
||||
{
|
||||
byte[] script = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteInt64LittleEndian(
|
||||
script.AsSpan(8, 8),
|
||||
BitConverter.DoubleToInt64Bits(double.NaN));
|
||||
|
||||
Assert.Throws<InvalidDataException>(() => RetailPhysicsScriptLoader.Parse(script));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parsers_RejectUnsupportedHookTypesWithoutReturningPartialObjects()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,234 +1,517 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Vfx;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
|
||||
|
||||
namespace AcDream.Core.Tests.Vfx;
|
||||
|
||||
public sealed class PhysicsScriptRunnerTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Recording sink so tests can assert each hook dispatch.
|
||||
/// </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));
|
||||
public List<(uint EntityId, Vector3 Position, AnimationHook Hook)> Calls { get; } = new();
|
||||
|
||||
public Action<uint, Vector3, AnimationHook>? Callback { get; init; }
|
||||
|
||||
public void OnHook(uint entityId, Vector3 worldPosition, AnimationHook hook)
|
||||
{
|
||||
Calls.Add((entityId, worldPosition, hook));
|
||||
Callback?.Invoke(entityId, worldPosition, hook);
|
||||
}
|
||||
}
|
||||
|
||||
private static DatPhysicsScript BuildScript(params (double time, AnimationHook hook)[] items)
|
||||
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 });
|
||||
foreach ((double time, AnimationHook hook) in items)
|
||||
script.ScriptData.Add(new PhysicsScriptData { StartTime = time, Hook = hook });
|
||||
return script;
|
||||
}
|
||||
|
||||
private static CreateParticleHook CreateHook(uint emitterInfoId)
|
||||
=> new CreateParticleHook { EmitterInfoId = emitterInfoId };
|
||||
private static CreateParticleHook CreateHook(uint emitterInfoId) =>
|
||||
new() { EmitterInfoId = emitterInfoId };
|
||||
|
||||
private static PhysicsScriptRunner MakeRunner(RecordingSink sink, params (uint id, DatPhysicsScript script)[] scripts)
|
||||
private static PhysicsScriptRunner MakeRunner(
|
||||
IAnimationHookSink sink,
|
||||
Func<double>? clock = null,
|
||||
Func<double>? randomUnit = null,
|
||||
Func<uint, bool>? canAdvanceOwner = null,
|
||||
params (uint Id, DatPhysicsScript Script)[] scripts)
|
||||
{
|
||||
// Build an in-memory resolver from the script table — no DatCollection needed.
|
||||
var table = new Dictionary<uint, DatPhysicsScript>();
|
||||
foreach (var (id, s) in scripts) table[id] = s;
|
||||
var table = scripts.ToDictionary(entry => entry.Id, entry => entry.Script);
|
||||
return new PhysicsScriptRunner(
|
||||
id => table.TryGetValue(id, out var s) ? s : null,
|
||||
sink);
|
||||
id => table.TryGetValue(id, out DatPhysicsScript? script) ? script : null,
|
||||
sink,
|
||||
clock,
|
||||
randomUnit,
|
||||
canAdvanceOwner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Play_UnknownScript_ReturnsFalse()
|
||||
public void PlayDirect_MissingOrZeroInputReturnsFalseWithoutQueueCorruption()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink); // no scripts registered
|
||||
Assert.False(runner.Play(0xDEADBEEF, entityId: 1, anchorWorldPos: Vector3.Zero));
|
||||
var runner = MakeRunner(sink);
|
||||
|
||||
Assert.False(runner.PlayDirect(1u, 0u));
|
||||
Assert.False(runner.PlayDirect(0u, 0x33000001u));
|
||||
Assert.False(runner.PlayDirect(1u, 0x33000001u));
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
Assert.Empty(sink.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Play_ZeroScriptId_IgnoredSilently()
|
||||
public void HooksFireInOrderAtAbsoluteScheduledTimes()
|
||||
{
|
||||
DatPhysicsScript script = BuildScript(
|
||||
(0.0, CreateHook(100)),
|
||||
(0.5, CreateHook(101)),
|
||||
(1.0, CreateHook(102)));
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink);
|
||||
Assert.False(runner.Play(0, entityId: 1, anchorWorldPos: Vector3.Zero));
|
||||
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
|
||||
runner.SetOwnerAnchor(7u, new Vector3(1, 2, 3));
|
||||
|
||||
Assert.True(runner.PlayDirect(7u, 0x330000AAu));
|
||||
runner.Tick(0.25);
|
||||
runner.Tick(0.60);
|
||||
runner.Tick(1.50);
|
||||
|
||||
Assert.Equal([100u, 101u, 102u],
|
||||
sink.Calls.Select(call => ((CreateParticleHook)call.Hook).EmitterInfoId.DataId));
|
||||
Assert.All(sink.Calls, call => Assert.Equal(new Vector3(1, 2, 3), call.Position));
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HooksFire_InOrder_AtScheduledTimes()
|
||||
public void DuplicatePlaysAppendSeriallyToOneOwner()
|
||||
{
|
||||
var script = BuildScript(
|
||||
(0.0, CreateHook(100)),
|
||||
(0.5, CreateHook(101)),
|
||||
(1.0, CreateHook(102)));
|
||||
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, (0xAAu, script));
|
||||
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: new Vector3(1, 2, 3));
|
||||
|
||||
runner.Tick(0.25f);
|
||||
Assert.Single(sink.Calls);
|
||||
Assert.Equal(100u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId);
|
||||
|
||||
runner.Tick(0.35f); // total 0.6
|
||||
Assert.Equal(2, sink.Calls.Count);
|
||||
Assert.Equal(101u, ((CreateParticleHook)sink.Calls[1].Hook).EmitterInfoId.DataId);
|
||||
|
||||
runner.Tick(0.9f); // total 1.5
|
||||
Assert.Equal(3, sink.Calls.Count);
|
||||
Assert.Equal(102u, ((CreateParticleHook)sink.Calls[2].Hook).EmitterInfoId.DataId);
|
||||
Assert.Equal(0, runner.ActiveScriptCount); // fully consumed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntityIdAndAnchor_ArePassedThrough()
|
||||
{
|
||||
var script = BuildScript((0.0, CreateHook(1)));
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, (0xAAu, script));
|
||||
|
||||
var anchor = new Vector3(123, 45, 67);
|
||||
runner.Play(scriptId: 0xAA, entityId: 0xCAFE, anchorWorldPos: anchor);
|
||||
runner.Tick(0.1f);
|
||||
|
||||
Assert.Single(sink.Calls);
|
||||
Assert.Equal(0xCAFEu, sink.Calls[0].EntityId);
|
||||
Assert.Equal(anchor, sink.Calls[0].Pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replay_SameScriptSameEntity_Replaces_DoesNotStack()
|
||||
{
|
||||
var script = BuildScript(
|
||||
DatPhysicsScript script = BuildScript(
|
||||
(0.0, CreateHook(1)),
|
||||
(1.0, CreateHook(2)));
|
||||
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, (0xAAu, script));
|
||||
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
|
||||
|
||||
runner.Play(scriptId: 0xAA, entityId: 1, anchorWorldPos: Vector3.Zero);
|
||||
runner.Tick(0.1f);
|
||||
Assert.Single(sink.Calls);
|
||||
|
||||
// Re-play — the old instance should be replaced, not stacked.
|
||||
runner.Play(scriptId: 0xAA, entityId: 1, anchorWorldPos: Vector3.Zero);
|
||||
Assert.Equal(1, runner.ActiveScriptCount);
|
||||
runner.Tick(0.1f);
|
||||
Assert.Equal(2, sink.Calls.Count);
|
||||
// Hook 0 fires AGAIN (fresh timeline from t=0), not hook 1.
|
||||
Assert.Equal(1u, ((CreateParticleHook)sink.Calls[1].Hook).EmitterInfoId.DataId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replay_DifferentEntities_BothActiveConcurrently()
|
||||
{
|
||||
var script = BuildScript((0.0, CreateHook(42)));
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, (0xAAu, script));
|
||||
|
||||
runner.Play(scriptId: 0xAA, entityId: 0x1, anchorWorldPos: new Vector3(1, 0, 0));
|
||||
runner.Play(scriptId: 0xAA, entityId: 0x2, anchorWorldPos: new Vector3(2, 0, 0));
|
||||
Assert.True(runner.PlayDirect(1u, 0x330000AAu));
|
||||
Assert.True(runner.PlayDirect(1u, 0x330000AAu));
|
||||
Assert.Equal(2, runner.ActiveScriptCount);
|
||||
Assert.Equal(1, runner.ActiveOwnerCount);
|
||||
|
||||
runner.Tick(0.1f);
|
||||
Assert.Equal(2, sink.Calls.Count);
|
||||
Assert.Contains(sink.Calls, c => c.EntityId == 1u);
|
||||
Assert.Contains(sink.Calls, c => c.EntityId == 2u);
|
||||
runner.Tick(0.0);
|
||||
Assert.Equal([1u], EmitterIds(sink));
|
||||
|
||||
// At exactly one second the first tail and the second head are both due.
|
||||
runner.Tick(1.0);
|
||||
Assert.Equal([1u, 2u, 1u], EmitterIds(sink));
|
||||
|
||||
runner.Tick(2.0);
|
||||
Assert.Equal([1u, 2u, 1u, 2u], EmitterIds(sink));
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopAllForEntity_CancelsEntityScripts_LeavesOthers()
|
||||
public void DifferentOwnersProgressIndependently()
|
||||
{
|
||||
var script = BuildScript(
|
||||
DatPhysicsScript script = BuildScript(
|
||||
(0.0, CreateHook(1)),
|
||||
(1.0, CreateHook(2)));
|
||||
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, (0xAAu, script));
|
||||
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
|
||||
|
||||
runner.Play(scriptId: 0xAA, entityId: 1, anchorWorldPos: Vector3.Zero);
|
||||
runner.Play(scriptId: 0xAA, entityId: 2, anchorWorldPos: Vector3.Zero);
|
||||
runner.Tick(0.1f); // both fire hook 0
|
||||
Assert.Equal(2, sink.Calls.Count);
|
||||
runner.PlayDirect(1u, 0x330000AAu);
|
||||
runner.Tick(0.5);
|
||||
runner.PlayDirect(2u, 0x330000AAu);
|
||||
runner.Tick(1.0);
|
||||
|
||||
runner.StopAllForEntity(1);
|
||||
Assert.Equal([(1u, 1u), (1u, 2u), (2u, 1u)],
|
||||
sink.Calls.Select(call =>
|
||||
(call.EntityId, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)));
|
||||
Assert.Equal(1, runner.ActiveScriptCount);
|
||||
runner.Tick(2.0f); // only entity 2's script should fire hook 1
|
||||
Assert.Equal(3, sink.Calls.Count);
|
||||
Assert.Equal(2u, sink.Calls[^1].EntityId);
|
||||
Assert.Equal(1, runner.ActiveOwnerCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallPES_NestedScript_SpawnsOnSameEntity()
|
||||
public void CatchUpTickDrainsEveryDueHookAndQueuedScript()
|
||||
{
|
||||
var outer = BuildScript((0.0, new CallPESHook { PES = 0xBB, Pause = 0f }));
|
||||
var inner = BuildScript((0.0, CreateHook(99)));
|
||||
|
||||
DatPhysicsScript script = BuildScript(
|
||||
(0.0, CreateHook(1)),
|
||||
(0.25, CreateHook(2)),
|
||||
(0.5, CreateHook(3)));
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, (0xAAu, outer), (0xBBu, inner));
|
||||
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: new Vector3(1, 2, 3));
|
||||
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
|
||||
runner.PlayDirect(1u, 0x330000AAu);
|
||||
runner.PlayDirect(1u, 0x330000AAu);
|
||||
|
||||
// First tick fires the CallPES hook. Inner script gets queued to
|
||||
// _active but does NOT fire this tick (we iterate _active
|
||||
// backwards, and the inner is appended AFTER the current index) —
|
||||
// matches retail's linked-list insertion semantics. Inner fires
|
||||
// on the NEXT tick instead.
|
||||
runner.Tick(0.1f);
|
||||
Assert.Empty(sink.Calls); // CallPES handled inline, no direct sink hit
|
||||
Assert.Equal(1, runner.ActiveScriptCount); // inner is queued, outer done
|
||||
runner.Tick(5.0);
|
||||
|
||||
// Second tick — inner's hook at t=0 fires now.
|
||||
runner.Tick(0.1f);
|
||||
Assert.Single(sink.Calls);
|
||||
Assert.Equal(99u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId);
|
||||
Assert.Equal(0x7u, sink.Calls[0].EntityId);
|
||||
Assert.Equal([1u, 2u, 3u, 1u, 2u, 3u], EmitterIds(sink));
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallPES_WithPause_DelaysSubScript()
|
||||
public void HookMayAppendToCurrentOwnerWithoutInvalidatingCatchUp()
|
||||
{
|
||||
var outer = BuildScript((0.0, new CallPESHook { PES = 0xBB, Pause = 0.5f }));
|
||||
var inner = BuildScript((0.0, CreateHook(99)));
|
||||
DatPhysicsScript outer = BuildScript((0.0, CreateHook(1)));
|
||||
DatPhysicsScript nested = BuildScript((0.0, CreateHook(2)));
|
||||
PhysicsScriptRunner? runner = null;
|
||||
var sink = new RecordingSink
|
||||
{
|
||||
Callback = (owner, _, hook) =>
|
||||
{
|
||||
if (((CreateParticleHook)hook).EmitterInfoId.DataId == 1u)
|
||||
runner!.PlayDirect(owner, 0x330000BBu);
|
||||
},
|
||||
};
|
||||
runner = MakeRunner(
|
||||
sink,
|
||||
scripts: [(0x330000AAu, outer), (0x330000BBu, nested)]);
|
||||
runner.PlayDirect(1u, 0x330000AAu);
|
||||
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, (0xAAu, outer), (0xBBu, inner));
|
||||
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: Vector3.Zero);
|
||||
runner.Tick(0.0);
|
||||
|
||||
// CallPES fires immediately, but inner script's hook is gated by Pause.
|
||||
runner.Tick(0.1f);
|
||||
Assert.Empty(sink.Calls); // inner hook waiting on Pause=0.5s
|
||||
|
||||
runner.Tick(0.5f); // total 0.6 > 0.5 pause
|
||||
Assert.Single(sink.Calls);
|
||||
Assert.Equal([1u, 2u], EmitterIds(sink));
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallPES_SelfLoopWithPause_DoesNotReplaceCurrentInstance()
|
||||
public void HookMayDeleteItsOwnerWithoutDispatchingDetachedTail()
|
||||
{
|
||||
var script = BuildScript(
|
||||
(0.0, new CallPESHook { PES = 0xAA, Pause = 30f }),
|
||||
(0.0, CreateHook(123)));
|
||||
DatPhysicsScript script = BuildScript(
|
||||
(0.0, CreateHook(1)),
|
||||
(0.0, CreateHook(2)));
|
||||
PhysicsScriptRunner? runner = null;
|
||||
var sink = new RecordingSink
|
||||
{
|
||||
Callback = (owner, _, hook) =>
|
||||
{
|
||||
if (((CreateParticleHook)hook).EmitterInfoId.DataId == 1u)
|
||||
runner!.StopAllForEntity(owner);
|
||||
},
|
||||
};
|
||||
runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
|
||||
runner.PlayDirect(1u, 0x330000AAu);
|
||||
|
||||
runner.Tick(0.0);
|
||||
|
||||
Assert.Equal([1u], EmitterIds(sink));
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScheduleCallPesUsesInjectedUniformDelayAndNearZeroAppendsImmediately()
|
||||
{
|
||||
DatPhysicsScript script = BuildScript((0.0, CreateHook(9)));
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, (0xAAu, script));
|
||||
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: Vector3.Zero);
|
||||
var runner = MakeRunner(
|
||||
sink,
|
||||
randomUnit: () => 0.5,
|
||||
scripts: [(0x330000AAu, script)]);
|
||||
|
||||
runner.Tick(0.1f);
|
||||
Assert.True(runner.ScheduleCallPes(1u, 0x330000AAu, 2f));
|
||||
Assert.Equal(1, runner.ScheduledCallPesCount);
|
||||
runner.Tick(0.999);
|
||||
Assert.Empty(sink.Calls);
|
||||
runner.Tick(1.0);
|
||||
Assert.Equal([9u], EmitterIds(sink));
|
||||
|
||||
Assert.Single(sink.Calls);
|
||||
Assert.Equal(123u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId);
|
||||
Assert.True(runner.ScheduleCallPes(
|
||||
1u,
|
||||
0x330000AAu,
|
||||
PhysicsScriptRunner.ImmediateCallPesThresholdSeconds / 2f));
|
||||
Assert.Equal(1, runner.ActiveScriptCount);
|
||||
|
||||
runner.Tick(29.8f);
|
||||
Assert.Single(sink.Calls);
|
||||
|
||||
runner.Tick(0.3f);
|
||||
Assert.Equal(2, sink.Calls.Count);
|
||||
runner.Tick(1.0);
|
||||
Assert.Equal([9u, 9u], EmitterIds(sink));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishedAnimationPhaseRunsImmediateScriptButDefersTimedCallOnePass()
|
||||
{
|
||||
DatPhysicsScript script = BuildScript((0.0, CreateHook(9)));
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(
|
||||
sink,
|
||||
randomUnit: () => 0.0,
|
||||
scripts: [(0x330000AAu, script)]);
|
||||
|
||||
runner.PublishTime(1.0);
|
||||
Assert.True(runner.ScheduleCallPes(1u, 0x330000AAu, 0.5f));
|
||||
Assert.True(runner.ScheduleCallPes(
|
||||
2u,
|
||||
0x330000AAu,
|
||||
PhysicsScriptRunner.ImmediateCallPesThresholdSeconds / 2f));
|
||||
runner.Tick(1.0);
|
||||
Assert.Equal([(2u, 9u)],
|
||||
sink.Calls.Select(call =>
|
||||
(call.EntityId, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)));
|
||||
|
||||
runner.PublishTime(1.1);
|
||||
runner.Tick(1.1);
|
||||
|
||||
Assert.Equal([(2u, 9u), (1u, 9u)],
|
||||
sink.Calls.Select(call =>
|
||||
(call.EntityId, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePhasePlayUsesPublishedCurrentClockForPositiveHookOffset()
|
||||
{
|
||||
DatPhysicsScript script = BuildScript((0.1, CreateHook(9)));
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
|
||||
|
||||
runner.PublishTime(1.0);
|
||||
Assert.True(runner.PlayDirect(1u, 0x330000AAu));
|
||||
runner.Tick(1.0);
|
||||
Assert.Empty(sink.Calls);
|
||||
|
||||
runner.PublishTime(1.1);
|
||||
runner.Tick(1.1);
|
||||
Assert.Equal([9u], EmitterIds(sink));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IneligibleOwnerRetainsScriptsAndTimedCallsUntilReentry()
|
||||
{
|
||||
bool eligible = true;
|
||||
DatPhysicsScript script = BuildScript((1.0, CreateHook(9)));
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(
|
||||
sink,
|
||||
randomUnit: () => 0.5,
|
||||
canAdvanceOwner: _ => eligible,
|
||||
scripts: [(0x330000AAu, script)]);
|
||||
runner.PlayDirect(1u, 0x330000AAu);
|
||||
runner.ScheduleCallPes(2u, 0x330000AAu, 2f);
|
||||
|
||||
eligible = false;
|
||||
runner.Tick(2.0);
|
||||
Assert.Empty(sink.Calls);
|
||||
Assert.Equal(1, runner.ActiveScriptCount);
|
||||
Assert.Equal(1, runner.ScheduledCallPesCount);
|
||||
|
||||
eligible = true;
|
||||
runner.Tick(2.0);
|
||||
Assert.Equal([(1u, 9u)],
|
||||
sink.Calls.Select(call =>
|
||||
(call.EntityId, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)));
|
||||
runner.Tick(3.0);
|
||||
Assert.Equal([(1u, 9u), (2u, 9u)],
|
||||
sink.Calls.Select(call =>
|
||||
(call.EntityId, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroTimeRecursiveCallChainIsRejectedWithoutHanging()
|
||||
{
|
||||
const uint firstDid = 0x330000AAu;
|
||||
const uint secondDid = 0x330000BBu;
|
||||
PhysicsScriptRunner? runner = null;
|
||||
var sink = new RecordingSink
|
||||
{
|
||||
Callback = (owner, _, hook) =>
|
||||
{
|
||||
uint target = ((CreateParticleHook)hook).EmitterInfoId.DataId == 1u
|
||||
? secondDid
|
||||
: firstDid;
|
||||
runner!.PlayDirect(owner, target);
|
||||
},
|
||||
};
|
||||
runner = MakeRunner(
|
||||
sink,
|
||||
scripts:
|
||||
[
|
||||
(firstDid, BuildScript((0.0, CreateHook(1)))),
|
||||
(secondDid, BuildScript((0.0, CreateHook(2)))),
|
||||
]);
|
||||
var diagnostics = new List<string>();
|
||||
runner.DiagnosticSink = diagnostics.Add;
|
||||
runner.PlayDirect(1u, firstDid);
|
||||
|
||||
runner.Tick(0.0);
|
||||
|
||||
Assert.Equal([1u, 2u], EmitterIds(sink));
|
||||
Assert.Single(diagnostics, message => message.Contains("recursive", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PositiveDurationRecursiveChainDrainsCatchUpWithoutFalseCycleRejection()
|
||||
{
|
||||
const uint firstDid = 0x33000428u;
|
||||
const uint secondDid = 0x33000429u;
|
||||
PhysicsScriptRunner? runner = null;
|
||||
var sink = new RecordingSink
|
||||
{
|
||||
Callback = (owner, _, hook) =>
|
||||
{
|
||||
uint target = ((CreateParticleHook)hook).EmitterInfoId.DataId == 1u
|
||||
? secondDid
|
||||
: firstDid;
|
||||
runner!.PlayDirect(owner, target);
|
||||
},
|
||||
};
|
||||
runner = MakeRunner(
|
||||
sink,
|
||||
scripts:
|
||||
[
|
||||
(firstDid, BuildScript((2.8, CreateHook(1)))),
|
||||
(secondDid, BuildScript((2.8, CreateHook(2)))),
|
||||
]);
|
||||
var diagnostics = new List<string>();
|
||||
runner.DiagnosticSink = diagnostics.Add;
|
||||
runner.PlayDirect(1u, firstDid);
|
||||
|
||||
runner.Tick(8.4);
|
||||
|
||||
Assert.Equal([1u, 2u, 1u], EmitterIds(sink));
|
||||
Assert.DoesNotContain(
|
||||
diagnostics,
|
||||
message => message.Contains("recursive", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal(1, runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossOwnerReentrantPlayWaitsForNextGlobalScriptPass()
|
||||
{
|
||||
const uint parentDid = 0x330000AAu;
|
||||
const uint childCurrentDid = 0x330000BBu;
|
||||
const uint childDefaultDid = 0x330000CCu;
|
||||
PhysicsScriptRunner? runner = null;
|
||||
var sink = new RecordingSink
|
||||
{
|
||||
Callback = (_, _, hook) =>
|
||||
{
|
||||
if (((CreateParticleHook)hook).EmitterInfoId.DataId == 1u)
|
||||
runner!.PlayDirect(2u, childDefaultDid);
|
||||
},
|
||||
};
|
||||
runner = MakeRunner(
|
||||
sink,
|
||||
scripts:
|
||||
[
|
||||
(parentDid, BuildScript((0.0, CreateHook(1)))),
|
||||
(childCurrentDid, BuildScript((0.0, CreateHook(2)))),
|
||||
(childDefaultDid, BuildScript((0.0, CreateHook(3)))),
|
||||
]);
|
||||
runner.PlayDirect(1u, parentDid);
|
||||
runner.PlayDirect(2u, childCurrentDid);
|
||||
|
||||
runner.Tick(0.0);
|
||||
Assert.Equal([1u, 2u], EmitterIds(sink));
|
||||
|
||||
runner.Tick(0.0);
|
||||
Assert.Equal([1u, 2u, 3u], EmitterIds(sink));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonFiniteTimelineIsRejectedBeforeItCanBypassRecursionGuard()
|
||||
{
|
||||
const uint scriptDid = 0x330000AAu;
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(
|
||||
sink,
|
||||
scripts: [(scriptDid, BuildScript((double.NaN, CreateHook(1))))]);
|
||||
var diagnostics = new List<string>();
|
||||
runner.DiagnosticSink = diagnostics.Add;
|
||||
|
||||
Assert.False(runner.PlayDirect(1u, scriptDid));
|
||||
runner.Tick(0.0);
|
||||
|
||||
Assert.Empty(sink.Calls);
|
||||
Assert.Contains(diagnostics, message => message.Contains("non-finite", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScheduleCallPesRejectsNonFiniteInputsAndStopClearsDelayedCalls()
|
||||
{
|
||||
DatPhysicsScript script = BuildScript((0.0, CreateHook(9)));
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(
|
||||
sink,
|
||||
randomUnit: () => 0.5,
|
||||
scripts: [(0x330000AAu, script)]);
|
||||
|
||||
Assert.False(runner.ScheduleCallPes(1u, 0x330000AAu, float.NaN));
|
||||
Assert.False(runner.ScheduleCallPes(1u, 0x330000AAu, float.PositiveInfinity));
|
||||
Assert.True(runner.ScheduleCallPes(1u, 0x330000AAu, 2f));
|
||||
runner.StopAllForEntity(1u);
|
||||
runner.Tick(10.0);
|
||||
|
||||
Assert.Equal(0, runner.ScheduledCallPesCount);
|
||||
Assert.Empty(sink.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentAnchorIsReadWhenHookFires()
|
||||
{
|
||||
DatPhysicsScript script = BuildScript((1.0, CreateHook(1)));
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
|
||||
runner.SetOwnerAnchor(1u, new Vector3(1, 0, 0));
|
||||
runner.PlayDirect(1u, 0x330000AAu);
|
||||
runner.SetOwnerAnchor(1u, new Vector3(7, 8, 9));
|
||||
|
||||
runner.Tick(1.0);
|
||||
|
||||
Assert.Equal(new Vector3(7, 8, 9), Assert.Single(sink.Calls).Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryHookFansOutThroughCompleteRouter()
|
||||
{
|
||||
DatPhysicsScript script = BuildScript((0.0, CreateHook(1)));
|
||||
var first = new RecordingSink();
|
||||
var second = new RecordingSink();
|
||||
var router = new AnimationHookRouter();
|
||||
router.Register(first);
|
||||
router.Register(second);
|
||||
var runner = MakeRunner(router, scripts: [(0x330000AAu, script)]);
|
||||
runner.PlayDirect(1u, 0x330000AAu);
|
||||
|
||||
runner.Tick(0.0);
|
||||
|
||||
Assert.Single(first.Calls);
|
||||
Assert.Single(second.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoaderFailureIsCachedAndDoesNotCorruptOtherOwners()
|
||||
{
|
||||
DatPhysicsScript good = BuildScript((0.0, CreateHook(1)));
|
||||
int failingLoads = 0;
|
||||
var sink = new RecordingSink();
|
||||
var runner = new PhysicsScriptRunner(
|
||||
id =>
|
||||
{
|
||||
if (id == 0x330000AAu)
|
||||
return good;
|
||||
failingLoads++;
|
||||
throw new InvalidDataException("fixture DAT failure");
|
||||
},
|
||||
sink);
|
||||
|
||||
Assert.False(runner.PlayDirect(1u, 0x330000BBu));
|
||||
Assert.False(runner.PlayDirect(1u, 0x330000BBu));
|
||||
Assert.True(runner.PlayDirect(2u, 0x330000AAu));
|
||||
runner.Tick(0.0);
|
||||
|
||||
Assert.Equal(1, failingLoads);
|
||||
Assert.Equal([1u], EmitterIds(sink));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TickRejectsNonFiniteOrBackwardsTime()
|
||||
{
|
||||
var runner = MakeRunner(new RecordingSink());
|
||||
runner.Tick(1.0);
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => runner.Tick(0.5));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => runner.Tick(double.NaN));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => runner.Tick(double.PositiveInfinity));
|
||||
}
|
||||
|
||||
private static uint[] EmitterIds(RecordingSink sink) =>
|
||||
sink.Calls
|
||||
.Select(call => ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)
|
||||
.ToArray();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,17 @@ public sealed class PhysicsScriptTableResolverTests
|
|||
Assert.Null(resolver.Resolve(TableDid, RawType, 0f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_LoaderFailureReturnsNoPlayAndPreservesDiagnosticException()
|
||||
{
|
||||
var resolver = new PhysicsScriptTableResolver(
|
||||
_ => throw new InvalidDataException("fixture DAT failure"));
|
||||
|
||||
Assert.Null(resolver.Resolve(TableDid, RawType, 0f, out Exception? failure));
|
||||
var invalid = Assert.IsType<InvalidDataException>(failure);
|
||||
Assert.Equal("fixture DAT failure", invalid.Message);
|
||||
}
|
||||
|
||||
private static PhysicsScriptTable BuildTable(
|
||||
params (float Mod, uint ScriptDid)[] entries)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -87,4 +87,20 @@ public class InteriorEntityIdAllocatorTests
|
|||
// that accidentally narrows it again gets caught here first.
|
||||
Assert.Equal(0xFFFu, InteriorEntityIdAllocator.MaxCounter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allocate_AcceptsLastCounterThenFailsBeforeNamespaceWrap()
|
||||
{
|
||||
uint counter = InteriorEntityIdAllocator.MaxCounter;
|
||||
|
||||
uint last = InteriorEntityIdAllocator.Allocate(0xA9u, 0xB4u, ref counter);
|
||||
|
||||
Assert.Equal(
|
||||
InteriorEntityIdAllocator.Base(0xA9u, 0xB4u)
|
||||
+ InteriorEntityIdAllocator.MaxCounter,
|
||||
last);
|
||||
InvalidDataException error = Assert.Throws<InvalidDataException>(
|
||||
() => InteriorEntityIdAllocator.Allocate(0xA9u, 0xB4u, ref counter));
|
||||
Assert.Contains("4096-entry", error.Message, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,6 +163,19 @@ public class LandblockLoaderTests
|
|||
Assert.Equal(1u, entities[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildEntitiesFromInfo_NamespacedIdOverflowFailsBeforeCrossLandblockAlias()
|
||||
{
|
||||
var info = new LandBlockInfo();
|
||||
for (uint i = 0; i < 256u; i++)
|
||||
info.Objects.Add(new Stab { Id = 0x01000001u, Frame = new Frame() });
|
||||
|
||||
InvalidDataException error = Assert.Throws<InvalidDataException>(
|
||||
() => LandblockLoader.BuildEntitiesFromInfo(info, landblockId: 0xA9B40000u));
|
||||
|
||||
Assert.Contains("255-entry", error.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildEntitiesFromInfo_TagsBuildingsWithIsBuildingShellTrue()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue