using System.Collections.Concurrent; using Dalamud.Game.ClientState.Conditions; 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 BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind; using IPlayerCharacter = Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter; 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 IClientState _clientState; private readonly ICondition _condition; private readonly LightlessMediator _mediator; private readonly ConcurrentDictionary _activePlayers = new(); private readonly ConcurrentDictionary _gposePlayers = new(); private readonly ConcurrentDictionary _actorsByHash = new(StringComparer.Ordinal); private readonly ConcurrentDictionary> _actorsByName = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _pendingHashResolutions = new(); private ActorSnapshot _snapshot = ActorSnapshot.Empty; private GposeSnapshot _gposeSnapshot = GposeSnapshot.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, ICondition condition, LightlessMediator mediator) { _logger = logger; _framework = framework; _interop = interop; _objectTable = objectTable; _clientState = clientState; _condition = condition; _mediator = mediator; } private bool IsZoning => _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51]; private ActorSnapshot Snapshot => Volatile.Read(ref _snapshot); private GposeSnapshot CurrentGposeSnapshot => Volatile.Read(ref _gposeSnapshot); public IReadOnlyList PlayerAddresses => Snapshot.PlayerAddresses; public IEnumerable ObjectDescriptors => _activePlayers.Values; public IReadOnlyList PlayerDescriptors => Snapshot.PlayerDescriptors; public IReadOnlyList OwnedDescriptors => Snapshot.OwnedDescriptors; public IReadOnlyList GposeDescriptors => CurrentGposeSnapshot.GposeDescriptors; 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 bool HasPendingHashResolutions => !_pendingHashResolutions.IsEmpty; 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 ownedDescriptors = OwnedDescriptors; for (var i = 0; i < ownedDescriptors.Count; i++) { var descriptor = ownedDescriptors[i]; if (descriptor.ObjectIndex != objectIndex) continue; if (descriptor.OwnedKind is { } resolvedKind) { ownedKind = resolvedKind; 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 (!IsZoning && 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(); _gposePlayers.Clear(); _actorsByHash.Clear(); _actorsByName.Clear(); _pendingHashResolutions.Clear(); Volatile.Write(ref _snapshot, ActorSnapshot.Empty); Volatile.Write(ref _gposeSnapshot, GposeSnapshot.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.LogTrace("ActorObjectService hooks enabled."); } private Task WarmupExistingActors() { return _framework.RunOnFrameworkThread(() => { RefreshTrackedActorsInternal(); _nextRefreshAllowed = DateTime.UtcNow + SnapshotRefreshInterval; }); } private unsafe void OnCharacterInitialized(Character* chara) { ExecuteOriginal(() => _onInitializeHook!.Original(chara), "Error invoking original character initialize."); QueueTrack((GameObject*)chara); } private unsafe void OnCharacterTerminated(Character* chara) { var address = (nint)chara; QueueUntrack(address); ExecuteOriginal(() => _onTerminateHook!.Original(chara), "Error invoking original character terminate."); } private unsafe GameObject* OnCharacterDisposed(Character* chara, byte freeMemory) { var address = (nint)chara; QueueUntrack(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.LogTrace("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); } var ownerId = ResolveOwnerId(gameObject); var localPlayerAddress = _objectTable.LocalPlayer?.Address ?? nint.Zero; if (localPlayerAddress == nint.Zero) return (null, ownerId); var localEntityId = ((Character*)localPlayerAddress)->EntityId; if (localEntityId == 0) return (null, ownerId); if (objectKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion) { var expectedMinionOrMount = GetMinionOrMountAddress(localPlayerAddress, localEntityId); if (expectedMinionOrMount != nint.Zero && (nint)gameObject == expectedMinionOrMount) { var resolvedOwner = ownerId != 0 ? ownerId : localEntityId; return (LightlessObjectKind.MinionOrMount, resolvedOwner); } } if (objectKind != DalamudObjectKind.BattleNpc) return (null, ownerId); if (ownerId != localEntityId) return (null, ownerId); var expectedPet = GetPetAddress(localPlayerAddress, localEntityId); if (expectedPet != nint.Zero && (nint)gameObject == expectedPet) return (LightlessObjectKind.Pet, ownerId); var expectedCompanion = GetCompanionAddress(localPlayerAddress, localEntityId); if (expectedCompanion != nint.Zero && (nint)gameObject == expectedCompanion) return (LightlessObjectKind.Companion, ownerId); return (null, ownerId); } private unsafe nint GetMinionOrMountAddress(nint localPlayerAddress, uint ownerEntityId) { if (localPlayerAddress == nint.Zero) return nint.Zero; var playerObject = (GameObject*)localPlayerAddress; var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1); if (candidateAddress != nint.Zero) { var candidate = (GameObject*)candidateAddress; var candidateKind = (DalamudObjectKind)candidate->ObjectKind; if (candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion) { if (ownerEntityId == 0 || ResolveOwnerId(candidate) == ownerEntityId) return candidateAddress; } } if (ownerEntityId == 0) return candidateAddress; foreach (var obj in _objectTable) { if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress) continue; if (obj.ObjectKind is not (DalamudObjectKind.MountType or DalamudObjectKind.Companion)) continue; var candidate = (GameObject*)obj.Address; if (ResolveOwnerId(candidate) == ownerEntityId) return obj.Address; } return candidateAddress; } private unsafe nint GetPetAddress(nint localPlayerAddress, uint ownerEntityId) { if (localPlayerAddress == nint.Zero || ownerEntityId == 0) return nint.Zero; var manager = CharacterManager.Instance(); if (manager != null) { var candidate = (nint)manager->LookupPetByOwnerObject((BattleChara*)localPlayerAddress); if (candidate != nint.Zero) { var candidateObj = (GameObject*)candidate; if (IsPetMatch(candidateObj, ownerEntityId)) return candidate; } } foreach (var obj in _objectTable) { if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress) continue; if (obj.ObjectKind != DalamudObjectKind.BattleNpc) continue; var candidate = (GameObject*)obj.Address; if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet) continue; if (ResolveOwnerId(candidate) == ownerEntityId) return obj.Address; } return nint.Zero; } private unsafe nint GetCompanionAddress(nint localPlayerAddress, uint ownerEntityId) { if (localPlayerAddress == nint.Zero || ownerEntityId == 0) return nint.Zero; var manager = CharacterManager.Instance(); if (manager != null) { var candidate = (nint)manager->LookupBuddyByOwnerObject((BattleChara*)localPlayerAddress); if (candidate != nint.Zero) { var candidateObj = (GameObject*)candidate; if (IsCompanionMatch(candidateObj, ownerEntityId)) return candidate; } } foreach (var obj in _objectTable) { if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress) continue; if (obj.ObjectKind != DalamudObjectKind.BattleNpc) continue; var candidate = (GameObject*)obj.Address; if (candidate->BattleNpcSubKind != BattleNpcSubKind.Buddy) continue; if (ResolveOwnerId(candidate) == ownerEntityId) return obj.Address; } return nint.Zero; } private static unsafe bool IsPetMatch(GameObject* candidate, uint ownerEntityId) { if (candidate == null) return false; if ((DalamudObjectKind)candidate->ObjectKind != DalamudObjectKind.BattleNpc) return false; if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet) return false; return ResolveOwnerId(candidate) == ownerEntityId; } private static unsafe bool IsCompanionMatch(GameObject* candidate, uint ownerEntityId) { if (candidate == null) return false; if ((DalamudObjectKind)candidate->ObjectKind != DalamudObjectKind.BattleNpc) return false; if (candidate->BattleNpcSubKind != BattleNpcSubKind.Buddy) return false; return ResolveOwnerId(candidate) == ownerEntityId; } private static unsafe uint ResolveOwnerId(GameObject* gameObject) { if (gameObject == null) return 0; if (gameObject->OwnerId != 0) return gameObject->OwnerId; var character = (Character*)gameObject; if (character == null) return 0; if (character->CompanionOwnerId != 0) return character->CompanionOwnerId; var parent = character->GetParentCharacter(); return parent != null ? parent->EntityId : 0; } 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.LogTrace("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; var gameObject = (GameObject*)address; if (_activePlayers.TryGetValue(address, out var existing)) { RefreshDescriptorIfNeeded(existing, gameObject); continue; } TrackGameObject(gameObject); } var stale = _activePlayers.Keys.Where(addr => !seen.Contains(addr)).ToList(); foreach (var staleAddress in stale) { UntrackGameObject(staleAddress); } if (_hooksActive) { _nextRefreshAllowed = DateTime.UtcNow + SnapshotRefreshInterval; } if (_clientState.IsGPosing) { RefreshGposeActorsInternal(); } else if (!_gposePlayers.IsEmpty) { _gposePlayers.Clear(); PublishGposeSnapshot(); } } private unsafe void RefreshDescriptorIfNeeded(ActorDescriptor existing, GameObject* gameObject) { if (gameObject == null) return; if (existing.ObjectKind != DalamudObjectKind.Player || !string.IsNullOrEmpty(existing.HashedContentId)) return; var objectKind = (DalamudObjectKind)gameObject->ObjectKind; if (!IsSupportedObjectKind(objectKind)) return; if (BuildDescriptor(gameObject, objectKind) is not { } updated) return; if (string.IsNullOrEmpty(updated.HashedContentId)) return; ReplaceDescriptor(existing, updated); _mediator.Publish(new ActorTrackedMessage(updated)); } private void ReplaceDescriptor(ActorDescriptor existing, ActorDescriptor updated) { RemoveDescriptorFromIndexes(existing); _activePlayers[updated.Address] = updated; IndexDescriptor(updated); UpdatePendingHashResolutions(updated); PublishSnapshot(); } 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) { ExecuteOriginal(() => _onCompanionInitializeHook!.Original(companion), "Error invoking original companion initialize."); QueueTrack((GameObject*)companion); } private unsafe void OnCompanionTerminated(Companion* companion) { var address = (nint)companion; QueueUntrack(address); ExecuteOriginal(() => _onCompanionTerminateHook!.Original(companion), "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); UpdatePendingHashResolutions(descriptor); PublishSnapshot(); } private void RemoveDescriptor(ActorDescriptor descriptor) { RemoveDescriptorFromIndexes(descriptor); _pendingHashResolutions.TryRemove(descriptor.Address, out _); PublishSnapshot(); } private void UpdatePendingHashResolutions(ActorDescriptor descriptor) { if (descriptor.ObjectKind != DalamudObjectKind.Player) { _pendingHashResolutions.TryRemove(descriptor.Address, out _); return; } if (string.IsNullOrEmpty(descriptor.HashedContentId)) { _pendingHashResolutions[descriptor.Address] = 1; return; } _pendingHashResolutions.TryRemove(descriptor.Address, out _); } private void PublishSnapshot() { var descriptors = _activePlayers.Values.ToArray(); var playerCount = 0; var ownedCount = 0; var companionCount = 0; foreach (var descriptor in descriptors) { if (descriptor.ObjectKind == DalamudObjectKind.Player) playerCount++; if (descriptor.OwnedKind is not null) ownedCount++; if (descriptor.ObjectKind == DalamudObjectKind.Companion) companionCount++; } var playerDescriptors = new ActorDescriptor[playerCount]; var ownedDescriptors = new ActorDescriptor[ownedCount]; var playerAddresses = new nint[playerCount]; var renderedCompanions = new nint[companionCount]; var ownedAddresses = new nint[ownedCount]; var ownedMap = new Dictionary(ownedCount); nint localPlayer = nint.Zero; nint localPet = nint.Zero; nint localMinionOrMount = nint.Zero; nint localCompanion = nint.Zero; var playerIndex = 0; var ownedIndex = 0; var companionIndex = 0; foreach (var descriptor in descriptors) { if (descriptor.ObjectKind == DalamudObjectKind.Player) { playerDescriptors[playerIndex] = descriptor; playerAddresses[playerIndex] = descriptor.Address; playerIndex++; } if (descriptor.ObjectKind == DalamudObjectKind.Companion) { renderedCompanions[companionIndex] = descriptor.Address; companionIndex++; } if (descriptor.OwnedKind is not { } ownedKind) { continue; } ownedDescriptors[ownedIndex] = descriptor; ownedAddresses[ownedIndex] = descriptor.Address; ownedMap[descriptor.Address] = ownedKind; switch (ownedKind) { case LightlessObjectKind.Player: localPlayer = descriptor.Address; break; case LightlessObjectKind.Pet: localPet = descriptor.Address; break; case LightlessObjectKind.MinionOrMount: localMinionOrMount = descriptor.Address; break; case LightlessObjectKind.Companion: localCompanion = descriptor.Address; break; } ownedIndex++; } var ownedSnapshot = new OwnedObjectSnapshot( playerAddresses, renderedCompanions, ownedAddresses, ownedMap, localPlayer, localPet, localMinionOrMount, localCompanion); var nextGeneration = Snapshot.Generation + 1; var snapshot = new ActorSnapshot(playerDescriptors, ownedDescriptors, 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 ExecuteOriginal(Action action, string errorMessage) { try { action(); } catch (Exception ex) { _logger.LogError(ex, errorMessage); } } private unsafe void QueueTrack(GameObject* gameObject) => QueueFrameworkUpdate(() => TrackGameObject(gameObject)); private void QueueUntrack(nint address) => QueueFrameworkUpdate(() => UntrackGameObject(address)); 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.LogTrace("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 unsafe void RefreshGposeActorsInternal() { var addresses = EnumerateGposeCharacterAddresses(); HashSet seen = new(addresses.Count); foreach (var address in addresses) { if (address == nint.Zero) continue; if (!seen.Add(address)) continue; if (_gposePlayers.ContainsKey(address)) continue; TrackGposeObject((GameObject*)address); } var stale = _gposePlayers.Keys.Where(addr => !seen.Contains(addr)).ToList(); foreach (var staleAddress in stale) { UntrackGposeObject(staleAddress); } PublishGposeSnapshot(); } private unsafe void TrackGposeObject(GameObject* gameObject) { if (gameObject == null) return; var objectKind = (DalamudObjectKind)gameObject->ObjectKind; if (objectKind != DalamudObjectKind.Player) return; if (BuildDescriptor(gameObject, objectKind) is not { } descriptor) return; if (!descriptor.IsInGpose) return; _gposePlayers[descriptor.Address] = descriptor; } private void UntrackGposeObject(nint address) { if (address == nint.Zero) return; _gposePlayers.TryRemove(address, out _); } private void PublishGposeSnapshot() { var gposeDescriptors = _gposePlayers.Values.ToArray(); var gposeAddresses = new nint[gposeDescriptors.Length]; for (var i = 0; i < gposeDescriptors.Length; i++) gposeAddresses[i] = gposeDescriptors[i].Address; var nextGeneration = CurrentGposeSnapshot.Generation + 1; var snapshot = new GposeSnapshot(gposeDescriptors, gposeAddresses, nextGeneration); Volatile.Write(ref _gposeSnapshot, snapshot); } private List EnumerateGposeCharacterAddresses() { var results = new List(16); foreach (var obj in _objectTable) { if (obj.ObjectKind != DalamudObjectKind.Player) continue; if (obj.ObjectIndex < 200) continue; results.Add(obj.Address); } 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 ((ulong)gameObject->RenderFlags == 2048) return false; var characterBase = (CharacterBase*)drawObject; if (characterBase->HasModelInSlotLoaded != 0) return false; if (characterBase->HasModelFilesInSlotLoaded != 0) return false; return true; } 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 OwnedDescriptors, IReadOnlyList PlayerAddresses, OwnedObjectSnapshot OwnedObjects, int Generation) { public static ActorSnapshot Empty { get; } = new( Array.Empty(), Array.Empty(), Array.Empty(), OwnedObjectSnapshot.Empty, 0); } private sealed record GposeSnapshot( IReadOnlyList GposeDescriptors, IReadOnlyList GposeAddresses, int Generation) { public static GposeSnapshot Empty { get; } = new( Array.Empty(), Array.Empty(), 0); } }