refactor(runtime): publish canonical entity object deltas

Issue stable Runtime identities at canonical registration, publish entity and inventory commits through one generation-stamped synchronous stream, and make graphical adapters borrow the same direct views and events as a no-window host. Preserve exact projection teardown and retail mutation order while removing App-side event reconstruction.

Make the hard-recenter ordering fixture independent of the production two-millisecond frame budget so its injected-failure gate is deterministic.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-26 06:42:13 +02:00
parent d3e96ff912
commit ce3ac310d9
23 changed files with 2352 additions and 666 deletions

View file

@ -251,7 +251,7 @@ public sealed class LiveEntityInboundAuthorityGateTests
Assert.True(timestampAccepted);
Assert.True(runtime.TryGetCanonical(Guid, out RuntimeEntityRecord replacement));
Assert.Equal((ushort)7, replacement.Incarnation);
Assert.Null(replacement.LocalEntityId);
Assert.NotNull(replacement.LocalEntityId);
Assert.False(runtime.TryGetRecord(Guid, out _));
}

View file

@ -469,6 +469,7 @@ public sealed class GameWindowSlice8BoundaryTests
"new ResourceShutdownStage(\"frame borrowers\"",
"new ResourceShutdownStage(\"session dependents\"",
"new ResourceShutdownStage(\"live entities\"",
"new ResourceShutdownStage(\"runtime entity/object stream\"",
"new ResourceShutdownStage(\"effect dispatch edges\"",
"new ResourceShutdownStage(\"live entity dependents\"",
"new ResourceShutdownStage(\"submitted GPU work\"",

View file

