Files
LightlessClient/LightlessSync/PlayerData/Handlers/OwnedObjectHandler.cs
2026-01-17 22:31:50 +01:00

461 lines
18 KiB
C#

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;
/// <summary>
/// Owned object handler for applying changes to owned objects.
/// </summary>
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;
}
/// <summary>
/// Applies the specified changes to the owned object of the given kind.
/// </summary>
/// <param name="applicationId">Application ID of the Character Object</param>
/// <param name="kind">Object Kind of the given object</param>
/// <param name="changes">Changes of the object</param>
/// <param name="data">Data of the object</param>
/// <param name="playerHandler">Owner of the object</param>
/// <param name="penumbraCollection">Collection if needed</param>
/// <param name="customizeIds">Customizing identications for the object</param>
/// <param name="token">Cancellation Token</param>
/// <returns>Successfully applied or not</returns>
public async Task<bool> ApplyAsync(
Guid applicationId,
ObjectKind kind,
HashSet<PlayerChanges> changes,
CharacterData data,
GameObjectHandler playerHandler,
Guid penumbraCollection,
Dictionary<ObjectKind, Guid?> 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<Task>();
// 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();
}
}
/// <summary>
/// Creates a GameObjectHandler for the owned object of the specified kind.
/// </summary>
/// <param name="kind">Object kind of the handler</param>
/// <param name="playerHandler">Owner of the given object</param>
/// <param name="token">Cancellation Token</param>
/// <returns>Handler for the GameObject with the handler</returns>
private async Task<GameObjectHandler?> 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;
}
/// <summary>
/// Entity ID reader with safety checks.
/// </summary>
/// <param name="playerHandler">Handler of the Object</param>
/// <returns>Entity Id</returns>
private static uint ReadEntityIdSafe(GameObjectHandler playerHandler) => playerHandler.GetGameObject()?.EntityId ?? 0;
/// <summary>
/// Finds an owned object by scanning the object table for the specified owner entity ID.
/// </summary>
/// <param name="kind">Object kind to find of owned object</param>
/// <param name="ownerEntityId">Owner Id</param>
/// <returns>Object Id</returns>
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;
}
/// <summary>
/// Determines if the given object matches the specified owned kind.
/// </summary>
/// <param name="obj">Game Object</param>
/// <param name="kind">Object Kind</param>
/// <returns></returns>
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
};
/// <summary>
/// Applies Customize Plus data to the specified object.
/// </summary>
/// <param name="address">Object Address</param>
/// <param name="customizeData">Data of the Customize+ that has to be applied</param>
/// <param name="kind">Object Kind</param>
/// <param name="customizeIds">Customize+ Ids</param>
/// <returns>Task</returns>
private async Task ApplyCustomizeAsync(nint address, string customizeData, ObjectKind kind, Dictionary<ObjectKind, Guid?> customizeIds)
{
customizeIds[kind] = await _ipc.CustomizePlus.SetBodyScaleAsync(address, customizeData).ConfigureAwait(false);
}
/// <summary>
/// Reverts Customize Plus changes for the specified object.
/// </summary>
/// <param name="customizeId">Customize+ Id</param>
/// <param name="kind">Object Id</param>
/// <param name="customizeIds">List of Customize+ ids</param>
/// <returns></returns>
private async Task RevertCustomizeAsync(Guid? customizeId, ObjectKind kind, Dictionary<ObjectKind, Guid?> customizeIds)
{
if (!customizeId.HasValue)
return;
await _ipc.CustomizePlus.RevertByIdAsync(customizeId.Value).ConfigureAwait(false);
customizeIds.Remove(kind);
}
}