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:
Erik 2026-07-14 07:47:03 +02:00
parent 8a5d77f7f4
commit 8dd996053d
24 changed files with 2449 additions and 631 deletions

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

View file

@ -1,4 +1,3 @@
using AcDream.App.Rendering;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;

View file

@ -324,4 +324,42 @@ public sealed class EntityScriptActivatorTests
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

@ -34,8 +34,8 @@ public sealed class PendingSpawnIntegrationTests
// the landblock streams in. ServerGuid != 0 makes this per-instance-tier.
var liveEntity = MakeServerSpawned(
id: 1, serverGuid: 0xCAFE0001u, gfxObjId: 0x01000099u);
// AppendLiveEntity takes the raw cell-form id; it canonicalises internally.
state.AppendLiveEntity(0x12340011u, liveEntity);
// Projection placement takes the raw cell-form id; it canonicalises internally.
state.PlaceLiveEntityProjection(0x12340011u, liveEntity);
Assert.Equal(1, state.PendingLiveEntityCount);
Assert.Empty(captured.IncrementCalls); // not registered yet — landblock not loaded
@ -86,9 +86,9 @@ public sealed class PendingSpawnIntegrationTests
// Now a live entity arrives — landblock is already loaded.
var liveEntity = MakeServerSpawned(id: 2, serverGuid: 0xCAFE0001u, gfxObjId: 0x01000099u);
state.AppendLiveEntity(0x12340022u, liveEntity);
state.PlaceLiveEntityProjection(0x12340022u, liveEntity);
// Adapter not invoked again — AppendLiveEntity doesn't drive ref counts.
// Adapter not invoked again — projection placement doesn't drive ref counts.
Assert.Single(captured.IncrementCalls);
Assert.Equal(0, state.PendingLiveEntityCount);
}

View file

@ -20,7 +20,7 @@ namespace AcDream.Core.Tests.Streaming;
/// dat-hydration paths (AddLandblock, AddEntitiesToExistingLandblock,
/// RemoveLandblock, RemoveEntitiesFromLandblock), and that the
/// pending-bucket merge in AddLandblock does NOT double-fire for live
/// entities that already had OnCreate at <see cref="GpuWorldState.AppendLiveEntity"/>.
/// entities, whose logical activation belongs to LiveEntityRuntime.
/// </summary>
public sealed class GpuWorldStateActivatorTests
{
@ -99,9 +99,9 @@ public sealed class GpuWorldStateActivatorTests
}
[Fact]
public void AddLandblock_DoesNotDoubleFire_OnPendingMerge()
public void PendingLiveProjection_DoesNotFireLogicalActivator()
{
// Live entity (ServerGuid!=0) arrives via AppendLiveEntity first —
// 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
@ -109,13 +109,12 @@ public sealed class GpuWorldStateActivatorTests
var p = BuildPipeline(scriptId: 0xAAu);
var live = Live(serverGuid: 0xCAFEu, pos: Vector3.Zero);
p.State.AppendLiveEntity(0xA9B4FFFFu, live);
p.State.PlaceLiveEntityProjection(0xA9B4FFFFu, live);
var emptyLb = MakeStubLandblock(0xA9B4FFFFu);
p.State.AddLandblock(emptyLb);
p.Runner.Tick(0.001f);
Assert.Single(p.Recording.Calls); // exactly one — no double-fire
Assert.Equal(0xCAFEu, p.Recording.Calls[0].EntityId);
Assert.Empty(p.Recording.Calls);
}
[Fact]

View file

