refactor(runtime): own canonical entity and object lifetime

Introduce one presentation-free RuntimeEntityObjectLifetime for the exact entity directory and ClientObjectTable. Make GameWindow, graphical projections, retained UI, interaction, session routing, create/delete integration, and reset borrow that owner while preserving synchronous retail ordering, dormant retention, and retry semantics.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-26 05:54:46 +02:00
parent d9bf4c4960
commit 5ef8b5371d
40 changed files with 712 additions and 170 deletions

View file

@ -25,6 +25,7 @@ using AcDream.Core.Rendering;
using AcDream.Core.Selection;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using Silk.NET.OpenGL;
@ -44,7 +45,7 @@ internal sealed record LivePresentationDependencies(
WorldGameState WorldGameState,
WorldEvents WorldEvents,
SelectionState Selection,
ClientObjectTable Objects,
RuntimeEntityObjectLifetime EntityObjects,
LiveEntityRuntimeSlot RuntimeSlot,
DeferredLiveEntityMotionRuntimeBindings MotionBindings,
DeferredEntityEffectAdvanceSource EffectAdvance,
@ -377,7 +378,8 @@ internal sealed class LivePresentationCompositionPhase
worldState,
new CompositeLiveEntityResourceLifecycle(
[.. resourceOwners]),
componentLifecycle);
componentLifecycle,
d.EntityObjects);
var liveRuntimeLease = scope.Own(
"canonical live-entity runtime",
liveEntities,
@ -487,7 +489,7 @@ internal sealed class LivePresentationCompositionPhase
() => new EquippedChildRenderController(
content.Dats,
d.DatLock,
d.Objects,
d.EntityObjects.Objects,
liveEntities,
d.EffectPoses,
parentAcceptance.TryAccept,
@ -675,7 +677,7 @@ internal sealed class LivePresentationCompositionPhase
static value => value.Dispose());
var selectionQuery = new WorldSelectionQuery(
liveEntities,
d.Objects,
d.EntityObjects.Objects,
selectionScene,
() => d.PlayerIdentity.ServerGuid,
interaction.LateBindings.SelectionCamera.Snapshot,
@ -699,7 +701,7 @@ internal sealed class LivePresentationCompositionPhase
}
});
var radarSnapshotProvider = new RadarSnapshotProvider(
d.Objects,
d.EntityObjects.Objects,
liveEntities,
() => liveEntities.Snapshots,
playerGuid: () => d.PlayerIdentity.ServerGuid,

View file

@ -26,6 +26,7 @@ using AcDream.Core.Player;
using AcDream.Core.Social;
using AcDream.Core.Spells;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Session;
using Silk.NET.Windowing;
@ -47,7 +48,7 @@ internal sealed record SessionPlayerDependencies(
WorldGameState WorldGameState,
WorldEvents WorldEvents,
SelectionState Selection,
ClientObjectTable Objects,
RuntimeEntityObjectLifetime EntityObjects,
EntityClassificationCache ClassificationCache,
LiveEntityRuntimeSlot RuntimeSlot,
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> AnimatedEntities,
@ -475,14 +476,14 @@ internal sealed class SessionPlayerCompositionPhase
var dormantLiveEntities = new DormantLiveEntityStore();
var deletion = new LiveEntityDeletionController(
live.LiveEntities,
d.Objects,
d.EntityObjects,
teardown,
d.PlayerIdentity,
dormantLiveEntities,
d.Options.DumpLiveSpawns ? d.Log : null);
var hydration = new LiveEntityHydrationController(
live.LiveEntities,
d.Objects,
d.EntityObjects,
d.DatLock,
projectionMaterializer,
new LiveEntityRelationshipProjection(live.EquippedChildren),
@ -505,7 +506,7 @@ internal sealed class SessionPlayerCompositionPhase
var networkUpdates = new LiveEntityNetworkUpdateController(
live.LiveEntities,
d.Objects,
d.EntityObjects.Objects,
hydration,
live.EntityEffects,
live.Presentation,
@ -574,7 +575,7 @@ internal sealed class SessionPlayerCompositionPhase
new CombatAttackTargetSource(
d.Selection,
live.LiveEntities,
d.Objects,
d.EntityObjects.Objects,
d.PlayerIdentity),
d.Settings,
d.PlayerController,
@ -809,7 +810,7 @@ internal sealed class SessionPlayerCompositionPhase
d.Shortcuts,
d.DesiredComponents),
new LiveSessionDomainRuntime(
d.Objects,
d.EntityObjects,
d.LocalPlayer,
d.Spellbook,
d.Combat,
@ -873,7 +874,7 @@ internal sealed class SessionPlayerCompositionPhase
var combatCommand = new LiveCombatModeCommandController(
new LiveSessionCombatModeAuthority(sessionHost),
new LocalPlayerCombatEquipmentSource(
d.Objects,
d.EntityObjects.Objects,
d.PlayerIdentity),
d.Combat,
new ItemInteractionCombatModeIntentSink(
@ -890,7 +891,7 @@ internal sealed class SessionPlayerCompositionPhase
liveSessionCommands,
d.PlayerIdentity,
live.LiveEntities,
d.Objects,
d.EntityObjects.Objects,
d.Chat,
d.PlayerController,
worldReveal,

View file

@ -23,6 +23,7 @@ using AcDream.Core.Social;
using AcDream.Core.Spells;
using AcDream.Core.World;
using AcDream.Content;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Vitals;
@ -41,7 +42,7 @@ internal sealed record LiveSessionPlayerRuntime(
DesiredComponentSnapshotState DesiredComponents);
internal sealed record LiveSessionDomainRuntime(
ClientObjectTable Objects,
RuntimeEntityObjectLifetime EntityObjects,
LocalPlayerState LocalPlayer,
Spellbook Spellbook,
CombatState Combat,
@ -173,7 +174,7 @@ internal sealed class LiveSessionRuntimeFactory
ExternalContainer = () => _domain.ExternalContainers.Reset(),
InteractionAndSelection = _interaction.SelectionInteractions.ResetSession,
SelectionPresentation = _world.SelectionScene.Reset,
ObjectTable = _domain.Objects.Clear,
ObjectTable = _domain.EntityObjects.ClearObjects,
Spellbook = _domain.Spellbook.Clear,
MagicRuntime = () => _ui.Magic?.Reset(),
CombatAttack = _interaction.CombatAttack.ResetSession,
@ -260,7 +261,7 @@ internal sealed class LiveSessionRuntimeFactory
}
private LiveInventorySessionBindings CreateInventoryBindings() => new(
_domain.Objects,
_domain.EntityObjects.Objects,
_domain.LocalPlayer,
PlayerGuid: () => _player.Identity.ServerGuid,
OnShortcuts: list => _player.Shortcuts.Items = list,
@ -333,7 +334,7 @@ internal sealed class LiveSessionRuntimeFactory
ShowSystemMessage: text => _domain.Chat.OnSystemMessage(text, 0u),
ShowWeenieError: code => _domain.Chat.OnWeenieError(code, null),
PlayerPublicWeenieBitfield: () =>
_domain.Objects.Get(_player.Identity.ServerGuid)?
_domain.EntityObjects.Objects.Get(_player.Identity.ServerGuid)?
.PublicWeenieBitfield,
ClientVersion: () =>
typeof(LiveSessionRuntimeFactory).Assembly
@ -351,7 +352,7 @@ internal sealed class LiveSessionRuntimeFactory
SaveAutoUi: () => _ui.RetailUi?.SaveLayout(),
LoadAutoUi: () => _ui.RetailUi?.RestoreLayout(),
IsAway: () =>
_domain.Objects.Get(_player.Identity.ServerGuid)?
_domain.EntityObjects.Objects.Get(_player.Identity.ServerGuid)?
.Properties.GetBool(0x6Eu) == true,
SetAway: session.SendSetAfkMode,
SetAwayMessage: session.SendSetAfkMessage,

View file

@ -6,6 +6,7 @@ using AcDream.App.Rendering.Wb;
using AcDream.App.Settings;
using AcDream.App.World;
using AcDream.Content;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Session;
using DatReaderWriter;
using Silk.NET.Input;
@ -340,7 +341,7 @@ public sealed class GameWindow :
// installs that immutable table once DatCollection opens in OnLoad.
public AcDream.Core.Spells.SpellTable SpellTable => SpellBook.Metadata;
public readonly AcDream.Core.Spells.Spellbook SpellBook = null!;
public readonly AcDream.Core.Items.ClientObjectTable Objects = new();
private readonly RuntimeEntityObjectLifetime _runtimeEntityObjects = new();
/// <summary>Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source).</summary>
private readonly ShortcutSnapshotState _shortcutSnapshots = new();
public IReadOnlyList<AcDream.Core.Items.ShortcutEntry> Shortcuts
@ -1260,7 +1261,7 @@ public sealed class GameWindow :
_combatAttackOperations,
_selection,
_externalContainers,
Objects,
_runtimeEntityObjects.Objects,
contentEffectsAudio.MagicCatalog,
SpellBook,
Chat,
@ -1314,7 +1315,7 @@ public sealed class GameWindow :
_worldGameState,
_worldEvents,
_selection,
Objects,
_runtimeEntityObjects,
_liveEntityRuntimeSlot,
_liveEntityMotionBindings,
_entityEffectAdvance,
@ -1372,7 +1373,7 @@ public sealed class GameWindow :
_worldGameState,
_worldEvents,
_selection,
Objects,
_runtimeEntityObjects,
_classificationCache,
_liveEntityRuntimeSlot,
_animatedEntities,

View file

@ -1,7 +1,7 @@
using AcDream.App.Input;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Entities;
namespace AcDream.App.World;
@ -17,7 +17,7 @@ internal interface ILiveEntityPruneSink
internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink
{
private readonly LiveEntityRuntime _runtime;
private readonly ClientObjectTable _objects;
private readonly RuntimeEntityObjectLifetime _entityObjects;
private readonly ILiveEntityTeardownCoordinator _teardown;
private readonly ILocalPlayerIdentitySource _identity;
private readonly DormantLiveEntityStore _dormant;
@ -25,14 +25,15 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink
public LiveEntityDeletionController(
LiveEntityRuntime runtime,
ClientObjectTable objects,
RuntimeEntityObjectLifetime entityObjects,
ILiveEntityTeardownCoordinator teardown,
ILocalPlayerIdentitySource identity,
DormantLiveEntityStore? dormant = null,
Action<string>? diagnostic = null)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_entityObjects = entityObjects
?? throw new ArgumentNullException(nameof(entityObjects));
_teardown = teardown ?? throw new ArgumentNullException(nameof(teardown));
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
_dormant = dormant ?? new DormantLiveEntityStore();
@ -56,11 +57,10 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink
bool removed = _runtime.UnregisterLiveEntity(
delete,
isLocalPlayer: false,
beforeTeardown: () =>
ObjectTableWiring.ApplyEntityDelete(_objects, delete));
removeRetainedObject: true);
bool removedDormant = _dormant.RemoveExact(delete);
if (!removed && removedDormant)
ObjectTableWiring.ApplyEntityDelete(_objects, delete);
_entityObjects.ApplyAcceptedDormantDelete(delete);
if (removed)
{
_dormant.RemoveExact(delete);

View file

@ -1,6 +1,5 @@
using AcDream.App.Rendering;
using AcDream.App.Input;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.World;
@ -169,7 +168,7 @@ internal interface ILiveEntityWorldOriginCoordinator
internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoadedSink
{
private readonly LiveEntityRuntime _runtime;
private readonly ClientObjectTable _objects;
private readonly RuntimeEntityObjectLifetime _entityObjects;
private readonly object _datLock;
private readonly ILiveEntityProjectionMaterializer _materializer;
private readonly ILiveEntityRelationshipProjection _relationships;
@ -193,7 +192,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
public LiveEntityHydrationController(
LiveEntityRuntime runtime,
ClientObjectTable objects,
RuntimeEntityObjectLifetime entityObjects,
object datLock,
ILiveEntityProjectionMaterializer materializer,
ILiveEntityRelationshipProjection relationships,
@ -207,7 +206,8 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
Action<string>? diagnostic = null)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_entityObjects = entityObjects
?? throw new ArgumentNullException(nameof(entityObjects));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
_materializer = materializer ?? throw new ArgumentNullException(nameof(materializer));
_relationships = relationships ?? throw new ArgumentNullException(nameof(relationships));
@ -285,15 +285,13 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
if (_runtime.IsCurrentCreateIntegration(
canonical,
createIntegrationVersion)
&& ObjectTableWiring.ApplyEntitySpawn(
_objects,
&& _entityObjects.ApplyAcceptedSpawn(
canonical,
createIntegrationVersion,
spawn,
replaceGeneration: result.Disposition is
AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration
|| dormantDisposition is DormantCreateDisposition.NewGeneration,
accepting: () => _runtime.IsCurrentCreateIntegration(
canonical,
createIntegrationVersion))
|| dormantDisposition is DormantCreateDisposition.NewGeneration)
&& _runtime.IsCurrentCreateIntegration(
canonical,
createIntegrationVersion))

View file

@ -448,6 +448,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
private readonly GpuWorldState _spatial;
private readonly ILiveEntityResourceLifecycle _resources;
private readonly ILiveEntityRuntimeComponentLifecycle _runtimeComponentLifecycle;
private readonly RuntimeEntityObjectLifetime _entityObjects;
private readonly RuntimeEntityDirectory _directory;
private readonly LiveEntityProjectionStore _projections;
// Logical components survive retail's 25-second leave-visibility lifetime.
@ -471,12 +472,12 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
public LiveEntityRuntime(
GpuWorldState spatial,
ILiveEntityResourceLifecycle resources,
uint firstLocalEntityId = FirstLiveEntityId)
RuntimeEntityObjectLifetime entityObjects)
: this(
spatial,
resources,
NullLiveEntityRuntimeComponentLifecycle.Instance,
firstLocalEntityId)
entityObjects)
{
}
@ -484,12 +485,12 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
GpuWorldState spatial,
ILiveEntityResourceLifecycle resources,
Action<LiveEntityRecord> tearDownRuntimeComponents,
uint firstLocalEntityId = FirstLiveEntityId)
RuntimeEntityObjectLifetime entityObjects)
: this(
spatial,
resources,
new DelegateLiveEntityRuntimeComponentLifecycle(tearDownRuntimeComponents),
firstLocalEntityId)
entityObjects)
{
}
@ -497,13 +498,15 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
GpuWorldState spatial,
ILiveEntityResourceLifecycle resources,
ILiveEntityRuntimeComponentLifecycle? runtimeComponentLifecycle,
uint firstLocalEntityId = FirstLiveEntityId)
RuntimeEntityObjectLifetime entityObjects)
{
_spatial = spatial ?? throw new ArgumentNullException(nameof(spatial));
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
_runtimeComponentLifecycle = runtimeComponentLifecycle
?? throw new ArgumentNullException(nameof(runtimeComponentLifecycle));
_directory = new RuntimeEntityDirectory(firstLocalEntityId);
_entityObjects = entityObjects
?? throw new ArgumentNullException(nameof(entityObjects));
_directory = _entityObjects.Entities;
_projections = new LiveEntityProjectionStore(_directory);
_spatial.LiveProjectionVisibilityChanged += OnSpatialVisibilityChanged;
}
@ -1187,6 +1190,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
public bool UnregisterLiveEntity(
DeleteObject.Parsed delete,
bool isLocalPlayer,
bool removeRetainedObject = false,
Action? beforeTeardown = null)
{
if (_isRegisteringResources)
@ -1208,9 +1212,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
&& (record.DeleteAcceptedForTeardown
|| _isClearing
|| _sessionClearPendingFinalization);
RuntimeEntityDeleteAcceptance? acceptedDelete = null;
if (!retryingAcceptedDelete)
{
if (!_directory.TryDelete(delete, isLocalPlayer))
if (!_entityObjects.TryAcceptDelete(
delete,
isLocalPlayer,
out acceptedDelete))
return false;
AdvanceLifetimeMutation(delete.Guid);
ParentAttachments.DeleteGeneration(delete.Guid, delete.InstanceSequence);
@ -1258,6 +1266,18 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
{
if (!retryingAcceptedDelete)
{
if (removeRetainedObject)
{
try
{
_entityObjects.CompleteAcceptedDelete(acceptedDelete!);
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
}
try
{
beforeTeardown?.Invoke();

View file

@ -10,7 +10,8 @@ namespace AcDream.Core.Net;
/// 64-bit qualities (experience), SetStackSize (0x0197) = stack count,
/// InventoryRemoveObject (0x0024) = inventory-view removal.
/// CreateObject/DeleteObject application remains exposed as pure helpers so
/// the App live-entity owner can freshness-gate the shared projections first.
/// the Runtime entity/object lifetime owner can freshness-gate the canonical
/// incarnation before either graphical or retained-object projections mutate.
/// Retail: ACCObjectMaint::CreateObject / DeleteObject (the weenie_object_table side).
/// </summary>
public static class ObjectTableWiring

View file

@ -0,0 +1,135 @@
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
namespace AcDream.Runtime.Entities;
/// <summary>
/// One exact DeleteObject acceptance issued by a
/// <see cref="RuntimeEntityObjectLifetime"/>. The graphical host retires the
/// active identity before returning this token to the Runtime owner for the
/// retained-object mutation, preserving retail callback order.
/// </summary>
public sealed class RuntimeEntityDeleteAcceptance
{
internal RuntimeEntityDeleteAcceptance(
RuntimeEntityObjectLifetime owner,
DeleteObject.Parsed delete)
{
Owner = owner;
Delete = delete;
}
internal RuntimeEntityObjectLifetime Owner { get; }
internal bool Completed { get; set; }
public DeleteObject.Parsed Delete { get; }
}
/// <summary>
/// Presentation-free ownership root for one session's canonical entity and
/// retained object lifetimes. Graphical and direct hosts borrow these exact
/// instances; they never allocate a second directory or object table.
/// </summary>
public sealed class RuntimeEntityObjectLifetime
{
public RuntimeEntityObjectLifetime(
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
{
Entities = new RuntimeEntityDirectory(firstLocalEntityId);
Objects = new ClientObjectTable();
}
public RuntimeEntityDirectory Entities { get; }
public ClientObjectTable Objects { get; }
/// <summary>
/// Applies the retained-object half of an accepted CreateObject while the
/// exact canonical incarnation and its integration version remain current.
/// Object-table callbacks are synchronous and may re-enter entity lifetime,
/// so the acceptance predicate is checked before, during, and after apply.
/// </summary>
public bool ApplyAcceptedSpawn(
RuntimeEntityRecord canonical,
ulong expectedCreateIntegrationVersion,
WorldSession.EntitySpawn spawn,
bool replaceGeneration)
{
ArgumentNullException.ThrowIfNull(canonical);
if (canonical.ServerGuid != spawn.Guid
|| canonical.Incarnation != spawn.InstanceSequence)
{
return false;
}
bool IsCurrent() =>
Entities.IsCurrent(canonical)
&& canonical.CreateIntegrationVersion
== expectedCreateIntegrationVersion;
return IsCurrent()
&& ObjectTableWiring.ApplyEntitySpawn(
Objects,
spawn,
replaceGeneration,
IsCurrent)
&& IsCurrent();
}
/// <summary>
/// Accepts the exact DeleteObject generation without yet publishing the
/// retained-object removal. This split lets the graphical host retire the
/// active identity first, matching the established reentrant callback
/// order, while Runtime remains the only freshness authority.
/// </summary>
public bool TryAcceptDelete(
DeleteObject.Parsed delete,
bool isLocalPlayer,
out RuntimeEntityDeleteAcceptance acceptance)
{
if (!Entities.TryDelete(delete, isLocalPlayer))
{
acceptance = null!;
return false;
}
acceptance = new RuntimeEntityDeleteAcceptance(this, delete);
return true;
}
/// <summary>
/// Completes a previously accepted delete exactly once. Marking the token
/// complete before synchronous callbacks preserves the current retry rule:
/// a throwing observer does not replay an already-committed removal.
/// </summary>
public void CompleteAcceptedDelete(
RuntimeEntityDeleteAcceptance acceptance)
{
ArgumentNullException.ThrowIfNull(acceptance);
if (!ReferenceEquals(acceptance.Owner, this))
{
throw new InvalidOperationException(
"A delete acceptance belongs to a different Runtime lifetime.");
}
if (acceptance.Completed)
{
throw new InvalidOperationException(
"A delete acceptance has already been completed.");
}
acceptance.Completed = true;
ObjectTableWiring.ApplyEntityDelete(Objects, acceptance.Delete);
}
/// <summary>
/// Applies a delete accepted by the dormant exact-incarnation owner after
/// the active Runtime directory correctly reports no live record.
/// </summary>
public void ApplyAcceptedDormantDelete(DeleteObject.Parsed delete) =>
ObjectTableWiring.ApplyEntityDelete(Objects, delete);
/// <summary>
/// Clears retained object state at the Runtime-owned reset stage. App
/// projection teardown remains a later acknowledged stage.
/// </summary>
public void ClearObjects() => Objects.Clear();
}