refactor(app): key live projections by runtime identity

Move materialized live-object sidecars and presentation worksets to exact RuntimeEntityKey ownership. Runtime remains the only GUID/incarnation/local-ID authority while hydration, animation, effects, lights, equipped children, renderer resources, visibility, liveness, and teardown resolve exact projection identities. Preserve synchronous callbacks, local-ID allocation order, and current rendering behavior.
This commit is contained in:
Erik 2026-07-25 21:50:58 +02:00
parent e18df84437
commit 420e5eea70
73 changed files with 2939 additions and 1715 deletions

View file

@ -115,7 +115,7 @@ public sealed class DeferredLiveEntityRuntimeComponentLifecycleTests
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }),
bridge);
WorldSession.EntitySpawn spawn = CreateSpawn(guid);
LiveEntityRecord record = runtime.RegisterLiveEntity(spawn).Record!;
LiveEntityRecord record = runtime.RegisterAndMaterializeProjection(spawn);
var delete = new DeleteObject.Parsed(guid, InstanceSequence: 1);
Assert.Throws<AggregateException>(() =>
@ -139,7 +139,8 @@ public sealed class DeferredLiveEntityRuntimeComponentLifecycleTests
var runtime = new LiveEntityRuntime(
new GpuWorldState(),
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
return runtime.RegisterLiveEntity(CreateSpawn(0x70000001u)).Record!;
return runtime.RegisterAndMaterializeProjection(
CreateSpawn(0x70000001u));
}
private static WorldSession.EntitySpawn CreateSpawn(uint guid) =>

View file

@ -90,8 +90,18 @@ public sealed class GameWindowLiveEntityCompositionTests
foreach (FieldInfo field in helperType.GetFields(
BindingFlags.Instance | BindingFlags.Static | BindingFlags.NonPublic))
{
Assert.False(typeof(IDictionary).IsAssignableFrom(field.FieldType),
$"{helperType.Name}.{field.Name} must resolve identity through LiveEntityRuntime.");
bool isExactSynchronousProjectionContext =
helperType == typeof(LiveEntityHydrationController)
&& field.Name == "_projectionOperations"
&& field.FieldType.IsGenericType
&& field.FieldType.GetGenericArguments()[0]
== typeof(AcDream.Runtime.Entities.RuntimeEntityRecord);
if (!isExactSynchronousProjectionContext)
{
Assert.False(
typeof(IDictionary).IsAssignableFrom(field.FieldType),
$"{helperType.Name}.{field.Name} must resolve identity through LiveEntityRuntime.");
}
string typeName = field.FieldType.FullName ?? field.FieldType.Name;
Assert.DoesNotContain("Silk.NET", typeName, StringComparison.Ordinal);
Assert.DoesNotContain("OpenGL", typeName, StringComparison.OrdinalIgnoreCase);

View file

@ -78,14 +78,16 @@ public sealed class LiveEntityHydrationControllerTests
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord record));
Assert.Null(record.WorldEntity);
RuntimeEntityRecord canonical = fixture.Canonical;
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
Assert.Null(canonical.LocalEntityId);
Assert.Equal(0, fixture.Resources.RegisterCount);
Assert.Empty(fixture.Materializer.Calls);
fixture.Origin.IsKnownValue = true;
fixture.Controller.OnLandblockLoaded(Cell);
LiveEntityRecord record = fixture.Record;
Assert.NotNull(record.WorldEntity);
Assert.Single(fixture.Materializer.Calls);
Assert.Equal(LiveProjectionPurpose.SpatialRecovery, fixture.Materializer.Calls[0].Purpose);
@ -130,11 +132,13 @@ public sealed class LiveEntityHydrationControllerTests
fixture.Materializer.SkipMaterializationCount = 1;
WorldSession.EntitySpawn spawn = Spawn(Generation: 1, PositionSequence: 1);
fixture.Controller.OnCreate(spawn);
LiveEntityRecord record = fixture.Record;
Assert.Null(record.WorldEntity);
RuntimeEntityRecord canonical = fixture.Canonical;
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
Assert.Null(canonical.LocalEntityId);
Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
LiveEntityRecord record = fixture.Record;
Assert.NotNull(record.WorldEntity);
Assert.Equal(2, fixture.Materializer.Calls.Count);
Assert.Equal(LiveProjectionPurpose.SpatialRecovery, fixture.Materializer.Calls[1].Purpose);
@ -516,13 +520,15 @@ public sealed class LiveEntityHydrationControllerTests
Assert.Throws<InvalidOperationException>(() =>
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)));
Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord record));
Assert.Null(record.WorldEntity);
RuntimeEntityRecord canonical = fixture.Canonical;
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
Assert.Null(canonical.LocalEntityId);
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
fixture.Controller.OnLandblockLoaded(Cell);
LiveEntityRecord record = fixture.Record;
Assert.NotNull(record.WorldEntity);
Assert.Equal(2, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
@ -644,7 +650,9 @@ public sealed class LiveEntityHydrationControllerTests
ClientObject retainedObject = fixture.Objects.Get(Guid)!;
Assert.True(fixture.Controller.OnPrune(
new LiveEntityPruneCandidate(Guid, Generation: 1)));
new LiveEntityPruneCandidate(
fixture.Record.ProjectionKey!.Value,
Guid)));
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
Assert.Same(retainedObject, fixture.Objects.Get(Guid));
@ -658,7 +666,9 @@ public sealed class LiveEntityHydrationControllerTests
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
Assert.True(fixture.Controller.OnPrune(
new LiveEntityPruneCandidate(Guid, Generation: 1)));
new LiveEntityPruneCandidate(
fixture.Record.ProjectionKey!.Value,
Guid)));
fixture.Controller.OnLandblockLoaded(Cell);
@ -677,7 +687,9 @@ public sealed class LiveEntityHydrationControllerTests
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
Assert.True(fixture.Controller.OnPrune(
new LiveEntityPruneCandidate(Guid, Generation: 1)));
new LiveEntityPruneCandidate(
fixture.Record.ProjectionKey!.Value,
Guid)));
Assert.True(fixture.Controller.OnDelete(
new DeleteObject.Parsed(Guid, InstanceSequence: 1)));
@ -694,7 +706,9 @@ public sealed class LiveEntityHydrationControllerTests
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 2, PositionSequence: 1));
Assert.True(fixture.Controller.OnPrune(
new LiveEntityPruneCandidate(Guid, Generation: 2)));
new LiveEntityPruneCandidate(
fixture.Record.ProjectionKey!.Value,
Guid)));
fixture.Controller.OnCreate(Spawn(
Generation: 1,
@ -723,7 +737,9 @@ public sealed class LiveEntityHydrationControllerTests
PositionSequence: 5);
fixture.Controller.OnCreate(accepted);
Assert.True(fixture.Controller.OnPrune(
new LiveEntityPruneCandidate(Guid, Generation: 1)));
new LiveEntityPruneCandidate(
fixture.Record.ProjectionKey!.Value,
Guid)));
CreateObject.ServerPosition stalePosition =
accepted.Position!.Value with
@ -760,7 +776,9 @@ public sealed class LiveEntityHydrationControllerTests
Value = 123,
});
Assert.True(fixture.Controller.OnPrune(
new LiveEntityPruneCandidate(Guid, Generation: 1)));
new LiveEntityPruneCandidate(
fixture.Record.ProjectionKey!.Value,
Guid)));
fixture.Controller.OnCreate(
Spawn(Generation: 2, PositionSequence: 1) with
@ -831,10 +849,11 @@ public sealed class LiveEntityHydrationControllerTests
Assert.True(fixture.Controller.OnDelete(
new DeleteObject.Parsed(Guid, InstanceSequence: 1)));
Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord replacement));
Assert.NotSame(old, replacement);
RuntimeEntityRecord replacement = fixture.Canonical;
Assert.Equal((ushort)2, replacement.Generation);
Assert.Equal("replacement", replacement.Snapshot.Name);
Assert.Null(replacement.LocalEntityId);
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
Assert.Equal(0, fixture.Runtime.PendingTeardownCount);
}
@ -1348,10 +1367,9 @@ public sealed class LiveEntityHydrationControllerTests
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
LiveEntityRecord record = fixture.Record;
Assert.False(record.CreateProjectionSynchronizationPending);
Assert.False(record.InitialHydrationCompleted);
Assert.Null(record.WorldEntity);
RuntimeEntityRecord canonical = fixture.Canonical;
Assert.Null(canonical.LocalEntityId);
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
Assert.Equal(0, fixture.Resources.RegisterCount);
fixture.Materializer.BeforeMaterialize = null;
@ -1371,6 +1389,7 @@ public sealed class LiveEntityHydrationControllerTests
};
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 3));
LiveEntityRecord record = fixture.Record;
Assert.True(record.InitialHydrationCompleted);
Assert.NotNull(record.WorldEntity);
Assert.Equal(1, fixture.Resources.RegisterCount);
@ -1481,12 +1500,14 @@ public sealed class LiveEntityHydrationControllerTests
};
fixture.Relationships.OnSpawnAction = _ =>
{
LiveEntityRecord record = fixture.Record;
if (record.WorldEntity is not null)
RuntimeEntityRecord canonical = fixture.Canonical;
if (fixture.Runtime.TryGetRecord(
Guid,
out LiveEntityRecord _))
return;
WorldEntity? attached = fixture.Runtime.MaterializeLiveEntity(
Guid,
canonical,
Cell,
id => new WorldEntity
{
@ -1498,10 +1519,13 @@ public sealed class LiveEntityHydrationControllerTests
MeshRefs = [],
ParentCellId = Cell,
},
LiveEntityProjectionKind.Attached);
LiveEntityProjectionKind.Attached,
initializeProjection: null,
out LiveEntityRecord? record);
Assert.NotNull(attached);
Assert.NotNull(record);
fixture.Controller.OnEntityReady(
LiveEntityReadyCandidate.Capture(record));
LiveEntityReadyCandidate.Capture(record!));
};
fixture.Controller.OnCreate(CelllessSpawn(
@ -1757,7 +1781,8 @@ public sealed class LiveEntityHydrationControllerTests
record,
staleVersion,
accepted));
Assert.Empty(fixture.Runtime.MaterializedWorldEntities);
Assert.Single(fixture.Runtime.MaterializedRecords);
Assert.Empty(fixture.Runtime.VisibleRecords);
}
[Fact]
@ -1890,6 +1915,17 @@ public sealed class LiveEntityHydrationControllerTests
}
}
public RuntimeEntityRecord Canonical
{
get
{
Assert.True(Runtime.TryGetCanonical(
Guid,
out RuntimeEntityRecord canonical));
return canonical;
}
}
public void Dispose()
{
try
@ -1991,24 +2027,24 @@ public sealed class LiveEntityHydrationControllerTests
public readonly List<(LiveProjectionPurpose Purpose, ushort Generation)> Calls = [];
public readonly List<ushort> PositionSequences = [];
public ulong? InstalledCreateIntegrationVersion { get; private set; }
public Action<LiveEntityRecord, WorldSession.EntitySpawn>? BeforeMaterialize { get; set; }
public Action<LiveEntityRecord, WorldSession.EntitySpawn>? AfterMaterialize { get; set; }
public Action<RuntimeEntityRecord, WorldSession.EntitySpawn>? BeforeMaterialize { get; set; }
public Action<RuntimeEntityRecord, WorldSession.EntitySpawn>? AfterMaterialize { get; set; }
public LiveProjectionPurpose? ThrowAfterMaterializePurposeOnce { get; set; }
public int SkipMaterializationCount { get; set; }
public bool TryMaterialize(
LiveEntityRecord expectedRecord,
RuntimeEntityRecord expectedCanonical,
WorldSession.EntitySpawn canonicalSpawn,
LiveProjectionPurpose purpose,
ulong expectedCreateIntegrationVersion,
AcDream.App.Rendering.LiveEntityAppearanceUpdateState? appearanceUpdate = null)
{
Calls.Add((purpose, expectedRecord.Generation));
Calls.Add((purpose, expectedCanonical.Generation));
PositionSequences.Add(canonicalSpawn.PositionSequence);
operations.Add($"materialize:{purpose}:{expectedRecord.Generation}");
BeforeMaterialize?.Invoke(expectedRecord, canonicalSpawn);
operations.Add($"materialize:{purpose}:{expectedCanonical.Generation}");
BeforeMaterialize?.Invoke(expectedCanonical, canonicalSpawn);
if (!runtime.IsCurrentCreateIntegration(
expectedRecord,
expectedCanonical,
expectedCreateIntegrationVersion))
{
return false;
@ -2030,7 +2066,7 @@ public sealed class LiveEntityHydrationControllerTests
}
WorldEntity? entity = runtime.MaterializeLiveEntity(
canonicalSpawn.Guid,
expectedCanonical,
position.LandblockId,
id => new WorldEntity
{
@ -2041,15 +2077,19 @@ public sealed class LiveEntityHydrationControllerTests
Rotation = Quaternion.Identity,
MeshRefs = [],
ParentCellId = position.LandblockId,
});
},
LiveEntityProjectionKind.World,
initializeProjection: null,
out LiveEntityRecord? expectedRecord);
if (ThrowAfterMaterializePurposeOnce == purpose)
{
ThrowAfterMaterializePurposeOnce = null;
throw new InvalidOperationException(
"fixture supersession projection failure");
}
AfterMaterialize?.Invoke(expectedRecord, canonicalSpawn);
AfterMaterialize?.Invoke(expectedCanonical, canonicalSpawn);
return entity is not null
&& expectedRecord is not null
&& runtime.IsCurrentRecord(expectedRecord);
}
@ -2137,7 +2177,7 @@ public sealed class LiveEntityHydrationControllerTests
{
}
public void OnProjectionRemoved(uint localEntityId)
public void OnProjectionRemoved(LiveEntityRecord record)
{
}

