diff --git a/LightlessSync/Changelog/changelog.yaml b/LightlessSync/Changelog/changelog.yaml index 18a0a8c..942964b 100644 --- a/LightlessSync/Changelog/changelog.yaml +++ b/LightlessSync/Changelog/changelog.yaml @@ -1,11 +1,44 @@ -tagline: "Lightless Sync v2.0.0" +tagline: "Lightless Sync v2.0.1" subline: "LIGHTLESS IS EVOLVING!!" changelog: + - name: "v2.0.1" + tagline: "Some Fixes" + date: "December 23 2025" + # be sure to set this every new version + isCurrent: true + versions: + - number: "Chat" + icon: "" + items: + - "You can turn off the syncshell chat as Owner by going to the Syncshell Admin panel -> Owner -> Enable/Disable Chat." + - "Fixed an issue where you can't chat due to regions being in a different language." + - number: "LightFinder" + icon: "" + items: + - "The icon/Lightfinder Text will be hidden when Game UI is hidden and behind game elements/UI" + - "Able to select an icon for the selected list or a custom glyph if you know the code." + - "Smoothing and reducing jitter on the icon/Lightfinder Text." + - "Fixed so higher scaled UI options (100/150/200% UI scale) wouldn't break the element." + - "Detects if GPose is active, wouldn't render the elements" + - number: "Miscellaneous fixes" + icon: "" + items: + - "Fixed the null error given on GetCID when transferring between zones/housing." + - "Added push/pop on certain ImGUI elements to remove them after being used. " + - "Having all tabs open in the Main UI wouldn't lag out the game anymore." + - "Cycle pause has been adjusted to the old function. There is a separate button to pause normally, now called 'Toggle (Un)Pause State'." + - "Changes have been made to the character redraw to address the issues with the building character data constantly being redrawn and the redrawn behavior with Honorific titles." + - "GPose characters should appear again in the actor screen" + - "Lightspeed download console messages are no longer shown as warnings." + - number: "Server Updates" + icon: "" + items: + - "Changes have been made to the disabling of your profile. It should save again." + - "Ability added to toggle chats from syncshell to be disabled." + - "Files are continuously being deleted due to high volumes in storage, potentially causing MCDOs to have missing files. We have increased the limit of the storage in our configurations to see if that helps." - name: "v2.0.0" tagline: "Thank you for 4 months!" date: "December 2025" - # be sure to set this every new version - isCurrent: true versions: - number: "Lightless Chat" icon: "" diff --git a/LightlessSync/FileCache/TransientResourceManager.cs b/LightlessSync/FileCache/TransientResourceManager.cs index 2ef8f22..115c616 100644 --- a/LightlessSync/FileCache/TransientResourceManager.cs +++ b/LightlessSync/FileCache/TransientResourceManager.cs @@ -59,7 +59,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase _playerRelatedPointers.Remove(msg.GameObjectHandler); }); - foreach (var descriptor in _actorObjectService.PlayerDescriptors) + foreach (var descriptor in _actorObjectService.ObjectDescriptors) { HandleActorTracked(descriptor); } @@ -291,7 +291,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase } var activeDescriptors = new Dictionary(); - foreach (var descriptor in _actorObjectService.PlayerDescriptors) + foreach (var descriptor in _actorObjectService.ObjectDescriptors) { if (TryResolveObjectKind(descriptor, out var resolvedKind)) { diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index 4987cc1..b368724 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -155,4 +155,5 @@ public class LightlessConfig : ILightlessConfiguration public string? SelectedFinderSyncshell { get; set; } = null; public string LastSeenVersion { get; set; } = string.Empty; public bool EnableParticleEffects { get; set; } = true; + public HashSet OrphanableTempCollections { get; set; } = []; } diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 81076b2..707d2a3 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -3,7 +3,7 @@ - 2.0.1 + 2.0.2 https://github.com/Light-Public-Syncshells/LightlessClient diff --git a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs index 65709d1..c90096d 100644 --- a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs +++ b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs @@ -16,6 +16,8 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP private readonly Func _getAddress; private readonly bool _isOwnedObject; private readonly PerformanceCollectorService _performanceCollector; + private readonly object _frameworkUpdateGate = new(); + private bool _frameworkUpdateSubscribed; private byte _classJob = 0; private Task? _delayedZoningTask; private bool _haltProcessing = false; @@ -47,7 +49,10 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP }); } - Mediator.Subscribe(this, (_) => FrameworkUpdate()); + if (_isOwnedObject) + { + EnableFrameworkUpdates(); + } Mediator.Subscribe(this, (_) => ZoneSwitchEnd()); Mediator.Subscribe(this, (_) => ZoneSwitchStart()); @@ -109,7 +114,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP { while (await _dalamudUtil.RunOnFrameworkThread(() => { - if (_haltProcessing) CheckAndUpdateObject(); + EnsureLatestObjectState(); if (CurrentDrawCondition != DrawCondition.None) return true; var gameObj = _dalamudUtil.CreateGameObject(Address); if (gameObj is Dalamud.Game.ClientState.Objects.Types.ICharacter chara) @@ -148,6 +153,11 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP _haltProcessing = false; } + public void Refresh() + { + _dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult(); + } + public async Task IsBeingDrawnRunOnFrameworkAsync() { return await _dalamudUtil.RunOnFrameworkThread(IsBeingDrawn).ConfigureAwait(false); @@ -361,7 +371,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP private bool IsBeingDrawn() { - if (_haltProcessing) CheckAndUpdateObject(); + EnsureLatestObjectState(); if (_dalamudUtil.IsAnythingDrawing) { @@ -373,6 +383,28 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP return CurrentDrawCondition != DrawCondition.None; } + private void EnsureLatestObjectState() + { + if (_haltProcessing || !_frameworkUpdateSubscribed) + { + CheckAndUpdateObject(); + } + } + + private void EnableFrameworkUpdates() + { + lock (_frameworkUpdateGate) + { + if (_frameworkUpdateSubscribed) + { + return; + } + + Mediator.Subscribe(this, _ => FrameworkUpdate()); + _frameworkUpdateSubscribed = true; + } + } + private unsafe DrawCondition IsBeingDrawnUnsafe() { if (Address == IntPtr.Zero) return DrawCondition.ObjectZero; diff --git a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs index 5561bfe..0566491 100644 --- a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs @@ -25,6 +25,11 @@ bool IsDownloading { get; } int PendingDownloadCount { get; } int ForbiddenDownloadCount { get; } + bool PendingModReapply { get; } + bool ModApplyDeferred { get; } + int MissingCriticalMods { get; } + int MissingNonCriticalMods { get; } + int MissingForbiddenMods { get; } DateTime? InvisibleSinceUtc { get; } DateTime? VisibilityEvictionDueAtUtc { get; } diff --git a/LightlessSync/PlayerData/Pairs/Pair.cs b/LightlessSync/PlayerData/Pairs/Pair.cs index 935b705..2a85cd3 100644 --- a/LightlessSync/PlayerData/Pairs/Pair.cs +++ b/LightlessSync/PlayerData/Pairs/Pair.cs @@ -87,22 +87,25 @@ public class Pair return; } - if (args.Target is not MenuTargetDefault target || target.TargetObjectId != handler.PlayerCharacterId || IsPaused) + if (args.Target is not MenuTargetDefault target || target.TargetObjectId != handler.PlayerCharacterId) { return; } - UiSharedService.AddContextMenuItem(args, name: "Open Profile", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => + if (!IsPaused) { - _mediator.Publish(new ProfileOpenStandaloneMessage(this)); - return Task.CompletedTask; - }); + UiSharedService.AddContextMenuItem(args, name: "Open Profile", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => + { + _mediator.Publish(new ProfileOpenStandaloneMessage(this)); + return Task.CompletedTask; + }); - UiSharedService.AddContextMenuItem(args, name: "Reapply last data", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => - { - ApplyLastReceivedData(forced: true); - return Task.CompletedTask; - }); + UiSharedService.AddContextMenuItem(args, name: "Reapply last data", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => + { + ApplyLastReceivedData(forced: true); + return Task.CompletedTask; + }); + } UiSharedService.AddContextMenuItem(args, name: "Change Permissions", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => { @@ -110,7 +113,24 @@ public class Pair return Task.CompletedTask; }); - UiSharedService.AddContextMenuItem(args, name: "Cycle pause state", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => + if (IsPaused) + { + UiSharedService.AddContextMenuItem(args, name: "Toggle Unpause State", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => + { + _ = _apiController.Value.UnpauseAsync(UserData); + return Task.CompletedTask; + }); + } + else + { + UiSharedService.AddContextMenuItem(args, name: "Toggle Pause State", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => + { + _ = _apiController.Value.PauseAsync(UserData); + return Task.CompletedTask; + }); + } + + UiSharedService.AddContextMenuItem(args, name: "Cycle Pause State", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => { TriggerCyclePause(); return Task.CompletedTask; @@ -218,6 +238,11 @@ public class Pair handler.IsApplying, handler.IsDownloading, handler.PendingDownloadCount, - handler.ForbiddenDownloadCount); + handler.ForbiddenDownloadCount, + handler.PendingModReapply, + handler.ModApplyDeferred, + handler.MissingCriticalMods, + handler.MissingNonCriticalMods, + handler.MissingForbiddenMods); } } diff --git a/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs b/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs index 31c3236..60abf35 100644 --- a/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs +++ b/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs @@ -16,7 +16,12 @@ public sealed record PairDebugInfo( bool IsApplying, bool IsDownloading, int PendingDownloadCount, - int ForbiddenDownloadCount) + int ForbiddenDownloadCount, + bool PendingModReapply, + bool ModApplyDeferred, + int MissingCriticalMods, + int MissingNonCriticalMods, + int MissingForbiddenMods) { public static PairDebugInfo Empty { get; } = new( false, @@ -34,5 +39,10 @@ public sealed record PairDebugInfo( false, false, 0, + 0, + false, + false, + 0, + 0, 0); } diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index 706b0bc..178daa8 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -8,6 +8,7 @@ using LightlessSync.Interop.Ipc; using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Handlers; using LightlessSync.Services; +using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Events; using LightlessSync.Services.Mediator; using LightlessSync.Services.PairProcessing; @@ -18,6 +19,7 @@ using LightlessSync.WebAPI.Files; using LightlessSync.WebAPI.Files.Models; 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; @@ -31,6 +33,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private sealed record CombatData(Guid ApplicationId, CharacterData CharacterData, bool Forced); private readonly DalamudUtilService _dalamudUtil; + private readonly ActorObjectService _actorObjectService; private readonly FileDownloadManager _downloadManager; private readonly FileCacheManager _fileDbManager; private readonly GameObjectHandlerFactory _gameObjectHandlerFactory; @@ -43,6 +46,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private readonly TextureDownscaleService _textureDownscaleService; private readonly PairStateCache _pairStateCache; private readonly PairPerformanceMetricsCache _performanceMetricsCache; + private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor; private readonly PairManager _pairManager; private CancellationTokenSource? _applicationCancellationTokenSource; private Guid _applicationId; @@ -56,11 +60,15 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa 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 _isVisible; private Guid _penumbraCollection; private readonly object _collectionGate = new(); private bool _redrawOnNextApplication = false; - private bool _explicitRedrawQueued; private readonly object _initializationGate = new(); private readonly object _pauseLock = new(); private Task _pauseTransitionTask = Task.CompletedTask; @@ -73,8 +81,23 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private readonly object _visibilityGraceGate = new(); private CancellationTokenSource? _visibilityGraceCts; private static readonly TimeSpan VisibilityEvictionGrace = TimeSpan.FromMinutes(1); + private static readonly HashSet NonPriorityModExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".tmb", + ".pap", + ".atex", + ".avfx", + ".scd" + }; 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); + private readonly object _actorInitializationGate = new(); + private ActorObjectService.ActorDescriptor? _pendingActorDescriptor; + private bool _actorInitializationInProgress; + private bool _frameworkUpdateSubscribed; public DateTime? InvisibleSinceUtc => _invisibleSinceUtc; public DateTime? VisibilityEvictionDueAtUtc => _visibilityEvictionDueAtUtc; @@ -126,6 +149,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa public long LastAppliedApproximateVRAMBytes { get; set; } = -1; public long LastAppliedApproximateEffectiveVRAMBytes { get; set; } = -1; public CharacterData? LastReceivedCharacterData { get; private set; } + public bool PendingModReapply => _pendingModReapply; + public bool ModApplyDeferred => _lastModApplyDeferred; + public int MissingCriticalMods => _lastMissingCriticalMods; + public int MissingNonCriticalMods => _lastMissingNonCriticalMods; + public int MissingForbiddenMods => _lastMissingForbiddenMods; public DateTime? LastDataReceivedAt => _lastDataReceivedAt; public DateTime? LastApplyAttemptAt => _lastApplyAttemptAt; public DateTime? LastSuccessfulApplyAt => _lastSuccessfulApplyAt; @@ -146,6 +174,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa FileDownloadManager transferManager, PluginWarningNotificationService pluginWarningNotificationManager, DalamudUtilService dalamudUtil, + ActorObjectService actorObjectService, IHostApplicationLifetime lifetime, FileCacheManager fileDbManager, PlayerPerformanceService playerPerformanceService, @@ -153,7 +182,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa ServerConfigurationManager serverConfigManager, TextureDownscaleService textureDownscaleService, PairStateCache pairStateCache, - PairPerformanceMetricsCache performanceMetricsCache) : base(logger, mediator) + PairPerformanceMetricsCache performanceMetricsCache, + PenumbraTempCollectionJanitor tempCollectionJanitor) : base(logger, mediator) { _pairManager = pairManager; Ident = ident; @@ -162,6 +192,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _downloadManager = transferManager; _pluginWarningNotificationManager = pluginWarningNotificationManager; _dalamudUtil = dalamudUtil; + _actorObjectService = actorObjectService; _lifetime = lifetime; _fileDbManager = fileDbManager; _playerPerformanceService = playerPerformanceService; @@ -170,7 +201,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _textureDownscaleService = textureDownscaleService; _pairStateCache = pairStateCache; _performanceMetricsCache = performanceMetricsCache; - LastAppliedDataBytes = -1; + _tempCollectionJanitor = tempCollectionJanitor; } public void Initialize() @@ -185,6 +216,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return; } + ActorObjectService.ActorDescriptor? trackedDescriptor = null; lock (_initializationGate) { if (Initialized) @@ -198,7 +230,12 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _forceApplyMods = true; } - Mediator.Subscribe(this, _ => FrameworkUpdate()); + var useFrameworkUpdate = !_actorObjectService.HooksActive; + if (useFrameworkUpdate) + { + Mediator.Subscribe(this, _ => FrameworkUpdate()); + _frameworkUpdateSubscribed = true; + } Mediator.Subscribe(this, _ => { _downloadCancellationTokenSource?.CancelDispose(); @@ -234,17 +271,49 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Mediator.Subscribe(this, _ => EnableSync()); Mediator.Subscribe(this, _ => DisableSync()); Mediator.Subscribe(this, _ => EnableSync()); - Mediator.Subscribe(this, msg => - { - if (_charaHandler is null || !ReferenceEquals(msg.DownloadId, _charaHandler)) + Mediator.Subscribe(this, msg => HandleActorTracked(msg.Descriptor)); + Mediator.Subscribe(this, msg => HandleActorUntracked(msg.Descriptor)); + Mediator.Subscribe(this, msg => { - return; + if (_charaHandler is null || !ReferenceEquals(msg.DownloadId, _charaHandler)) + { + 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(); + }); + + if (!useFrameworkUpdate + && _actorObjectService.TryGetActorByHash(Ident, out var descriptor) + && descriptor.Address != nint.Zero) + { + trackedDescriptor = descriptor; } - TryApplyQueuedData(); - }); Initialized = true; } + + if (trackedDescriptor.HasValue) + { + HandleActorTracked(trackedDescriptor.Value); + } } private IReadOnlyList GetCurrentPairs() @@ -355,6 +424,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { _penumbraCollection = created; _pairStateCache.StoreTemporaryCollection(Ident, created); + _tempCollectionJanitor.Register(created); } return _penumbraCollection; @@ -387,6 +457,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _needsCollectionRebuild = true; _forceFullReapply = true; _forceApplyMods = true; + _tempCollectionJanitor.Unregister(toRelease); } if (!releaseFromPenumbra || toRelease == Guid.Empty || !_ipcManager.Penumbra.APIAvailable) @@ -737,6 +808,67 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return true; } + private bool IsForbiddenHash(string hash) + => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, hash, StringComparison.Ordinal)); + + private static bool IsNonPriorityModPath(string? gamePath) + { + if (string.IsNullOrEmpty(gamePath)) + { + return false; + } + + var extension = Path.GetExtension(gamePath); + return !string.IsNullOrEmpty(extension) && NonPriorityModExtensions.Contains(extension); + } + + private static bool IsCriticalModReplacement(FileReplacementData replacement) + { + foreach (var gamePath in replacement.GamePaths) + { + if (!IsNonPriorityModPath(gamePath)) + { + return true; + } + } + + return false; + } + + private void CountMissingReplacements(IEnumerable missing, out int critical, out int nonCritical, out int forbidden) + { + critical = 0; + nonCritical = 0; + forbidden = 0; + + foreach (var replacement in missing) + { + if (IsForbiddenHash(replacement.Hash)) + { + forbidden++; + } + + if (IsCriticalModReplacement(replacement)) + { + critical++; + } + else + { + nonCritical++; + } + } + } + + private static void RemoveModApplyChanges(Dictionary> updatedData) + { + foreach (var changes in updatedData.Values) + { + changes.Remove(PlayerChanges.ModFiles); + changes.Remove(PlayerChanges.ModManip); + changes.Remove(PlayerChanges.ForcedRedraw); + } + } + private bool CanApplyNow() { return !_dalamudUtil.IsInCombat @@ -760,6 +892,16 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _lastBlockingConditions = Array.Empty(); } + private void DeferApplication(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization, UserData user, string reason, + string failureKey, LogLevel logLevel, string logMessage, params object?[] logArgs) + { + Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, reason))); + Logger.Log(logLevel, logMessage, logArgs); + RecordFailure(reason, failureKey); + _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); + SetUploading(false); + } + public void ApplyCharacterData(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization = false) { _lastApplyAttemptAt = DateTime.UtcNow; @@ -777,72 +919,48 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (_dalamudUtil.IsInCombat) { const string reason = "Cannot apply character data: you are in combat, deferring application"; - Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, - reason))); - Logger.LogDebug("[BASE-{appBase}] Received data but player is in combat", applicationBase); - RecordFailure(reason, "Combat"); - _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); - SetUploading(false); + DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "Combat", LogLevel.Debug, + "[BASE-{appBase}] Received data but player is in combat", applicationBase); return; } if (_dalamudUtil.IsPerforming) { const string reason = "Cannot apply character data: you are performing music, deferring application"; - Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, - reason))); - Logger.LogDebug("[BASE-{appBase}] Received data but player is performing", applicationBase); - RecordFailure(reason, "Performance"); - _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); - SetUploading(false); + DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "Performance", LogLevel.Debug, + "[BASE-{appBase}] Received data but player is performing", applicationBase); return; } if (_dalamudUtil.IsInInstance) { const string reason = "Cannot apply character data: you are in an instance, deferring application"; - Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, - reason))); - Logger.LogDebug("[BASE-{appBase}] Received data but player is in instance", applicationBase); - RecordFailure(reason, "Instance"); - _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); - SetUploading(false); + DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "Instance", LogLevel.Debug, + "[BASE-{appBase}] Received data but player is in instance", applicationBase); return; } if (_dalamudUtil.IsInCutscene) { const string reason = "Cannot apply character data: you are in a cutscene, deferring application"; - Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, - reason))); - Logger.LogDebug("[BASE-{appBase}] Received data but player is in a cutscene", applicationBase); - RecordFailure(reason, "Cutscene"); - _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); - SetUploading(false); + DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "Cutscene", LogLevel.Debug, + "[BASE-{appBase}] Received data but player is in a cutscene", applicationBase); return; } if (_dalamudUtil.IsInGpose) { const string reason = "Cannot apply character data: you are in GPose, deferring application"; - Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, - reason))); - Logger.LogDebug("[BASE-{appBase}] Received data but player is in GPose", applicationBase); - RecordFailure(reason, "GPose"); - _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); - SetUploading(false); + DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "GPose", LogLevel.Debug, + "[BASE-{appBase}] Received data but player is in GPose", applicationBase); return; } if (!_ipcManager.Penumbra.APIAvailable || !_ipcManager.Glamourer.APIAvailable) { const string reason = "Cannot apply character data: Penumbra or Glamourer is not available, deferring application"; - Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, - reason))); - Logger.LogInformation("[BASE-{appbase}] Application of data for {player} while Penumbra/Glamourer unavailable, returning", applicationBase, GetLogIdentifier()); - RecordFailure(reason, "PluginUnavailable"); - _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); - SetUploading(false); + DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "PluginUnavailable", LogLevel.Information, + "[BASE-{appbase}] Application of data for {player} while Penumbra/Glamourer unavailable, returning", applicationBase, GetLogIdentifier()); return; } @@ -885,13 +1003,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _forceApplyMods = false; } - _explicitRedrawQueued = false; - if (_redrawOnNextApplication && charaDataToUpdate.TryGetValue(ObjectKind.Player, out var player)) { player.Add(PlayerChanges.ForcedRedraw); _redrawOnNextApplication = false; - _explicitRedrawQueued = true; } if (charaDataToUpdate.TryGetValue(ObjectKind.Player, out var playerChanges)) @@ -1085,7 +1200,14 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger.LogDebug("[{applicationId}] Applying Customization Data for {handler}", applicationId, handler); await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handler, applicationId, 30000, token).ConfigureAwait(false); + if (handler.Address != nint.Zero) + { + await _actorObjectService.WaitForFullyLoadedAsync(handler.Address, token).ConfigureAwait(false); + } + token.ThrowIfCancellationRequested(); + var tasks = new List(); + bool needsRedraw = false; foreach (var change in changes.Value.OrderBy(p => (int)p)) { Logger.LogDebug("[{applicationId}] Processing {change} for {handler}", applicationId, change, handler); @@ -1094,45 +1216,39 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa case PlayerChanges.Customize: if (charaData.CustomizePlusData.TryGetValue(changes.Key, out var customizePlusData)) { - _customizeIds[changes.Key] = await _ipcManager.CustomizePlus.SetBodyScaleAsync(handler.Address, customizePlusData).ConfigureAwait(false); + tasks.Add(ApplyCustomizeAsync(handler.Address, customizePlusData, changes.Key)); } else if (_customizeIds.TryGetValue(changes.Key, out var customizeId)) { - await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false); - _customizeIds.Remove(changes.Key); + tasks.Add(RevertCustomizeAsync(customizeId, changes.Key)); } break; case PlayerChanges.Heels: - await _ipcManager.Heels.SetOffsetForPlayerAsync(handler.Address, charaData.HeelsData).ConfigureAwait(false); + tasks.Add(_ipcManager.Heels.SetOffsetForPlayerAsync(handler.Address, charaData.HeelsData)); break; case PlayerChanges.Honorific: - await _ipcManager.Honorific.SetTitleAsync(handler.Address, charaData.HonorificData).ConfigureAwait(false); + tasks.Add(_ipcManager.Honorific.SetTitleAsync(handler.Address, charaData.HonorificData)); break; case PlayerChanges.Glamourer: if (charaData.GlamourerData.TryGetValue(changes.Key, out var glamourerData)) { - await _ipcManager.Glamourer.ApplyAllAsync(Logger, handler, glamourerData, applicationId, token).ConfigureAwait(false); + tasks.Add(_ipcManager.Glamourer.ApplyAllAsync(Logger, handler, glamourerData, applicationId, token)); } break; case PlayerChanges.Moodles: - await _ipcManager.Moodles.SetStatusAsync(handler.Address, charaData.MoodlesData).ConfigureAwait(false); + tasks.Add(_ipcManager.Moodles.SetStatusAsync(handler.Address, charaData.MoodlesData)); break; case PlayerChanges.PetNames: - await _ipcManager.PetNames.SetPlayerData(handler.Address, charaData.PetNamesData).ConfigureAwait(false); + tasks.Add(_ipcManager.PetNames.SetPlayerData(handler.Address, charaData.PetNamesData)); break; case PlayerChanges.ForcedRedraw: - if (!ShouldPerformForcedRedraw(changes.Key, changes.Value, charaData)) - { - Logger.LogTrace("[{applicationId}] Skipping forced redraw for {handler}", applicationId, handler); - break; - } - await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, token).ConfigureAwait(false); + needsRedraw = true; break; default: @@ -1140,6 +1256,16 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } token.ThrowIfCancellationRequested(); } + + if (tasks.Count > 0) + { + await Task.WhenAll(tasks).ConfigureAwait(false); + } + + if (needsRedraw) + { + await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, token).ConfigureAwait(false); + } } finally { @@ -1147,44 +1273,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } - private bool ShouldPerformForcedRedraw(ObjectKind objectKind, ICollection changeSet, CharacterData newData) - { - if (objectKind != ObjectKind.Player) - { - return true; - } - - var hasModFiles = changeSet.Contains(PlayerChanges.ModFiles); - var hasManip = changeSet.Contains(PlayerChanges.ModManip); - var modsChanged = hasModFiles && PlayerModFilesChanged(newData, _cachedData); - var manipChanged = hasManip && !string.Equals(_cachedData?.ManipulationData, newData.ManipulationData, StringComparison.Ordinal); - - if (modsChanged) - { - _explicitRedrawQueued = false; - return true; - } - - if (manipChanged) - { - _explicitRedrawQueued = false; - return true; - } - - if (_explicitRedrawQueued) - { - _explicitRedrawQueued = false; - return true; - } - - if ((hasModFiles || hasManip) && (_forceFullReapply || _needsCollectionRebuild)) - { - _explicitRedrawQueued = false; - return true; - } - - return false; - } private static Dictionary> BuildFullChangeSet(CharacterData characterData) { @@ -1339,6 +1427,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa bool skipDownscaleForPair = ShouldSkipDownscale(); var user = GetPrimaryUserData(); Dictionary<(string GamePath, string? Hash), string> moddedPaths; + List missingReplacements = []; if (updateModdedPaths) { @@ -1350,6 +1439,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { int attempts = 0; List toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); + missingReplacements = toDownloadReplacements; while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested) { @@ -1399,6 +1489,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } 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)))) { @@ -1422,6 +1513,54 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa : []; } + 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) + { + deferModApply = true; + _lastModApplyDeferred = true; + Logger.LogDebug("[BASE-{appBase}] Critical mod files missing for {handler}, deferring mod apply ({count} missing)", + applicationBase, GetLogIdentifier(), missingReplacements.Count); + } + 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); + } + downloadToken.ThrowIfCancellationRequested(); var handlerForApply = _charaHandler; @@ -1454,7 +1593,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, token); + _applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, wantsModApply, pendingModReapply, token); } finally { @@ -1463,7 +1602,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, CancellationToken token) + Dictionary<(string GamePath, string? Hash), string> moddedPaths, bool wantsModApply, bool pendingModReapply, CancellationToken token) { try { @@ -1472,6 +1611,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger.LogDebug("[{applicationId}] Waiting for initial draw for for {handler}", _applicationId, handlerForApply); await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handlerForApply, _applicationId, 30000, token).ConfigureAwait(false); + if (handlerForApply.Address != nint.Zero) + { + await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token).ConfigureAwait(false); + } token.ThrowIfCancellationRequested(); @@ -1538,7 +1681,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _cachedData = charaData; _pairStateCache.Store(Ident, charaData); - _forceFullReapply = false; + if (wantsModApply) + { + _pendingModReapply = pendingModReapply; + } + _forceFullReapply = _pendingModReapply; _needsCollectionRebuild = false; if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0) { @@ -1584,8 +1731,15 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private void FrameworkUpdate() { - if (string.IsNullOrEmpty(PlayerName)) + if (string.IsNullOrEmpty(PlayerName) && _charaHandler is null) { + var now = DateTime.UtcNow; + if (now < _nextActorLookupUtc) + { + return; + } + + _nextActorLookupUtc = now + ActorLookupInterval; var pc = _dalamudUtil.FindPlayerByNameHash(Ident); if (pc == default((string, nint))) return; Logger.LogDebug("One-Time Initializing {handler}", GetLogIdentifier()); @@ -1595,6 +1749,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa $"Initializing User For Character {pc.Name}"))); } + TryHandleVisibilityUpdate(); + } + + private void TryHandleVisibilityUpdate() + { if (_charaHandler?.Address != nint.Zero && !IsVisible && !_pauseRequested) { Guid appData = Guid.NewGuid(); @@ -1641,16 +1800,24 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } else if (_charaHandler?.Address == nint.Zero && IsVisible) { - IsVisible = false; - _charaHandler.Invalidate(); - _downloadCancellationTokenSource?.CancelDispose(); - _downloadCancellationTokenSource = null; - Logger.LogTrace("{handler} visibility changed, now: {visi}", GetLogIdentifier(), IsVisible); + HandleVisibilityLoss(logChange: true); } TryApplyQueuedData(); } + private void HandleVisibilityLoss(bool logChange) + { + IsVisible = false; + _charaHandler?.Invalidate(); + _downloadCancellationTokenSource?.CancelDispose(); + _downloadCancellationTokenSource = null; + if (logChange) + { + Logger.LogTrace("{handler} visibility changed, now: {visi}", GetLogIdentifier(), IsVisible); + } + } + private void Initialize(string name) { PlayerName = name; @@ -1977,7 +2144,164 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } _dataReceivedInDowntime = null; - ApplyCharacterData(pending.ApplicationId, - pending.CharacterData, pending.Forced); + _ = Task.Run(() => + { + try + { + ApplyCharacterData(pending.ApplicationId, pending.CharacterData, pending.Forced); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed applying queued data for {handler}", GetLogIdentifier()); + } + }); + } + + private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor) + { + if (!TryResolveDescriptorHash(descriptor, out var hashedCid)) + return; + + if (!string.Equals(hashedCid, Ident, StringComparison.Ordinal)) + return; + + if (descriptor.Address == nint.Zero) + return; + + RefreshTrackedHandler(descriptor); + QueueActorInitialization(descriptor); + } + + private void QueueActorInitialization(ActorObjectService.ActorDescriptor descriptor) + { + lock (_actorInitializationGate) + { + _pendingActorDescriptor = descriptor; + if (_actorInitializationInProgress) + { + return; + } + + _actorInitializationInProgress = true; + } + + _ = Task.Run(InitializeFromTrackedAsync); + } + + private async Task InitializeFromTrackedAsync() + { + try + { + await ActorInitializationLimiter.WaitAsync().ConfigureAwait(false); + while (true) + { + ActorObjectService.ActorDescriptor? descriptor; + lock (_actorInitializationGate) + { + descriptor = _pendingActorDescriptor; + _pendingActorDescriptor = null; + } + + if (!descriptor.HasValue) + { + break; + } + + if (_frameworkUpdateSubscribed && _actorObjectService.HooksActive) + { + Mediator.Unsubscribe(this); + _frameworkUpdateSubscribed = false; + } + + if (string.IsNullOrEmpty(PlayerName) || _charaHandler is null) + { + Logger.LogDebug("Actor tracked for {handler}, initializing from hook", GetLogIdentifier()); + Initialize(descriptor.Value.Name); + Mediator.Publish(new EventMessage(new Event(PlayerName, GetPrimaryUserData(), nameof(PairHandlerAdapter), EventSeverity.Informational, + $"Initializing User For Character {descriptor.Value.Name}"))); + } + + RefreshTrackedHandler(descriptor.Value); + TryHandleVisibilityUpdate(); + } + } + finally + { + ActorInitializationLimiter.Release(); + lock (_actorInitializationGate) + { + _actorInitializationInProgress = false; + if (_pendingActorDescriptor.HasValue) + { + _actorInitializationInProgress = true; + _ = Task.Run(InitializeFromTrackedAsync); + } + } + } + } + + private void RefreshTrackedHandler(ActorObjectService.ActorDescriptor descriptor) + { + if (_charaHandler is null) + return; + + if (descriptor.Address == nint.Zero) + return; + + if (_charaHandler.Address == descriptor.Address) + return; + + _charaHandler.Refresh(); + } + + private void HandleActorUntracked(ActorObjectService.ActorDescriptor descriptor) + { + if (!TryResolveDescriptorHash(descriptor, out var hashedCid)) + { + if (_charaHandler is null || _charaHandler.Address == nint.Zero) + return; + + if (descriptor.Address != _charaHandler.Address) + return; + } + else if (!string.Equals(hashedCid, Ident, StringComparison.Ordinal)) + { + return; + } + + if (_charaHandler is null || _charaHandler.Address == nint.Zero) + return; + + if (descriptor.Address != _charaHandler.Address) + return; + + HandleVisibilityLoss(logChange: false); + } + + private bool TryResolveDescriptorHash(ActorObjectService.ActorDescriptor descriptor, out string hashedCid) + { + hashedCid = descriptor.HashedContentId ?? string.Empty; + if (!string.IsNullOrEmpty(hashedCid)) + return true; + + if (descriptor.ObjectKind != DalamudObjectKind.Player || descriptor.Address == nint.Zero) + return false; + + hashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(descriptor.Address); + return !string.IsNullOrEmpty(hashedCid); + } + + private async Task ApplyCustomizeAsync(nint address, string customizeData, ObjectKind kind) + { + _customizeIds[kind] = await _ipcManager.CustomizePlus.SetBodyScaleAsync(address, customizeData).ConfigureAwait(false); + } + + private async Task RevertCustomizeAsync(Guid? customizeId, ObjectKind kind) + { + if (!customizeId.HasValue) + return; + + await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId.Value).ConfigureAwait(false); + _customizeIds.Remove(kind); } } diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs index 1fe2703..5169820 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs @@ -2,6 +2,7 @@ using LightlessSync.FileCache; using LightlessSync.Interop.Ipc; using LightlessSync.PlayerData.Factories; using LightlessSync.Services; +using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; using LightlessSync.Services.PairProcessing; using LightlessSync.Services.ServerConfiguration; @@ -30,6 +31,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory private readonly TextureDownscaleService _textureDownscaleService; private readonly PairStateCache _pairStateCache; private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache; + private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor; public PairHandlerAdapterFactory( ILoggerFactory loggerFactory, @@ -47,7 +49,8 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory ServerConfigurationManager serverConfigManager, TextureDownscaleService textureDownscaleService, PairStateCache pairStateCache, - PairPerformanceMetricsCache pairPerformanceMetricsCache) + PairPerformanceMetricsCache pairPerformanceMetricsCache, + PenumbraTempCollectionJanitor tempCollectionJanitor) { _loggerFactory = loggerFactory; _mediator = mediator; @@ -65,12 +68,14 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory _textureDownscaleService = textureDownscaleService; _pairStateCache = pairStateCache; _pairPerformanceMetricsCache = pairPerformanceMetricsCache; + _tempCollectionJanitor = tempCollectionJanitor; } public IPairHandlerAdapter Create(string ident) { var downloadManager = _fileDownloadManagerFactory.Create(); var dalamudUtilService = _serviceProvider.GetRequiredService(); + var actorObjectService = _serviceProvider.GetRequiredService(); return new PairHandlerAdapter( _loggerFactory.CreateLogger(), _mediator, @@ -81,6 +86,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory downloadManager, _pluginWarningNotificationManager, dalamudUtilService, + actorObjectService, _lifetime, _fileCacheManager, _playerPerformanceService, @@ -88,6 +94,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory _serverConfigManager, _textureDownscaleService, _pairStateCache, - _pairPerformanceMetricsCache); + _pairPerformanceMetricsCache, + _tempCollectionJanitor); } } diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index d4e13b3..59dd112 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -40,6 +40,7 @@ using System.Reflection; using OtterTex; using LightlessSync.Services.LightFinder; using LightlessSync.Services.PairProcessing; +using LightlessSync.UI.Models; namespace LightlessSync; @@ -135,6 +136,7 @@ public sealed class Plugin : IDalamudPlugin services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => new TextureMetadataHelper(sp.GetRequiredService>(), gameData)); @@ -201,6 +203,7 @@ public sealed class Plugin : IDalamudPlugin gameInteropProvider, objectTable, clientState, + condition, sp.GetRequiredService())); services.AddSingleton(sp => new DalamudUtilService( @@ -298,7 +301,10 @@ public sealed class Plugin : IDalamudPlugin sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + chatGui, + sp.GetRequiredService()) + ); // IPC callers / manager services.AddSingleton(sp => new IpcCallerPenumbra( diff --git a/LightlessSync/Services/ActorTracking/ActorObjectService.cs b/LightlessSync/Services/ActorTracking/ActorObjectService.cs index c76d6dd..759417f 100644 --- a/LightlessSync/Services/ActorTracking/ActorObjectService.cs +++ b/LightlessSync/Services/ActorTracking/ActorObjectService.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using Dalamud.Game.ClientState.Conditions; using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Hooking; using Dalamud.Plugin.Services; @@ -31,13 +32,18 @@ public sealed class ActorObjectService : IHostedService, IDisposable private readonly IFramework _framework; private readonly IGameInteropProvider _interop; private readonly IObjectTable _objectTable; + private readonly IClientState _clientState; + private readonly ICondition _condition; private readonly LightlessMediator _mediator; private readonly ConcurrentDictionary _activePlayers = new(); + private readonly ConcurrentDictionary _gposePlayers = new(); private readonly ConcurrentDictionary _actorsByHash = new(StringComparer.Ordinal); private readonly ConcurrentDictionary> _actorsByName = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _pendingHashResolutions = new(); private readonly OwnedObjectTracker _ownedTracker = new(); private ActorSnapshot _snapshot = ActorSnapshot.Empty; + private GposeSnapshot _gposeSnapshot = GposeSnapshot.Empty; private Hook? _onInitializeHook; private Hook? _onTerminateHook; @@ -55,21 +61,29 @@ public sealed class ActorObjectService : IHostedService, IDisposable IGameInteropProvider interop, IObjectTable objectTable, IClientState clientState, + ICondition condition, LightlessMediator mediator) { _logger = logger; _framework = framework; _interop = interop; _objectTable = objectTable; + _clientState = clientState; + _condition = condition; _mediator = mediator; } + private bool IsZoning => _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51]; + private ActorSnapshot Snapshot => Volatile.Read(ref _snapshot); + private GposeSnapshot CurrentGposeSnapshot => Volatile.Read(ref _gposeSnapshot); public IReadOnlyList PlayerAddresses => Snapshot.PlayerAddresses; - public IEnumerable PlayerDescriptors => _activePlayers.Values; - public IReadOnlyList PlayerCharacterDescriptors => Snapshot.PlayerDescriptors; + public IEnumerable ObjectDescriptors => _activePlayers.Values; + public IReadOnlyList PlayerDescriptors => Snapshot.PlayerDescriptors; + public IReadOnlyList OwnedDescriptors => Snapshot.OwnedDescriptors; + public IReadOnlyList GposeDescriptors => CurrentGposeSnapshot.GposeDescriptors; public bool TryGetActorByHash(string hash, out ActorDescriptor descriptor) => _actorsByHash.TryGetValue(hash, out descriptor); public bool TryGetValidatedActorByHash(string hash, out ActorDescriptor descriptor) @@ -113,6 +127,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable return false; } public bool HooksActive => _hooksActive; + public bool HasPendingHashResolutions => !_pendingHashResolutions.IsEmpty; public IReadOnlyList RenderedPlayerAddresses => Snapshot.OwnedObjects.RenderedPlayers; public IReadOnlyList RenderedCompanionAddresses => Snapshot.OwnedObjects.RenderedCompanions; public IReadOnlyList OwnedObjectAddresses => Snapshot.OwnedObjects.OwnedAddresses; @@ -207,7 +222,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable cancellationToken.ThrowIfCancellationRequested(); var isLoaded = await _framework.RunOnFrameworkThread(() => IsObjectFullyLoaded(address)).ConfigureAwait(false); - if (isLoaded) + if (!IsZoning && isLoaded) return; await Task.Delay(100, cancellationToken).ConfigureAwait(false); @@ -297,10 +312,13 @@ public sealed class ActorObjectService : IHostedService, IDisposable { DisposeHooks(); _activePlayers.Clear(); + _gposePlayers.Clear(); _actorsByHash.Clear(); _actorsByName.Clear(); + _pendingHashResolutions.Clear(); _ownedTracker.Reset(); Volatile.Write(ref _snapshot, ActorSnapshot.Empty); + Volatile.Write(ref _gposeSnapshot, GposeSnapshot.Empty); return Task.CompletedTask; } @@ -336,7 +354,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable _onCompanionTerminateHook.Enable(); _hooksActive = true; - _logger.LogDebug("ActorObjectService hooks enabled."); + _logger.LogTrace("ActorObjectService hooks enabled."); } private Task WarmupExistingActors() @@ -350,36 +368,21 @@ public sealed class ActorObjectService : IHostedService, IDisposable private unsafe void OnCharacterInitialized(Character* chara) { - try - { - _onInitializeHook!.Original(chara); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error invoking original character initialize."); - } - - QueueFrameworkUpdate(() => TrackGameObject((GameObject*)chara)); + ExecuteOriginal(() => _onInitializeHook!.Original(chara), "Error invoking original character initialize."); + QueueTrack((GameObject*)chara); } private unsafe void OnCharacterTerminated(Character* chara) { var address = (nint)chara; - QueueFrameworkUpdate(() => UntrackGameObject(address)); - try - { - _onTerminateHook!.Original(chara); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error invoking original character terminate."); - } + QueueUntrack(address); + ExecuteOriginal(() => _onTerminateHook!.Original(chara), "Error invoking original character terminate."); } private unsafe GameObject* OnCharacterDisposed(Character* chara, byte freeMemory) { var address = (nint)chara; - QueueFrameworkUpdate(() => UntrackGameObject(address)); + QueueUntrack(address); try { return _onDestructorHook!.Original(chara, freeMemory); @@ -416,7 +419,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable if (_logger.IsEnabled(LogLevel.Debug)) { - _logger.LogDebug("Actor tracked: {Name} addr={Address:X} idx={Index} owned={OwnedKind} local={Local} gpose={Gpose}", + _logger.LogTrace("Actor tracked: {Name} addr={Address:X} idx={Index} owned={OwnedKind} local={Local} gpose={Gpose}", descriptor.Name, descriptor.Address, descriptor.ObjectIndex, @@ -534,7 +537,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable RemoveDescriptor(descriptor); if (_logger.IsEnabled(LogLevel.Debug)) { - _logger.LogDebug("Actor untracked: {Name} addr={Address:X} idx={Index} owned={OwnedKind}", + _logger.LogTrace("Actor untracked: {Name} addr={Address:X} idx={Index} owned={OwnedKind}", descriptor.Name, descriptor.Address, descriptor.ObjectIndex, @@ -558,10 +561,14 @@ public sealed class ActorObjectService : IHostedService, IDisposable if (!seen.Add(address)) continue; - if (_activePlayers.ContainsKey(address)) + var gameObject = (GameObject*)address; + if (_activePlayers.TryGetValue(address, out var existing)) + { + RefreshDescriptorIfNeeded(existing, gameObject); continue; + } - TrackGameObject((GameObject*)address); + TrackGameObject(gameObject); } var stale = _activePlayers.Keys.Where(addr => !seen.Contains(addr)).ToList(); @@ -574,6 +581,50 @@ public sealed class ActorObjectService : IHostedService, IDisposable { _nextRefreshAllowed = DateTime.UtcNow + SnapshotRefreshInterval; } + + if (_clientState.IsGPosing) + { + RefreshGposeActorsInternal(); + } + else if (!_gposePlayers.IsEmpty) + { + _gposePlayers.Clear(); + PublishGposeSnapshot(); + } + } + + private unsafe void RefreshDescriptorIfNeeded(ActorDescriptor existing, GameObject* gameObject) + { + if (gameObject == null) + return; + + if (existing.ObjectKind != DalamudObjectKind.Player || !string.IsNullOrEmpty(existing.HashedContentId)) + return; + + var objectKind = (DalamudObjectKind)gameObject->ObjectKind; + if (!IsSupportedObjectKind(objectKind)) + return; + + if (BuildDescriptor(gameObject, objectKind) is not { } updated) + return; + + if (string.IsNullOrEmpty(updated.HashedContentId)) + return; + + ReplaceDescriptor(existing, updated); + _mediator.Publish(new ActorTrackedMessage(updated)); + } + + private void ReplaceDescriptor(ActorDescriptor existing, ActorDescriptor updated) + { + RemoveDescriptorFromIndexes(existing); + _ownedTracker.OnDescriptorRemoved(existing); + + _activePlayers[updated.Address] = updated; + IndexDescriptor(updated); + _ownedTracker.OnDescriptorAdded(updated); + UpdatePendingHashResolutions(updated); + PublishSnapshot(); } private void IndexDescriptor(ActorDescriptor descriptor) @@ -605,30 +656,15 @@ public sealed class ActorObjectService : IHostedService, IDisposable private unsafe void OnCompanionInitialized(Companion* companion) { - try - { - _onCompanionInitializeHook!.Original(companion); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error invoking original companion initialize."); - } - - QueueFrameworkUpdate(() => TrackGameObject((GameObject*)companion)); + ExecuteOriginal(() => _onCompanionInitializeHook!.Original(companion), "Error invoking original companion initialize."); + QueueTrack((GameObject*)companion); } private unsafe void OnCompanionTerminated(Companion* companion) { var address = (nint)companion; - QueueFrameworkUpdate(() => UntrackGameObject(address)); - try - { - _onCompanionTerminateHook!.Original(companion); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error invoking original companion terminate."); - } + QueueUntrack(address); + ExecuteOriginal(() => _onCompanionTerminateHook!.Original(companion), "Error invoking original companion terminate."); } private void RemoveDescriptorFromIndexes(ActorDescriptor descriptor) @@ -655,6 +691,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable _activePlayers[descriptor.Address] = descriptor; IndexDescriptor(descriptor); _ownedTracker.OnDescriptorAdded(descriptor); + UpdatePendingHashResolutions(descriptor); PublishSnapshot(); } @@ -662,21 +699,42 @@ public sealed class ActorObjectService : IHostedService, IDisposable { RemoveDescriptorFromIndexes(descriptor); _ownedTracker.OnDescriptorRemoved(descriptor); + _pendingHashResolutions.TryRemove(descriptor.Address, out _); PublishSnapshot(); } + private void UpdatePendingHashResolutions(ActorDescriptor descriptor) + { + if (descriptor.ObjectKind != DalamudObjectKind.Player) + { + _pendingHashResolutions.TryRemove(descriptor.Address, out _); + return; + } + + if (string.IsNullOrEmpty(descriptor.HashedContentId)) + { + _pendingHashResolutions[descriptor.Address] = 1; + return; + } + + _pendingHashResolutions.TryRemove(descriptor.Address, out _); + } + private void PublishSnapshot() { var playerDescriptors = _activePlayers.Values .Where(descriptor => descriptor.ObjectKind == DalamudObjectKind.Player) .ToArray(); + var ownedDescriptors = _activePlayers.Values + .Where(descriptor => descriptor.OwnedKind is not null) + .ToArray(); var playerAddresses = new nint[playerDescriptors.Length]; for (var i = 0; i < playerDescriptors.Length; i++) playerAddresses[i] = playerDescriptors[i].Address; var ownedSnapshot = _ownedTracker.CreateSnapshot(); var nextGeneration = Snapshot.Generation + 1; - var snapshot = new ActorSnapshot(playerDescriptors, playerAddresses, ownedSnapshot, nextGeneration); + var snapshot = new ActorSnapshot(playerDescriptors, ownedDescriptors, playerAddresses, ownedSnapshot, nextGeneration); Volatile.Write(ref _snapshot, snapshot); } @@ -694,6 +752,24 @@ public sealed class ActorObjectService : IHostedService, IDisposable _ = _framework.RunOnFrameworkThread(action); } + private void ExecuteOriginal(Action action, string errorMessage) + { + try + { + action(); + } + catch (Exception ex) + { + _logger.LogError(ex, errorMessage); + } + } + + private unsafe void QueueTrack(GameObject* gameObject) + => QueueFrameworkUpdate(() => TrackGameObject(gameObject)); + + private void QueueUntrack(nint address) + => QueueFrameworkUpdate(() => UntrackGameObject(address)); + private void DisposeHooks() { var hadHooks = _hooksActive @@ -725,7 +801,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable if (hadHooks) { - _logger.LogDebug("ActorObjectService hooks disabled."); + _logger.LogTrace("ActorObjectService hooks disabled."); } } @@ -770,6 +846,89 @@ public sealed class ActorObjectService : IHostedService, IDisposable return results; } + private unsafe void RefreshGposeActorsInternal() + { + var addresses = EnumerateGposeCharacterAddresses(); + HashSet seen = new(addresses.Count); + + foreach (var address in addresses) + { + if (address == nint.Zero) + continue; + + if (!seen.Add(address)) + continue; + + if (_gposePlayers.ContainsKey(address)) + continue; + + TrackGposeObject((GameObject*)address); + } + + var stale = _gposePlayers.Keys.Where(addr => !seen.Contains(addr)).ToList(); + foreach (var staleAddress in stale) + { + UntrackGposeObject(staleAddress); + } + + PublishGposeSnapshot(); + } + + private unsafe void TrackGposeObject(GameObject* gameObject) + { + if (gameObject == null) + return; + + var objectKind = (DalamudObjectKind)gameObject->ObjectKind; + if (objectKind != DalamudObjectKind.Player) + return; + + if (BuildDescriptor(gameObject, objectKind) is not { } descriptor) + return; + + if (!descriptor.IsInGpose) + return; + + _gposePlayers[descriptor.Address] = descriptor; + } + + private void UntrackGposeObject(nint address) + { + if (address == nint.Zero) + return; + + _gposePlayers.TryRemove(address, out _); + } + + private void PublishGposeSnapshot() + { + var gposeDescriptors = _gposePlayers.Values.ToArray(); + var gposeAddresses = new nint[gposeDescriptors.Length]; + for (var i = 0; i < gposeDescriptors.Length; i++) + gposeAddresses[i] = gposeDescriptors[i].Address; + + var nextGeneration = CurrentGposeSnapshot.Generation + 1; + var snapshot = new GposeSnapshot(gposeDescriptors, gposeAddresses, nextGeneration); + Volatile.Write(ref _gposeSnapshot, snapshot); + } + + private List EnumerateGposeCharacterAddresses() + { + var results = new List(16); + foreach (var obj in _objectTable) + { + if (obj.ObjectKind != DalamudObjectKind.Player) + continue; + + if (obj.ObjectIndex < 200) + continue; + + results.Add(obj.Address); + } + + return results; + } + private static unsafe bool IsObjectFullyLoaded(nint address) { if (address == nint.Zero) @@ -783,13 +942,10 @@ public sealed class ActorObjectService : IHostedService, IDisposable if (drawObject == null) return false; - if ((gameObject->RenderFlags & VisibilityFlags.Nameplate) != VisibilityFlags.None) + if ((ulong)gameObject->RenderFlags == 2048) return false; var characterBase = (CharacterBase*)drawObject; - if (characterBase == null) - return false; - if (characterBase->HasModelInSlotLoaded != 0) return false; @@ -925,14 +1081,27 @@ public sealed class ActorObjectService : IHostedService, IDisposable private sealed record ActorSnapshot( IReadOnlyList PlayerDescriptors, + IReadOnlyList OwnedDescriptors, IReadOnlyList PlayerAddresses, OwnedObjectSnapshot OwnedObjects, int Generation) { public static ActorSnapshot Empty { get; } = new( + Array.Empty(), Array.Empty(), Array.Empty(), OwnedObjectSnapshot.Empty, 0); } + + private sealed record GposeSnapshot( + IReadOnlyList GposeDescriptors, + IReadOnlyList GposeAddresses, + int Generation) + { + public static GposeSnapshot Empty { get; } = new( + Array.Empty(), + Array.Empty(), + 0); + } } diff --git a/LightlessSync/Services/Chat/ZoneChatService.cs b/LightlessSync/Services/Chat/ZoneChatService.cs index 6eebf4f..38e76d9 100644 --- a/LightlessSync/Services/Chat/ZoneChatService.cs +++ b/LightlessSync/Services/Chat/ZoneChatService.cs @@ -1,4 +1,5 @@ using LightlessSync.API.Dto.Chat; +using LightlessSync.API.Data.Extensions; using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; using LightlessSync.WebAPI; @@ -36,6 +37,8 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS private readonly Dictionary _lastPresenceStates = new(StringComparer.Ordinal); private readonly Dictionary _selfTokens = new(StringComparer.Ordinal); private readonly List _pendingSelfMessages = new(); + private List? _cachedChannelSnapshots; + private bool _channelsSnapshotDirty = true; private bool _isLoggedIn; private bool _isConnected; @@ -69,6 +72,11 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS { using (_sync.EnterScope()) { + if (!_channelsSnapshotDirty && _cachedChannelSnapshots is not null) + { + return _cachedChannelSnapshots; + } + var snapshots = new List(_channelOrder.Count); foreach (var key in _channelOrder) { @@ -98,6 +106,8 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS state.Messages.ToList())); } + _cachedChannelSnapshots = snapshots; + _channelsSnapshotDirty = false; return snapshots; } } @@ -135,6 +145,8 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS state.UnreadCount = 0; _lastReadCounts[key] = state.Messages.Count; } + + MarkChannelsSnapshotDirtyLocked(); } } @@ -186,6 +198,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS if (!wasEnabled) { _chatEnabled = true; + MarkChannelsSnapshotDirtyLocked(); } } @@ -231,6 +244,8 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS state.IsAvailable = false; state.StatusText = "Chat services disabled"; } + + MarkChannelsSnapshotDirtyLocked(); } UnregisterChatHandler(); @@ -717,7 +732,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS _zoneDefinitions[key] = new ZoneChannelDefinition(key, info.DisplayName ?? key, descriptor, territories); } - var territoryData = _dalamudUtilService.TerritoryData.Value; + var territoryData = _dalamudUtilService.TerritoryDataEnglish.Value; foreach (var kvp in territoryData) { foreach (var variant in EnumerateTerritoryKeys(kvp.Value)) @@ -853,6 +868,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS var infos = new List(groups.Count); foreach (var group in groups) { + // basically prune the channel if it's disabled + if (group.GroupPermissions.IsDisableChat()) + { + continue; + } + var descriptor = new ChatChannelDescriptor { Type = ChatChannelType.Group, @@ -1023,6 +1044,8 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS state.UnreadCount = Math.Min(Math.Max(unreadFromHistory, incrementalUnread), MaxUnreadCount); state.HasUnread = state.UnreadCount > 0; } + + MarkChannelsSnapshotDirtyLocked(); } Mediator.Publish(new ChatChannelMessageAdded(key, message)); @@ -1204,9 +1227,25 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS { _activeChannelKey = _channelOrder.Count > 0 ? _channelOrder[0] : null; } + + MarkChannelsSnapshotDirtyLocked(); } - private void PublishChannelListChanged() => Mediator.Publish(new ChatChannelsUpdated()); + private void MarkChannelsSnapshotDirty() + { + using (_sync.EnterScope()) + { + _channelsSnapshotDirty = true; + } + } + + private void MarkChannelsSnapshotDirtyLocked() => _channelsSnapshotDirty = true; + + private void PublishChannelListChanged() + { + MarkChannelsSnapshotDirty(); + Mediator.Publish(new ChatChannelsUpdated()); + } private static IEnumerable EnumerateTerritoryKeys(string? value) { diff --git a/LightlessSync/Services/ContextMenuService.cs b/LightlessSync/Services/ContextMenuService.cs index 53bbb45..7d35529 100644 --- a/LightlessSync/Services/ContextMenuService.cs +++ b/LightlessSync/Services/ContextMenuService.cs @@ -4,21 +4,22 @@ using Dalamud.Plugin; using Dalamud.Plugin.Services; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; +using LightlessSync.Services.LightFinder; using LightlessSync.Services.Mediator; +using LightlessSync.UI; +using LightlessSync.UI.Services; using LightlessSync.Utils; using LightlessSync.WebAPI; using Lumina.Excel.Sheets; -using LightlessSync.UI.Services; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using LightlessSync.UI; -using LightlessSync.Services.LightFinder; namespace LightlessSync.Services; internal class ContextMenuService : IHostedService { private readonly IContextMenu _contextMenu; + private readonly IChatGui _chatGui; private readonly IDalamudPluginInterface _pluginInterface; private readonly IDataManager _gameData; private readonly ILogger _logger; @@ -29,6 +30,7 @@ internal class ContextMenuService : IHostedService private readonly ApiController _apiController; private readonly IObjectTable _objectTable; private readonly LightlessConfigService _configService; + private readonly NotificationService _lightlessNotification; private readonly LightFinderScannerService _broadcastScannerService; private readonly LightFinderService _broadcastService; private readonly LightlessProfileManager _lightlessProfileManager; @@ -43,7 +45,7 @@ internal class ContextMenuService : IHostedService ILogger logger, DalamudUtilService dalamudUtil, ApiController apiController, - IObjectTable objectTable, + IObjectTable objectTable, LightlessConfigService configService, PairRequestService pairRequestService, PairUiService pairUiService, @@ -51,7 +53,9 @@ internal class ContextMenuService : IHostedService LightFinderScannerService broadcastScannerService, LightFinderService broadcastService, LightlessProfileManager lightlessProfileManager, - LightlessMediator mediator) + LightlessMediator mediator, + IChatGui chatGui, + NotificationService lightlessNotification) { _contextMenu = contextMenu; _pluginInterface = pluginInterface; @@ -68,6 +72,8 @@ internal class ContextMenuService : IHostedService _broadcastService = broadcastService; _lightlessProfileManager = lightlessProfileManager; _mediator = mediator; + _chatGui = chatGui; + _lightlessNotification = lightlessNotification; } public Task StartAsync(CancellationToken cancellationToken) @@ -99,6 +105,12 @@ internal class ContextMenuService : IHostedService if (!_pluginInterface.UiBuilder.ShouldModifyUi) return; + if (!_configService.Current.EnableRightClickMenus) + { + _logger.LogTrace("Right-click menus are disabled in configuration."); + return; + } + if (args.AddonName != null) { var addonName = args.AddonName; @@ -129,7 +141,6 @@ internal class ContextMenuService : IHostedService var snapshot = _pairUiService.GetSnapshot(); var pair = snapshot.PairsByUid.Values.FirstOrDefault(p => - p.IsVisible && p.PlayerCharacterId != uint.MaxValue && p.PlayerCharacterId == target.TargetObjectId); @@ -199,6 +210,18 @@ internal class ContextMenuService : IHostedService .Where(p => p.IsVisible && p.PlayerCharacterId != uint.MaxValue) .Select(p => (ulong)p.PlayerCharacterId)]; + private void NotifyInChat(string message, NotificationType type = NotificationType.Info) + { + if (!_configService.Current.UseLightlessNotifications || (_configService.Current.LightlessPairRequestNotification == NotificationLocation.Chat || _configService.Current.LightlessPairRequestNotification == NotificationLocation.ChatAndLightlessUi)) + { + var chatMsg = $"[Lightless] {message}"; + if (type == NotificationType.Error) + _chatGui.PrintError(chatMsg); + else + _chatGui.Print(chatMsg); + } + } + private async Task HandleSelection(IMenuArgs args) { if (args.Target is not MenuTargetDefault target) @@ -227,6 +250,9 @@ internal class ContextMenuService : IHostedService { _pairRequestService.RemoveRequest(receiverCid); } + + // Notify in chat when NotificationService is disabled + NotifyInChat($"Pair request sent to {target.TargetName}@{world.Name}.", NotificationType.Info); } catch (Exception ex) { diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 5908624..ce4c321 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -91,43 +91,10 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber return gameData.GetExcelSheet(Dalamud.Game.ClientLanguage.English)! .ToDictionary(k => k.RowId, k => k.NameEnglish.ToString()); }); - TerritoryData = new(() => - { - return gameData.GetExcelSheet(Dalamud.Game.ClientLanguage.English)! - .Where(w => w.RowId != 0) - .ToDictionary(w => w.RowId, w => - { - StringBuilder sb = new(); - sb.Append(w.PlaceNameRegion.Value.Name); - if (w.PlaceName.ValueNullable != null) - { - sb.Append(" - "); - sb.Append(w.PlaceName.Value.Name); - } - return sb.ToString(); - }); - }); - MapData = new(() => - { - return gameData.GetExcelSheet(Dalamud.Game.ClientLanguage.English)! - .Where(w => w.RowId != 0) - .ToDictionary(w => w.RowId, w => - { - StringBuilder sb = new(); - sb.Append(w.PlaceNameRegion.Value.Name); - if (w.PlaceName.ValueNullable != null) - { - sb.Append(" - "); - sb.Append(w.PlaceName.Value.Name); - } - if (w.PlaceNameSub.ValueNullable != null && !string.IsNullOrEmpty(w.PlaceNameSub.Value.Name.ToString())) - { - sb.Append(" - "); - sb.Append(w.PlaceNameSub.Value.Name); - } - return (w, sb.ToString()); - }); - }); + var clientLanguage = _clientState.ClientLanguage; + TerritoryData = new(() => BuildTerritoryData(clientLanguage)); + TerritoryDataEnglish = new(() => BuildTerritoryData(Dalamud.Game.ClientLanguage.English)); + MapData = new(() => BuildMapData(clientLanguage)); mediator.Subscribe(this, (msg) => { if (clientState.IsPvP) return; @@ -158,6 +125,71 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber private Lazy RebuildCID() => new(GetCID); public bool IsWine { get; init; } + private Dictionary BuildTerritoryData(Dalamud.Game.ClientLanguage language) + { + var placeNames = _gameData.GetExcelSheet(language)!; + return _gameData.GetExcelSheet(language)! + .Where(w => w.RowId != 0) + .ToDictionary(w => w.RowId, w => + { + var regionName = GetPlaceName(placeNames, w.PlaceNameRegion.RowId); + var placeName = GetPlaceName(placeNames, w.PlaceName.RowId); + return BuildPlaceName(regionName, placeName, string.Empty); + }); + } + + private Dictionary BuildMapData(Dalamud.Game.ClientLanguage language) + { + var placeNames = _gameData.GetExcelSheet(language)!; + return _gameData.GetExcelSheet(language)! + .Where(w => w.RowId != 0) + .ToDictionary(w => w.RowId, w => + { + var regionName = GetPlaceName(placeNames, w.PlaceNameRegion.RowId); + var placeName = GetPlaceName(placeNames, w.PlaceName.RowId); + var subPlaceName = GetPlaceName(placeNames, w.PlaceNameSub.RowId); + var displayName = BuildPlaceName(regionName, placeName, subPlaceName); + return (w, displayName); + }); + } + private static string GetPlaceName(Lumina.Excel.ExcelSheet placeNames, uint rowId) + { + if (rowId == 0) + { + return string.Empty; + } + + return placeNames.GetRow(rowId).Name.ToString(); + } + + private static string BuildPlaceName(string regionName, string placeName, string subPlaceName) + { + StringBuilder sb = new(); + if (!string.IsNullOrWhiteSpace(regionName)) + { + sb.Append(regionName); + } + + if (!string.IsNullOrWhiteSpace(placeName)) + { + if (sb.Length > 0) + { + sb.Append(" - "); + } + sb.Append(placeName); + } + + if (!string.IsNullOrWhiteSpace(subPlaceName)) + { + if (sb.Length > 0) + { + sb.Append(" - "); + } + sb.Append(subPlaceName); + } + + return sb.ToString(); + } private bool ResolvePairAddress(Pair pair, out Pair resolvedPair, out nint address) { resolvedPair = _pairFactory.Value.Create(pair.UniqueIdent) ?? pair; @@ -245,6 +277,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber public Lazy> JobData { get; private set; } public Lazy> WorldData { get; private set; } public Lazy> TerritoryData { get; private set; } + public Lazy> TerritoryDataEnglish { get; private set; } public Lazy> MapData { get; private set; } public bool IsLodEnabled { get; private set; } public LightlessMediator Mediator { get; } @@ -264,7 +297,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber return false; } - if (!TerritoryData.Value.TryGetValue(territoryId, out var name) || string.IsNullOrWhiteSpace(name)) + if (!TerritoryDataEnglish.Value.TryGetValue(territoryId, out var name) || string.IsNullOrWhiteSpace(name)) { return false; } @@ -355,7 +388,8 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber var playerAddress = playerPointer.Value; var ownerEntityId = ((Character*)playerAddress)->EntityId; - if (ownerEntityId == 0) return IntPtr.Zero; + var candidateAddress = _objectTable.GetObjectAddress(((GameObject*)playerAddress)->ObjectIndex + 1); + if (ownerEntityId == 0) return candidateAddress; if (playerAddress == _actorObjectService.LocalPlayerAddress) { @@ -366,6 +400,17 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber } } + if (candidateAddress != nint.Zero) + { + var candidate = (GameObject*)candidateAddress; + var candidateKind = (DalamudObjectKind)candidate->ObjectKind; + if ((candidateKind == DalamudObjectKind.MountType || candidateKind == DalamudObjectKind.Companion) + && ResolveOwnerId(candidate) == ownerEntityId) + { + return candidateAddress; + } + } + var ownedObject = FindOwnedObject(ownerEntityId, playerAddress, static kind => kind == DalamudObjectKind.MountType || kind == DalamudObjectKind.Companion); if (ownedObject != nint.Zero) @@ -373,7 +418,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber return ownedObject; } - return _objectTable.GetObjectAddress(((GameObject*)playerAddress)->ObjectIndex + 1); + return candidateAddress; } public async Task GetMinionOrMountAsync(IntPtr? playerPointer = null) @@ -806,7 +851,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber bool isDrawingChanged = false; if ((nint)drawObj != IntPtr.Zero) { - isDrawing = (gameObj->RenderFlags & VisibilityFlags.Nameplate) != VisibilityFlags.None; + isDrawing = gameObj->RenderFlags == (VisibilityFlags)0b100000000000; if (!isDrawing) { isDrawing = ((CharacterBase*)drawObj)->HasModelInSlotLoaded != 0; @@ -872,9 +917,12 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber _performanceCollector.LogPerformance(this, $"TrackedActorsToState", () => { - _actorObjectService.RefreshTrackedActors(); + if (!_actorObjectService.HooksActive || !isNormalFrameworkUpdate || _actorObjectService.HasPendingHashResolutions) + { + _actorObjectService.RefreshTrackedActors(); + } - var playerDescriptors = _actorObjectService.PlayerCharacterDescriptors; + var playerDescriptors = _actorObjectService.PlayerDescriptors; for (var i = 0; i < playerDescriptors.Count; i++) { var actor = playerDescriptors[i]; diff --git a/LightlessSync/Services/LightFinder/LightFinderScannerService.cs b/LightlessSync/Services/LightFinder/LightFinderScannerService.cs index 35c7d0b..1a6369d 100644 --- a/LightlessSync/Services/LightFinder/LightFinderScannerService.cs +++ b/LightlessSync/Services/LightFinder/LightFinderScannerService.cs @@ -168,10 +168,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase return; var now = DateTime.UtcNow; - var newSet = _broadcastCache - .Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID)) - .Select(e => e.Key) - .ToHashSet(StringComparer.Ordinal); + var nearbyCids = GetNearbyHashedCids(out _); + var newSet = nearbyCids.Count == 0 + ? new HashSet(StringComparer.Ordinal) + : _broadcastCache + .Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID)) + .Where(e => nearbyCids.Contains(e.Key)) + .Select(e => e.Key) + .ToHashSet(StringComparer.Ordinal); if (!_syncshellCids.SetEquals(newSet)) { @@ -183,12 +187,17 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase } } - public List GetActiveSyncshellBroadcasts() + public List GetActiveSyncshellBroadcasts(bool excludeLocal = false) { var now = DateTime.UtcNow; + var nearbyCids = GetNearbyHashedCids(out var localCid); + if (nearbyCids.Count == 0) + return []; return [.. _broadcastCache .Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID)) + .Where(e => nearbyCids.Contains(e.Key)) + .Where(e => !excludeLocal || !string.Equals(e.Key, localCid, StringComparison.Ordinal)) .Select(e => new BroadcastStatusInfoDto { HashedCID = e.Key, @@ -198,6 +207,47 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase })]; } + public bool TryGetLocalHashedCid(out string hashedCid) + { + hashedCid = string.Empty; + var descriptors = _actorTracker.PlayerDescriptors; + if (descriptors.Count == 0) + return false; + + foreach (var descriptor in descriptors) + { + if (!descriptor.IsLocalPlayer || string.IsNullOrWhiteSpace(descriptor.HashedContentId)) + continue; + + hashedCid = descriptor.HashedContentId; + return true; + } + + return false; + } + + private HashSet GetNearbyHashedCids(out string? localCid) + { + localCid = null; + var descriptors = _actorTracker.PlayerDescriptors; + if (descriptors.Count == 0) + return new HashSet(StringComparer.Ordinal); + + var set = new HashSet(StringComparer.Ordinal); + foreach (var descriptor in descriptors) + { + if (string.IsNullOrWhiteSpace(descriptor.HashedContentId)) + continue; + + if (descriptor.IsLocalPlayer) + localCid = descriptor.HashedContentId; + + set.Add(descriptor.HashedContentId); + } + + return set; + } + private async Task ExpiredBroadcastCleanupLoop() { var token = _cleanupCts.Token; diff --git a/LightlessSync/Services/PenumbraTempCollectionJanitor.cs b/LightlessSync/Services/PenumbraTempCollectionJanitor.cs new file mode 100644 index 0000000..03fb53b --- /dev/null +++ b/LightlessSync/Services/PenumbraTempCollectionJanitor.cs @@ -0,0 +1,71 @@ +using LightlessSync.Interop.Ipc; +using LightlessSync.LightlessConfiguration; +using LightlessSync.Services.Mediator; +using Microsoft.Extensions.Logging; + +namespace LightlessSync.Services; + +public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriberBase +{ + private readonly IpcManager _ipc; + private readonly LightlessConfigService _config; + private int _ran; + + public PenumbraTempCollectionJanitor( + ILogger logger, + LightlessMediator mediator, + IpcManager ipc, + LightlessConfigService config) : base(logger, mediator) + { + _ipc = ipc; + _config = config; + + Mediator.Subscribe(this, _ => CleanupOrphansOnBoot()); + } + + public void Register(Guid id) + { + if (id == Guid.Empty) return; + if (_config.Current.OrphanableTempCollections.Add(id)) + _config.Save(); + } + + public void Unregister(Guid id) + { + if (id == Guid.Empty) return; + if (_config.Current.OrphanableTempCollections.Remove(id)) + _config.Save(); + } + + private void CleanupOrphansOnBoot() + { + if (Interlocked.Exchange(ref _ran, 1) == 1) + return; + + if (!_ipc.Penumbra.APIAvailable) + return; + + var ids = _config.Current.OrphanableTempCollections.ToArray(); + if (ids.Length == 0) + return; + + var appId = Guid.NewGuid(); + Logger.LogInformation("Cleaning up {count} orphaned Lightless temp collections found in configuration", ids.Length); + + foreach (var id in ids) + { + try + { + _ipc.Penumbra.RemoveTemporaryCollectionAsync(Logger, appId, id) + .GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed removing orphaned temp collection {id}", id); + } + } + + _config.Current.OrphanableTempCollections.Clear(); + _config.Save(); + } +} \ No newline at end of file diff --git a/LightlessSync/UI/Components/DrawUserPair.cs b/LightlessSync/UI/Components/DrawUserPair.cs index 96b9ea4..8e03ae4 100644 --- a/LightlessSync/UI/Components/DrawUserPair.cs +++ b/LightlessSync/UI/Components/DrawUserPair.cs @@ -133,6 +133,26 @@ public class DrawUserPair UiSharedService.AttachToolTip("This reapplies the last received character data to this character"); } + var isPaused = _pair.UserPair!.OwnPermissions.IsPaused(); + if (!isPaused) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Pause, "Toggle Pause State", _menuWidth, true)) + { + _ = _apiController.PauseAsync(_pair.UserData); + ImGui.CloseCurrentPopup(); + } + UiSharedService.AttachToolTip("Pauses syncing with this user."); + } + else + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Play, "Toggle Unpause State", _menuWidth, true)) + { + _ = _apiController.UnpauseAsync(_pair.UserData); + ImGui.CloseCurrentPopup(); + } + UiSharedService.AttachToolTip("Resumes syncing with this user."); + } + if (_uiSharedService.IconTextButton(FontAwesomeIcon.PlayCircle, "Cycle pause state", _menuWidth, true)) { _ = _apiController.CyclePauseAsync(_pair); diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index b960b46..2d9cdc1 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -164,9 +164,25 @@ public class DownloadUi : WindowMediatorSubscriberBase const float rounding = 6f; var shadowOffset = new Vector2(2, 2); - foreach (var transfer in _currentDownloads.ToList()) + List>> transfers; + try + { + transfers = _currentDownloads.ToList(); + } + catch (ArgumentException) + { + return; + } + + + foreach (var transfer in transfers) { var transferKey = transfer.Key; + + // Skip if no valid game object + if (transferKey.GetGameObject() == null) + continue; + var rawPos = _dalamudUtilService.WorldToScreen(transferKey.GetGameObject()); // If RawPos is zero, remove it from smoothed dictionary diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index a6b9c33..2740534 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -1552,6 +1552,11 @@ public class SettingsUi : WindowMediatorSubscriberBase DrawPairPropertyRow("Downloading", FormatBool(debugInfo.IsDownloading)); DrawPairPropertyRow("Pending Downloads", debugInfo.PendingDownloadCount.ToString(CultureInfo.InvariantCulture)); DrawPairPropertyRow("Forbidden Downloads", debugInfo.ForbiddenDownloadCount.ToString(CultureInfo.InvariantCulture)); + DrawPairPropertyRow("Pending Mod Reapply", FormatBool(debugInfo.PendingModReapply)); + DrawPairPropertyRow("Mod Apply Deferred", FormatBool(debugInfo.ModApplyDeferred)); + DrawPairPropertyRow("Missing Critical Mods", debugInfo.MissingCriticalMods.ToString(CultureInfo.InvariantCulture)); + DrawPairPropertyRow("Missing Non-Critical Mods", debugInfo.MissingNonCriticalMods.ToString(CultureInfo.InvariantCulture)); + DrawPairPropertyRow("Missing Forbidden Mods", debugInfo.MissingForbiddenMods.ToString(CultureInfo.InvariantCulture)); ImGui.EndTable(); } diff --git a/LightlessSync/UI/SyncshellAdminUI.cs b/LightlessSync/UI/SyncshellAdminUI.cs index 730d124..0458c05 100644 --- a/LightlessSync/UI/SyncshellAdminUI.cs +++ b/LightlessSync/UI/SyncshellAdminUI.cs @@ -297,6 +297,25 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase var ownerTab = ImRaii.TabItem("Owner Settings"); if (ownerTab) { + bool isChatDisabled = perm.IsDisableChat(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Syncshell Chat"); + _uiSharedService.BooleanToColoredIcon(!isChatDisabled); + ImGui.SameLine(230); + using (ImRaii.PushColor(ImGuiCol.Text, isChatDisabled ? UIColors.Get("PairBlue") : UIColors.Get("DimRed"))) + { + if (_uiSharedService.IconTextButton( + isChatDisabled ? FontAwesomeIcon.Comment : FontAwesomeIcon.Ban, + isChatDisabled ? "Enable syncshell chat" : "Disable syncshell chat")) + { + perm.SetDisableChat(!isChatDisabled); + _ = _apiController.GroupChangeGroupPermissionState(new(GroupFullInfo.Group, perm)); + } + } + UiSharedService.AttachToolTip("Disables syncshell chat for all members."); + + ImGuiHelpers.ScaledDummy(6f); + ImGui.AlignTextToFramePadding(); ImGui.TextUnformatted("New Password"); var availableWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X; diff --git a/LightlessSync/UI/UISharedService.cs b/LightlessSync/UI/UISharedService.cs index 2b5431a..fc5225c 100644 --- a/LightlessSync/UI/UISharedService.cs +++ b/LightlessSync/UI/UISharedService.cs @@ -947,13 +947,16 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase } } - if (_discordOAuthCheck != null && _discordOAuthCheck.IsCompleted) + if (_discordOAuthCheck != null && _discordOAuthCheck.IsCompleted && _discordOAuthCheck.Result != null) { - if (IconTextButton(FontAwesomeIcon.ArrowRight, "Authenticate with Server")) + if (_discordOAuthGetCode == null) { - _discordOAuthGetCode = _serverConfigurationManager.GetDiscordOAuthToken(_discordOAuthCheck.Result!, selectedServer.ServerUri, _discordOAuthGetCts.Token); + if (IconTextButton(FontAwesomeIcon.ArrowRight, "Authenticate with Server")) + { + _discordOAuthGetCode = _serverConfigurationManager.GetDiscordOAuthToken(_discordOAuthCheck.Result, selectedServer.ServerUri, _discordOAuthGetCts.Token); + } } - else if (_discordOAuthGetCode != null && !_discordOAuthGetCode.IsCompleted) + else if (!_discordOAuthGetCode.IsCompleted) { TextWrapped("A browser window has been opened, follow it to authenticate. Click the button below if you accidentally closed the window and need to restart the authentication."); if (IconTextButton(FontAwesomeIcon.Ban, "Cancel Authentication")) @@ -962,7 +965,7 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase _discordOAuthGetCode = null; } } - else if (_discordOAuthGetCode != null && _discordOAuthGetCode.IsCompleted) + else { TextWrapped("Discord OAuth is completed, status: "); ImGui.SameLine(); diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs index d01aa2a..b0b03c6 100644 --- a/LightlessSync/UI/ZoneChatUi.cs +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -17,6 +17,7 @@ using LightlessSync.Services.ServerConfiguration; using LightlessSync.UI.Services; using LightlessSync.UI.Style; using LightlessSync.Utils; +using OtterGui.Text; using LightlessSync.WebAPI; using LightlessSync.WebAPI.SignalR.Utils; using Microsoft.Extensions.Logging; @@ -211,12 +212,6 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase protected override void DrawInternal() { - if (_titleBarStylePopCount > 0) - { - ImGui.PopStyleColor(_titleBarStylePopCount); - _titleBarStylePopCount = 0; - } - var config = _chatConfigService.Current; var isFocused = ImGui.IsWindowFocused(ImGuiFocusedFlags.RootAndChildWindows); var isHovered = ImGui.IsWindowHovered(ImGuiHoveredFlags.RootAndChildWindows); @@ -400,52 +395,57 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } else { - for (var i = 0; i < channel.Messages.Count; i++) + var itemHeight = ImGui.GetTextLineHeightWithSpacing(); + using var clipper = ImUtf8.ListClipper(channel.Messages.Count, itemHeight); + while (clipper.Step()) { - var message = channel.Messages[i]; - ImGui.PushID(i); - - if (message.IsSystem) + for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) { - DrawSystemEntry(message); - ImGui.PopID(); - continue; - } + var message = channel.Messages[i]; + ImGui.PushID(i); - if (message.Payload is not { } payload) - { - ImGui.PopID(); - continue; - } - - var timestampText = string.Empty; - if (showTimestamps) - { - timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] "; - } - var color = message.FromSelf ? UIColors.Get("LightlessBlue") : ImGuiColors.DalamudWhite; - - ImGui.PushStyleColor(ImGuiCol.Text, color); - ImGui.TextWrapped($"{timestampText}{message.DisplayName}: {payload.Message}"); - ImGui.PopStyleColor(); - - if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}")) - { - var contextLocalTimestamp = payload.SentAtUtc.ToLocalTime(); - var contextTimestampText = contextLocalTimestamp.ToString("yyyy-MM-dd HH:mm:ss 'UTC'z", CultureInfo.InvariantCulture); - ImGui.TextDisabled(contextTimestampText); - ImGui.Separator(); - - var actionIndex = 0; - foreach (var action in GetContextMenuActions(channel, message)) + if (message.IsSystem) { - DrawContextMenuAction(action, actionIndex++); + DrawSystemEntry(message); + ImGui.PopID(); + continue; } - ImGui.EndPopup(); - } + if (message.Payload is not { } payload) + { + ImGui.PopID(); + continue; + } - ImGui.PopID(); + var timestampText = string.Empty; + if (showTimestamps) + { + timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] "; + } + var color = message.FromSelf ? UIColors.Get("LightlessBlue") : ImGuiColors.DalamudWhite; + + ImGui.PushStyleColor(ImGuiCol.Text, color); + ImGui.TextWrapped($"{timestampText}{message.DisplayName}: {payload.Message}"); + ImGui.PopStyleColor(); + + if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}")) + { + var contextLocalTimestamp = payload.SentAtUtc.ToLocalTime(); + var contextTimestampText = contextLocalTimestamp.ToString("yyyy-MM-dd HH:mm:ss 'UTC'z", CultureInfo.InvariantCulture); + ImGui.TextDisabled(contextTimestampText); + ImGui.Separator(); + + var actionIndex = 0; + foreach (var action in GetContextMenuActions(channel, message)) + { + DrawContextMenuAction(action, actionIndex++); + } + + ImGui.EndPopup(); + } + + ImGui.PopID(); + } } } @@ -833,6 +833,11 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.PopStyleVar(1); _pushedStyle = false; } + if (_titleBarStylePopCount > 0) + { + ImGui.PopStyleColor(_titleBarStylePopCount); + _titleBarStylePopCount = 0; + } base.PostDraw(); } diff --git a/LightlessSync/Utils/VariousExtensions.cs b/LightlessSync/Utils/VariousExtensions.cs index 3f47d98..0020bc9 100644 --- a/LightlessSync/Utils/VariousExtensions.cs +++ b/LightlessSync/Utils/VariousExtensions.cs @@ -60,16 +60,6 @@ public static class VariousExtensions CharacterData? oldData, ILogger logger, IPairPerformanceSubject cachedPlayer, bool forceApplyCustomization, bool forceApplyMods) { oldData ??= new(); - static bool FileReplacementsEquivalent(ICollection left, ICollection right) - { - if (left.Count != right.Count) - { - return false; - } - - var comparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer.Instance; - return !left.Except(right, comparer).Any() && !right.Except(left, comparer).Any(); - } var charaDataToUpdate = new Dictionary>(); foreach (ObjectKind objectKind in Enum.GetValues()) @@ -105,7 +95,7 @@ public static class VariousExtensions { var oldList = oldData.FileReplacements[objectKind]; var newList = newData.FileReplacements[objectKind]; - var listsAreEqual = FileReplacementsEquivalent(oldList, newList); + var listsAreEqual = oldList.SequenceEqual(newList, PlayerData.Data.FileReplacementDataComparer.Instance); if (!listsAreEqual || forceApplyMods) { logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (FileReplacements not equal) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModFiles); @@ -128,9 +118,9 @@ public static class VariousExtensions .OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList(); var newTail = newFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/tail/", StringComparison.OrdinalIgnoreCase))) .OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList(); - var existingTransients = existingFileReplacements.Where(g => g.GamePaths.Any(g => !g.EndsWith("mdl", StringComparison.OrdinalIgnoreCase) && !g.EndsWith("tex", StringComparison.OrdinalIgnoreCase) && !g.EndsWith("mtrl", StringComparison.OrdinalIgnoreCase))) + var existingTransients = existingFileReplacements.Where(g => g.GamePaths.Any(g => !g.EndsWith("mdl") && !g.EndsWith("tex") && !g.EndsWith("mtrl"))) .OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList(); - var newTransients = newFileReplacements.Where(g => g.GamePaths.Any(g => !g.EndsWith("mdl", StringComparison.OrdinalIgnoreCase) && !g.EndsWith("tex", StringComparison.OrdinalIgnoreCase) && !g.EndsWith("mtrl", StringComparison.OrdinalIgnoreCase))) + var newTransients = newFileReplacements.Where(g => g.GamePaths.Any(g => !g.EndsWith("mdl") && !g.EndsWith("tex") && !g.EndsWith("mtrl"))) .OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList(); logger.LogTrace("[BASE-{appbase}] ExistingFace: {of}, NewFace: {fc}; ExistingHair: {eh}, NewHair: {nh}; ExistingTail: {et}, NewTail: {nt}; ExistingTransient: {etr}, NewTransient: {ntr}", applicationBase, @@ -177,8 +167,7 @@ public static class VariousExtensions if (objectKind != ObjectKind.Player) continue; bool manipDataDifferent = !string.Equals(oldData.ManipulationData, newData.ManipulationData, StringComparison.Ordinal); - var hasManipulationData = !string.IsNullOrEmpty(newData.ManipulationData); - if (manipDataDifferent || (forceApplyMods && hasManipulationData)) + if (manipDataDifferent || forceApplyMods) { 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 de9bca7..47774f7 100644 --- a/LightlessSync/WebAPI/Files/FileDownloadManager.cs +++ b/LightlessSync/WebAPI/Files/FileDownloadManager.cs @@ -3,6 +3,7 @@ using LightlessSync.API.Data; using LightlessSync.API.Dto.Files; using LightlessSync.API.Routes; using LightlessSync.FileCache; +using LightlessSync.LightlessConfiguration; using LightlessSync.PlayerData.Handlers; using LightlessSync.Services.Mediator; using LightlessSync.Services.TextureCompression; @@ -11,20 +12,23 @@ using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Net; using System.Net.Http.Json; -using LightlessSync.LightlessConfiguration; namespace LightlessSync.WebAPI.Files; public partial class FileDownloadManager : DisposableMediatorSubscriberBase { private readonly Dictionary _downloadStatus; + private readonly object _downloadStatusLock = new(); + private readonly FileCompactor _fileCompactor; private readonly FileCacheManager _fileDbManager; private readonly FileTransferOrchestrator _orchestrator; private readonly LightlessConfigService _configService; private readonly TextureDownscaleService _textureDownscaleService; private readonly TextureMetadataHelper _textureMetadataHelper; + private readonly ConcurrentDictionary _activeDownloadStreams; + private volatile bool _disableDirectDownloads; private int _consecutiveDirectDownloadFailures; private bool _lastConfigDirectDownloadsState; @@ -36,7 +40,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase FileCacheManager fileCacheManager, FileCompactor fileCompactor, LightlessConfigService configService, - TextureDownscaleService textureDownscaleService, TextureMetadataHelper textureMetadataHelper) : base(logger, mediator) + TextureDownscaleService textureDownscaleService, + TextureMetadataHelper textureMetadataHelper) : base(logger, mediator) { _downloadStatus = new Dictionary(StringComparer.Ordinal); _orchestrator = orchestrator; @@ -48,42 +53,39 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase _activeDownloadStreams = new(); _lastConfigDirectDownloadsState = _configService.Current.EnableDirectDownloads; - Mediator.Subscribe(this, (msg) => + Mediator.Subscribe(this, _ => { if (_activeDownloadStreams.IsEmpty) return; + var newLimit = _orchestrator.DownloadLimitPerSlot(); Logger.LogTrace("Setting new Download Speed Limit to {newLimit}", newLimit); + foreach (var stream in _activeDownloadStreams.Keys) - { stream.BandwidthLimit = newLimit; - } }); } public List CurrentDownloads { get; private set; } = []; - public List ForbiddenTransfers => _orchestrator.ForbiddenTransfers; public Guid? CurrentOwnerToken { get; private set; } - - public bool IsDownloading => CurrentDownloads.Any(); + public bool IsDownloading => CurrentDownloads.Count != 0; private bool ShouldUseDirectDownloads() - { - return _configService.Current.EnableDirectDownloads && !_disableDirectDownloads; - } + => _configService.Current.EnableDirectDownloads && !_disableDirectDownloads; public static void MungeBuffer(Span buffer) { for (int i = 0; i < buffer.Length; ++i) - { buffer[i] ^= 42; - } } public void ClearDownload() { CurrentDownloads.Clear(); - _downloadStatus.Clear(); + lock (_downloadStatusLock) + { + _downloadStatus.Clear(); + } CurrentOwnerToken = null; } @@ -101,9 +103,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase finally { if (gameObject is not null) - { Mediator.Publish(new DownloadFinishedMessage(gameObject)); - } + Mediator.Publish(new ResumeScanMessage(nameof(DownloadFiles))); } } @@ -111,32 +112,74 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase protected override void Dispose(bool disposing) { ClearDownload(); + foreach (var stream in _activeDownloadStreams.Keys.ToList()) { - try - { - stream.Dispose(); - } + try { stream.Dispose(); } catch { - // do nothing - // - } - finally - { - _activeDownloadStreams.TryRemove(stream, out _); + // ignore } + finally { _activeDownloadStreams.TryRemove(stream, out _); } } + base.Dispose(disposing); } + private sealed class DownloadSlotLease : IAsyncDisposable + { + private readonly FileTransferOrchestrator _orch; + private bool _released; + + public DownloadSlotLease(FileTransferOrchestrator orch) => _orch = orch; + + public ValueTask DisposeAsync() + { + if (!_released) + { + _released = true; + _orch.ReleaseDownloadSlot(); + } + return ValueTask.CompletedTask; + } + } + + private async ValueTask AcquireSlotAsync(CancellationToken ct) + { + await _orchestrator.WaitForDownloadSlotAsync(ct).ConfigureAwait(false); + return new DownloadSlotLease(_orchestrator); + } + + private void SetStatus(string key, DownloadStatus status) + { + lock (_downloadStatusLock) + { + if (_downloadStatus.TryGetValue(key, out var st)) + st.DownloadStatus = status; + } + } + + private void AddTransferredBytes(string key, long delta) + { + lock (_downloadStatusLock) + { + if (_downloadStatus.TryGetValue(key, out var st)) + st.TransferredBytes += delta; + } + } + + private void MarkTransferredFiles(string key, int files) + { + lock (_downloadStatusLock) + { + if (_downloadStatus.TryGetValue(key, out var st)) + st.TransferredFiles = files; + } + } + private static byte MungeByte(int byteOrEof) { - if (byteOrEof == -1) - { - throw new EndOfStreamException(); - } - + if (byteOrEof == -1) throw new EndOfStreamException(); return (byte)(byteOrEof ^ 42); } @@ -144,6 +187,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase { List hashName = []; List fileLength = []; + var separator = (char)MungeByte(fileBlockStream.ReadByte()); if (separator != '#') throw new InvalidDataException("Data is invalid, first char is not #"); @@ -151,8 +195,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase while (true) { int readByte = fileBlockStream.ReadByte(); - if (readByte == -1) - throw new EndOfStreamException(); + if (readByte == -1) throw new EndOfStreamException(); var readChar = (char)MungeByte(readByte); if (readChar == ':') @@ -161,39 +204,69 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase continue; } if (readChar == '#') break; + if (!readHash) hashName.Add(readChar); else fileLength.Add(readChar); } + return (string.Join("", hashName), long.Parse(string.Join("", fileLength))); } - private async Task DownloadAndMungeFileHttpClient(string downloadGroup, Guid requestId, List fileTransfer, string tempPath, IProgress progress, CancellationToken ct) + private static async Task ReadExactlyAsync(FileStream stream, Memory buffer, CancellationToken ct) { - Logger.LogDebug("GUID {requestId} on server {uri} for files {files}", requestId, fileTransfer[0].DownloadUri, string.Join(", ", fileTransfer.Select(c => c.Hash).ToList())); - - await WaitForDownloadReady(fileTransfer, requestId, ct).ConfigureAwait(false); - - if (_downloadStatus.TryGetValue(downloadGroup, out var downloadStatus)) + int offset = 0; + while (offset < buffer.Length) { - downloadStatus.DownloadStatus = DownloadStatus.Downloading; + int n = await stream.ReadAsync(buffer.Slice(offset), ct).ConfigureAwait(false); + if (n == 0) throw new EndOfStreamException(); + offset += n; } - else + } + + private static Dictionary BuildReplacementLookup(List fileReplacement) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var r in fileReplacement) { - Logger.LogWarning("Download status missing for {group} when starting download", downloadGroup); + if (r == null || string.IsNullOrWhiteSpace(r.Hash)) continue; + if (map.ContainsKey(r.Hash)) continue; + + var gamePath = r.GamePaths?.FirstOrDefault() ?? string.Empty; + + string ext = ""; + try + { + ext = Path.GetExtension(gamePath)?.TrimStart('.') ?? ""; + } + catch + { + // ignore + } + + if (string.IsNullOrWhiteSpace(ext)) + ext = "bin"; + + map[r.Hash] = (ext, gamePath); } - var requestUrl = LightlessFiles.CacheGetFullPath(fileTransfer[0].DownloadUri, requestId); - - await DownloadFileThrottled(requestUrl, tempPath, progress, MungeBuffer, ct, withToken: true).ConfigureAwait(false); + return map; } private delegate void DownloadDataCallback(Span data); - private async Task DownloadFileThrottled(Uri requestUrl, string destinationFilename, IProgress progress, DownloadDataCallback? callback, CancellationToken ct, bool withToken) + private async Task DownloadFileThrottled( + Uri requestUrl, + string destinationFilename, + IProgress progress, + DownloadDataCallback? callback, + CancellationToken ct, + bool withToken) { const int maxRetries = 3; int retryCount = 0; TimeSpan retryDelay = TimeSpan.FromSeconds(2); + HttpResponseMessage? response = null; while (true) @@ -201,7 +274,14 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase try { Logger.LogDebug("Attempt {attempt} - Downloading {requestUrl}", retryCount + 1, requestUrl); - response = await _orchestrator.SendRequestAsync(HttpMethod.Get, requestUrl, ct, HttpCompletionOption.ResponseHeadersRead, withToken).ConfigureAwait(false); + response = await _orchestrator.SendRequestAsync( + HttpMethod.Get, + requestUrl, + ct, + HttpCompletionOption.ResponseHeadersRead, + withToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); break; } @@ -246,52 +326,47 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase Logger.LogWarning(ex, "Error during download of {requestUrl}, HttpStatusCode: {code}", requestUrl, ex.StatusCode); if (ex.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Unauthorized) - { throw new InvalidDataException($"Http error {ex.StatusCode} (cancelled: {ct.IsCancellationRequested}): {requestUrl}", ex); - } throw; } } ThrottledStream? stream = null; - FileStream? fileStream = null; try { - fileStream = File.Create(destinationFilename); + // Determine buffer size based on content length + var contentLen = response!.Content.Headers.ContentLength ?? 0; + var bufferSize = contentLen > 1024 * 1024 ? 65536 : 8196; + var buffer = new byte[bufferSize]; + + // Create destination file stream + var fileStream = new FileStream( + destinationFilename, + FileMode.Create, + FileAccess.Write, + FileShare.None, + bufferSize: 64 * 1024, + useAsync: true); + + // Download with throttling await using (fileStream.ConfigureAwait(false)) { - var bufferSize = response!.Content.Headers.ContentLength > 1024 * 1024 ? 65536 : 8196; - var buffer = new byte[bufferSize]; - var limit = _orchestrator.DownloadLimitPerSlot(); Logger.LogTrace("Starting Download with a speed limit of {limit} to {destination}", limit, destinationFilename); - stream = new(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), limit); + stream = new ThrottledStream(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), limit); _activeDownloadStreams.TryAdd(stream, 0); while (true) { ct.ThrowIfCancellationRequested(); - int bytesRead; - try - { - bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct).ConfigureAwait(false); - } - catch (OperationCanceledException ex) - { - Logger.LogWarning(ex, "Request got cancelled : {url}", requestUrl); - throw; - } - if (bytesRead == 0) - { - break; - } + int bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct).ConfigureAwait(false); + if (bytesRead == 0) break; callback?.Invoke(buffer.AsSpan(0, bytesRead)); - await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), ct).ConfigureAwait(false); progress.Report(bytesRead); @@ -300,24 +375,16 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase Logger.LogDebug("{requestUrl} downloaded to {destination}", requestUrl, destinationFilename); } } - catch (OperationCanceledException) - { - throw; - } - catch (Exception) + catch { try { - fileStream?.Close(); - if (!string.IsNullOrEmpty(destinationFilename) && File.Exists(destinationFilename)) - { File.Delete(destinationFilename); - } } - catch - { - // ignore cleanup errors + catch + { + // ignore } throw; } @@ -333,515 +400,6 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase } } - private async Task DecompressBlockFileAsync(string downloadStatusKey, string blockFilePath, List fileReplacement, string downloadLabel, bool skipDownscale) - { - if (_downloadStatus.TryGetValue(downloadStatusKey, out var status)) - { - status.TransferredFiles = 1; - status.DownloadStatus = DownloadStatus.Decompressing; - } - - FileStream? fileBlockStream = null; - try - { - fileBlockStream = File.OpenRead(blockFilePath); - while (fileBlockStream.Position < fileBlockStream.Length) - { - (string fileHash, long fileLengthBytes) = ReadBlockFileHeader(fileBlockStream); - - try - { - var fileExtension = fileReplacement.First(f => string.Equals(f.Hash, fileHash, StringComparison.OrdinalIgnoreCase)).GamePaths[0].Split(".")[^1]; - var filePath = _fileDbManager.GetCacheFilePath(fileHash, fileExtension); - Logger.LogDebug("{dlName}: Decompressing {file}:{le} => {dest}", downloadLabel, fileHash, fileLengthBytes, filePath); - - byte[] compressedFileContent = new byte[fileLengthBytes]; - var readBytes = await fileBlockStream.ReadAsync(compressedFileContent, CancellationToken.None).ConfigureAwait(false); - if (readBytes != fileLengthBytes) - { - throw new EndOfStreamException(); - } - MungeBuffer(compressedFileContent); - - var decompressedFile = LZ4Wrapper.Unwrap(compressedFileContent); - await _fileCompactor.WriteAllBytesAsync(filePath, decompressedFile, CancellationToken.None).ConfigureAwait(false); - - var gamePath = fileReplacement.FirstOrDefault(f => string.Equals(f.Hash, fileHash, StringComparison.OrdinalIgnoreCase))?.GamePaths.FirstOrDefault() ?? string.Empty; - PersistFileToStorage(fileHash, filePath, gamePath, skipDownscale); - } - catch (EndOfStreamException) - { - Logger.LogWarning("{dlName}: Failure to extract file {fileHash}, stream ended prematurely", downloadLabel, fileHash); - } - catch (Exception e) - { - Logger.LogWarning(e, "{dlName}: Error during decompression", downloadLabel); - } - } - } - catch (EndOfStreamException) - { - Logger.LogDebug("{dlName}: Failure to extract file header data, stream ended", downloadLabel); - } - catch (Exception ex) - { - Logger.LogError(ex, "{dlName}: Error during block file read", downloadLabel); - } - finally - { - if (fileBlockStream != null) - await fileBlockStream.DisposeAsync().ConfigureAwait(false); - } - } - - private async Task PerformDirectDownloadFallbackAsync(DownloadFileTransfer directDownload, List fileReplacement, - IProgress progress, CancellationToken token, bool skipDownscale, bool slotAlreadyAcquired) - { - if (string.IsNullOrEmpty(directDownload.DirectDownloadUrl)) - { - throw new InvalidOperationException("Direct download fallback requested without a direct download URL."); - } - - var downloadKey = directDownload.DirectDownloadUrl!; - bool slotAcquiredHere = false; - string? blockFile = null; - - try - { - if (!slotAlreadyAcquired) - { - if (_downloadStatus.TryGetValue(downloadKey, out var tracker)) - { - tracker.DownloadStatus = DownloadStatus.WaitingForSlot; - } - - await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false); - slotAcquiredHere = true; - } - - if (_downloadStatus.TryGetValue(downloadKey, out var queueTracker)) - { - queueTracker.DownloadStatus = DownloadStatus.WaitingForQueue; - } - - var requestIdResponse = await _orchestrator.SendRequestAsync(HttpMethod.Post, LightlessFiles.RequestEnqueueFullPath(directDownload.DownloadUri), - new[] { directDownload.Hash }, token).ConfigureAwait(false); - var requestId = Guid.Parse((await requestIdResponse.Content.ReadAsStringAsync().ConfigureAwait(false)).Trim('"')); - - blockFile = _fileDbManager.GetCacheFilePath(requestId.ToString("N"), "blk"); - - await DownloadAndMungeFileHttpClient(downloadKey, requestId, [directDownload], blockFile, progress, token).ConfigureAwait(false); - - if (!File.Exists(blockFile)) - { - throw new FileNotFoundException("Block file missing after direct download fallback.", blockFile); - } - - await DecompressBlockFileAsync(downloadKey, blockFile, fileReplacement, $"fallback-{directDownload.Hash}", skipDownscale).ConfigureAwait(false); - } - finally - { - if (slotAcquiredHere) - { - _orchestrator.ReleaseDownloadSlot(); - } - - if (!string.IsNullOrEmpty(blockFile)) - { - try - { - File.Delete(blockFile); - } - catch - { - // ignore cleanup errors - } - } - } - } - - public async Task> InitiateDownloadList(GameObjectHandler? gameObjectHandler, List fileReplacement, CancellationToken ct, Guid? ownerToken = null) - { - CurrentOwnerToken = ownerToken; - var objectName = gameObjectHandler?.Name ?? "Unknown"; - Logger.LogDebug("Download start: {id}", objectName); - - if (fileReplacement == null || fileReplacement.Count == 0) - { - Logger.LogDebug("{dlName}: No file replacements provided", objectName); - CurrentDownloads = []; - return CurrentDownloads; - } - - var hashes = fileReplacement.Where(f => f != null && !string.IsNullOrWhiteSpace(f.Hash)).Select(f => f.Hash).Distinct(StringComparer.Ordinal).ToList(); - - if (hashes.Count == 0) - { - Logger.LogDebug("{dlName}: No valid hashes to download", objectName); - CurrentDownloads = []; - return CurrentDownloads; - } - - List downloadFileInfoFromService = - [ - .. 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.Distinct().Select(d => new DownloadFileTransfer(d)) - .Where(d => d.CanBeTransferred).ToList(); - - return CurrentDownloads; - } - - private async Task DownloadFilesInternal(GameObjectHandler? gameObjectHandler, List fileReplacement, CancellationToken ct, bool skipDownscale) - { - var objectName = gameObjectHandler?.Name ?? "Unknown"; - - var configAllowsDirect = _configService.Current.EnableDirectDownloads; - if (configAllowsDirect != _lastConfigDirectDownloadsState) - { - _lastConfigDirectDownloadsState = configAllowsDirect; - if (configAllowsDirect) - { - _disableDirectDownloads = false; - _consecutiveDirectDownloadFailures = 0; - } - } - - var allowDirectDownloads = ShouldUseDirectDownloads(); - - var directDownloads = new List(); - var batchDownloads = new List(); - - foreach (var download in CurrentDownloads) - { - if (!string.IsNullOrEmpty(download.DirectDownloadUrl) && allowDirectDownloads) - { - directDownloads.Add(download); - } - else - { - batchDownloads.Add(download); - } - } - - var downloadBatches = batchDownloads.GroupBy(f => f.DownloadUri.Host + ":" + f.DownloadUri.Port, StringComparer.Ordinal).ToArray(); - - foreach (var directDownload in directDownloads) - { - _downloadStatus[directDownload.DirectDownloadUrl!] = new FileDownloadStatus() - { - DownloadStatus = DownloadStatus.Initializing, - TotalBytes = directDownload.Total, - TotalFiles = 1, - TransferredBytes = 0, - TransferredFiles = 0 - }; - } - - foreach (var downloadBatch in downloadBatches) - { - _downloadStatus[downloadBatch.Key] = new FileDownloadStatus() - { - DownloadStatus = DownloadStatus.Initializing, - TotalBytes = downloadBatch.Sum(c => c.Total), - TotalFiles = 1, - TransferredBytes = 0, - TransferredFiles = 0 - }; - } - - if (directDownloads.Count > 0 || downloadBatches.Length > 0) - { - Logger.LogWarning("Downloading {direct} files directly, and {batchtotal} in {batches} batches.", directDownloads.Count, batchDownloads.Count, downloadBatches.Length); - } - - if (gameObjectHandler is not null) - { - Mediator.Publish(new DownloadStartedMessage(gameObjectHandler, _downloadStatus)); - } - - Task batchDownloadsTask = downloadBatches.Length == 0 ? Task.CompletedTask : Parallel.ForEachAsync(downloadBatches, new ParallelOptions() - { - MaxDegreeOfParallelism = downloadBatches.Length, - CancellationToken = ct, - }, - async (fileGroup, token) => - { - var requestIdResponse = await _orchestrator.SendRequestAsync(HttpMethod.Post, LightlessFiles.RequestEnqueueFullPath(fileGroup.First().DownloadUri), - fileGroup.Select(c => c.Hash), token).ConfigureAwait(false); - Logger.LogDebug("Sent request for {n} files on server {uri} with result {result}", fileGroup.Count(), fileGroup.First().DownloadUri, - await requestIdResponse.Content.ReadAsStringAsync(token).ConfigureAwait(false)); - - Guid requestId = Guid.Parse((await requestIdResponse.Content.ReadAsStringAsync().ConfigureAwait(false)).Trim('"')); - - Logger.LogDebug("GUID {requestId} for {n} files on server {uri}", requestId, fileGroup.Count(), fileGroup.First().DownloadUri); - - var blockFile = _fileDbManager.GetCacheFilePath(requestId.ToString("N"), "blk"); - FileInfo fi = new(blockFile); - try - { - if (!_downloadStatus.TryGetValue(fileGroup.Key, out var downloadStatus)) - { - Logger.LogWarning("Download status missing for {group}, aborting", fileGroup.Key); - return; - } - - downloadStatus.DownloadStatus = DownloadStatus.WaitingForSlot; - await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false); - downloadStatus.DownloadStatus = DownloadStatus.WaitingForQueue; - var progress = CreateInlineProgress((bytesDownloaded) => - { - try - { - if (_downloadStatus.TryGetValue(fileGroup.Key, out FileDownloadStatus? value)) - { - value.TransferredBytes += bytesDownloaded; - } - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Could not set download progress"); - } - }); - await DownloadAndMungeFileHttpClient(fileGroup.Key, requestId, [.. fileGroup], blockFile, progress, token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - Logger.LogDebug("{dlName}: Detected cancellation of download, partially extracting files for {id}", fi.Name, objectName); - } - catch (Exception ex) - { - _orchestrator.ReleaseDownloadSlot(); - File.Delete(blockFile); - Logger.LogError(ex, "{dlName}: Error during download of {id}", fi.Name, requestId); - ClearDownload(); - return; - } - - try - { - if (!File.Exists(blockFile)) - { - Logger.LogWarning("{dlName}: Block file missing before extraction, skipping", fi.Name); - return; - } - - await DecompressBlockFileAsync(fileGroup.Key, blockFile, fileReplacement, fi.Name, skipDownscale).ConfigureAwait(false); - } - finally - { - _orchestrator.ReleaseDownloadSlot(); - File.Delete(blockFile); - } - }); - - Task directDownloadsTask = directDownloads.Count == 0 ? Task.CompletedTask : Parallel.ForEachAsync(directDownloads, new ParallelOptions() - { - MaxDegreeOfParallelism = directDownloads.Count, - CancellationToken = ct, - }, - async (directDownload, token) => - { - if (!_downloadStatus.TryGetValue(directDownload.DirectDownloadUrl!, out var downloadTracker)) - { - Logger.LogWarning("Download status missing for direct URL {url}", directDownload.DirectDownloadUrl); - return; - } - - var progress = CreateInlineProgress((bytesDownloaded) => - { - try - { - if (_downloadStatus.TryGetValue(directDownload.DirectDownloadUrl!, out FileDownloadStatus? value)) - { - value.TransferredBytes += bytesDownloaded; - } - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Could not set download progress"); - } - }); - - if (!ShouldUseDirectDownloads()) - { - await PerformDirectDownloadFallbackAsync(directDownload, fileReplacement, progress, token, skipDownscale, slotAlreadyAcquired: false).ConfigureAwait(false); - return; - } - - var tempFilename = _fileDbManager.GetCacheFilePath(directDownload.Hash, "bin"); - var slotAcquired = false; - - try - { - downloadTracker.DownloadStatus = DownloadStatus.WaitingForSlot; - await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false); - slotAcquired = true; - - downloadTracker.DownloadStatus = DownloadStatus.Downloading; - Logger.LogDebug("Beginning direct download of {hash} from {url}", directDownload.Hash, directDownload.DirectDownloadUrl); - await DownloadFileThrottled(new Uri(directDownload.DirectDownloadUrl!), tempFilename, progress, null, token, withToken: false).ConfigureAwait(false); - - Interlocked.Exchange(ref _consecutiveDirectDownloadFailures, 0); - - downloadTracker.DownloadStatus = DownloadStatus.Decompressing; - - try - { - var replacement = fileReplacement.FirstOrDefault(f => string.Equals(f.Hash, directDownload.Hash, StringComparison.OrdinalIgnoreCase)); - if (replacement == null || replacement.GamePaths.Length == 0) - { - Logger.LogWarning("{hash}: No replacement data found for direct download.", directDownload.Hash); - return; - } - - var fileExtension = replacement.GamePaths[0].Split(".")[^1]; - var finalFilename = _fileDbManager.GetCacheFilePath(directDownload.Hash, fileExtension); - Logger.LogDebug("Decompressing direct download {hash} from {compressedFile} to {finalFile}", directDownload.Hash, tempFilename, finalFilename); - byte[] compressedBytes = await File.ReadAllBytesAsync(tempFilename).ConfigureAwait(false); - var decompressedBytes = LZ4Wrapper.Unwrap(compressedBytes); - await _fileCompactor.WriteAllBytesAsync(finalFilename, decompressedBytes, CancellationToken.None).ConfigureAwait(false); - PersistFileToStorage(directDownload.Hash, finalFilename, replacement.GamePaths[0], skipDownscale); - - downloadTracker.TransferredFiles = 1; - Logger.LogDebug("Finished direct download of {hash}.", directDownload.Hash); - } - catch (Exception ex) - { - Logger.LogError(ex, "Exception downloading {hash} from {url}", directDownload.Hash, directDownload.DirectDownloadUrl); - } - } - catch (OperationCanceledException ex) - { - if (token.IsCancellationRequested) - { - Logger.LogDebug("{hash}: Direct download cancelled by caller, discarding file.", directDownload.Hash); - } - else - { - Logger.LogWarning(ex, "{hash}: Direct download cancelled unexpectedly.", directDownload.Hash); - } - - ClearDownload(); - return; - } - catch (Exception ex) - { - var expectedDirectDownloadFailure = ex is InvalidDataException; - var failureCount = 0; - - if (expectedDirectDownloadFailure) - { - Logger.LogInformation(ex, "{hash}: Direct download unavailable, attempting queued fallback.", directDownload.Hash); - } - else - { - failureCount = Interlocked.Increment(ref _consecutiveDirectDownloadFailures); - Logger.LogWarning(ex, "{hash}: Direct download failed, attempting queued fallback.", directDownload.Hash); - } - - try - { - downloadTracker.DownloadStatus = DownloadStatus.WaitingForQueue; - await PerformDirectDownloadFallbackAsync(directDownload, fileReplacement, progress, token, skipDownscale, slotAcquired).ConfigureAwait(false); - - if (!expectedDirectDownloadFailure && failureCount >= 3 && !_disableDirectDownloads) - { - _disableDirectDownloads = true; - Logger.LogWarning("Disabling direct downloads for this session after {count} consecutive failures.", failureCount); - } - } - catch (Exception fallbackEx) - { - if (slotAcquired) - { - _orchestrator.ReleaseDownloadSlot(); - slotAcquired = false; - } - - Logger.LogError(fallbackEx, "{hash}: Error during direct download fallback.", directDownload.Hash); - ClearDownload(); - return; - } - } - finally - { - if (slotAcquired) - { - _orchestrator.ReleaseDownloadSlot(); - } - - try - { - File.Delete(tempFilename); - } - catch - { - // ignore - } - } - }); - - await Task.WhenAll(batchDownloadsTask, directDownloadsTask).ConfigureAwait(false); - - Logger.LogDebug("Download end: {id}", objectName); - - ClearDownload(); - } - - private async Task> FilesGetSizes(List hashes, CancellationToken ct) - { - if (!_orchestrator.IsInitialized) throw new InvalidOperationException("FileTransferManager is not initialized"); - var response = await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.ServerFilesGetSizesFullPath(_orchestrator.FilesCdnUri!), hashes, ct).ConfigureAwait(false); - return await response.Content.ReadFromJsonAsync>(cancellationToken: ct).ConfigureAwait(false) ?? []; - } - - private void PersistFileToStorage(string fileHash, string filePath, string gamePath, bool skipDownscale) - { - var fi = new FileInfo(filePath); - Func RandomDayInThePast() - { - DateTime start = new(1995, 1, 1, 1, 1, 1, DateTimeKind.Local); - Random gen = new(); - int range = (DateTime.Today - start).Days; - return () => start.AddDays(gen.Next(range)); - } - - fi.CreationTime = RandomDayInThePast().Invoke(); - fi.LastAccessTime = DateTime.Today; - fi.LastWriteTime = RandomDayInThePast().Invoke(); - try - { - var entry = _fileDbManager.CreateCacheEntry(filePath); - var mapKind = _textureMetadataHelper.DetermineMapKind(gamePath, filePath); - if (!skipDownscale) - { - _textureDownscaleService.ScheduleDownscale(fileHash, filePath, mapKind); - } - if (entry != null && !string.Equals(entry.Hash, fileHash, StringComparison.OrdinalIgnoreCase)) - { - Logger.LogError("Hash mismatch after extracting, got {hash}, expected {expectedHash}, deleting file", entry.Hash, fileHash); - File.Delete(filePath); - _fileDbManager.RemoveHashedFile(entry.Hash, entry.PrefixedFilePath); - } - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Error creating cache entry"); - } - } - private async Task WaitForDownloadReady(List downloadFileTransfer, Guid requestId, CancellationToken downloadCt) { bool alreadyCancelled = false; @@ -861,19 +419,17 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase { if (downloadCt.IsCancellationRequested) throw; - try - { - var req = await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCheckQueueFullPath(downloadFileTransfer[0].DownloadUri, requestId), - downloadFileTransfer.Select(c => c.Hash).ToList(), downloadCt).ConfigureAwait(false); - req.EnsureSuccessStatusCode(); - } - catch (Exception ex) when (!downloadCt.IsCancellationRequested) - { - Logger.LogDebug(ex, "Transient error checking queue status for {requestId}, will retry", requestId); - } + var req = await _orchestrator.SendRequestAsync( + HttpMethod.Get, + LightlessFiles.RequestCheckQueueFullPath(downloadFileTransfer[0].DownloadUri, requestId), + downloadFileTransfer.Select(c => c.Hash).ToList(), + downloadCt).ConfigureAwait(false); + + req.EnsureSuccessStatusCode(); localTimeoutCts.Dispose(); composite.Dispose(); + localTimeoutCts = new(); localTimeoutCts.CancelAfter(TimeSpan.FromSeconds(5)); composite = CancellationTokenSource.CreateLinkedTokenSource(downloadCt, localTimeoutCts.Token); @@ -889,12 +445,13 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase { try { - await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCancelFullPath(downloadFileTransfer[0].DownloadUri, requestId)).ConfigureAwait(false); + await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCancelFullPath(downloadFileTransfer[0].DownloadUri, requestId)) + .ConfigureAwait(false); alreadyCancelled = true; } - catch + catch { - // ignore whatever happens here + // ignore } throw; @@ -905,34 +462,537 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase { try { - await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCancelFullPath(downloadFileTransfer[0].DownloadUri, requestId)).ConfigureAwait(false); + await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCancelFullPath(downloadFileTransfer[0].DownloadUri, requestId)) + .ConfigureAwait(false); } - catch + catch { - // ignore whatever happens here + // ignore } } _orchestrator.ClearDownloadRequest(requestId); } } - private static IProgress CreateInlineProgress(Action callback) + private async Task DownloadQueuedBlockFileAsync( + string statusKey, + Guid requestId, + List transfers, + string tempPath, + IProgress progress, + CancellationToken ct) { - return new InlineProgress(callback); + Logger.LogDebug("GUID {requestId} on server {uri} for files {files}", + requestId, transfers[0].DownloadUri, string.Join(", ", transfers.Select(c => c.Hash))); + + // Wait for ready WITHOUT holding a slot + SetStatus(statusKey, DownloadStatus.WaitingForQueue); + await WaitForDownloadReady(transfers, requestId, ct).ConfigureAwait(false); + + // Hold slot ONLY for the GET + SetStatus(statusKey, DownloadStatus.WaitingForSlot); + await using ((await AcquireSlotAsync(ct).ConfigureAwait(false)).ConfigureAwait(false)) + { + SetStatus(statusKey, DownloadStatus.Downloading); + + var requestUrl = LightlessFiles.CacheGetFullPath(transfers[0].DownloadUri, requestId); + await DownloadFileThrottled(requestUrl, tempPath, progress, MungeBuffer, ct, withToken: true).ConfigureAwait(false); + } } + private async Task DecompressBlockFileAsync( + string downloadStatusKey, + string blockFilePath, + Dictionary replacementLookup, + string downloadLabel, + CancellationToken ct, + bool skipDownscale) + { + SetStatus(downloadStatusKey, DownloadStatus.Decompressing); + MarkTransferredFiles(downloadStatusKey, 1); + + try + { + var fileBlockStream = File.OpenRead(blockFilePath); + await using (fileBlockStream.ConfigureAwait(false)) + { + while (fileBlockStream.Position < fileBlockStream.Length) + { + (string fileHash, long fileLengthBytes) = ReadBlockFileHeader(fileBlockStream); + + try + { + if (fileLengthBytes < 0 || fileLengthBytes > int.MaxValue) + throw new InvalidDataException($"Invalid block entry length: {fileLengthBytes}"); + + if (!replacementLookup.TryGetValue(fileHash, out var repl)) + { + Logger.LogWarning("{dlName}: No replacement mapping for {fileHash}", downloadLabel, fileHash); + // still need to skip bytes: + var skip = checked((int)fileLengthBytes); + fileBlockStream.Position += skip; + continue; + } + + var filePath = _fileDbManager.GetCacheFilePath(fileHash, repl.Extension); + + Logger.LogDebug("{dlName}: Decompressing {file}:{len} => {dest}", downloadLabel, fileHash, fileLengthBytes, filePath); + + var len = checked((int)fileLengthBytes); + var compressed = new byte[len]; + + await ReadExactlyAsync(fileBlockStream, compressed.AsMemory(0, len), ct).ConfigureAwait(false); + + MungeBuffer(compressed); + var decompressed = LZ4Wrapper.Unwrap(compressed); + + await _fileCompactor.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false); + PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale); + } + catch (EndOfStreamException) + { + Logger.LogWarning("{dlName}: Failure to extract file {fileHash}, stream ended prematurely", downloadLabel, fileHash); + } + catch (Exception e) + { + Logger.LogWarning(e, "{dlName}: Error during decompression", downloadLabel); + } + } + } + } + catch (EndOfStreamException) + { + Logger.LogDebug("{dlName}: Failure to extract file header data, stream ended", downloadLabel); + } + catch (Exception ex) + { + Logger.LogError(ex, "{dlName}: Error during block file read", downloadLabel); + } + } + + public async Task> InitiateDownloadList( + GameObjectHandler? gameObjectHandler, + List fileReplacement, + CancellationToken ct, + Guid? ownerToken = null) + { + CurrentOwnerToken = ownerToken; + var objectName = gameObjectHandler?.Name ?? "Unknown"; + Logger.LogDebug("Download start: {id}", objectName); + + if (fileReplacement == null || fileReplacement.Count == 0) + { + Logger.LogDebug("{dlName}: No file replacements provided", objectName); + CurrentDownloads = []; + return CurrentDownloads; + } + + var hashes = fileReplacement + .Where(f => f != null && !string.IsNullOrWhiteSpace(f.Hash)) + .Select(f => f.Hash) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (hashes.Count == 0) + { + Logger.LogDebug("{dlName}: No valid hashes to download", objectName); + CurrentDownloads = []; + return CurrentDownloads; + } + + List downloadFileInfoFromService = + [ + .. 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 + .Distinct() + .Select(d => new DownloadFileTransfer(d)) + .Where(d => d.CanBeTransferred) + .ToList(); + + return CurrentDownloads; + } + + private sealed record BatchChunk(string Key, List Items); + + private static IEnumerable> ChunkList(List items, int chunkSize) + { + for (int i = 0; i < items.Count; i += chunkSize) + yield return items.GetRange(i, Math.Min(chunkSize, items.Count - i)); + } + + private async Task DownloadFilesInternal(GameObjectHandler? gameObjectHandler, List fileReplacement, CancellationToken ct, bool skipDownscale) + { + var objectName = gameObjectHandler?.Name ?? "Unknown"; + + // config toggles + var configAllowsDirect = _configService.Current.EnableDirectDownloads; + if (configAllowsDirect != _lastConfigDirectDownloadsState) + { + _lastConfigDirectDownloadsState = configAllowsDirect; + if (configAllowsDirect) + { + _disableDirectDownloads = false; + _consecutiveDirectDownloadFailures = 0; + } + } + + var allowDirectDownloads = ShouldUseDirectDownloads(); + var replacementLookup = BuildReplacementLookup(fileReplacement); + + var directDownloads = new List(); + var batchDownloads = new List(); + + foreach (var download in CurrentDownloads) + { + if (!string.IsNullOrEmpty(download.DirectDownloadUrl) && allowDirectDownloads) + directDownloads.Add(download); + else + batchDownloads.Add(download); + } + + // Chunk per host so we can fill all slots + var slots = Math.Max(1, _configService.Current.ParallelDownloads); + + var batchChunks = batchDownloads + .GroupBy(f => $"{f.DownloadUri.Host}:{f.DownloadUri.Port}", StringComparer.Ordinal) + .SelectMany(g => + { + var list = g.ToList(); + var chunkCount = Math.Min(slots, Math.Max(1, list.Count)); + var chunkSize = (int)Math.Ceiling(list.Count / (double)chunkCount); + + return ChunkList(list, chunkSize) + .Select(chunk => new BatchChunk(g.Key, chunk)); + }) + .ToArray(); + + // init statuses + lock (_downloadStatusLock) + { + _downloadStatus.Clear(); + + // direct downloads and batch downloads tracked separately + foreach (var d in directDownloads) + { + _downloadStatus[d.DirectDownloadUrl!] = new FileDownloadStatus + { + DownloadStatus = DownloadStatus.Initializing, + TotalBytes = d.Total, + TotalFiles = 1, + TransferredBytes = 0, + TransferredFiles = 0 + }; + } + + foreach (var g in batchChunks.GroupBy(c => c.Key, StringComparer.Ordinal)) + { + _downloadStatus[g.Key] = new FileDownloadStatus + { + DownloadStatus = DownloadStatus.Initializing, + TotalBytes = g.SelectMany(x => x.Items).Sum(x => x.Total), + TotalFiles = 1, + TransferredBytes = 0, + TransferredFiles = 0 + }; + } + } + + if (directDownloads.Count > 0 || batchChunks.Length > 0) + { + Logger.LogInformation("Downloading {direct} files directly, and {batchtotal} queued in {chunks} chunks.", + directDownloads.Count, batchDownloads.Count, batchChunks.Length); + } + + if (gameObjectHandler is not null) + Mediator.Publish(new DownloadStartedMessage(gameObjectHandler, _downloadStatus)); + + // allow some extra workers so downloads can continue while earlier items decompress. + var workerDop = Math.Clamp(slots * 2, 2, 16); + + // batch downloads + 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)); + + // 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)); + + await Task.WhenAll(batchTask, directTask).ConfigureAwait(false); + + Logger.LogDebug("Download end: {id}", objectName); + ClearDownload(); + } + + private async Task ProcessBatchChunkAsync(BatchChunk chunk, Dictionary replacementLookup, CancellationToken ct, bool skipDownscale) + { + var statusKey = chunk.Key; + + // enqueue (no slot) + SetStatus(statusKey, DownloadStatus.WaitingForQueue); + + var requestIdResponse = await _orchestrator.SendRequestAsync( + HttpMethod.Post, + LightlessFiles.RequestEnqueueFullPath(chunk.Items[0].DownloadUri), + chunk.Items.Select(c => c.Hash), + ct).ConfigureAwait(false); + + var requestId = Guid.Parse((await requestIdResponse.Content.ReadAsStringAsync(ct).ConfigureAwait(false)).Trim('"')); + + var blockFile = _fileDbManager.GetCacheFilePath(requestId.ToString("N"), "blk"); + var fi = new FileInfo(blockFile); + + try + { + // download (with slot) + var progress = CreateInlineProgress(bytes => AddTransferredBytes(statusKey, bytes)); + + // Download slot held on get + await DownloadQueuedBlockFileAsync(statusKey, requestId, chunk.Items, blockFile, progress, ct).ConfigureAwait(false); + + // decompress if file exists + if (!File.Exists(blockFile)) + { + Logger.LogWarning("{dlName}: Block file missing before extraction, skipping", fi.Name); + return; + } + + await DecompressBlockFileAsync(statusKey, blockFile, replacementLookup, fi.Name, ct, skipDownscale).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + Logger.LogDebug("{dlName}: Detected cancellation of download, partially extracting files for {id}", fi.Name, requestId); + } + catch (Exception ex) + { + Logger.LogError(ex, "{dlName}: Error during batch chunk processing", fi.Name); + ClearDownload(); + } + finally + { + try { File.Delete(blockFile); } catch { /* ignore */ } + } + } + + private async Task ProcessDirectAsync(DownloadFileTransfer directDownload, Dictionary replacementLookup, CancellationToken ct, bool skipDownscale) + { + var progress = CreateInlineProgress(bytes => + { + if (!string.IsNullOrEmpty(directDownload.DirectDownloadUrl)) + AddTransferredBytes(directDownload.DirectDownloadUrl!, bytes); + }); + + if (!ShouldUseDirectDownloads() || string.IsNullOrEmpty(directDownload.DirectDownloadUrl)) + { + await ProcessDirectAsQueuedFallbackAsync(directDownload, replacementLookup, progress, ct, skipDownscale).ConfigureAwait(false); + return; + } + + var tempFilename = _fileDbManager.GetCacheFilePath(directDownload.Hash, "bin"); + + try + { + // Download slot held on get + SetStatus(directDownload.DirectDownloadUrl!, DownloadStatus.WaitingForSlot); + + await using ((await AcquireSlotAsync(ct).ConfigureAwait(false)).ConfigureAwait(false)) + { + SetStatus(directDownload.DirectDownloadUrl!, DownloadStatus.Downloading); + Logger.LogDebug("Beginning direct download of {hash} from {url}", directDownload.Hash, directDownload.DirectDownloadUrl); + + await DownloadFileThrottled(new Uri(directDownload.DirectDownloadUrl!), tempFilename, progress, callback: null, ct, withToken: false) + .ConfigureAwait(false); + } + + Interlocked.Exchange(ref _consecutiveDirectDownloadFailures, 0); + + // Decompress/write + SetStatus(directDownload.DirectDownloadUrl!, DownloadStatus.Decompressing); + + if (!replacementLookup.TryGetValue(directDownload.Hash, out var repl)) + { + Logger.LogWarning("{hash}: No replacement data found for direct download.", directDownload.Hash); + return; + } + + var finalFilename = _fileDbManager.GetCacheFilePath(directDownload.Hash, repl.Extension); + + Logger.LogDebug("Decompressing direct download {hash} from {compressedFile} to {finalFile}", + directDownload.Hash, tempFilename, finalFilename); + + // Read compressed bytes and decompress in memory + byte[] compressedBytes = await File.ReadAllBytesAsync(tempFilename, ct).ConfigureAwait(false); + var decompressedBytes = LZ4Wrapper.Unwrap(compressedBytes); + + await _fileCompactor.WriteAllBytesAsync(finalFilename, decompressedBytes, ct).ConfigureAwait(false); + PersistFileToStorage(directDownload.Hash, finalFilename, repl.GamePath, skipDownscale); + + MarkTransferredFiles(directDownload.DirectDownloadUrl!, 1); + Logger.LogDebug("Finished direct download of {hash}.", directDownload.Hash); + } + catch (OperationCanceledException ex) + { + if (ct.IsCancellationRequested) + Logger.LogDebug("{hash}: Direct download cancelled by caller, discarding file.", directDownload.Hash); + else + Logger.LogWarning(ex, "{hash}: Direct download cancelled unexpectedly.", directDownload.Hash); + + ClearDownload(); + } + catch (Exception ex) + { + var expectedDirectDownloadFailure = ex is InvalidDataException; + var failureCount = expectedDirectDownloadFailure ? 0 : Interlocked.Increment(ref _consecutiveDirectDownloadFailures); + + if (expectedDirectDownloadFailure) + Logger.LogInformation(ex, "{hash}: Direct download unavailable, attempting queued fallback.", directDownload.Hash); + else + Logger.LogWarning(ex, "{hash}: Direct download failed, attempting queued fallback.", directDownload.Hash); + + try + { + await ProcessDirectAsQueuedFallbackAsync(directDownload, replacementLookup, progress, ct, skipDownscale).ConfigureAwait(false); + + if (!expectedDirectDownloadFailure && failureCount >= 3 && !_disableDirectDownloads) + { + _disableDirectDownloads = true; + Logger.LogWarning("Disabling direct downloads for this session after {count} consecutive failures.", failureCount); + } + } + catch (Exception fallbackEx) + { + Logger.LogError(fallbackEx, "{hash}: Error during direct download fallback.", directDownload.Hash); + ClearDownload(); + } + } + finally + { + try { File.Delete(tempFilename); } + catch + { + // ignore + } + } + } + + private async Task ProcessDirectAsQueuedFallbackAsync( + DownloadFileTransfer directDownload, + Dictionary replacementLookup, + IProgress progress, + CancellationToken ct, + bool skipDownscale) + { + if (string.IsNullOrEmpty(directDownload.DirectDownloadUrl)) + throw new InvalidOperationException("Direct download fallback requested without a direct download URL."); + + var statusKey = directDownload.DirectDownloadUrl!; + + SetStatus(statusKey, DownloadStatus.WaitingForQueue); + + var requestIdResponse = await _orchestrator.SendRequestAsync( + HttpMethod.Post, + LightlessFiles.RequestEnqueueFullPath(directDownload.DownloadUri), + new[] { directDownload.Hash }, + ct).ConfigureAwait(false); + + var requestId = Guid.Parse((await requestIdResponse.Content.ReadAsStringAsync(ct).ConfigureAwait(false)).Trim('"')); + var blockFile = _fileDbManager.GetCacheFilePath(requestId.ToString("N"), "blk"); + + try + { + await DownloadQueuedBlockFileAsync(statusKey, requestId, [directDownload], blockFile, progress, ct).ConfigureAwait(false); + + 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) + .ConfigureAwait(false); + } + finally + { + try { File.Delete(blockFile); } + catch + { + // ignore + } + } + } + + private async Task> FilesGetSizes(List hashes, CancellationToken ct) + { + if (!_orchestrator.IsInitialized) + throw new InvalidOperationException("FileTransferManager is not initialized"); + + // batch request + var response = await _orchestrator.SendRequestAsync( + HttpMethod.Get, + LightlessFiles.ServerFilesGetSizesFullPath(_orchestrator.FilesCdnUri!), + hashes, + ct).ConfigureAwait(false); + + // ensure success + return await response.Content.ReadFromJsonAsync>(cancellationToken: ct).ConfigureAwait(false) ?? []; + } + + private void PersistFileToStorage(string fileHash, string filePath, string gamePath, bool skipDownscale) + { + var fi = new FileInfo(filePath); + + Func RandomDayInThePast() + { + DateTime start = new(1995, 1, 1, 1, 1, 1, DateTimeKind.Local); + Random gen = new(); + int range = (DateTime.Today - start).Days; + return () => start.AddDays(gen.Next(range)); + } + + fi.CreationTime = RandomDayInThePast().Invoke(); + fi.LastAccessTime = DateTime.Today; + fi.LastWriteTime = RandomDayInThePast().Invoke(); + + try + { + var entry = _fileDbManager.CreateCacheEntry(filePath); + var mapKind = _textureMetadataHelper.DetermineMapKind(gamePath, filePath); + + if (!skipDownscale) + _textureDownscaleService.ScheduleDownscale(fileHash, filePath, mapKind); + + if (entry != null && !string.Equals(entry.Hash, fileHash, StringComparison.OrdinalIgnoreCase)) + { + Logger.LogError("Hash mismatch after extracting, got {hash}, expected {expectedHash}, deleting file", + entry.Hash, fileHash); + + File.Delete(filePath); + _fileDbManager.RemoveHashedFile(entry.Hash, entry.PrefixedFilePath); + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Error creating cache entry"); + } + } + + private static IProgress CreateInlineProgress(Action callback) => new InlineProgress(callback); + private sealed class InlineProgress : IProgress { private readonly Action _callback; - - public InlineProgress(Action callback) - { - _callback = callback ?? throw new ArgumentNullException(nameof(callback)); - } - - public void Report(long value) - { - _callback(value); - } + public InlineProgress(Action callback) => _callback = callback ?? throw new ArgumentNullException(nameof(callback)); + public void Report(long value) => _callback(value); } } diff --git a/LightlessSync/WebAPI/Files/FileTransferOrchestrator.cs b/LightlessSync/WebAPI/Files/FileTransferOrchestrator.cs index ac77b23..9586577 100644 --- a/LightlessSync/WebAPI/Files/FileTransferOrchestrator.cs +++ b/LightlessSync/WebAPI/Files/FileTransferOrchestrator.cs @@ -18,56 +18,72 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase private readonly LightlessConfigService _lightlessConfig; private readonly object _semaphoreModificationLock = new(); private readonly TokenProvider _tokenProvider; + private int _availableDownloadSlots; private SemaphoreSlim _downloadSemaphore; + private int CurrentlyUsedDownloadSlots => _availableDownloadSlots - _downloadSemaphore.CurrentCount; - public FileTransferOrchestrator(ILogger logger, LightlessConfigService lightlessConfig, - LightlessMediator mediator, TokenProvider tokenProvider, HttpClient httpClient) : base(logger, mediator) + public FileTransferOrchestrator( + ILogger logger, + LightlessConfigService lightlessConfig, + LightlessMediator mediator, + TokenProvider tokenProvider, + HttpClient httpClient) : base(logger, mediator) { _lightlessConfig = lightlessConfig; _tokenProvider = tokenProvider; _httpClient = httpClient; + var ver = Assembly.GetExecutingAssembly().GetName().Version; - _httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("LightlessSync", ver!.Major + "." + ver!.Minor + "." + ver!.Build)); + _httpClient.DefaultRequestHeaders.UserAgent.Add( + new ProductInfoHeaderValue("LightlessSync", $"{ver!.Major}.{ver.Minor}.{ver.Build}")); - _availableDownloadSlots = lightlessConfig.Current.ParallelDownloads; - _downloadSemaphore = new(_availableDownloadSlots, _availableDownloadSlots); + _availableDownloadSlots = Math.Max(1, lightlessConfig.Current.ParallelDownloads); + _downloadSemaphore = new SemaphoreSlim(_availableDownloadSlots, _availableDownloadSlots); - Mediator.Subscribe(this, (msg) => - { - FilesCdnUri = msg.Connection.ServerInfo.FileServerAddress; - }); - - Mediator.Subscribe(this, (msg) => - { - FilesCdnUri = null; - }); - Mediator.Subscribe(this, (msg) => - { - _downloadReady[msg.RequestId] = true; - }); + Mediator.Subscribe(this, msg => FilesCdnUri = msg.Connection.ServerInfo.FileServerAddress); + Mediator.Subscribe(this, _ => FilesCdnUri = null); + Mediator.Subscribe(this, msg => _downloadReady[msg.RequestId] = true); } + /// + /// Files CDN Uri from server + /// public Uri? FilesCdnUri { private set; get; } + + /// + /// Forbidden file transfers given by server + /// public List ForbiddenTransfers { get; } = []; + + /// + /// Is the FileTransferOrchestrator initialized + /// public bool IsInitialized => FilesCdnUri != null; - public void ClearDownloadRequest(Guid guid) - { - _downloadReady.Remove(guid, out _); - } + /// + /// Configured parallel downloads in settings (ParallelDownloads) + /// + public int ConfiguredParallelDownloads => Math.Max(1, _lightlessConfig.Current.ParallelDownloads); + /// + /// Clears the download request for the given guid + /// + /// Guid of download request + public void ClearDownloadRequest(Guid guid) => _downloadReady.Remove(guid, out _); + + /// + /// Is the download ready for the given guid + /// + /// Guid of download request + /// Completion of the download public bool IsDownloadReady(Guid guid) - { - if (_downloadReady.TryGetValue(guid, out bool isReady) && isReady) - { - return true; - } - - return false; - } + => _downloadReady.TryGetValue(guid, out bool isReady) && isReady; + /// + /// Release a download slot after download is complete + /// public void ReleaseDownloadSlot() { try @@ -81,60 +97,26 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase } } - public async Task SendRequestAsync(HttpMethod method, Uri uri, - CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead, - bool withToken = true) - { - return await SendRequestInternalAsync(() => new HttpRequestMessage(method, uri), - ct, httpCompletionOption, withToken, allowRetry: true).ConfigureAwait(false); - } - - public async Task SendRequestAsync(HttpMethod method, Uri uri, T content, CancellationToken ct, - bool withToken = true) where T : class - { - return await SendRequestInternalAsync(() => - { - var requestMessage = new HttpRequestMessage(method, uri); - if (content is not ByteArrayContent byteArrayContent) - { - requestMessage.Content = JsonContent.Create(content); - } - else - { - var clonedContent = new ByteArrayContent(byteArrayContent.ReadAsByteArrayAsync().GetAwaiter().GetResult()); - foreach (var header in byteArrayContent.Headers) - { - clonedContent.Headers.TryAddWithoutValidation(header.Key, header.Value); - } - requestMessage.Content = clonedContent; - } - - return requestMessage; - }, ct, HttpCompletionOption.ResponseContentRead, withToken, - allowRetry: content is not HttpContent || content is ByteArrayContent).ConfigureAwait(false); - } - - public async Task SendRequestStreamAsync(HttpMethod method, Uri uri, ProgressableStreamContent content, - CancellationToken ct, bool withToken = true) - { - return await SendRequestInternalAsync(() => - { - var requestMessage = new HttpRequestMessage(method, uri) - { - Content = content - }; - return requestMessage; - }, ct, HttpCompletionOption.ResponseContentRead, withToken, allowRetry: false).ConfigureAwait(false); - } - + /// + /// Wait for an available download slot asyncronously + /// + /// Cancellation Token + /// Task of the slot public async Task WaitForDownloadSlotAsync(CancellationToken token) { lock (_semaphoreModificationLock) { - if (_availableDownloadSlots != _lightlessConfig.Current.ParallelDownloads && _availableDownloadSlots == _downloadSemaphore.CurrentCount) + var desired = Math.Max(1, _lightlessConfig.Current.ParallelDownloads); + + if (_availableDownloadSlots != desired && + _availableDownloadSlots == _downloadSemaphore.CurrentCount) { - _availableDownloadSlots = _lightlessConfig.Current.ParallelDownloads; - _downloadSemaphore = new(_availableDownloadSlots, _availableDownloadSlots); + _availableDownloadSlots = desired; + + var old = _downloadSemaphore; + _downloadSemaphore = new SemaphoreSlim(_availableDownloadSlots, _availableDownloadSlots); + + try { old.Dispose(); } catch { /* ignore */ } } } @@ -142,10 +124,15 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase Mediator.Publish(new DownloadLimitChangedMessage()); } + /// + /// Download limit per slot in bytes + /// + /// Bytes of the download limit public long DownloadLimitPerSlot() { var limit = _lightlessConfig.Current.DownloadSpeedLimitInBytes; if (limit <= 0) return 0; + limit = _lightlessConfig.Current.DownloadSpeedType switch { LightlessConfiguration.Models.DownloadSpeeds.Bps => limit, @@ -153,22 +140,113 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase LightlessConfiguration.Models.DownloadSpeeds.MBps => limit * 1024 * 1024, _ => limit, }; - var currentUsedDlSlots = CurrentlyUsedDownloadSlots; - var avaialble = _availableDownloadSlots; - var currentCount = _downloadSemaphore.CurrentCount; - var dividedLimit = limit / (currentUsedDlSlots == 0 ? 1 : currentUsedDlSlots); - if (dividedLimit < 0) + + var usedSlots = CurrentlyUsedDownloadSlots; + var divided = limit / (usedSlots <= 0 ? 1 : usedSlots); + + if (divided < 0) { - Logger.LogWarning("Calculated Bandwidth Limit is negative, returning Infinity: {value}, CurrentlyUsedDownloadSlots is {currentSlots}, " + - "DownloadSpeedLimit is {limit}, available slots: {avail}, current count: {count}", dividedLimit, currentUsedDlSlots, limit, avaialble, currentCount); + Logger.LogWarning( + "Calculated Bandwidth Limit is negative, returning Infinity: {value}, usedSlots={usedSlots}, limit={limit}, avail={avail}, currentCount={count}", + divided, usedSlots, limit, _availableDownloadSlots, _downloadSemaphore.CurrentCount); return long.MaxValue; } - return Math.Clamp(dividedLimit, 1, long.MaxValue); + + return Math.Clamp(divided, 1, long.MaxValue); } - private async Task SendRequestInternalAsync(Func requestFactory, - CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead, - bool withToken = true, bool allowRetry = true) + /// + /// sends an HTTP request without content serialization + /// + /// HttpMethod for the request + /// Uri for the request + /// Cancellation Token + /// Enum of HttpCollectionOption + /// Include Cancellation Token + /// Http response of the request + public async Task SendRequestAsync( + HttpMethod method, + Uri uri, + CancellationToken? ct = null, + HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead, + bool withToken = true) + { + return await SendRequestInternalAsync( + () => new HttpRequestMessage(method, uri), + ct, + httpCompletionOption, + withToken, + allowRetry: true).ConfigureAwait(false); + } + + /// + /// Sends an HTTP request with JSON content serialization + /// + /// HttpResponseMessage + /// Http method + /// Url of the direct download link + /// content of the request + /// cancellation token + /// include cancellation token + /// + public async Task SendRequestAsync( + HttpMethod method, + Uri uri, + T content, + CancellationToken ct, + bool withToken = true) where T : class + { + return await SendRequestInternalAsync(() => + { + var requestMessage = new HttpRequestMessage(method, uri); + + if (content is ByteArrayContent byteArrayContent) + { + var bytes = byteArrayContent.ReadAsByteArrayAsync(ct).GetAwaiter().GetResult(); + var cloned = new ByteArrayContent(bytes); + foreach (var header in byteArrayContent.Headers) + cloned.Headers.TryAddWithoutValidation(header.Key, header.Value); + + requestMessage.Content = cloned; + } + else + { + requestMessage.Content = JsonContent.Create(content); + } + + return requestMessage; + }, ct, HttpCompletionOption.ResponseContentRead, withToken, + allowRetry: content is not HttpContent || content is ByteArrayContent).ConfigureAwait(false); + } + + public async Task SendRequestStreamAsync( + HttpMethod method, + Uri uri, + ProgressableStreamContent content, + CancellationToken ct, + bool withToken = true) + { + return await SendRequestInternalAsync(() => + { + return new HttpRequestMessage(method, uri) { Content = content }; + }, ct, HttpCompletionOption.ResponseContentRead, withToken, allowRetry: false).ConfigureAwait(false); + } + + /// + /// sends an HTTP request with optional retry logic for transient network errors + /// + /// Request factory + /// Cancellation Token + /// Http Options + /// With cancellation token + /// Allows retry of request + /// Response message of request + private async Task SendRequestInternalAsync( + Func requestFactory, + CancellationToken? ct = null, + HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead, + bool withToken = true, + bool allowRetry = true) { const int maxAttempts = 2; var attempt = 0; @@ -184,8 +262,11 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); } - if (requestMessage.Content != null && requestMessage.Content is not StreamContent && requestMessage.Content is not ByteArrayContent) + if (requestMessage.Content != null && + requestMessage.Content is not StreamContent && + requestMessage.Content is not ByteArrayContent) { + // log content for debugging var content = await ((JsonContent)requestMessage.Content).ReadAsStringAsync().ConfigureAwait(false); Logger.LogDebug("Sending {method} to {uri} (Content: {content})", requestMessage.Method, requestMessage.RequestUri, content); } @@ -196,9 +277,10 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase try { - if (ct != null) - return await _httpClient.SendAsync(requestMessage, httpCompletionOption, ct.Value).ConfigureAwait(false); - return await _httpClient.SendAsync(requestMessage, httpCompletionOption).ConfigureAwait(false); + // send request + return ct != null + ? await _httpClient.SendAsync(requestMessage, httpCompletionOption, ct.Value).ConfigureAwait(false) + : await _httpClient.SendAsync(requestMessage, httpCompletionOption).ConfigureAwait(false); } catch (TaskCanceledException) { @@ -208,14 +290,11 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase { Logger.LogWarning(ex, "Transient error during SendRequestInternal for {uri}, retrying attempt {attempt}/{maxAttempts}", requestMessage.RequestUri, attempt, maxAttempts); + if (ct.HasValue) - { await Task.Delay(TimeSpan.FromMilliseconds(200), ct.Value).ConfigureAwait(false); - } else - { await Task.Delay(TimeSpan.FromMilliseconds(200)).ConfigureAwait(false); - } } catch (Exception ex) { @@ -225,6 +304,11 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase } } + /// + /// Is the exception a transient network exception + /// + /// expection + /// Is transient network expection private static bool IsTransientNetworkException(Exception ex) { var current = ex; @@ -232,12 +316,13 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase { if (current is SocketException socketEx) { - return socketEx.SocketErrorCode is SocketError.ConnectionReset or SocketError.ConnectionAborted or SocketError.TimedOut; + return socketEx.SocketErrorCode is + SocketError.ConnectionReset or + SocketError.ConnectionAborted or + SocketError.TimedOut; } - current = current.InnerException; } - return false; } -} \ No newline at end of file +} diff --git a/LightlessSync/WebAPI/SignalR/ApiController.cs b/LightlessSync/WebAPI/SignalR/ApiController.cs index 011a6d8..14c90ca 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.cs @@ -418,7 +418,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL public Task CyclePauseAsync(PairUniqueIdentifier ident) { - var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); _ = Task.Run(async () => { var token = timeoutCts.Token; @@ -430,20 +430,19 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL return; } - var originalPermissions = entry.SelfPermissions; - var targetPermissions = originalPermissions; - targetPermissions.SetPaused(!originalPermissions.IsPaused()); + var targetPermissions = entry.SelfPermissions; + targetPermissions.SetPaused(paused: true); await UserSetPairPermissions(new UserPermissionsDto(entry.User, targetPermissions)).ConfigureAwait(false); - var applied = false; + var pauseApplied = false; while (!token.IsCancellationRequested) { if (_pairCoordinator.Ledger.TryGetEntry(ident, out var updated) && updated is not null) { if (updated.SelfPermissions == targetPermissions) { - applied = true; + pauseApplied = true; entry = updated; break; } @@ -453,13 +452,16 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL Logger.LogTrace("Waiting for permissions change for {uid}", ident.UserId); } - if (!applied) + if (!pauseApplied) { Logger.LogWarning("CyclePauseAsync timed out waiting for pause acknowledgement for {uid}", ident.UserId); return; } - Logger.LogDebug("CyclePauseAsync toggled paused for {uid} to {state}", ident.UserId, targetPermissions.IsPaused()); + targetPermissions.SetPaused(paused: false); + await UserSetPairPermissions(new UserPermissionsDto(entry.User, targetPermissions)).ConfigureAwait(false); + + Logger.LogDebug("CyclePauseAsync completed pause cycle for {uid}", ident.UserId); } catch (OperationCanceledException) { @@ -479,16 +481,26 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL } public async Task PauseAsync(UserData userData) + { + await SetPausedStateAsync(userData, paused: true).ConfigureAwait(false); + } + + public async Task UnpauseAsync(UserData userData) + { + await SetPausedStateAsync(userData, paused: false).ConfigureAwait(false); + } + + private async Task SetPausedStateAsync(UserData userData, bool paused) { var pairIdent = new PairUniqueIdentifier(userData.UID); if (!_pairCoordinator.Ledger.TryGetEntry(pairIdent, out var entry) || entry is null) { - Logger.LogWarning("PauseAsync: pair {uid} not found in ledger", userData.UID); + Logger.LogWarning("SetPausedStateAsync: pair {uid} not found in ledger", userData.UID); return; } var permissions = entry.SelfPermissions; - permissions.SetPaused(paused: true); + permissions.SetPaused(paused); await UserSetPairPermissions(new UserPermissionsDto(userData, permissions)).ConfigureAwait(false); }