using Dalamud.Game.ClientState.Objects.Enums; using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Plugin.Services; using LightlessSync.API.Data; using LightlessSync.Interop.Ipc; using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.ActorTracking; using Microsoft.Extensions.Logging; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; namespace LightlessSync.PlayerData.Handlers; internal sealed class OwnedObjectHandler { // Debug information for owned object resolution 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; // Dependencies private readonly ILogger _logger; private readonly DalamudUtilService _dalamudUtil; private readonly GameObjectHandlerFactory _handlerFactory; private readonly IpcManager _ipc; private readonly ActorObjectService _actorObjectService; private readonly IObjectTable _objectTable; // Timeouts for fully loaded checks 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; } /// /// Applies the specified changes to the owned object of the given kind. /// /// Application ID of the Character Object /// Object Kind of the given object /// Changes of the object /// Data of the object /// Owner of the object /// Collection if needed /// Customizing identications for the object /// Cancellation Token /// Successfully applied or not public async Task ApplyAsync( Guid applicationId, ObjectKind kind, HashSet changes, CharacterData data, GameObjectHandler playerHandler, Guid penumbraCollection, Dictionary customizeIds, CancellationToken token) { // Validate player handler if (playerHandler.Address == nint.Zero) return false; // Create handler for owned object var handler = await CreateHandlerAsync(kind, playerHandler, token).ConfigureAwait(false); if (handler is null || handler.Address == nint.Zero) return false; try { token.ThrowIfCancellationRequested(); // Determine if we have file replacements for this kind bool hasFileReplacements = kind != ObjectKind.Player && data.FileReplacements.TryGetValue(kind, out var repls) && repls is { Count: > 0 }; // Determine if we should assign a Penumbra collection bool shouldAssignCollection = kind != ObjectKind.Player && hasFileReplacements && penumbraCollection != Guid.Empty && _ipc.Penumbra.APIAvailable; // Determine if only IPC-only changes are being made for player bool isPlayerIpcOnly = kind == ObjectKind.Player && changes.Count > 0 && changes.All(c => c is PlayerChanges.Honorific or PlayerChanges.Moodles or PlayerChanges.PetNames or PlayerChanges.Heels); // Wait for drawing to complete await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false); // Determine timeouts var drawTimeoutMs = handler.ObjectKind == ObjectKind.Player ? 30000 : 5000; var fullyLoadedTimeoutMs = handler.ObjectKind == ObjectKind.Player ? _fullyLoadedTimeoutMsPlayer : _fullyLoadedTimeoutMsOther; // Wait for drawing to complete await _dalamudUtil .WaitWhileCharacterIsDrawing(_logger, handler, applicationId, drawTimeoutMs, token) .ConfigureAwait(false); if (handler.Address != nint.Zero) { // Wait for fully loaded 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(); // Assign Penumbra collection if needed if (shouldAssignCollection) { // Get object index 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; } // Assign collection await _ipc.Penumbra .AssignTemporaryCollectionAsync(_logger, penumbraCollection, objIndex.Value) .ConfigureAwait(false); } var tasks = new List(); // Apply each change foreach (var change in changes.OrderBy(c => (int)c)) { token.ThrowIfCancellationRequested(); // Handle each change type 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; } } // Await all tasks for change applications if (tasks.Count > 0) await Task.WhenAll(tasks).ConfigureAwait(false); token.ThrowIfCancellationRequested(); // Determine if redraw is needed 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) ); // Skip redraw for player if only IPC-only changes were made if (isPlayerIpcOnly) needsRedraw = false; // Perform redraw if needed 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(); } } /// /// Creates a GameObjectHandler for the owned object of the specified kind. /// /// Object kind of the handler /// Owner of the given object /// Cancellation Token /// Handler for the GameObject with the handler private async Task CreateHandlerAsync(ObjectKind kind, GameObjectHandler playerHandler, CancellationToken token) { token.ThrowIfCancellationRequested(); // Debug info setter 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); } // Direct return for player if (kind == ObjectKind.Player) return playerHandler; // First, try direct retrieval via Dalamud API var playerPtr = playerHandler.Address; if (playerPtr == nint.Zero) { SetMinionDebug("player_ptr_zero", "playerHandler.Address == 0"); return null; } // Try direct retrieval 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 }; // If that fails, scan the object table for owned objects var stage = ownedPtr != nint.Zero ? "direct" : "direct_miss"; // Owner ID based scan if (ownedPtr == nint.Zero) { token.ThrowIfCancellationRequested(); // Get owner entity ID var ownerEntityId = playerHandler.EntityId; if (ownerEntityId == 0 || ownerEntityId == uint.MaxValue) { // Read unsafe ownerEntityId = await _dalamudUtil .RunOnFrameworkThread(() => ReadEntityIdSafe(playerHandler)) .ConfigureAwait(false); } if (ownerEntityId != 0 && ownerEntityId != uint.MaxValue) { // Scan for owned object ownedPtr = await _dalamudUtil .RunOnFrameworkThread(() => FindOwnedByOwnerIdSafe(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; } token.ThrowIfCancellationRequested(); // Create handler 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; } // Get object index for debug ushort? objIndex = await _dalamudUtil.RunOnFrameworkThread(() => handler.GetGameObject()?.ObjectIndex) .ConfigureAwait(false); SetMinionDebug(stage, failure: null, handler.Address, objIndex); return handler; } /// /// Entity ID reader with safety checks. /// /// Handler of the Object /// Entity Id private static uint ReadEntityIdSafe(GameObjectHandler playerHandler) => playerHandler.GetGameObject()?.EntityId ?? 0; /// /// Finds an owned object by scanning the object table for the specified owner entity ID. /// /// Object kind to find of owned object /// Owner Id /// Object Id private nint FindOwnedByOwnerIdSafe(ObjectKind kind, uint ownerEntityId) { // Validate owner ID if (ownerEntityId == 0 || ownerEntityId == uint.MaxValue) return nint.Zero; // Scan object table foreach (var obj in _objectTable) { // Validate object if (obj is null || obj.Address == nint.Zero) continue; // Check owner ID match if (obj.OwnerId != ownerEntityId) continue; // Check kind match if (!IsOwnedKindMatch(obj, kind)) continue; return obj.Address; } return nint.Zero; } /// /// Determines if the given object matches the specified owned kind. /// /// Game Object /// Object Kind /// private static bool IsOwnedKindMatch(IGameObject obj, ObjectKind kind) => kind switch { // Match minion or mount ObjectKind.MinionOrMount => obj.ObjectKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion, // Match pet ObjectKind.Pet => obj.ObjectKind == DalamudObjectKind.BattleNpc && obj is IBattleNpc bnPet && bnPet.BattleNpcKind == BattleNpcSubKind.Pet, // Match companion ObjectKind.Companion => obj.ObjectKind == DalamudObjectKind.BattleNpc && obj is IBattleNpc bnBuddy && bnBuddy.BattleNpcKind == BattleNpcSubKind.Chocobo, _ => false }; /// /// Applies Customize Plus data to the specified object. /// /// Object Address /// Data of the Customize+ that has to be applied /// Object Kind /// Customize+ Ids /// Task private async Task ApplyCustomizeAsync(nint address, string customizeData, ObjectKind kind, Dictionary customizeIds) { customizeIds[kind] = await _ipc.CustomizePlus.SetBodyScaleAsync(address, customizeData).ConfigureAwait(false); } /// /// Reverts Customize Plus changes for the specified object. /// /// Customize+ Id /// Object Id /// List of Customize+ ids /// 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); } }