@ -17,6 +17,7 @@ using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Input;
@ -174,6 +175,172 @@ public sealed class CurrentGameRuntimeAdapterTests
Assert.Equal(RuntimeLifecycleState.Stopped, harness.Runtime.Lifecycle.State);
}
[Fact]
public void DirectAndGraphicalHosts_ProduceIdenticalEntityObjectTrace()
{
WorldSession.EntitySpawn spawn =
Spawn(Harness.TargetGuid, instance: 7, cell: 0x12340001u);
var direct = new RuntimeEntityObjectLifetime();
direct.BindEventContext(static () => default, static () => 0UL);
var directTrace = new EntityObjectTrace();
using IDisposable directSubscription =
direct.Events.Subscribe(directTrace);
RuntimeEntityRegistrationResult directRegistration =
direct.RegisterEntity(spawn);
RuntimeEntityRecord directCanonical =
Assert.IsType<RuntimeEntityRecord>(directRegistration.Canonical);
direct.Objects.AddOrUpdate(Object(spawn));
direct.Objects.MoveItem(
spawn.Guid,
Harness.PlayerGuid,
newSlot: 3);
var vector = new VectorUpdate.Parsed(
spawn.Guid,
new Vector3(1f, 2f, 3f),
new Vector3(0f, 0f, 0.25f),
spawn.InstanceSequence,
VectorSequence: 2);
Assert.True(direct.TryApplyVector(
vector,
acknowledgeProjection: null,
out _));
var state = new SetState.Parsed(
spawn.Guid,
(uint)(PhysicsStateFlags.ReportCollisions
| PhysicsStateFlags.Hidden),
spawn.InstanceSequence,
StateSequence: 2);
Assert.True(direct.TryApplyState(
state,
acknowledgeProjection: null,
out _,
out _));
WorldSession.EntityPositionUpdate position =
Position(spawn.Guid, spawn.InstanceSequence);
Assert.True(direct.TryApplyPosition(
position,
isLocalPlayer: false,
forcePositionRotation: null,
currentLocalVelocity: null,
projectionRequiresTeleportHook: false,
acknowledgeProjection: null,
out _,
out _,
out _));
Assert.True(direct.TryApplyPickup(
new PickupEvent.Parsed(
spawn.Guid,
spawn.InstanceSequence,
PositionSequence: 3),
acknowledgeProjection: null,
out _));
Assert.True(direct.TryAcceptDelete(
new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence),
isLocalPlayer: false,
removeRetainedObject: true,
out RuntimeEntityDeleteAcceptance directDelete));
direct.CompleteAcceptedDelete(directDelete);
Assert.Null(direct.RetireCanonicalOnly(directCanonical));
using var graphical = new Harness();
var graphicalTrace = new EntityObjectTrace();
using IDisposable graphicalSubscription =
graphical.EntityObjects.Events.Subscribe(graphicalTrace);
_ = graphical.Entities.RegisterAndMaterializeProjection(spawn);
graphical.Objects.AddOrUpdate(Object(spawn));
graphical.Objects.MoveItem(
spawn.Guid,
Harness.PlayerGuid,
newSlot: 3);
Assert.True(graphical.Entities.TryApplyVector(vector, out _));
Assert.True(graphical.Entities.TryApplyState(state, out _, out _));
Assert.True(graphical.Entities.TryApplyPosition(
position,
isLocalPlayer: false,
forcePositionRotation: null,
currentLocalVelocity: null,
out _,
out _,
out _));
Assert.True(graphical.Entities.TryApplyPickup(
new PickupEvent.Parsed(
spawn.Guid,
spawn.InstanceSequence,
PositionSequence: 3),
out _));
Assert.True(graphical.Entities.UnregisterLiveEntity(
new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence),
isLocalPlayer: false,
removeRetainedObject: true));
Assert.Equal(directTrace.Entries, graphicalTrace.Entries);
Assert.Equal(0, direct.Entities.Count);
Assert.Equal(0, direct.Objects.ObjectCount);
Assert.Equal(0, graphical.EntityObjects.Entities.Count);
Assert.Equal(0, graphical.Objects.ObjectCount);
}
[Fact]
public void GraphicalObserverFailure_DoesNotStarveLaterObserverOrOwner()
{
using var harness = new Harness();
var throwing = new ThrowingEntityObserver();
var recording = new RuntimeTraceRecorder();
IDisposable first = harness.Runtime.Subscribe(throwing);
IDisposable second = harness.Runtime.Subscribe(recording);
LiveEntityRecord record =
harness.Entities.RegisterAndMaterializeProjection(Spawn(
Harness.TargetGuid,
instance: 4,
cell: 0x12340001u));
Assert.True(harness.EntityObjects.Entities.IsCurrent(record.Canonical));
Assert.Contains(
recording.Entries,
entry => entry.Kind == RuntimeTraceKind.Entity
&& entry.PrimaryObjectId == Harness.TargetGuid);
Assert.Equal(1, harness.EntityObjects.Events.SubscriberCount);
Assert.Equal(0, harness.EntityObjects.Events.DispatchFailureCount);
first.Dispose();
second.Dispose();
Assert.Equal(0, harness.EntityObjects.Events.SubscriberCount);
}
private static ClientObject Object(WorldSession.EntitySpawn spawn) => new()
{
ObjectId = spawn.Guid,
Name = spawn.Name ?? string.Empty,
StackSize = 4,
Value = 25,
ContainerId = Harness.PlayerGuid,
ContainerSlot = 2,
};
private static WorldSession.EntityPositionUpdate Position(
uint guid,
ushort instance) =>
new(
guid,
new CreateObject.ServerPosition(
0x12350001u,
30f,
40f,
8f,
1f,
0f,
0f,
0f),
Velocity: new Vector3(1f, 2f, 3f),
PlacementId: null,
IsGrounded: true,
InstanceSequence: instance,
PositionSequence: 2,
TeleportSequence: 0,
ForcePositionSequence: 0);
private sealed class Harness : IDisposable
{
public const uint PlayerGuid = 0x50000002u;
@ -185,10 +352,12 @@ public sealed class CurrentGameRuntimeAdapterTests
public Harness()
{
Identity = new LocalPlayerIdentityState();
Entities = LiveEntityRuntimeFixture.Create(
EntityObjects = new RuntimeEntityObjectLifetime();
Entities = new LiveEntityRuntime(
new GpuWorldState(),
new NoopEntityResources());
Objects = new ClientObjectTable();
new NoopEntityResources(),
EntityObjects);
Objects = EntityObjects.Objects;
Chat = new ChatLog();
Selection = new SelectionState();
MovementInput = new DispatcherMovementInputSource();
@ -233,7 +402,7 @@ public sealed class CurrentGameRuntimeAdapterTests
Commands,
Identity,
Entities,
Objects,
EntityObjects,
Chat,
new LocalPlayerControllerSlot(),
WorldReveal,
@ -246,6 +415,7 @@ public sealed class CurrentGameRuntimeAdapterTests
public RuntimeOptions Options { get; }
public LocalPlayerIdentityState Identity { get; }
public RuntimeEntityObjectLifetime EntityObjects { get; }
public LiveEntityRuntime Entities { get; }
public ClientObjectTable Objects { get; }
public ChatLog Chat { get; }
@ -541,6 +711,64 @@ public sealed class CurrentGameRuntimeAdapterTests
ActivationType activation) => false;
}
private sealed class EntityObjectTrace : IRuntimeEntityObjectObserver
{
public List<string> Entries { get; } = [];
public void OnEntity(in RuntimeEntityDelta delta) =>
Entries.Add(
$"E:{delta.Stamp.Sequence}:{delta.Change}:"
+ $"{delta.Entity.Identity.ServerGuid:X8}:"
+ $"{delta.Entity.Identity.LocalEntityId}:"
+ $"{delta.Entity.Identity.Incarnation}:"
+ $"{delta.Entity.CellId:X8}:"
+ $"{delta.Entity.PhysicsState:X8}:"
+ $"{delta.Entity.Position?.ObjCellId:X8}:"
+ $"{delta.Entity.Position?.Frame.Origin.X}:"
+ $"{delta.Entity.Position?.Frame.Origin.Y}:"
+ $"{delta.Entity.Position?.Frame.Origin.Z}");
public void OnInventory(in RuntimeInventoryDelta delta) =>
Entries.Add(
$"I:{delta.Stamp.Sequence}:{delta.Change}:"
+ $"{delta.Item.ObjectId:X8}:"
+ $"{delta.Item.Incarnation}:"
+ $"{delta.Item.ContainerId:X8}:"
+ $"{delta.Item.ContainerSlot}:"
+ $"{delta.Item.StackSize}:"
+ $"{delta.Item.Value}");
}
private sealed class ThrowingEntityObserver : IRuntimeEventObserver
{
public void OnLifecycle(in RuntimeLifecycleDelta delta)
{
}
public void OnCommand(in RuntimeCommandDelta delta)
{
}
public void OnEntity(in RuntimeEntityDelta delta) =>
throw new InvalidOperationException("observer");
public void OnInventory(in RuntimeInventoryDelta delta)
{
}
public void OnChat(in RuntimeChatDelta delta)
{
}
public void OnMovement(in RuntimeMovementDelta delta)
{
}
public void OnPortal(in RuntimePortalDelta delta)
{
}
}
private sealed class SelectionQuery(uint target) : IWorldSelectionQuery
{
public uint? PickAtCursor(bool includeSelf) => target;

View file

@ -1168,7 +1168,8 @@ public sealed class LandblockPresentationPipelineTests
nearRadius: 0,
farRadius: 0,
onLandblockLoaded: _ => recoveryCalls++,
ensureEnvCellMeshes: _ => replayCalls++);
ensureEnvCellMeshes: _ => replayCalls++,
workBudgetOptions: GenerousWorkBudget());
Assert.Throws<InvalidOperationException>(() => controller.Tick(0x80, 0x80));
Assert.True(state.IsNearTier(landblockId));

