using LightlessSync; using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using Dalamud.Game; using Dalamud.Game.ClientState; using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Hooking; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.Game.Character; using FFXIVClientStructs.FFXIV.Client.Game.Object; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using FFXIVClientStructs.Interop; using System.Threading; namespace LightlessSync.Services.ActorTracking; public sealed unsafe 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 LightlessMediator _mediator; private readonly ConcurrentDictionary _activePlayers = new(); private readonly ConcurrentDictionary _actorsByHash = new(StringComparer.Ordinal); private readonly ConcurrentDictionary> _actorsByName = new(StringComparer.Ordinal); private ActorDescriptor[] _playerCharacterSnapshot = Array.Empty(); private nint[] _playerAddressSnapshot = Array.Empty(); private readonly HashSet _renderedPlayers = new(); private readonly HashSet _renderedCompanions = new(); private readonly Dictionary _ownedObjects = new(); private nint[] _renderedPlayerSnapshot = Array.Empty(); private nint[] _renderedCompanionSnapshot = Array.Empty(); private nint[] _ownedObjectSnapshot = Array.Empty(); private IReadOnlyDictionary _ownedObjectMapSnapshot = new Dictionary(); private nint _localPlayerAddress = nint.Zero; private nint _localPetAddress = nint.Zero; private nint _localMinionMountAddress = nint.Zero; private nint _localCompanionAddress = nint.Zero; 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; _clientState = clientState; _mediator = mediator; } public IReadOnlyList PlayerAddresses => Volatile.Read(ref _playerAddressSnapshot); public IEnumerable PlayerDescriptors => _activePlayers.Values; public IReadOnlyList PlayerCharacterDescriptors => Volatile.Read(ref _playerCharacterSnapshot); public bool TryGetActorByHash(string hash, out ActorDescriptor descriptor) => _actorsByHash.TryGetValue(hash, out descriptor); 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 (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 => Volatile.Read(ref _renderedPlayerSnapshot); public IReadOnlyList RenderedCompanionAddresses => Volatile.Read(ref _renderedCompanionSnapshot); public IReadOnlyList OwnedObjectAddresses => Volatile.Read(ref _ownedObjectSnapshot); public IReadOnlyDictionary OwnedObjects => Volatile.Read(ref _ownedObjectMapSnapshot); public nint LocalPlayerAddress => Volatile.Read(ref _localPlayerAddress); public nint LocalPetAddress => Volatile.Read(ref _localPetAddress); public nint LocalMinionOrMountAddress => Volatile.Read(ref _localMinionMountAddress); public nint LocalCompanionAddress => Volatile.Read(ref _localCompanionAddress); public bool TryGetOwnedObject(LightlessObjectKind kind, out nint address) { address = kind switch { LightlessObjectKind.Player => Volatile.Read(ref _localPlayerAddress), LightlessObjectKind.Pet => Volatile.Read(ref _localPetAddress), LightlessObjectKind.MinionOrMount => Volatile.Read(ref _localMinionMountAddress), LightlessObjectKind.Companion => Volatile.Read(ref _localCompanionAddress), _ => 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 (TryGetActorByHash(hash, out var descriptor) && descriptor.Address != nint.Zero) { address = descriptor.Address; return true; } address = nint.Zero; return false; } 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(); Volatile.Write(ref _playerCharacterSnapshot, Array.Empty()); Volatile.Write(ref _playerAddressSnapshot, Array.Empty()); Volatile.Write(ref _renderedPlayerSnapshot, Array.Empty()); Volatile.Write(ref _renderedCompanionSnapshot, Array.Empty()); Volatile.Write(ref _ownedObjectSnapshot, Array.Empty()); Volatile.Write(ref _ownedObjectMapSnapshot, new Dictionary()); Volatile.Write(ref _localPlayerAddress, nint.Zero); Volatile.Write(ref _localPetAddress, nint.Zero); Volatile.Write(ref _localMinionMountAddress, nint.Zero); Volatile.Write(ref _localCompanionAddress, nint.Zero); _renderedPlayers.Clear(); _renderedCompanions.Clear(); _ownedObjects.Clear(); return Task.CompletedTask; } private 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 void OnCharacterInitialized(Character* chara) { try { _onInitializeHook!.Original(chara); } catch (Exception ex) { _logger.LogError(ex, "Error invoking original character initialize."); } QueueFrameworkUpdate(() => TrackGameObject((GameObject*)chara)); } private 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 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 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)) { RemoveDescriptorFromIndexes(existing); RemoveDescriptorFromCollections(existing); } _activePlayers[descriptor.Address] = descriptor; IndexDescriptor(descriptor); AddDescriptorToCollections(descriptor); RebuildSnapshots(); 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 ActorDescriptor? BuildDescriptor(GameObject* gameObject, DalamudObjectKind objectKind) { if (gameObject == null) return null; var address = (nint)gameObject; string name = string.Empty; ushort objectIndex = (ushort)gameObject->ObjectIndex; bool isInGpose = objectIndex >= 200; bool isLocal = _clientState.LocalPlayer?.Address == address; string hashedCid = string.Empty; if (_objectTable.CreateObjectReference(address) is IPlayerCharacter playerCharacter) { name = playerCharacter.Name.TextValue ?? string.Empty; objectIndex = playerCharacter.ObjectIndex; isInGpose = objectIndex >= 200; isLocal = playerCharacter.Address == _clientState.LocalPlayer?.Address; } else { name = gameObject->NameString ?? string.Empty; } if (objectKind == DalamudObjectKind.Player) { hashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(address); } var (ownedKind, ownerEntityId) = DetermineOwnedKind(gameObject, objectKind, isLocal); return new ActorDescriptor(name, hashedCid, address, objectIndex, isLocal, isInGpose, objectKind, ownedKind, ownerEntityId); } private (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 (_clientState.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)) { RemoveDescriptorFromIndexes(descriptor); RemoveDescriptorFromCollections(descriptor); RebuildSnapshots(); 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 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 void OnCompanionInitialized(Companion* companion) { try { _onCompanionInitializeHook!.Original(companion); } catch (Exception ex) { _logger.LogError(ex, "Error invoking original companion initialize."); } QueueFrameworkUpdate(() => TrackGameObject((GameObject*)companion)); } private 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)) { if (_actorsByName.TryGetValue(descriptor.Name, out var bucket)) { bucket.TryRemove(descriptor.Address, out _); if (bucket.IsEmpty) { _actorsByName.TryRemove(descriptor.Name, out _); } } } } private void AddDescriptorToCollections(ActorDescriptor descriptor) { if (descriptor.ObjectKind == DalamudObjectKind.Player) { _renderedPlayers.Add(descriptor.Address); if (descriptor.IsLocalPlayer) { Volatile.Write(ref _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: Volatile.Write(ref _localPlayerAddress, descriptor.Address); break; case LightlessObjectKind.Pet: Volatile.Write(ref _localPetAddress, descriptor.Address); break; case LightlessObjectKind.MinionOrMount: Volatile.Write(ref _localMinionMountAddress, descriptor.Address); break; case LightlessObjectKind.Companion: Volatile.Write(ref _localCompanionAddress, descriptor.Address); break; } } } private void RemoveDescriptorFromCollections(ActorDescriptor descriptor) { if (descriptor.ObjectKind == DalamudObjectKind.Player) { _renderedPlayers.Remove(descriptor.Address); if (descriptor.IsLocalPlayer && Volatile.Read(ref _localPlayerAddress) == descriptor.Address) { Volatile.Write(ref _localPlayerAddress, nint.Zero); } } else if (descriptor.ObjectKind == DalamudObjectKind.Companion) { _renderedCompanions.Remove(descriptor.Address); if (Volatile.Read(ref _localCompanionAddress) == descriptor.Address) { Volatile.Write(ref _localCompanionAddress, nint.Zero); } } if (descriptor.OwnedKind is { } ownedKind) { _ownedObjects.Remove(descriptor.Address); switch (ownedKind) { case LightlessObjectKind.Player when Volatile.Read(ref _localPlayerAddress) == descriptor.Address: Volatile.Write(ref _localPlayerAddress, nint.Zero); break; case LightlessObjectKind.Pet when Volatile.Read(ref _localPetAddress) == descriptor.Address: Volatile.Write(ref _localPetAddress, nint.Zero); break; case LightlessObjectKind.MinionOrMount when Volatile.Read(ref _localMinionMountAddress) == descriptor.Address: Volatile.Write(ref _localMinionMountAddress, nint.Zero); break; case LightlessObjectKind.Companion when Volatile.Read(ref _localCompanionAddress) == descriptor.Address: Volatile.Write(ref _localCompanionAddress, nint.Zero); break; } } } private void RebuildSnapshots() { var playerDescriptors = _activePlayers.Values .Where(descriptor => descriptor.ObjectKind == DalamudObjectKind.Player) .ToArray(); Volatile.Write(ref _playerCharacterSnapshot, playerDescriptors); Volatile.Write(ref _playerAddressSnapshot, playerDescriptors.Select(d => d.Address).ToArray()); Volatile.Write(ref _renderedPlayerSnapshot, _renderedPlayers.ToArray()); Volatile.Write(ref _renderedCompanionSnapshot, _renderedCompanions.ToArray()); Volatile.Write(ref _ownedObjectSnapshot, _ownedObjects.Keys.ToArray()); Volatile.Write(ref _ownedObjectMapSnapshot, new Dictionary(_ownedObjects)); } 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 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; } }