View file

@ -355,7 +355,7 @@ public sealed class LiveEntityLifecycleStressTests
if (record.WorldEntity is { } entity)
{
cleanups.Add(() => Engine.ShadowObjects.Deregister(entity.Id));
cleanups.Add(() => _liveLights?.Forget(entity.Id));
cleanups.Add(() => _liveLights?.Forget(record));
}
LiveEntityTeardown.Run(cleanups);
});
@ -402,14 +402,8 @@ public sealed class LiveEntityLifecycleStressTests
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,
LiveEntityRecord record = Runtime.RegisterAndMaterializeProjection(
spawn,
localId => new WorldEntity
{
Id = localId,
@ -419,7 +413,12 @@ public sealed class LiveEntityLifecycleStressTests
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
ParentCellId = CellA,
}));
},
initializeProjection: exact =>
exact.EffectProfile = EntityEffectProfile.CreateLive(
new Setup(),
spawn.Physics!.Value));
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
Assert.True(Effects.OnLiveEntityReady(guid));
Assert.True(Projectiles.TryBind(record, Setup, 0.0, 1, 1));
@ -520,13 +519,8 @@ public sealed class LiveEntityLifecycleStressTests
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,
LiveEntityRecord record = Runtime.RegisterAndMaterializeProjection(
spawn,
localId => new WorldEntity
{
Id = localId,
@ -536,7 +530,12 @@ public sealed class LiveEntityLifecycleStressTests
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
ParentCellId = CellOne,
})!;
},
initializeProjection: exact =>
exact.EffectProfile = EntityEffectProfile.CreateLive(
new Setup(),
spawn.Physics!.Value));
Entity = Assert.IsType<WorldEntity>(record.WorldEntity);
Assert.True(Effects.OnLiveEntityReady(Guid));
_remote.Body.SnapToCell(CellOne, Entity.Position, Entity.Position);

View file