View file

@ -80,7 +80,7 @@ public sealed class LiveEntityHydrationControllerTests
RuntimeEntityRecord canonical = fixture.Canonical;
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
Assert.Null(canonical.LocalEntityId);
Assert.NotNull(canonical.LocalEntityId);
Assert.Equal(0, fixture.Resources.RegisterCount);
Assert.Empty(fixture.Materializer.Calls);
@ -134,7 +134,7 @@ public sealed class LiveEntityHydrationControllerTests
fixture.Controller.OnCreate(spawn);
RuntimeEntityRecord canonical = fixture.Canonical;
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
Assert.Null(canonical.LocalEntityId);
Assert.NotNull(canonical.LocalEntityId);
Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
@ -522,7 +522,7 @@ public sealed class LiveEntityHydrationControllerTests
RuntimeEntityRecord canonical = fixture.Canonical;
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
Assert.Null(canonical.LocalEntityId);
Assert.NotNull(canonical.LocalEntityId);
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
@ -852,7 +852,7 @@ public sealed class LiveEntityHydrationControllerTests
RuntimeEntityRecord replacement = fixture.Canonical;
Assert.Equal((ushort)2, replacement.Generation);
Assert.Equal("replacement", replacement.Snapshot.Name);
Assert.Null(replacement.LocalEntityId);
Assert.NotNull(replacement.LocalEntityId);
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
Assert.Equal(0, fixture.Runtime.PendingTeardownCount);
}
@ -1368,7 +1368,7 @@ public sealed class LiveEntityHydrationControllerTests
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
RuntimeEntityRecord canonical = fixture.Canonical;
Assert.Null(canonical.LocalEntityId);
Assert.NotNull(canonical.LocalEntityId);
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
Assert.Equal(0, fixture.Resources.RegisterCount);

View file

@ -312,7 +312,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
Assert.True(runtime.TryGetCanonical(guid, out var replacement));
Assert.Equal((ushort)2, replacement.Incarnation);
Assert.Null(replacement.LocalEntityId);
Assert.NotNull(replacement.LocalEntityId);
Assert.Null(replacement.PhysicsBody);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Null(retired.RemoteMotionRuntime);

View file

