using Dalamud.Plugin.Services; using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.Interop.Ipc; using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.ActorTracking; using Microsoft.Extensions.Logging; namespace LightlessSync.PlayerData.Handlers; internal sealed class OwnedObjectHandler { internal readonly record struct OwnedResolveDebug( DateTime? ResolvedAtUtc, nint Address, ushort? ObjectIndex, string Stage, string? FailureReason) { public string? AddressHex => Address == nint.Zero ? null : $"0x{Address:X}"; public static OwnedResolveDebug Empty => new(null, nint.Zero, null, string.Empty, null); } private OwnedResolveDebug _minionResolveDebug = OwnedResolveDebug.Empty; public OwnedResolveDebug MinionResolveDebug => _minionResolveDebug; private readonly ILogger _logger; private readonly DalamudUtilService _dalamudUtil; private readonly GameObjectHandlerFactory _handlerFactory; private readonly IpcManager _ipc; private readonly ActorObjectService _actorObjectService; private IObjectTable _objectTable; private const int _fullyLoadedTimeoutMsPlayer = 30000; private const int _fullyLoadedTimeoutMsOther = 5000; public OwnedObjectHandler( ILogger logger, DalamudUtilService dalamudUtil, GameObjectHandlerFactory handlerFactory, IpcManager ipc, ActorObjectService actorObjectService, IObjectTable objectTable) { _logger = logger; _dalamudUtil = dalamudUtil; _handlerFactory = handlerFactory; _ipc = ipc; _actorObjectService = actorObjectService; _objectTable = objectTable; } public async Task ApplyAsync( Guid applicationId, ObjectKind kind, HashSet changes, CharacterData data, GameObjectHandler playerHandler, Guid penumbraCollection, Dictionary customizeIds, CancellationToken token) { if (playerHandler.Address == nint.Zero) return false; var handler = await CreateHandlerAsync(kind, playerHandler, token).ConfigureAwait(false); if (handler is null || handler.Address == nint.Zero) return false; try { token.ThrowIfCancellationRequested(); bool hasFileReplacements = kind != ObjectKind.Player && data.FileReplacements.TryGetValue(kind, out var repls) && repls is { Count: > 0 }; bool shouldAssignCollection = kind != ObjectKind.Player && hasFileReplacements && penumbraCollection != Guid.Empty && _ipc.Penumbra.APIAvailable; bool isPlayerIpcOnly = kind == ObjectKind.Player && changes.Count > 0 && changes.All(c => c is PlayerChanges.Honorific or PlayerChanges.Moodles or PlayerChanges.PetNames or PlayerChanges.Heels); await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false); var drawTimeoutMs = handler.ObjectKind == ObjectKind.Player ? 30000 : 5000; var fullyLoadedTimeoutMs = handler.ObjectKind == ObjectKind.Player ? _fullyLoadedTimeoutMsPlayer : _fullyLoadedTimeoutMsOther; await _dalamudUtil .WaitWhileCharacterIsDrawing(_logger, handler, applicationId, drawTimeoutMs, token) .ConfigureAwait(false); if (handler.Address != nint.Zero) { var loaded = await _actorObjectService .WaitForFullyLoadedAsync(handler.Address, token, fullyLoadedTimeoutMs) .ConfigureAwait(false); if (!loaded) { _logger.LogTrace("[{appId}] {kind}: not fully loaded in time, skipping for now", applicationId, kind); return false; } } token.ThrowIfCancellationRequested(); if (shouldAssignCollection) { var objIndex = await _dalamudUtil .RunOnFrameworkThread(() => handler.GetGameObject()?.ObjectIndex) .ConfigureAwait(false); if (!objIndex.HasValue) { _logger.LogTrace("[{appId}] {kind}: ObjectIndex not available yet, cannot assign collection", applicationId, kind); return false; } await _ipc.Penumbra .AssignTemporaryCollectionAsync(_logger, penumbraCollection, objIndex.Value) .ConfigureAwait(false); } var tasks = new List(); foreach (var change in changes.OrderBy(c => (int)c)) { token.ThrowIfCancellationRequested(); switch (change) { case PlayerChanges.Customize: if (data.CustomizePlusData.TryGetValue(kind, out var customizeData) && !string.IsNullOrEmpty(customizeData)) tasks.Add(ApplyCustomizeAsync(handler.Address, customizeData, kind, customizeIds)); else if (customizeIds.TryGetValue(kind, out var existingId)) tasks.Add(RevertCustomizeAsync(existingId, kind, customizeIds)); break; case PlayerChanges.Glamourer: if (data.GlamourerData.TryGetValue(kind, out var glamourerData) && !string.IsNullOrEmpty(glamourerData)) tasks.Add(_ipc.Glamourer.ApplyAllAsync(_logger, handler, glamourerData, applicationId, token)); break; case PlayerChanges.Heels: if (kind == ObjectKind.Player && !string.IsNullOrEmpty(data.HeelsData)) tasks.Add(_ipc.Heels.SetOffsetForPlayerAsync(handler.Address, data.HeelsData)); break; case PlayerChanges.Honorific: if (kind == ObjectKind.Player && !string.IsNullOrEmpty(data.HonorificData)) tasks.Add(_ipc.Honorific.SetTitleAsync(handler.Address, data.HonorificData)); break; case PlayerChanges.Moodles: if (kind == ObjectKind.Player && !string.IsNullOrEmpty(data.MoodlesData)) tasks.Add(_ipc.Moodles.SetStatusAsync(handler.Address, data.MoodlesData)); break; case PlayerChanges.PetNames: if (kind == ObjectKind.Player && !string.IsNullOrEmpty(data.PetNamesData)) tasks.Add(_ipc.PetNames.SetPlayerData(handler.Address, data.PetNamesData)); break; case PlayerChanges.ModFiles: case PlayerChanges.ModManip: case PlayerChanges.ForcedRedraw: default: break; } } if (tasks.Count > 0) await Task.WhenAll(tasks).ConfigureAwait(false); token.ThrowIfCancellationRequested(); bool needsRedraw = _ipc.Penumbra.APIAvailable && ( shouldAssignCollection || changes.Contains(PlayerChanges.ForcedRedraw) || changes.Contains(PlayerChanges.ModFiles) || changes.Contains(PlayerChanges.ModManip) || changes.Contains(PlayerChanges.Glamourer) || changes.Contains(PlayerChanges.Customize) ); if (isPlayerIpcOnly) needsRedraw = false; if (needsRedraw && _ipc.Penumbra.APIAvailable) { _logger.LogWarning( "[{appId}] {kind}: Redrawing ownedTarget={isOwned} (needsRedraw={needsRedraw})", applicationId, kind, kind != ObjectKind.Player, needsRedraw); await _ipc.Penumbra .RedrawAsync(_logger, handler, applicationId, token) .ConfigureAwait(false); } return true; } finally { if (!ReferenceEquals(handler, playerHandler)) handler.Dispose(); } } private async Task CreateHandlerAsync(ObjectKind kind, GameObjectHandler playerHandler, CancellationToken token) { void SetMinionDebug(string stage, string? failure, nint addr = default, ushort? objIndex = null) { if (kind != ObjectKind.MinionOrMount) return; _minionResolveDebug = new OwnedResolveDebug( DateTime.UtcNow, addr, objIndex, stage, failure); } if (kind == ObjectKind.Player) return playerHandler; var playerPtr = playerHandler.Address; if (playerPtr == nint.Zero) { SetMinionDebug("player_ptr_zero", "playerHandler.Address == 0"); return null; } nint ownedPtr = kind switch { ObjectKind.Companion => await _dalamudUtil.GetCompanionAsync(playerPtr).ConfigureAwait(false), ObjectKind.MinionOrMount => await _dalamudUtil.GetMinionOrMountAsync(playerPtr).ConfigureAwait(false), ObjectKind.Pet => await _dalamudUtil.GetPetAsync(playerPtr).ConfigureAwait(false), _ => nint.Zero }; var stage = ownedPtr != nint.Zero ? "direct" : "direct_miss"; if (ownedPtr == nint.Zero) { var ownerEntityId = playerHandler.EntityId; if (ownerEntityId == 0 || ownerEntityId == uint.MaxValue) { ownerEntityId = await _dalamudUtil.RunOnFrameworkThread(() => ReadEntityIdUnsafe(playerPtr)) .ConfigureAwait(false); } if (ownerEntityId != 0 && ownerEntityId != uint.MaxValue) { ownedPtr = await _dalamudUtil.RunOnFrameworkThread(() => FindOwnedByOwnerIdUnsafe(kind, ownerEntityId)) .ConfigureAwait(false); stage = ownedPtr != nint.Zero ? "owner_scan" : "owner_scan_miss"; } else { stage = "owner_id_unavailable"; } } if (ownedPtr == nint.Zero) { SetMinionDebug(stage, "ownedPtr == 0"); return null; } var handler = await _handlerFactory.Create(kind, () => ownedPtr, isWatched: false).ConfigureAwait(false); if (handler is null || handler.Address == nint.Zero) { SetMinionDebug(stage, "handlerFactory returned null/zero", ownedPtr); return null; } ushort? objIndex = await _dalamudUtil.RunOnFrameworkThread(() => handler.GetGameObject()?.ObjectIndex) .ConfigureAwait(false); SetMinionDebug(stage, null, handler.Address, objIndex); return handler; } private static unsafe uint ReadEntityIdUnsafe(nint playerPtr) { if (playerPtr == nint.Zero) return 0; var ch = (FFXIVClientStructs.FFXIV.Client.Game.Character.Character*)playerPtr; return ch != null ? ch->EntityId : 0; } private unsafe nint FindOwnedByOwnerIdUnsafe(ObjectKind kind, uint ownerEntityId) { foreach (var obj in _objectTable) { if (obj is null || obj.Address == nint.Zero) continue; var addr = obj.Address; var go = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)addr; if (go == null) continue; var ok = kind switch { ObjectKind.MinionOrMount => obj.ObjectKind is Dalamud.Game.ClientState.Objects.Enums.ObjectKind.MountType or Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Companion, ObjectKind.Pet => obj.ObjectKind == Dalamud.Game.ClientState.Objects.Enums.ObjectKind.BattleNpc && go->BattleNpcSubKind == FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind.Pet, ObjectKind.Companion => obj.ObjectKind == Dalamud.Game.ClientState.Objects.Enums.ObjectKind.BattleNpc && go->BattleNpcSubKind == FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind.Buddy, _ => false }; if (!ok) continue; var resolvedOwner = ResolveOwnerIdUnsafe(go); if (resolvedOwner == ownerEntityId) return addr; } return nint.Zero; } private static unsafe uint ResolveOwnerIdUnsafe(FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject* gameObject) { if (gameObject == null) return 0; if (gameObject->OwnerId != 0) return gameObject->OwnerId; var character = (FFXIVClientStructs.FFXIV.Client.Game.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 async Task ApplyCustomizeAsync(nint address, string customizeData, ObjectKind kind, Dictionary customizeIds) { customizeIds[kind] = await _ipc.CustomizePlus.SetBodyScaleAsync(address, customizeData).ConfigureAwait(false); } private async Task RevertCustomizeAsync(Guid? customizeId, ObjectKind kind, Dictionary customizeIds) { if (!customizeId.HasValue) return; await _ipc.CustomizePlus.RevertByIdAsync(customizeId.Value).ConfigureAwait(false); customizeIds.Remove(kind); } }