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/Interop/Ipc/IpcCallerPenumbra.cs b/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs index c167654..0092cfb 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs @@ -92,6 +92,9 @@ public sealed class IpcCallerPenumbra : IpcServiceBase public string GetMetaManipulations() => _resources.GetMetaManipulations(); + public Task>>> GetClientOnScreenResourcePaths() // why did i add this, i honestly don't know but i'm keeping it anyways, fuck you + => _resources.GetClientOnScreenResourcePathsAsync(); + public Task<(string[] forward, string[][] reverse)> ResolvePathsAsync(string[] forward, string[] reverse) => _resources.ResolvePathsAsync(forward, reverse); 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..99ada4e 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; @@ -56,11 +59,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 +80,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 +148,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 +173,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa FileDownloadManager transferManager, PluginWarningNotificationService pluginWarningNotificationManager, DalamudUtilService dalamudUtil, + ActorObjectService actorObjectService, IHostApplicationLifetime lifetime, FileCacheManager fileDbManager, PlayerPerformanceService playerPerformanceService, @@ -162,6 +190,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _downloadManager = transferManager; _pluginWarningNotificationManager = pluginWarningNotificationManager; _dalamudUtil = dalamudUtil; + _actorObjectService = actorObjectService; _lifetime = lifetime; _fileDbManager = fileDbManager; _playerPerformanceService = playerPerformanceService; @@ -185,6 +214,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return; } + ActorObjectService.ActorDescriptor? trackedDescriptor = null; lock (_initializationGate) { if (Initialized) @@ -198,7 +228,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 +269,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() @@ -737,6 +804,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 +888,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 +915,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 +999,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 +1196,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 +1212,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 +1252,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 +1269,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 +1423,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 +1435,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 +1485,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 +1509,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 +1589,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 +1598,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 +1607,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 +1677,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 +1727,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 +1745,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 +1796,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 +2140,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..2001f1f 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; @@ -71,6 +72,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory { var downloadManager = _fileDownloadManagerFactory.Create(); var dalamudUtilService = _serviceProvider.GetRequiredService(); + var actorObjectService = _serviceProvider.GetRequiredService(); return new PairHandlerAdapter( _loggerFactory.CreateLogger(), _mediator, @@ -81,6 +83,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory downloadManager, _pluginWarningNotificationManager, dalamudUtilService, + actorObjectService, _lifetime, _fileCacheManager, _playerPerformanceService, diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 8198bb3..a6e33ac 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -201,6 +201,7 @@ public sealed class Plugin : IDalamudPlugin gameInteropProvider, objectTable, clientState, + condition, sp.GetRequiredService())); services.AddSingleton(sp => new DalamudUtilService( 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..762654b 100644 --- a/LightlessSync/Services/ContextMenuService.cs +++ b/LightlessSync/Services/ContextMenuService.cs @@ -129,7 +129,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); diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 106adf2..91d0037 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) @@ -784,7 +829,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; @@ -850,9 +895,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 52ff1dc..f50fcfc 100644 --- a/LightlessSync/Services/LightFinder/LightFinderScannerService.cs +++ b/LightlessSync/Services/LightFinder/LightFinderScannerService.cs @@ -148,10 +148,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase private void UpdateSyncshellBroadcasts() { 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)) { @@ -163,12 +167,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, @@ -178,6 +187,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/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/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 548fc75..ad570df 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -1546,6 +1546,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/SyncshellFinderUI.cs b/LightlessSync/UI/SyncshellFinderUI.cs index 0586c06..7076537 100644 --- a/LightlessSync/UI/SyncshellFinderUI.cs +++ b/LightlessSync/UI/SyncshellFinderUI.cs @@ -140,19 +140,10 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase return; } - string? myHashedCid = null; - try - { - var cid = _dalamudUtilService.GetCID(); - myHashedCid = cid.ToString().GetHash256(); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to get CID, not excluding own broadcast."); - } - var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts().Where(b => !string.Equals(b.HashedCID, myHashedCid, StringComparison.Ordinal)).ToList() ?? []; + var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts().ToList() ?? []; + _broadcastScannerService.TryGetLocalHashedCid(out var localHashedCid); - var cardData = new List<(GroupJoinDto Shell, string BroadcasterName)>(); + var cardData = new List<(GroupJoinDto Shell, string BroadcasterName, bool IsSelfBroadcast)>(); foreach (var shell in _nearbySyncshells) { @@ -185,9 +176,15 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase broadcasterName = !string.IsNullOrEmpty(worldName) ? $"{name} ({worldName})" : name; + + var isSelfBroadcast = !string.IsNullOrEmpty(localHashedCid) + && string.Equals(broadcast.HashedCID, localHashedCid, StringComparison.Ordinal); + + cardData.Add((shell, broadcasterName, isSelfBroadcast)); + continue; } - cardData.Add((shell, broadcasterName)); + cardData.Add((shell, broadcasterName, false)); } if (cardData.Count == 0) @@ -210,7 +207,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase DrawConfirmation(); } - private void DrawSyncshellList(List<(GroupJoinDto Shell, string BroadcasterName)> listData) + private void DrawSyncshellList(List<(GroupJoinDto Shell, string BroadcasterName, bool IsSelfBroadcast)> listData) { const int shellsPerPage = 3; var totalPages = (int)Math.Ceiling(listData.Count / (float)shellsPerPage); @@ -227,7 +224,10 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase for (int index = firstIndex; index < lastExclusive; index++) { - var (shell, broadcasterName) = listData[index]; + var (shell, broadcasterName, isSelfBroadcast) = listData[index]; + var broadcasterLabel = string.IsNullOrEmpty(broadcasterName) + ? (isSelfBroadcast ? "You" : string.Empty) + : (isSelfBroadcast ? $"{broadcasterName} (You)" : broadcasterName); ImGui.PushID(shell.Group.GID); float rowHeight = 74f * ImGuiHelpers.GlobalScale; @@ -239,7 +239,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase var style = ImGui.GetStyle(); float startX = ImGui.GetCursorPosX(); float regionW = ImGui.GetContentRegionAvail().X; - float rightTxtW = ImGui.CalcTextSize(broadcasterName).X; + float rightTxtW = ImGui.CalcTextSize(broadcasterLabel).X; _uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple")); if (ImGui.IsItemHovered()) @@ -252,7 +252,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase float rightX = startX + regionW - rightTxtW - style.ItemSpacing.X; ImGui.SameLine(); ImGui.SetCursorPosX(rightX); - ImGui.TextUnformatted(broadcasterName); + ImGui.TextUnformatted(broadcasterLabel); if (ImGui.IsItemHovered()) ImGui.SetTooltip("Broadcaster of the syncshell."); @@ -291,7 +291,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase float joinX = rowStartLocal.X + (tagsWidth > 0 ? tagsWidth + style.ItemSpacing.X : 0f); ImGui.SetCursorPos(new Vector2(joinX, btnBaselineY)); - DrawJoinButton(shell); + DrawJoinButton(shell, isSelfBroadcast); float btnHeight = ImGui.GetFrameHeightWithSpacing(); float rowHeightUsed = MathF.Max(tagsHeight, btnHeight); @@ -311,7 +311,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase DrawPagination(totalPages); } - private void DrawSyncshellGrid(List<(GroupJoinDto Shell, string BroadcasterName)> cardData) + private void DrawSyncshellGrid(List<(GroupJoinDto Shell, string BroadcasterName, bool IsSelfBroadcast)> cardData) { const int shellsPerPage = 4; var totalPages = (int)Math.Ceiling(cardData.Count / (float)shellsPerPage); @@ -336,7 +336,10 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase for (int index = firstIndex; index < lastExclusive; index++) { var localIndex = index - firstIndex; - var (shell, broadcasterName) = cardData[index]; + var (shell, broadcasterName, isSelfBroadcast) = cardData[index]; + var broadcasterLabel = string.IsNullOrEmpty(broadcasterName) + ? (isSelfBroadcast ? "You" : string.Empty) + : (isSelfBroadcast ? $"{broadcasterName} (You)" : broadcasterName); if (localIndex % 2 != 0) ImGui.SameLine(); @@ -373,17 +376,17 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase float maxBroadcasterWidth = regionRightX - minBroadcasterX; - string broadcasterToShow = broadcasterName; + string broadcasterToShow = broadcasterLabel; - if (!string.IsNullOrEmpty(broadcasterName) && maxBroadcasterWidth > 0f) + if (!string.IsNullOrEmpty(broadcasterLabel) && maxBroadcasterWidth > 0f) { - float bcFullWidth = ImGui.CalcTextSize(broadcasterName).X; + float bcFullWidth = ImGui.CalcTextSize(broadcasterLabel).X; string toolTip; if (bcFullWidth > maxBroadcasterWidth) { - broadcasterToShow = TruncateTextToWidth(broadcasterName, maxBroadcasterWidth); - toolTip = broadcasterName + Environment.NewLine + Environment.NewLine + "Broadcaster of the syncshell."; + broadcasterToShow = TruncateTextToWidth(broadcasterLabel, maxBroadcasterWidth); + toolTip = broadcasterLabel + Environment.NewLine + Environment.NewLine + "Broadcaster of the syncshell."; } else { @@ -443,7 +446,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase if (remainingY > 0) ImGui.Dummy(new Vector2(0, remainingY)); - DrawJoinButton(shell); + DrawJoinButton(shell, isSelfBroadcast); ImGui.EndChild(); ImGui.EndGroup(); @@ -489,7 +492,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase } } - private void DrawJoinButton(dynamic shell) + private void DrawJoinButton(GroupJoinDto shell, bool isSelfBroadcast) { const string visibleLabel = "Join"; var label = $"{visibleLabel}##{shell.Group.GID}"; @@ -517,7 +520,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase buttonSize = new Vector2(-1, 0); } - if (!isAlreadyMember && !isRecentlyJoined) + if (!isAlreadyMember && !isRecentlyJoined && !isSelfBroadcast) { ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen")); ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f)); @@ -567,7 +570,9 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase ImGui.Button(label, buttonSize); } - UiSharedService.AttachToolTip("Already a member or owner of this Syncshell."); + UiSharedService.AttachToolTip(isSelfBroadcast + ? "This is your own Syncshell." + : "Already a member or owner of this Syncshell."); } ImGui.PopStyleColor(3); diff --git a/LightlessSync/UI/TopTabMenu.cs b/LightlessSync/UI/TopTabMenu.cs index cc69a5d..46a06c4 100644 --- a/LightlessSync/UI/TopTabMenu.cs +++ b/LightlessSync/UI/TopTabMenu.cs @@ -800,22 +800,9 @@ public class TopTabMenu if (!_lightFinderService.IsBroadcasting) return "Syncshell Finder"; - string? myHashedCid = null; - try - { - var cid = _dalamudUtilService.GetCID(); - myHashedCid = cid.ToString().GetHash256(); - } - catch (Exception) - { - // Couldnt get own CID, log and return default table - } - var nearbyCount = _lightFinderScannerService - .GetActiveSyncshellBroadcasts() - .Where(b => - !string.IsNullOrEmpty(b.GID) && - !string.Equals(b.HashedCID, myHashedCid, StringComparison.Ordinal)) + .GetActiveSyncshellBroadcasts(excludeLocal: true) + .Where(b => !string.IsNullOrEmpty(b.GID)) .Select(b => b.GID!) .Distinct(StringComparer.Ordinal) .Count(); 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 49dd868..59a7e3d 100644 --- a/LightlessSync/WebAPI/Files/FileDownloadManager.cs +++ b/LightlessSync/WebAPI/Files/FileDownloadManager.cs @@ -563,7 +563,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase if (directDownloads.Count > 0 || downloadBatches.Length > 0) { - Logger.LogWarning("Downloading {direct} files directly, and {batchtotal} in {batches} batches.", directDownloads.Count, batchDownloads.Count, downloadBatches.Length); + Logger.LogInformation("Downloading {direct} files directly, and {batchtotal} in {batches} batches.", directDownloads.Count, batchDownloads.Count, downloadBatches.Length); } if (gameObjectHandler is not null) 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); }