Restore the named-retail object update order across local, remote, static, projectile, animation, shadow, teleport, and effect lifetimes. Separate authoritative root commits from spatial rebucketing, preserve per-owner hook/FIFO ordering, and remove update-path allocations with exact lifecycle and residency gates. Add deterministic conformance, adversarial lifetime, GUID-reuse, pending-cell, quaternion, timestamp, and allocation coverage. Release build is warning-free and all 6,446 tests pass with five intentional skips; retail, architecture, and adversarial reviews are clean. Co-authored-by: OpenAI Codex <codex@openai.com>
1050 lines
42 KiB
C#
1050 lines
42 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Physics;
|
|
using AcDream.App.Rendering.Vfx;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Lighting;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Vfx;
|
|
using AcDream.Core.World;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Enums;
|
|
using DatReaderWriter.Types;
|
|
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
|
|
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
|
|
|
namespace AcDream.App.Tests.World;
|
|
|
|
/// <summary>
|
|
/// Step 9 deterministic whole-lifecycle gate for the canonical live-object,
|
|
/// projectile, and DAT-effect ownership graph. It deliberately exercises
|
|
/// enough simultaneous owners to expose collection mutation and incomplete
|
|
/// teardown that single-object conformance tests cannot reveal.
|
|
/// </summary>
|
|
public sealed class LiveEntityLifecycleStressTests
|
|
{
|
|
private const int OwnerCount = 96;
|
|
private const int ReusedOwnerCount = 32;
|
|
private const uint GuidBase = 0x72000000u;
|
|
private const uint CellA = 0x01010001u;
|
|
private const uint CellB = 0x01020001u;
|
|
private const uint ScriptDid = 0x3300AA01u;
|
|
private const uint EmitterDid = 0x3200AA01u;
|
|
private const uint SetupDid = 0x02000124u;
|
|
private const uint RecallMotion = 0x10000153u;
|
|
|
|
private static readonly PhysicsStateFlags MissileState =
|
|
PhysicsStateFlags.ReportCollisions
|
|
| PhysicsStateFlags.Lighting
|
|
| PhysicsStateFlags.Missile
|
|
| PhysicsStateFlags.AlignPath
|
|
| PhysicsStateFlags.PathClipped;
|
|
|
|
[Fact]
|
|
public void NinetySixOwners_ChurnDeleteResetAndGuidReuse_LeaveNoRuntimeResources()
|
|
{
|
|
using var fixture = new Fixture();
|
|
var oldLocalIds = new List<uint>(OwnerCount + ReusedOwnerCount);
|
|
var lifetimeRecords = new List<LiveEntityRecord>(OwnerCount + ReusedOwnerCount);
|
|
|
|
for (int i = 0; i < OwnerCount; i++)
|
|
{
|
|
uint guid = GuidBase + (uint)i;
|
|
fixture.Effects.HandleDirect(new PlayPhysicsScript(guid, ScriptDid));
|
|
LiveEntityRecord record = fixture.Spawn(guid, generation: 1, i);
|
|
lifetimeRecords.Add(record);
|
|
oldLocalIds.Add(record.LocalEntityId!.Value);
|
|
|
|
// Repeated direct network PES plays append to the same owner's
|
|
// retail FIFO. They must neither deduplicate nor create another
|
|
// logical owner. The separate recall test below drives the exact
|
|
// recall motion and CallPES chain.
|
|
if (i % 12 == 0)
|
|
{
|
|
fixture.Effects.HandleDirect(new PlayPhysicsScript(guid, ScriptDid));
|
|
fixture.Effects.HandleDirect(new PlayPhysicsScript(guid, ScriptDid));
|
|
}
|
|
}
|
|
|
|
fixture.Runner.Tick(0.0);
|
|
fixture.Particles.Tick(0.01f);
|
|
fixture.Projectiles.Tick(
|
|
currentTime: 0.05,
|
|
liveCenterX: 1,
|
|
liveCenterY: 1,
|
|
playerWorldPosition: null);
|
|
|
|
Assert.Equal(OwnerCount, fixture.Runtime.Count);
|
|
Assert.Equal(OwnerCount, fixture.Runtime.MaterializedCount);
|
|
Assert.Equal(OwnerCount, fixture.RenderOwners.Count);
|
|
Assert.Equal(OwnerCount, fixture.Projectiles.Count);
|
|
Assert.Equal(OwnerCount, fixture.Effects.ReadyOwnerCount);
|
|
Assert.Equal(0, fixture.Effects.PendingPacketCount);
|
|
Assert.Equal(OwnerCount, fixture.Runner.ActiveOwnerCount);
|
|
Assert.True(fixture.Runner.ActiveScriptCount > OwnerCount);
|
|
Assert.Equal(OwnerCount, fixture.Particles.ActiveEmitterCount);
|
|
Assert.Equal(OwnerCount, fixture.ParticleSink.ActiveBindingCount);
|
|
Assert.Equal(OwnerCount, fixture.ParticleSink.LogicalEmitterCount);
|
|
Assert.Equal(OwnerCount, fixture.ParticleSink.TrackedOwnerCount);
|
|
Assert.Equal(OwnerCount, fixture.ParticleSink.RenderPassOwnerCount);
|
|
Assert.Equal(OwnerCount, fixture.Poses.Count);
|
|
Assert.Equal(OwnerCount, fixture.Lights.RegisteredCount);
|
|
Assert.Equal(OwnerCount, fixture.LiveLights.TrackedOwnerCount);
|
|
Assert.Equal(OwnerCount, fixture.LiveLights.PresentedOwnerCount);
|
|
Assert.Equal(OwnerCount, fixture.Lighting.OwnedLightOwnerCount);
|
|
Assert.Equal(OwnerCount, fixture.Lighting.PoseTrackedOwnerCount);
|
|
Assert.Equal(OwnerCount, fixture.Lighting.RetainedOwnerStateCount);
|
|
Assert.Equal(OwnerCount, fixture.Engine.ShadowObjects.TotalRegistered);
|
|
Assert.Equal(OwnerCount, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
|
|
|
|
// Loaded -> pending -> loaded -> pending -> loaded churn keeps the
|
|
// same projections and all logical owners; only spatial presentation
|
|
// changes. This is the streaming race that previously replayed create.
|
|
for (int i = 0; i < OwnerCount; i += 2)
|
|
Assert.True(fixture.Runtime.RebucketLiveEntity(GuidBase + (uint)i, CellB));
|
|
Assert.Equal(OwnerCount / 2, fixture.Spatial.PendingLiveEntityCount);
|
|
Assert.Equal(OwnerCount, fixture.RenderOwners.Count);
|
|
Assert.Equal(OwnerCount / 2, fixture.ParticleSink.HiddenPresentationOwnerCount);
|
|
Assert.Equal(OwnerCount / 2, fixture.Lights.RegisteredCount);
|
|
Assert.Equal(OwnerCount / 2, fixture.LiveLights.PresentedOwnerCount);
|
|
|
|
fixture.Spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
|
|
Assert.Equal(0, fixture.Spatial.PendingLiveEntityCount);
|
|
Assert.Equal(0, fixture.ParticleSink.HiddenPresentationOwnerCount);
|
|
Assert.Equal(OwnerCount, fixture.Lights.RegisteredCount);
|
|
fixture.Spatial.RemoveLandblock(0x0102FFFFu);
|
|
Assert.Equal(OwnerCount / 2, fixture.Spatial.PendingLiveEntityCount);
|
|
fixture.Spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
|
|
Assert.Equal(0, fixture.Spatial.PendingLiveEntityCount);
|
|
Assert.Equal(OwnerCount, fixture.RenderOwners.Count);
|
|
Assert.Equal(OwnerCount, fixture.Projectiles.Count);
|
|
Assert.Equal(OwnerCount, fixture.Lights.RegisteredCount);
|
|
Assert.Equal(OwnerCount, fixture.LiveLights.PresentedOwnerCount);
|
|
|
|
// End one third rapidly, then reuse those exact server GUIDs with a
|
|
// newer instance timestamp. No queue, body, emitter, light, or shadow
|
|
// from generation 1 may attach to generation 2.
|
|
for (int i = 0; i < ReusedOwnerCount; i++)
|
|
{
|
|
uint guid = GuidBase + (uint)i;
|
|
Assert.True(fixture.Runtime.UnregisterLiveEntity(
|
|
new DeleteObject.Parsed(guid, InstanceSequence: 1),
|
|
isLocalPlayer: false));
|
|
}
|
|
Assert.Equal(OwnerCount - ReusedOwnerCount, fixture.Runtime.Count);
|
|
Assert.Equal(OwnerCount - ReusedOwnerCount, fixture.RenderOwners.Count);
|
|
Assert.Equal(OwnerCount - ReusedOwnerCount, fixture.Particles.ActiveEmitterCount);
|
|
Assert.Equal(OwnerCount - ReusedOwnerCount, fixture.Lights.RegisteredCount);
|
|
Assert.Equal(
|
|
OwnerCount - ReusedOwnerCount,
|
|
fixture.Engine.ShadowObjects.RetainedRegistrationCount);
|
|
|
|
for (int i = 0; i < ReusedOwnerCount; i++)
|
|
{
|
|
uint guid = GuidBase + (uint)i;
|
|
fixture.Effects.HandleDirect(new PlayPhysicsScript(guid, ScriptDid));
|
|
LiveEntityRecord replacement = fixture.Spawn(guid, generation: 2, i);
|
|
lifetimeRecords.Add(replacement);
|
|
oldLocalIds.Add(replacement.LocalEntityId!.Value);
|
|
}
|
|
fixture.Runner.Tick(1.0);
|
|
fixture.Particles.Tick(0.01f);
|
|
|
|
Assert.Equal(OwnerCount, fixture.Runtime.Count);
|
|
Assert.Equal(OwnerCount, fixture.RenderOwners.Count);
|
|
Assert.Equal(OwnerCount, fixture.Projectiles.Count);
|
|
Assert.Equal(OwnerCount, fixture.Particles.ActiveEmitterCount);
|
|
Assert.Equal(OwnerCount, fixture.Lights.RegisteredCount);
|
|
Assert.Equal(OwnerCount, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
|
|
|
|
// Mix accepted deletes with a session reset, and leave packets for
|
|
// never-created owners pending so the network reset path is covered.
|
|
for (int i = 0; i < OwnerCount; i += 2)
|
|
{
|
|
uint guid = GuidBase + (uint)i;
|
|
ushort generation = i < ReusedOwnerCount ? (ushort)2 : (ushort)1;
|
|
Assert.True(fixture.Runtime.UnregisterLiveEntity(
|
|
new DeleteObject.Parsed(guid, generation),
|
|
isLocalPlayer: false));
|
|
}
|
|
for (int i = 0; i < 8; i++)
|
|
{
|
|
fixture.Effects.HandleDirect(new PlayPhysicsScript(
|
|
0x73000000u + (uint)i,
|
|
ScriptDid));
|
|
}
|
|
Assert.Equal(8, fixture.Effects.PendingPacketCount);
|
|
|
|
fixture.Runtime.Clear();
|
|
fixture.Effects.ClearNetworkState();
|
|
fixture.Particles.Tick(0.01f);
|
|
|
|
Assert.Equal(0, fixture.Runtime.Count);
|
|
Assert.Equal(0, fixture.Runtime.MaterializedCount);
|
|
Assert.Empty(fixture.RenderOwners);
|
|
Assert.Equal(fixture.RenderRegisterCount, fixture.RenderUnregisterCount);
|
|
Assert.Equal(0, fixture.Projectiles.Count);
|
|
Assert.Equal(0, fixture.Effects.ReadyOwnerCount);
|
|
Assert.Equal(0, fixture.Effects.PendingPacketCount);
|
|
Assert.Equal(0, fixture.Runner.ActiveOwnerCount);
|
|
Assert.Equal(0, fixture.Runner.ActiveScriptCount);
|
|
Assert.Equal(0, fixture.Runner.ScheduledCallPesCount);
|
|
Assert.Equal(0, fixture.Runner.OwnerAnchorCount);
|
|
Assert.Equal(0, fixture.Particles.ActiveEmitterCount);
|
|
Assert.Equal(0, fixture.Particles.ActiveParticleCount);
|
|
Assert.Equal(0, fixture.ParticleSink.ActiveBindingCount);
|
|
Assert.Equal(0, fixture.ParticleSink.LogicalEmitterCount);
|
|
Assert.Equal(0, fixture.ParticleSink.TrackedOwnerCount);
|
|
Assert.Equal(0, fixture.ParticleSink.RenderPassOwnerCount);
|
|
Assert.Equal(0, fixture.ParticleSink.HiddenPresentationOwnerCount);
|
|
Assert.Equal(0, fixture.Poses.Count);
|
|
Assert.Equal(0, fixture.Lights.RegisteredCount);
|
|
Assert.Equal(0, fixture.Lighting.OwnedLightOwnerCount);
|
|
Assert.Equal(0, fixture.Lighting.PoseTrackedOwnerCount);
|
|
Assert.Equal(0, fixture.Lighting.RetainedOwnerStateCount);
|
|
Assert.Equal(0, fixture.LiveLights.TrackedOwnerCount);
|
|
Assert.Equal(0, fixture.LiveLights.PresentedOwnerCount);
|
|
Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered);
|
|
Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
|
|
Assert.Equal(0, fixture.Engine.ShadowObjects.SuspendedRegistrationCount);
|
|
Assert.Equal(0, fixture.Spatial.PendingLiveEntityCount);
|
|
Assert.Equal(0, fixture.Spatial.PendingBucketCount);
|
|
Assert.Equal(0, fixture.Spatial.PendingRescueCount);
|
|
Assert.Equal(0, fixture.Spatial.PersistentGuidCount);
|
|
Assert.Equal(0, fixture.Spatial.PendingVisibilityTransitionCount);
|
|
Assert.DoesNotContain(fixture.Spatial.Entities, entity => entity.ServerGuid != 0);
|
|
Assert.All(lifetimeRecords, record =>
|
|
{
|
|
Assert.Null(record.PhysicsBody);
|
|
Assert.Null(record.ProjectileRuntime);
|
|
Assert.Null(record.EffectProfile);
|
|
Assert.Null(record.WorldEntity);
|
|
});
|
|
|
|
// A stale retained shadow payload would resurrect here despite the
|
|
// visible-cell count already reading zero.
|
|
foreach (uint localId in oldLocalIds)
|
|
{
|
|
fixture.Engine.ShadowObjects.UpdatePosition(
|
|
localId,
|
|
new Vector3(20f, 20f, 10f),
|
|
Quaternion.Identity,
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f,
|
|
landblockId: 0x01010000u,
|
|
seedCellId: CellA);
|
|
}
|
|
Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
|
|
Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered);
|
|
|
|
// Session teardown is idempotent and cannot recreate bookkeeping.
|
|
fixture.Runtime.Clear();
|
|
fixture.Effects.ClearNetworkState();
|
|
Assert.Equal(0, fixture.Runtime.Count);
|
|
Assert.Equal(0, fixture.Effects.PendingPacketCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void RepeatedRetailRecallMotion_HiddenTeleportUnhide_DrainsEveryPresentationOwner()
|
|
{
|
|
using var fixture = new RecallPortalFixture();
|
|
|
|
for (int cycle = 0; cycle < 12; cycle++)
|
|
{
|
|
fixture.PlayRecallMotion(cycle + 1.0);
|
|
Assert.Equal(1, fixture.Particles.ActiveEmitterCount);
|
|
Assert.Equal(1, fixture.ParticleSink.ActiveBindingCount);
|
|
|
|
fixture.ApplyHidden();
|
|
Assert.Equal(1, fixture.Presentation.DeferredShadowRestoreCount);
|
|
|
|
fixture.BeginDeferredTeleport();
|
|
Assert.Equal(1, fixture.Teleport.PendingPlacementCount);
|
|
Assert.Equal(1, fixture.Presentation.ActivePlacementCount);
|
|
|
|
fixture.LoadDestination();
|
|
Assert.Equal(0, fixture.Teleport.PendingPlacementCount);
|
|
Assert.Equal(0, fixture.Presentation.ActivePlacementCount);
|
|
Assert.Equal(1, fixture.Presentation.DeferredShadowRestoreCount);
|
|
|
|
fixture.ApplyVisible();
|
|
Assert.Equal(0, fixture.Presentation.DeferredShadowRestoreCount);
|
|
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered);
|
|
fixture.UnloadPriorLandblock();
|
|
}
|
|
|
|
Assert.Equal(12, fixture.TypedScripts.Count(type =>
|
|
type == LiveEntityPresentationController.HiddenScriptType));
|
|
Assert.Equal(12, fixture.TypedScripts.Count(type =>
|
|
type == LiveEntityPresentationController.UnHideScriptType));
|
|
Assert.Equal(12, fixture.RecallHookCount);
|
|
|
|
fixture.Clear();
|
|
|
|
Assert.Equal(0, fixture.Runtime.Count);
|
|
Assert.Equal(0, fixture.Effects.ReadyOwnerCount);
|
|
Assert.Equal(0, fixture.Runner.ActiveOwnerCount);
|
|
Assert.Equal(0, fixture.Runner.OwnerAnchorCount);
|
|
Assert.Equal(0, fixture.Particles.ActiveEmitterCount);
|
|
Assert.Equal(0, fixture.ParticleSink.ActiveBindingCount);
|
|
Assert.Equal(0, fixture.Poses.Count);
|
|
Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
|
|
Assert.Equal(0, fixture.Teleport.PendingPlacementCount);
|
|
Assert.Equal(0, fixture.Presentation.ReadyOwnerCount);
|
|
Assert.Equal(0, fixture.Presentation.DeferredShadowRestoreCount);
|
|
Assert.Equal(0, fixture.Presentation.ActivePlacementCount);
|
|
Assert.Equal(0, fixture.Spatial.PendingLiveEntityCount);
|
|
Assert.Equal(0, fixture.Spatial.PendingBucketCount);
|
|
}
|
|
|
|
private sealed class Fixture : IDisposable
|
|
{
|
|
private readonly Dictionary<uint, DatPhysicsScript> _scripts = new();
|
|
private EntityEffectController? _effects;
|
|
private LiveEntityLightController? _liveLights;
|
|
|
|
internal Fixture()
|
|
{
|
|
Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
|
AddEmptyPhysicsLandblock(Engine, 0x0101FFFFu, 0f, 0f);
|
|
AddEmptyPhysicsLandblock(Engine, 0x0102FFFFu, 0f, 192f);
|
|
|
|
var emitterRegistry = new EmitterDescRegistry();
|
|
emitterRegistry.Register(PersistentEmitter());
|
|
Particles = new ParticleSystem(emitterRegistry, new Random(42));
|
|
ParticleSink = new ParticleHookSink(Particles, Poses);
|
|
Lighting = new LightingHookSink(Lights, Poses);
|
|
Router.Register(ParticleSink);
|
|
Router.Register(Lighting);
|
|
|
|
_scripts.Add(ScriptDid, PersistentScript());
|
|
Runner = new PhysicsScriptRunner(
|
|
did => _scripts.TryGetValue(did, out DatPhysicsScript? script)
|
|
? script
|
|
: null,
|
|
Router,
|
|
randomUnit: () => 0.5,
|
|
canAdvanceOwner: ownerId => _effects?.CanAdvanceOwner(ownerId) ?? true);
|
|
|
|
Runtime = new LiveEntityRuntime(
|
|
Spatial,
|
|
new DelegateLiveEntityResourceLifecycle(
|
|
entity =>
|
|
{
|
|
Assert.True(RenderOwners.Add(entity.Id));
|
|
RenderRegisterCount++;
|
|
Poses.PublishMeshRefs(entity);
|
|
ParticleSink.SetEntityRenderPass(entity.Id, ParticleRenderPass.Scene);
|
|
},
|
|
entity =>
|
|
{
|
|
Runner.StopAllForEntity(entity.Id);
|
|
ParticleSink.StopAllForEntity(entity.Id, fadeOut: false);
|
|
Poses.Remove(entity.Id);
|
|
Assert.True(RenderOwners.Remove(entity.Id));
|
|
RenderUnregisterCount++;
|
|
}),
|
|
record =>
|
|
{
|
|
var cleanups = new List<Action>
|
|
{
|
|
() => _effects?.OnLiveEntityUnregistered(record),
|
|
};
|
|
if (record.WorldEntity is { } entity)
|
|
{
|
|
cleanups.Add(() => Engine.ShadowObjects.Deregister(entity.Id));
|
|
cleanups.Add(() => _liveLights?.Forget(entity.Id));
|
|
}
|
|
LiveEntityTeardown.Run(cleanups);
|
|
});
|
|
|
|
Effects = new EntityEffectController(
|
|
Runtime,
|
|
Runner,
|
|
new PhysicsScriptTableResolver(_ => null),
|
|
Poses);
|
|
_effects = Effects;
|
|
Router.Register(Effects);
|
|
Projectiles = new ProjectileController(Runtime, Engine);
|
|
LiveLights = new LiveEntityLightController(
|
|
Runtime,
|
|
Poses,
|
|
Lighting,
|
|
setupId => setupId == SetupDid ? Setup : null);
|
|
_liveLights = LiveLights;
|
|
Runtime.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
|
|
}
|
|
|
|
internal GpuWorldState Spatial { get; } = new();
|
|
internal PhysicsEngine Engine { get; } = new();
|
|
internal EntityEffectPoseRegistry Poses { get; } = new();
|
|
internal AnimationHookRouter Router { get; } = new();
|
|
internal ParticleSystem Particles { get; }
|
|
internal ParticleHookSink ParticleSink { get; }
|
|
internal LightManager Lights { get; } = new();
|
|
internal LightingHookSink Lighting { get; }
|
|
internal PhysicsScriptRunner Runner { get; }
|
|
internal LiveEntityRuntime Runtime { get; }
|
|
internal EntityEffectController Effects { get; }
|
|
internal ProjectileController Projectiles { get; }
|
|
internal LiveEntityLightController LiveLights { get; }
|
|
internal Setup Setup { get; } = ProjectileSetup();
|
|
internal HashSet<uint> RenderOwners { get; } = new();
|
|
internal int RenderRegisterCount { get; private set; }
|
|
internal int RenderUnregisterCount { get; private set; }
|
|
|
|
internal LiveEntityRecord Spawn(uint guid, ushort generation, int ordinal)
|
|
{
|
|
Vector3 position = new(
|
|
8f + (ordinal % 12) * 6f,
|
|
8f + (ordinal / 12) * 6f,
|
|
10f);
|
|
WorldSession.EntitySpawn spawn = MissileSpawn(guid, generation, position);
|
|
LiveEntityRegistrationResult registration = Runtime.RegisterLiveEntity(spawn);
|
|
LiveEntityRecord record = Assert.IsType<LiveEntityRecord>(registration.Record);
|
|
Runtime.SetEffectProfile(
|
|
guid,
|
|
EntityEffectProfile.CreateLive(new Setup(), spawn.Physics!.Value));
|
|
WorldEntity entity = Assert.IsType<WorldEntity>(Runtime.MaterializeLiveEntity(
|
|
guid,
|
|
CellA,
|
|
localId => new WorldEntity
|
|
{
|
|
Id = localId,
|
|
ServerGuid = guid,
|
|
SourceGfxObjOrSetupId = SetupDid,
|
|
Position = position,
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = Array.Empty<MeshRef>(),
|
|
ParentCellId = CellA,
|
|
}));
|
|
Assert.True(Effects.OnLiveEntityReady(guid));
|
|
Assert.True(Projectiles.TryBind(record, Setup, 0.0, 1, 1));
|
|
|
|
Engine.ShadowObjects.Register(
|
|
entity.Id,
|
|
SetupDid,
|
|
entity.Position,
|
|
entity.Rotation,
|
|
radius: 0.1f,
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f,
|
|
landblockId: 0x01010000u,
|
|
state: (uint)MissileState,
|
|
seedCellId: CellA,
|
|
isStatic: false);
|
|
Assert.True(LiveLights.Register(guid));
|
|
return record;
|
|
}
|
|
|
|
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
|
{
|
|
if (record.WorldEntity is { } entity)
|
|
ParticleSink.SetEntityPresentationVisible(entity.Id, visible);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Runtime.Clear();
|
|
Effects.ClearNetworkState();
|
|
Runtime.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged;
|
|
LiveLights.Dispose();
|
|
}
|
|
}
|
|
|
|
private sealed class RecallPortalFixture : IDisposable
|
|
{
|
|
private const uint Guid = 0x75000001u;
|
|
private const uint CellOne = 0x01010001u;
|
|
private const uint CellTwo = 0x01020001u;
|
|
private const uint RecallScriptDid = 0x3300BB01u;
|
|
private const uint IdleAnimationDid = 0x0300BB01u;
|
|
private const uint RecallAnimationDid = 0x0300BB02u;
|
|
private const uint Style = 0x8000003Du;
|
|
private const uint ReadyMotion = 0x41000003u;
|
|
|
|
private readonly AnimationHookFrameQueue _hookQueue;
|
|
private readonly TestRemoteMotion _remote = new();
|
|
private uint _currentCell = CellOne;
|
|
private uint _destinationCell;
|
|
private ushort _stateSequence = 1;
|
|
private bool _cleared;
|
|
|
|
internal RecallPortalFixture()
|
|
{
|
|
Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
|
var emitterRegistry = new EmitterDescRegistry();
|
|
emitterRegistry.Register(PersistentEmitter());
|
|
Particles = new ParticleSystem(emitterRegistry, new Random(73));
|
|
ParticleSink = new ParticleHookSink(Particles, Poses);
|
|
Router.Register(ParticleSink);
|
|
|
|
DatPhysicsScript recallScript = RecallScript();
|
|
Runner = new PhysicsScriptRunner(
|
|
did => did == RecallScriptDid ? recallScript : null,
|
|
Router,
|
|
randomUnit: () => 0.5);
|
|
|
|
EntityEffectController? effects = null;
|
|
LiveEntityPresentationController? presentation = null;
|
|
RemoteTeleportController? teleport = null;
|
|
Runtime = new LiveEntityRuntime(
|
|
Spatial,
|
|
new DelegateLiveEntityResourceLifecycle(
|
|
entity =>
|
|
{
|
|
Poses.PublishMeshRefs(entity);
|
|
ParticleSink.SetEntityRenderPass(entity.Id, ParticleRenderPass.Scene);
|
|
},
|
|
entity =>
|
|
{
|
|
Runner.StopAllForEntity(entity.Id);
|
|
ParticleSink.StopAllForEntity(entity.Id, fadeOut: false);
|
|
Poses.Remove(entity.Id);
|
|
}),
|
|
record =>
|
|
{
|
|
effects?.OnLiveEntityUnregistered(record);
|
|
presentation?.Forget(record);
|
|
teleport?.Forget(record);
|
|
if (record.WorldEntity is { } entity)
|
|
Engine.ShadowObjects.Deregister(entity.Id);
|
|
});
|
|
Effects = effects = new EntityEffectController(
|
|
Runtime,
|
|
Runner,
|
|
new PhysicsScriptTableResolver(_ => null),
|
|
Poses);
|
|
Router.Register(Effects);
|
|
|
|
WorldSession.EntitySpawn spawn = RecallSpawn(Guid, CellOne);
|
|
Runtime.RegisterLiveEntity(spawn);
|
|
Runtime.SetEffectProfile(
|
|
Guid,
|
|
EntityEffectProfile.CreateLive(new Setup(), spawn.Physics!.Value));
|
|
Entity = Runtime.MaterializeLiveEntity(
|
|
Guid,
|
|
CellOne,
|
|
localId => new WorldEntity
|
|
{
|
|
Id = localId,
|
|
ServerGuid = Guid,
|
|
SourceGfxObjOrSetupId = SetupDid,
|
|
Position = new Vector3(10f, 10f, 10f),
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = Array.Empty<MeshRef>(),
|
|
ParentCellId = CellOne,
|
|
})!;
|
|
Assert.True(Effects.OnLiveEntityReady(Guid));
|
|
|
|
_remote.Body.SnapToCell(CellOne, Entity.Position, Entity.Position);
|
|
_remote.CellId = CellOne;
|
|
Runtime.SetRemoteMotionRuntime(Guid, _remote);
|
|
Engine.ShadowObjects.Register(
|
|
Entity.Id,
|
|
SetupDid,
|
|
Entity.Position,
|
|
Entity.Rotation,
|
|
radius: 0.48f,
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f,
|
|
landblockId: 0x0101FFFFu,
|
|
collisionType: ShadowCollisionType.Cylinder,
|
|
cylHeight: 1.835f,
|
|
seedCellId: CellOne,
|
|
isStatic: false);
|
|
|
|
Presentation = presentation = new LiveEntityPresentationController(
|
|
Runtime,
|
|
Engine.ShadowObjects,
|
|
(_, type, _) =>
|
|
{
|
|
TypedScripts.Add(type);
|
|
return true;
|
|
},
|
|
liveCenter: () => (1, 1));
|
|
Assert.True(Presentation.OnLiveEntityReady(Guid));
|
|
|
|
Teleport = teleport = new RemoteTeleportController(
|
|
Engine,
|
|
Runtime,
|
|
(_, _) => (0.48f, 1.835f),
|
|
(position, _) => position,
|
|
(_, _, _) => { },
|
|
(guid, generation, defer) =>
|
|
Presentation.CompleteAuthoritativePlacement(guid, generation, defer),
|
|
(guid, generation) =>
|
|
Presentation.BeginAuthoritativePlacement(guid, generation),
|
|
resolvePlacement: (position, cell, _, _, _, _) => new ResolveResult(
|
|
position,
|
|
cell,
|
|
IsOnGround: true,
|
|
InContact: true,
|
|
OnWalkable: true,
|
|
ContactPlane: new Plane(Vector3.UnitZ, 0f),
|
|
ContactPlaneCellId: cell));
|
|
|
|
Runtime.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
|
|
_hookQueue = new AnimationHookFrameQueue(Router, Poses);
|
|
}
|
|
|
|
internal GpuWorldState Spatial { get; } = new();
|
|
internal PhysicsEngine Engine { get; } = new();
|
|
internal EntityEffectPoseRegistry Poses { get; } = new();
|
|
internal AnimationHookRouter Router { get; } = new();
|
|
internal ParticleSystem Particles { get; }
|
|
internal ParticleHookSink ParticleSink { get; }
|
|
internal PhysicsScriptRunner Runner { get; }
|
|
internal LiveEntityRuntime Runtime { get; }
|
|
internal EntityEffectController Effects { get; }
|
|
internal WorldEntity Entity { get; }
|
|
internal LiveEntityPresentationController Presentation { get; }
|
|
internal RemoteTeleportController Teleport { get; }
|
|
internal List<uint> TypedScripts { get; } = [];
|
|
internal int RecallHookCount { get; private set; }
|
|
|
|
internal void PlayRecallMotion(double gameTime)
|
|
{
|
|
AnimationSequencer recallSequencer = BuildRecallSequencer();
|
|
recallSequencer.SetCycle(Style, ReadyMotion);
|
|
recallSequencer.PlayAction(RecallMotion);
|
|
recallSequencer.Advance(0.15f);
|
|
IReadOnlyList<AnimationHook> hooks = recallSequencer.ConsumePendingHooks();
|
|
RecallHookCount += hooks.Count(hook => hook is CallPESHook call
|
|
&& (uint)call.PES == RecallScriptDid);
|
|
_hookQueue.Capture(Entity.Id, recallSequencer, hooks);
|
|
_hookQueue.Drain();
|
|
Runner.Tick(gameTime);
|
|
Particles.Tick(0.01f);
|
|
}
|
|
|
|
internal void ApplyHidden()
|
|
{
|
|
Assert.True(Runtime.TryApplyState(
|
|
new SetState.Parsed(
|
|
Guid,
|
|
(uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions),
|
|
InstanceSequence: 1,
|
|
StateSequence: ++_stateSequence),
|
|
out _,
|
|
out _));
|
|
Assert.True(Presentation.OnStateAccepted(Guid));
|
|
}
|
|
|
|
internal void BeginDeferredTeleport()
|
|
{
|
|
_destinationCell = _currentCell == CellOne ? CellTwo : CellOne;
|
|
Assert.False(Spatial.IsLoaded((_destinationCell & 0xFFFF0000u) | 0xFFFFu));
|
|
Teleport.BeginPlacement(Guid, generation: 1);
|
|
Assert.True(Runtime.RebucketLiveEntity(Guid, _destinationCell));
|
|
RemoteTeleportController.Result result = Teleport.TryApply(
|
|
_remote,
|
|
Entity,
|
|
new Vector3(Entity.Position.X + 3f, Entity.Position.Y, Entity.Position.Z),
|
|
_destinationCell,
|
|
Entity.Position,
|
|
Quaternion.Identity,
|
|
gameTime: _stateSequence,
|
|
destinationProjectionVisible: false,
|
|
generation: 1,
|
|
positionSequence: 1);
|
|
Assert.True(result.Applied);
|
|
Assert.False(result.ContactResolved);
|
|
}
|
|
|
|
internal void LoadDestination()
|
|
{
|
|
Spatial.AddLandblock(EmptyLandblock(
|
|
(_destinationCell & 0xFFFF0000u) | 0xFFFFu));
|
|
}
|
|
|
|
internal void ApplyVisible()
|
|
{
|
|
Assert.True(Runtime.TryApplyState(
|
|
new SetState.Parsed(
|
|
Guid,
|
|
(uint)PhysicsStateFlags.ReportCollisions,
|
|
InstanceSequence: 1,
|
|
StateSequence: ++_stateSequence),
|
|
out _,
|
|
out _));
|
|
Assert.True(Presentation.OnStateAccepted(Guid));
|
|
}
|
|
|
|
internal void UnloadPriorLandblock()
|
|
{
|
|
uint priorLandblock = (_currentCell & 0xFFFF0000u) | 0xFFFFu;
|
|
Spatial.RemoveLandblock(priorLandblock);
|
|
_currentCell = _destinationCell;
|
|
}
|
|
|
|
internal void Clear()
|
|
{
|
|
if (_cleared)
|
|
return;
|
|
_cleared = true;
|
|
Runtime.Clear();
|
|
Effects.ClearNetworkState();
|
|
_hookQueue.Clear();
|
|
Particles.Tick(0.01f);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Clear();
|
|
Runtime.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged;
|
|
Teleport.Dispose();
|
|
Presentation.Dispose();
|
|
}
|
|
|
|
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
|
{
|
|
if (record.WorldEntity is { } entity)
|
|
ParticleSink.SetEntityPresentationVisible(entity.Id, visible);
|
|
}
|
|
|
|
private static AnimationSequencer BuildRecallSequencer()
|
|
{
|
|
var setup = new Setup();
|
|
setup.Parts.Add(0x01000001u);
|
|
setup.DefaultScale.Add(Vector3.One);
|
|
|
|
var table = new MotionTable
|
|
{
|
|
DefaultStyle = (DRWMotionCommand)Style,
|
|
};
|
|
table.StyleDefaults[(DRWMotionCommand)Style] =
|
|
(DRWMotionCommand)ReadyMotion;
|
|
int readyKey = unchecked((int)((Style << 16) | (ReadyMotion & 0xFFFFFFu)));
|
|
table.Cycles[readyKey] = MotionData(IdleAnimationDid);
|
|
var links = new MotionCommandData();
|
|
links.MotionData[unchecked((int)RecallMotion)] = MotionData(RecallAnimationDid);
|
|
table.Links[readyKey] = links;
|
|
|
|
Animation idle = Animation(frames: 2);
|
|
Animation recall = Animation(frames: 2);
|
|
recall.PartFrames[0].Hooks.Add(new CallPESHook
|
|
{
|
|
Direction = AnimationHookDir.Forward,
|
|
PES = RecallScriptDid,
|
|
Pause = 0f,
|
|
});
|
|
var loader = new RecallAnimationLoader();
|
|
loader.Add(IdleAnimationDid, idle);
|
|
loader.Add(RecallAnimationDid, recall);
|
|
return new AnimationSequencer(setup, table, loader);
|
|
}
|
|
|
|
private static MotionData MotionData(uint animationDid)
|
|
{
|
|
var data = new MotionData();
|
|
QualifiedDataId<Animation> id = animationDid;
|
|
data.Anims.Add(new AnimData
|
|
{
|
|
AnimId = id,
|
|
LowFrame = 0,
|
|
HighFrame = -1,
|
|
Framerate = 10f,
|
|
});
|
|
return data;
|
|
}
|
|
|
|
private static Animation Animation(int frames)
|
|
{
|
|
var animation = new Animation();
|
|
for (int i = 0; i < frames; i++)
|
|
{
|
|
var frame = new AnimationFrame(1u);
|
|
frame.Frames.Add(new Frame
|
|
{
|
|
Origin = Vector3.Zero,
|
|
Orientation = Quaternion.Identity,
|
|
});
|
|
animation.PartFrames.Add(frame);
|
|
}
|
|
return animation;
|
|
}
|
|
|
|
private sealed class RecallAnimationLoader : IAnimationLoader
|
|
{
|
|
private readonly Dictionary<uint, Animation> _animations = new();
|
|
|
|
internal void Add(uint id, Animation animation) => _animations[id] = animation;
|
|
|
|
public Animation? LoadAnimation(uint id) =>
|
|
_animations.TryGetValue(id, out Animation? animation) ? animation : null;
|
|
}
|
|
|
|
private sealed class TestRemoteMotion : ILiveEntityRemotePlacementRuntime
|
|
{
|
|
private uint _cellId;
|
|
private Func<uint>? _readCell;
|
|
private Action<uint>? _writeCell;
|
|
|
|
public PhysicsBody Body { get; } = new();
|
|
public uint CellId
|
|
{
|
|
get => _readCell?.Invoke() ?? _cellId;
|
|
set
|
|
{
|
|
_cellId = value;
|
|
_writeCell?.Invoke(value);
|
|
}
|
|
}
|
|
public bool Airborne { get; set; }
|
|
public Vector3 LastServerPosition { get; set; }
|
|
public double LastServerPositionTime { get; set; }
|
|
public Vector3 LastShadowSyncPosition { get; set; }
|
|
public Quaternion LastShadowSyncOrientation { get; set; }
|
|
public void BindCanonicalCell(Func<uint> read, Action<uint> write)
|
|
{
|
|
_readCell = read;
|
|
_writeCell = write;
|
|
}
|
|
public void HitGround() { }
|
|
public void LeaveGround() { }
|
|
}
|
|
}
|
|
|
|
private static DatPhysicsScript PersistentScript()
|
|
{
|
|
var script = new DatPhysicsScript();
|
|
script.ScriptData.Add(new PhysicsScriptData
|
|
{
|
|
StartTime = 0.0,
|
|
Hook = new CreateParticleHook
|
|
{
|
|
EmitterInfoId = EmitterDid,
|
|
PartIndex = uint.MaxValue,
|
|
EmitterId = 1u,
|
|
Offset = new Frame
|
|
{
|
|
Origin = Vector3.Zero,
|
|
Orientation = Quaternion.Identity,
|
|
},
|
|
},
|
|
});
|
|
script.ScriptData.Add(new PhysicsScriptData
|
|
{
|
|
StartTime = 60.0,
|
|
Hook = new DestroyParticleHook { EmitterId = 1u },
|
|
});
|
|
return script;
|
|
}
|
|
|
|
private static DatPhysicsScript RecallScript()
|
|
{
|
|
var script = new DatPhysicsScript();
|
|
script.ScriptData.Add(new PhysicsScriptData
|
|
{
|
|
StartTime = 0.0,
|
|
Hook = new CreateParticleHook
|
|
{
|
|
EmitterInfoId = EmitterDid,
|
|
PartIndex = uint.MaxValue,
|
|
EmitterId = 7u,
|
|
Offset = new Frame
|
|
{
|
|
Origin = Vector3.Zero,
|
|
Orientation = Quaternion.Identity,
|
|
},
|
|
},
|
|
});
|
|
return script;
|
|
}
|
|
|
|
private static EmitterDesc PersistentEmitter() => new()
|
|
{
|
|
DatId = EmitterDid,
|
|
Type = AcDream.Core.Vfx.ParticleType.Still,
|
|
EmitterKind = ParticleEmitterKind.BirthratePerSec,
|
|
MaxParticles = 2,
|
|
InitialParticles = 1,
|
|
TotalParticles = 0,
|
|
TotalDuration = 0f,
|
|
Lifespan = 120f,
|
|
LifetimeMin = 120f,
|
|
LifetimeMax = 120f,
|
|
Birthrate = 0f,
|
|
StartSize = 0.25f,
|
|
EndSize = 0.25f,
|
|
StartAlpha = 1f,
|
|
EndAlpha = 1f,
|
|
};
|
|
|
|
private static Setup ProjectileSetup() => new()
|
|
{
|
|
Spheres =
|
|
{
|
|
new Sphere
|
|
{
|
|
Origin = new Vector3(0f, -0.165f, 0.1045f),
|
|
Radius = 0.102f,
|
|
},
|
|
},
|
|
Lights =
|
|
{
|
|
[0] = new LightInfo
|
|
{
|
|
ViewSpaceLocation = new Frame
|
|
{
|
|
Origin = Vector3.Zero,
|
|
Orientation = Quaternion.Identity,
|
|
},
|
|
Color = new ColorARGB
|
|
{
|
|
Red = 255,
|
|
Green = 255,
|
|
Blue = 255,
|
|
Alpha = 255,
|
|
},
|
|
Intensity = 1f,
|
|
Falloff = 8f,
|
|
},
|
|
},
|
|
};
|
|
|
|
private static WorldSession.EntitySpawn MissileSpawn(
|
|
uint guid,
|
|
ushort generation,
|
|
Vector3 position)
|
|
{
|
|
var wirePosition = new CreateObject.ServerPosition(
|
|
CellA,
|
|
position.X,
|
|
position.Y,
|
|
position.Z,
|
|
1f, 0f, 0f, 0f);
|
|
var timestamps = new PhysicsTimestamps(
|
|
Position: 1,
|
|
Movement: 1,
|
|
State: 1,
|
|
Vector: 1,
|
|
Teleport: 0,
|
|
ServerControlledMove: 1,
|
|
ForcePosition: 0,
|
|
ObjDesc: 1,
|
|
Instance: generation);
|
|
var physics = new PhysicsSpawnData(
|
|
RawState: (uint)MissileState,
|
|
Position: wirePosition,
|
|
Movement: null,
|
|
AnimationFrame: null,
|
|
SetupTableId: SetupDid,
|
|
MotionTableId: null,
|
|
SoundTableId: null,
|
|
PhysicsScriptTableId: null,
|
|
Parent: null,
|
|
Children: null,
|
|
Scale: 1f,
|
|
Friction: 0f,
|
|
Elasticity: 0f,
|
|
Translucency: null,
|
|
Velocity: new Vector3(2f, 0f, 0f),
|
|
Acceleration: null,
|
|
AngularVelocity: Vector3.Zero,
|
|
DefaultScriptType: null,
|
|
DefaultScriptIntensity: null,
|
|
Timestamps: timestamps);
|
|
return new WorldSession.EntitySpawn(
|
|
guid,
|
|
wirePosition,
|
|
SetupDid,
|
|
Array.Empty<CreateObject.AnimPartChange>(),
|
|
Array.Empty<CreateObject.TextureChange>(),
|
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
|
BasePaletteId: null,
|
|
ObjScale: 1f,
|
|
Name: "Stress missile",
|
|
ItemType: null,
|
|
MotionState: null,
|
|
MotionTableId: null,
|
|
PhysicsState: (uint)MissileState,
|
|
Friction: 0f,
|
|
Elasticity: 0f,
|
|
InstanceSequence: generation,
|
|
MovementSequence: 1,
|
|
ServerControlSequence: 1,
|
|
PositionSequence: 1,
|
|
Physics: physics);
|
|
}
|
|
|
|
private static WorldSession.EntitySpawn RecallSpawn(uint guid, uint cell)
|
|
{
|
|
var wirePosition = new CreateObject.ServerPosition(
|
|
cell, 10f, 10f, 10f, 1f, 0f, 0f, 0f);
|
|
var timestamps = new PhysicsTimestamps(
|
|
Position: 1,
|
|
Movement: 1,
|
|
State: 1,
|
|
Vector: 1,
|
|
Teleport: 1,
|
|
ServerControlledMove: 1,
|
|
ForcePosition: 1,
|
|
ObjDesc: 1,
|
|
Instance: 1);
|
|
var physics = new PhysicsSpawnData(
|
|
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
|
Position: wirePosition,
|
|
Movement: null,
|
|
AnimationFrame: null,
|
|
SetupTableId: SetupDid,
|
|
MotionTableId: 0x09000001u,
|
|
SoundTableId: null,
|
|
PhysicsScriptTableId: null,
|
|
Parent: null,
|
|
Children: null,
|
|
Scale: 1f,
|
|
Friction: null,
|
|
Elasticity: null,
|
|
Translucency: null,
|
|
Velocity: null,
|
|
Acceleration: null,
|
|
AngularVelocity: null,
|
|
DefaultScriptType: null,
|
|
DefaultScriptIntensity: null,
|
|
Timestamps: timestamps);
|
|
return new WorldSession.EntitySpawn(
|
|
guid,
|
|
wirePosition,
|
|
SetupDid,
|
|
Array.Empty<CreateObject.AnimPartChange>(),
|
|
Array.Empty<CreateObject.TextureChange>(),
|
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
|
BasePaletteId: null,
|
|
ObjScale: 1f,
|
|
Name: "Recall stress owner",
|
|
ItemType: null,
|
|
MotionState: null,
|
|
MotionTableId: 0x09000001u,
|
|
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
|
InstanceSequence: 1,
|
|
MovementSequence: 1,
|
|
ServerControlSequence: 1,
|
|
PositionSequence: 1,
|
|
Physics: physics);
|
|
}
|
|
|
|
private static void AddEmptyPhysicsLandblock(
|
|
PhysicsEngine engine,
|
|
uint landblockId,
|
|
float offsetX,
|
|
float offsetY)
|
|
{
|
|
var heights = new byte[81];
|
|
var table = new float[256];
|
|
Array.Fill(table, -1000f);
|
|
engine.AddLandblock(
|
|
landblockId,
|
|
new TerrainSurface(heights, table),
|
|
Array.Empty<CellSurface>(),
|
|
Array.Empty<PortalPlane>(),
|
|
offsetX,
|
|
offsetY);
|
|
}
|
|
|
|
private static LoadedLandblock EmptyLandblock(uint canonicalId) =>
|
|
new(canonicalId, new LandBlock(), Array.Empty<WorldEntity>());
|
|
}
|