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

@ -1,365 +0,0 @@
using System.Collections.Generic;
using System.Numerics;
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.Core.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(0xCAFEu);
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_KeysByEntityId_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(0x40A9B401u, p.Recording.Calls[0].EntityId);
Assert.Equal(new Vector3(5, 5, 5), p.Recording.Calls[0].Pos);
}
[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_StopsByGivenKey_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(0x40A9B402u); // caller passes the entity.Id key
Assert.Equal(0, runner.ActiveScriptCount);
system.Tick(0.01f);
Assert.Equal(0, system.ActiveEmitterCount);
}
[Fact]
public void OnRemoveLegacyOwner_StopsPreMaterializationF754ServerGuidAlias()
{
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));
// 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);
}
}

View file

@ -1,168 +0,0 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using Xunit;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.Core.Tests.Streaming;
/// <summary>
/// Phase C.1.5b: verifies <see cref="GpuWorldState"/> fires
/// <see cref="EntityScriptActivator.OnCreate"/> /
/// <see cref="EntityScriptActivator.OnRemove"/> from the four
/// dat-hydration paths (AddLandblock, AddEntitiesToExistingLandblock,
/// RemoveLandblock, RemoveEntitiesFromLandblock), and that the
/// pending-bucket merge in AddLandblock does NOT double-fire for live
/// entities, whose logical activation belongs to LiveEntityRuntime.
/// </summary>
public sealed class GpuWorldStateActivatorTests
{
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 sealed record Pipeline(
GpuWorldState State,
PhysicsScriptRunner Runner,
ParticleHookSink Sink,
RecordingSink Recording);
private static Pipeline BuildPipeline(uint scriptId)
{
var script = new DatPhysicsScript();
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = 0.0,
Hook = new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() },
});
var table = new Dictionary<uint, DatPhysicsScript> { [scriptId] = script };
var registry = new EmitterDescRegistry();
var system = new ParticleSystem(registry);
var sink = new ParticleHookSink(system);
var recording = new RecordingSink();
var runner = new PhysicsScriptRunner(id => table.TryGetValue(id, out var s) ? s : null, recording);
var activator = new EntityScriptActivator(runner, sink,
_ => new ScriptActivationInfo(scriptId, Array.Empty<Matrix4x4>()));
var state = new GpuWorldState(entityScriptActivator: activator);
return new Pipeline(state, runner, sink, recording);
}
private static LoadedLandblock MakeStubLandblock(uint canonicalId, params WorldEntity[] entities)
=> new(canonicalId, new LandBlock(), entities);
private static WorldEntity DatHydrated(uint id, Vector3 pos) => new()
{
Id = id,
ServerGuid = 0u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = pos,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
private static WorldEntity Live(uint serverGuid, Vector3 pos) => new()
{
Id = serverGuid,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = pos,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
[Fact]
public void AddLandblock_FiresActivatorForDatHydratedEntity()
{
var p = BuildPipeline(scriptId: 0xAAu);
var entity = DatHydrated(id: 0x40A9B401u, pos: new Vector3(1, 2, 3));
var lb = MakeStubLandblock(0xA9B4FFFFu, entity);
p.State.AddLandblock(lb);
// 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(new Vector3(1, 2, 3), p.Recording.Calls[0].Pos);
}
[Fact]
public void PendingLiveProjection_DoesNotFireLogicalActivator()
{
// Live entity (ServerGuid!=0) arrives via spatial projection first —
// OnCreate fires once at that point. Then AddLandblock for the
// same canonical id pulls the pending entity into the loaded list.
// The new fire-site MUST NOT call OnCreate again (the live entity
// is filtered out by ServerGuid != 0).
var p = BuildPipeline(scriptId: 0xAAu);
var live = Live(serverGuid: 0xCAFEu, pos: Vector3.Zero);
p.State.PlaceLiveEntityProjection(0xA9B4FFFFu, live);
var emptyLb = MakeStubLandblock(0xA9B4FFFFu);
p.State.AddLandblock(emptyLb);
p.Runner.Tick(0.001f);
Assert.Empty(p.Recording.Calls);
}
[Fact]
public void RemoveLandblock_FiresOnRemoveForDatHydratedEntity()
{
var p = BuildPipeline(scriptId: 0xAAu);
var entity = DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero);
var lb = MakeStubLandblock(0xA9B4FFFFu, entity);
p.State.AddLandblock(lb);
// Don't Tick: Play queued the script in _active immediately; ticking
// would fire its single StartTime=0 hook and self-remove the script
// before we can observe RemoveLandblock cleaning it up.
Assert.Equal(1, p.Runner.ActiveScriptCount);
p.State.RemoveLandblock(0xA9B4FFFFu);
Assert.Equal(0, p.Runner.ActiveScriptCount);
}
[Fact]
public void AddEntitiesToExistingLandblock_FiresActivatorForEachPromoted()
{
var p = BuildPipeline(scriptId: 0xAAu);
var emptyLb = MakeStubLandblock(0xA9B4FFFFu);
p.State.AddLandblock(emptyLb);
var promoted = new[]
{
DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero),
DatHydrated(id: 0x40A9B402u, pos: Vector3.UnitX),
};
p.State.AddEntitiesToExistingLandblock(0xA9B4FFFFu, promoted);
p.Runner.Tick(0.001f);
Assert.Equal(2, p.Recording.Calls.Count);
}
[Fact]
public void RemoveEntitiesFromLandblock_FiresOnRemoveForDatHydratedEntities()
{
var p = BuildPipeline(scriptId: 0xAAu);
var entity = DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero);
var lb = MakeStubLandblock(0xA9B4FFFFu, entity);
p.State.AddLandblock(lb);
// Don't Tick: see comment in RemoveLandblock_FiresOnRemoveForDatHydratedEntity.
Assert.Equal(1, p.Runner.ActiveScriptCount);
p.State.RemoveEntitiesFromLandblock(0xA9B4FFFFu);
Assert.Equal(0, p.Runner.ActiveScriptCount);
}
}

View file

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

View file

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

View file

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

View file

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