diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index 7b3708f..398d96c 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -60,6 +60,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private Guid _applicationId; private Task? _applicationTask; private CharacterData? _cachedData = null; + private CharacterData? _lastAppliedData = null; private GameObjectHandler? _charaHandler; private readonly Dictionary _customizeIds = []; private CombatData? _dataReceivedInDowntime; @@ -256,6 +257,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } Mediator.Subscribe(this, _ => { + LogDownloadCancellation("zone switch start"); _downloadCancellationTokenSource?.CancelDispose(); _charaHandler?.Invalidate(); IsVisible = false; @@ -385,6 +387,42 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return string.Equals(alias, Ident, StringComparison.Ordinal) ? alias : $"{alias} ({Ident})"; } + private void LogDownloadCancellation(string reason, Guid? applicationBase = null) + { + if (_downloadCancellationTokenSource is null) + { + return; + } + + var inFlight = _pairDownloadTask is { IsCompleted: false }; + if (inFlight) + { + if (applicationBase.HasValue) + { + Logger.LogDebug("[BASE-{appBase}] {handler}: Cancelling in-flight download ({reason})", + applicationBase.Value, GetLogIdentifier(), reason); + } + else + { + Logger.LogDebug("{handler}: Cancelling in-flight download ({reason})", + GetLogIdentifier(), reason); + } + } + else + { + if (applicationBase.HasValue) + { + Logger.LogDebug("[BASE-{appBase}] {handler}: Cancelling download token ({reason}, in-flight={inFlight})", + applicationBase.Value, GetLogIdentifier(), reason, inFlight); + } + else + { + Logger.LogDebug("{handler}: Cancelling download token ({reason}, in-flight={inFlight})", + GetLogIdentifier(), reason, inFlight); + } + } + } + private Task EnsurePenumbraCollectionAsync() { if (_penumbraCollection != Guid.Empty) @@ -851,6 +889,40 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private bool IsForbiddenHash(string hash) => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, hash, StringComparison.Ordinal)); + private bool HasMissingFiles(CharacterData data) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var replacement in data.FileReplacements.SelectMany(k => k.Value)) + { + if (!string.IsNullOrEmpty(replacement.FileSwapPath)) + { + continue; + } + + var hash = replacement.Hash; + if (string.IsNullOrWhiteSpace(hash) || !seen.Add(hash)) + { + continue; + } + + var fileCache = _fileDbManager.GetFileCacheByHash(hash); + if (fileCache is null || !File.Exists(fileCache.ResolvedFilepath)) + { + if (fileCache is not null) + { + _fileDbManager.RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath); + } + + if (!IsForbiddenHash(hash)) + { + return true; + } + } + } + + return false; + } + private static bool IsNonPriorityModPath(string? gamePath) { if (string.IsNullOrEmpty(gamePath)) @@ -1012,10 +1084,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa "Cannot apply character data: Receiving Player is in an invalid state, deferring application"))); Logger.LogDebug("[BASE-{appBase}] Received data but player was in invalid state, charaHandlerIsNull: {charaIsNull}, playerPointerIsNull: {ptrIsNull}", applicationBase, _charaHandler == null, PlayerCharacter == IntPtr.Zero); - var hasDiffMods = characterData.CheckUpdatedData(applicationBase, _cachedData, Logger, + var hasDiffMods = characterData.CheckUpdatedData(applicationBase, _lastAppliedData, Logger, this, forceApplyCustomization, forceApplyMods: false) .Any(p => p.Value.Contains(PlayerChanges.ModManip) || p.Value.Contains(PlayerChanges.ModFiles)); - _forceApplyMods = hasDiffMods || _forceApplyMods || _cachedData == null; + _forceApplyMods = hasDiffMods || _forceApplyMods || _lastAppliedData == null; + _pendingModReapply = true; _cachedData = characterData; Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods); return; @@ -1023,27 +1096,34 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa SetUploading(false); + 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}, last applied hash is {oldHash}", applicationBase, characterData.DataHash.Value, _lastAppliedData?.DataHash.Value ?? "NODATA"); + + var hasMissingFiles = false; + if (string.Equals(characterData.DataHash.Value, _lastAppliedData?.DataHash.Value ?? string.Empty, StringComparison.Ordinal) + && !forceApplyCustomization + && !_forceApplyMods + && !_pendingModReapply) + { + hasMissingFiles = HasMissingFiles(characterData); + if (!hasMissingFiles) + { + return; + } + } + _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 (string.Equals(characterData.DataHash.Value, _cachedData?.DataHash.Value ?? string.Empty, StringComparison.Ordinal) - && !forceApplyCustomization) - { - return; - } - Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational, "Applying Character Data"))); - _forceApplyMods |= forceApplyCustomization; + _forceApplyMods |= forceApplyCustomization || hasMissingFiles; - var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this, + var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _lastAppliedData?.DeepClone() ?? new(), Logger, this, forceApplyCustomization, _forceApplyMods, suppressForcedModRedraw); if (handlerReady && _forceApplyMods) @@ -1282,6 +1362,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Guid applicationId = Guid.NewGuid(); _applicationCancellationTokenSource?.CancelDispose(); _applicationCancellationTokenSource = null; + LogDownloadCancellation("dispose"); _downloadCancellationTokenSource?.CancelDispose(); _downloadCancellationTokenSource = null; ClearAllOwnedObjectRetries(); @@ -1363,6 +1444,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { PlayerName = null; _cachedData = null; + _lastAppliedData = null; LastReceivedCharacterData = null; _performanceMetricsCache.Clear(Ident); Logger.LogDebug("Disposing {name} complete", name); @@ -1695,6 +1777,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa var updateModdedPaths = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModFiles)); var updateManip = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModManip)); + LogDownloadCancellation("new download request", applicationBase); _downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource(); var downloadToken = _downloadCancellationTokenSource.Token; _ = DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, downloadToken) @@ -1741,13 +1824,14 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } var handlerForDownload = _charaHandler; - _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, toDownloadFiles, downloadToken, skipDownscaleForPair, skipDecimationForPair).ConfigureAwait(false)); + _pairDownloadTask = _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, toDownloadFiles, downloadToken, skipDownscaleForPair, skipDecimationForPair); await _pairDownloadTask.ConfigureAwait(false); if (downloadToken.IsCancellationRequested) { Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase); + _pendingModReapply = true; RecordFailure("Download cancelled", "Cancellation"); return; } @@ -1809,6 +1893,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (handlerForApply is null || handlerForApply.Address == nint.Zero) { Logger.LogDebug("[BASE-{appBase}] Handler not available for {player}, cached data for later application", applicationBase, GetLogIdentifier()); + _pendingModReapply = true; _cachedData = charaData; _pairStateCache.Store(Ident, charaData); RecordFailure("Handler not available for application", "HandlerUnavailable"); @@ -1836,6 +1921,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, token); } + catch (OperationCanceledException) when (downloadToken.IsCancellationRequested) + { + _pendingModReapply = true; + RecordFailure("Download cancelled", "Cancellation"); + } finally { await concurrencyLease.DisposeAsync().ConfigureAwait(false); @@ -1859,6 +1949,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { Logger.LogDebug("[BASE-{applicationId}] Timed out waiting for {handler} to fully load, caching data for later application", applicationBase, GetLogIdentifier()); + _pendingModReapply = true; _cachedData = charaData; _pairStateCache.Store(Ident, charaData); RecordFailure("Actor not fully loaded within timeout", "FullyLoadedTimeout"); @@ -1875,6 +1966,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (penumbraCollection == Guid.Empty) { Logger.LogTrace("[BASE-{applicationId}] Penumbra collection unavailable for {handler}, caching data for later application", applicationBase, GetLogIdentifier()); + _pendingModReapply = true; _cachedData = charaData; _pairStateCache.Store(Ident, charaData); RecordFailure("Penumbra collection unavailable", "PenumbraUnavailable"); @@ -1893,6 +1985,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (!objIndex.HasValue) { Logger.LogDebug("[BASE-{applicationId}] GameObject not available for {handler}, caching data for later application", applicationBase, GetLogIdentifier()); + _pendingModReapply = true; _cachedData = charaData; _pairStateCache.Store(Ident, charaData); RecordFailure("Game object not available for application", "GameObjectUnavailable"); @@ -1958,6 +2051,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } _cachedData = charaData; + _lastAppliedData = charaData; _pairStateCache.Store(Ident, charaData); if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0) { @@ -1977,6 +2071,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa catch (OperationCanceledException) { Logger.LogDebug("[{applicationId}] Application cancelled for {handler}", _applicationId, GetLogIdentifier()); + _pendingModReapply = true; _cachedData = charaData; _pairStateCache.Store(Ident, charaData); RecordFailure("Application cancelled", "Cancellation"); @@ -2072,6 +2167,25 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } TryApplyQueuedData(); + + if (_pendingModReapply && IsVisible && !IsApplying && LastReceivedCharacterData is not null && CanApplyNow()) + { + var now = DateTime.UtcNow; + if (!_lastApplyAttemptAt.HasValue || now - _lastApplyAttemptAt.Value > TimeSpan.FromSeconds(5)) + { + _ = Task.Run(() => + { + try + { + ApplyLastReceivedData(forced: true); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to reapply pending data for {handler}", GetLogIdentifier()); + } + }); + } + } } private void HandleVisibilityLoss(bool logChange) @@ -2079,6 +2193,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa IsVisible = false; _charaHandler?.Invalidate(); ClearAllOwnedObjectRetries(); + LogDownloadCancellation("visibility lost"); _downloadCancellationTokenSource?.CancelDispose(); _downloadCancellationTokenSource = null; if (logChange) @@ -2384,7 +2499,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private async Task RevertToRestoredAsync(Guid applicationId) { - if (_charaHandler is null || _charaHandler.Address == nint.Zero) + var handler = _charaHandler; + if (handler is null || handler.Address == nint.Zero) { return false; } @@ -2392,7 +2508,15 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa try { var reverted = false; - var gameObject = await _dalamudUtil.RunOnFrameworkThread(() => _charaHandler.GetGameObject()).ConfigureAwait(false); + var gameObject = await _dalamudUtil.RunOnFrameworkThread(() => + { + if (handler.Address == nint.Zero) + { + return null; + } + + return handler.GetGameObject(); + }).ConfigureAwait(false); if (gameObject is not Dalamud.Game.ClientState.Objects.Types.ICharacter character) { return false; @@ -2450,6 +2574,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private void DisableSync() { + LogDownloadCancellation("sync disabled"); _downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate(); _applicationCancellationTokenSource = _applicationCancellationTokenSource?.CancelRecreate(); } @@ -2457,6 +2582,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private void EnableSync() { TryApplyQueuedData(); + + if (_pendingModReapply && LastReceivedCharacterData is not null && !IsApplying && CanApplyNow()) + { + ApplyLastReceivedData(forced: true); + } } private void TryApplyQueuedData() diff --git a/LightlessSync/Services/PerformanceCollectorService.cs b/LightlessSync/Services/PerformanceCollectorService.cs index 75fe736..5bec813 100644 --- a/LightlessSync/Services/PerformanceCollectorService.cs +++ b/LightlessSync/Services/PerformanceCollectorService.cs @@ -131,7 +131,10 @@ public sealed class PerformanceCollectorService : IHostedService DrawSeparator(sb, longestCounterName); } - var pastEntries = limitBySeconds > 0 ? entry.Value.Where(e => e.Item1.AddMinutes(limitBySeconds / 60.0d) >= TimeOnly.FromDateTime(DateTime.Now)).ToList() : [.. entry.Value]; + var snapshot = entry.Value.Snapshot(); + var pastEntries = limitBySeconds > 0 + ? snapshot.Where(e => e.Item1.AddMinutes(limitBySeconds / 60.0d) >= TimeOnly.FromDateTime(DateTime.Now)).ToList() + : snapshot; if (pastEntries.Any()) { @@ -189,7 +192,11 @@ public sealed class PerformanceCollectorService : IHostedService { try { - var last = entries.Value.ToList()[^1]; + if (!entries.Value.TryGetLast(out var last)) + { + continue; + } + if (last.Item1.AddMinutes(10) < TimeOnly.FromDateTime(DateTime.Now) && !PerformanceCounters.TryRemove(entries.Key, out _)) { _logger.LogDebug("Could not remove performance counter {counter}", entries.Key); diff --git a/LightlessSync/UI/Components/OptimizationSettingsPanel.cs b/LightlessSync/UI/Components/OptimizationSettingsPanel.cs index a75df2d..7b0477f 100644 --- a/LightlessSync/UI/Components/OptimizationSettingsPanel.cs +++ b/LightlessSync/UI/Components/OptimizationSettingsPanel.cs @@ -108,6 +108,14 @@ public sealed class OptimizationSettingsPanel new SeStringUtils.RichTextEntry("When enabled, we cannot provide support for appearance issues caused by this setting!", UIColors.Get("DimRed"), true)); }); + DrawCallout("texture-opt-info", UIColors.Get("LightlessGrey"), () => + { + _uiSharedService.DrawNoteLine("i ", UIColors.Get("LightlessGrey"), + new SeStringUtils.RichTextEntry("Compression, downscale, and mip trimming only apply to "), + new SeStringUtils.RichTextEntry("newly downloaded pairs", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(". Existing downloads are not reprocessed; re-download to apply.")); + }); + ImGui.Dummy(new Vector2(0f, 2f * scale)); DrawGroupHeader("Core Controls", UIColors.Get("LightlessYellow")); @@ -282,6 +290,11 @@ public sealed class OptimizationSettingsPanel new SeStringUtils.RichTextEntry(" will be decimated to the "), new SeStringUtils.RichTextEntry("target ratio", UIColors.Get("LightlessGreen"), true), new SeStringUtils.RichTextEntry(". This can reduce quality or alter intended structure.")); + + _uiSharedService.DrawNoteLine("i ", UIColors.Get("LightlessGreen"), + new SeStringUtils.RichTextEntry("Decimation only applies to "), + new SeStringUtils.RichTextEntry("newly downloaded pairs", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(". Existing downloads are not reprocessed; re-download to apply.")); }); DrawGroupHeader("Core Controls", UIColors.Get("LightlessOrange")); diff --git a/LightlessSync/Utils/RollingList.cs b/LightlessSync/Utils/RollingList.cs index 4ddb22b..2528fe8 100644 --- a/LightlessSync/Utils/RollingList.cs +++ b/LightlessSync/Utils/RollingList.cs @@ -29,6 +29,29 @@ public class RollingList : IEnumerable } } + public bool TryGetLast(out T value) + { + lock (_addLock) + { + if (_list.Count == 0) + { + value = default!; + return false; + } + + value = _list.Last!.Value; + return true; + } + } + + public List Snapshot() + { + lock (_addLock) + { + return new List(_list); + } + } + public void Add(T value) { lock (_addLock)