@ -29,7 +29,7 @@ public class GpuWorldStateTests
};
[Fact]
public void AppendLiveEntity_LandblockAlreadyLoaded_AppendsImmediately()
public void PlaceLiveEntityProjection_LandblockAlreadyLoaded_AppendsImmediately()
{
var state = new GpuWorldState();
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
@ -37,20 +37,20 @@ public class GpuWorldStateTests
// Server sends a spawn at landblock 0xA9B40011 — same landblock,
// cell index 0x0011. Canonicalize drops the cell index, lookup
// succeeds, entity lands in the loaded landblock immediately.
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(42));
state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(42));
Assert.Single(state.Entities);
Assert.Equal(0u, (uint)state.PendingLiveEntityCount);
}
[Fact]
public void AppendLiveEntity_LandblockNotLoaded_ParksInPending()
public void PlaceLiveEntityProjection_LandblockNotLoaded_ParksInPending()
{
var state = new GpuWorldState();
// No landblock loaded — the spawn must survive instead of being
// silently dropped (the bug from Phase A.1's first live run).
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(42));
state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(42));
Assert.Empty(state.Entities); // not visible yet
Assert.Equal(1, state.PendingLiveEntityCount);
@ -62,9 +62,9 @@ public class GpuWorldStateTests
var state = new GpuWorldState();
// Three spawns arrive before the landblock loads.
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1));
state.AppendLiveEntity(0xA9B40022u, MakeStubEntity(2)); // same landblock, different cell
state.AppendLiveEntity(0xA9B40033u, MakeStubEntity(3));
state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1));
state.PlaceLiveEntityProjection(0xA9B40022u, MakeStubEntity(2)); // same landblock, different cell
state.PlaceLiveEntityProjection(0xA9B40033u, MakeStubEntity(3));
Assert.Equal(3, state.PendingLiveEntityCount);
Assert.Empty(state.Entities);
@ -82,8 +82,8 @@ public class GpuWorldStateTests
{
var state = new GpuWorldState();
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1)); // pending for 0xA9B4FFFF
state.AppendLiveEntity(0xAAAA0022u, MakeStubEntity(2)); // pending for 0xAAAAFFFF
state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1)); // pending for 0xA9B4FFFF
state.PlaceLiveEntityProjection(0xAAAA0022u, MakeStubEntity(2)); // pending for 0xAAAAFFFF
// Loading 0xA9B4FFFF only drains its own bucket.
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
@ -93,12 +93,12 @@ public class GpuWorldStateTests
}
[Fact]
public void RelocateEntity_StrandedInPending_MovesToLoadedTarget()
public void RebucketLiveEntity_StrandedInPending_MovesToLoadedTarget()
{
// Regression: the cold-spawn / run-out "invisible player" bug
// (2026-07-03). A persistent (server-spawned) entity that spawned into a
// not-yet-loaded landblock is parked in the pending bucket. The per-frame
// RelocateEntity is supposed to keep it homed to its current landblock so
// RebucketLiveEntity keeps it homed to its current landblock so
// it draws — but the old implementation scanned ONLY _loaded, so it
// silently no-op'd on a pending entity and the player stayed hidden
// forever. Confirmed live: "[ent] APPEND guid=0x5000000A -> PENDING(hidden)"
@ -118,16 +118,16 @@ public class GpuWorldStateTests
state.MarkPersistent(0x5000000Au);
// Spawned before its landblock streamed in → parked in pending, hidden.
state.AppendLiveEntity(0xADAF0011u, player);
state.PlaceLiveEntityProjection(0xADAF0011u, player);
Assert.Empty(state.Entities);
Assert.Equal(1, state.PendingLiveEntityCount);
// The player's current landblock IS loaded now (the live log shows the
// sibling NPCs re-hydrated into it). RelocateEntity — called every frame
// sibling NPCs re-hydrated into it). RebucketLiveEntity — called every frame
// to keep the player homed to its current landblock — must promote the
// stranded pending entity into the loaded bucket so it draws.
state.AddLandblock(MakeStubLandblock(0xBBBBFFFFu));
state.RelocateEntity(player, 0xBBBB0011u);
state.RebucketLiveEntity(player, 0xBBBB0011u);
Assert.Single(state.Entities); // now drawn
Assert.Equal(0, state.PendingLiveEntityCount); // no longer stranded
@ -139,8 +139,8 @@ public class GpuWorldStateTests
var state = new GpuWorldState();
// Spawns for landblock 0xA9B4FFFF arrive while it's pending.
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1));
state.AppendLiveEntity(0xA9B40022u, MakeStubEntity(2));
state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1));
state.PlaceLiveEntityProjection(0xA9B40022u, MakeStubEntity(2));
Assert.Equal(2, state.PendingLiveEntityCount);
// Player moves away — the streamer says "this landblock is no
@ -159,7 +159,7 @@ public class GpuWorldStateTests
var state = new GpuWorldState();
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1));
state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1));
Assert.Single(state.Entities);
state.RemoveLandblock(0xA9B4FFFFu);
@ -172,7 +172,7 @@ public class GpuWorldStateTests
{
var state = new GpuWorldState();
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1));
state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1));
Assert.False(state.IsLoaded(0xA9B4FFFFu)); // pending doesn't count
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
@ -193,7 +193,7 @@ public class GpuWorldStateTests
[Fact]
public void RemoveLandblock_RescuesPersistentEntity_FromPendingBucket()
{
// #138 (secondary): the player is re-injected via AppendLiveEntity every
// #138 (secondary): the player is re-injected via rebucketing every
// frame; right after a teleport its landblock hasn't streamed in, so it
// lands in the pending bucket. If that landblock is then unloaded before
// it finishes loading, the persistent entity must still be rescued —
@ -203,7 +203,7 @@ public class GpuWorldStateTests
state.MarkPersistent(playerGuid);
var player = MakeServerEntity(id: 1, serverGuid: playerGuid);
state.AppendLiveEntity(0xA9B40011u, player); // landblock not loaded → pending
state.PlaceLiveEntityProjection(0xA9B40011u, player); // landblock not loaded → pending
Assert.Equal(1, state.PendingLiveEntityCount);
state.RemoveLandblock(0xA9B4FFFFu); // unloaded while still pending
@ -212,17 +212,36 @@ public class GpuWorldStateTests
}
[Fact]
public void RemoveLandblock_DoesNotRescue_NonPersistentPendingEntity()
public void RemoveLandblock_RetainsNonPersistentLiveProjectionForSameIdentityReload()
{
// A normal server object (door) in the pending bucket is NOT persistent;
// it must still be dropped (and is recoverable via #138 re-hydrate from
// the retained inbound spawn snapshot, not via the rescue path).
// A normal server object (door) is not rescued with the moving player,
// but its exact projection remains pending for this landblock. Reload
// must merge that identity instead of reconstructing from CreateObject.
var state = new GpuWorldState();
var door = MakeServerEntity(id: 2, serverGuid: 0x7A9B4001u); // not marked persistent
state.AppendLiveEntity(0xA9B40011u, door);
state.PlaceLiveEntityProjection(0xA9B40011u, door);
state.RemoveLandblock(0xA9B4FFFFu);
Assert.Empty(state.DrainRescued());
Assert.Equal(1, state.PendingLiveEntityCount);
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
Assert.Same(door, Assert.Single(state.Entities));
}
[Fact]
public void RemoveLandblock_LoadedLiveProjectionMovesToPendingAndReloadsSameIdentity()
{
var state = new GpuWorldState();
var door = MakeServerEntity(id: 3, serverGuid: 0x7A9B4002u);
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
state.PlaceLiveEntityProjection(0xA9B40011u, door);
state.RemoveLandblock(0xA9B4FFFFu);
Assert.Empty(state.Entities);
Assert.Equal(1, state.PendingLiveEntityCount);
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
Assert.Same(door, Assert.Single(state.Entities));
}
}

