diff --git a/LightlessSync/Interop/Ipc/Penumbra/PenumbraCollections.cs b/LightlessSync/Interop/Ipc/Penumbra/PenumbraCollections.cs index c095471..c3e497a 100644 --- a/LightlessSync/Interop/Ipc/Penumbra/PenumbraCollections.cs +++ b/LightlessSync/Interop/Ipc/Penumbra/PenumbraCollections.cs @@ -1,10 +1,8 @@ -using System.Collections.Concurrent; using Dalamud.Plugin; using LightlessSync.Interop.Ipc.Framework; using LightlessSync.Services; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; -using Penumbra.Api.Enums; using Penumbra.Api.IpcSubscribers; namespace LightlessSync.Interop.Ipc.Penumbra; @@ -16,10 +14,6 @@ public sealed class PenumbraCollections : PenumbraBase private readonly DeleteTemporaryCollection _removeTemporaryCollection; private readonly AddTemporaryMod _addTemporaryMod; private readonly RemoveTemporaryMod _removeTemporaryMod; - private readonly GetCollections _getCollections; - private readonly ConcurrentDictionary _activeTemporaryCollections = new(); - - private int _cleanupScheduled; public PenumbraCollections( ILogger logger, @@ -32,7 +26,6 @@ public sealed class PenumbraCollections : PenumbraBase _removeTemporaryCollection = new DeleteTemporaryCollection(pluginInterface); _addTemporaryMod = new AddTemporaryMod(pluginInterface); _removeTemporaryMod = new RemoveTemporaryMod(pluginInterface); - _getCollections = new GetCollections(pluginInterface); } public override string Name => "Penumbra.Collections"; @@ -62,16 +55,11 @@ public sealed class PenumbraCollections : PenumbraBase var (collectionId, collectionName) = await DalamudUtil.RunOnFrameworkThread(() => { var name = $"Lightless_{uid}"; - _createNamedTemporaryCollection.Invoke(name, name, out var tempCollectionId); - logger.LogTrace("Creating Temp Collection {CollectionName}, GUID: {CollectionId}", name, tempCollectionId); + var createResult = _createNamedTemporaryCollection.Invoke(name, name, out var tempCollectionId); + logger.LogTrace("Creating Temp Collection {CollectionName}, GUID: {CollectionId}, Result: {Result}", name, tempCollectionId, createResult); return (tempCollectionId, name); }).ConfigureAwait(false); - if (collectionId != Guid.Empty) - { - _activeTemporaryCollections[collectionId] = collectionName; - } - return collectionId; } @@ -89,7 +77,6 @@ public sealed class PenumbraCollections : PenumbraBase logger.LogTrace("[{ApplicationId}] RemoveTemporaryCollection: {Result}", applicationId, result); }).ConfigureAwait(false); - _activeTemporaryCollections.TryRemove(collectionId, out _); } public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary modPaths) @@ -131,67 +118,5 @@ public sealed class PenumbraCollections : PenumbraBase protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current) { - if (current == IpcConnectionState.Available) - { - ScheduleCleanup(); - } - else if (previous == IpcConnectionState.Available && current != IpcConnectionState.Available) - { - Interlocked.Exchange(ref _cleanupScheduled, 0); - } } - - private void ScheduleCleanup() - { - if (Interlocked.Exchange(ref _cleanupScheduled, 1) != 0) - { - return; - } - - _ = Task.Run(CleanupTemporaryCollectionsAsync); - } - - private async Task CleanupTemporaryCollectionsAsync() - { - if (!IsAvailable) - { - return; - } - - try - { - var collections = await DalamudUtil.RunOnFrameworkThread(() => _getCollections.Invoke()).ConfigureAwait(false); - foreach (var (collectionId, name) in collections) - { - if (!IsLightlessCollectionName(name) || _activeTemporaryCollections.ContainsKey(collectionId)) - { - continue; - } - - Logger.LogDebug("Cleaning up stale temporary collection {CollectionName} ({CollectionId})", name, collectionId); - var deleteResult = await DalamudUtil.RunOnFrameworkThread(() => - { - var result = (PenumbraApiEc)_removeTemporaryCollection.Invoke(collectionId); - Logger.LogTrace("Cleanup RemoveTemporaryCollection result for {CollectionName} ({CollectionId}): {Result}", name, collectionId, result); - return result; - }).ConfigureAwait(false); - - if (deleteResult == PenumbraApiEc.Success) - { - _activeTemporaryCollections.TryRemove(collectionId, out _); - } - else - { - Logger.LogDebug("Skipped removing temporary collection {CollectionName} ({CollectionId}). Result: {Result}", name, collectionId, deleteResult); - } - } - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Failed to clean up Penumbra temporary collections"); - } - } - - private static bool IsLightlessCollectionName(string? name) - => !string.IsNullOrEmpty(name) && name.StartsWith("Lightless_", StringComparison.Ordinal); } diff --git a/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs b/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs index 19a1e7f..9ca2df0 100644 --- a/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs +++ b/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs @@ -4,6 +4,7 @@ using LightlessSync.PlayerData.Handlers; using LightlessSync.Services; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; +using System.Diagnostics; using System.Globalization; using Penumbra.Api.Helpers; using Penumbra.Api.IpcSubscribers; @@ -42,17 +43,33 @@ public sealed class PenumbraResource : PenumbraBase return null; } - return await DalamudUtil.RunOnFrameworkThread(() => + var requestId = Guid.NewGuid(); + var totalTimer = Stopwatch.StartNew(); + logger.LogTrace("[{requestId}] Requesting Penumbra.GetGameObjectResourcePaths for {handler}", requestId, handler); + + var result = await DalamudUtil.RunOnFrameworkThread(() => { - logger.LogTrace("Calling On IPC: Penumbra.GetGameObjectResourcePaths"); var idx = handler.GetGameObject()?.ObjectIndex; if (idx == null) { + logger.LogTrace("[{requestId}] GetGameObjectResourcePaths aborted (missing object index) for {handler}", requestId, handler); return null; } - return _gameObjectResourcePaths.Invoke(idx.Value)[0]; + logger.LogTrace("[{requestId}] Invoking Penumbra.GetGameObjectResourcePaths for index {index}", requestId, idx.Value); + var invokeTimer = Stopwatch.StartNew(); + var data = _gameObjectResourcePaths.Invoke(idx.Value)[0]; + invokeTimer.Stop(); + logger.LogTrace("[{requestId}] Penumbra.GetGameObjectResourcePaths returned {count} entries in {elapsedMs}ms", + requestId, data?.Count ?? 0, invokeTimer.ElapsedMilliseconds); + return data; }).ConfigureAwait(false); + + totalTimer.Stop(); + logger.LogTrace("[{requestId}] Penumbra.GetGameObjectResourcePaths finished in {elapsedMs}ms (null: {isNull})", + requestId, totalTimer.ElapsedMilliseconds, result is null); + + return result; } public string GetMetaManipulations() diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index 35c1958..0663258 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -161,6 +161,7 @@ public class LightlessConfig : ILightlessConfiguration public string LastSeenVersion { get; set; } = string.Empty; public bool EnableParticleEffects { get; set; } = true; public HashSet OrphanableTempCollections { get; set; } = []; + public List OrphanableTempCollectionEntries { get; set; } = []; public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Safe; public bool AnimationAllowOneBasedShift { get; set; } = true; diff --git a/LightlessSync/LightlessConfiguration/Models/OrphanableTempCollectionEntry.cs b/LightlessSync/LightlessConfiguration/Models/OrphanableTempCollectionEntry.cs new file mode 100644 index 0000000..2288018 --- /dev/null +++ b/LightlessSync/LightlessConfiguration/Models/OrphanableTempCollectionEntry.cs @@ -0,0 +1,7 @@ +namespace LightlessSync.LightlessConfiguration.Models; + +public sealed class OrphanableTempCollectionEntry +{ + public Guid Id { get; set; } + public DateTime RegisteredAtUtc { get; set; } = DateTime.MinValue; +} diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 744e503..51dba8f 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -257,7 +257,28 @@ public class PlayerDataFactory getMoodlesData = _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address); } - var resolvedPaths = await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false) ?? throw new InvalidOperationException("Penumbra returned null data; couldn't proceed with character"); + Guid penumbraRequestId = Guid.Empty; + Stopwatch? penumbraSw = null; + if (logDebug) + { + penumbraRequestId = Guid.NewGuid(); + penumbraSw = Stopwatch.StartNew(); + _logger.LogDebug("Penumbra GetCharacterData start {id} for {obj}", penumbraRequestId, playerRelatedObject); + } + + var resolvedPaths = await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false); + + if (logDebug) + { + penumbraSw!.Stop(); + _logger.LogDebug("Penumbra GetCharacterData done {id} in {elapsedMs}ms (count={count})", + penumbraRequestId, + penumbraSw.ElapsedMilliseconds, + resolvedPaths?.Count ?? -1); + } + + if (resolvedPaths == null) + throw new InvalidOperationException("Penumbra returned null data; couldn't proceed with character"); ct.ThrowIfCancellationRequested(); var staticBuildTask = Task.Run(() => BuildStaticReplacements(resolvedPaths), ct); diff --git a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs index d04cc3b..8375ed3 100644 --- a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs @@ -30,8 +30,6 @@ public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject int MissingCriticalMods { get; } int MissingNonCriticalMods { get; } int MissingForbiddenMods { get; } - DateTime? InvisibleSinceUtc { get; } - DateTime? VisibilityEvictionDueAtUtc { get; } void Initialize(); void ApplyData(CharacterData data); diff --git a/LightlessSync/PlayerData/Pairs/Pair.cs b/LightlessSync/PlayerData/Pairs/Pair.cs index e95b7fe..9d794f8 100644 --- a/LightlessSync/PlayerData/Pairs/Pair.cs +++ b/LightlessSync/PlayerData/Pairs/Pair.cs @@ -217,12 +217,6 @@ public class Pair if (handler is null) return PairDebugInfo.Empty; - var now = DateTime.UtcNow; - var dueAt = handler.VisibilityEvictionDueAtUtc; - var remainingSeconds = dueAt.HasValue - ? Math.Max(0, (dueAt.Value - now).TotalSeconds) - : (double?)null; - return new PairDebugInfo( true, handler.Initialized, @@ -231,9 +225,6 @@ public class Pair handler.LastDataReceivedAt, handler.LastApplyAttemptAt, handler.LastSuccessfulApplyAt, - handler.InvisibleSinceUtc, - handler.VisibilityEvictionDueAtUtc, - remainingSeconds, handler.LastFailureReason, handler.LastBlockingConditions, handler.IsApplying, diff --git a/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs b/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs index 60abf35..820c687 100644 --- a/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs +++ b/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs @@ -8,9 +8,6 @@ public sealed record PairDebugInfo( DateTime? LastDataReceivedAt, DateTime? LastApplyAttemptAt, DateTime? LastSuccessfulApplyAt, - DateTime? InvisibleSinceUtc, - DateTime? VisibilityEvictionDueAtUtc, - double? VisibilityEvictionRemainingSeconds, string? LastFailureReason, IReadOnlyList BlockingConditions, bool IsApplying, @@ -32,9 +29,6 @@ public sealed record PairDebugInfo( null, null, null, - null, - null, - null, Array.Empty(), false, false, diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index c4f3e70..9a3683f 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -24,7 +24,6 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; -using FileReplacementDataComparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer; namespace LightlessSync.PlayerData.Pairs; @@ -66,32 +65,26 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private CombatData? _dataReceivedInDowntime; private CancellationTokenSource? _downloadCancellationTokenSource; private bool _forceApplyMods = false; - private bool _forceFullReapply; - private Dictionary<(string GamePath, string? Hash), string>? _lastAppliedModdedPaths; - private bool _needsCollectionRebuild; private bool _pendingModReapply; private bool _lastModApplyDeferred; private int _lastMissingCriticalMods; private int _lastMissingNonCriticalMods; private int _lastMissingForbiddenMods; - private bool _lastMissingCachedFiles; - private string? _lastSuccessfulDataHash; private bool _isVisible; private Guid _penumbraCollection; private readonly object _collectionGate = new(); + private Task? _penumbraCollectionTask; private bool _redrawOnNextApplication = false; private readonly object _initializationGate = new(); private readonly object _pauseLock = new(); private Task _pauseTransitionTask = Task.CompletedTask; private bool _pauseRequested; + private bool _wasRevertedOnPause; private DateTime? _lastDataReceivedAt; private DateTime? _lastApplyAttemptAt; private DateTime? _lastSuccessfulApplyAt; private string? _lastFailureReason; private IReadOnlyList _lastBlockingConditions = Array.Empty(); - private readonly object _visibilityGraceGate = new(); - private CancellationTokenSource? _visibilityGraceCts; - private static readonly TimeSpan VisibilityEvictionGrace = TimeSpan.FromMinutes(1); private readonly object _ownedRetryGate = new(); private readonly Dictionary> _pendingOwnedChanges = new(); private CancellationTokenSource? _ownedRetryCts; @@ -117,8 +110,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private bool _lastAllowNeighborTolerance; private readonly ConcurrentDictionary _dumpedRemoteSkeletonForHash = new(StringComparer.OrdinalIgnoreCase); - private DateTime? _invisibleSinceUtc; - private DateTime? _visibilityEvictionDueAtUtc; private DateTime _nextActorLookupUtc = DateTime.MinValue; private static readonly TimeSpan ActorLookupInterval = TimeSpan.FromSeconds(1); private static readonly SemaphoreSlim ActorInitializationLimiter = new(1, 1); @@ -132,8 +123,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private ushort _lastKnownObjectIndex = ushort.MaxValue; private string? _lastKnownName; - public DateTime? InvisibleSinceUtc => _invisibleSinceUtc; - public DateTime? VisibilityEvictionDueAtUtc => _visibilityEvictionDueAtUtc; public string Ident { get; } public bool Initialized { get; private set; } public bool ScheduledForDeletion { get; set; } @@ -150,23 +139,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (!_isVisible) { DisableSync(); - - _invisibleSinceUtc = DateTime.UtcNow; - _visibilityEvictionDueAtUtc = _invisibleSinceUtc.Value.Add(VisibilityEvictionGrace); - - StartVisibilityGraceTask(); - } - else - { - CancelVisibilityGraceTask(); - - _invisibleSinceUtc = null; - _visibilityEvictionDueAtUtc = null; - - ScheduledForDeletion = false; - - if (_charaHandler is not null && _charaHandler.Address != nint.Zero) - _ = EnsurePenumbraCollection(); } var user = GetPrimaryUserData(); @@ -246,6 +218,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _tempCollectionJanitor = tempCollectionJanitor; _modelAnalyzer = modelAnalyzer; _configService = configService; + + _ = EnsurePenumbraCollectionAsync(); } public void Initialize() @@ -286,9 +260,9 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _charaHandler?.Invalidate(); IsVisible = false; }); - Mediator.Subscribe(this, _ => + Mediator.Subscribe(this, __ => { - ResetPenumbraCollection(releaseFromPenumbra: false, reason: "PenumbraInitialized"); + _ = EnsurePenumbraCollectionAsync(); if (!IsVisible && _charaHandler is not null) { PlayerName = string.Empty; @@ -297,7 +271,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } EnableSync(); }); - Mediator.Subscribe(this, _ => ResetPenumbraCollection(releaseFromPenumbra: false, reason: "PenumbraDisposed")); Mediator.Subscribe(this, msg => { if (msg.GameObjectHandler == _charaHandler) @@ -324,23 +297,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return; } - if (_pendingModReapply && IsVisible) - { - if (LastReceivedCharacterData is not null) - { - Logger.LogDebug("Downloads finished for {handler}, reapplying pending mod data", GetLogIdentifier()); - ApplyLastReceivedData(forced: true); - return; - } - - if (_cachedData is not null) - { - Logger.LogDebug("Downloads finished for {handler}, reapplying pending mod data from cache", GetLogIdentifier()); - ApplyCharacterData(Guid.NewGuid(), _cachedData, forceApplyCustomization: true); - return; - } - } - TryApplyQueuedData(); }); @@ -429,32 +385,29 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return string.Equals(alias, Ident, StringComparison.Ordinal) ? alias : $"{alias} ({Ident})"; } - private Guid EnsurePenumbraCollection() + private Task EnsurePenumbraCollectionAsync() { - if (!IsVisible) - { - return Guid.Empty; - } - if (_penumbraCollection != Guid.Empty) { - return _penumbraCollection; + return Task.FromResult(_penumbraCollection); } lock (_collectionGate) { if (_penumbraCollection != Guid.Empty) { - return _penumbraCollection; + return Task.FromResult(_penumbraCollection); } - var cached = _pairStateCache.TryGetTemporaryCollection(Ident); - if (cached.HasValue && cached.Value != Guid.Empty) - { - _penumbraCollection = cached.Value; - return _penumbraCollection; - } + _penumbraCollectionTask ??= Task.Run(CreatePenumbraCollectionAsync); + return _penumbraCollectionTask; + } + } + private async Task CreatePenumbraCollectionAsync() + { + try + { if (!_ipcManager.Penumbra.APIAvailable) { return Guid.Empty; @@ -462,16 +415,28 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa var user = GetPrimaryUserDataSafe(); var uid = !string.IsNullOrEmpty(user.UID) ? user.UID : Ident; - var created = _ipcManager.Penumbra.CreateTemporaryCollectionAsync(Logger, uid) - .ConfigureAwait(false).GetAwaiter().GetResult(); - if (created != Guid.Empty) + var collection = await _ipcManager.Penumbra.CreateTemporaryCollectionAsync(Logger, uid).ConfigureAwait(false); + if (collection != Guid.Empty) { - _penumbraCollection = created; - _pairStateCache.StoreTemporaryCollection(Ident, created); - _tempCollectionJanitor.Register(created); + _tempCollectionJanitor.Register(collection); } - return _penumbraCollection; + lock (_collectionGate) + { + if (_penumbraCollection == Guid.Empty && collection != Guid.Empty) + { + _penumbraCollection = collection; + } + } + + return collection; + } + finally + { + lock (_collectionGate) + { + _penumbraCollectionTask = null; + } } } @@ -489,18 +454,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } - var cached = _pairStateCache.ClearTemporaryCollection(Ident); - if (cached.HasValue && cached.Value != Guid.Empty) - { - toRelease = cached.Value; - hadCollection = true; - } - if (hadCollection) { - _needsCollectionRebuild = true; - _forceFullReapply = true; - _forceApplyMods = true; _tempCollectionJanitor.Unregister(toRelease); } @@ -639,43 +594,18 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return; } - var hasMissingCachedFiles = HasMissingCachedFiles(LastReceivedCharacterData); - var missingStarted = !_lastMissingCachedFiles && hasMissingCachedFiles; - var missingResolved = _lastMissingCachedFiles && !hasMissingCachedFiles; - _lastMissingCachedFiles = hasMissingCachedFiles; - var shouldForce = forced || missingStarted || missingResolved; - var forceApplyCustomization = forced; - if (IsPaused()) { Logger.LogTrace("Permissions paused for {Ident}, skipping reapply", Ident); return; } - var sanitized = CloneAndSanitizeLastReceived(out var dataHash); + var sanitized = CloneAndSanitizeLastReceived(out _); if (sanitized is null) { Logger.LogTrace("Sanitized data null for {Ident}", Ident); return; } - var dataApplied = !string.IsNullOrEmpty(dataHash) - && string.Equals(dataHash, _lastSuccessfulDataHash ?? string.Empty, StringComparison.Ordinal); - var needsApply = !dataApplied; - var modFilesChanged = PlayerModFilesChanged(sanitized, _cachedData); - var shouldForceMods = shouldForce || modFilesChanged; - forceApplyCustomization = forced || needsApply; - var suppressForcedModRedraw = !forced && hasMissingCachedFiles && dataApplied; - - if (shouldForceMods) - { - _forceApplyMods = true; - _forceFullReapply = true; - LastAppliedDataBytes = -1; - LastAppliedDataTris = -1; - LastAppliedApproximateEffectiveTris = -1; - LastAppliedApproximateVRAMBytes = -1; - LastAppliedApproximateEffectiveVRAMBytes = -1; - } _pairStateCache.Store(Ident, sanitized); @@ -696,11 +626,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { Logger.LogTrace("Handler for {Ident} not visible, caching sanitized data for later", Ident); _cachedData = sanitized; - _forceFullReapply = true; return; } - ApplyCharacterData(Guid.NewGuid(), sanitized, forceApplyCustomization, suppressForcedModRedraw); + ApplyCharacterData(Guid.NewGuid(), sanitized, forceApplyCustomization: forced); } public bool FetchPerformanceMetricsFromCache() @@ -867,54 +796,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa new PairPerformanceMetrics(LastAppliedDataTris, LastAppliedApproximateVRAMBytes, LastAppliedApproximateEffectiveVRAMBytes, LastAppliedApproximateEffectiveTris)); } - private bool HasMissingCachedFiles(CharacterData characterData) - { - try - { - HashSet inspectedHashes = new(StringComparer.OrdinalIgnoreCase); - foreach (var replacements in characterData.FileReplacements.Values) - { - foreach (var replacement in replacements) - { - if (!string.IsNullOrEmpty(replacement.FileSwapPath)) - { - if (Path.IsPathRooted(replacement.FileSwapPath) && !File.Exists(replacement.FileSwapPath)) - { - Logger.LogTrace("Missing file swap path {Path} detected for {Handler}", replacement.FileSwapPath, GetLogIdentifier()); - return true; - } - continue; - } - - if (string.IsNullOrEmpty(replacement.Hash) || !inspectedHashes.Add(replacement.Hash)) - { - continue; - } - - var cacheEntry = _fileDbManager.GetFileCacheByHash(replacement.Hash); - if (cacheEntry is null) - { - Logger.LogTrace("Missing cached file {Hash} detected for {Handler}", replacement.Hash, GetLogIdentifier()); - return true; - } - - if (!File.Exists(cacheEntry.ResolvedFilepath)) - { - Logger.LogTrace("Cached file {Hash} missing on disk for {Handler}, removing cache entry", replacement.Hash, GetLogIdentifier()); - _fileDbManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath); - return true; - } - } - } - } - catch (Exception ex) - { - Logger.LogDebug(ex, "Failed to determine cache availability for {Handler}", GetLogIdentifier()); - } - - return false; - } - private CharacterData? RemoveNotSyncedFiles(CharacterData? data) { Logger.LogTrace("Removing not synced files for {Ident}", Ident); @@ -967,25 +848,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return data; } - private bool HasValidCachedModdedPaths() - { - if (_lastAppliedModdedPaths is null || _lastAppliedModdedPaths.Count == 0) - { - return false; - } - - foreach (var entry in _lastAppliedModdedPaths) - { - if (string.IsNullOrEmpty(entry.Value) || !File.Exists(entry.Value)) - { - Logger.LogDebug("Cached file path {path} missing for {handler}, forcing recalculation", entry.Value ?? "empty", GetLogIdentifier()); - return false; - } - } - - return true; - } - private bool IsForbiddenHash(string hash) => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, hash, StringComparison.Ordinal)); @@ -1155,31 +1017,23 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa .Any(p => p.Value.Contains(PlayerChanges.ModManip) || p.Value.Contains(PlayerChanges.ModFiles)); _forceApplyMods = hasDiffMods || _forceApplyMods || _cachedData == null; _cachedData = characterData; - _forceFullReapply = true; Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods); + return; } - var forceModsForMissing = _pendingModReapply; - if (!forceModsForMissing && HasMissingCachedFiles(characterData)) - { - forceModsForMissing = true; - } - - if (forceModsForMissing) - { - _forceApplyMods = true; - } - - var suppressForcedModRedrawOnForcedApply = suppressForcedModRedraw || forceModsForMissing; - SetUploading(false); + _pendingModReapply = false; + _lastModApplyDeferred = false; + _lastMissingCriticalMods = 0; + _lastMissingNonCriticalMods = 0; + _lastMissingForbiddenMods = 0; + Logger.LogDebug("[BASE-{appbase}] Applying data for {player}, forceApplyCustomization: {forced}, forceApplyMods: {forceMods}", applicationBase, GetLogIdentifier(), forceApplyCustomization, _forceApplyMods); Logger.LogDebug("[BASE-{appbase}] Hash for data is {newHash}, current cache hash is {oldHash}", applicationBase, characterData.DataHash.Value, _cachedData?.DataHash.Value ?? "NODATA"); - if (handlerReady - && string.Equals(characterData.DataHash.Value, _cachedData?.DataHash.Value ?? string.Empty, StringComparison.Ordinal) - && !forceApplyCustomization && !_forceApplyMods) + if (string.Equals(characterData.DataHash.Value, _cachedData?.DataHash.Value ?? string.Empty, StringComparison.Ordinal) + && !forceApplyCustomization) { return; } @@ -1187,8 +1041,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational, "Applying Character Data"))); + _forceApplyMods |= forceApplyCustomization; + var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this, - forceApplyCustomization, _forceApplyMods, suppressForcedModRedrawOnForcedApply); + forceApplyCustomization, _forceApplyMods, suppressForcedModRedraw); if (handlerReady && _forceApplyMods) { @@ -1207,11 +1063,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } Logger.LogDebug("[BASE-{appbase}] Downloading and applying character for {name}", applicationBase, GetPrimaryAliasOrUidSafe()); - - var forceFullReapply = _forceFullReapply - || LastAppliedApproximateVRAMBytes < 0 || LastAppliedDataTris < 0 || LastAppliedApproximateEffectiveTris < 0; - - DownloadAndApplyCharacter(applicationBase, characterData.DeepClone(), charaDataToUpdate, forceFullReapply); + DownloadAndApplyCharacter(applicationBase, characterData.DeepClone(), charaDataToUpdate); } public override string ToString() @@ -1245,46 +1097,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } - private void CancelVisibilityGraceTask() - { - lock (_visibilityGraceGate) - { - _visibilityGraceCts?.CancelDispose(); - _visibilityGraceCts = null; - } - } - - private void StartVisibilityGraceTask() - { - CancellationToken token; - lock (_visibilityGraceGate) - { - _visibilityGraceCts = _visibilityGraceCts?.CancelRecreate() ?? new CancellationTokenSource(); - token = _visibilityGraceCts.Token; - } - - _visibilityGraceTask = Task.Run(async () => - { - try - { - await Task.Delay(VisibilityEvictionGrace, token).ConfigureAwait(false); - token.ThrowIfCancellationRequested(); - if (IsVisible) return; - - ScheduledForDeletion = true; - ResetPenumbraCollection(reason: "VisibilityLostTimeout"); - } - catch (OperationCanceledException) - { - // operation cancelled, do nothing - } - catch (Exception ex) - { - Logger.LogDebug(ex, "Visibility grace task failed for {handler}", GetLogIdentifier()); - } - }, CancellationToken.None); - } - private void ScheduleOwnedObjectRetry(ObjectKind kind, HashSet changes) { if (kind == ObjectKind.Player || changes.Count == 0) @@ -1477,10 +1289,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _ownedRetryCts = null; _downloadManager.Dispose(); _charaHandler?.Dispose(); - CancelVisibilityGraceTask(); _charaHandler = null; - _invisibleSinceUtc = null; - _visibilityEvictionDueAtUtc = null; if (!string.IsNullOrEmpty(name)) { @@ -1496,7 +1305,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa var isStopping = _lifetime.ApplicationStopping.IsCancellationRequested; if (isStopping) { - ResetPenumbraCollection(reason: "DisposeStopping", awaitIpc: false); ScheduleSafeRevertOnDisposal(applicationId, name, alias); return; } @@ -1555,9 +1363,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { PlayerName = null; _cachedData = null; - _lastSuccessfulDataHash = null; - _lastAppliedModdedPaths = null; - _needsCollectionRebuild = false; _performanceMetricsCache.Clear(Ident); Logger.LogDebug("Disposing {name} complete", name); } @@ -1878,87 +1683,27 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return result; } - private static bool PlayerModFilesChanged(CharacterData newData, CharacterData? previousData) - { - return !FileReplacementListsEqual( - TryGetFileReplacementList(newData, ObjectKind.Player), - TryGetFileReplacementList(previousData, ObjectKind.Player)); - } - - private static IReadOnlyCollection? TryGetFileReplacementList(CharacterData? data, ObjectKind objectKind) - { - if (data is null) - { - return null; - } - - return data.FileReplacements.TryGetValue(objectKind, out var list) ? list : null; - } - - private static bool FileReplacementListsEqual(IReadOnlyCollection? left, IReadOnlyCollection? right) - { - if (left is null || left.Count == 0) - { - return right is null || right.Count == 0; - } - - if (right is null || right.Count == 0) - { - return false; - } - - var comparer = FileReplacementDataComparer.Instance; - return !left.Except(right, comparer).Any() && !right.Except(left, comparer).Any(); - } - - private void DownloadAndApplyCharacter(Guid applicationBase, CharacterData charaData, Dictionary> updatedData, bool forceFullReapply) + private void DownloadAndApplyCharacter(Guid applicationBase, CharacterData charaData, Dictionary> updatedData) { if (!updatedData.Any()) { - if (forceFullReapply) - { - updatedData = BuildFullChangeSet(charaData); - } - - if (!updatedData.Any()) - { - Logger.LogDebug("[BASE-{appBase}] Nothing to update for {obj}", applicationBase, GetLogIdentifier()); - _forceFullReapply = false; - return; - } + Logger.LogDebug("[BASE-{appBase}] Nothing to update for {obj}", applicationBase, GetLogIdentifier()); + return; } var updateModdedPaths = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModFiles)); var updateManip = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModManip)); - var needsCollectionRebuild = _needsCollectionRebuild; - var reuseCachedModdedPaths = !updateModdedPaths && needsCollectionRebuild && _lastAppliedModdedPaths is not null; - updateModdedPaths = updateModdedPaths || needsCollectionRebuild; - updateManip = updateManip || needsCollectionRebuild; - Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths = null; - if (reuseCachedModdedPaths) - { - if (HasValidCachedModdedPaths()) - { - cachedModdedPaths = _lastAppliedModdedPaths; - } - else - { - Logger.LogDebug("{handler}: Cached files missing, recalculating mappings", GetLogIdentifier()); - _lastAppliedModdedPaths = null; - } - } _downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource(); var downloadToken = _downloadCancellationTokenSource.Token; - _ = DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, cachedModdedPaths, downloadToken) + _ = DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, downloadToken) .ConfigureAwait(false); } private Task? _pairDownloadTask; - private Task _visibilityGraceTask; private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary> updatedData, - bool updateModdedPaths, bool updateManip, Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths, CancellationToken downloadToken) + bool updateModdedPaths, bool updateManip, CancellationToken downloadToken) { var concurrencyLease = await _pairProcessingLimiter.AcquireAsync(downloadToken).ConfigureAwait(false); try @@ -1966,154 +1711,96 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa bool skipDownscaleForPair = ShouldSkipDownscale(); bool skipDecimationForPair = ShouldSkipDecimation(); var user = GetPrimaryUserData(); - Dictionary<(string GamePath, string? Hash), string> moddedPaths; - List missingReplacements = []; + Dictionary<(string GamePath, string? Hash), string> moddedPaths = []; if (updateModdedPaths) { - if (cachedModdedPaths is not null) - { - moddedPaths = new Dictionary<(string GamePath, string? Hash), string>(cachedModdedPaths, cachedModdedPaths.Comparer); - } - else - { - int attempts = 0; - List toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); - missingReplacements = toDownloadReplacements; + int attempts = 0; + List toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); - while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested) + while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested) + { + if (_pairDownloadTask != null && !_pairDownloadTask.IsCompleted) { - if (_pairDownloadTask != null && !_pairDownloadTask.IsCompleted) - { - Logger.LogDebug("[BASE-{appBase}] Finishing prior running download task for player {name}, {kind}", applicationBase, PlayerName, updatedData); - await _pairDownloadTask.ConfigureAwait(false); - } - - Logger.LogDebug("[BASE-{appBase}] Downloading missing files for player {name}, {kind}", applicationBase, PlayerName, updatedData); - - Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational, - $"Starting download for {toDownloadReplacements.Count} files"))); - var toDownloadFiles = await _downloadManager.InitiateDownloadList(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false); - - if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, toDownloadFiles)) - { - RecordFailure("Auto pause triggered by VRAM usage thresholds", "VRAMThreshold"); - _downloadManager.ClearDownload(); - return; - } - - var handlerForDownload = _charaHandler; - _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, toDownloadFiles, downloadToken, skipDownscaleForPair, skipDecimationForPair).ConfigureAwait(false)); - + Logger.LogDebug("[BASE-{appBase}] Finishing prior running download task for player {name}, {kind}", applicationBase, PlayerName, updatedData); await _pairDownloadTask.ConfigureAwait(false); - - if (downloadToken.IsCancellationRequested) - { - Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase); - RecordFailure("Download cancelled", "Cancellation"); - return; - } - - if (!skipDownscaleForPair) - { - var downloadedTextureHashes = toDownloadReplacements - .Where(static replacement => replacement.GamePaths.Any(static path => path.EndsWith(".tex", StringComparison.OrdinalIgnoreCase))) - .Select(static replacement => replacement.Hash) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (downloadedTextureHashes.Count > 0) - { - await _textureDownscaleService.WaitForPendingJobsAsync(downloadedTextureHashes, downloadToken).ConfigureAwait(false); - } - } - - if (!skipDecimationForPair) - { - var downloadedModelHashes = toDownloadReplacements - .Where(static replacement => replacement.GamePaths.Any(static path => path.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase))) - .Select(static replacement => replacement.Hash) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (downloadedModelHashes.Count > 0) - { - await _modelDecimationService.WaitForPendingJobsAsync(downloadedModelHashes, downloadToken).ConfigureAwait(false); - } - } - - toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); - missingReplacements = toDownloadReplacements; - - if (toDownloadReplacements.TrueForAll(c => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, c.Hash, StringComparison.Ordinal)))) - { - break; - } - - await Task.Delay(TimeSpan.FromSeconds(2), downloadToken).ConfigureAwait(false); } - if (!await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false)) + Logger.LogDebug("[BASE-{appBase}] Downloading missing files for player {name}, {kind}", applicationBase, PlayerName, updatedData); + + Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational, + $"Starting download for {toDownloadReplacements.Count} files"))); + var toDownloadFiles = await _downloadManager.InitiateDownloadList(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false); + + if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, toDownloadFiles)) { - RecordFailure("Auto pause triggered by performance thresholds", "PerformanceThreshold"); + RecordFailure("Auto pause triggered by VRAM usage thresholds", "VRAMThreshold"); + _downloadManager.ClearDownload(); return; } + + var handlerForDownload = _charaHandler; + _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, toDownloadFiles, downloadToken, skipDownscaleForPair, skipDecimationForPair).ConfigureAwait(false)); + + await _pairDownloadTask.ConfigureAwait(false); + + if (downloadToken.IsCancellationRequested) + { + Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase); + RecordFailure("Download cancelled", "Cancellation"); + return; + } + + if (!skipDownscaleForPair) + { + var downloadedTextureHashes = toDownloadReplacements + .Where(static replacement => replacement.GamePaths.Any(static path => path.EndsWith(".tex", StringComparison.OrdinalIgnoreCase))) + .Select(static replacement => replacement.Hash) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (downloadedTextureHashes.Count > 0) + { + await _textureDownscaleService.WaitForPendingJobsAsync(downloadedTextureHashes, downloadToken).ConfigureAwait(false); + } + } + + if (!skipDecimationForPair) + { + var downloadedModelHashes = toDownloadReplacements + .Where(static replacement => replacement.GamePaths.Any(static path => path.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase))) + .Select(static replacement => replacement.Hash) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (downloadedModelHashes.Count > 0) + { + await _modelDecimationService.WaitForPendingJobsAsync(downloadedModelHashes, downloadToken).ConfigureAwait(false); + } + } + + toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); + + if (toDownloadReplacements.TrueForAll(c => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, c.Hash, StringComparison.Ordinal)))) + { + break; + } + + await Task.Delay(TimeSpan.FromSeconds(2), downloadToken).ConfigureAwait(false); } - } - else - { - moddedPaths = cachedModdedPaths is not null - ? new Dictionary<(string GamePath, string? Hash), string>(cachedModdedPaths, cachedModdedPaths.Comparer) - : []; - } - var wantsModApply = updateModdedPaths || updateManip; - var pendingModReapply = false; - var deferModApply = false; - - if (wantsModApply && missingReplacements.Count > 0) - { - CountMissingReplacements(missingReplacements, out var missingCritical, out var missingNonCritical, out var missingForbidden); - _lastMissingCriticalMods = missingCritical; - _lastMissingNonCriticalMods = missingNonCritical; - _lastMissingForbiddenMods = missingForbidden; - - var hasCriticalMissing = missingCritical > 0; - var hasNonCriticalMissing = missingNonCritical > 0; - var hasDownloadableMissing = missingReplacements.Any(replacement => !IsForbiddenHash(replacement.Hash)); - var hasDownloadableCriticalMissing = hasCriticalMissing - && missingReplacements.Any(replacement => !IsForbiddenHash(replacement.Hash) && IsCriticalModReplacement(replacement)); - - pendingModReapply = hasDownloadableMissing; - _lastModApplyDeferred = false; - - if (hasDownloadableCriticalMissing) + if (!await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false)) { - deferModApply = true; - _lastModApplyDeferred = true; - Logger.LogDebug("[BASE-{appBase}] Critical mod files missing for {handler}, deferring mod apply ({count} missing)", - applicationBase, GetLogIdentifier(), missingReplacements.Count); + RecordFailure("Auto pause triggered by performance thresholds", "PerformanceThreshold"); + return; } - else if (hasNonCriticalMissing && hasDownloadableMissing) - { - Logger.LogDebug("[BASE-{appBase}] Non-critical mod files missing for {handler}, applying partial mods and reapplying after downloads ({count} missing)", - applicationBase, GetLogIdentifier(), missingReplacements.Count); - } - } - else - { - _lastMissingCriticalMods = 0; - _lastMissingNonCriticalMods = 0; - _lastMissingForbiddenMods = 0; - _lastModApplyDeferred = false; } - if (deferModApply) - { - updateModdedPaths = false; - updateManip = false; - RemoveModApplyChanges(updatedData); - } + _pendingModReapply = false; + _lastMissingCriticalMods = 0; + _lastMissingNonCriticalMods = 0; + _lastMissingForbiddenMods = 0; + _lastModApplyDeferred = false; downloadToken.ThrowIfCancellationRequested(); @@ -2123,7 +1810,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger.LogDebug("[BASE-{appBase}] Handler not available for {player}, cached data for later application", applicationBase, GetLogIdentifier()); _cachedData = charaData; _pairStateCache.Store(Ident, charaData); - _forceFullReapply = true; RecordFailure("Handler not available for application", "HandlerUnavailable"); return; } @@ -2140,7 +1826,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (downloadToken.IsCancellationRequested || (appToken?.IsCancellationRequested ?? false)) { - _forceFullReapply = true; RecordFailure("Application cancelled", "Cancellation"); return; } @@ -2148,7 +1833,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource(); var token = _applicationCancellationTokenSource.Token; - _applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, wantsModApply, pendingModReapply, token); + _applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, token); } finally { @@ -2157,7 +1842,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } private async Task ApplyCharacterDataAsync(Guid applicationBase, GameObjectHandler handlerForApply, CharacterData charaData, Dictionary> updatedData, bool updateModdedPaths, bool updateManip, - Dictionary<(string GamePath, string? Hash), string> moddedPaths, bool wantsModApply, bool pendingModReapply, CancellationToken token) + Dictionary<(string GamePath, string? Hash), string> moddedPaths, CancellationToken token) { try { @@ -2175,7 +1860,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa applicationBase, GetLogIdentifier()); _cachedData = charaData; _pairStateCache.Store(Ident, charaData); - _forceFullReapply = true; RecordFailure("Actor not fully loaded within timeout", "FullyLoadedTimeout"); return; } @@ -2186,13 +1870,12 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Guid penumbraCollection = Guid.Empty; if (updateModdedPaths || updateManip) { - penumbraCollection = EnsurePenumbraCollection(); + penumbraCollection = await EnsurePenumbraCollectionAsync().ConfigureAwait(false); if (penumbraCollection == Guid.Empty) { Logger.LogTrace("[BASE-{applicationId}] Penumbra collection unavailable for {handler}, caching data for later application", applicationBase, GetLogIdentifier()); _cachedData = charaData; _pairStateCache.Store(Ident, charaData); - _forceFullReapply = true; RecordFailure("Penumbra collection unavailable", "PenumbraUnavailable"); return; } @@ -2200,7 +1883,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (updateModdedPaths) { - // ensure collection is set var objIndex = await _dalamudUtil.RunOnFrameworkThread(() => { var gameObject = handlerForApply.GetGameObject(); @@ -2212,25 +1894,20 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger.LogDebug("[BASE-{applicationId}] GameObject not available for {handler}, caching data for later application", applicationBase, GetLogIdentifier()); _cachedData = charaData; _pairStateCache.Store(Ident, charaData); - _forceFullReapply = true; RecordFailure("Game object not available for application", "GameObjectUnavailable"); return; } + await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value).ConfigureAwait(false); + SplitPapMappings(moddedPaths, out var withoutPap, out var papOnly); var hasPap = papOnly.Count > 0; - await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value).ConfigureAwait(false); - await _ipcManager.Penumbra.SetTemporaryModsAsync( Logger, _applicationId, penumbraCollection, withoutPap.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)) .ConfigureAwait(false); - await _ipcManager.Penumbra.RedrawAsync(Logger, handlerForApply, _applicationId, token).ConfigureAwait(false); - if (handlerForApply.Address != nint.Zero) - await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token).ConfigureAwait(false); - if (hasPap) { var removedPap = await StripIncompatiblePapAsync(handlerForApply, charaData, papOnly, token).ConfigureAwait(false); @@ -2247,12 +1924,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger, _applicationId, penumbraCollection, merged.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)) .ConfigureAwait(false); - - _lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(merged, merged.Comparer); - } - else - { - _lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(withoutPap, withoutPap.Comparer); } LastAppliedDataBytes = -1; @@ -2287,12 +1958,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _cachedData = charaData; _pairStateCache.Store(Ident, charaData); - if (wantsModApply) - { - _pendingModReapply = pendingModReapply; - } - _forceFullReapply = _pendingModReapply; - _needsCollectionRebuild = false; if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0) { _playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List()); @@ -2304,7 +1969,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } StorePerformanceMetrics(charaData); - _lastSuccessfulDataHash = GetDataHashSafe(charaData); _lastSuccessfulApplyAt = DateTime.UtcNow; ClearFailureState(); Logger.LogDebug("[{applicationId}] Application finished", _applicationId); @@ -2314,7 +1978,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger.LogDebug("[{applicationId}] Application cancelled for {handler}", _applicationId, GetLogIdentifier()); _cachedData = charaData; _pairStateCache.Store(Ident, charaData); - _forceFullReapply = true; RecordFailure("Application cancelled", "Cancellation"); } catch (Exception ex) @@ -2325,13 +1988,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _forceApplyMods = true; _cachedData = charaData; _pairStateCache.Store(Ident, charaData); - _forceFullReapply = true; Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId); } else { Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId); - _forceFullReapply = true; } RecordFailure($"Application failed: {ex.Message}", "Exception"); } @@ -2375,7 +2036,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { try { - _forceFullReapply = true; ApplyCharacterData(appData, cachedData!, forceApplyCustomization: true); } catch (Exception ex) @@ -2392,7 +2052,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { try { - _forceFullReapply = true; ApplyLastReceivedData(forced: true); } catch (Exception ex) @@ -2456,6 +2115,29 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _ = ReapplyPetNamesAsync(petNamesData!); }); + + var handlerForAssign = _charaHandler; + _ = Task.Run(async () => + { + if (handlerForAssign is null) + { + return; + } + + var penumbraCollection = await EnsurePenumbraCollectionAsync().ConfigureAwait(false); + if (penumbraCollection == Guid.Empty) + { + return; + } + + var objIndex = await _dalamudUtil.RunOnFrameworkThread(() => handlerForAssign.GetGameObject()?.ObjectIndex) + .ConfigureAwait(false); + if (objIndex.HasValue) + { + await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value) + .ConfigureAwait(false); + } + }); } private async Task ReapplyHonorificAsync(string honorificData) @@ -2652,6 +2334,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { Logger.LogDebug("Pausing handler {handler}", GetLogIdentifier()); DisableSync(); + _wasRevertedOnPause = false; if (_charaHandler is null || _charaHandler.Address == nint.Zero) { @@ -2660,7 +2343,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } var applicationId = Guid.NewGuid(); - await RevertToRestoredAsync(applicationId).ConfigureAwait(false); + _wasRevertedOnPause = await RevertToRestoredAsync(applicationId).ConfigureAwait(false); IsVisible = false; } catch (Exception ex) @@ -2684,9 +2367,12 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa IsVisible = true; } + var forceApply = _wasRevertedOnPause; + _wasRevertedOnPause = false; + if (LastReceivedCharacterData is not null) { - ApplyLastReceivedData(forced: true); + ApplyLastReceivedData(forced: forceApply); } } catch (Exception ex) @@ -2695,29 +2381,31 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } - private async Task RevertToRestoredAsync(Guid applicationId) + private async Task RevertToRestoredAsync(Guid applicationId) { if (_charaHandler is null || _charaHandler.Address == nint.Zero) { - return; + return false; } try { + var reverted = false; var gameObject = await _dalamudUtil.RunOnFrameworkThread(() => _charaHandler.GetGameObject()).ConfigureAwait(false); if (gameObject is not Dalamud.Game.ClientState.Objects.Types.ICharacter character) { - return; + return false; } if (_ipcManager.Penumbra.APIAvailable) { - var penumbraCollection = EnsurePenumbraCollection(); + var penumbraCollection = await EnsurePenumbraCollectionAsync().ConfigureAwait(false); if (penumbraCollection != Guid.Empty) { await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, character.ObjectIndex).ConfigureAwait(false); await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, applicationId, penumbraCollection, new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); await _ipcManager.Penumbra.SetManipulationDataAsync(Logger, applicationId, penumbraCollection, string.Empty).ConfigureAwait(false); + reverted = true; } } @@ -2740,25 +2428,23 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (string.IsNullOrEmpty(characterName)) { Logger.LogWarning("[{applicationId}] Failed to determine character name for {handler} while reverting", applicationId, GetLogIdentifier()); - return; + return reverted; } foreach (var kind in kinds) { await RevertCustomizationDataAsync(kind, characterName, applicationId, CancellationToken.None).ConfigureAwait(false); + reverted = true; } - _cachedData = null; - LastAppliedDataBytes = -1; - LastAppliedDataTris = -1; - LastAppliedApproximateEffectiveTris = -1; - LastAppliedApproximateVRAMBytes = -1; - LastAppliedApproximateEffectiveVRAMBytes = -1; + return reverted; } catch (Exception ex) { Logger.LogWarning(ex, "Failed to revert handler {handler} during pause", GetLogIdentifier()); } + + return false; } private void DisableSync() diff --git a/LightlessSync/Services/PenumbraTempCollectionJanitor.cs b/LightlessSync/Services/PenumbraTempCollectionJanitor.cs index 03fb53b..87d37ac 100644 --- a/LightlessSync/Services/PenumbraTempCollectionJanitor.cs +++ b/LightlessSync/Services/PenumbraTempCollectionJanitor.cs @@ -1,4 +1,6 @@ -using LightlessSync.Interop.Ipc; +using System.Linq; +using LightlessSync.Interop.Ipc; +using LightlessSync.LightlessConfiguration.Models; using LightlessSync.LightlessConfiguration; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; @@ -10,6 +12,7 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber private readonly IpcManager _ipc; private readonly LightlessConfigService _config; private int _ran; + private static readonly TimeSpan OrphanCleanupDelay = TimeSpan.FromDays(1); public PenumbraTempCollectionJanitor( ILogger logger, @@ -26,15 +29,46 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber public void Register(Guid id) { if (id == Guid.Empty) return; - if (_config.Current.OrphanableTempCollections.Add(id)) + var changed = false; + var config = _config.Current; + if (config.OrphanableTempCollections.Add(id)) + { + changed = true; + } + + var now = DateTime.UtcNow; + var existing = config.OrphanableTempCollectionEntries.FirstOrDefault(entry => entry.Id == id); + if (existing is null) + { + config.OrphanableTempCollectionEntries.Add(new OrphanableTempCollectionEntry + { + Id = id, + RegisteredAtUtc = now + }); + changed = true; + } + else if (existing.RegisteredAtUtc == DateTime.MinValue) + { + existing.RegisteredAtUtc = now; + changed = true; + } + + if (changed) + { _config.Save(); + } } public void Unregister(Guid id) { if (id == Guid.Empty) return; - if (_config.Current.OrphanableTempCollections.Remove(id)) + var config = _config.Current; + var changed = config.OrphanableTempCollections.Remove(id); + changed |= RemoveEntry(config.OrphanableTempCollectionEntries, id) > 0; + if (changed) + { _config.Save(); + } } private void CleanupOrphansOnBoot() @@ -45,14 +79,33 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber if (!_ipc.Penumbra.APIAvailable) return; - var ids = _config.Current.OrphanableTempCollections.ToArray(); - if (ids.Length == 0) + var config = _config.Current; + var ids = config.OrphanableTempCollections; + var entries = config.OrphanableTempCollectionEntries; + if (ids.Count == 0 && entries.Count == 0) return; - var appId = Guid.NewGuid(); - Logger.LogInformation("Cleaning up {count} orphaned Lightless temp collections found in configuration", ids.Length); + var now = DateTime.UtcNow; + var changed = EnsureEntries(ids, entries, now); + var cutoff = now - OrphanCleanupDelay; + var expired = entries + .Where(entry => entry.Id != Guid.Empty && entry.RegisteredAtUtc != DateTime.MinValue && entry.RegisteredAtUtc <= cutoff) + .Select(entry => entry.Id) + .Distinct() + .ToList(); + if (expired.Count == 0) + { + if (changed) + { + _config.Save(); + } + return; + } - foreach (var id in ids) + var appId = Guid.NewGuid(); + Logger.LogInformation("Cleaning up {count} orphaned Lightless temp collections older than {delay}", expired.Count, OrphanCleanupDelay); + + foreach (var id in expired) { try { @@ -65,7 +118,70 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber } } - _config.Current.OrphanableTempCollections.Clear(); + foreach (var id in expired) + { + ids.Remove(id); + } + + foreach (var id in expired) + { + RemoveEntry(entries, id); + } + _config.Save(); } -} \ No newline at end of file + + private static int RemoveEntry(List entries, Guid id) + { + var removed = 0; + for (var i = entries.Count - 1; i >= 0; i--) + { + if (entries[i].Id != id) + { + continue; + } + + entries.RemoveAt(i); + removed++; + } + + return removed; + } + + private static bool EnsureEntries(HashSet ids, List entries, DateTime now) + { + var changed = false; + foreach (var id in ids) + { + if (id == Guid.Empty) + { + continue; + } + + if (entries.Any(entry => entry.Id == id)) + { + continue; + } + + entries.Add(new OrphanableTempCollectionEntry + { + Id = id, + RegisteredAtUtc = now + }); + changed = true; + } + + foreach (var entry in entries) + { + if (entry.Id == Guid.Empty || entry.RegisteredAtUtc != DateTime.MinValue) + { + continue; + } + + entry.RegisteredAtUtc = now; + changed = true; + } + + return changed; + } +} diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index bc31556..d1bf52e 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -1485,8 +1485,6 @@ public class SettingsUi : WindowMediatorSubscriberBase DrawPairPropertyRow("Has Handler", FormatBool(debugInfo.HasHandler)); DrawPairPropertyRow("Handler Initialized", FormatBool(debugInfo.HandlerInitialized)); DrawPairPropertyRow("Handler Visible", FormatBool(debugInfo.HandlerVisible)); - DrawPairPropertyRow("Last Time person rendered in", FormatTimestamp(debugInfo.InvisibleSinceUtc)); - DrawPairPropertyRow("Handler Timer Temp Collection removal", FormatCountdown(debugInfo.VisibilityEvictionRemainingSeconds)); DrawPairPropertyRow("Handler Scheduled For Deletion", FormatBool(debugInfo.HandlerScheduledForDeletion)); DrawPairPropertyRow("Note", pair.GetNote() ?? "(none)"); @@ -1622,8 +1620,6 @@ public class SettingsUi : WindowMediatorSubscriberBase sb.AppendLine($"Has Handler: {FormatBool(debugInfo.HasHandler)}"); sb.AppendLine($"Handler Initialized: {FormatBool(debugInfo.HandlerInitialized)}"); sb.AppendLine($"Handler Visible: {FormatBool(debugInfo.HandlerVisible)}"); - sb.AppendLine($"Last Time person rendered in: {FormatTimestamp(debugInfo.InvisibleSinceUtc)}"); - sb.AppendLine($"Handler Timer Temp Collection removal: {FormatCountdown(debugInfo.VisibilityEvictionRemainingSeconds)}"); sb.AppendLine($"Handler Scheduled For Deletion: {FormatBool(debugInfo.HandlerScheduledForDeletion)}"); sb.AppendLine($"Note: {pair.GetNote() ?? "(none)"}");