@ -1656,7 +1656,7 @@ public sealed class LiveEntityRuntimeTests
Assert.False(runtime.TryGetRecord(spawn.Guid, out _));
RuntimeEntityRecord canonical = CurrentCanonical(runtime, spawn.Guid);
Assert.Null(canonical.LocalEntityId);
Assert.NotNull(canonical.LocalEntityId);
Assert.Equal(0, runtime.MaterializedCount);
Assert.Empty(runtime.VisibleRecords);
Assert.Empty(spatial.Entities);
@ -1901,7 +1901,7 @@ public sealed class LiveEntityRuntimeTests
Assert.NotNull(replacement.PriorGenerationCleanupFailure);
RuntimeEntityRecord canonical = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)2, canonical.Generation);
Assert.Null(canonical.LocalEntityId);
Assert.NotNull(canonical.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
WorldEntity installed = runtime.MaterializeLiveEntity(
guid,
@ -2459,7 +2459,7 @@ public sealed class LiveEntityRuntimeTests
&& exception.Message.Contains("active logical-lifetime transition", StringComparison.Ordinal));
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)2, current.Generation);
Assert.Null(current.LocalEntityId);
Assert.NotNull(current.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Empty(spatial.Entities);
Assert.DoesNotContain(
@ -2493,7 +2493,7 @@ public sealed class LiveEntityRuntimeTests
Assert.False(outer.LogicalRegistrationCreated);
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)3, current.Generation);
Assert.Null(current.LocalEntityId);
Assert.NotNull(current.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Empty(spatial.Entities);
Assert.Equal(1, resources.RegisterCount);
@ -2524,8 +2524,8 @@ public sealed class LiveEntityRuntimeTests
Assert.True(outer.LogicalRegistrationCreated);
RuntimeEntityRecord replacement = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)2, replacement.Generation);
Assert.Null(replacement.LocalEntityId);
Assert.Null(CurrentCanonical(runtime, unrelatedGuid).LocalEntityId);
Assert.NotNull(replacement.LocalEntityId);
Assert.NotNull(CurrentCanonical(runtime, unrelatedGuid).LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.False(runtime.TryGetRecord(unrelatedGuid, out _));
Assert.Equal(2, runtime.Count);
@ -2587,7 +2587,7 @@ public sealed class LiveEntityRuntimeTests
Assert.False(outer.LogicalRegistrationCreated);
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)3, current.Generation);
Assert.Null(current.LocalEntityId);
Assert.NotNull(current.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
@ -2619,7 +2619,7 @@ public sealed class LiveEntityRuntimeTests
&& exception.Message == "old incarnation cleanup failed");
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)3, current.Generation);
Assert.Null(current.LocalEntityId);
Assert.NotNull(current.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
@ -2671,7 +2671,7 @@ public sealed class LiveEntityRuntimeTests
Assert.Contains("atomic resource registration", error.Message, StringComparison.Ordinal);
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)1, current.Generation);
Assert.Null(current.LocalEntityId);
Assert.NotNull(current.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
@ -2695,7 +2695,7 @@ public sealed class LiveEntityRuntimeTests
Assert.Contains("atomic resource registration", error.Message, StringComparison.Ordinal);
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
Assert.Null(current.LocalEntityId);
Assert.NotNull(current.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);

View file

@ -4,8 +4,11 @@ using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.Runtime;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
namespace AcDream.App.Tests.World;
@ -230,6 +233,46 @@ public sealed class RuntimeEntityOwnershipTests
property => IsPresentationType(property.PropertyType));
}
[Fact]
public void CurrentRuntimeAdapters_DoNotRetainEntityOrInventoryMirrors()
{
FieldInfo[] viewFields = typeof(CurrentGameRuntimeViewAdapter)
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic);
Assert.Contains(
viewFields,
field => field.FieldType == typeof(IRuntimeEntityView));
Assert.Contains(
viewFields,
field => field.FieldType == typeof(IRuntimeInventoryView));
Assert.DoesNotContain(
typeof(CurrentGameRuntimeViewAdapter).GetNestedTypes(
BindingFlags.NonPublic),
type => type.Name is "EntityView" or "InventoryView");
FieldInfo[] eventFields = typeof(CurrentGameRuntimeEventAdapter)
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic);
Assert.DoesNotContain(
eventFields,
field => field.FieldType == typeof(LiveEntityRuntime)
|| field.FieldType == typeof(ClientObjectTable)
|| field.FieldType == typeof(RuntimeEventSequencer));
Assert.Contains(
eventFields,
field => field.FieldType
== typeof(RuntimeEntityObjectLifetime));
string root = FindRepositoryRoot();
string liveSource = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"World",
"LiveEntityRuntime.cs"));
Assert.DoesNotContain("_entityObjects.PublishEntity", liveSource);
Assert.DoesNotContain("_directory.TryApply", liveSource);
Assert.DoesNotContain("_directory.RemoveActive", liveSource);
}
private static bool IsPresentationType(Type type)
{
string? ns = type.Namespace;