View file

@ -37,6 +37,32 @@ public class GpuWorldStateTwoTierTests
Assert.True(state.IsLoaded(0xAAAAFFFFu)); // landblock still resident
}
[Fact]
public void RemoveEntitiesFromLandblock_DropsStaticLayerButRetainsLiveProjection()
{
var state = new GpuWorldState();
WorldEntity staticEntity = MakeStubEntity(1);
var liveEntity = new WorldEntity
{
Id = 2,
ServerGuid = 0x70000002u,
SourceGfxObjOrSetupId = 0x01000001u,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>(),
};
state.AddLandblock(MakeStubLandblock(
0xAAAAFFFFu,
staticEntity,
liveEntity));
state.RemoveEntitiesFromLandblock(0xAAAAFFFFu);
Assert.Same(liveEntity, Assert.Single(state.Entities));
Assert.DoesNotContain(staticEntity, state.Entities);
Assert.True(state.IsLoaded(0xAAAAFFFFu));
}
[Fact]
public void AddEntitiesToExistingLandblock_MergesIntoExistingRecord()
{
@ -118,7 +144,6 @@ public class GpuWorldStateTwoTierTests
int callCount = 0;
var state = new GpuWorldState(
wbSpawnAdapter: null,
wbEntitySpawnAdapter: null,
onLandblockUnloaded: id => { observed = id; callCount++; });
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu, MakeStubEntity(1)));
@ -144,7 +169,6 @@ public class GpuWorldStateTwoTierTests
int callCount = 0;
var state = new GpuWorldState(
wbSpawnAdapter: null,
wbEntitySpawnAdapter: null,
onLandblockUnloaded: _ => callCount++);
// Landblock never loaded.