@ -1,5 +1,6 @@
using AcDream.App.World;
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Entities;
namespace AcDream.App.Tests.World;
@ -14,7 +15,9 @@ public sealed class LiveEntityLivenessControllerTests
Assert.Empty(tracker.Tick(10.0, samples));
Assert.Empty(tracker.Tick(34.999, samples));
Assert.Equal(
new LiveEntityPruneCandidate(0x7000_0001u, 4),
new LiveEntityPruneCandidate(
new RuntimeEntityKey(0x7000_0001u, 4),
0x7000_0001u),
Assert.Single(tracker.Tick(35.0, samples)));
Assert.Equal(0, tracker.DeadlineCount);
}
@ -47,7 +50,7 @@ public sealed class LiveEntityLivenessControllerTests
Assert.Empty(tracker.Tick(24.0, [Sample(1, 2, visible: false)]));
Assert.Empty(tracker.Tick(25.0, [Sample(1, 2, visible: false)]));
Assert.Equal(
new LiveEntityPruneCandidate(1, 2),
new LiveEntityPruneCandidate(new RuntimeEntityKey(1, 2), 1),
Assert.Single(tracker.Tick(49.0, [Sample(1, 2, visible: false)])));
}
@ -80,7 +83,11 @@ public sealed class LiveEntityLivenessControllerTests
ushort generation,
bool visible,
bool retained = false) =>
new(guid, generation, visible, retained);
new(
new RuntimeEntityKey(guid, generation),
guid,
visible,
retained);
private static CreateObject.ServerPosition Position(
uint cell,

View file

@ -6,6 +6,7 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
namespace AcDream.App.Tests.World;
@ -16,7 +17,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
{
const uint guid = 0x70000010u;
var runtime = Runtime();
LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
LiveEntityRecord first = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1));
var firstMotion = new HostConsumerMotion();
runtime.SetRemoteMotionRuntime(guid, firstMotion);
EntityPhysicsHost firstHost = Host(guid);
@ -26,7 +27,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
Assert.True(runtime.TryGetPhysicsHost(guid, out IPhysicsObjHost resolved));
Assert.Same(firstHost, resolved);
LiveEntityRecord second = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
LiveEntityRecord second = runtime.RegisterAndMaterializeProjection(Spawn(guid, 2));
var secondMotion = new HostConsumerMotion();
runtime.SetRemoteMotionRuntime(guid, secondMotion);
EntityPhysicsHost replacement = Host(guid);
@ -42,7 +43,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
{
const uint guid = 0x70000011u;
var runtime = Runtime();
LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
LiveEntityRecord first = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1));
EntityPhysicsHost original = Host(guid);
runtime.InstallPhysicsHost(first, original);
@ -51,7 +52,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
Assert.Throws<InvalidOperationException>(() =>
runtime.InstallPhysicsHost(first, Host(guid)));
LiveEntityRecord second = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
LiveEntityRecord second = runtime.RegisterAndMaterializeProjection(Spawn(guid, 2));
Assert.Throws<InvalidOperationException>(() =>
runtime.InstallPhysicsHost(first, Host(guid)));
Assert.Null(second.PhysicsHost);
@ -63,8 +64,8 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
const uint watcherGuid = 0x70000020u;
const uint targetGuid = 0x70000021u;
var runtime = Runtime();
LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!;
LiveEntityRecord targetRecord = runtime.RegisterLiveEntity(Spawn(targetGuid, 1)).Record!;
LiveEntityRecord watcherRecord = runtime.RegisterAndMaterializeProjection(Spawn(watcherGuid, 1));
LiveEntityRecord targetRecord = runtime.RegisterAndMaterializeProjection(Spawn(targetGuid, 1));
IPhysicsObjHost? Resolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
? host
@ -129,7 +130,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
{
const uint guid = 0x70000025u;
var runtime = Runtime();
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
LiveEntityRecord record = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1));
EntityPhysicsHost original = Host(
guid,
position: new Vector3(1f, 0f, 0f));
@ -163,7 +164,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
{
const uint guid = 0x70000022u;
var runtime = Runtime();
LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
LiveEntityRecord first = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1));
int firstUpdates = 0;
int firstInterrupts = 0;
var firstHost = CallbackHost(
@ -172,7 +173,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
() => firstInterrupts++);
runtime.InstallPhysicsHost(first, firstHost);
LiveEntityRecord replacement = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
LiveEntityRecord replacement = runtime.RegisterAndMaterializeProjection(Spawn(guid, 2));
int replacementUpdates = 0;
int replacementInterrupts = 0;
var replacementHost = CallbackHost(
@ -195,7 +196,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
{
const uint guid = 0x70000030u;
var runtime = Runtime();
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
LiveEntityRecord record = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1));
var motion = new RemoteMotion(new PhysicsBody());
runtime.SetRemoteMotionRuntime(guid, motion);
EntityPhysicsHost minimal = Host(guid);
@ -221,7 +222,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
{
const uint guid = 0x70000031u;
var runtime = Runtime();
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
LiveEntityRecord record = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1));
var failing = new ThrowingCellConsumerMotion();
Assert.Throws<InvalidOperationException>(() =>
@ -240,11 +241,11 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
{
const uint guid = 0x70000032u;
var runtime = Runtime();
runtime.RegisterLiveEntity(Spawn(guid, 1));
runtime.RegisterAndMaterializeProjection(Spawn(guid, 1));
var stale = new RemoteMotion(new PhysicsBody());
runtime.SetRemoteMotionRuntime(guid, stale);
LiveEntityRecord replacement = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
LiveEntityRecord replacement = runtime.RegisterAndMaterializeProjection(Spawn(guid, 2));
Assert.Throws<InvalidOperationException>(() =>
runtime.SetRemoteMotionRuntime(guid, stale));
Assert.Null(replacement.RemoteMotionRuntime);
@ -261,7 +262,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
{
const uint guid = 0x70000033u;
var runtime = Runtime();
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
LiveEntityRecord record = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1));
var firstBody = new PhysicsBody();
var secondBody = new PhysicsBody();
var alternating = new AlternatingBodyMotion(firstBody, secondBody);
@ -282,7 +283,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
{
const uint guid = 0x70000036u;
var runtime = Runtime();
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
LiveEntityRecord record = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1));
var canonical = new PhysicsBody();
var unrelated = new PhysicsBody();
PhysicsStateFlags unrelatedInitialState = unrelated.State;
@ -302,17 +303,18 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
{
const uint guid = 0x70000034u;
var runtime = Runtime();
LiveEntityRecord retired = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
LiveEntityRecord retired = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1));
var motion = new CallbackCanonicalMotion(() =>
runtime.RegisterLiveEntity(Spawn(guid, 2)));
Assert.Throws<InvalidOperationException>(() =>
runtime.SetRemoteMotionRuntime(guid, motion));
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement));
Assert.Equal((ushort)2, replacement.Generation);
Assert.Null(replacement.RemoteMotionRuntime);
Assert.True(runtime.TryGetCanonical(guid, out var replacement));
Assert.Equal((ushort)2, replacement.Incarnation);
Assert.Null(replacement.LocalEntityId);
Assert.Null(replacement.PhysicsBody);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Null(retired.RemoteMotionRuntime);
Assert.Null(retired.PhysicsBody);
Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _));
@ -323,7 +325,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
{
const uint guid = 0x70000035u;
var runtime = Runtime();
LiveEntityRecord retired = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
LiveEntityRecord retired = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1));
var motion = new CallbackCanonicalMotion(runtime.Clear);
Assert.Throws<InvalidOperationException>(() =>
@ -343,8 +345,8 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
const uint rightGuid = 0x70000041u;
LiveEntityRuntime runtime = null!;
runtime = Runtime(record => CleanPhysicsHost(record));
LiveEntityRecord leftRecord = runtime.RegisterLiveEntity(Spawn(leftGuid, 1)).Record!;
LiveEntityRecord rightRecord = runtime.RegisterLiveEntity(Spawn(rightGuid, 1)).Record!;
LiveEntityRecord leftRecord = runtime.RegisterAndMaterializeProjection(Spawn(leftGuid, 1));
LiveEntityRecord rightRecord = runtime.RegisterAndMaterializeProjection(Spawn(rightGuid, 1));
IPhysicsObjHost? Resolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
? host
@ -374,8 +376,8 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
const uint rightGuid = 0x70000043u;
LiveEntityRuntime runtime = null!;
runtime = Runtime(record => CleanPhysicsHost(record));
LiveEntityRecord leftRecord = runtime.RegisterLiveEntity(Spawn(leftGuid, 1)).Record!;
LiveEntityRecord rightRecord = runtime.RegisterLiveEntity(Spawn(rightGuid, 1)).Record!;
LiveEntityRecord leftRecord = runtime.RegisterAndMaterializeProjection(Spawn(leftGuid, 1));
LiveEntityRecord rightRecord = runtime.RegisterAndMaterializeProjection(Spawn(rightGuid, 1));
IPhysicsObjHost? Resolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
? host
@ -410,21 +412,14 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
if (failOnce)
{
failOnce = false;
replacementRecord = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
IPhysicsObjHost? Resolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
? host
: null;
replacementHost = Host(guid, Resolve);
runtime.InstallPhysicsHost(replacementRecord, replacementHost);
replacementHost.PositionManager.StickTo(targetGuid, 0.5f, 1f);
runtime.RegisterLiveEntity(Spawn(guid, 2));
throw new InvalidOperationException("fixture failure before old host cleanup");
}
CleanPhysicsHost(record);
});
LiveEntityRecord oldRecord = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
LiveEntityRecord targetRecord = runtime.RegisterLiveEntity(Spawn(targetGuid, 1)).Record!;
LiveEntityRecord oldRecord = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1));
LiveEntityRecord targetRecord = runtime.RegisterAndMaterializeProjection(Spawn(targetGuid, 1));
IPhysicsObjHost? InitialResolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
? host
@ -438,6 +433,14 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
Assert.Throws<AggregateException>(() => runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(guid, InstanceSequence: 1),
isLocalPlayer: false));
replacementRecord = runtime.RegisterAndMaterializeProjection(Spawn(guid, 2));
IPhysicsObjHost? ResolveReplacement(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
? host
: null;
replacementHost = Host(guid, ResolveReplacement);
runtime.InstallPhysicsHost(replacementRecord, replacementHost);
replacementHost.PositionManager.StickTo(targetGuid, 0.5f, 1f);
Assert.Same(oldHost, oldRecord.PhysicsHost);
Assert.Same(replacementHost, replacementRecord!.PhysicsHost);
Assert.Equal(targetGuid, replacementHost!.PositionManager.GetStickyObjectId());
@ -466,8 +469,8 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
throw new InvalidOperationException("fixture post-exit failure");
}
});
LiveEntityRecord retiredRecord = runtime.RegisterLiveEntity(Spawn(retiredGuid, 1)).Record!;
LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!;
LiveEntityRecord retiredRecord = runtime.RegisterAndMaterializeProjection(Spawn(retiredGuid, 1));
LiveEntityRecord watcherRecord = runtime.RegisterAndMaterializeProjection(Spawn(watcherGuid, 1));
IPhysicsObjHost? Resolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
? host
@ -493,7 +496,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
{
const uint guid = 0x70000048u;
var runtime = Runtime();
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
LiveEntityRecord record = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1));
record.FullCellId = 0x01010123u;
record.WorldEntity = new AcDream.Core.World.WorldEntity
{
@ -521,32 +524,31 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
runtime = Runtime(record =>
{
if (record.ServerGuid == departingGuid)
{
LiveEntityRecord replacement = runtime.RegisterLiveEntity(
Spawn(departingGuid, 2)).Record!;
replacement.FullCellId = 0x02020202u;
replacement.WorldEntity = Entity(
departingGuid,
new Vector3(999f, 999f, 999f),
Quaternion.Identity);
runtime.InstallPhysicsHost(replacement, Host(departingGuid));
}
runtime.RegisterLiveEntity(Spawn(departingGuid, 2));
CleanPhysicsHost(record);
});
LiveEntityRecord departing = runtime.RegisterLiveEntity(Spawn(departingGuid, 1)).Record!;
LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!;
departing.FullCellId = 0x01010123u;
Quaternion departingRotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f);
departing.WorldEntity = Entity(
departingGuid,
new Vector3(12f, 34f, 56f),
departingRotation);
watcherRecord.WorldEntity = Entity(watcherGuid, Vector3.Zero, Quaternion.Identity);
LiveEntityRecord departing = runtime.RegisterAndMaterializeProjection(
Spawn(departingGuid, 1),
localId => Entity(
localId,
departingGuid,
new Vector3(12f, 34f, 56f),
departingRotation));
departing.FullCellId = 0x01010123u;
WorldEntity departingEntity = Assert.IsType<WorldEntity>(departing.WorldEntity);
LiveEntityRecord watcherRecord = runtime.RegisterAndMaterializeProjection(
Spawn(watcherGuid, 1),
localId => Entity(
localId,
watcherGuid,
Vector3.Zero,
Quaternion.Identity));
IPhysicsObjHost? Resolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) ? host : null;
var body = new PhysicsBody
{
Position = departing.WorldEntity.Position,
Position = departingEntity.Position,
Orientation = departingRotation,
};
var remote = new RemoteMotion(body);
@ -586,6 +588,14 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(departingGuid, InstanceSequence: 1),
isLocalPlayer: false));
LiveEntityRecord replacement = runtime.RegisterAndMaterializeProjection(
Spawn(departingGuid, 2));
replacement.FullCellId = 0x02020202u;
runtime.InstallPhysicsHost(
replacement,
Host(
departingGuid,
position: new Vector3(999f, 999f, 999f)));
TargetInfo exit = Assert.Single(delivered);
Assert.Equal(TargetStatus.ExitWorld, exit.Status);
@ -600,11 +610,11 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
{
const uint guid = 0x7000004Bu;
var runtime = Runtime();
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
LiveEntityRecord record = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1));
EntityPhysicsHost host = EntityPhysicsHost.CreateMinimal(record, _ => null, () => 0);
runtime.InstallPhysicsHost(record, host);
Assert.Empty(runtime.WorldEntities);
Assert.Empty(runtime.VisibleRecords);
Assert.True(runtime.TryGetPhysicsHost(guid, out IPhysicsObjHost resolved));
Assert.Same(host, resolved);
}
@ -645,12 +655,13 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
InstanceSequence: instance);
private static AcDream.Core.World.WorldEntity Entity(
uint localId,
uint guid,
Vector3 position,
Quaternion rotation) =>
new()
{
Id = 1_000_000u + (guid & 0xFFFFu),
Id = localId,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = position,

View file

@ -73,7 +73,9 @@ public sealed class LiveEntityPresentationControllerTests
fixture.PresentationOrder);
Assert.Equal(1, fixture.Shadows.TotalRegistered);
Assert.True(fixture.Entity.IsDrawVisible);
Assert.Same(fixture.Entity, Assert.Single(fixture.Runtime.WorldEntities).Value);
Assert.Same(
fixture.Entity,
Assert.Single(fixture.Runtime.VisibleRecords).WorldEntity);
}
[Fact]
@ -289,7 +291,9 @@ public sealed class LiveEntityPresentationControllerTests
Assert.Equal(0, fixture.Shadows.TotalRegistered);
Assert.True(fixture.Runtime.RebucketLiveEntity(Fixture.Guid, 0x02020001u));
Assert.False(fixture.Runtime.WorldEntities.ContainsKey(Fixture.Guid));
Assert.False(fixture.Runtime.TryGetInteractionEligibleEntity(
Fixture.Guid,
out _));
Assert.True(fixture.Runtime.TryApplyState(
new SetState.Parsed(Fixture.Guid, 0u, 1, 3),
@ -303,7 +307,9 @@ public sealed class LiveEntityPresentationControllerTests
new LandBlock(),
Array.Empty<WorldEntity>()));
Assert.True(fixture.Runtime.WorldEntities.ContainsKey(Fixture.Guid));
Assert.True(fixture.Runtime.TryGetInteractionEligibleEntity(
Fixture.Guid,
out _));
Assert.Equal(1, fixture.Shadows.TotalRegistered);
}
@ -318,7 +324,9 @@ public sealed class LiveEntityPresentationControllerTests
Fixture.Guid,
0x02020001u));
Assert.False(fixture.Runtime.WorldEntities.ContainsKey(Fixture.Guid));
Assert.False(fixture.Runtime.TryGetInteractionEligibleEntity(
Fixture.Guid,
out _));
Assert.Equal(0, fixture.Shadows.TotalRegistered);
Assert.Equal(1, fixture.Shadows.RetainedRegistrationCount);
Assert.Equal(1, fixture.Shadows.SuspendedRegistrationCount);
@ -331,7 +339,7 @@ public sealed class LiveEntityPresentationControllerTests
Assert.Same(
fixture.Entity,
Assert.Single(fixture.Runtime.WorldEntities).Value);
Assert.Single(fixture.Runtime.VisibleRecords).WorldEntity);
Assert.Equal(1, fixture.Shadows.TotalRegistered);
Assert.Equal(0, fixture.Shadows.SuspendedRegistrationCount);
Assert.False(fixture.Controller.HasDeferredShadowRestore(Fixture.Guid));
@ -467,7 +475,7 @@ public sealed class LiveEntityPresentationControllerTests
new LandBlock(),
Array.Empty<WorldEntity>()));
Assert.Same(entity, Assert.Single(runtime.WorldEntities).Value);
Assert.Same(entity, Assert.Single(runtime.VisibleRecords).WorldEntity);
Assert.Equal(1, shadows.TotalRegistered);
Assert.Equal(0, shadows.SuspendedRegistrationCount);
Assert.False(controller.HasDeferredShadowRestore(Fixture.Guid));

