diff --git a/LightlessAPI b/LightlessAPI index 5656600..4ecd537 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit 56566003e0e93bba05dcef49fd3ce23c6a204d81 +Subproject commit 4ecd5375e63082f44b841bcba38d5dd3f4a2a79b diff --git a/LightlessSync/FileCache/FileCacheManager.cs b/LightlessSync/FileCache/FileCacheManager.cs index b0becf3..854cdd9 100644 --- a/LightlessSync/FileCache/FileCacheManager.cs +++ b/LightlessSync/FileCache/FileCacheManager.cs @@ -280,6 +280,26 @@ public sealed class FileCacheManager : IHostedService return CreateFileEntity(cacheFolder, CachePrefix, fi); } + public FileCacheEntity? CreateCacheEntryWithKnownHash(string path, string hash) + { + if (string.IsNullOrWhiteSpace(hash)) + { + return CreateCacheEntry(path); + } + + FileInfo fi = new(path); + if (!fi.Exists) return null; + _logger.LogTrace("Creating cache entry for {path} using provided hash", path); + var cacheFolder = _configService.Current.CacheFolder; + if (string.IsNullOrEmpty(cacheFolder)) return null; + if (!TryBuildPrefixedPath(fi.FullName, cacheFolder, CachePrefix, out var prefixedPath, out _)) + { + return null; + } + + return CreateFileCacheEntity(fi, prefixedPath, hash); + } + public FileCacheEntity? CreateFileEntry(string path) { FileInfo fi = new(path); diff --git a/LightlessSync/Interop/Ipc/IpcManager.cs b/LightlessSync/Interop/Ipc/IpcManager.cs index 59d17c7..1131d7d 100644 --- a/LightlessSync/Interop/Ipc/IpcManager.cs +++ b/LightlessSync/Interop/Ipc/IpcManager.cs @@ -5,6 +5,8 @@ namespace LightlessSync.Interop.Ipc; public sealed partial class IpcManager : DisposableMediatorSubscriberBase { + private bool _wasInitialized; + public IpcManager(ILogger logger, LightlessMediator mediator, IpcCallerPenumbra penumbraIpc, IpcCallerGlamourer glamourerIpc, IpcCallerCustomize customizeIpc, IpcCallerHeels heelsIpc, IpcCallerHonorific honorificIpc, IpcCallerMoodles moodlesIpc, IpcCallerPetNames ipcCallerPetNames, IpcCallerBrio ipcCallerBrio) : base(logger, mediator) @@ -18,7 +20,8 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase PetNames = ipcCallerPetNames; Brio = ipcCallerBrio; - if (Initialized) + _wasInitialized = Initialized; + if (_wasInitialized) { Mediator.Publish(new PenumbraInitializedMessage()); } @@ -58,5 +61,13 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase Moodles.CheckAPI(); PetNames.CheckAPI(); Brio.CheckAPI(); + + var initialized = Initialized; + if (initialized && !_wasInitialized) + { + Mediator.Publish(new PenumbraInitializedMessage()); + } + + _wasInitialized = initialized; } } \ No newline at end of file diff --git a/LightlessSync/LightlessPlugin.cs b/LightlessSync/LightlessPlugin.cs index fe7e9a4..e82235f 100644 --- a/LightlessSync/LightlessPlugin.cs +++ b/LightlessSync/LightlessPlugin.cs @@ -74,6 +74,7 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService private readonly DalamudUtilService _dalamudUtil; private readonly LightlessConfigService _lightlessConfigService; private readonly ServerConfigurationManager _serverConfigurationManager; + private readonly PairHandlerRegistry _pairHandlerRegistry; private readonly IServiceScopeFactory _serviceScopeFactory; private IServiceScope? _runtimeServiceScope; private Task? _launchTask = null; @@ -81,11 +82,13 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService public LightlessPlugin(ILogger logger, LightlessConfigService lightlessConfigService, ServerConfigurationManager serverConfigurationManager, DalamudUtilService dalamudUtil, + PairHandlerRegistry pairHandlerRegistry, IServiceScopeFactory serviceScopeFactory, LightlessMediator mediator) : base(logger, mediator) { _lightlessConfigService = lightlessConfigService; _serverConfigurationManager = serverConfigurationManager; _dalamudUtil = dalamudUtil; + _pairHandlerRegistry = pairHandlerRegistry; _serviceScopeFactory = serviceScopeFactory; } @@ -108,12 +111,20 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService public Task StopAsync(CancellationToken cancellationToken) { + Logger.LogDebug("Halting LightlessPlugin"); + try + { + _pairHandlerRegistry.ResetAllHandlers(); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to reset pair handlers on shutdown"); + } + UnsubscribeAll(); DalamudUtilOnLogOut(); - Logger.LogDebug("Halting LightlessPlugin"); - return Task.CompletedTask; } diff --git a/LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs b/LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs index 0891035..713333e 100644 --- a/LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs +++ b/LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs @@ -125,6 +125,7 @@ public sealed partial class PairCoordinator } } + _mediator.Publish(new PairOnlineMessage(new PairUniqueIdentifier(dto.User.UID))); PublishPairDataChanged(); } diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index b0f2710..b753657 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Diagnostics; +using Dalamud.Plugin.Services; using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; @@ -48,6 +49,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private readonly PairPerformanceMetricsCache _performanceMetricsCache; private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor; private readonly PairManager _pairManager; + private readonly IFramework _framework; private CancellationTokenSource? _applicationCancellationTokenSource; private Guid _applicationId; private Task? _applicationTask; @@ -66,6 +68,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private int _lastMissingNonCriticalMods; private int _lastMissingForbiddenMods; private bool _lastMissingCachedFiles; + private string? _lastSuccessfulDataHash; private bool _isVisible; private Guid _penumbraCollection; private readonly object _collectionGate = new(); @@ -82,6 +85,13 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa 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; + private Task _ownedRetryTask = Task.CompletedTask; + private static readonly TimeSpan OwnedRetryInitialDelay = TimeSpan.FromSeconds(1); + private static readonly TimeSpan OwnedRetryMaxDelay = TimeSpan.FromSeconds(10); + private static readonly TimeSpan OwnedRetryStaleDataGrace = TimeSpan.FromMinutes(5); private static readonly HashSet NonPriorityModExtensions = new(StringComparer.OrdinalIgnoreCase) { ".tmb", @@ -95,10 +105,15 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private DateTime _nextActorLookupUtc = DateTime.MinValue; private static readonly TimeSpan ActorLookupInterval = TimeSpan.FromSeconds(1); private static readonly SemaphoreSlim ActorInitializationLimiter = new(1, 1); + private const int FullyLoadedTimeoutMsPlayer = 30000; + private const int FullyLoadedTimeoutMsOther = 5000; private readonly object _actorInitializationGate = new(); private ActorObjectService.ActorDescriptor? _pendingActorDescriptor; private bool _actorInitializationInProgress; private bool _frameworkUpdateSubscribed; + private nint _lastKnownAddress = nint.Zero; + private ushort _lastKnownObjectIndex = ushort.MaxValue; + private string? _lastKnownName; public DateTime? InvisibleSinceUtc => _invisibleSinceUtc; public DateTime? VisibilityEvictionDueAtUtc => _visibilityEvictionDueAtUtc; @@ -175,6 +190,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa FileDownloadManager transferManager, PluginWarningNotificationService pluginWarningNotificationManager, DalamudUtilService dalamudUtil, + IFramework framework, ActorObjectService actorObjectService, IHostApplicationLifetime lifetime, FileCacheManager fileDbManager, @@ -193,6 +209,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _downloadManager = transferManager; _pluginWarningNotificationManager = pluginWarningNotificationManager; _dalamudUtil = dalamudUtil; + _framework = framework; _actorObjectService = actorObjectService; _lifetime = lifetime; _fileDbManager = fileDbManager; @@ -432,7 +449,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } - private void ResetPenumbraCollection(bool releaseFromPenumbra = true, string? reason = null) + private void ResetPenumbraCollection(bool releaseFromPenumbra = true, string? reason = null, bool awaitIpc = true) { Guid toRelease = Guid.Empty; bool hadCollection = false; @@ -466,16 +483,33 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return; } - try + var applicationId = Guid.NewGuid(); + if (awaitIpc) { - var applicationId = Guid.NewGuid(); - Logger.LogTrace("[{applicationId}] Removing temp collection {CollectionId} for {handler} ({reason})", applicationId, toRelease, GetLogIdentifier(), reason ?? "Cleanup"); - _ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, applicationId, toRelease).GetAwaiter().GetResult(); + try + { + Logger.LogTrace("[{applicationId}] Removing temp collection {CollectionId} for {handler} ({reason})", applicationId, toRelease, GetLogIdentifier(), reason ?? "Cleanup"); + _ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, applicationId, toRelease).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed to remove temporary Penumbra collection for {handler}", GetLogIdentifier()); + } + return; } - catch (Exception ex) + + _ = Task.Run(async () => { - Logger.LogDebug(ex, "Failed to remove temporary Penumbra collection for {handler}", GetLogIdentifier()); - } + try + { + Logger.LogTrace("[{applicationId}] Removing temp collection {CollectionId} for {handler} ({reason})", applicationId, toRelease, GetLogIdentifier(), reason ?? "Cleanup"); + await _ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, applicationId, toRelease).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed to remove temporary Penumbra collection for {handler}", GetLogIdentifier()); + } + }); } private bool AnyPair(Func predicate) @@ -559,9 +593,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } var hasMissingCachedFiles = HasMissingCachedFiles(LastReceivedCharacterData); + var missingStarted = !_lastMissingCachedFiles && hasMissingCachedFiles; var missingResolved = _lastMissingCachedFiles && !hasMissingCachedFiles; _lastMissingCachedFiles = hasMissingCachedFiles; - var shouldForce = forced || missingResolved; + var shouldForce = forced || missingStarted || missingResolved; + var forceApplyCustomization = forced; if (IsPaused()) { @@ -569,7 +605,22 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return; } - if (shouldForce) + var sanitized = CloneAndSanitizeLastReceived(out var dataHash); + 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 hasModReplacements = sanitized.FileReplacements.Values.Any(list => list.Count > 0); + var needsModReapply = needsApply && hasModReplacements; + var shouldForceMods = shouldForce || needsModReapply; + forceApplyCustomization = forced || needsApply; + var suppressForcedModRedraw = !forced && hasMissingCachedFiles && dataApplied; + + if (shouldForceMods) { _forceApplyMods = true; _forceFullReapply = true; @@ -579,15 +630,21 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa LastAppliedApproximateEffectiveVRAMBytes = -1; } - var sanitized = CloneAndSanitizeLastReceived(out _); - if (sanitized is null) - { - Logger.LogTrace("Sanitized data null for {Ident}", Ident); - return; - } - _pairStateCache.Store(Ident, sanitized); + if (!IsVisible && !_pauseRequested) + { + if (_charaHandler is not null && _charaHandler.Address == nint.Zero) + { + _charaHandler.Refresh(); + } + + if (PlayerCharacter != nint.Zero) + { + IsVisible = true; + } + } + if (!IsVisible) { Logger.LogTrace("Handler for {Ident} not visible, caching sanitized data for later", Ident); @@ -596,7 +653,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return; } - ApplyCharacterData(Guid.NewGuid(), sanitized, shouldForce); + ApplyCharacterData(Guid.NewGuid(), sanitized, forceApplyCustomization, suppressForcedModRedraw); } public bool FetchPerformanceMetricsFromCache() @@ -906,7 +963,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa SetUploading(false); } - public void ApplyCharacterData(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization = false) + public void ApplyCharacterData(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization = false, bool suppressForcedModRedraw = false) { _lastApplyAttemptAt = DateTime.UtcNow; ClearFailureState(); @@ -1000,7 +1057,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational, "Applying Character Data"))); - var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this, forceApplyCustomization, _forceApplyMods); + var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this, + forceApplyCustomization, _forceApplyMods, suppressForcedModRedraw); if (handlerReady && _forceApplyMods) { @@ -1097,12 +1155,183 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa }, CancellationToken.None); } + private void ScheduleOwnedObjectRetry(ObjectKind kind, HashSet changes) + { + if (kind == ObjectKind.Player || changes.Count == 0) + { + return; + } + + lock (_ownedRetryGate) + { + _pendingOwnedChanges[kind] = new HashSet(changes); + if (!_ownedRetryTask.IsCompleted) + { + return; + } + + _ownedRetryCts = _ownedRetryCts?.CancelRecreate() ?? new CancellationTokenSource(); + var token = _ownedRetryCts.Token; + _ownedRetryTask = Task.Run(() => OwnedObjectRetryLoopAsync(token), CancellationToken.None); + } + } + + private void ClearOwnedObjectRetry(ObjectKind kind) + { + lock (_ownedRetryGate) + { + if (!_pendingOwnedChanges.Remove(kind)) + { + return; + } + } + } + + private void ClearAllOwnedObjectRetries() + { + lock (_ownedRetryGate) + { + _pendingOwnedChanges.Clear(); + } + } + + private bool IsOwnedRetryDataStale() + { + if (!_lastDataReceivedAt.HasValue) + { + return true; + } + + return DateTime.UtcNow - _lastDataReceivedAt.Value > OwnedRetryStaleDataGrace; + } + + private async Task OwnedObjectRetryLoopAsync(CancellationToken token) + { + var delay = OwnedRetryInitialDelay; + try + { + while (!token.IsCancellationRequested) + { + if (IsOwnedRetryDataStale()) + { + ClearAllOwnedObjectRetries(); + return; + } + + Dictionary> pending; + lock (_ownedRetryGate) + { + if (_pendingOwnedChanges.Count == 0) + { + return; + } + + pending = _pendingOwnedChanges.ToDictionary(kvp => kvp.Key, kvp => new HashSet(kvp.Value)); + } + + if (!IsVisible || IsPaused() || !CanApplyNow() || PlayerCharacter == nint.Zero || _charaHandler is null) + { + await Task.Delay(delay, token).ConfigureAwait(false); + delay = IncreaseRetryDelay(delay); + continue; + } + + if ((_applicationTask?.IsCompleted ?? true) == false || (_pairDownloadTask?.IsCompleted ?? true) == false) + { + await Task.Delay(delay, token).ConfigureAwait(false); + delay = IncreaseRetryDelay(delay); + continue; + } + + var sanitized = CloneAndSanitizeLastReceived(out _); + if (sanitized is null) + { + await Task.Delay(delay, token).ConfigureAwait(false); + delay = IncreaseRetryDelay(delay); + continue; + } + + bool anyApplied = false; + foreach (var entry in pending) + { + if (!HasAppearanceDataForKind(sanitized, entry.Key)) + { + ClearOwnedObjectRetry(entry.Key); + continue; + } + + var applied = await ApplyCustomizationDataAsync(Guid.NewGuid(), entry, sanitized, token).ConfigureAwait(false); + if (applied) + { + ClearOwnedObjectRetry(entry.Key); + anyApplied = true; + } + } + + if (!anyApplied) + { + await Task.Delay(delay, token).ConfigureAwait(false); + delay = IncreaseRetryDelay(delay); + } + else + { + delay = OwnedRetryInitialDelay; + } + } + } + catch (OperationCanceledException) + { + // ignore + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Owned object retry task failed for {handler}", GetLogIdentifier()); + } + } + + private static TimeSpan IncreaseRetryDelay(TimeSpan delay) + { + var nextMs = Math.Min(delay.TotalMilliseconds * 2, OwnedRetryMaxDelay.TotalMilliseconds); + return TimeSpan.FromMilliseconds(nextMs); + } + + private static bool HasAppearanceDataForKind(CharacterData data, ObjectKind kind) + { + if (data.FileReplacements.TryGetValue(kind, out var replacements) && replacements.Count > 0) + { + return true; + } + + if (data.GlamourerData.TryGetValue(kind, out var glamourer) && !string.IsNullOrEmpty(glamourer)) + { + return true; + } + + if (data.CustomizePlusData.TryGetValue(kind, out var customize) && !string.IsNullOrEmpty(customize)) + { + return true; + } + + return false; + } + protected override void Dispose(bool disposing) { base.Dispose(disposing); SetUploading(false); var name = PlayerName; + if (!string.IsNullOrEmpty(name)) + { + _lastKnownName = name; + } + + var currentAddress = PlayerCharacter; + if (currentAddress != nint.Zero) + { + _lastKnownAddress = currentAddress; + } + var user = GetPrimaryUserDataSafe(); var alias = GetPrimaryAliasOrUidSafe(); Logger.LogDebug("Disposing {name} ({user})", name, alias); @@ -1113,6 +1342,9 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _applicationCancellationTokenSource = null; _downloadCancellationTokenSource?.CancelDispose(); _downloadCancellationTokenSource = null; + ClearAllOwnedObjectRetries(); + _ownedRetryCts?.CancelDispose(); + _ownedRetryCts = null; _downloadManager.Dispose(); _charaHandler?.Dispose(); CancelVisibilityGraceTask(); @@ -1125,43 +1357,62 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Mediator.Publish(new EventMessage(new Event(name, user, nameof(PairHandlerAdapter), EventSeverity.Informational, "Disposing User"))); } - if (_lifetime.ApplicationStopping.IsCancellationRequested) return; - - if (_dalamudUtil is { IsZoning: false, IsInCutscene: false } && !string.IsNullOrEmpty(name)) + if (IsFrameworkUnloading()) { - Logger.LogTrace("[{applicationId}] Restoring state for {name} ({user})", applicationId, name, alias); - Logger.LogDebug("[{applicationId}] Removing Temp Collection for {name} ({user})", applicationId, name, alias); - ResetPenumbraCollection(reason: nameof(Dispose)); - if (!IsVisible) + Logger.LogWarning("Framework is unloading, skipping disposal for {name} ({user})", name, alias); + return; + } + + var isStopping = _lifetime.ApplicationStopping.IsCancellationRequested; + if (isStopping) + { + ResetPenumbraCollection(reason: "DisposeStopping", awaitIpc: false); + ScheduleSafeRevertOnDisposal(applicationId, name, alias); + return; + } + + var canCleanup = !string.IsNullOrEmpty(name) + && _dalamudUtil.IsLoggedIn + && !_dalamudUtil.IsZoning + && !_dalamudUtil.IsInCutscene; + + if (!canCleanup) + { + return; + } + + Logger.LogTrace("[{applicationId}] Restoring state for {name} ({user})", applicationId, name, alias); + Logger.LogDebug("[{applicationId}] Removing Temp Collection for {name} ({user})", applicationId, name, alias); + ResetPenumbraCollection(reason: nameof(Dispose)); + if (!IsVisible) + { + Logger.LogDebug("[{applicationId}] Restoring Glamourer for {name} ({user})", applicationId, name, alias); + _ipcManager.Glamourer.RevertByNameAsync(Logger, name, applicationId).GetAwaiter().GetResult(); + } + else + { + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromSeconds(60)); + + var effectiveCachedData = _cachedData ?? _pairStateCache.TryLoad(Ident); + if (effectiveCachedData is not null) { - Logger.LogDebug("[{applicationId}] Restoring Glamourer for {name} ({user})", applicationId, name, alias); - _ipcManager.Glamourer.RevertByNameAsync(Logger, name, applicationId).GetAwaiter().GetResult(); + _cachedData = effectiveCachedData; } - else + + Logger.LogInformation("[{applicationId}] CachedData is null {isNull}, contains things: {contains}", + applicationId, _cachedData == null, _cachedData?.FileReplacements.Any() ?? false); + + foreach (KeyValuePair> item in _cachedData?.FileReplacements ?? []) { - using var cts = new CancellationTokenSource(); - cts.CancelAfter(TimeSpan.FromSeconds(60)); - - var effectiveCachedData = _cachedData ?? _pairStateCache.TryLoad(Ident); - if (effectiveCachedData is not null) + try { - _cachedData = effectiveCachedData; + RevertCustomizationDataAsync(item.Key, name, applicationId, cts.Token).GetAwaiter().GetResult(); } - - Logger.LogInformation("[{applicationId}] CachedData is null {isNull}, contains things: {contains}", - applicationId, _cachedData == null, _cachedData?.FileReplacements.Any() ?? false); - - foreach (KeyValuePair> item in _cachedData?.FileReplacements ?? []) + catch (InvalidOperationException ex) { - try - { - RevertCustomizationDataAsync(item.Key, name, applicationId, cts.Token).GetAwaiter().GetResult(); - } - catch (InvalidOperationException ex) - { - Logger.LogWarning(ex, "Failed disposing player (not present anymore?)"); - break; - } + Logger.LogWarning(ex, "Failed disposing player (not present anymore?)"); + break; } } } @@ -1174,6 +1425,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { PlayerName = null; _cachedData = null; + _lastSuccessfulDataHash = null; _lastAppliedModdedPaths = null; _needsCollectionRebuild = false; _performanceMetricsCache.Clear(Ident); @@ -1181,9 +1433,145 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } - private async Task ApplyCustomizationDataAsync(Guid applicationId, KeyValuePair> changes, CharacterData charaData, CancellationToken token) + private bool IsFrameworkUnloading() { - if (PlayerCharacter == nint.Zero) return; + try + { + var prop = _framework.GetType().GetProperty("IsFrameworkUnloading"); + if (prop?.PropertyType == typeof(bool)) + { + return (bool)prop.GetValue(_framework)!; + } + } + catch + { + // ignore + } + + return false; + } + + private void ScheduleSafeRevertOnDisposal(Guid applicationId, string? name, string alias) + { + var cleanupName = !string.IsNullOrEmpty(name) ? name : _lastKnownName; + var cleanupAddress = _lastKnownAddress != nint.Zero + ? _lastKnownAddress + : _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Ident); + var cleanupObjectIndex = _lastKnownObjectIndex; + var cleanupIdent = Ident; + var customizeIds = _customizeIds.Values.Where(id => id.HasValue) + .Select(id => id!.Value) + .Distinct() + .ToList(); + + if (string.IsNullOrEmpty(cleanupName) + && cleanupAddress == nint.Zero + && cleanupObjectIndex == ushort.MaxValue + && customizeIds.Count == 0) + { + return; + } + + _ = Task.Run(() => SafeRevertOnDisposalAsync( + applicationId, + cleanupName, + cleanupAddress, + cleanupObjectIndex, + cleanupIdent, + customizeIds, + alias)); + } + + private async Task SafeRevertOnDisposalAsync( + Guid applicationId, + string? cleanupName, + nint cleanupAddress, + ushort cleanupObjectIndex, + string cleanupIdent, + IReadOnlyList customizeIds, + string alias) + { + try + { + if (IsFrameworkUnloading()) + { + return; + } + + if (!string.IsNullOrEmpty(cleanupName) && _ipcManager.Glamourer.APIAvailable) + { + Logger.LogDebug("[{applicationId}] Restoring Glamourer for {name} ({user})", applicationId, cleanupName, alias); + await _ipcManager.Glamourer.RevertByNameAsync(Logger, cleanupName, applicationId).ConfigureAwait(false); + } + + if (_ipcManager.CustomizePlus.APIAvailable && customizeIds.Count > 0) + { + foreach (var customizeId in customizeIds) + { + await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false); + } + } + + var address = cleanupAddress; + if (address == nint.Zero && cleanupObjectIndex != ushort.MaxValue) + { + address = await _dalamudUtil.RunOnFrameworkThread(() => + { + var obj = _dalamudUtil.GetCharacterFromObjectTableByIndex(cleanupObjectIndex); + if (obj is not Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter player) + { + return nint.Zero; + } + + if (!DalamudUtilService.TryGetHashedCID(player, out var hash) + || !string.Equals(hash, cleanupIdent, StringComparison.Ordinal)) + { + return nint.Zero; + } + + return player.Address; + }).ConfigureAwait(false); + } + + if (address == nint.Zero) + { + return; + } + + if (_ipcManager.CustomizePlus.APIAvailable) + { + await _ipcManager.CustomizePlus.RevertAsync(address).ConfigureAwait(false); + } + + if (_ipcManager.Heels.APIAvailable) + { + await _ipcManager.Heels.RestoreOffsetForPlayerAsync(address).ConfigureAwait(false); + } + + if (_ipcManager.Honorific.APIAvailable) + { + await _ipcManager.Honorific.ClearTitleAsync(address).ConfigureAwait(false); + } + + if (_ipcManager.Moodles.APIAvailable) + { + await _ipcManager.Moodles.RevertStatusAsync(address).ConfigureAwait(false); + } + + if (_ipcManager.PetNames.APIAvailable) + { + await _ipcManager.PetNames.ClearPlayerData(address).ConfigureAwait(false); + } + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed shutdown cleanup for {name}", cleanupName ?? cleanupIdent); + } + } + + private async Task ApplyCustomizationDataAsync(Guid applicationId, KeyValuePair> changes, CharacterData charaData, CancellationToken token) + { + if (PlayerCharacter == nint.Zero) return false; var ptr = PlayerCharacter; var handler = changes.Key switch @@ -1199,14 +1587,29 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { if (handler.Address == nint.Zero) { - return; + return false; } Logger.LogDebug("[{applicationId}] Applying Customization Data for {handler}", applicationId, handler); - await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handler, applicationId, 30000, token).ConfigureAwait(false); + await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false); + if (handler.ObjectKind != ObjectKind.Player + && handler.CurrentDrawCondition == GameObjectHandler.DrawCondition.DrawObjectZero) + { + Logger.LogDebug("[{applicationId}] Skipping customization apply for {handler}, draw object not available", applicationId, handler); + return false; + } + + var drawTimeoutMs = handler.ObjectKind == ObjectKind.Player ? 30000 : 5000; + var fullyLoadedTimeoutMs = handler.ObjectKind == ObjectKind.Player ? FullyLoadedTimeoutMsPlayer : FullyLoadedTimeoutMsOther; + await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handler, applicationId, drawTimeoutMs, token).ConfigureAwait(false); if (handler.Address != nint.Zero) { - await _actorObjectService.WaitForFullyLoadedAsync(handler.Address, token).ConfigureAwait(false); + var fullyLoaded = await _actorObjectService.WaitForFullyLoadedAsync(handler.Address, token, fullyLoadedTimeoutMs).ConfigureAwait(false); + if (!fullyLoaded) + { + Logger.LogDebug("[{applicationId}] Timed out waiting for {handler} to fully load, skipping customization apply", applicationId, handler); + return false; + } } token.ThrowIfCancellationRequested(); @@ -1270,6 +1673,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, token).ConfigureAwait(false); } + + return true; } finally { @@ -1577,37 +1982,25 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa RecordFailure("Handler not available for application", "HandlerUnavailable"); return; } - _applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource(); - if (_applicationTask != null && !_applicationTask.IsCompleted) + var appToken = _applicationCancellationTokenSource?.Token; + while ((!_applicationTask?.IsCompleted ?? false) + && !downloadToken.IsCancellationRequested + && (!appToken?.IsCancellationRequested ?? false)) { - Logger.LogDebug("[BASE-{appBase}] Cancelling current data application (Id: {id}) for player ({handler})", applicationBase, _applicationId, PlayerName); - - var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(downloadToken, timeoutCts.Token); - - try - { - await _applicationTask.WaitAsync(combinedCts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - Logger.LogWarning("[BASE-{appBase}] Timeout waiting for application task {id} to complete, proceeding anyway", applicationBase, _applicationId); - } - finally - { - timeoutCts.Dispose(); - combinedCts.Dispose(); - } + Logger.LogDebug("[BASE-{appBase}] Waiting for current data application (Id: {id}) for player ({handler}) to finish", + applicationBase, _applicationId, PlayerName); + await Task.Delay(250).ConfigureAwait(false); } - if (downloadToken.IsCancellationRequested) + if (downloadToken.IsCancellationRequested || (appToken?.IsCancellationRequested ?? false)) { _forceFullReapply = true; RecordFailure("Application cancelled", "Cancellation"); return; } + _applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource(); var token = _applicationCancellationTokenSource.Token; _applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, wantsModApply, pendingModReapply, token); @@ -1630,7 +2023,17 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handlerForApply, _applicationId, 30000, token).ConfigureAwait(false); if (handlerForApply.Address != nint.Zero) { - await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token).ConfigureAwait(false); + var fullyLoaded = await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token, FullyLoadedTimeoutMsPlayer).ConfigureAwait(false); + if (!fullyLoaded) + { + Logger.LogDebug("[BASE-{applicationId}] Timed out waiting for {handler} to fully load, caching data for later application", + applicationBase, GetLogIdentifier()); + _cachedData = charaData; + _pairStateCache.Store(Ident, charaData); + _forceFullReapply = true; + RecordFailure("Actor not fully loaded within timeout", "FullyLoadedTimeout"); + return; + } } token.ThrowIfCancellationRequested(); @@ -1692,7 +2095,15 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa foreach (var kind in updatedData) { - await ApplyCustomizationDataAsync(_applicationId, kind, charaData, token).ConfigureAwait(false); + var applied = await ApplyCustomizationDataAsync(_applicationId, kind, charaData, token).ConfigureAwait(false); + if (applied) + { + ClearOwnedObjectRetry(kind.Key); + } + else if (kind.Key != ObjectKind.Player) + { + ScheduleOwnedObjectRetry(kind.Key, kind.Value); + } token.ThrowIfCancellationRequested(); } @@ -1714,6 +2125,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } StorePerformanceMetrics(charaData); + _lastSuccessfulDataHash = GetDataHashSafe(charaData); _lastSuccessfulApplyAt = DateTime.UtcNow; ClearFailureState(); Logger.LogDebug("[{applicationId}] Application finished", _applicationId); @@ -1827,6 +2239,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { IsVisible = false; _charaHandler?.Invalidate(); + ClearAllOwnedObjectRetries(); _downloadCancellationTokenSource?.CancelDispose(); _downloadCancellationTokenSource = null; if (logChange) @@ -1839,6 +2252,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { PlayerName = name; _charaHandler = _gameObjectHandlerFactory.Create(ObjectKind.Player, () => _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Ident), isWatched: false).GetAwaiter().GetResult(); + UpdateLastKnownActor(_charaHandler.Address, name); var user = GetPrimaryUserData(); if (!string.IsNullOrEmpty(user.UID)) @@ -2185,6 +2599,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (descriptor.Address == nint.Zero) return; + UpdateLastKnownActor(descriptor); RefreshTrackedHandler(descriptor); QueueActorInitialization(descriptor); } @@ -2308,6 +2723,29 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return !string.IsNullOrEmpty(hashedCid); } + private void UpdateLastKnownActor(ActorObjectService.ActorDescriptor descriptor) + { + _lastKnownAddress = descriptor.Address; + _lastKnownObjectIndex = descriptor.ObjectIndex; + if (!string.IsNullOrEmpty(descriptor.Name)) + { + _lastKnownName = descriptor.Name; + } + } + + private void UpdateLastKnownActor(nint address, string? name) + { + if (address != nint.Zero) + { + _lastKnownAddress = address; + } + + if (!string.IsNullOrEmpty(name)) + { + _lastKnownName = name; + } + } + private async Task ApplyCustomizeAsync(nint address, string customizeData, ObjectKind kind) { _customizeIds[kind] = await _ipcManager.CustomizePlus.SetBodyScaleAsync(address, customizeData).ConfigureAwait(false); diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs index 5169820..fee3b9a 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs @@ -7,6 +7,7 @@ using LightlessSync.Services.Mediator; using LightlessSync.Services.PairProcessing; using LightlessSync.Services.ServerConfiguration; using LightlessSync.Services.TextureCompression; +using Dalamud.Plugin.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -32,6 +33,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory private readonly PairStateCache _pairStateCache; private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache; private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor; + private readonly IFramework _framework; public PairHandlerAdapterFactory( ILoggerFactory loggerFactory, @@ -42,6 +44,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory FileDownloadManagerFactory fileDownloadManagerFactory, PluginWarningNotificationService pluginWarningNotificationManager, IServiceProvider serviceProvider, + IFramework framework, IHostApplicationLifetime lifetime, FileCacheManager fileCacheManager, PlayerPerformanceService playerPerformanceService, @@ -60,6 +63,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory _fileDownloadManagerFactory = fileDownloadManagerFactory; _pluginWarningNotificationManager = pluginWarningNotificationManager; _serviceProvider = serviceProvider; + _framework = framework; _lifetime = lifetime; _fileCacheManager = fileCacheManager; _playerPerformanceService = playerPerformanceService; @@ -86,6 +90,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory downloadManager, _pluginWarningNotificationManager, dalamudUtilService, + _framework, actorObjectService, _lifetime, _fileCacheManager, diff --git a/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs b/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs index f71080a..35bf3ed 100644 --- a/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs +++ b/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs @@ -50,6 +50,7 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase }); Mediator.Subscribe(this, (_) => PushToAllVisibleUsers()); + Mediator.Subscribe(this, (msg) => HandlePairOnline(msg.PairIdent)); Mediator.Subscribe(this, (_) => { _fileTransferManager.CancelUpload(); @@ -111,6 +112,20 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase _ = PushCharacterDataAsync(forced); } + private void HandlePairOnline(PairUniqueIdentifier pairIdent) + { + if (!_apiController.IsConnected || !_pairLedger.IsPairVisible(pairIdent)) + { + return; + } + + if (_pairLedger.GetHandler(pairIdent)?.UserData is { } user) + { + _usersToPushDataTo.Add(user); + PushCharacterData(forced: true); + } + } + private async Task PushCharacterDataAsync(bool forced = false) { await _pushLock.WaitAsync(_runtimeCts.Token).ConfigureAwait(false); @@ -152,5 +167,6 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase } } - private List GetVisibleUsers() => [.. _pairLedger.GetVisiblePairs().Select(connection => connection.User)]; + private List GetVisibleUsers() + => [.. _pairLedger.GetVisiblePairs().Where(connection => connection.IsOnline).Select(connection => connection.User)]; } diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 2d46b43..058559f 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -105,6 +105,7 @@ public sealed class Plugin : IDalamudPlugin services.AddSingleton(new WindowSystem("LightlessSync")); services.AddSingleton(); services.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", string.Empty, useEmbedded: true)); + services.AddSingleton(framework); services.AddSingleton(gameGui); services.AddSingleton(gameInteropProvider); services.AddSingleton(addonLifecycle); diff --git a/LightlessSync/Services/ActorTracking/ActorObjectService.cs b/LightlessSync/Services/ActorTracking/ActorObjectService.cs index 28c5533..cf22e66 100644 --- a/LightlessSync/Services/ActorTracking/ActorObjectService.cs +++ b/LightlessSync/Services/ActorTracking/ActorObjectService.cs @@ -213,18 +213,25 @@ public sealed class ActorObjectService : IHostedService, IDisposable return false; } - public async Task WaitForFullyLoadedAsync(nint address, CancellationToken cancellationToken = default) + public async Task WaitForFullyLoadedAsync(nint address, CancellationToken cancellationToken = default, int timeOutMs = 30000) { if (address == nint.Zero) throw new ArgumentException("Address cannot be zero.", nameof(address)); + var timeoutAt = timeOutMs > 0 ? Environment.TickCount64 + timeOutMs : long.MaxValue; while (true) { cancellationToken.ThrowIfCancellationRequested(); - var isLoaded = await _framework.RunOnFrameworkThread(() => IsObjectFullyLoaded(address)).ConfigureAwait(false); - if (!IsZoning && isLoaded) - return; + var loadState = await _framework.RunOnFrameworkThread(() => GetObjectLoadState(address)).ConfigureAwait(false); + if (!loadState.IsValid) + return false; + + if (!IsZoning && loadState.IsLoaded) + return true; + + if (Environment.TickCount64 >= timeoutAt) + return false; await Task.Delay(100, cancellationToken).ConfigureAwait(false); } @@ -1143,6 +1150,18 @@ public sealed class ActorObjectService : IHostedService, IDisposable return results; } + private LoadState GetObjectLoadState(nint address) + { + if (address == nint.Zero) + return LoadState.Invalid; + + var obj = _objectTable.CreateObjectReference(address); + if (obj is null || obj.Address != address) + return LoadState.Invalid; + + return new LoadState(true, IsObjectFullyLoaded(address)); + } + private static unsafe bool IsObjectFullyLoaded(nint address) { if (address == nint.Zero) @@ -1169,6 +1188,11 @@ public sealed class ActorObjectService : IHostedService, IDisposable return true; } + private readonly record struct LoadState(bool IsValid, bool IsLoaded) + { + public static LoadState Invalid => new(false, false); + } + private sealed record OwnedObjectSnapshot( IReadOnlyList RenderedPlayers, IReadOnlyList RenderedCompanions, diff --git a/LightlessSync/Services/Mediator/Messages.cs b/LightlessSync/Services/Mediator/Messages.cs index 758b9f5..7767d74 100644 --- a/LightlessSync/Services/Mediator/Messages.cs +++ b/LightlessSync/Services/Mediator/Messages.cs @@ -104,6 +104,7 @@ public record PairUiUpdatedMessage(PairUiSnapshot Snapshot) : MessageBase; public record CensusUpdateMessage(byte Gender, byte RaceId, byte TribeId) : MessageBase; public record TargetPairMessage(Pair Pair) : MessageBase; public record PairFocusCharacterMessage(Pair Pair) : SameThreadMessage; +public record PairOnlineMessage(PairUniqueIdentifier PairIdent) : MessageBase; public record CombatStartMessage : MessageBase; public record CombatEndMessage : MessageBase; public record PerformanceStartMessage : MessageBase; diff --git a/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs b/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs index 7a09ae7..5113e20 100644 --- a/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs +++ b/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs @@ -77,16 +77,39 @@ public sealed class TextureDownscaleService } public void ScheduleDownscale(string hash, string filePath, TextureMapKind mapKind) + => ScheduleDownscale(hash, filePath, () => mapKind); + + public void ScheduleDownscale(string hash, string filePath, Func mapKindFactory) { if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) return; if (_activeJobs.ContainsKey(hash)) return; _activeJobs[hash] = Task.Run(async () => { + TextureMapKind mapKind; + try + { + mapKind = mapKindFactory(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to determine texture map kind for {Hash}; skipping downscale", hash); + return; + } + await DownscaleInternalAsync(hash, filePath, mapKind).ConfigureAwait(false); }, CancellationToken.None); } + public bool ShouldScheduleDownscale(string filePath) + { + if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) + return false; + + var performanceConfig = _playerPerformanceConfigService.Current; + return performanceConfig.EnableNonIndexTextureMipTrim || performanceConfig.EnableIndexTextureDownscale; + } + public string GetPreferredPath(string hash, string originalPath) { if (_downscaledPaths.TryGetValue(hash, out var existing) && File.Exists(existing)) diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index 2d9cdc1..0697333 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -206,12 +206,16 @@ public class DownloadUi : WindowMediatorSubscriberBase var dlQueue = 0; var dlProg = 0; var dlDecomp = 0; + var dlComplete = 0; foreach (var entry in transfer.Value) { var fileStatus = entry.Value; switch (fileStatus.DownloadStatus) { + case DownloadStatus.Initializing: + dlQueue++; + break; case DownloadStatus.WaitingForSlot: dlSlot++; break; @@ -224,15 +228,20 @@ public class DownloadUi : WindowMediatorSubscriberBase case DownloadStatus.Decompressing: dlDecomp++; break; + case DownloadStatus.Completed: + dlComplete++; + break; } } + var isAllComplete = dlComplete > 0 && dlProg == 0 && dlDecomp == 0 && dlQueue == 0 && dlSlot == 0; + string statusText; if (dlProg > 0) { statusText = "Downloading"; } - else if (dlDecomp > 0 || (totalBytes > 0 && transferredBytes >= totalBytes)) + else if (dlDecomp > 0) { statusText = "Decompressing"; } @@ -244,6 +253,10 @@ public class DownloadUi : WindowMediatorSubscriberBase { statusText = "Waiting for slot"; } + else if (isAllComplete) + { + statusText = "Completed"; + } else { statusText = "Waiting"; @@ -309,7 +322,7 @@ public class DownloadUi : WindowMediatorSubscriberBase fillPercent = transferredBytes / (double)totalBytes; showFill = true; } - else if (dlDecomp > 0 || transferredBytes >= totalBytes) + else if (dlDecomp > 0 || dlComplete > 0 || transferredBytes >= totalBytes) { fillPercent = 1.0; showFill = true; @@ -341,10 +354,14 @@ public class DownloadUi : WindowMediatorSubscriberBase downloadText = $"{statusText} {UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}"; } - else if ((dlDecomp > 0 || transferredBytes >= totalBytes) && hasValidSize) + else if (dlDecomp > 0) { downloadText = "Decompressing"; } + else if (isAllComplete) + { + downloadText = "Completed"; + } else { // Waiting states @@ -417,6 +434,7 @@ public class DownloadUi : WindowMediatorSubscriberBase var totalDlQueue = 0; var totalDlProg = 0; var totalDlDecomp = 0; + var totalDlComplete = 0; var perPlayer = new List<( string Name, @@ -428,7 +446,8 @@ public class DownloadUi : WindowMediatorSubscriberBase int DlSlot, int DlQueue, int DlProg, - int DlDecomp)>(); + int DlDecomp, + int DlComplete)>(); foreach (var transfer in _currentDownloads) { @@ -450,12 +469,17 @@ public class DownloadUi : WindowMediatorSubscriberBase var playerDlQueue = 0; var playerDlProg = 0; var playerDlDecomp = 0; + var playerDlComplete = 0; foreach (var entry in transfer.Value) { var fileStatus = entry.Value; switch (fileStatus.DownloadStatus) { + case DownloadStatus.Initializing: + playerDlQueue++; + totalDlQueue++; + break; case DownloadStatus.WaitingForSlot: playerDlSlot++; totalDlSlot++; @@ -472,6 +496,10 @@ public class DownloadUi : WindowMediatorSubscriberBase playerDlDecomp++; totalDlDecomp++; break; + case DownloadStatus.Completed: + playerDlComplete++; + totalDlComplete++; + break; } } @@ -497,7 +525,8 @@ public class DownloadUi : WindowMediatorSubscriberBase playerDlSlot, playerDlQueue, playerDlProg, - playerDlDecomp + playerDlDecomp, + playerDlComplete )); } @@ -521,7 +550,7 @@ public class DownloadUi : WindowMediatorSubscriberBase // Overall texts var headerText = - $"Downloading {transferredFiles}/{totalFiles} files [W:{totalDlSlot}/Q:{totalDlQueue}/P:{totalDlProg}/D:{totalDlDecomp}]"; + $"Downloading {transferredFiles}/{totalFiles} files [W:{totalDlSlot}/Q:{totalDlQueue}/P:{totalDlProg}/D:{totalDlDecomp}/C:{totalDlComplete}]"; var bytesText = $"{UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}"; @@ -544,7 +573,7 @@ public class DownloadUi : WindowMediatorSubscriberBase foreach (var p in perPlayer) { var line = - $"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}] {p.TransferredFiles}/{p.TotalFiles}"; + $"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}/C:{p.DlComplete}] {p.TransferredFiles}/{p.TotalFiles}"; var lineSize = ImGui.CalcTextSize(line); if (lineSize.X > contentWidth) @@ -662,7 +691,7 @@ public class DownloadUi : WindowMediatorSubscriberBase && p.TransferredBytes > 0; var labelLine = - $"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}] {p.TransferredFiles}/{p.TotalFiles}"; + $"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}/C:{p.DlComplete}] {p.TransferredFiles}/{p.TotalFiles}"; if (!showBar) { @@ -721,13 +750,18 @@ public class DownloadUi : WindowMediatorSubscriberBase // Text inside bar: downloading vs decompressing string barText; - var isDecompressing = p.DlDecomp > 0 && p.TransferredBytes >= p.TotalBytes && p.TotalBytes > 0; + var isDecompressing = p.DlDecomp > 0; + var isAllComplete = p.DlComplete > 0 && p.DlProg == 0 && p.DlDecomp == 0 && p.DlQueue == 0 && p.DlSlot == 0; if (isDecompressing) { // Keep bar full, static text showing decompressing barText = "Decompressing..."; } + else if (isAllComplete) + { + barText = "Completed"; + } else { var bytesInside = @@ -808,6 +842,7 @@ public class DownloadUi : WindowMediatorSubscriberBase var dlQueue = 0; var dlProg = 0; var dlDecomp = 0; + var dlComplete = 0; long totalBytes = 0; long transferredBytes = 0; @@ -817,22 +852,29 @@ public class DownloadUi : WindowMediatorSubscriberBase var fileStatus = entry.Value; switch (fileStatus.DownloadStatus) { + case DownloadStatus.Initializing: dlQueue++; break; case DownloadStatus.WaitingForSlot: dlSlot++; break; case DownloadStatus.WaitingForQueue: dlQueue++; break; case DownloadStatus.Downloading: dlProg++; break; case DownloadStatus.Decompressing: dlDecomp++; break; + case DownloadStatus.Completed: dlComplete++; break; } totalBytes += fileStatus.TotalBytes; transferredBytes += fileStatus.TransferredBytes; } var progress = totalBytes > 0 ? (float)transferredBytes / totalBytes : 0f; + if (dlComplete > 0 && dlProg == 0 && dlDecomp == 0 && dlQueue == 0 && dlSlot == 0) + { + progress = 1f; + } string status; if (dlDecomp > 0) status = "decompressing"; else if (dlProg > 0) status = "downloading"; else if (dlQueue > 0) status = "queued"; else if (dlSlot > 0) status = "waiting"; + else if (dlComplete > 0) status = "completed"; else status = "completed"; downloadStatus.Add((item.Key.Name, progress, status)); diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 1c86580..730a788 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -863,10 +863,11 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.DrawHelpText( $"The download window will show the current progress of outstanding downloads.{Environment.NewLine}{Environment.NewLine}" + - $"What do W/Q/P/D stand for?{Environment.NewLine}W = Waiting for Slot (see Maximum Parallel Downloads){Environment.NewLine}" + + $"What do W/Q/P/D/C stand for?{Environment.NewLine}W = Waiting for Slot (see Maximum Parallel Downloads){Environment.NewLine}" + $"Q = Queued on Server, waiting for queue ready signal{Environment.NewLine}" + $"P = Processing download (aka downloading){Environment.NewLine}" + - $"D = Decompressing download"); + $"D = Decompressing download{Environment.NewLine}" + + $"C = Completed download"); if (!_configService.Current.ShowTransferWindow) ImGui.BeginDisabled(); ImGui.Indent(); diff --git a/LightlessSync/Utils/VariousExtensions.cs b/LightlessSync/Utils/VariousExtensions.cs index 0020bc9..d250279 100644 --- a/LightlessSync/Utils/VariousExtensions.cs +++ b/LightlessSync/Utils/VariousExtensions.cs @@ -57,7 +57,8 @@ public static class VariousExtensions } public static Dictionary> CheckUpdatedData(this CharacterData newData, Guid applicationBase, - CharacterData? oldData, ILogger logger, IPairPerformanceSubject cachedPlayer, bool forceApplyCustomization, bool forceApplyMods) + CharacterData? oldData, ILogger logger, IPairPerformanceSubject cachedPlayer, bool forceApplyCustomization, bool forceApplyMods, + bool suppressForcedRedrawOnForcedModApply = false) { oldData ??= new(); @@ -78,6 +79,7 @@ public static class VariousExtensions bool hasNewAndOldFileReplacements = newFileReplacements != null && existingFileReplacements != null; bool hasNewAndOldGlamourerData = newGlamourerData != null && existingGlamourerData != null; + var forceRedrawOnForcedApply = forceApplyMods && !suppressForcedRedrawOnForcedModApply; if (hasNewButNotOldFileReplacements || hasOldButNotNewFileReplacements || hasNewButNotOldGlamourerData || hasOldButNotNewGlamourerData) { @@ -100,7 +102,7 @@ public static class VariousExtensions { logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (FileReplacements not equal) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModFiles); charaDataToUpdate[objectKind].Add(PlayerChanges.ModFiles); - if (forceApplyMods || objectKind != ObjectKind.Player) + if (objectKind != ObjectKind.Player || forceRedrawOnForcedApply) { charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw); } @@ -167,7 +169,7 @@ public static class VariousExtensions if (objectKind != ObjectKind.Player) continue; bool manipDataDifferent = !string.Equals(oldData.ManipulationData, newData.ManipulationData, StringComparison.Ordinal); - if (manipDataDifferent || forceApplyMods) + if (manipDataDifferent || forceRedrawOnForcedApply) { logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff manip data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModManip); charaDataToUpdate[objectKind].Add(PlayerChanges.ModManip); diff --git a/LightlessSync/WebAPI/Files/FileDownloadManager.cs b/LightlessSync/WebAPI/Files/FileDownloadManager.cs index 8aa2b0b..552bcb3 100644 --- a/LightlessSync/WebAPI/Files/FileDownloadManager.cs +++ b/LightlessSync/WebAPI/Files/FileDownloadManager.cs @@ -28,8 +28,6 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase private readonly TextureMetadataHelper _textureMetadataHelper; private readonly ConcurrentDictionary _activeDownloadStreams; - private readonly SemaphoreSlim _decompressGate = - new(Math.Max(1, Environment.ProcessorCount / 2), Math.Max(1, Environment.ProcessorCount / 2)); private volatile bool _disableDirectDownloads; private int _consecutiveDirectDownloadFailures; @@ -502,18 +500,11 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase } } - private void RemoveStatus(string key) - { - lock (_downloadStatusLock) - { - _downloadStatus.Remove(key); - } - } - private async Task DecompressBlockFileAsync( string downloadStatusKey, string blockFilePath, Dictionary replacementLookup, + IReadOnlyDictionary rawSizeLookup, string downloadLabel, CancellationToken ct, bool skipDownscale) @@ -542,7 +533,9 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase if (!replacementLookup.TryGetValue(fileHash, out var repl)) { Logger.LogWarning("{dlName}: No replacement mapping for {fileHash}", downloadLabel, fileHash); - fileBlockStream.Seek(len, SeekOrigin.Current); + // still need to skip bytes: + var skip = checked((int)fileLengthBytes); + fileBlockStream.Position += skip; continue; } @@ -552,37 +545,23 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase // read compressed data var compressed = new byte[len]; + await ReadExactlyAsync(fileBlockStream, compressed.AsMemory(0, len), ct).ConfigureAwait(false); - if (len == 0) + MungeBuffer(compressed); + var decompressed = LZ4Wrapper.Unwrap(compressed); + + if (rawSizeLookup.TryGetValue(fileHash, out var expectedRawSize) + && expectedRawSize > 0 + && decompressed.LongLength != expectedRawSize) { - await _fileCompactor.WriteAllBytesAsync(filePath, Array.Empty(), ct).ConfigureAwait(false); - PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale); + Logger.LogWarning("{dlName}: Decompressed size mismatch for {fileHash} (expected {expected}, got {actual})", + downloadLabel, fileHash, expectedRawSize, decompressed.LongLength); continue; } - MungeBuffer(compressed); - - // limit concurrent decompressions - await _decompressGate.WaitAsync(ct).ConfigureAwait(false); - try - { - var sw = System.Diagnostics.Stopwatch.StartNew(); - - // decompress - var decompressed = LZ4Wrapper.Unwrap(compressed); - - Logger.LogTrace("{dlName}: Unwrap {fileHash} took {ms}ms (compressed {c} bytes, decompressed {d} bytes)", - downloadLabel, fileHash, sw.ElapsedMilliseconds, compressed.Length, decompressed?.Length ?? -1); - - // write to file - await _fileCompactor.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false); - PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale); - } - finally - { - _decompressGate.Release(); - } + await _fileCompactor.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false); + PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale); } catch (EndOfStreamException) { @@ -594,6 +573,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase } } } + + SetStatus(downloadStatusKey, DownloadStatus.Completed); } catch (EndOfStreamException) { @@ -603,10 +584,6 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase { Logger.LogError(ex, "{dlName}: Error during block file read", downloadLabel); } - finally - { - RemoveStatus(downloadStatusKey); - } } public async Task> InitiateDownloadList( @@ -644,21 +621,25 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase .. await FilesGetSizes(hashes, ct).ConfigureAwait(false), ]; + Logger.LogDebug("Files with size 0 or less: {files}", + string.Join(", ", downloadFileInfoFromService.Where(f => f.Size <= 0).Select(f => f.Hash))); + foreach (var dto in downloadFileInfoFromService.Where(c => c.IsForbidden)) { if (!_orchestrator.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, dto.Hash, StringComparison.Ordinal))) _orchestrator.ForbiddenTransfers.Add(new DownloadFileTransfer(dto)); } - CurrentDownloads = [.. downloadFileInfoFromService + CurrentDownloads = downloadFileInfoFromService .Distinct() .Select(d => new DownloadFileTransfer(d)) - .Where(d => d.CanBeTransferred)]; + .Where(d => d.CanBeTransferred) + .ToList(); return CurrentDownloads; } - private sealed record BatchChunk(string Key, List Items); + private sealed record BatchChunk(string HostKey, string StatusKey, List Items); private static IEnumerable> ChunkList(List items, int chunkSize) { @@ -684,6 +665,20 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase var allowDirectDownloads = ShouldUseDirectDownloads(); var replacementLookup = BuildReplacementLookup(fileReplacement); + var rawSizeLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var download in CurrentDownloads) + { + if (string.IsNullOrWhiteSpace(download.Hash)) + { + continue; + } + + if (!rawSizeLookup.TryGetValue(download.Hash, out var existing) || existing <= 0) + { + rawSizeLookup[download.Hash] = download.TotalRaw; + } + } var directDownloads = new List(); var batchDownloads = new List(); @@ -708,7 +703,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase var chunkSize = (int)Math.Ceiling(list.Count / (double)chunkCount); return ChunkList(list, chunkSize) - .Select(chunk => new BatchChunk(g.Key, chunk)); + .Select((chunk, index) => new BatchChunk(g.Key, $"{g.Key}#{index + 1}", chunk)); }) .ToArray(); @@ -722,7 +717,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase { _downloadStatus[d.DirectDownloadUrl!] = new FileDownloadStatus { - DownloadStatus = DownloadStatus.Initializing, + DownloadStatus = DownloadStatus.WaitingForSlot, TotalBytes = d.Total, TotalFiles = 1, TransferredBytes = 0, @@ -730,12 +725,12 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase }; } - foreach (var g in batchChunks.GroupBy(c => c.Key, StringComparer.Ordinal)) + foreach (var chunk in batchChunks) { - _downloadStatus[g.Key] = new FileDownloadStatus + _downloadStatus[chunk.StatusKey] = new FileDownloadStatus { - DownloadStatus = DownloadStatus.Initializing, - TotalBytes = g.SelectMany(x => x.Items).Sum(x => x.Total), + DownloadStatus = DownloadStatus.WaitingForQueue, + TotalBytes = chunk.Items.Sum(x => x.Total), TotalFiles = 1, TransferredBytes = 0, TransferredFiles = 0 @@ -759,13 +754,13 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase Task batchTask = batchChunks.Length == 0 ? Task.CompletedTask : Parallel.ForEachAsync(batchChunks, new ParallelOptions { MaxDegreeOfParallelism = workerDop, CancellationToken = ct }, - async (chunk, token) => await ProcessBatchChunkAsync(chunk, replacementLookup, token, skipDownscale).ConfigureAwait(false)); + async (chunk, token) => await ProcessBatchChunkAsync(chunk, replacementLookup, rawSizeLookup, token, skipDownscale).ConfigureAwait(false)); // direct downloads Task directTask = directDownloads.Count == 0 ? Task.CompletedTask : Parallel.ForEachAsync(directDownloads, new ParallelOptions { MaxDegreeOfParallelism = workerDop, CancellationToken = ct }, - async (d, token) => await ProcessDirectAsync(d, replacementLookup, token, skipDownscale).ConfigureAwait(false)); + async (d, token) => await ProcessDirectAsync(d, replacementLookup, rawSizeLookup, token, skipDownscale).ConfigureAwait(false)); await Task.WhenAll(batchTask, directTask).ConfigureAwait(false); @@ -773,9 +768,14 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase ClearDownload(); } - private async Task ProcessBatchChunkAsync(BatchChunk chunk, Dictionary replacementLookup, CancellationToken ct, bool skipDownscale) + private async Task ProcessBatchChunkAsync( + BatchChunk chunk, + Dictionary replacementLookup, + IReadOnlyDictionary rawSizeLookup, + CancellationToken ct, + bool skipDownscale) { - var statusKey = chunk.Key; + var statusKey = chunk.StatusKey; // enqueue (no slot) SetStatus(statusKey, DownloadStatus.WaitingForQueue); @@ -803,10 +803,11 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase if (!File.Exists(blockFile)) { Logger.LogWarning("{dlName}: Block file missing before extraction, skipping", fi.Name); + SetStatus(statusKey, DownloadStatus.Completed); return; } - await DecompressBlockFileAsync(statusKey, blockFile, replacementLookup, fi.Name, ct, skipDownscale).ConfigureAwait(false); + await DecompressBlockFileAsync(statusKey, blockFile, replacementLookup, rawSizeLookup, fi.Name, ct, skipDownscale).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -823,7 +824,12 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase } } - private async Task ProcessDirectAsync(DownloadFileTransfer directDownload, Dictionary replacementLookup, CancellationToken ct, bool skipDownscale) + private async Task ProcessDirectAsync( + DownloadFileTransfer directDownload, + Dictionary replacementLookup, + IReadOnlyDictionary rawSizeLookup, + CancellationToken ct, + bool skipDownscale) { var progress = CreateInlineProgress(bytes => { @@ -833,7 +839,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase if (!ShouldUseDirectDownloads() || string.IsNullOrEmpty(directDownload.DirectDownloadUrl)) { - await ProcessDirectAsQueuedFallbackAsync(directDownload, replacementLookup, progress, ct, skipDownscale).ConfigureAwait(false); + await ProcessDirectAsQueuedFallbackAsync(directDownload, replacementLookup, rawSizeLookup, progress, ct, skipDownscale).ConfigureAwait(false); return; } @@ -861,6 +867,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase if (!replacementLookup.TryGetValue(directDownload.Hash, out var repl)) { Logger.LogWarning("{hash}: No replacement data found for direct download.", directDownload.Hash); + SetStatus(directDownload.DirectDownloadUrl!, DownloadStatus.Completed); return; } @@ -873,13 +880,18 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase byte[] compressedBytes = await File.ReadAllBytesAsync(tempFilename, ct).ConfigureAwait(false); var decompressedBytes = LZ4Wrapper.Unwrap(compressedBytes); + if (directDownload.TotalRaw > 0 && decompressedBytes.LongLength != directDownload.TotalRaw) + { + throw new InvalidDataException( + $"{directDownload.Hash}: Decompressed size mismatch (expected {directDownload.TotalRaw}, got {decompressedBytes.LongLength})"); + } + await _fileCompactor.WriteAllBytesAsync(finalFilename, decompressedBytes, ct).ConfigureAwait(false); PersistFileToStorage(directDownload.Hash, finalFilename, repl.GamePath, skipDownscale); MarkTransferredFiles(directDownload.DirectDownloadUrl!, 1); + SetStatus(directDownload.DirectDownloadUrl!, DownloadStatus.Completed); Logger.LogDebug("Finished direct download of {hash}.", directDownload.Hash); - - RemoveStatus(directDownload.DirectDownloadUrl!); } catch (OperationCanceledException ex) { @@ -902,7 +914,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase try { - await ProcessDirectAsQueuedFallbackAsync(directDownload, replacementLookup, progress, ct, skipDownscale).ConfigureAwait(false); + await ProcessDirectAsQueuedFallbackAsync(directDownload, replacementLookup, rawSizeLookup, progress, ct, skipDownscale).ConfigureAwait(false); if (!expectedDirectDownloadFailure && failureCount >= 3 && !_disableDirectDownloads) { @@ -929,6 +941,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase private async Task ProcessDirectAsQueuedFallbackAsync( DownloadFileTransfer directDownload, Dictionary replacementLookup, + IReadOnlyDictionary rawSizeLookup, IProgress progress, CancellationToken ct, bool skipDownscale) @@ -956,7 +969,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase if (!File.Exists(blockFile)) throw new FileNotFoundException("Block file missing after direct download fallback.", blockFile); - await DecompressBlockFileAsync(statusKey, blockFile, replacementLookup, $"fallback-{directDownload.Hash}", ct, skipDownscale) + await DecompressBlockFileAsync(statusKey, blockFile, replacementLookup, rawSizeLookup, $"fallback-{directDownload.Hash}", ct, skipDownscale) .ConfigureAwait(false); } finally @@ -1003,11 +1016,15 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase try { - var entry = _fileDbManager.CreateCacheEntry(filePath); - var mapKind = _textureMetadataHelper.DetermineMapKind(gamePath, filePath); + var entry = _fileDbManager.CreateCacheEntryWithKnownHash(filePath, fileHash); - if (!skipDownscale) - _textureDownscaleService.ScheduleDownscale(fileHash, filePath, mapKind); + if (!skipDownscale && _textureDownscaleService.ShouldScheduleDownscale(filePath)) + { + _textureDownscaleService.ScheduleDownscale( + fileHash, + filePath, + () => _textureMetadataHelper.DetermineMapKind(gamePath, filePath)); + } if (entry != null && !string.Equals(entry.Hash, fileHash, StringComparison.OrdinalIgnoreCase)) { diff --git a/LightlessSync/WebAPI/Files/Models/DownloadStatus.cs b/LightlessSync/WebAPI/Files/Models/DownloadStatus.cs index 6e10a73..3e210c8 100644 --- a/LightlessSync/WebAPI/Files/Models/DownloadStatus.cs +++ b/LightlessSync/WebAPI/Files/Models/DownloadStatus.cs @@ -6,5 +6,6 @@ public enum DownloadStatus WaitingForSlot, WaitingForQueue, Downloading, - Decompressing + Decompressing, + Completed } \ No newline at end of file