View file

@ -1,128 +0,0 @@
using System.Collections.Generic;
using AcDream.App.Streaming;
using Xunit;
namespace AcDream.Core.Tests.Streaming;
/// <summary>
/// #138 — selection logic for re-hydrating server objects when a landblock
/// reloads after a dungeon collapse (or a Near→Far demote). See
/// <see cref="LandblockEntityRehydrator"/> for the retail/ACE rationale.
/// </summary>
public class LandblockEntityRehydratorTests
{
private const uint PlayerGuid = 0x50000001u;
// A door spawned in cell 0x00070123 of dungeon landblock 0x0007; its
// cell-resolved spawn id must match the streamed canonical id 0x0007FFFF.
private const uint DungeonLb = 0x0007FFFFu;
private const uint DoorGuid = 0x7A9B4001u;
private const uint DoorSpawnId = 0x00070123u;
private static LandblockEntityRehydrator.RetainedSpawn Spawn(
uint guid, uint spawnLbId, bool hasMesh = true)
=> new(guid, spawnLbId, hasMesh);
[Fact]
public void RetainedSpawnInLoadedLandblock_NotPresent_IsSelected()
{
var result = LandblockEntityRehydrator.SelectGuidsToRehydrate(
DungeonLb,
new[] { Spawn(DoorGuid, DoorSpawnId) },
new HashSet<uint>(),
PlayerGuid);
Assert.Equal(new[] { DoorGuid }, result);
}
[Fact]
public void CellResolvedSpawnId_MatchesCanonicalLoadedId()
{
// The streamed landblock id is canonical (0xAAAAFFFF); the spawn id is
// cell-resolved (0xAAAA00CC). They name the same landblock, so the door
// must be selected — the canonicalization is the load-bearing step.
var result = LandblockEntityRehydrator.SelectGuidsToRehydrate(
DungeonLb, // 0x0007FFFF
new[] { Spawn(DoorGuid, 0x000701A9u) }, // a different cell, same landblock
new HashSet<uint>(),
PlayerGuid);
Assert.Equal(new[] { DoorGuid }, result);
}
[Fact]
public void SpawnInDifferentLandblock_IsNotSelected()
{
var result = LandblockEntityRehydrator.SelectGuidsToRehydrate(
DungeonLb,
new[] { Spawn(DoorGuid, 0xA9B30123u) }, // Holtburg, not the loaded dungeon
new HashSet<uint>(),
PlayerGuid);
Assert.Empty(result);
}
[Fact]
public void AlreadyPresentGuid_IsNotSelected()
{
// Already rendered (server re-sent it, or it was never dropped) — a
// re-hydrate would be a redundant mesh rebuild.
var result = LandblockEntityRehydrator.SelectGuidsToRehydrate(
DungeonLb,
new[] { Spawn(DoorGuid, DoorSpawnId) },
new HashSet<uint> { DoorGuid },
PlayerGuid);
Assert.Empty(result);
}
[Fact]
public void PlayerGuid_IsNeverSelected()
{
// The player has a retained spawn too, but the persistent-rescue path
// owns it (preserves its live pose). Re-hydrating would reset it.
var result = LandblockEntityRehydrator.SelectGuidsToRehydrate(
DungeonLb,
new[] { Spawn(PlayerGuid, DoorSpawnId) },
new HashSet<uint>(),
PlayerGuid);
Assert.Empty(result);
}
[Fact]
public void SpawnWithoutWorldMesh_IsNotSelected()
{
// Inventory items / setup-less spawns build no visible entity.
var result = LandblockEntityRehydrator.SelectGuidsToRehydrate(
DungeonLb,
new[] { Spawn(DoorGuid, DoorSpawnId, hasMesh: false) },
new HashSet<uint>(),
PlayerGuid);
Assert.Empty(result);
}
[Fact]
public void MixedSet_SelectsOnlyAbsentWorldObjectsInLoadedLandblock()
{
uint npcGuid = 0x7A9B4002u; // same dungeon landblock, absent → select
uint chestGuid = 0x7A9B4003u; // same landblock but already present → skip
uint holtburgGuid = 0x7A9B4004u; // different landblock → skip
uint heldItemGuid = 0x7A9B4005u; // no world mesh → skip
var spawns = new[]
{
Spawn(DoorGuid, DoorSpawnId), // select
Spawn(npcGuid, 0x00070177u), // select (same lb, different cell)
Spawn(chestGuid, 0x00070123u), // skip (present)
Spawn(holtburgGuid, 0xA9B30100u), // skip (other lb)
Spawn(heldItemGuid, 0x00070123u, hasMesh: false), // skip (no mesh)
Spawn(PlayerGuid, 0x00070100u), // skip (player)
};
var result = LandblockEntityRehydrator.SelectGuidsToRehydrate(
DungeonLb, spawns, new HashSet<uint> { chestGuid }, PlayerGuid);
Assert.Equal(new HashSet<uint> { DoorGuid, npcGuid }, new HashSet<uint>(result));
}
}