View file

@ -247,10 +247,8 @@ public sealed class LiveEntityProjectionWithdrawalControllerTests
MotionState: null,
MotionTableId: null,
InstanceSequence: instance);
LiveEntityRecord record = Live.RegisterLiveEntity(spawn).Record!;
WorldEntity entity = Live.MaterializeLiveEntity(
Guid,
Cell,
LiveEntityRecord record = Live.RegisterAndMaterializeProjection(
spawn,
id => new WorldEntity
{
Id = id,
@ -260,7 +258,8 @@ public sealed class LiveEntityProjectionWithdrawalControllerTests
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
ParentCellId = Cell,
})!;
});
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
var snapshot = new WorldEntitySnapshot(
entity.Id,
entity.SourceGfxObjOrSetupId,

View file

@ -11,7 +11,8 @@ public sealed class LiveEntityRuntimeTeardownControllerTests
[Fact]
public void Retry_ReusesPlanAndDoesNotReplayCompletedOwners()
{
LiveEntityRecord record = new(Spawn());
LiveEntityRecord record =
LiveEntityTestFixture.CreateExactProjectionRecord(Spawn());
int factoryCalls = 0;
int completedOwnerCalls = 0;
int retryingOwnerCalls = 0;

View file

@ -196,7 +196,7 @@ public sealed class LiveEntityRuntimeTests
Assert.Single(spatial.Entities);
Assert.True(runtime.WithdrawLiveEntityProjection(spawn.Guid));
Assert.Empty(spatial.Entities);
Assert.Empty(runtime.WorldEntities);
Assert.Empty(runtime.VisibleRecords);
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(0, resources.UnregisterCount);
Assert.True(runtime.TryGetAnimationRuntime(entity!.Id, out _));
@ -424,6 +424,7 @@ public sealed class LiveEntityRuntimeTests
guid,
0x01010001u,
id => Entity(id, guid))!;
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
var animation = new AnimationRuntime(entity);
var remote = new RemoteMotionRuntime();
var projectile = new ProjectileRuntime(remote.Body);
@ -431,9 +432,9 @@ public sealed class LiveEntityRuntimeTests
runtime.SetRemoteMotionRuntime(guid, remote);
runtime.SetProjectileRuntime(guid, projectile);
Assert.Same(animation, Assert.Single(runtime.SpatialAnimationRuntimes).Value);
Assert.Same(remote, Assert.Single(runtime.SpatialRemoteMotionRuntimes).Value);
Assert.Same(projectile, Assert.Single(runtime.SpatialProjectileRuntimes).Value);
Assert.True(runtime.IsCurrentSpatialAnimation(record, animation));
Assert.True(runtime.IsCurrentSpatialRemoteMotion(record, remote));
Assert.True(runtime.IsCurrentSpatialProjectile(record, projectile));
// Hidden suppresses presentation, not the live CPhysicsObj workset.
Assert.True(runtime.TryApplyState(new SetState.Parsed(
@ -441,16 +442,16 @@ public sealed class LiveEntityRuntimeTests
(uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.IgnoreCollisions),
1,
2), out _));
Assert.Single(runtime.SpatialAnimationRuntimes);
Assert.Single(runtime.SpatialRemoteMotionRuntimes);
Assert.Single(runtime.SpatialProjectileRuntimes);
Assert.Equal(1, runtime.SpatialAnimationRuntimeCount);
Assert.Equal(1, runtime.SpatialRemoteMotionRuntimeCount);
Assert.Equal(1, runtime.SpatialProjectileRuntimeCount);
// A pending projection retains every logical component but disappears
// from all per-frame worksets.
Assert.True(runtime.RebucketLiveEntity(guid, 0x02020001u));
Assert.Empty(runtime.SpatialAnimationRuntimes);
Assert.Empty(runtime.SpatialRemoteMotionRuntimes);
Assert.Empty(runtime.SpatialProjectileRuntimes);
Assert.Equal(0, runtime.SpatialAnimationRuntimeCount);
Assert.Equal(0, runtime.SpatialRemoteMotionRuntimeCount);
Assert.Equal(0, runtime.SpatialProjectileRuntimeCount);
Assert.True(runtime.TryGetAnimationRuntime(entity.Id, out var retainedAnimation));
Assert.Same(animation, retainedAnimation);
Assert.True(runtime.TryGetRemoteMotionRuntime(guid, out var retainedRemote));
@ -459,14 +460,14 @@ public sealed class LiveEntityRuntimeTests
Assert.Same(projectile, retainedProjectile);
spatial.AddLandblock(EmptyLandblock(0x0202FFFFu));
Assert.Single(runtime.SpatialAnimationRuntimes);
Assert.Single(runtime.SpatialRemoteMotionRuntimes);
Assert.Single(runtime.SpatialProjectileRuntimes);
Assert.Equal(1, runtime.SpatialAnimationRuntimeCount);
Assert.Equal(1, runtime.SpatialRemoteMotionRuntimeCount);
Assert.Equal(1, runtime.SpatialProjectileRuntimeCount);
Assert.True(runtime.WithdrawLiveEntityProjection(guid));
Assert.Empty(runtime.SpatialAnimationRuntimes);
Assert.Empty(runtime.SpatialRemoteMotionRuntimes);
Assert.Empty(runtime.SpatialProjectileRuntimes);
Assert.Equal(0, runtime.SpatialAnimationRuntimeCount);
Assert.Equal(0, runtime.SpatialRemoteMotionRuntimeCount);
Assert.Equal(0, runtime.SpatialProjectileRuntimeCount);
}
[Fact]
@ -484,22 +485,25 @@ public sealed class LiveEntityRuntimeTests
var animation = new AnimationRuntime(entity);
runtime.SetAnimationRuntime(guid, animation);
var view = new LiveEntityAnimationRuntimeView<AnimationRuntime>(BoundSlot(runtime));
var spatialIds = new HashSet<uint>();
Assert.Equal(1, view.Count);
Assert.Equal(entity.Id, Assert.Single(view.Keys));
view.CopySpatialIdsTo(spatialIds);
Assert.Equal(entity.Id, Assert.Single(spatialIds));
Assert.Same(animation, Assert.Single(view).Value);
Assert.True(runtime.RebucketLiveEntity(guid, 0x02020001u));
Assert.Equal(0, view.Count);
Assert.Empty(view.Keys);
view.CopySpatialIdsTo(spatialIds);
Assert.Empty(spatialIds);
Assert.Empty(view);
Assert.True(view.TryGetValue(entity.Id, out AnimationRuntime retained));
Assert.Same(animation, retained);
Assert.True(view.Remove(entity.Id));
Assert.False(view.TryGetValue(entity.Id, out _));
Assert.Empty(runtime.AnimationRuntimes);
Assert.Empty(runtime.SpatialAnimationRuntimes);
Assert.Equal(0, runtime.AnimationRuntimeCount);
Assert.Equal(0, runtime.SpatialAnimationRuntimeCount);
}
[Fact]
@ -531,8 +535,8 @@ public sealed class LiveEntityRuntimeTests
}
Assert.Single(visited);
Assert.Single(runtime.SpatialAnimationRuntimes);
Assert.Equal(2, runtime.AnimationRuntimes.Count);
Assert.Equal(1, runtime.SpatialAnimationRuntimeCount);
Assert.Equal(2, runtime.AnimationRuntimeCount);
}
[Fact]
@ -601,10 +605,10 @@ public sealed class LiveEntityRuntimeTests
Assert.False(runtime.WithdrawLiveEntityProjection(guid));
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current));
Assert.Equal((ushort)2, current.Generation);
KeyValuePair<uint, ILiveEntityAnimationRuntime> indexed =
Assert.Single(runtime.SpatialAnimationRuntimes);
Assert.Same(current.AnimationRuntime, indexed.Value);
Assert.NotSame(oldAnimation, indexed.Value);
Assert.True(runtime.IsCurrentSpatialAnimation(
current,
current.AnimationRuntime!));
Assert.NotSame(oldAnimation, current.AnimationRuntime);
}
[Fact]
@ -670,13 +674,18 @@ public sealed class LiveEntityRuntimeTests
spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
WorldSession.EntitySpawn spawn = Spawn(guid, 6, 1, 0x01010001u);
runtime.RegisterLiveEntity(spawn);
LiveEntityRegistrationResult registration =
runtime.RegisterLiveEntity(spawn);
RuntimeEntityRecord canonical = Assert.IsType<RuntimeEntityRecord>(
registration.Canonical);
var profile = new EffectProfile();
runtime.SetEffectProfile(guid, profile);
runtime.MaterializeLiveEntity(
guid,
canonical,
spawn.Position!.Value.LandblockId,
id => Entity(id, guid));
id => Entity(id, guid),
LiveEntityProjectionKind.World,
initializeProjection: record => record.EffectProfile = profile,
out _);
Assert.True(runtime.RebucketLiveEntity(guid, 0x01020001u));
Assert.True(runtime.TryGetEffectProfile(guid, out var retained));
@ -865,7 +874,7 @@ public sealed class LiveEntityRuntimeTests
LiveEntityProjectionKind.Attached)!;
Assert.Single(spatial.Entities);
Assert.Empty(runtime.WorldEntities);
Assert.Empty(runtime.VisibleRecords);
Assert.False(runtime.TryMarkWorldSpawnPublished(spawn.Guid));
Assert.True(runtime.TryGetWorldEntity(spawn.Guid, out WorldEntity resolved));
Assert.Same(attached, resolved);
@ -880,7 +889,7 @@ public sealed class LiveEntityRuntimeTests
Assert.Same(attached, same);
Assert.True(same.IsAncestorDrawVisible);
Assert.Same(attached, Assert.Single(runtime.WorldEntities).Value);
Assert.Same(attached, Assert.Single(runtime.VisibleRecords).WorldEntity);
Assert.True(runtime.TryMarkWorldSpawnPublished(spawn.Guid));
Assert.False(runtime.TryMarkWorldSpawnPublished(spawn.Guid));
Assert.Equal(1, resources.RegisterCount);
@ -902,12 +911,12 @@ public sealed class LiveEntityRuntimeTests
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);
Assert.Empty(runtime.VisibleRecords);
Assert.Same(entity, Assert.Single(runtime.MaterializedRecords).WorldEntity);
spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
Assert.Same(entity, Assert.Single(spatial.Entities));
Assert.Same(entity, Assert.Single(runtime.WorldEntities).Value);
Assert.Same(entity, Assert.Single(runtime.VisibleRecords).WorldEntity);
Assert.True(runtime.RebucketLiveEntity(spawn.Guid, 0x01020001u));
Assert.Same(entity, Assert.Single(spatial.Entities));
Assert.Equal(1, resources.RegisterCount);
@ -928,8 +937,8 @@ public sealed class LiveEntityRuntimeTests
spawn.Position!.Value.LandblockId,
id => Entity(id, guid))!;
Assert.Empty(runtime.WorldEntities);
Assert.Same(entity, Assert.Single(runtime.MaterializedWorldEntities).Value);
Assert.Empty(runtime.VisibleRecords);
Assert.Same(entity, Assert.Single(runtime.MaterializedRecords).WorldEntity);
Assert.True(runtime.TryApplyVector(
new VectorUpdate.Parsed(
guid,
@ -960,8 +969,8 @@ public sealed class LiveEntityRuntimeTests
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.Empty(runtime.VisibleRecords);
Assert.Same(entity, Assert.Single(runtime.MaterializedRecords).WorldEntity);
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(0, resources.UnregisterCount);
Assert.Equal(1, spatial.PendingLiveEntityCount);
@ -1097,11 +1106,7 @@ public sealed class LiveEntityRuntimeTests
spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u);
LiveEntityRecord record = runtime.RegisterLiveEntity(spawn).Record!;
runtime.MaterializeLiveEntity(
guid,
spawn.Position!.Value.LandblockId,
id => Entity(id, guid));
LiveEntityRecord record = RegisterProjection(runtime, spawn);
RetailObjectQuantumClock clock = record.ObjectClock;
Assert.Equal(0, clock.Advance(0.02).Count);
@ -1145,15 +1150,10 @@ public sealed class LiveEntityRuntimeTests
1,
0x01010001u,
PhysicsStateFlags.Static);
LiveEntityRecord record = runtime.RegisterLiveEntity(spawn).Record!;
LiveEntityRecord record = RegisterProjection(runtime, spawn);
var remote = new RemoteMotionRuntime();
runtime.SetRemoteMotionRuntime(guid, remote);
runtime.MaterializeLiveEntity(
guid,
spawn.Position!.Value.LandblockId,
id => Entity(id, guid));
Assert.False(record.ObjectClock.IsActive);
Assert.Equal(0.0, record.ObjectClock.PendingSeconds, 8);
Assert.False(remote.Body.TransientState.HasFlag(TransientStateFlags.Active));
@ -1168,14 +1168,11 @@ public sealed class LiveEntityRuntimeTests
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
LiveEntityRecord ordinary = runtime.RegisterLiveEntity(
Spawn(ordinaryGuid, 1, 1, 0x01010001u)).Record!;
LiveEntityRecord ordinary = RegisterProjection(
runtime,
Spawn(ordinaryGuid, 1, 1, 0x01010001u));
var ordinaryRemote = new RemoteMotionRuntime();
runtime.SetRemoteMotionRuntime(ordinaryGuid, ordinaryRemote);
runtime.MaterializeLiveEntity(
ordinaryGuid,
0x01010001u,
id => Entity(id, ordinaryGuid));
ordinary.ObjectClock.Deactivate();
ordinaryRemote.Body.TransientState &= ~TransientStateFlags.Active;
@ -1183,14 +1180,16 @@ public sealed class LiveEntityRuntimeTests
Assert.True(ordinary.ObjectClock.IsActive);
Assert.True(ordinaryRemote.Body.TransientState.HasFlag(TransientStateFlags.Active));
LiveEntityRecord staticRecord = runtime.RegisterLiveEntity(
Spawn(staticGuid, 1, 1, 0x01010001u, PhysicsStateFlags.Static)).Record!;
LiveEntityRecord staticRecord = RegisterProjection(
runtime,
Spawn(
staticGuid,
1,
1,
0x01010001u,
PhysicsStateFlags.Static));
var staticRemote = new RemoteMotionRuntime();
runtime.SetRemoteMotionRuntime(staticGuid, staticRemote);
runtime.MaterializeLiveEntity(
staticGuid,
0x01010001u,
id => Entity(id, staticGuid));
Assert.False(runtime.TryActivateOrdinaryObject(staticGuid, staticRemote));
Assert.False(staticRecord.ObjectClock.IsActive);
@ -1207,14 +1206,11 @@ public sealed class LiveEntityRuntimeTests
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
LiveEntityRecord ordinary = runtime.RegisterLiveEntity(
Spawn(ordinaryGuid, 1, 1, 0x01010001u)).Record!;
LiveEntityRecord ordinary = RegisterProjection(
runtime,
Spawn(ordinaryGuid, 1, 1, 0x01010001u));
var ordinaryRemote = new RemoteMotionRuntime();
runtime.SetRemoteMotionRuntime(ordinaryGuid, ordinaryRemote);
runtime.MaterializeLiveEntity(
ordinaryGuid,
0x01010001u,
id => Entity(id, ordinaryGuid));
ordinary.ObjectClock.Deactivate();
ordinaryRemote.Body.TransientState &= ~TransientStateFlags.Active;
ordinaryRemote.Body.LastUpdateTime = 1.0;
@ -1234,14 +1230,11 @@ public sealed class LiveEntityRuntimeTests
// Defensive split state: the retained object clock is already active,
// but a body consumer cleared its legacy Active bit. set_velocity must
// still rebase that body's compatibility timestamp.
LiveEntityRecord defensive = runtime.RegisterLiveEntity(
Spawn(defensiveGuid, 1, 1, 0x01010001u)).Record!;
LiveEntityRecord defensive = RegisterProjection(
runtime,
Spawn(defensiveGuid, 1, 1, 0x01010001u));
var defensiveRemote = new RemoteMotionRuntime();
runtime.SetRemoteMotionRuntime(defensiveGuid, defensiveRemote);
runtime.MaterializeLiveEntity(
defensiveGuid,
0x01010001u,
id => Entity(id, defensiveGuid));
Assert.True(defensive.ObjectClock.IsActive);
defensiveRemote.Body.TransientState &= ~TransientStateFlags.Active;
defensiveRemote.Body.LastUpdateTime = 2.0;
@ -1255,19 +1248,16 @@ public sealed class LiveEntityRuntimeTests
Assert.True(defensiveRemote.Body.IsActive);
Assert.Equal(9.0, defensiveRemote.Body.LastUpdateTime);
LiveEntityRecord staticRecord = runtime.RegisterLiveEntity(
LiveEntityRecord staticRecord = RegisterProjection(
runtime,
Spawn(
staticGuid,
1,
1,
0x01010001u,
PhysicsStateFlags.Static)).Record!;
PhysicsStateFlags.Static));
var staticRemote = new RemoteMotionRuntime();
runtime.SetRemoteMotionRuntime(staticGuid, staticRemote);
runtime.MaterializeLiveEntity(
staticGuid,
0x01010001u,
id => Entity(id, staticGuid));
staticRemote.Body.TransientState &= ~TransientStateFlags.Active;
staticRemote.Body.LastUpdateTime = 3.0;
@ -1291,8 +1281,9 @@ public sealed class LiveEntityRuntimeTests
const uint childGuid = 0x70000055u;
var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources());
runtime.RegisterLiveEntity(Spawn(parentGuid, 1, 1, 0x01010001u));
LiveEntityRecord child = runtime.RegisterLiveEntity(
Spawn(childGuid, 1, 1, 0x01010001u)).Record!;
LiveEntityRecord child = RegisterProjection(
runtime,
Spawn(childGuid, 1, 1, 0x01010001u));
ulong initialPosition = child.PositionAuthorityVersion;
ulong initialVelocity = child.VelocityAuthorityVersion;
@ -1325,11 +1316,7 @@ public sealed class LiveEntityRuntimeTests
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
WorldSession.EntitySpawn firstSpawn = Spawn(guid, 1, 1, 0x01010001u);
LiveEntityRecord first = runtime.RegisterLiveEntity(firstSpawn).Record!;
runtime.MaterializeLiveEntity(
guid,
firstSpawn.Position!.Value.LandblockId,
id => Entity(id, guid));
LiveEntityRecord first = RegisterProjection(runtime, firstSpawn);
RetailObjectQuantumClock firstClock = first.ObjectClock;
Assert.Equal(0, firstClock.Advance(0.02).Count);
@ -1341,7 +1328,7 @@ public sealed class LiveEntityRuntimeTests
new DeleteObject.Parsed(guid, InstanceSequence: 1),
isLocalPlayer: false));
WorldSession.EntitySpawn secondSpawn = Spawn(guid, 2, 2, 0x01010001u);
LiveEntityRecord second = runtime.RegisterLiveEntity(secondSpawn).Record!;
LiveEntityRecord second = RegisterProjection(runtime, secondSpawn);
Assert.NotSame(first, second);
Assert.NotSame(firstClock, second.ObjectClock);
@ -1364,9 +1351,17 @@ public sealed class LiveEntityRuntimeTests
Assert.True(runtime.TryApplyState(
new SetState.Parsed(stateBeforeBindGuid, (uint)firstState, 1, 2),
out _));
runtime.MaterializeLiveEntity(
stateBeforeBindGuid,
0x01010001u,
id => Entity(id, stateBeforeBindGuid));
var lateBody = new RemoteMotionRuntime();
runtime.SetRemoteMotionRuntime(stateBeforeBindGuid, lateBody);
runtime.MaterializeLiveEntity(
bindBeforeStateGuid,
0x01010001u,
id => Entity(id, bindBeforeStateGuid));
var earlyBody = new RemoteMotionRuntime();
runtime.SetRemoteMotionRuntime(bindBeforeStateGuid, earlyBody);
PhysicsStateFlags secondState = PhysicsStateFlags.Static
@ -1388,6 +1383,10 @@ public sealed class LiveEntityRuntimeTests
const uint guid = 0x70000039u;
var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources());
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid));
var remote = new RemoteMotionRuntime();
runtime.SetRemoteMotionRuntime(guid, remote);
@ -1404,9 +1403,10 @@ public sealed class LiveEntityRuntimeTests
const uint guid = 0x70000040u;
var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources());
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
LiveEntityRecord record = RegisterProjection(
runtime,
Spawn(guid, 1, 1, 0x01010001u));
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
Assert.True(record.TryDequeueStateTransition(out RetailPhysicsStateTransition transition));
Assert.Equal(RetailHiddenTransition.None, transition.HiddenTransition);
Assert.Equal(PhysicsStateFlags.ReportCollisions, record.FinalPhysicsState);
@ -1431,8 +1431,8 @@ public sealed class LiveEntityRuntimeTests
id => Entity(id, guid))!;
Assert.False(entity.IsDrawVisible);
Assert.Empty(runtime.WorldEntities);
Assert.Same(entity, Assert.Single(runtime.MaterializedWorldEntities).Value);
Assert.Empty(runtime.VisibleRecords);
Assert.Same(entity, Assert.Single(runtime.MaterializedRecords).WorldEntity);
Assert.Single(spatial.Entities);
Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
@ -1462,7 +1462,7 @@ public sealed class LiveEntityRuntimeTests
2), out _, out RetailPhysicsStateTransition hidden));
Assert.Equal(RetailHiddenTransition.BecameHidden, hidden.HiddenTransition);
Assert.False(entity.IsDrawVisible);
Assert.Empty(runtime.WorldEntities);
Assert.Empty(runtime.VisibleRecords);
Assert.False(runtime.TryGetInteractionEligibleEntity(guid, out _));
Assert.False(runtime.TryGetInteractionEligibleRecord(guid, entity.Id, out _));
@ -1478,7 +1478,7 @@ public sealed class LiveEntityRuntimeTests
Assert.Same(entity, eligibleRecord.WorldEntity);
Assert.False(runtime.TryGetInteractionEligibleRecord(guid, entity.Id + 1u, out _));
Assert.Same(entity, eligible);
Assert.Same(entity, Assert.Single(runtime.WorldEntities).Value);
Assert.Same(entity, Assert.Single(runtime.VisibleRecords).WorldEntity);
Assert.Same(entity, Assert.Single(spatial.Entities));
}
@ -1573,7 +1573,7 @@ public sealed class LiveEntityRuntimeTests
spatial.RemoveLandblock(0x0101FFFFu);
Assert.Empty(runtime.WorldEntities);
Assert.Empty(runtime.VisibleRecords);
Assert.True(runtime.TryGetRecord(spawn.Guid, out LiveEntityRecord record));
Assert.True(record.IsSpatiallyProjected);
Assert.False(record.IsSpatiallyVisible);
@ -1582,7 +1582,7 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(0, resources.UnregisterCount);
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
Assert.Same(entity, Assert.Single(runtime.WorldEntities).Value);
Assert.Same(entity, Assert.Single(runtime.VisibleRecords).WorldEntity);
Assert.True(record.IsSpatiallyVisible);
Assert.Equal(1, resources.RegisterCount);
}
@ -1632,7 +1632,7 @@ public sealed class LiveEntityRuntimeTests
runtime.Clear();
Assert.Equal(0, runtime.Count);
Assert.Empty(runtime.WorldEntities);
Assert.Empty(runtime.VisibleRecords);
Assert.Empty(spatial.Entities);
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
@ -1654,10 +1654,11 @@ public sealed class LiveEntityRuntimeTests
spawn.Position!.Value.LandblockId,
id => Entity(id, spawn.Guid)));
Assert.True(runtime.TryGetRecord(spawn.Guid, out LiveEntityRecord record));
Assert.Null(record.WorldEntity);
Assert.False(runtime.TryGetRecord(spawn.Guid, out _));
RuntimeEntityRecord canonical = CurrentCanonical(runtime, spawn.Guid);
Assert.Null(canonical.LocalEntityId);
Assert.Equal(0, runtime.MaterializedCount);
Assert.Empty(runtime.WorldEntities);
Assert.Empty(runtime.VisibleRecords);
Assert.Empty(spatial.Entities);
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
@ -1724,7 +1725,7 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(1, runtime.RetryPendingTeardowns());
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current));
Assert.Same(replacement, current.WorldEntity);
Assert.Same(replacement, Assert.Single(runtime.MaterializedWorldEntities).Value);
Assert.Same(replacement, Assert.Single(runtime.MaterializedRecords).WorldEntity);
Assert.Equal(0, runtime.PendingTeardownCount);
}
@ -1746,7 +1747,7 @@ public sealed class LiveEntityRuntimeTests
LiveEntityRegistrationResult retransmit = runtime.RegisterLiveEntity(spawn);
Assert.False(retransmit.LogicalRegistrationCreated);
Assert.Null(retransmit.Record);
Assert.Null(retransmit.Projection);
Assert.Equal(0, runtime.Count);
Assert.Equal(1, runtime.PendingTeardownCount);
@ -1780,7 +1781,7 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(0, runtime.Count);
Assert.Equal(1, runtime.PendingTeardownCount);
Assert.Equal(1, runtime.MaterializedCount);
Assert.Empty(runtime.WorldEntities);
Assert.Empty(runtime.VisibleRecords);
Assert.Empty(spatial.Entities);
}
@ -1898,9 +1899,10 @@ public sealed class LiveEntityRuntimeTests
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);
RuntimeEntityRecord canonical = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)2, canonical.Generation);
Assert.Null(canonical.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
WorldEntity installed = runtime.MaterializeLiveEntity(
guid,
0x01010001u,
@ -1949,7 +1951,7 @@ public sealed class LiveEntityRuntimeTests
runtime.RebucketLiveEntity(guid, 0x01020001u);
Assert.Same(attached, Assert.Single(spatial.Entities));
Assert.Empty(runtime.WorldEntities);
Assert.Empty(runtime.VisibleRecords);
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(0, resources.UnregisterCount);
var destination = Assert.Single(
@ -2082,8 +2084,8 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(0x0202FFFFu, record.CanonicalLandblockId);
Assert.True(record.IsSpatiallyProjected);
Assert.False(record.IsSpatiallyVisible);
Assert.Same(entity, Assert.Single(runtime.MaterializedWorldEntities).Value);
Assert.Empty(runtime.WorldEntities);
Assert.Same(entity, Assert.Single(runtime.MaterializedRecords).WorldEntity);
Assert.Empty(runtime.VisibleRecords);
Assert.Equal(1, spatial.PendingLiveEntityCount);
Assert.Equal(0, spatial.PendingVisibilityTransitionCount);
}
@ -2112,8 +2114,8 @@ public sealed class LiveEntityRuntimeTests
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
Assert.False(record.IsSpatiallyProjected);
Assert.False(record.IsSpatiallyVisible);
Assert.Empty(runtime.MaterializedWorldEntities);
Assert.Empty(runtime.WorldEntities);
Assert.Single(runtime.MaterializedRecords);
Assert.Empty(runtime.VisibleRecords);
Assert.Empty(spatial.Entities);
Assert.Equal(0, spatial.PendingLiveEntityCount);
Assert.Equal(0, spatial.PendingVisibilityTransitionCount);
@ -2148,8 +2150,8 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(0x01010011u, replacement.FullCellId);
Assert.True(replacement.IsSpatiallyProjected);
Assert.True(replacement.IsSpatiallyVisible);
Assert.Same(replacement.WorldEntity, Assert.Single(runtime.MaterializedWorldEntities).Value);
Assert.Same(replacement.WorldEntity, Assert.Single(runtime.WorldEntities).Value);
Assert.Same(replacement.WorldEntity, Assert.Single(runtime.MaterializedRecords).WorldEntity);
Assert.Same(replacement.WorldEntity, Assert.Single(runtime.VisibleRecords).WorldEntity);
Assert.Same(replacement.WorldEntity, Assert.Single(spatial.Entities));
}
@ -2161,11 +2163,14 @@ public sealed class LiveEntityRuntimeTests
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid));
WorldEntity original = runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid))!;
bool replaced = false;
spatial.LiveProjectionVisibilityChanged += (edgeGuid, visible) =>
spatial.LiveProjectionVisibilityChanged += (localEntityId, visible) =>
{
if (replaced || visible || edgeGuid != guid)
if (replaced || visible || localEntityId != original.Id)
return;
replaced = true;
Assert.True(runtime.UnregisterLiveEntity(
@ -2183,8 +2188,8 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(0x0101FFFFu, replacement.CanonicalLandblockId);
Assert.True(replacement.IsSpatiallyProjected);
Assert.True(replacement.IsSpatiallyVisible);
Assert.Same(replacement.WorldEntity, Assert.Single(runtime.MaterializedWorldEntities).Value);
Assert.Same(replacement.WorldEntity, Assert.Single(runtime.WorldEntities).Value);
Assert.Same(replacement.WorldEntity, Assert.Single(runtime.MaterializedRecords).WorldEntity);
Assert.Same(replacement.WorldEntity, Assert.Single(runtime.VisibleRecords).WorldEntity);
Assert.Same(replacement.WorldEntity, Assert.Single(spatial.Entities));
Assert.Equal(0, spatial.PendingLiveEntityCount);
}
@ -2213,8 +2218,8 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(0x01010022u, record.FullCellId);
Assert.True(record.IsSpatiallyProjected);
Assert.True(record.IsSpatiallyVisible);
Assert.Same(record.WorldEntity, Assert.Single(runtime.MaterializedWorldEntities).Value);
Assert.Same(record.WorldEntity, Assert.Single(runtime.WorldEntities).Value);
Assert.Same(record.WorldEntity, Assert.Single(runtime.MaterializedRecords).WorldEntity);
Assert.Same(record.WorldEntity, Assert.Single(runtime.VisibleRecords).WorldEntity);
Assert.Same(record.WorldEntity, Assert.Single(spatial.Entities));
}
@ -2260,8 +2265,8 @@ public sealed class LiveEntityRuntimeTests
Assert.True(record.IsSpatiallyProjected);
Assert.True(record.IsSpatiallyVisible);
Assert.Same(pending, record.WorldEntity);
Assert.Same(pending, Assert.Single(runtime.MaterializedWorldEntities).Value);
Assert.Same(pending, Assert.Single(runtime.WorldEntities).Value);
Assert.Same(pending, Assert.Single(runtime.MaterializedRecords).WorldEntity);
Assert.Same(pending, Assert.Single(runtime.VisibleRecords).WorldEntity);
Assert.Same(pending, Assert.Single(spatial.Entities));
}
@ -2300,7 +2305,7 @@ public sealed class LiveEntityRuntimeTests
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current));
Assert.Equal((ushort)2, current.Generation);
Assert.Same(replacementEntity, current.WorldEntity);
Assert.Same(replacementEntity, Assert.Single(runtime.WorldEntities).Value);
Assert.Same(replacementEntity, Assert.Single(runtime.VisibleRecords).WorldEntity);
Assert.Equal(1, runtime.PendingTeardownCount);
Assert.Equal(1, runtime.RetryPendingTeardowns());
Assert.Equal(0, runtime.PendingTeardownCount);
@ -2315,11 +2320,14 @@ public sealed class LiveEntityRuntimeTests
spatial.AddLandblock(EmptyLandblock(0x0303FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid));
WorldEntity original = runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid))!;
bool rebucketed = false;
spatial.LiveProjectionVisibilityChanged += (edgeGuid, visible) =>
spatial.LiveProjectionVisibilityChanged += (localEntityId, visible) =>
{
if (rebucketed || visible || edgeGuid != guid)
if (rebucketed || visible || localEntityId != original.Id)
return;
rebucketed = true;
Assert.True(runtime.RebucketLiveEntity(guid, 0x03030033u));
@ -2332,8 +2340,8 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(0x0303FFFFu, record.CanonicalLandblockId);
Assert.True(record.IsSpatiallyProjected);
Assert.True(record.IsSpatiallyVisible);
Assert.Same(record.WorldEntity, Assert.Single(runtime.MaterializedWorldEntities).Value);
Assert.Same(record.WorldEntity, Assert.Single(runtime.WorldEntities).Value);
Assert.Same(record.WorldEntity, Assert.Single(runtime.MaterializedRecords).WorldEntity);
Assert.Same(record.WorldEntity, Assert.Single(runtime.VisibleRecords).WorldEntity);
Assert.Same(record.WorldEntity, Assert.Single(spatial.Entities));
Assert.Equal(0, spatial.PendingLiveEntityCount);
}
@ -2374,7 +2382,8 @@ public sealed class LiveEntityRuntimeTests
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement));
Assert.Equal((ushort)2, replacement.Generation);
Assert.True(replacement.IsSpatiallyVisible);
Assert.True(spatial.IsLiveEntityVisible(guid));
Assert.True(spatial.IsLiveEntityVisible(
replacement.WorldEntity!.Id));
Assert.Equal(2, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
}
@ -2448,11 +2457,14 @@ public sealed class LiveEntityRuntimeTests
error.Flatten().InnerExceptions,
exception => exception is InvalidOperationException
&& exception.Message.Contains("active logical-lifetime transition", StringComparison.Ordinal));
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current));
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)2, current.Generation);
Assert.Null(current.WorldEntity);
Assert.Null(current.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Empty(spatial.Entities);
Assert.DoesNotContain(guid, runtime.WorldEntities.Keys);
Assert.DoesNotContain(
runtime.VisibleRecords,
record => record.ServerGuid == guid);
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
Assert.False(runtime.TryGetServerGuid(old.Id, out _));
@ -2479,9 +2491,10 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(CreateObjectTimestampDisposition.StaleGeneration, outer.Inbound.Disposition);
Assert.False(outer.LogicalRegistrationCreated);
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current));
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)3, current.Generation);
Assert.Null(current.WorldEntity);
Assert.Null(current.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Empty(spatial.Entities);
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
@ -2509,9 +2522,12 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(CreateObjectTimestampDisposition.NewGeneration, outer.Inbound.Disposition);
Assert.True(outer.LogicalRegistrationCreated);
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement));
RuntimeEntityRecord replacement = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)2, replacement.Generation);
Assert.True(runtime.TryGetRecord(unrelatedGuid, out _));
Assert.Null(replacement.LocalEntityId);
Assert.Null(CurrentCanonical(runtime, unrelatedGuid).LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.False(runtime.TryGetRecord(unrelatedGuid, out _));
Assert.Equal(2, runtime.Count);
}
@ -2569,9 +2585,10 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(CreateObjectTimestampDisposition.StaleGeneration, outer.Inbound.Disposition);
Assert.False(outer.LogicalRegistrationCreated);
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current));
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)3, current.Generation);
Assert.Null(current.WorldEntity);
Assert.Null(current.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
}
@ -2600,9 +2617,10 @@ public sealed class LiveEntityRuntimeTests
error.Flatten().InnerExceptions,
exception => exception is InvalidOperationException
&& exception.Message == "old incarnation cleanup failed");
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current));
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)3, current.Generation);
Assert.Null(current.WorldEntity);
Assert.Null(current.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
}
@ -2651,9 +2669,10 @@ public sealed class LiveEntityRuntimeTests
runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid)));
Assert.Contains("atomic resource registration", error.Message, StringComparison.Ordinal);
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current));
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)1, current.Generation);
Assert.Null(current.WorldEntity);
Assert.Null(current.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
Assert.Equal(0, runtime.MaterializedCount);
@ -2675,8 +2694,9 @@ public sealed class LiveEntityRuntimeTests
runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid)));
Assert.Contains("atomic resource registration", error.Message, StringComparison.Ordinal);
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current));
Assert.Null(current.WorldEntity);
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
Assert.Null(current.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
Assert.Equal(0, runtime.MaterializedCount);
@ -2746,7 +2766,7 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(0, runtime.Count);
Assert.Equal(1, runtime.PendingTeardownCount);
Assert.Equal(1, runtime.MaterializedCount);
Assert.Empty(runtime.WorldEntities);
Assert.Empty(runtime.VisibleRecords);
Assert.Empty(spatial.Entities);
Assert.Equal(0, spatial.PendingLiveEntityCount);
Assert.Equal(1, resources.RegisterCount);
@ -2755,7 +2775,7 @@ public sealed class LiveEntityRuntimeTests
runtime.Clear();
Assert.Equal(0, runtime.PendingTeardownCount);
Assert.Equal(0, runtime.MaterializedCount);
Assert.Empty(runtime.MaterializedWorldEntities);
Assert.Empty(runtime.MaterializedRecords);
Assert.Equal(2, resources.UnregisterCount);
}
@ -2779,57 +2799,11 @@ public sealed class LiveEntityRuntimeTests
beforeTeardown: () => throw new InvalidOperationException("fixture callback failure")));
Assert.Equal(0, runtime.Count);
Assert.Empty(runtime.WorldEntities);
Assert.Empty(runtime.VisibleRecords);
Assert.Empty(spatial.Entities);
Assert.Equal(1, resources.UnregisterCount);
}
[Fact]
public void IncarnationCleanup_OldTombstoneCannotMutateReplacementGuidState()
{
const uint guid = 0x70000035u;
var oldRecord = new LiveEntityRecord(Spawn(guid, 1, 1, 0x01010001u));
var replacement = new LiveEntityRecord(Spawn(guid, 2, 1, 0x01010001u));
LiveEntityRecord? current = replacement;
var cleanup = new LiveEntityIncarnationCleanup(oldRecord, _ => current);
object oldHost = new();
object newHost = new();
var hosts = new Dictionary<uint, object> { [guid] = newHost };
bool guidStateCleared = false;
var plan = new LiveEntityTeardownPlan(
[
() => cleanup.RunIfNoReplacement(() => guidStateCleared = true),
() => cleanup.RemoveCaptured(hosts, oldHost),
]);
plan.Advance();
Assert.False(guidStateCleared);
Assert.Same(newHost, hosts[guid]);
Assert.True(plan.IsComplete);
}
[Fact]
public void IncarnationCleanup_RemovesOnlyCapturedOwnerAndResumesAfterReplacementEnds()
{
const uint guid = 0x70000036u;
var oldRecord = new LiveEntityRecord(Spawn(guid, 1, 1, 0x01010001u));
var replacement = new LiveEntityRecord(Spawn(guid, 2, 1, 0x01010001u));
LiveEntityRecord? current = replacement;
var cleanup = new LiveEntityIncarnationCleanup(oldRecord, _ => current);
object oldHost = new();
var hosts = new Dictionary<uint, object> { [guid] = oldHost };
int guidCleanupCount = 0;
cleanup.RemoveCaptured(hosts, oldHost);
cleanup.RunIfNoReplacement(() => guidCleanupCount++);
current = null;
cleanup.RunIfNoReplacement(() => guidCleanupCount++);
Assert.Empty(hosts);
Assert.Equal(1, guidCleanupCount);
}
private static LoadedLandblock EmptyLandblock(uint canonicalId) =>
new(canonicalId, new LandBlock(), Array.Empty<WorldEntity>());
@ -2849,6 +2823,34 @@ public sealed class LiveEntityRuntimeTests
: null,
update => runtime.TryApplyParent(update, out _));
private static LiveEntityRecord RegisterProjection(
LiveEntityRuntime runtime,
WorldSession.EntitySpawn spawn)
{
LiveEntityRegistrationResult registration =
runtime.RegisterLiveEntity(spawn);
RuntimeEntityRecord canonical = Assert.IsType<RuntimeEntityRecord>(
registration.Canonical);
WorldEntity entity = Assert.IsType<WorldEntity>(
runtime.MaterializeLiveEntity(
canonical,
spawn.Position?.LandblockId ?? 0u,
id => Entity(id, spawn.Guid),
LiveEntityProjectionKind.World,
initializeProjection: null,
out LiveEntityRecord? record));
Assert.Equal(entity.Id, canonical.LocalEntityId);
return Assert.IsType<LiveEntityRecord>(record);
}
private static RuntimeEntityRecord CurrentCanonical(
LiveEntityRuntime runtime,
uint serverGuid)
{
Assert.True(runtime.TryGetCanonical(serverGuid, out RuntimeEntityRecord canonical));
return canonical;
}
private static WorldEntity Entity(uint id, uint guid) => new()
{
Id = id,

View file

@ -0,0 +1,87 @@
using System.Numerics;
using AcDream.Core.Net;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
namespace AcDream.App.World;
/// <summary>
/// Test-only construction seam for exact Runtime identities and App
/// projections. Component fixtures must not manufacture detached
/// <see cref="LiveEntityRecord"/> instances because that bypasses the same
/// local-ID/incarnation key used by production.
/// </summary>
internal static class LiveEntityTestFixture
{
public static LiveEntityRecord CreateExactProjectionRecord(
WorldSession.EntitySpawn spawn)
{
var directory = new RuntimeEntityDirectory();
RuntimeEntityRecord canonical = directory.AddActive(spawn);
directory.ClaimLocalId(canonical);
var projections = new LiveEntityProjectionStore(directory);
return projections.AddMaterializing(canonical);
}
public static LiveEntityRecord RegisterAndMaterializeProjection(
this LiveEntityRuntime runtime,
WorldSession.EntitySpawn spawn,
Func<uint, WorldEntity>? factory = null,
LiveEntityProjectionKind projectionKind = LiveEntityProjectionKind.World,
Action<LiveEntityRecord>? initializeProjection = null)
{
ArgumentNullException.ThrowIfNull(runtime);
LiveEntityRegistrationResult registration =
runtime.RegisterLiveEntity(spawn);
RuntimeEntityRecord canonical = registration.Canonical
?? throw new InvalidOperationException(
"The fixture cannot materialize a stale CreateObject.");
if (registration.Projection is { } existing)
return existing;
WorldEntity? entity = runtime.MaterializeLiveEntity(
canonical,
spawn.Position?.LandblockId ?? canonical.FullCellId,
factory ?? (id => CreateWorldEntity(id, spawn)),
projectionKind,
initializeProjection,
out LiveEntityRecord? record);
if (entity is null || record is null)
{
throw new InvalidOperationException(
"The fixture failed to materialize the exact Runtime incarnation.");
}
return record;
}
private static WorldEntity CreateWorldEntity(
uint localEntityId,
WorldSession.EntitySpawn spawn)
{
var position = spawn.Position;
return new WorldEntity
{
Id = localEntityId,
ServerGuid = spawn.Guid,
SourceGfxObjOrSetupId = spawn.SetupTableId ?? 0u,
Position = position is { } placed
? new Vector3(
placed.PositionX,
placed.PositionY,
placed.PositionZ)
: Vector3.Zero,
Rotation = position is { } oriented
? new Quaternion(
oriented.RotationX,
oriented.RotationY,
oriented.RotationZ,
oriented.RotationW)
: Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
ParentCellId = position?.LandblockId,
EffectCellId = position?.LandblockId,
};
}
}

View file

@ -1,4 +1,9 @@
using System.Reflection;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.World;
using AcDream.Runtime.Entities;
@ -14,28 +19,118 @@ public sealed class RuntimeEntityOwnershipTests
BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Missing canonical entity directory.");
FieldInfo sidecars = typeof(LiveEntityRuntime).GetField(
"_activeRecords",
"_projections",
BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Missing App projection sidecar store.");
Assert.Equal(typeof(RuntimeEntityDirectory), directory.FieldType);
Assert.Equal(typeof(RuntimeEntityRecord), sidecars.FieldType.GetGenericArguments()[0]);
Assert.Equal(typeof(LiveEntityRecord), sidecars.FieldType.GetGenericArguments()[1]);
Assert.Equal(typeof(LiveEntityProjectionStore), sidecars.FieldType);
}
[Fact]
public void AppCompatibilityLookup_IsNotASecondGuidDictionary()
public void AppProjectionOwners_UseExactRuntimeKeysAndNoGuidDictionary()
{
FieldInfo compatibilityView = typeof(LiveEntityRuntime).GetField(
"_recordsByGuid",
BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Missing migration compatibility view.");
FieldInfo[] runtimeFields = typeof(LiveEntityRuntime).GetFields(
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.DoesNotContain(runtimeFields, IsGuidDictionary);
Assert.False(compatibilityView.FieldType.IsGenericType);
Assert.DoesNotContain(
compatibilityView.FieldType.GetInterfaces(),
type => type.IsGenericType
&& type.GetGenericTypeDefinition() == typeof(IDictionary<,>));
FieldInfo[] storeFields = typeof(LiveEntityProjectionStore).GetFields(
BindingFlags.Instance | BindingFlags.NonPublic);
FieldInfo[] exactStores = storeFields
.Where(field => IsDictionaryWithKey(field, typeof(RuntimeEntityKey)))
.ToArray();
Assert.NotEmpty(exactStores);
Assert.All(
exactStores,
field => Assert.Equal(
typeof(LiveEntityRecord),
field.FieldType.GetGenericArguments()[1]));
Assert.DoesNotContain(storeFields, IsGuidDictionary);
}
[Fact]
public void MaterializedPresentationWorksets_AreExactKeyed()
{
AssertExactKeyFields(
typeof(LiveEntityPresentationController),
"_readyOwners",
"_suspendedShadowOwners",
"_activePlacementOwners");
AssertExactKeyFields(typeof(RemoteTeleportController), "_pending");
AssertExactKeyFields(typeof(LiveRenderProjectionJournal), "_byKey");
AssertExactKeyFields(
typeof(EntityEffectController),
"_liveProfiles",
"_readyLiveOwners");
AssertExactKeyFields(
typeof(LiveEntityLightController),
"_trackedOwners",
"_presentOwners");
AssertExactKeyFields(
typeof(EquippedChildRenderController),
"_attachedByChild",
"_pendingUnparentByChild",
"_pendingOrdinaryRemovalByRoot",
"_pendingDetachedRemovalByChild",
"_pendingReparentRemovalByChild",
"_pendingPoseLossRemovalByChild",
"_pendingOrphanRemovalByChild");
AssertExactKeyFields(typeof(LiveEntityAnimationScheduler), "_schedules");
AssertExactKeyFields(typeof(EntitySpawnAdapter), "_ownersByKey");
AssertExactKeyFields(
typeof(LiveEntityLivenessTracker),
"_deadlines",
"_present");
AssertExactKeyFields(
typeof(RemoteMovementObservationTracker),
"_lastMove");
}
[Fact]
public void AppAssembly_HasNoGuidKeyedLiveEntityRecordDictionary()
{
FieldInfo[] forbidden = typeof(LiveEntityRuntime).Assembly
.GetTypes()
.SelectMany(type => type.GetFields(
BindingFlags.Instance
| BindingFlags.Static
| BindingFlags.Public
| BindingFlags.NonPublic))
.Where(field =>
IsDictionaryWithKey(field, typeof(uint))
&& field.FieldType.GetGenericArguments()[1]
== typeof(LiveEntityRecord))
.ToArray();
Assert.Empty(forbidden);
}
[Fact]
public void ExactOwners_HaveNoRetainedGuidIndex()
{
Type[] exactOwnerTypes =
[
typeof(LiveEntityProjectionStore),
typeof(LiveEntityPresentationController),
typeof(RemoteTeleportController),
typeof(LiveRenderProjectionJournal),
typeof(LiveEntityLightController),
typeof(EquippedChildRenderController),
typeof(LiveEntityAnimationScheduler),
typeof(EntitySpawnAdapter),
typeof(LiveEntityLivenessTracker),
typeof(RemoteMovementObservationTracker),
];
FieldInfo[] forbidden = exactOwnerTypes
.SelectMany(type => type.GetFields(
BindingFlags.Instance
| BindingFlags.NonPublic))
.Where(field =>
field.Name.Contains("Guid", StringComparison.OrdinalIgnoreCase))
.ToArray();
Assert.Empty(forbidden);
}
[Fact]
@ -73,4 +168,42 @@ public sealed class RuntimeEntityOwnershipTests
|| ns?.StartsWith("Silk.NET", StringComparison.Ordinal) == true
|| ns?.StartsWith("ImGuiNET", StringComparison.Ordinal) == true;
}
private static bool IsGuidDictionary(FieldInfo field) =>
IsDictionaryWithKey(field, typeof(uint));
private static void AssertExactKeyFields(
Type owner,
params string[] fieldNames)
{
foreach (string fieldName in fieldNames)
{
FieldInfo field = owner.GetField(
fieldName,
BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException(
$"{owner.Name}.{fieldName} is missing.");
Assert.True(
IsCollectionWithKey(field, typeof(RuntimeEntityKey)),
$"{owner.Name}.{fieldName} must be keyed by RuntimeEntityKey, "
+ $"but was {field.FieldType}.");
}
}
private static bool IsCollectionWithKey(FieldInfo field, Type keyType)
{
if (!field.FieldType.IsGenericType)
return false;
Type generic = field.FieldType.GetGenericTypeDefinition();
return (generic == typeof(Dictionary<,>)
|| generic == typeof(HashSet<>))
&& field.FieldType.GetGenericArguments()[0] == keyType;
}
private static bool IsDictionaryWithKey(FieldInfo field, Type keyType) =>
field.FieldType.IsGenericType
&& field.FieldType.GetGenericTypeDefinition() == typeof(Dictionary<,>)
&& field.FieldType.GetGenericArguments()[0] == keyType;
}