From d8b4122ec393c61190348c66125cb3091780e638 Mon Sep 17 00:00:00 2001 From: cake Date: Fri, 16 Jan 2026 15:54:38 +0100 Subject: [PATCH] Removed unsafe handling of game object and owned object --- .../Factories/GameObjectHandlerFactory.cs | 9 +- .../PlayerData/Handlers/GameObjectHandler.cs | 242 +++++++----------- .../PlayerData/Handlers/OwnedObjectHandler.cs | 184 +++++++++---- .../PlayerData/Pairs/PairHandlerAdapter.cs | 58 +++++ 4 files changed, 291 insertions(+), 202 deletions(-) diff --git a/LightlessSync/PlayerData/Factories/GameObjectHandlerFactory.cs b/LightlessSync/PlayerData/Factories/GameObjectHandlerFactory.cs index 4741b55..267a2e8 100644 --- a/LightlessSync/PlayerData/Factories/GameObjectHandlerFactory.cs +++ b/LightlessSync/PlayerData/Factories/GameObjectHandlerFactory.cs @@ -1,4 +1,5 @@ -using LightlessSync.API.Data.Enum; +using Dalamud.Plugin.Services; +using LightlessSync.API.Data.Enum; using LightlessSync.PlayerData.Handlers; using LightlessSync.Services; using LightlessSync.Services.Mediator; @@ -11,6 +12,7 @@ public class GameObjectHandlerFactory { private readonly IServiceProvider _serviceProvider; private readonly ILoggerFactory _loggerFactory; + private readonly IObjectTable _objectTable; private readonly LightlessMediator _lightlessMediator; private readonly PerformanceCollectorService _performanceCollectorService; @@ -18,12 +20,14 @@ public class GameObjectHandlerFactory ILoggerFactory loggerFactory, PerformanceCollectorService performanceCollectorService, LightlessMediator lightlessMediator, - IServiceProvider serviceProvider) + IServiceProvider serviceProvider, + IObjectTable objectTable) { _loggerFactory = loggerFactory; _performanceCollectorService = performanceCollectorService; _lightlessMediator = lightlessMediator; _serviceProvider = serviceProvider; + _objectTable = objectTable; } public async Task Create(ObjectKind objectKind, Func getAddressFunc, bool isWatched = false) @@ -36,6 +40,7 @@ public class GameObjectHandlerFactory dalamudUtilService, objectKind, getAddressFunc, + _objectTable, isWatched)).ConfigureAwait(false); } } \ No newline at end of file diff --git a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs index 28b67b6..2f085ec 100644 --- a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs +++ b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs @@ -1,18 +1,17 @@ -using FFXIVClientStructs.FFXIV.Client.Game.Character; -using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using Dalamud.Game.ClientState.Objects.Types; using LightlessSync.Services; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; -using System.Runtime.CompilerServices; -using static FFXIVClientStructs.FFXIV.Client.Game.Character.DrawDataContainer; -using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags; using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Plugin.Services; namespace LightlessSync.PlayerData.Handlers; public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighPriorityMediatorSubscriber { private readonly DalamudUtilService _dalamudUtil; + private readonly IObjectTable _objectTable; private readonly Func _getAddress; private readonly bool _isOwnedObject; private readonly PerformanceCollectorService _performanceCollector; @@ -24,7 +23,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP private CancellationTokenSource _zoningCts = new(); public GameObjectHandler(ILogger logger, PerformanceCollectorService performanceCollector, - LightlessMediator mediator, DalamudUtilService dalamudUtil, ObjectKind objectKind, Func getAddress, bool ownedObject = true) : base(logger, mediator) + LightlessMediator mediator, DalamudUtilService dalamudUtil, ObjectKind objectKind, Func getAddress, IObjectTable objectTable, bool ownedObject = true) : base(logger, mediator) { _performanceCollector = performanceCollector; ObjectKind = objectKind; @@ -82,6 +81,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP }); Mediator.Publish(new GameObjectHandlerCreatedMessage(this, _isOwnedObject)); + _objectTable = objectTable; _dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult(); } @@ -110,14 +110,14 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP private ushort[] MainHandData { get; set; } = new ushort[3]; private ushort[] OffHandData { get; set; } = new ushort[3]; - public async Task ActOnFrameworkAfterEnsureNoDrawAsync(Action act, CancellationToken token) + public async Task ActOnFrameworkAfterEnsureNoDrawAsync(Action act, CancellationToken token) { while (await _dalamudUtil.RunOnFrameworkThread(() => { EnsureLatestObjectState(); if (CurrentDrawCondition != DrawCondition.None) return true; var gameObj = _dalamudUtil.CreateGameObject(Address); - if (gameObj is Dalamud.Game.ClientState.Objects.Types.ICharacter chara) + if (gameObj is ICharacter chara) { act.Invoke(chara); } @@ -140,7 +140,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP } } - public Dalamud.Game.ClientState.Objects.Types.IGameObject? GetGameObject() + public IGameObject? GetGameObject() { return _dalamudUtil.CreateGameObject(Address); } @@ -169,9 +169,22 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP return $"{owned}/{ObjectKind}:{Name} ({Address:X},{DrawObjectAddress:X})"; } + private IGameObject? TryGetObjectByAddress(nint address) + { + if (address == nint.Zero) return null; + + foreach (var obj in _objectTable) + { + if (obj is null) continue; + if (obj.Address == address) + return obj; + } + return null; + } + private void CheckAndUpdateObject() => CheckAndUpdateObject(allowPublish: true); - private unsafe void CheckAndUpdateObject(bool allowPublish) + private void CheckAndUpdateObject(bool allowPublish) { var prevAddr = Address; var prevDrawObj = DrawObjectAddress; @@ -179,127 +192,118 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP Address = _getAddress(); - if (Address != IntPtr.Zero) - { - var gameObject = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address; - DrawObjectAddress = (IntPtr)gameObject->DrawObject; - EntityId = gameObject->EntityId; + IGameObject? obj = null; + ICharacter? chara = null; - var chara = (Character*)Address; - nameString = chara->GameObject.NameString; - if (!string.IsNullOrEmpty(nameString) && !string.Equals(nameString, Name, StringComparison.Ordinal)) - Name = nameString; + if (Address != nint.Zero) + { + obj = TryGetObjectByAddress(Address); + + if (obj is not null) + { + EntityId = obj.EntityId; + + DrawObjectAddress = Address; + + nameString = obj.Name.TextValue ?? string.Empty; + if (!string.IsNullOrEmpty(nameString) && !string.Equals(nameString, Name, StringComparison.Ordinal)) + Name = nameString; + + chara = obj as ICharacter; + } + else + { + DrawObjectAddress = nint.Zero; + EntityId = uint.MaxValue; + } } else { - DrawObjectAddress = IntPtr.Zero; + DrawObjectAddress = nint.Zero; EntityId = uint.MaxValue; } - CurrentDrawCondition = IsBeingDrawnUnsafe(); + CurrentDrawCondition = IsBeingDrawnSafe(obj, chara); if (_haltProcessing || !allowPublish) return; bool drawObjDiff = DrawObjectAddress != prevDrawObj; bool addrDiff = Address != prevAddr; - if (Address != IntPtr.Zero && DrawObjectAddress != IntPtr.Zero) + bool nameChange = false; + if (nameString is not null) { - var chara = (Character*)Address; - var drawObj = (DrawObject*)DrawObjectAddress; - var objType = drawObj->Object.GetObjectType(); - var isHuman = objType == ObjectType.CharacterBase - && ((CharacterBase*)drawObj)->GetModelType() == CharacterBase.ModelType.Human; - - nameString ??= ((Character*)Address)->GameObject.NameString; - var nameChange = !string.Equals(nameString, Name, StringComparison.Ordinal); + nameChange = !string.Equals(nameString, Name, StringComparison.Ordinal); if (nameChange) Name = nameString; + } - bool equipDiff = false; + bool customizeDiff = false; - if (isHuman) + if (chara is not null) + { + var classJob = chara.ClassJob.RowId; + if (classJob != _classJob) { - var classJob = chara->CharacterData.ClassJob; - if (classJob != _classJob) - { - Logger.LogTrace("[{this}] classjob changed from {old} to {new}", this, _classJob, classJob); - _classJob = classJob; - Mediator.Publish(new ClassJobChangedMessage(this)); - } - - equipDiff = CompareAndUpdateEquipByteData((byte*)&((Human*)drawObj)->Head); - - ref var mh = ref chara->DrawData.Weapon(WeaponSlot.MainHand); - ref var oh = ref chara->DrawData.Weapon(WeaponSlot.OffHand); - equipDiff |= CompareAndUpdateMainHand((Weapon*)mh.DrawObject); - equipDiff |= CompareAndUpdateOffHand((Weapon*)oh.DrawObject); - - if (equipDiff) - Logger.LogTrace("Checking [{this}] equip data as human from draw obj, result: {diff}", this, equipDiff); - } - else - { - equipDiff = CompareAndUpdateEquipByteData((byte*)Unsafe.AsPointer(ref chara->DrawData.EquipmentModelIds[0])); - if (equipDiff) - Logger.LogTrace("Checking [{this}] equip data from game obj, result: {diff}", this, equipDiff); + Logger.LogTrace("[{this}] classjob changed from {old} to {new}", this, _classJob, classJob); + _classJob = (byte)classJob; + Mediator.Publish(new ClassJobChangedMessage(this)); } - if (equipDiff && !_isOwnedObject) // send the message out immediately and cancel out, no reason to continue if not self + customizeDiff = CompareAndUpdateCustomizeData(chara.Customize); + + if (_isOwnedObject && ObjectKind == ObjectKind.Player && chara.Customize.Length > (int)CustomizeIndex.Tribe) { - Logger.LogTrace("[{this}] Changed", this); - return; - } + var gender = chara.Customize[(int)CustomizeIndex.Gender]; + var raceId = chara.Customize[(int)CustomizeIndex.Race]; + var tribeId = chara.Customize[(int)CustomizeIndex.Tribe]; - bool customizeDiff = false; - - if (isHuman) - { - var gender = ((Human*)drawObj)->Customize.Sex; - var raceId = ((Human*)drawObj)->Customize.Race; - var tribeId = ((Human*)drawObj)->Customize.Tribe; - - if (_isOwnedObject && ObjectKind == ObjectKind.Player - && (gender != Gender || raceId != RaceId || tribeId != TribeId)) + if (gender != Gender || raceId != RaceId || tribeId != TribeId) { Mediator.Publish(new CensusUpdateMessage(gender, raceId, tribeId)); Gender = gender; RaceId = raceId; TribeId = tribeId; } + } + } - customizeDiff = CompareAndUpdateCustomizeData(((Human*)drawObj)->Customize.Data); - if (customizeDiff) - Logger.LogTrace("Checking [{this}] customize data as human from draw obj, result: {diff}", this, customizeDiff); - } - else - { - customizeDiff = CompareAndUpdateCustomizeData(chara->DrawData.CustomizeData.Data); - if (customizeDiff) - Logger.LogTrace("Checking [{this}] customize data from game obj, result: {diff}", this, equipDiff); - } - if ((addrDiff || drawObjDiff || equipDiff || customizeDiff || nameChange) && _isOwnedObject) - { - Logger.LogDebug("[{this}] Changed, Sending CreateCacheObjectMessage", this); - Mediator.Publish(new CreateCacheForObjectMessage(this)); - } + if ((addrDiff || drawObjDiff || customizeDiff || nameChange) && _isOwnedObject) + { + Logger.LogDebug("[{this}] Changed, Sending CreateCacheObjectMessage", this); + Mediator.Publish(new CreateCacheForObjectMessage(this)); } else if (addrDiff || drawObjDiff) { - CurrentDrawCondition = DrawCondition.DrawObjectZero; + if (Address == nint.Zero) + CurrentDrawCondition = DrawCondition.ObjectZero; + else if (DrawObjectAddress == nint.Zero) + CurrentDrawCondition = DrawCondition.DrawObjectZero; + Logger.LogTrace("[{this}] Changed", this); + if (_isOwnedObject && ObjectKind != ObjectKind.Player) - { Mediator.Publish(new ClearCacheForObjectMessage(this)); - } } } - private unsafe bool CompareAndUpdateCustomizeData(Span customizeData) + private DrawCondition IsBeingDrawnSafe(IGameObject? obj, ICharacter? chara) + { + if (Address == nint.Zero) return DrawCondition.ObjectZero; + if (obj is null) return DrawCondition.DrawObjectZero; + + if (chara is not null && (chara.Customize is null || chara.Customize.Length == 0)) + return DrawCondition.DrawObjectZero; + + return DrawCondition.None; + } + + private bool CompareAndUpdateCustomizeData(ReadOnlySpan customizeData) { bool hasChanges = false; - for (int i = 0; i < customizeData.Length; i++) + var len = Math.Min(customizeData.Length, CustomizeData.Length); + for (int i = 0; i < len; i++) { var data = customizeData[i]; if (CustomizeData[i] != data) @@ -312,48 +316,6 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP return hasChanges; } - private unsafe bool CompareAndUpdateEquipByteData(byte* equipSlotData) - { - bool hasChanges = false; - for (int i = 0; i < EquipSlotData.Length; i++) - { - var data = equipSlotData[i]; - if (EquipSlotData[i] != data) - { - EquipSlotData[i] = data; - hasChanges = true; - } - } - - return hasChanges; - } - - private unsafe bool CompareAndUpdateMainHand(Weapon* weapon) - { - if ((nint)weapon == nint.Zero) return false; - bool hasChanges = false; - hasChanges |= weapon->ModelSetId != MainHandData[0]; - MainHandData[0] = weapon->ModelSetId; - hasChanges |= weapon->Variant != MainHandData[1]; - MainHandData[1] = weapon->Variant; - hasChanges |= weapon->SecondaryId != MainHandData[2]; - MainHandData[2] = weapon->SecondaryId; - return hasChanges; - } - - private unsafe bool CompareAndUpdateOffHand(Weapon* weapon) - { - if ((nint)weapon == nint.Zero) return false; - bool hasChanges = false; - hasChanges |= weapon->ModelSetId != OffHandData[0]; - OffHandData[0] = weapon->ModelSetId; - hasChanges |= weapon->Variant != OffHandData[1]; - OffHandData[1] = weapon->Variant; - hasChanges |= weapon->SecondaryId != OffHandData[2]; - OffHandData[2] = weapon->SecondaryId; - return hasChanges; - } - private void FrameworkUpdate() { try @@ -403,24 +365,6 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP } } - private unsafe DrawCondition IsBeingDrawnUnsafe() - { - if (Address == IntPtr.Zero) return DrawCondition.ObjectZero; - if (DrawObjectAddress == IntPtr.Zero) return DrawCondition.DrawObjectZero; - var visibilityFlags = ((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address)->RenderFlags; - if (visibilityFlags != VisibilityFlags.None) return DrawCondition.RenderFlags; - - if (ObjectKind == ObjectKind.Player) - { - var modelInSlotLoaded = (((CharacterBase*)DrawObjectAddress)->HasModelInSlotLoaded != 0); - if (modelInSlotLoaded) return DrawCondition.ModelInSlotLoaded; - var modelFilesInSlotLoaded = (((CharacterBase*)DrawObjectAddress)->HasModelFilesInSlotLoaded != 0); - if (modelFilesInSlotLoaded) return DrawCondition.ModelFilesInSlotLoaded; - } - - return DrawCondition.None; - } - private void ZoneSwitchEnd() { if (!_isOwnedObject) return; diff --git a/LightlessSync/PlayerData/Handlers/OwnedObjectHandler.cs b/LightlessSync/PlayerData/Handlers/OwnedObjectHandler.cs index 912b778..1f1572d 100644 --- a/LightlessSync/PlayerData/Handlers/OwnedObjectHandler.cs +++ b/LightlessSync/PlayerData/Handlers/OwnedObjectHandler.cs @@ -1,4 +1,5 @@ -using Dalamud.Plugin.Services; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Plugin.Services; using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.Interop.Ipc; @@ -7,11 +8,15 @@ using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.ActorTracking; using Microsoft.Extensions.Logging; +using Dalamud.Game.ClientState.Objects.Enums; +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, @@ -26,12 +31,15 @@ internal sealed class OwnedObjectHandler 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 IObjectTable _objectTable; + private readonly IObjectTable _objectTable; + + // Timeouts for fully loaded checks private const int _fullyLoadedTimeoutMsPlayer = 30000; private const int _fullyLoadedTimeoutMsOther = 5000; @@ -51,6 +59,18 @@ internal sealed class OwnedObjectHandler _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, @@ -61,9 +81,11 @@ internal sealed class OwnedObjectHandler 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; @@ -72,17 +94,20 @@ internal sealed class OwnedObjectHandler { 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 @@ -91,17 +116,21 @@ internal sealed class OwnedObjectHandler 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); @@ -115,8 +144,10 @@ internal sealed class OwnedObjectHandler token.ThrowIfCancellationRequested(); + // Assign Penumbra collection if needed if (shouldAssignCollection) { + // Get object index var objIndex = await _dalamudUtil .RunOnFrameworkThread(() => handler.GetGameObject()?.ObjectIndex) .ConfigureAwait(false); @@ -127,6 +158,7 @@ internal sealed class OwnedObjectHandler return false; } + // Assign collection await _ipc.Penumbra .AssignTemporaryCollectionAsync(_logger, penumbraCollection, objIndex.Value) .ConfigureAwait(false); @@ -134,10 +166,12 @@ internal sealed class OwnedObjectHandler 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: @@ -146,7 +180,7 @@ internal sealed class OwnedObjectHandler 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)); @@ -180,11 +214,13 @@ internal sealed class OwnedObjectHandler } } + // 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 && ( @@ -196,9 +232,11 @@ internal sealed class OwnedObjectHandler || 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( @@ -219,8 +257,18 @@ internal sealed class OwnedObjectHandler } } + /// + /// 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) @@ -234,9 +282,11 @@ internal sealed class OwnedObjectHandler 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) { @@ -244,6 +294,7 @@ internal sealed class OwnedObjectHandler return null; } + // Try direct retrieval nint ownedPtr = kind switch { ObjectKind.Companion => await _dalamudUtil.GetCompanionAsync(playerPtr).ConfigureAwait(false), @@ -252,20 +303,29 @@ internal sealed class OwnedObjectHandler _ => 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) { - ownerEntityId = await _dalamudUtil.RunOnFrameworkThread(() => ReadEntityIdUnsafe(playerPtr)) + // Read unsafe + ownerEntityId = await _dalamudUtil + .RunOnFrameworkThread(() => ReadEntityIdSafe(playerHandler)) .ConfigureAwait(false); } if (ownerEntityId != 0 && ownerEntityId != uint.MaxValue) { - ownedPtr = await _dalamudUtil.RunOnFrameworkThread(() => FindOwnedByOwnerIdUnsafe(kind, ownerEntityId)) + // Scan for owned object + ownedPtr = await _dalamudUtil + .RunOnFrameworkThread(() => FindOwnedByOwnerIdSafe(kind, ownerEntityId)) .ConfigureAwait(false); stage = ownedPtr != nint.Zero ? "owner_scan" : "owner_scan_miss"; @@ -282,6 +342,9 @@ internal sealed class OwnedObjectHandler 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) { @@ -289,83 +352,102 @@ internal sealed class OwnedObjectHandler return null; } + // Get object index for debug ushort? objIndex = await _dalamudUtil.RunOnFrameworkThread(() => handler.GetGameObject()?.ObjectIndex) .ConfigureAwait(false); - SetMinionDebug(stage, null, handler.Address, objIndex); + SetMinionDebug(stage, failure: 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; - } + /// + /// Entity ID reader with safety checks. + /// + /// Handler of the Object + /// Entity Id + private static uint ReadEntityIdSafe(GameObjectHandler playerHandler) => playerHandler.GetGameObject()?.EntityId ?? 0; - private unsafe nint FindOwnedByOwnerIdUnsafe(ObjectKind kind, uint ownerEntityId) + /// + /// 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; - var addr = obj.Address; - var go = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)addr; - if (go == null) + // Check owner ID match + if (obj.OwnerId != ownerEntityId) 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) + // Check kind match + if (!IsOwnedKindMatch(obj, kind)) continue; - var resolvedOwner = ResolveOwnerIdUnsafe(go); - if (resolvedOwner == ownerEntityId) - return addr; + return obj.Address; } return nint.Zero; } - private static unsafe uint ResolveOwnerIdUnsafe(FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject* gameObject) + /// + /// Determines if the given object matches the specified owned kind. + /// + /// Game Object + /// Object Kind + /// + private static bool IsOwnedKindMatch(IGameObject obj, ObjectKind kind) => kind switch { - if (gameObject == null) return 0; + // Match minion or mount + ObjectKind.MinionOrMount => + obj.ObjectKind is DalamudObjectKind.MountType + or DalamudObjectKind.Companion, - if (gameObject->OwnerId != 0) - return gameObject->OwnerId; + // Match pet + ObjectKind.Pet => + obj.ObjectKind == DalamudObjectKind.BattleNpc + && obj is IBattleNpc bnPet + && bnPet.BattleNpcKind == BattleNpcSubKind.Pet, - 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; - } + // 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) diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index 16ddb7c..549b883 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -2885,6 +2885,62 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa TryHandleVisibilityUpdate(); } + private void KickOwnedObjectRetryFromTracked() + { + if (!IsVisible || IsPaused() || !CanApplyNow() || _charaHandler is null || _charaHandler.Address == nint.Zero) + return; + + var data = _cachedData ?? LastReceivedCharacterData ?? _pairStateCache.TryLoad(Ident); + if (data is null) + return; + + static HashSet BuildOwnedChanges(CharacterData d, ObjectKind k) + { + var set = new HashSet(); + + if (d.FileReplacements.TryGetValue(k, out var repls) && repls is { Count: > 0 }) + set.Add(PlayerChanges.ModFiles); + + if (d.GlamourerData.TryGetValue(k, out var g) && !string.IsNullOrEmpty(g)) + set.Add(PlayerChanges.Glamourer); + + if (d.CustomizePlusData.TryGetValue(k, out var c) && !string.IsNullOrEmpty(c)) + set.Add(PlayerChanges.Customize); + + if (set.Count > 0) + set.Add(PlayerChanges.ForcedRedraw); + + return set; + } + + var kinds = new[] { ObjectKind.MinionOrMount, ObjectKind.Pet, ObjectKind.Companion }; + + lock (_ownedRetryGate) + { + foreach (var k in kinds) + { + if (!HasAppearanceDataForKind(data, k)) + continue; + + var changes = BuildOwnedChanges(data, k); + if (changes.Count == 0) + continue; + + _pendingOwnedChanges[k] = changes; + } + + if (_pendingOwnedChanges.Count == 0) + return; + + _ownedRetryCts = _ownedRetryCts?.CancelRecreate() ?? new CancellationTokenSource(); + if (_ownedRetryTask.IsCompleted) + _ownedRetryTask = Task.Run(() => OwnedObjectRetryLoopAsync(_ownedRetryCts.Token), CancellationToken.None); + } + + Logger.LogDebug("{handler}: Kicked owned-object retry from ActorTracked (pending: {pending})", + GetLogIdentifier(), string.Join(", ", _pendingOwnedChanges.Keys)); + } + private void TryHandleVisibilityUpdate() { if (_charaHandler?.Address != nint.Zero && !IsVisible && !_pauseRequested) @@ -3386,6 +3442,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return; ScheduleOwnedObjectRetry(ownedKind, changes); + + KickOwnedObjectRetryFromTracked(); } private static HashSet BuildOwnedChangeSetForKind(CharacterData data, ObjectKind kind)