using System.Collections.Concurrent; using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Hooking; using Dalamud.Plugin.Services; using FFXIVClientStructs.Interop; using FFXIVClientStructs.FFXIV.Client.Game.Character; using FFXIVClientStructs.FFXIV.Client.Game.Object; using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind; namespace LightlessSync.Services.ActorTracking; public sealed class ActorObjectService : IHostedService, IDisposable { public readonly record struct ActorDescriptor( string Name, string HashedContentId, nint Address, ushort ObjectIndex, bool IsLocalPlayer, bool IsInGpose, DalamudObjectKind ObjectKind, LightlessObjectKind? OwnedKind, uint OwnerEntityId); private readonly ILogger _logger; private readonly IFramework _framework; private readonly IGameInteropProvider _interop; private readonly IObjectTable _objectTable; private readonly LightlessMediator _mediator; private readonly ConcurrentDictionary _activePlayers = new(); private readonly ConcurrentDictionary _actorsByHash = new(StringComparer.Ordinal); private readonly ConcurrentDictionary> _actorsByName = new(StringComparer.Ordinal); private readonly OwnedObjectTracker _ownedTracker = new(); private ActorSnapshot _snapshot = ActorSnapshot.Empty; private Hook? _onInitializeHook; private Hook? _onTerminateHook; private Hook? _onDestructorHook; private Hook? _onCompanionInitializeHook; private Hook? _onCompanionTerminateHook; private bool _hooksActive; private static readonly TimeSpan SnapshotRefreshInterval = TimeSpan.FromSeconds(1); private DateTime _nextRefreshAllowed = DateTime.MinValue; public ActorObjectService( ILogger logger, IFramework framework, IGameInteropProvider interop, IObjectTable objectTable, IClientState clientState, LightlessMediator mediator) { _logger = logger; _framework = framework; _interop = interop; _objectTable = objectTable; _mediator = mediator; } private ActorSnapshot Snapshot => Volatile.Read(ref _snapshot); public IReadOnlyList PlayerAddresses => Snapshot.PlayerAddresses; public IEnumerable PlayerDescriptors => _activePlayers.Values; public IReadOnlyList PlayerCharacterDescriptors => Snapshot.PlayerDescriptors; public bool TryGetActorByHash(string hash, out ActorDescriptor descriptor) => _actorsByHash.TryGetValue(hash, out descriptor); public bool TryGetValidatedActorByHash(string hash, out ActorDescriptor descriptor) { descriptor = default; if (!_actorsByHash.TryGetValue(hash, out var candidate)) return false; if (!ValidateDescriptorThreadSafe(candidate)) return false; descriptor = candidate; return true; } public bool TryGetPlayerByName(string name, out ActorDescriptor descriptor) { descriptor = default; if (!_actorsByName.TryGetValue(name, out var entries) || entries.IsEmpty) return false; ActorDescriptor? best = null; foreach (var candidate in entries.Values) { if (!ValidateDescriptorThreadSafe(candidate)) continue; if (best is null || IsBetterNameMatch(candidate, best.Value)) { best = candidate; } } if (best is { } selected) { descriptor = selected; return true; } return false; } public bool HooksActive => _hooksActive; public IReadOnlyList RenderedPlayerAddresses => Snapshot.OwnedObjects.RenderedPlayers; public IReadOnlyList RenderedCompanionAddresses => Snapshot.OwnedObjects.RenderedCompanions; public IReadOnlyList OwnedObjectAddresses => Snapshot.OwnedObjects.OwnedAddresses; public IReadOnlyDictionary OwnedObjects => Snapshot.OwnedObjects.Map; public nint LocalPlayerAddress => Snapshot.OwnedObjects.LocalPlayer; public nint LocalPetAddress => Snapshot.OwnedObjects.LocalPet; public nint LocalMinionOrMountAddress => Snapshot.OwnedObjects.LocalMinionOrMount; public nint LocalCompanionAddress => Snapshot.OwnedObjects.LocalCompanion; public bool TryGetOwnedKind(nint address, out LightlessObjectKind kind) => OwnedObjects.TryGetValue(address, out kind); public bool TryGetOwnedActor(LightlessObjectKind kind, out ActorDescriptor descriptor) { descriptor = default; if (!TryGetOwnedObject(kind, out var address)) return false; return TryGetDescriptor(address, out descriptor); } public bool TryGetOwnedObjectByIndex(ushort objectIndex, out LightlessObjectKind ownedKind) { ownedKind = default; var ownedSnapshot = OwnedObjects; foreach (var (address, kind) in ownedSnapshot) { if (!TryGetDescriptor(address, out var descriptor)) continue; if (descriptor.ObjectIndex == objectIndex) { ownedKind = kind; return true; } } return false; } public bool TryGetOwnedObject(LightlessObjectKind kind, out nint address) { var ownedSnapshot = Snapshot.OwnedObjects; address = kind switch { LightlessObjectKind.Player => ownedSnapshot.LocalPlayer, LightlessObjectKind.Pet => ownedSnapshot.LocalPet, LightlessObjectKind.MinionOrMount => ownedSnapshot.LocalMinionOrMount, LightlessObjectKind.Companion => ownedSnapshot.LocalCompanion, _ => nint.Zero }; return address != nint.Zero; } public bool TryGetOwnedActor(uint ownerEntityId, LightlessObjectKind? kindFilter, out ActorDescriptor descriptor) { descriptor = default; foreach (var candidate in _activePlayers.Values) { if (candidate.OwnerEntityId != ownerEntityId) continue; if (kindFilter.HasValue && candidate.OwnedKind != kindFilter) continue; descriptor = candidate; return true; } return false; } public bool TryGetPlayerAddressByHash(string hash, out nint address) { if (TryGetValidatedActorByHash(hash, out var descriptor) && descriptor.Address != nint.Zero) { address = descriptor.Address; return true; } address = nint.Zero; return false; } public async Task WaitForFullyLoadedAsync(nint address, CancellationToken cancellationToken = default) { if (address == nint.Zero) throw new ArgumentException("Address cannot be zero.", nameof(address)); while (true) { cancellationToken.ThrowIfCancellationRequested(); var isLoaded = await _framework.RunOnFrameworkThread(() => IsObjectFullyLoaded(address)).ConfigureAwait(false); if (isLoaded) return; await Task.Delay(100, cancellationToken).ConfigureAwait(false); } } private bool ValidateDescriptorThreadSafe(ActorDescriptor descriptor) { if (_framework.IsInFrameworkUpdateThread) return ValidateDescriptorInternal(descriptor); return _framework.RunOnFrameworkThread(() => ValidateDescriptorInternal(descriptor)).GetAwaiter().GetResult(); } private bool ValidateDescriptorInternal(ActorDescriptor descriptor) { if (descriptor.Address == nint.Zero) return false; if (descriptor.ObjectKind == DalamudObjectKind.Player && !string.IsNullOrEmpty(descriptor.HashedContentId)) { if (!TryGetLivePlayerHash(descriptor, out var liveHash)) { UntrackGameObject(descriptor.Address); return false; } if (!string.Equals(liveHash, descriptor.HashedContentId, StringComparison.Ordinal)) { UntrackGameObject(descriptor.Address); return false; } } return true; } private bool TryGetLivePlayerHash(ActorDescriptor descriptor, out string liveHash) { liveHash = string.Empty; if (_objectTable.CreateObjectReference(descriptor.Address) is not IPlayerCharacter playerCharacter) return false; return DalamudUtilService.TryGetHashedCID(playerCharacter, out liveHash); } public void RefreshTrackedActors(bool force = false) { var now = DateTime.UtcNow; if (!force && _hooksActive) { if (now < _nextRefreshAllowed) return; _nextRefreshAllowed = now + SnapshotRefreshInterval; } if (_framework.IsInFrameworkUpdateThread) { RefreshTrackedActorsInternal(); } else { _ = _framework.RunOnFrameworkThread(RefreshTrackedActorsInternal); } } public Task StartAsync(CancellationToken cancellationToken) { try { InitializeHooks(); var warmupTask = WarmupExistingActors(); return warmupTask; } catch (Exception ex) { _logger.LogError(ex, "Failed to initialize ActorObjectService hooks, falling back to empty cache."); DisposeHooks(); return Task.CompletedTask; } } public Task StopAsync(CancellationToken cancellationToken) { DisposeHooks(); _activePlayers.Clear(); _actorsByHash.Clear(); _actorsByName.Clear(); _ownedTracker.Reset(); Volatile.Write(ref _snapshot, ActorSnapshot.Empty); return Task.CompletedTask; } private unsafe void InitializeHooks() { if (_hooksActive) return; _onInitializeHook = _interop.HookFromAddress( (nint)Character.StaticVirtualTablePointer->OnInitialize, OnCharacterInitialized); _onTerminateHook = _interop.HookFromAddress( (nint)Character.StaticVirtualTablePointer->Terminate, OnCharacterTerminated); _onDestructorHook = _interop.HookFromAddress( (nint)Character.StaticVirtualTablePointer->Dtor, OnCharacterDisposed); _onCompanionInitializeHook = _interop.HookFromAddress( (nint)Companion.StaticVirtualTablePointer->OnInitialize, OnCompanionInitialized); _onCompanionTerminateHook = _interop.HookFromAddress( (nint)Companion.StaticVirtualTablePointer->Terminate, OnCompanionTerminated); _onInitializeHook.Enable(); _onTerminateHook.Enable(); _onDestructorHook.Enable(); _onCompanionInitializeHook.Enable(); _onCompanionTerminateHook.Enable(); _hooksActive = true; _logger.LogDebug("ActorObjectService hooks enabled."); } private Task WarmupExistingActors() { return _framework.RunOnFrameworkThread(() => { RefreshTrackedActorsInternal(); _nextRefreshAllowed = DateTime.UtcNow + SnapshotRefreshInterval; }); } private unsafe void OnCharacterInitialized(Character* chara) { try { _onInitializeHook!.Original(chara); } catch (Exception ex) { _logger.LogError(ex, "Error invoking original character initialize."); } QueueFrameworkUpdate(() => TrackGameObject((GameObject*)chara)); } private unsafe void OnCharacterTerminated(Character* chara) { var address = (nint)chara; QueueFrameworkUpdate(() => UntrackGameObject(address)); try { _onTerminateHook!.Original(chara); } catch (Exception ex) { _logger.LogError(ex, "Error invoking original character terminate."); } } private unsafe GameObject* OnCharacterDisposed(Character* chara, byte freeMemory) { var address = (nint)chara; QueueFrameworkUpdate(() => UntrackGameObject(address)); try { return _onDestructorHook!.Original(chara, freeMemory); } catch (Exception ex) { _logger.LogError(ex, "Error invoking original character destructor."); return null; } } private unsafe void TrackGameObject(GameObject* gameObject) { if (gameObject == null) return; var objectKind = (DalamudObjectKind)gameObject->ObjectKind; if (!IsSupportedObjectKind(objectKind)) return; if (BuildDescriptor(gameObject, objectKind) is not { } descriptor) return; if (descriptor.ObjectKind != DalamudObjectKind.Player && descriptor.OwnedKind is null) return; if (_activePlayers.TryGetValue(descriptor.Address, out var existing)) { RemoveDescriptor(existing); } AddDescriptor(descriptor); if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug("Actor tracked: {Name} addr={Address:X} idx={Index} owned={OwnedKind} local={Local} gpose={Gpose}", descriptor.Name, descriptor.Address, descriptor.ObjectIndex, descriptor.OwnedKind?.ToString() ?? "", descriptor.IsLocalPlayer, descriptor.IsInGpose); } _mediator.Publish(new ActorTrackedMessage(descriptor)); } private unsafe ActorDescriptor? BuildDescriptor(GameObject* gameObject, DalamudObjectKind objectKind) { if (gameObject == null) return null; var address = (nint)gameObject; string name = string.Empty; ushort objectIndex = gameObject->ObjectIndex; bool isInGpose = objectIndex >= 200; bool isLocal = _objectTable.LocalPlayer?.Address == address; string hashedCid = string.Empty; IPlayerCharacter? resolvedPlayer = null; if (_objectTable.CreateObjectReference(address) is IPlayerCharacter playerCharacter) { resolvedPlayer = playerCharacter; name = playerCharacter.Name.TextValue ?? string.Empty; objectIndex = playerCharacter.ObjectIndex; isInGpose = objectIndex >= 200; isLocal = playerCharacter.Address == _objectTable.LocalPlayer?.Address; } else { name = gameObject->NameString ?? string.Empty; } if (objectKind == DalamudObjectKind.Player) { if (resolvedPlayer == null || !DalamudUtilService.TryGetHashedCID(resolvedPlayer, out hashedCid)) { hashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(address); } } var (ownedKind, ownerEntityId) = DetermineOwnedKind(gameObject, objectKind, isLocal); return new ActorDescriptor(name, hashedCid, address, objectIndex, isLocal, isInGpose, objectKind, ownedKind, ownerEntityId); } private unsafe (LightlessObjectKind? OwnedKind, uint OwnerEntityId) DetermineOwnedKind(GameObject* gameObject, DalamudObjectKind objectKind, bool isLocalPlayer) { if (gameObject == null) return (null, 0); if (objectKind == DalamudObjectKind.Player) { var entityId = ((Character*)gameObject)->EntityId; return (isLocalPlayer ? LightlessObjectKind.Player : null, entityId); } if (isLocalPlayer) { var entityId = ((Character*)gameObject)->EntityId; return (LightlessObjectKind.Player, entityId); } if (_objectTable.LocalPlayer is not { } localPlayer) return (null, 0); var ownerId = gameObject->OwnerId; if (ownerId == 0) { var character = (Character*)gameObject; if (character != null) { ownerId = character->CompanionOwnerId; if (ownerId == 0) { var parent = character->GetParentCharacter(); if (parent != null) { ownerId = parent->EntityId; } } } } if (ownerId == 0 || ownerId != localPlayer.EntityId) return (null, ownerId); var ownedKind = objectKind switch { DalamudObjectKind.MountType => LightlessObjectKind.MinionOrMount, DalamudObjectKind.Companion => LightlessObjectKind.MinionOrMount, DalamudObjectKind.BattleNpc => gameObject->BattleNpcSubKind switch { BattleNpcSubKind.Buddy => LightlessObjectKind.Companion, BattleNpcSubKind.Pet => LightlessObjectKind.Pet, _ => (LightlessObjectKind?)null, }, _ => (LightlessObjectKind?)null, }; return (ownedKind, ownerId); } private void UntrackGameObject(nint address) { if (address == nint.Zero) return; if (_activePlayers.TryRemove(address, out var descriptor)) { RemoveDescriptor(descriptor); if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug("Actor untracked: {Name} addr={Address:X} idx={Index} owned={OwnedKind}", descriptor.Name, descriptor.Address, descriptor.ObjectIndex, descriptor.OwnedKind?.ToString() ?? ""); } _mediator.Publish(new ActorUntrackedMessage(descriptor)); } } private unsafe void RefreshTrackedActorsInternal() { var addresses = EnumerateActiveCharacterAddresses(); HashSet seen = new(addresses.Count); foreach (var address in addresses) { if (address == nint.Zero) continue; if (!seen.Add(address)) continue; if (_activePlayers.ContainsKey(address)) continue; TrackGameObject((GameObject*)address); } var stale = _activePlayers.Keys.Where(addr => !seen.Contains(addr)).ToList(); foreach (var staleAddress in stale) { UntrackGameObject(staleAddress); } if (_hooksActive) { _nextRefreshAllowed = DateTime.UtcNow + SnapshotRefreshInterval; } } private void IndexDescriptor(ActorDescriptor descriptor) { if (!string.IsNullOrEmpty(descriptor.HashedContentId)) { _actorsByHash[descriptor.HashedContentId] = descriptor; } if (descriptor.ObjectKind == DalamudObjectKind.Player && !string.IsNullOrEmpty(descriptor.Name)) { var bucket = _actorsByName.GetOrAdd(descriptor.Name, _ => new ConcurrentDictionary()); bucket[descriptor.Address] = descriptor; } } private static bool IsBetterNameMatch(ActorDescriptor candidate, ActorDescriptor current) { if (!candidate.IsInGpose && current.IsInGpose) return true; if (candidate.IsInGpose && !current.IsInGpose) return false; return candidate.ObjectIndex < current.ObjectIndex; } private bool TryGetDescriptor(nint address, out ActorDescriptor descriptor) => _activePlayers.TryGetValue(address, out descriptor); private unsafe void OnCompanionInitialized(Companion* companion) { try { _onCompanionInitializeHook!.Original(companion); } catch (Exception ex) { _logger.LogError(ex, "Error invoking original companion initialize."); } QueueFrameworkUpdate(() => TrackGameObject((GameObject*)companion)); } private unsafe void OnCompanionTerminated(Companion* companion) { var address = (nint)companion; QueueFrameworkUpdate(() => UntrackGameObject(address)); try { _onCompanionTerminateHook!.Original(companion); } catch (Exception ex) { _logger.LogError(ex, "Error invoking original companion terminate."); } } private void RemoveDescriptorFromIndexes(ActorDescriptor descriptor) { if (!string.IsNullOrEmpty(descriptor.HashedContentId)) { _actorsByHash.TryRemove(descriptor.HashedContentId, out _); } if (descriptor.ObjectKind == DalamudObjectKind.Player && !string.IsNullOrEmpty(descriptor.Name) && _actorsByName.TryGetValue(descriptor.Name, out var bucket)) { bucket.TryRemove(descriptor.Address, out _); if (bucket.IsEmpty) { _actorsByName.TryRemove(descriptor.Name, out _); } } } private void AddDescriptor(ActorDescriptor descriptor) { _activePlayers[descriptor.Address] = descriptor; IndexDescriptor(descriptor); _ownedTracker.OnDescriptorAdded(descriptor); PublishSnapshot(); } private void RemoveDescriptor(ActorDescriptor descriptor) { RemoveDescriptorFromIndexes(descriptor); _ownedTracker.OnDescriptorRemoved(descriptor); PublishSnapshot(); } private void PublishSnapshot() { var playerDescriptors = _activePlayers.Values .Where(descriptor => descriptor.ObjectKind == DalamudObjectKind.Player) .ToArray(); var playerAddresses = new nint[playerDescriptors.Length]; for (var i = 0; i < playerDescriptors.Length; i++) playerAddresses[i] = playerDescriptors[i].Address; var ownedSnapshot = _ownedTracker.CreateSnapshot(); var nextGeneration = Snapshot.Generation + 1; var snapshot = new ActorSnapshot(playerDescriptors, playerAddresses, ownedSnapshot, nextGeneration); Volatile.Write(ref _snapshot, snapshot); } private void QueueFrameworkUpdate(Action action) { if (action == null) return; if (_framework.IsInFrameworkUpdateThread) { action(); return; } _ = _framework.RunOnFrameworkThread(action); } private void DisposeHooks() { var hadHooks = _hooksActive || _onInitializeHook is not null || _onTerminateHook is not null || _onDestructorHook is not null || _onCompanionInitializeHook is not null || _onCompanionTerminateHook is not null; _onInitializeHook?.Disable(); _onTerminateHook?.Disable(); _onDestructorHook?.Disable(); _onCompanionInitializeHook?.Disable(); _onCompanionTerminateHook?.Disable(); _onInitializeHook?.Dispose(); _onTerminateHook?.Dispose(); _onDestructorHook?.Dispose(); _onCompanionInitializeHook?.Dispose(); _onCompanionTerminateHook?.Dispose(); _onInitializeHook = null; _onTerminateHook = null; _onDestructorHook = null; _onCompanionInitializeHook = null; _onCompanionTerminateHook = null; _hooksActive = false; if (hadHooks) { _logger.LogDebug("ActorObjectService hooks disabled."); } } public void Dispose() { DisposeHooks(); GC.SuppressFinalize(this); } private static bool IsSupportedObjectKind(DalamudObjectKind objectKind) => objectKind is DalamudObjectKind.Player or DalamudObjectKind.BattleNpc or DalamudObjectKind.Companion or DalamudObjectKind.MountType; private static unsafe List EnumerateActiveCharacterAddresses() { var results = new List(64); var manager = GameObjectManager.Instance(); if (manager == null) return results; const int objectLimit = 200; unsafe { for (var i = 0; i < objectLimit; i++) { Pointer objPtr = manager->Objects.IndexSorted[i]; var obj = objPtr.Value; if (obj == null) continue; var objectKind = (DalamudObjectKind)obj->ObjectKind; if (!IsSupportedObjectKind(objectKind)) continue; results.Add((nint)obj); } } return results; } private static unsafe bool IsObjectFullyLoaded(nint address) { if (address == nint.Zero) return false; var gameObject = (GameObject*)address; if (gameObject == null) return false; var drawObject = gameObject->DrawObject; if (drawObject == null) return false; if (gameObject->RenderFlags == 2048) return false; var characterBase = (CharacterBase*)drawObject; if (characterBase == null) return false; if (characterBase->HasModelInSlotLoaded != 0) return false; if (characterBase->HasModelFilesInSlotLoaded != 0) return false; return true; } private sealed class OwnedObjectTracker { private readonly HashSet _renderedPlayers = new(); private readonly HashSet _renderedCompanions = new(); private readonly Dictionary _ownedObjects = new(); private nint _localPlayerAddress = nint.Zero; private nint _localPetAddress = nint.Zero; private nint _localMinionMountAddress = nint.Zero; private nint _localCompanionAddress = nint.Zero; public void OnDescriptorAdded(ActorDescriptor descriptor) { if (descriptor.ObjectKind == DalamudObjectKind.Player) { _renderedPlayers.Add(descriptor.Address); if (descriptor.IsLocalPlayer) _localPlayerAddress = descriptor.Address; } else if (descriptor.ObjectKind == DalamudObjectKind.Companion) { _renderedCompanions.Add(descriptor.Address); } if (descriptor.OwnedKind is { } ownedKind) { _ownedObjects[descriptor.Address] = ownedKind; switch (ownedKind) { case LightlessObjectKind.Player: _localPlayerAddress = descriptor.Address; break; case LightlessObjectKind.Pet: _localPetAddress = descriptor.Address; break; case LightlessObjectKind.MinionOrMount: _localMinionMountAddress = descriptor.Address; break; case LightlessObjectKind.Companion: _localCompanionAddress = descriptor.Address; break; } } } public void OnDescriptorRemoved(ActorDescriptor descriptor) { if (descriptor.ObjectKind == DalamudObjectKind.Player) { _renderedPlayers.Remove(descriptor.Address); if (descriptor.IsLocalPlayer && _localPlayerAddress == descriptor.Address) _localPlayerAddress = nint.Zero; } else if (descriptor.ObjectKind == DalamudObjectKind.Companion) { _renderedCompanions.Remove(descriptor.Address); if (_localCompanionAddress == descriptor.Address) _localCompanionAddress = nint.Zero; } if (descriptor.OwnedKind is { } ownedKind) { _ownedObjects.Remove(descriptor.Address); switch (ownedKind) { case LightlessObjectKind.Player when _localPlayerAddress == descriptor.Address: _localPlayerAddress = nint.Zero; break; case LightlessObjectKind.Pet when _localPetAddress == descriptor.Address: _localPetAddress = nint.Zero; break; case LightlessObjectKind.MinionOrMount when _localMinionMountAddress == descriptor.Address: _localMinionMountAddress = nint.Zero; break; case LightlessObjectKind.Companion when _localCompanionAddress == descriptor.Address: _localCompanionAddress = nint.Zero; break; } } } public OwnedObjectSnapshot CreateSnapshot() => new( _renderedPlayers.ToArray(), _renderedCompanions.ToArray(), _ownedObjects.Keys.ToArray(), new Dictionary(_ownedObjects), _localPlayerAddress, _localPetAddress, _localMinionMountAddress, _localCompanionAddress); public void Reset() { _renderedPlayers.Clear(); _renderedCompanions.Clear(); _ownedObjects.Clear(); _localPlayerAddress = nint.Zero; _localPetAddress = nint.Zero; _localMinionMountAddress = nint.Zero; _localCompanionAddress = nint.Zero; } } private sealed record OwnedObjectSnapshot( IReadOnlyList RenderedPlayers, IReadOnlyList RenderedCompanions, IReadOnlyList OwnedAddresses, IReadOnlyDictionary Map, nint LocalPlayer, nint LocalPet, nint LocalMinionOrMount, nint LocalCompanion) { public static OwnedObjectSnapshot Empty { get; } = new( Array.Empty(), Array.Empty(), Array.Empty(), new Dictionary(), nint.Zero, nint.Zero, nint.Zero, nint.Zero); } private sealed record ActorSnapshot( IReadOnlyList PlayerDescriptors, IReadOnlyList PlayerAddresses, OwnedObjectSnapshot OwnedObjects, int Generation) { public static ActorSnapshot Empty { get; } = new( Array.Empty(), Array.Empty(), OwnedObjectSnapshot.Empty, 0); } }