View file

@ -78,19 +78,20 @@ public class StreamingControllerTests
// Note: LoadedLandblock's actual fields are LandblockId, Heightmap,
// Entities (positional record). Adjust if the first positional arg
// name differs.
var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty<WorldEntity>());
const uint landblockId = 0x3232FFFFu;
var lb = new LoadedLandblock(landblockId, new LandBlock(), System.Array.Empty<WorldEntity>());
// A.5 T10-T12 follow-up: use a real empty mesh instance instead of
// default! so any future test that flows MeshData through the apply
// callback gets a non-null reference to inspect rather than an NRE.
var stubMesh = new AcDream.Core.Terrain.LandblockMeshData(
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
System.Array.Empty<uint>());
fake.Pending.Enqueue(new LandblockStreamResult.Loaded(0x32320FFEu, LandblockStreamTier.Near, lb, stubMesh));
fake.Pending.Enqueue(new LandblockStreamResult.Loaded(landblockId, LandblockStreamTier.Near, lb, stubMesh));
controller.Tick(50, 50);
Assert.Single(applied);
Assert.True(state.IsLoaded(0x32320FFEu));
Assert.True(state.IsLoaded(landblockId));
}
[Fact]
@ -102,12 +103,13 @@ public class StreamingControllerTests
fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions,
(_, _) => { }, state, nearRadius: 2, farRadius: 2);
var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty<WorldEntity>());
const uint landblockId = 0x3232FFFFu;
var lb = new LoadedLandblock(landblockId, new LandBlock(), System.Array.Empty<WorldEntity>());
state.AddLandblock(lb);
fake.Pending.Enqueue(new LandblockStreamResult.Unloaded(0x32320FFEu));
fake.Pending.Enqueue(new LandblockStreamResult.Unloaded(landblockId));
controller.Tick(50, 50);
Assert.False(state.IsLoaded(0x32320FFEu));
Assert.False(state.IsLoaded(landblockId));
}
}