From f6a5c85c2d902a816fb179adb133aae2ee287cae Mon Sep 17 00:00:00 2001 From: cake Date: Thu, 15 Jan 2026 06:11:07 +0100 Subject: [PATCH] Added debug information regarding minions --- .../PlayerData/Handlers/OwnedObjectHandler.cs | 155 ++++- .../PlayerData/Pairs/IPairHandlerAdapter.cs | 96 ++-- LightlessSync/PlayerData/Pairs/Pair.cs | 13 +- .../PlayerData/Pairs/PairDebugInfo.cs | 66 ++- .../PlayerData/Pairs/PairHandlerAdapter.cs | 529 ++++++++++++++++-- .../Pairs/PairHandlerAdapterFactory.cs | 6 +- LightlessSync/Plugin.cs | 1 + .../ActorTracking/ActorObjectService.cs | 130 ++++- LightlessSync/UI/SettingsUi.cs | 40 ++ 9 files changed, 894 insertions(+), 142 deletions(-) diff --git a/LightlessSync/PlayerData/Handlers/OwnedObjectHandler.cs b/LightlessSync/PlayerData/Handlers/OwnedObjectHandler.cs index f335057..912b778 100644 --- a/LightlessSync/PlayerData/Handlers/OwnedObjectHandler.cs +++ b/LightlessSync/PlayerData/Handlers/OwnedObjectHandler.cs @@ -1,22 +1,37 @@ -using Microsoft.Extensions.Logging; +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 LightlessSync.Interop.Ipc; -using LightlessSync.PlayerData.Pairs; +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; @@ -25,13 +40,15 @@ internal sealed class OwnedObjectHandler DalamudUtilService dalamudUtil, GameObjectHandlerFactory handlerFactory, IpcManager ipc, - ActorObjectService actorObjectService) + ActorObjectService actorObjectService, + IObjectTable objectTable) { _logger = logger; _dalamudUtil = dalamudUtil; _handlerFactory = handlerFactory; _ipc = ipc; _actorObjectService = actorObjectService; + _objectTable = objectTable; } public async Task ApplyAsync( @@ -204,10 +221,29 @@ internal sealed class OwnedObjectHandler 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), @@ -216,12 +252,115 @@ internal sealed class OwnedObjectHandler _ => nint.Zero }; - if (ownedPtr == nint.Zero) - return null; + var stage = ownedPtr != nint.Zero ? "direct" : "direct_miss"; - return await _handlerFactory.Create(kind, () => ownedPtr, isWatched: false).ConfigureAwait(false); + 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); diff --git a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs index 0566491..a2d89d0 100644 --- a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs @@ -1,43 +1,61 @@ - using LightlessSync.API.Data; +using LightlessSync.API.Data; - namespace LightlessSync.PlayerData.Pairs; +namespace LightlessSync.PlayerData.Pairs; - /// - /// orchestrates the lifecycle of a paired character - /// - public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject - { - new string Ident { get; } - bool Initialized { get; } - bool IsVisible { get; } - bool ScheduledForDeletion { get; set; } - CharacterData? LastReceivedCharacterData { get; } - long LastAppliedDataBytes { get; } - new string? PlayerName { get; } - string PlayerNameHash { get; } - uint PlayerCharacterId { get; } - DateTime? LastDataReceivedAt { get; } - DateTime? LastApplyAttemptAt { get; } - DateTime? LastSuccessfulApplyAt { get; } - string? LastFailureReason { get; } - IReadOnlyList LastBlockingConditions { get; } - bool IsApplying { get; } - bool IsDownloading { get; } - int PendingDownloadCount { get; } - int ForbiddenDownloadCount { get; } - bool PendingModReapply { get; } - bool ModApplyDeferred { get; } - int MissingCriticalMods { get; } - int MissingNonCriticalMods { get; } - int MissingForbiddenMods { get; } - DateTime? InvisibleSinceUtc { get; } - DateTime? VisibilityEvictionDueAtUtc { get; } +/// +/// orchestrates the lifecycle of a paired character +/// +public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject +{ + new string Ident { get; } + bool Initialized { get; } + bool IsVisible { get; } + bool ScheduledForDeletion { get; set; } + CharacterData? LastReceivedCharacterData { get; } + long LastAppliedDataBytes { get; } + new string? PlayerName { get; } + string PlayerNameHash { get; } + uint PlayerCharacterId { get; } + + DateTime? LastDataReceivedAt { get; } + DateTime? LastApplyAttemptAt { get; } + DateTime? LastSuccessfulApplyAt { get; } + + string? LastFailureReason { get; } + IReadOnlyList LastBlockingConditions { get; } + + bool IsApplying { get; } + bool IsDownloading { get; } + int PendingDownloadCount { get; } + int ForbiddenDownloadCount { get; } + + bool PendingModReapply { get; } + bool ModApplyDeferred { get; } + int MissingCriticalMods { get; } + int MissingNonCriticalMods { get; } + int MissingForbiddenMods { get; } + + DateTime? InvisibleSinceUtc { get; } + DateTime? VisibilityEvictionDueAtUtc { get; } + + string? MinionAddressHex { get; } + ushort? MinionObjectIndex { get; } + DateTime? MinionResolvedAtUtc { get; } + string? MinionResolveStage { get; } + string? MinionResolveFailureReason { get; } + + bool MinionPendingRetry { get; } + IReadOnlyList MinionPendingRetryChanges { get; } + bool MinionHasAppearanceData { get; } + + Guid OwnedPenumbraCollectionId { get; } + bool NeedsCollectionRebuildDebug { get; } void Initialize(); - void ApplyData(CharacterData data); - void ApplyLastReceivedData(bool forced = false); - bool FetchPerformanceMetricsFromCache(); - void LoadCachedCharacterData(CharacterData data); - void SetUploading(bool uploading); - void SetPaused(bool paused); - } + void ApplyData(CharacterData data); + void ApplyLastReceivedData(bool forced = false); + bool FetchPerformanceMetricsFromCache(); + void LoadCachedCharacterData(CharacterData data); + void SetUploading(bool uploading); + void SetPaused(bool paused); +} diff --git a/LightlessSync/PlayerData/Pairs/Pair.cs b/LightlessSync/PlayerData/Pairs/Pair.cs index e95b7fe..ed1047d 100644 --- a/LightlessSync/PlayerData/Pairs/Pair.cs +++ b/LightlessSync/PlayerData/Pairs/Pair.cs @@ -244,6 +244,17 @@ public class Pair handler.ModApplyDeferred, handler.MissingCriticalMods, handler.MissingNonCriticalMods, - handler.MissingForbiddenMods); + handler.MissingForbiddenMods, + + handler.MinionAddressHex, + handler.MinionObjectIndex, + handler.MinionResolvedAtUtc, + handler.MinionResolveStage, + handler.MinionResolveFailureReason, + handler.MinionPendingRetry, + handler.MinionPendingRetryChanges, + handler.MinionHasAppearanceData, + handler.OwnedPenumbraCollectionId, + handler.NeedsCollectionRebuildDebug); } } diff --git a/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs b/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs index 60abf35..258b2c6 100644 --- a/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs +++ b/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs @@ -21,28 +21,50 @@ public sealed record PairDebugInfo( bool ModApplyDeferred, int MissingCriticalMods, int MissingNonCriticalMods, - int MissingForbiddenMods) + int MissingForbiddenMods, + + string? MinionAddressHex, + ushort? MinionObjectIndex, + DateTime? MinionResolvedAtUtc, + string? MinionResolveStage, + string? MinionResolveFailureReason, + bool MinionPendingRetry, + IReadOnlyList MinionPendingRetryChanges, + bool MinionHasAppearanceData, + Guid OwnedPenumbraCollectionId, + bool NeedsCollectionRebuild) { public static PairDebugInfo Empty { get; } = new( - false, - false, - false, - false, - null, - null, - null, - null, - null, - null, - null, - Array.Empty(), - false, - false, - 0, - 0, - false, - false, - 0, - 0, - 0); + HasHandler: false, + HandlerInitialized: false, + HandlerVisible: false, + HandlerScheduledForDeletion: false, + LastDataReceivedAt: null, + LastApplyAttemptAt: null, + LastSuccessfulApplyAt: null, + InvisibleSinceUtc: null, + VisibilityEvictionDueAtUtc: null, + VisibilityEvictionRemainingSeconds: null, + LastFailureReason: null, + BlockingConditions: [], + IsApplying: false, + IsDownloading: false, + PendingDownloadCount: 0, + ForbiddenDownloadCount: 0, + PendingModReapply: false, + ModApplyDeferred: false, + MissingCriticalMods: 0, + MissingNonCriticalMods: 0, + MissingForbiddenMods: 0, + + MinionAddressHex: null, + MinionObjectIndex: null, + MinionResolvedAtUtc: null, + MinionResolveStage: null, + MinionResolveFailureReason: null, + MinionPendingRetry: false, + MinionPendingRetryChanges: [], + MinionHasAppearanceData: false, + OwnedPenumbraCollectionId: Guid.Empty, + NeedsCollectionRebuild: false); } diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index b547396..16ddb7c 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -1,6 +1,5 @@ -using System.Collections.Concurrent; -using System.Diagnostics; -using Dalamud.Plugin.Services; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game.Object; using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; @@ -21,9 +20,11 @@ using LightlessSync.Utils; using LightlessSync.WebAPI.Files; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; +using System.Diagnostics; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; -using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; using FileReplacementDataComparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer; +using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; namespace LightlessSync.PlayerData.Pairs; @@ -133,6 +134,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private ushort _lastKnownObjectIndex = ushort.MaxValue; private string? _lastKnownName; + private readonly object _ownedReapplyGate = new(); + private DateTime _nextOwnedReapplyUtc = DateTime.MinValue; + private static readonly TimeSpan OwnedReapplyThrottle = TimeSpan.FromSeconds(1); + public DateTime? InvisibleSinceUtc => _invisibleSinceUtc; public DateTime? VisibilityEvictionDueAtUtc => _visibilityEvictionDueAtUtc; public string Ident { get; } @@ -198,6 +203,54 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa public bool IsDownloading => _downloadManager.IsDownloading; public int PendingDownloadCount => _downloadManager.CurrentDownloads.Count; public int ForbiddenDownloadCount => _downloadManager.ForbiddenTransfers.Count; + public string? MinionAddressHex => _ownedObjectHandler.MinionResolveDebug.AddressHex; + public ushort? MinionObjectIndex => _ownedObjectHandler.MinionResolveDebug.ObjectIndex; + public DateTime? MinionResolvedAtUtc => _ownedObjectHandler.MinionResolveDebug.ResolvedAtUtc; + public string? MinionResolveStage => string.IsNullOrEmpty(_ownedObjectHandler.MinionResolveDebug.Stage) ? null : _ownedObjectHandler.MinionResolveDebug.Stage; + public string? MinionResolveFailureReason => _ownedObjectHandler.MinionResolveDebug.FailureReason; + + public bool MinionPendingRetry + { + get + { + lock (_ownedRetryGate) + return _pendingOwnedChanges.ContainsKey(ObjectKind.MinionOrMount); + } + } + + public IReadOnlyList MinionPendingRetryChanges + { + get + { + lock (_ownedRetryGate) + { + if (_pendingOwnedChanges.TryGetValue(ObjectKind.MinionOrMount, out var set)) + return set.Select(s => s.ToString()).ToArray(); + + return Array.Empty(); + } + } + } + + public bool MinionHasAppearanceData + { + get + { + var data = _cachedData ?? LastReceivedCharacterData ?? _pairStateCache.TryLoad(Ident); + return data is not null && HasAppearanceDataForKind(data, ObjectKind.MinionOrMount); + } + } + + public Guid OwnedPenumbraCollectionId + { + get + { + lock (_collectionGate) + return _penumbraOwnedCollection; + } + } + + public bool NeedsCollectionRebuildDebug => _needsCollectionRebuild; public PairHandlerAdapter( ILogger logger, @@ -210,6 +263,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa PluginWarningNotificationService pluginWarningNotificationManager, DalamudUtilService dalamudUtil, IFramework framework, + IObjectTable objectTable, ActorObjectService actorObjectService, IHostApplicationLifetime lifetime, FileCacheManager fileDbManager, @@ -247,7 +301,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _tempCollectionJanitor = tempCollectionJanitor; _modelAnalyzer = modelAnalyzer; _configService = configService; - _ownedObjectHandler = new OwnedObjectHandler(Logger, _dalamudUtil, _gameObjectHandlerFactory, _ipcManager, _actorObjectService); + _ownedObjectHandler = new OwnedObjectHandler(Logger, _dalamudUtil, _gameObjectHandlerFactory, _ipcManager, _actorObjectService, objectTable); } public void Initialize() @@ -1304,6 +1358,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private async Task OwnedObjectRetryLoopAsync(CancellationToken token) { var delay = OwnedRetryInitialDelay; + try { while (!token.IsCancellationRequested) @@ -1318,11 +1373,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa lock (_ownedRetryGate) { if (_pendingOwnedChanges.Count == 0) - { return; - } - pending = _pendingOwnedChanges.ToDictionary(kvp => kvp.Key, kvp => new HashSet(kvp.Value)); + pending = _pendingOwnedChanges.ToDictionary( + kvp => kvp.Key, + kvp => new HashSet(kvp.Value)); } if (!IsVisible || IsPaused() || !CanApplyNow() || PlayerCharacter == nint.Zero || _charaHandler is null) @@ -1347,16 +1402,62 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa continue; } - bool anyApplied = false; - foreach (var entry in pending) + token.ThrowIfCancellationRequested(); + + var ownedPending = pending + .Where(k => k.Key != ObjectKind.Player) + .ToList(); + + if (ownedPending.Count == 0) { + return; + } + + var needsOwnedCollection = + _ipcManager.Penumbra.APIAvailable + && ownedPending.Any(e => + e.Value.Contains(PlayerChanges.ModFiles) + && sanitized.FileReplacements.TryGetValue(e.Key, out var repls) + && repls is { Count: > 0 }); + + Guid ownedCollection = Guid.Empty; + if (needsOwnedCollection) + { + ownedCollection = EnsureOwnedPenumbraCollection(); + if (ownedCollection == Guid.Empty) + { + await Task.Delay(delay, token).ConfigureAwait(false); + delay = IncreaseRetryDelay(delay); + continue; + } + + + await TryRefreshOwnedCollectionModsAsync(ownedCollection, sanitized, token).ConfigureAwait(false); + } + + bool anyApplied = false; + + foreach (var entry in ownedPending) + { + token.ThrowIfCancellationRequested(); + if (!HasAppearanceDataForKind(sanitized, entry.Key)) { ClearOwnedObjectRetry(entry.Key); continue; } - var applied = await ApplyCustomizationDataAsync(Guid.NewGuid(), entry, sanitized, token).ConfigureAwait(false); + var applied = await _ownedObjectHandler.ApplyAsync( + Guid.NewGuid(), + entry.Key, + entry.Value, + sanitized, + _charaHandler, + ownedCollection, + _customizeIds, + token) + .ConfigureAwait(false); + if (applied) { ClearOwnedObjectRetry(entry.Key); @@ -1385,6 +1486,95 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } + private async Task TryRefreshOwnedCollectionModsAsync( + Guid ownedCollection, + CharacterData sanitized, + CancellationToken token) + { + if (ownedCollection == Guid.Empty) + return; + + if (!_ipcManager.Penumbra.APIAvailable) + return; + + static bool IsOwnedKind(ObjectKind k) => + k is ObjectKind.MinionOrMount or ObjectKind.Pet or ObjectKind.Companion; + + var ownedGamePaths = new HashSet(StringComparer.OrdinalIgnoreCase); + var ownedFileSwaps = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var kvp in sanitized.FileReplacements) + { + if (!IsOwnedKind(kvp.Key)) + continue; + + foreach (var repl in kvp.Value) + { + if (!string.IsNullOrEmpty(repl.FileSwapPath)) + { + foreach (var gp in repl.GamePaths) + { + if (!string.IsNullOrEmpty(gp)) + ownedFileSwaps[gp] = repl.FileSwapPath!; + } + + continue; + } + + foreach (var gp in repl.GamePaths) + { + if (!string.IsNullOrEmpty(gp)) + ownedGamePaths.Add(gp); + } + } + } + + if (ownedGamePaths.Count == 0 && ownedFileSwaps.Count == 0) + return; + + token.ThrowIfCancellationRequested(); + + Dictionary<(string GamePath, string? Hash), string>? resolved = null; + + if (_lastAppliedModdedPaths is not null && _lastAppliedModdedPaths.Count > 0 && HasValidCachedModdedPaths()) + { + resolved = _lastAppliedModdedPaths; + } + else + { + _ = TryCalculateModdedDictionary(Guid.NewGuid(), sanitized, out var recomputed, token); + resolved = recomputed; + + _lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(resolved, resolved.Comparer); + } + + token.ThrowIfCancellationRequested(); + + var ownedMods = new Dictionary(StringComparer.Ordinal); + + foreach (var kv in resolved) + { + var gp = kv.Key.GamePath; + if (ownedGamePaths.Contains(gp)) + ownedMods[gp] = kv.Value; + } + + foreach (var kv in ownedFileSwaps) + ownedMods[kv.Key] = kv.Value; + + if (ownedMods.Count == 0) + return; + + var refreshId = Guid.NewGuid(); + Logger.LogDebug("[{appId}] Refreshing OWNED temp collection mods ({count} paths) for {handler}", + refreshId, ownedMods.Count, GetLogIdentifier()); + + await _ipcManager.Penumbra + .SetTemporaryModsAsync(Logger, refreshId, ownedCollection, ownedMods, scope: "OwnedRetryRefresh") + .ConfigureAwait(false); + } + + private static TimeSpan IncreaseRetryDelay(TimeSpan delay) { var nextMs = Math.Min(delay.TotalMilliseconds * 2, OwnedRetryMaxDelay.TotalMilliseconds); @@ -1674,7 +1864,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { ObjectKind.Player => _charaHandler!, ObjectKind.Companion => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetCompanionPtr(ptr), isWatched: false).ConfigureAwait(false), - ObjectKind.MinionOrMount => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetMinionOrMountPtr(ptr), isWatched: false).ConfigureAwait(false), + ObjectKind.MinionOrMount => await CreateMinionOrMountHandlerAsync(token).ConfigureAwait(false), ObjectKind.Pet => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetPetPtr(ptr), isWatched: false).ConfigureAwait(false), _ => throw new NotSupportedException("ObjectKind not supported: " + changes.Key) }; @@ -1777,8 +1967,36 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa await Task.WhenAll(tasks).ConfigureAwait(false); if (!isIpcOnly && needsRedraw && _ipcManager.Penumbra.APIAvailable) + { await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, token).ConfigureAwait(false); + if (handler.ObjectKind == ObjectKind.Player) + { + var fullyLoaded = await _actorObjectService + .WaitForFullyLoadedAsync(handler.Address, token, FullyLoadedTimeoutMsPlayer) + .ConfigureAwait(false); + + if (!fullyLoaded) + { + Logger.LogDebug("[{applicationId}] Timed out waiting for PLAYER {handler} to fully load, skipping customization apply", + applicationId, handler); + return false; + } + } + else + { + var ready = await WaitForNonPlayerDrawableAsync(handler.Address, token, timeoutMs: FullyLoadedTimeoutMsOther) + .ConfigureAwait(false); + + if (!ready) + { + Logger.LogDebug("[{applicationId}] Timed out waiting for OWNED {handler} to become drawable, skipping (will retry)", + applicationId, handler); + return false; + } + } + } + return true; } finally @@ -1787,6 +2005,97 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } + private async Task CreateMinionOrMountHandlerAsync(CancellationToken token) + { + if (_charaHandler is null || _charaHandler.Address == nint.Zero) + return await _gameObjectHandlerFactory.Create(ObjectKind.MinionOrMount, () => nint.Zero, isWatched: false).ConfigureAwait(false); + + var ownedPtr = await ResolveMinionOrMountAddressAsync(_charaHandler, token).ConfigureAwait(false); + return await _gameObjectHandlerFactory.Create(ObjectKind.MinionOrMount, () => ownedPtr, isWatched: false).ConfigureAwait(false); + } + + private static async Task WaitForNonPlayerDrawableAsync(nint address, CancellationToken token, int timeoutMs) + { + var until = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < until) + { + token.ThrowIfCancellationRequested(); + + if (IsNonPlayerDrawable(address)) + return true; + + await Task.Delay(100, token).ConfigureAwait(false); + } + + return false; + } + + private static unsafe bool IsNonPlayerDrawable(nint address) + { + if (address == nint.Zero) + return false; + + var go = (GameObject*)address; + if (go == null) + return false; + + if (go->DrawObject == null) + return false; + + if ((ulong)go->RenderFlags == 2048) + return false; + + return true; + } + + private async Task> ResolveMinionOrMountCandidatesAsync(GameObjectHandler playerHandler, CancellationToken token) + { + if (playerHandler is null || playerHandler.Address == nint.Zero) + return []; + + return await _dalamudUtil.RunOnFrameworkThread(() => + { + var ownerEntityId = playerHandler.EntityId; + if (ownerEntityId == 0 || ownerEntityId == uint.MaxValue) + return Array.Empty(); + + var objIndex = playerHandler.GetGameObject()?.ObjectIndex ?? (ushort)0; + + return _actorObjectService.GetMinionOrMountCandidates(ownerEntityId, objIndex); + }).ConfigureAwait(false); + } + + private async Task ResolveMinionOrMountAddressAsync(GameObjectHandler playerHandler, CancellationToken token) + { + if (playerHandler is null || playerHandler.Address == nint.Zero) + return nint.Zero; + + var ownerEntityId = playerHandler.EntityId; + if (ownerEntityId != 0 && ownerEntityId != uint.MaxValue) + { + var owned = await _dalamudUtil.RunOnFrameworkThread(() => + { + return _actorObjectService.TryFindOwnedObject( + ownerEntityId, + ObjectKind.MinionOrMount, + out var addr) + ? addr + : nint.Zero; + }).ConfigureAwait(false); + + if (owned != nint.Zero) + return owned; + } + + try + { + return await _dalamudUtil.GetMinionOrMountAsync(playerHandler.Address).ConfigureAwait(false); + } + catch + { + return nint.Zero; + } + } private static Dictionary> BuildFullChangeSet(CharacterData characterData) { @@ -2137,9 +2446,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa kvp.Key != ObjectKind.Player && kvp.Value.Contains(PlayerChanges.ModFiles)); - var wantsOwnedCollectionAssignNow = - needsOwnedCollectionAssign - && updateModdedPaths; + var wantsOwnedCollectionAssignNow = needsOwnedCollectionAssign; Guid ownedAssignCollection = Guid.Empty; if (wantsOwnedCollectionAssignNow) @@ -2187,38 +2494,47 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa while (!token.IsCancellationRequested) { - var ownedPtr = await ResolveOtherOwnedPtrAsync(kvp.Key, handlerForApply.Address).ConfigureAwait(false); - if (ownedPtr != nint.Zero) - { - using var ownedHandler = await _gameObjectHandlerFactory - .Create(kvp.Key, () => ownedPtr, isWatched: false) - .ConfigureAwait(false); + IReadOnlyList ownedPtrs; - if (ownedHandler.Address != nint.Zero) + if (kvp.Key == ObjectKind.MinionOrMount) + ownedPtrs = await ResolveMinionOrMountCandidatesAsync(handlerForApply, token).ConfigureAwait(false); + else + ownedPtrs = new[] { await ResolveOtherOwnedPtrAsync(kvp.Key, handlerForApply.Address).ConfigureAwait(false) }; + + ownedPtrs = ownedPtrs.Where(p => p != nint.Zero).Distinct().ToArray(); + if (ownedPtrs.Count > 0) + { + foreach (var ptr in ownedPtrs) { + using var ownedHandler = await _gameObjectHandlerFactory + .Create(kvp.Key, () => ptr, isWatched: false) + .ConfigureAwait(false); + + if (ownedHandler.Address == nint.Zero) + continue; + var objIndex = await _dalamudUtil.RunOnFrameworkThread(() => { var go = ownedHandler.GetGameObject(); return go?.ObjectIndex; }).ConfigureAwait(false); - if (objIndex.HasValue) - { - await _ipcManager.Penumbra - .AssignTemporaryCollectionAsync(Logger, ownedCollectionSnapshot, objIndex.Value) - .ConfigureAwait(false); + if (!objIndex.HasValue) + continue; - await _ipcManager.Penumbra - .RedrawAsync(Logger, ownedHandler, Guid.NewGuid(), token) - .ConfigureAwait(false); + await _ipcManager.Penumbra + .AssignTemporaryCollectionAsync(Logger, ownedCollectionSnapshot, objIndex.Value) + .ConfigureAwait(false); - Logger.LogDebug( - "Assigned OWNED temp collection {collection} to owned object {kind} for {handler} and redrew", - ownedCollectionSnapshot, kvp.Key, GetLogIdentifier()); + await _ipcManager.Penumbra + .RedrawAsync(Logger, ownedHandler, Guid.NewGuid(), token) + .ConfigureAwait(false); - break; - } + Logger.LogDebug("Assigned OWNED temp collection {collection} to {kind} candidate idx={idx} for {handler}", + ownedCollectionSnapshot, kvp.Key, objIndex.Value, GetLogIdentifier()); } + + break; } await Task.Delay(delay, token).ConfigureAwait(false); @@ -2420,6 +2736,13 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa // Final redraw await _ipcManager.Penumbra.RedrawAsync(Logger, handlerForApply, _applicationId, token).ConfigureAwait(false); + + if (handlerForApply.Address != nint.Zero) + { + await _actorObjectService + .WaitForFullyLoadedAsync(handlerForApply.Address, token, FullyLoadedTimeoutMsPlayer) + .ConfigureAwait(false); + } // Cache last applied modded paths _lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(moddedPaths, moddedPaths.Comparer); @@ -3026,18 +3349,104 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor) { - if (!TryResolveDescriptorHash(descriptor, out var hashedCid)) + if (TryResolveDescriptorHash(descriptor, out var hashedCid) + && string.Equals(hashedCid, Ident, StringComparison.Ordinal)) + { + if (descriptor.Address == nint.Zero) + return; + + UpdateLastKnownActor(descriptor); + RefreshTrackedHandler(descriptor); + QueueActorInitialization(descriptor); + return; + } + + if (_charaHandler is null || _charaHandler.Address == nint.Zero) return; - if (!string.Equals(hashedCid, Ident, StringComparison.Ordinal)) + var ownerId = descriptor.OwnerEntityId; + if (ownerId == 0 || ownerId != _charaHandler.EntityId) return; - if (descriptor.Address == nint.Zero) + if (!TryMapOwnedKind(descriptor, out var ownedKind)) return; - UpdateLastKnownActor(descriptor); - RefreshTrackedHandler(descriptor); - QueueActorInitialization(descriptor); + var data = _cachedData + ?? LastReceivedCharacterData + ?? _pairStateCache.TryLoad(Ident); + + if (data is null) + return; + + if (!HasAppearanceDataForKind(data, ownedKind)) + return; + + var changes = BuildOwnedChangeSetForKind(data, ownedKind); + if (changes.Count == 0) + return; + + ScheduleOwnedObjectRetry(ownedKind, changes); + } + + private static HashSet BuildOwnedChangeSetForKind(CharacterData data, ObjectKind kind) + { + var changes = new HashSet(); + + if (data.FileReplacements.TryGetValue(kind, out var repls) && repls is { Count: > 0 }) + changes.Add(PlayerChanges.ModFiles); + + if (data.GlamourerData.TryGetValue(kind, out var glamourer) && !string.IsNullOrEmpty(glamourer)) + changes.Add(PlayerChanges.Glamourer); + + if (data.CustomizePlusData.TryGetValue(kind, out var customize) && !string.IsNullOrEmpty(customize)) + changes.Add(PlayerChanges.Customize); + + return changes; + } + + private static unsafe bool TryMapOwnedKind(ActorObjectService.ActorDescriptor descriptor, out ObjectKind kind) + { + kind = default; + + switch (descriptor.ObjectKind) + { + case DalamudObjectKind.MountType: + kind = ObjectKind.MinionOrMount; + return true; + + case DalamudObjectKind.Companion: + kind = ObjectKind.Companion; + return true; + + case DalamudObjectKind.BattleNpc: + { + if (descriptor.Address == nint.Zero) + return false; + + var go = (GameObject*)descriptor.Address; + if (go == null) + return false; + + var subKind = go->BattleNpcSubKind; + + if (subKind == BattleNpcSubKind.Pet) + { + kind = ObjectKind.Pet; + return true; + } + + if (subKind == BattleNpcSubKind.Buddy) + { + kind = ObjectKind.Companion; + return true; + } + + return false; + } + + default: + return false; + } } private void QueueActorInitialization(ActorObjectService.ActorDescriptor descriptor) @@ -3124,26 +3533,50 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private void HandleActorUntracked(ActorObjectService.ActorDescriptor descriptor) { - if (!TryResolveDescriptorHash(descriptor, out var hashedCid)) + if (TryResolveDescriptorHash(descriptor, out var hashedCid)) { + if (!string.Equals(hashedCid, Ident, StringComparison.Ordinal)) + return; + if (_charaHandler is null || _charaHandler.Address == nint.Zero) return; if (descriptor.Address != _charaHandler.Address) return; - } - else if (!string.Equals(hashedCid, Ident, StringComparison.Ordinal)) - { + + HandleVisibilityLoss(logChange: false); return; } if (_charaHandler is null || _charaHandler.Address == nint.Zero) return; - if (descriptor.Address != _charaHandler.Address) - return; + var localEntityId = _charaHandler.EntityId; + if (localEntityId != 0 && localEntityId != uint.MaxValue + && descriptor.OwnerEntityId != 0 + && descriptor.OwnerEntityId == localEntityId) + { + switch (descriptor.ObjectKind) + { + case DalamudObjectKind.MountType: + ClearOwnedObjectRetry(ObjectKind.MinionOrMount); + return; - HandleVisibilityLoss(logChange: false); + case DalamudObjectKind.Companion: + ClearOwnedObjectRetry(ObjectKind.Companion); + return; + + case DalamudObjectKind.BattleNpc: + ClearOwnedObjectRetry(ObjectKind.Pet); + ClearOwnedObjectRetry(ObjectKind.Companion); + return; + } + } + + if (descriptor.Address == _charaHandler.Address) + { + HandleVisibilityLoss(logChange: false); + } } private static bool TryResolveDescriptorHash(ActorObjectService.ActorDescriptor descriptor, out string hashedCid) diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs index 47336eb..c101e90 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs @@ -40,6 +40,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory private readonly LightlessConfigService _configService; private readonly XivDataAnalyzer _modelAnalyzer; private readonly IFramework _framework; + private readonly IObjectTable _objectTable; public PairHandlerAdapterFactory( ILoggerFactory loggerFactory, @@ -63,7 +64,8 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory PairPerformanceMetricsCache pairPerformanceMetricsCache, PenumbraTempCollectionJanitor tempCollectionJanitor, XivDataAnalyzer modelAnalyzer, - LightlessConfigService configService) + LightlessConfigService configService, + IObjectTable objectTable) { _loggerFactory = loggerFactory; _mediator = mediator; @@ -87,6 +89,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory _tempCollectionJanitor = tempCollectionJanitor; _modelAnalyzer = modelAnalyzer; _configService = configService; + _objectTable = objectTable; } public IPairHandlerAdapter Create(string ident) @@ -105,6 +108,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory _pluginWarningNotificationManager, dalamudUtilService, _framework, + _objectTable, actorObjectService, _lifetime, _fileCacheManager, diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 55a3dae..a4f15a2 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -110,6 +110,7 @@ public sealed class Plugin : IDalamudPlugin services.AddSingleton(gameGui); services.AddSingleton(gameInteropProvider); services.AddSingleton(addonLifecycle); + services.AddSingleton(objectTable); services.AddSingleton(pluginInterface.UiBuilder); // Core singletons diff --git a/LightlessSync/Services/ActorTracking/ActorObjectService.cs b/LightlessSync/Services/ActorTracking/ActorObjectService.cs index c5529cf..e49db02 100644 --- a/LightlessSync/Services/ActorTracking/ActorObjectService.cs +++ b/LightlessSync/Services/ActorTracking/ActorObjectService.cs @@ -508,7 +508,10 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS return new ActorDescriptor(name, hashedCid, address, objectIndex, isLocal, isInGpose, objectKind, ownedKind, ownerEntityId); } - private unsafe (LightlessObjectKind? OwnedKind, uint OwnerEntityId) DetermineOwnedKind(GameObject* gameObject, DalamudObjectKind objectKind, bool isLocalPlayer) + private unsafe (LightlessObjectKind? OwnedKind, uint OwnerEntityId) DetermineOwnedKind( + GameObject* gameObject, + DalamudObjectKind objectKind, + bool isLocalPlayer) { if (gameObject == null) return (null, 0); @@ -520,6 +523,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS } var ownerId = ResolveOwnerId(gameObject); + var localPlayerAddress = _objectTable.LocalPlayer?.Address ?? nint.Zero; if (localPlayerAddress == nint.Zero) return (null, ownerId); @@ -531,9 +535,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS if (objectKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion) { var expectedMinionOrMount = GetMinionOrMountAddress(localPlayerAddress, localEntityId); - if (expectedMinionOrMount != nint.Zero - && (nint)gameObject == expectedMinionOrMount - && IsPlayerRelatedOwnedAddress(expectedMinionOrMount, LightlessObjectKind.MinionOrMount)) + if (expectedMinionOrMount != nint.Zero && (nint)gameObject == expectedMinionOrMount) { var resolvedOwner = ownerId != 0 ? ownerId : localEntityId; return (LightlessObjectKind.MinionOrMount, resolvedOwner); @@ -543,20 +545,16 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS if (objectKind != DalamudObjectKind.BattleNpc) return (null, ownerId); - if (ownerId != localEntityId) + if (ownerId != 0 && ownerId != localEntityId) return (null, ownerId); var expectedPet = GetPetAddress(localPlayerAddress, localEntityId); - if (expectedPet != nint.Zero - && (nint)gameObject == expectedPet - && IsPlayerRelatedOwnedAddress(expectedPet, LightlessObjectKind.Pet)) - return (LightlessObjectKind.Pet, ownerId); + if (expectedPet != nint.Zero && (nint)gameObject == expectedPet) + return (LightlessObjectKind.Pet, ownerId != 0 ? ownerId : localEntityId); var expectedCompanion = GetCompanionAddress(localPlayerAddress, localEntityId); - if (expectedCompanion != nint.Zero - && (nint)gameObject == expectedCompanion - && IsPlayerRelatedOwnedAddress(expectedCompanion, LightlessObjectKind.Companion)) - return (LightlessObjectKind.Companion, ownerId); + if (expectedCompanion != nint.Zero && (nint)gameObject == expectedCompanion) + return (LightlessObjectKind.Companion, ownerId != 0 ? ownerId : localEntityId); return (null, ownerId); } @@ -584,21 +582,25 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS return nint.Zero; var playerObject = (GameObject*)localPlayerAddress; - var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1); - if (ownerEntityId == 0) - return nint.Zero; + var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1); if (candidateAddress != nint.Zero) { var candidate = (GameObject*)candidateAddress; var candidateKind = (DalamudObjectKind)candidate->ObjectKind; + if (candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion) { - if (ResolveOwnerId(candidate) == ownerEntityId) + var resolvedOwner = ResolveOwnerId(candidate); + + if (resolvedOwner == ownerEntityId || resolvedOwner == 0) return candidateAddress; } } + if (ownerEntityId == 0) + return nint.Zero; + foreach (var obj in _objectTable) { if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress) @@ -615,6 +617,90 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS return nint.Zero; } + public unsafe bool TryFindOwnedObject(uint ownerEntityId, LightlessObjectKind kind, out nint address) + { + address = nint.Zero; + if (ownerEntityId == 0) return false; + + foreach (var addr in EnumerateActiveCharacterAddresses()) + { + if (addr == nint.Zero) continue; + + var go = (GameObject*)addr; + var ok = (DalamudObjectKind)go->ObjectKind; + + switch (kind) + { + case LightlessObjectKind.MinionOrMount: + if (ok is DalamudObjectKind.MountType or DalamudObjectKind.Companion + && ResolveOwnerId(go) == ownerEntityId) + { + address = addr; + return true; + } + break; + + case LightlessObjectKind.Pet: + if (ok == DalamudObjectKind.BattleNpc + && go->BattleNpcSubKind == BattleNpcSubKind.Pet + && ResolveOwnerId(go) == ownerEntityId) + { + address = addr; + return true; + } + break; + + case LightlessObjectKind.Companion: + if (ok == DalamudObjectKind.BattleNpc + && go->BattleNpcSubKind == BattleNpcSubKind.Buddy + && ResolveOwnerId(go) == ownerEntityId) + { + address = addr; + return true; + } + break; + } + } + + return false; + } + + public unsafe IReadOnlyList GetMinionOrMountCandidates(uint ownerEntityId, ushort preferredPlayerIndex) + { + var results = new List<(nint Ptr, int Score)>(4); + + var manager = GameObjectManager.Instance(); + if (manager == null || ownerEntityId == 0) + return Array.Empty(); + + const int objectLimit = 200; + for (var i = 0; i < objectLimit; i++) + { + var obj = manager->Objects.IndexSorted[i].Value; + if (obj == null) + continue; + + var kind = (DalamudObjectKind)obj->ObjectKind; + if (kind is not (DalamudObjectKind.MountType or DalamudObjectKind.Companion)) + continue; + + var owner = ResolveOwnerId(obj); + if (owner != ownerEntityId) + continue; + + var idx = obj->ObjectIndex; + var score = Math.Abs(idx - (preferredPlayerIndex + 1)); + if (obj->DrawObject == null) score += 50; + + results.Add(((nint)obj, score)); + } + + return results + .OrderBy(r => r.Score) + .Select(r => r.Ptr) + .ToArray(); + } + private unsafe nint GetPetAddress(nint localPlayerAddress, uint ownerEntityId) { if (localPlayerAddress == nint.Zero || ownerEntityId == 0) @@ -1219,21 +1305,19 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS private static unsafe bool IsObjectFullyLoaded(nint address) { - if (address == nint.Zero) - return false; + if (address == nint.Zero) return false; var gameObject = (GameObject*)address; - if (gameObject == null) - return false; + if (gameObject == null) return false; var drawObject = gameObject->DrawObject; - if (drawObject == null) - return false; + if (drawObject == null) return false; if ((ulong)gameObject->RenderFlags == 2048) return false; var characterBase = (CharacterBase*)drawObject; + if (characterBase->HasModelInSlotLoaded != 0) return false; diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 207455e..c4d2610 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -1688,6 +1688,46 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.EndTable(); } + ImGui.Separator(); + ImGui.TextUnformatted("Owned / Minion-Mount"); + + if (ImGui.BeginTable("##pairDebugOwnedMinion", 2, ImGuiTableFlags.SizingStretchProp)) + { + DrawPairPropertyRow("Owned Temp Collection", debugInfo.OwnedPenumbraCollectionId == Guid.Empty + ? "n/a" + : debugInfo.OwnedPenumbraCollectionId.ToString()); + + DrawPairPropertyRow("Needs Collection Rebuild", FormatBool(debugInfo.NeedsCollectionRebuild)); + + DrawPairPropertyRow("Minion Ptr", string.IsNullOrEmpty(debugInfo.MinionAddressHex) + ? "n/a" + : debugInfo.MinionAddressHex); + + DrawPairPropertyRow("Minion ObjectIndex", debugInfo.MinionObjectIndex.HasValue + ? debugInfo.MinionObjectIndex.Value.ToString(CultureInfo.InvariantCulture) + : "n/a"); + + DrawPairPropertyRow("Minion Resolved At", FormatTimestamp(debugInfo.MinionResolvedAtUtc)); + + DrawPairPropertyRow("Minion Resolve Stage", string.IsNullOrEmpty(debugInfo.MinionResolveStage) + ? "n/a" + : debugInfo.MinionResolveStage); + + DrawPairPropertyRow("Minion Resolve Failure", string.IsNullOrEmpty(debugInfo.MinionResolveFailureReason) + ? "n/a" + : debugInfo.MinionResolveFailureReason); + + DrawPairPropertyRow("Minion Pending Retry", FormatBool(debugInfo.MinionPendingRetry)); + + var retryChanges = debugInfo.MinionPendingRetryChanges is { Count: > 0 } + ? string.Join(", ", debugInfo.MinionPendingRetryChanges) + : "n/a"; + DrawPairPropertyRow("Minion Pending Changes", retryChanges); + + DrawPairPropertyRow("Minion Has Appearance Data", FormatBool(debugInfo.MinionHasAppearanceData)); + + ImGui.EndTable(); + } ImGui.Separator(); ImGui.TextUnformatted("Syncshell Memberships"); if (snapshot.PairsWithGroups.TryGetValue(pair, out var groups) && groups.Count > 0)