refactor(world): separate live lifetime from spatial buckets
Introduce LiveEntityRuntime as the canonical owner of each accepted server-object incarnation, stable local identity, timestamped state, parent relations, runtime components, and exactly-once teardown. Split logical registration from rebucketing so pending landblocks, equipment attachment, pickup re-entry, and GUID replacement reuse the same entity and effect owners. Keep canonical materialized and visible target/radar views distinct, preserve retail leave_world versus exit_world semantics, gate root simulation while cell-less, and track transitional pre-Create F754 owners through delete and session reset. Remove stale-spawn rehydration and make GpuWorldState spatial-only for live objects. Add lifecycle, generation, pending, unload, attachment, event-publication, local-ID, rollback, and effect-cleanup coverage; update architecture, milestones, memory, and the divergence register. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
parent
8a5d77f7f4
commit
8dd996053d
24 changed files with 2449 additions and 631 deletions
876
tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs
Normal file
876
tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs
Normal file
|
|
@ -0,0 +1,876 @@
|
|||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
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.Types;
|
||||
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
|
||||
|
||||
namespace AcDream.App.Tests.World;
|
||||
|
||||
public sealed class LiveEntityRuntimeTests
|
||||
{
|
||||
private sealed class RecordingResources : ILiveEntityResourceLifecycle
|
||||
{
|
||||
public int RegisterCount { get; private set; }
|
||||
public int UnregisterCount { get; private set; }
|
||||
public void Register(WorldEntity entity) => RegisterCount++;
|
||||
public void Unregister(WorldEntity entity) => UnregisterCount++;
|
||||
}
|
||||
|
||||
private sealed class AnimationRuntime(WorldEntity entity) : ILiveEntityAnimationRuntime
|
||||
{
|
||||
public WorldEntity Entity { get; } = entity;
|
||||
}
|
||||
|
||||
private sealed class FailingRegisterResources : ILiveEntityResourceLifecycle
|
||||
{
|
||||
public int RegisterCount { get; private set; }
|
||||
public int UnregisterCount { get; private set; }
|
||||
|
||||
public void Register(WorldEntity entity)
|
||||
{
|
||||
RegisterCount++;
|
||||
throw new InvalidOperationException("fixture registration failure");
|
||||
}
|
||||
|
||||
public void Unregister(WorldEntity entity) => UnregisterCount++;
|
||||
}
|
||||
|
||||
private sealed class FailingUnregisterResources(uint failingGuid) : ILiveEntityResourceLifecycle
|
||||
{
|
||||
public int UnregisterCount { get; private set; }
|
||||
|
||||
public void Register(WorldEntity entity) { }
|
||||
|
||||
public void Unregister(WorldEntity entity)
|
||||
{
|
||||
UnregisterCount++;
|
||||
if (entity.ServerGuid == failingGuid)
|
||||
throw new InvalidOperationException("fixture unregister failure");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterRebucketWithdrawAndRestore_UsesOneLogicalCreate()
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
|
||||
var resources = new RecordingResources();
|
||||
var runtime = new LiveEntityRuntime(spatial, resources);
|
||||
WorldSession.EntitySpawn spawn = Spawn(0x70000001u, 1, 1, 0x01010001u);
|
||||
|
||||
LiveEntityRegistrationResult registered = runtime.RegisterLiveEntity(spawn);
|
||||
WorldEntity? entity = runtime.MaterializeLiveEntity(
|
||||
spawn.Guid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, spawn.Guid));
|
||||
runtime.SetAnimationRuntime(spawn.Guid, new AnimationRuntime(entity!));
|
||||
|
||||
Assert.True(registered.LogicalRegistrationCreated);
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.Single(spatial.Entities);
|
||||
Assert.True(runtime.RebucketLiveEntity(spawn.Guid, 0x01020001u));
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.Single(spatial.Entities);
|
||||
Assert.True(runtime.WithdrawLiveEntityProjection(spawn.Guid));
|
||||
Assert.Empty(spatial.Entities);
|
||||
Assert.Empty(runtime.WorldEntities);
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.Equal(0, resources.UnregisterCount);
|
||||
Assert.True(runtime.TryGetAnimationRuntime(entity!.Id, out _));
|
||||
Assert.True(runtime.RebucketLiveEntity(spawn.Guid, 0x01010001u));
|
||||
Assert.Single(spatial.Entities);
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.True(runtime.TryGetAnimationRuntime(entity!.Id, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitialChildCreate_PreservesParentEventQueuedForMissingChild()
|
||||
{
|
||||
const uint parentGuid = 0x70000020u;
|
||||
const uint childGuid = 0x70000021u;
|
||||
var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources());
|
||||
runtime.RegisterLiveEntity(Spawn(parentGuid, 9, 1, 0x01010001u));
|
||||
runtime.ParentAttachments.Enqueue(new ParentEvent.Parsed(
|
||||
parentGuid, childGuid, 1, 2, 9, 5));
|
||||
ResolveParent(runtime, childGuid);
|
||||
|
||||
runtime.RegisterLiveEntity(Spawn(childGuid, 3, 4, 0x01010001u));
|
||||
ResolveParent(runtime, childGuid);
|
||||
|
||||
Assert.True(runtime.ParentAttachments.TryGetProjection(
|
||||
childGuid, out ParentAttachmentRelation relation));
|
||||
Assert.Equal(parentGuid, relation.ParentGuid);
|
||||
Assert.True(runtime.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn child));
|
||||
Assert.Null(child.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitialParentCreate_PreservesCreateObjectRelationWaitingForParent()
|
||||
{
|
||||
const uint parentGuid = 0x70000022u;
|
||||
const uint childGuid = 0x70000023u;
|
||||
var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources());
|
||||
runtime.RegisterLiveEntity(Spawn(childGuid, 3, 4, 0x01010001u));
|
||||
runtime.ParentAttachments.AcceptCreateObjectRelation(new ParentAttachmentRelation(
|
||||
parentGuid, childGuid, 1, 2, 9, 4));
|
||||
|
||||
runtime.RegisterLiveEntity(Spawn(parentGuid, 9, 1, 0x01010001u));
|
||||
|
||||
Assert.True(runtime.ParentAttachments.TryGetProjection(
|
||||
childGuid, out ParentAttachmentRelation relation));
|
||||
Assert.Equal(parentGuid, relation.ParentGuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameGenerationCreate_DoesNotReconstructProjection()
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var resources = new RecordingResources();
|
||||
var runtime = new LiveEntityRuntime(spatial, resources);
|
||||
WorldSession.EntitySpawn first = Spawn(0x70000002u, 4, 10, 0x01010001u);
|
||||
runtime.RegisterLiveEntity(first);
|
||||
WorldEntity original = runtime.MaterializeLiveEntity(
|
||||
first.Guid,
|
||||
first.Position!.Value.LandblockId,
|
||||
id => Entity(id, first.Guid))!;
|
||||
|
||||
LiveEntityRegistrationResult refresh = runtime.RegisterLiveEntity(
|
||||
Spawn(first.Guid, 4, 11, 0x01010001u));
|
||||
WorldEntity retained = runtime.MaterializeLiveEntity(
|
||||
first.Guid,
|
||||
first.Position.Value.LandblockId,
|
||||
_ => throw new InvalidOperationException("same generation reconstructed"))!;
|
||||
|
||||
Assert.False(refresh.LogicalRegistrationCreated);
|
||||
Assert.Same(original, retained);
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.Equal(0, resources.UnregisterCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewGeneration_TearsDownExactlyOnceAndReusesNoLocalIdentity()
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var resources = new RecordingResources();
|
||||
int runtimeTearDowns = 0;
|
||||
var runtime = new LiveEntityRuntime(
|
||||
spatial,
|
||||
resources,
|
||||
_ => runtimeTearDowns++);
|
||||
const uint guid = 0x70000003u;
|
||||
|
||||
runtime.RegisterLiveEntity(Spawn(guid, 9, 1, 0x01010001u));
|
||||
WorldEntity old = runtime.MaterializeLiveEntity(
|
||||
guid, 0x01010001u, id => Entity(id, guid))!;
|
||||
LiveEntityRegistrationResult replacement = runtime.RegisterLiveEntity(
|
||||
Spawn(guid, 10, 1, 0x01010001u));
|
||||
WorldEntity current = runtime.MaterializeLiveEntity(
|
||||
guid, 0x01010001u, id => Entity(id, guid))!;
|
||||
|
||||
Assert.True(replacement.ReplacedExistingGeneration);
|
||||
Assert.NotEqual(old.Id, current.Id);
|
||||
Assert.Equal(2, resources.RegisterCount);
|
||||
Assert.Equal(1, resources.UnregisterCount);
|
||||
Assert.Equal(1, runtimeTearDowns);
|
||||
Assert.Single(spatial.Entities);
|
||||
Assert.Same(current, spatial.Entities[0]);
|
||||
Assert.False(runtime.TryGetServerGuid(old.Id, out _));
|
||||
Assert.True(runtime.TryGetServerGuid(current.Id, out uint mapped));
|
||||
Assert.Equal(guid, mapped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppearanceUpdate_MutatesSnapshotWithoutChangingIdentity()
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var resources = new RecordingResources();
|
||||
var runtime = new LiveEntityRuntime(spatial, resources);
|
||||
WorldSession.EntitySpawn spawn = Spawn(0x70000004u, 2, 3, 0x01010001u);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
WorldEntity entity = runtime.MaterializeLiveEntity(
|
||||
spawn.Guid, 0x01010001u, id => Entity(id, spawn.Guid))!;
|
||||
|
||||
var update = new ObjDescEvent.Parsed(
|
||||
spawn.Guid,
|
||||
new CreateObject.ModelData(
|
||||
0x04000001u,
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.AnimPartChange>()),
|
||||
InstanceSequence: 2,
|
||||
ObjDescSequence: 2);
|
||||
Assert.True(runtime.TryApplyObjDesc(update, out _));
|
||||
|
||||
Assert.True(runtime.TryGetRecord(spawn.Guid, out LiveEntityRecord record));
|
||||
Assert.Same(entity, record.WorldEntity);
|
||||
Assert.Equal((uint)0x04000001u, record.Snapshot.BasePaletteId);
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.Equal(0, resources.UnregisterCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteAndGuidReuse_HaveSeparateLogicalLifetimes()
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var resources = new RecordingResources();
|
||||
var runtime = new LiveEntityRuntime(spatial, resources);
|
||||
const uint guid = 0x70000005u;
|
||||
runtime.RegisterLiveEntity(Spawn(guid, 3, 1, 0x01010001u));
|
||||
WorldEntity first = runtime.MaterializeLiveEntity(
|
||||
guid, 0x01010001u, id => Entity(id, guid))!;
|
||||
|
||||
Assert.True(runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(guid, 3),
|
||||
isLocalPlayer: false));
|
||||
Assert.Empty(spatial.Entities);
|
||||
Assert.False(runtime.TryGetRecord(guid, out _));
|
||||
|
||||
runtime.RegisterLiveEntity(Spawn(guid, 3, 1, 0x01010001u));
|
||||
WorldEntity second = runtime.MaterializeLiveEntity(
|
||||
guid, 0x01010001u, id => Entity(id, guid))!;
|
||||
|
||||
Assert.NotEqual(first.Id, second.Id);
|
||||
Assert.Equal(2, resources.RegisterCount);
|
||||
Assert.Equal(1, resources.UnregisterCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttachedProjection_RendersWithoutEnteringTopLevelWorldIndex()
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var resources = new RecordingResources();
|
||||
var runtime = new LiveEntityRuntime(spatial, resources);
|
||||
WorldSession.EntitySpawn spawn = Spawn(0x70000006u, 3, 1, 0x01010001u);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
|
||||
WorldEntity attached = runtime.MaterializeLiveEntity(
|
||||
spawn.Guid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, spawn.Guid),
|
||||
LiveEntityProjectionKind.Attached)!;
|
||||
|
||||
Assert.Single(spatial.Entities);
|
||||
Assert.Empty(runtime.WorldEntities);
|
||||
Assert.False(runtime.TryMarkWorldSpawnPublished(spawn.Guid));
|
||||
Assert.True(runtime.TryGetWorldEntity(spawn.Guid, out WorldEntity resolved));
|
||||
Assert.Same(attached, resolved);
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
|
||||
WorldEntity same = runtime.MaterializeLiveEntity(
|
||||
spawn.Guid,
|
||||
spawn.Position.Value.LandblockId,
|
||||
_ => throw new InvalidOperationException("attachment reconstructed"),
|
||||
LiveEntityProjectionKind.World)!;
|
||||
|
||||
Assert.Same(attached, same);
|
||||
Assert.Same(attached, Assert.Single(runtime.WorldEntities).Value);
|
||||
Assert.True(runtime.TryMarkWorldSpawnPublished(spawn.Guid));
|
||||
Assert.False(runtime.TryMarkWorldSpawnPublished(spawn.Guid));
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingToLoadedTransition_DoesNotReplayLogicalRegistration()
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
var resources = new RecordingResources();
|
||||
var runtime = new LiveEntityRuntime(spatial, resources);
|
||||
WorldSession.EntitySpawn spawn = Spawn(0x70000007u, 1, 1, 0x01020001u);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
WorldEntity entity = runtime.MaterializeLiveEntity(
|
||||
spawn.Guid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, spawn.Guid))!;
|
||||
|
||||
Assert.Empty(spatial.Entities);
|
||||
Assert.Equal(1, spatial.PendingLiveEntityCount);
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.Empty(runtime.WorldEntities);
|
||||
Assert.Same(entity, Assert.Single(runtime.MaterializedWorldEntities).Value);
|
||||
|
||||
spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
|
||||
Assert.Same(entity, Assert.Single(spatial.Entities));
|
||||
Assert.Same(entity, Assert.Single(runtime.WorldEntities).Value);
|
||||
Assert.True(runtime.RebucketLiveEntity(spawn.Guid, 0x01020001u));
|
||||
Assert.Same(entity, Assert.Single(spatial.Entities));
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.Equal(0, resources.UnregisterCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingProjection_RemainsCanonicalAcrossAuthoritativeUpdatesWithoutReregister()
|
||||
{
|
||||
const uint guid = 0x70000025u;
|
||||
var spatial = new GpuWorldState();
|
||||
var resources = new RecordingResources();
|
||||
var runtime = new LiveEntityRuntime(spatial, resources);
|
||||
WorldSession.EntitySpawn spawn = Spawn(guid, 2, 1, 0x01010001u);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
WorldEntity entity = runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, guid))!;
|
||||
|
||||
Assert.Empty(runtime.WorldEntities);
|
||||
Assert.Same(entity, Assert.Single(runtime.MaterializedWorldEntities).Value);
|
||||
Assert.True(runtime.TryApplyVector(
|
||||
new VectorUpdate.Parsed(
|
||||
guid,
|
||||
new System.Numerics.Vector3(2f, 3f, 4f),
|
||||
System.Numerics.Vector3.Zero,
|
||||
spawn.InstanceSequence,
|
||||
2),
|
||||
out _));
|
||||
var positionUpdate = new WorldSession.EntityPositionUpdate(
|
||||
guid,
|
||||
new CreateObject.ServerPosition(
|
||||
0x01020001u, 20f, 30f, 6f, 1f, 0f, 0f, 0f),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
spawn.InstanceSequence,
|
||||
2,
|
||||
0,
|
||||
0);
|
||||
Assert.True(runtime.TryApplyPosition(
|
||||
positionUpdate,
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
out PositionTimestampDisposition disposition,
|
||||
out WorldSession.EntitySpawn accepted,
|
||||
out _));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||
Assert.True(runtime.RebucketLiveEntity(guid, accepted.Position!.Value.LandblockId));
|
||||
|
||||
Assert.Empty(runtime.WorldEntities);
|
||||
Assert.Same(entity, Assert.Single(runtime.MaterializedWorldEntities).Value);
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.Equal(0, resources.UnregisterCount);
|
||||
Assert.Equal(1, spatial.PendingLiveEntityCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PickupLeaveWorld_PreservesOwnersButPausesRootUntilPositionReentry()
|
||||
{
|
||||
const uint guid = 0x70000026u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var resources = new RecordingResources();
|
||||
var runtime = new LiveEntityRuntime(spatial, resources);
|
||||
WorldSession.EntitySpawn spawn = Spawn(guid, 4, 10, 0x01010001u);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
WorldEntity entity = runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, guid))!;
|
||||
var animation = new AnimationRuntime(entity);
|
||||
runtime.SetAnimationRuntime(guid, animation);
|
||||
uint localId = entity.Id;
|
||||
Assert.True(runtime.TryMarkWorldSpawnPublished(guid));
|
||||
|
||||
Assert.True(runtime.TryApplyPickup(
|
||||
new PickupEvent.Parsed(guid, spawn.InstanceSequence, 11),
|
||||
out _));
|
||||
Assert.True(runtime.WithdrawLiveEntityProjection(guid));
|
||||
|
||||
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
|
||||
Assert.Equal(0u, record.FullCellId);
|
||||
Assert.Equal(0u, record.CanonicalLandblockId);
|
||||
Assert.False(runtime.ShouldAdvanceRootRuntime(guid));
|
||||
Assert.True(runtime.TryGetAnimationRuntime(localId, out var retainedAnimation));
|
||||
Assert.Same(animation, retainedAnimation);
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.Equal(0, resources.UnregisterCount);
|
||||
|
||||
var positionUpdate = new WorldSession.EntityPositionUpdate(
|
||||
guid,
|
||||
new CreateObject.ServerPosition(
|
||||
0x01010002u, 30f, 40f, 7f, 1f, 0f, 0f, 0f),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
spawn.InstanceSequence,
|
||||
12,
|
||||
0,
|
||||
0);
|
||||
Assert.True(runtime.TryApplyPosition(
|
||||
positionUpdate,
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
out PositionTimestampDisposition disposition,
|
||||
out WorldSession.EntitySpawn accepted,
|
||||
out _));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||
WorldEntity same = runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
accepted.Position!.Value.LandblockId,
|
||||
_ => throw new InvalidOperationException("re-entry reconstructed the projection"))!;
|
||||
|
||||
Assert.Same(entity, same);
|
||||
Assert.Equal(localId, same.Id);
|
||||
Assert.False(runtime.TryMarkWorldSpawnPublished(guid));
|
||||
Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
|
||||
Assert.Equal(0x01010002u, record.FullCellId);
|
||||
Assert.True(runtime.TryGetAnimationRuntime(localId, out retainedAnimation));
|
||||
Assert.Same(animation, retainedAnimation);
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.Equal(0, resources.UnregisterCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LandblockUnload_HidesTopLevelViewAndReloadRestoresSameIdentity()
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var resources = new RecordingResources();
|
||||
var runtime = new LiveEntityRuntime(spatial, resources);
|
||||
WorldSession.EntitySpawn spawn = Spawn(0x70000024u, 1, 1, 0x01010001u);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
WorldEntity entity = runtime.MaterializeLiveEntity(
|
||||
spawn.Guid,
|
||||
0x01010001u,
|
||||
id => Entity(id, spawn.Guid))!;
|
||||
|
||||
spatial.RemoveLandblock(0x0101FFFFu);
|
||||
|
||||
Assert.Empty(runtime.WorldEntities);
|
||||
Assert.True(runtime.TryGetRecord(spawn.Guid, out LiveEntityRecord record));
|
||||
Assert.True(record.IsSpatiallyProjected);
|
||||
Assert.False(record.IsSpatiallyVisible);
|
||||
Assert.Equal(1, spatial.PendingLiveEntityCount);
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.Equal(0, resources.UnregisterCount);
|
||||
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
Assert.Same(entity, Assert.Single(runtime.WorldEntities).Value);
|
||||
Assert.True(record.IsSpatiallyVisible);
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadedToPendingToLoaded_RemovesOldDrawSlotWithoutLogicalRecreate()
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var resources = new RecordingResources();
|
||||
var runtime = new LiveEntityRuntime(spatial, resources);
|
||||
WorldSession.EntitySpawn spawn = Spawn(0x70000009u, 1, 1, 0x01010001u);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
WorldEntity entity = runtime.MaterializeLiveEntity(
|
||||
spawn.Guid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, spawn.Guid))!;
|
||||
|
||||
Assert.Same(entity, Assert.Single(spatial.Entities));
|
||||
Assert.True(runtime.RebucketLiveEntity(spawn.Guid, 0x01020001u));
|
||||
Assert.Empty(spatial.Entities);
|
||||
Assert.Equal(1, spatial.PendingLiveEntityCount);
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
|
||||
spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
|
||||
Assert.Same(entity, Assert.Single(spatial.Entities));
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.Equal(0, resources.UnregisterCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionClear_IsSymmetricAndIdempotent()
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var resources = new RecordingResources();
|
||||
int runtimeTearDowns = 0;
|
||||
var runtime = new LiveEntityRuntime(spatial, resources, _ => runtimeTearDowns++);
|
||||
WorldSession.EntitySpawn spawn = Spawn(0x70000008u, 1, 1, 0x01010001u);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
runtime.MaterializeLiveEntity(
|
||||
spawn.Guid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, spawn.Guid));
|
||||
|
||||
runtime.Clear();
|
||||
runtime.Clear();
|
||||
|
||||
Assert.Equal(0, runtime.Count);
|
||||
Assert.Empty(runtime.WorldEntities);
|
||||
Assert.Empty(spatial.Entities);
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.Equal(1, resources.UnregisterCount);
|
||||
Assert.Equal(1, runtimeTearDowns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResourceRegistrationFailure_RollsBackMaterializedIdentityAndSpatialState()
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var resources = new FailingRegisterResources();
|
||||
var runtime = new LiveEntityRuntime(spatial, resources);
|
||||
WorldSession.EntitySpawn spawn = Spawn(0x7000000Au, 1, 1, 0x01010001u);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => runtime.MaterializeLiveEntity(
|
||||
spawn.Guid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, spawn.Guid)));
|
||||
|
||||
Assert.True(runtime.TryGetRecord(spawn.Guid, out LiveEntityRecord record));
|
||||
Assert.Null(record.WorldEntity);
|
||||
Assert.Equal(0, runtime.MaterializedCount);
|
||||
Assert.Empty(runtime.WorldEntities);
|
||||
Assert.Empty(spatial.Entities);
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.Equal(1, resources.UnregisterCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionClear_AttemptsEveryTeardownAndClearsIdentityWhenOneResourceFails()
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var resources = new FailingUnregisterResources(0x7000000Bu);
|
||||
var runtime = new LiveEntityRuntime(spatial, resources);
|
||||
foreach (uint guid in new[] { 0x7000000Bu, 0x7000000Cu })
|
||||
{
|
||||
WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, guid));
|
||||
}
|
||||
|
||||
Assert.Throws<AggregateException>(() => runtime.Clear());
|
||||
|
||||
Assert.Equal(2, resources.UnregisterCount);
|
||||
Assert.Equal(0, runtime.Count);
|
||||
Assert.Equal(0, runtime.MaterializedCount);
|
||||
Assert.Empty(runtime.WorldEntities);
|
||||
Assert.Empty(spatial.Entities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerationReplacement_InstallsNewRecordBeforeReportingOldCleanupFailure()
|
||||
{
|
||||
const uint guid = 0x7000000Eu;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var resources = new FailingUnregisterResources(guid);
|
||||
var runtime = new LiveEntityRuntime(spatial, resources);
|
||||
WorldSession.EntitySpawn first = Spawn(guid, 1, 1, 0x01010001u);
|
||||
runtime.RegisterLiveEntity(first);
|
||||
runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
first.Position!.Value.LandblockId,
|
||||
id => Entity(id, guid));
|
||||
|
||||
LiveEntityRegistrationResult replacement = runtime.RegisterLiveEntity(
|
||||
Spawn(guid, 2, 1, 0x01010001u));
|
||||
|
||||
Assert.NotNull(replacement.PriorGenerationCleanupFailure);
|
||||
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
|
||||
Assert.Equal((ushort)2, record.Generation);
|
||||
Assert.Null(record.WorldEntity);
|
||||
WorldEntity installed = runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x01010001u,
|
||||
id => Entity(id, guid))!;
|
||||
Assert.Equal(guid, installed.ServerGuid);
|
||||
Assert.Single(spatial.Entities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalOnlyRebucket_DoesNotOverwriteAuthoritativeFullCell()
|
||||
{
|
||||
const uint guid = 0x7000000Fu;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010022u));
|
||||
runtime.MaterializeLiveEntity(guid, 0x01010022u, id => Entity(id, guid));
|
||||
|
||||
runtime.RebucketLiveEntity(guid, 0x0102FFFFu);
|
||||
|
||||
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
|
||||
Assert.Equal(0x01010022u, record.FullCellId);
|
||||
Assert.Equal(0x0102FFFFu, record.CanonicalLandblockId);
|
||||
|
||||
runtime.RebucketLiveEntity(guid, 0x01020033u);
|
||||
Assert.Equal(0x01020033u, record.FullCellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttachedProjection_CrossesLandblocksWithoutChangingIdentityOrResources()
|
||||
{
|
||||
const uint guid = 0x70000010u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
|
||||
var resources = new RecordingResources();
|
||||
var runtime = new LiveEntityRuntime(spatial, resources);
|
||||
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
|
||||
WorldEntity attached = runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x01010001u,
|
||||
id => Entity(id, guid),
|
||||
LiveEntityProjectionKind.Attached)!;
|
||||
|
||||
runtime.RebucketLiveEntity(guid, 0x01020001u);
|
||||
|
||||
Assert.Same(attached, Assert.Single(spatial.Entities));
|
||||
Assert.Empty(runtime.WorldEntities);
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.Equal(0, resources.UnregisterCount);
|
||||
var destination = Assert.Single(
|
||||
spatial.LandblockEntries,
|
||||
entry => entry.LandblockId == 0x0102FFFFu);
|
||||
Assert.Same(attached, Assert.Single(destination.Entities));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalIdAllocator_WrapsInsideLiveNamespaceWithoutReturningZero()
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var runtime = new LiveEntityRuntime(
|
||||
spatial,
|
||||
new RecordingResources(),
|
||||
firstLocalEntityId: LiveEntityRuntime.LastLiveEntityId);
|
||||
WorldSession.EntitySpawn first = Spawn(0x70000011u, 1, 1, 0x01010001u);
|
||||
WorldSession.EntitySpawn second = Spawn(0x70000012u, 1, 1, 0x01010001u);
|
||||
runtime.RegisterLiveEntity(first);
|
||||
runtime.RegisterLiveEntity(second);
|
||||
|
||||
WorldEntity firstEntity = runtime.MaterializeLiveEntity(
|
||||
first.Guid, 0x01010001u, id => Entity(id, first.Guid))!;
|
||||
WorldEntity secondEntity = runtime.MaterializeLiveEntity(
|
||||
second.Guid, 0x01010001u, id => Entity(id, second.Guid))!;
|
||||
|
||||
Assert.Equal(LiveEntityRuntime.LastLiveEntityId, firstEntity.Id);
|
||||
Assert.Equal(LiveEntityRuntime.FirstLiveEntityId, secondEntity.Id);
|
||||
Assert.NotEqual(0u, secondEntity.Id);
|
||||
Assert.True(runtime.TryGetLocalEntityId(second.Guid, out uint resolved));
|
||||
Assert.Equal(secondEntity.Id, resolved);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new LiveEntityRuntime(
|
||||
new GpuWorldState(),
|
||||
new RecordingResources(),
|
||||
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()
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var resources = new RecordingResources();
|
||||
var runtime = new LiveEntityRuntime(spatial, resources);
|
||||
WorldSession.EntitySpawn spawn = Spawn(0x7000000Du, 4, 1, 0x01010001u);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
runtime.MaterializeLiveEntity(
|
||||
spawn.Guid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, spawn.Guid));
|
||||
|
||||
Assert.Throws<AggregateException>(() => runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence),
|
||||
isLocalPlayer: false,
|
||||
beforeTeardown: () => throw new InvalidOperationException("fixture callback failure")));
|
||||
|
||||
Assert.Equal(0, runtime.Count);
|
||||
Assert.Empty(runtime.WorldEntities);
|
||||
Assert.Empty(spatial.Entities);
|
||||
Assert.Equal(1, resources.UnregisterCount);
|
||||
}
|
||||
|
||||
private static LoadedLandblock EmptyLandblock(uint canonicalId) =>
|
||||
new(canonicalId, new LandBlock(), Array.Empty<WorldEntity>());
|
||||
|
||||
private static void ResolveParent(LiveEntityRuntime runtime, uint childGuid) =>
|
||||
runtime.ParentAttachments.Resolve(
|
||||
childGuid,
|
||||
guid => runtime.TryGetSnapshot(guid, out _),
|
||||
guid => runtime.TryGetSnapshot(guid, out WorldSession.EntitySpawn spawn)
|
||||
? spawn.InstanceSequence
|
||||
: null,
|
||||
update => runtime.TryApplyParent(update, out _));
|
||||
|
||||
private static WorldEntity Entity(uint id, uint guid) => new()
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = System.Numerics.Vector3.Zero,
|
||||
Rotation = System.Numerics.Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(
|
||||
uint guid,
|
||||
ushort instance,
|
||||
ushort positionSequence,
|
||||
uint cell)
|
||||
{
|
||||
var position = new CreateObject.ServerPosition(
|
||||
cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
|
||||
var timestamps = new PhysicsTimestamps(
|
||||
positionSequence, 1, 1, 1, 0, 1, 0, 1, instance);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
Position: position,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: 0x02000001u,
|
||||
MotionTableId: 0x09000001u,
|
||||
SoundTableId: null,
|
||||
PhysicsScriptTableId: null,
|
||||
Parent: null,
|
||||
Children: null,
|
||||
Scale: null,
|
||||
Friction: null,
|
||||
Elasticity: null,
|
||||
Translucency: null,
|
||||
Velocity: null,
|
||||
Acceleration: null,
|
||||
AngularVelocity: null,
|
||||
DefaultScriptType: null,
|
||||
DefaultScriptIntensity: null,
|
||||
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: instance,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: positionSequence,
|
||||
Physics: physics);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue