diff --git a/LightlessAPI b/LightlessAPI index 8e4432a..5656600 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit 8e4432af45c1955436afe309c93e019577ad10e5 +Subproject commit 56566003e0e93bba05dcef49fd3ce23c6a204d81 diff --git a/LightlessSync/Changelog/changelog.yaml b/LightlessSync/Changelog/changelog.yaml index 18a0a8c..942964b 100644 --- a/LightlessSync/Changelog/changelog.yaml +++ b/LightlessSync/Changelog/changelog.yaml @@ -1,11 +1,44 @@ -tagline: "Lightless Sync v2.0.0" +tagline: "Lightless Sync v2.0.1" subline: "LIGHTLESS IS EVOLVING!!" changelog: + - name: "v2.0.1" + tagline: "Some Fixes" + date: "December 23 2025" + # be sure to set this every new version + isCurrent: true + versions: + - number: "Chat" + icon: "" + items: + - "You can turn off the syncshell chat as Owner by going to the Syncshell Admin panel -> Owner -> Enable/Disable Chat." + - "Fixed an issue where you can't chat due to regions being in a different language." + - number: "LightFinder" + icon: "" + items: + - "The icon/Lightfinder Text will be hidden when Game UI is hidden and behind game elements/UI" + - "Able to select an icon for the selected list or a custom glyph if you know the code." + - "Smoothing and reducing jitter on the icon/Lightfinder Text." + - "Fixed so higher scaled UI options (100/150/200% UI scale) wouldn't break the element." + - "Detects if GPose is active, wouldn't render the elements" + - number: "Miscellaneous fixes" + icon: "" + items: + - "Fixed the null error given on GetCID when transferring between zones/housing." + - "Added push/pop on certain ImGUI elements to remove them after being used. " + - "Having all tabs open in the Main UI wouldn't lag out the game anymore." + - "Cycle pause has been adjusted to the old function. There is a separate button to pause normally, now called 'Toggle (Un)Pause State'." + - "Changes have been made to the character redraw to address the issues with the building character data constantly being redrawn and the redrawn behavior with Honorific titles." + - "GPose characters should appear again in the actor screen" + - "Lightspeed download console messages are no longer shown as warnings." + - number: "Server Updates" + icon: "" + items: + - "Changes have been made to the disabling of your profile. It should save again." + - "Ability added to toggle chats from syncshell to be disabled." + - "Files are continuously being deleted due to high volumes in storage, potentially causing MCDOs to have missing files. We have increased the limit of the storage in our configurations to see if that helps." - name: "v2.0.0" tagline: "Thank you for 4 months!" date: "December 2025" - # be sure to set this every new version - isCurrent: true versions: - number: "Lightless Chat" icon: "" diff --git a/LightlessSync/FileCache/TransientResourceManager.cs b/LightlessSync/FileCache/TransientResourceManager.cs index 2ef8f22..115c616 100644 --- a/LightlessSync/FileCache/TransientResourceManager.cs +++ b/LightlessSync/FileCache/TransientResourceManager.cs @@ -59,7 +59,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase _playerRelatedPointers.Remove(msg.GameObjectHandler); }); - foreach (var descriptor in _actorObjectService.PlayerDescriptors) + foreach (var descriptor in _actorObjectService.ObjectDescriptors) { HandleActorTracked(descriptor); } @@ -291,7 +291,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase } var activeDescriptors = new Dictionary(); - foreach (var descriptor in _actorObjectService.PlayerDescriptors) + foreach (var descriptor in _actorObjectService.ObjectDescriptors) { if (TryResolveObjectKind(descriptor, out var resolvedKind)) { diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 28e7eb2..81076b2 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -3,7 +3,7 @@ - 2.0.0 + 2.0.1 https://github.com/Light-Public-Syncshells/LightlessClient diff --git a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs index 65709d1..c90096d 100644 --- a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs +++ b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs @@ -16,6 +16,8 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP private readonly Func _getAddress; private readonly bool _isOwnedObject; private readonly PerformanceCollectorService _performanceCollector; + private readonly object _frameworkUpdateGate = new(); + private bool _frameworkUpdateSubscribed; private byte _classJob = 0; private Task? _delayedZoningTask; private bool _haltProcessing = false; @@ -47,7 +49,10 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP }); } - Mediator.Subscribe(this, (_) => FrameworkUpdate()); + if (_isOwnedObject) + { + EnableFrameworkUpdates(); + } Mediator.Subscribe(this, (_) => ZoneSwitchEnd()); Mediator.Subscribe(this, (_) => ZoneSwitchStart()); @@ -109,7 +114,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP { while (await _dalamudUtil.RunOnFrameworkThread(() => { - if (_haltProcessing) CheckAndUpdateObject(); + EnsureLatestObjectState(); if (CurrentDrawCondition != DrawCondition.None) return true; var gameObj = _dalamudUtil.CreateGameObject(Address); if (gameObj is Dalamud.Game.ClientState.Objects.Types.ICharacter chara) @@ -148,6 +153,11 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP _haltProcessing = false; } + public void Refresh() + { + _dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult(); + } + public async Task IsBeingDrawnRunOnFrameworkAsync() { return await _dalamudUtil.RunOnFrameworkThread(IsBeingDrawn).ConfigureAwait(false); @@ -361,7 +371,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP private bool IsBeingDrawn() { - if (_haltProcessing) CheckAndUpdateObject(); + EnsureLatestObjectState(); if (_dalamudUtil.IsAnythingDrawing) { @@ -373,6 +383,28 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP return CurrentDrawCondition != DrawCondition.None; } + private void EnsureLatestObjectState() + { + if (_haltProcessing || !_frameworkUpdateSubscribed) + { + CheckAndUpdateObject(); + } + } + + private void EnableFrameworkUpdates() + { + lock (_frameworkUpdateGate) + { + if (_frameworkUpdateSubscribed) + { + return; + } + + Mediator.Subscribe(this, _ => FrameworkUpdate()); + _frameworkUpdateSubscribed = true; + } + } + private unsafe DrawCondition IsBeingDrawnUnsafe() { if (Address == IntPtr.Zero) return DrawCondition.ObjectZero; diff --git a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs index 5561bfe..0566491 100644 --- a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs @@ -25,6 +25,11 @@ bool IsDownloading { get; } int PendingDownloadCount { get; } int ForbiddenDownloadCount { get; } + bool PendingModReapply { get; } + bool ModApplyDeferred { get; } + int MissingCriticalMods { get; } + int MissingNonCriticalMods { get; } + int MissingForbiddenMods { get; } DateTime? InvisibleSinceUtc { get; } DateTime? VisibilityEvictionDueAtUtc { get; } diff --git a/LightlessSync/PlayerData/Pairs/Pair.cs b/LightlessSync/PlayerData/Pairs/Pair.cs index 935b705..2a85cd3 100644 --- a/LightlessSync/PlayerData/Pairs/Pair.cs +++ b/LightlessSync/PlayerData/Pairs/Pair.cs @@ -87,22 +87,25 @@ public class Pair return; } - if (args.Target is not MenuTargetDefault target || target.TargetObjectId != handler.PlayerCharacterId || IsPaused) + if (args.Target is not MenuTargetDefault target || target.TargetObjectId != handler.PlayerCharacterId) { return; } - UiSharedService.AddContextMenuItem(args, name: "Open Profile", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => + if (!IsPaused) { - _mediator.Publish(new ProfileOpenStandaloneMessage(this)); - return Task.CompletedTask; - }); + UiSharedService.AddContextMenuItem(args, name: "Open Profile", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => + { + _mediator.Publish(new ProfileOpenStandaloneMessage(this)); + return Task.CompletedTask; + }); - UiSharedService.AddContextMenuItem(args, name: "Reapply last data", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => - { - ApplyLastReceivedData(forced: true); - return Task.CompletedTask; - }); + UiSharedService.AddContextMenuItem(args, name: "Reapply last data", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => + { + ApplyLastReceivedData(forced: true); + return Task.CompletedTask; + }); + } UiSharedService.AddContextMenuItem(args, name: "Change Permissions", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => { @@ -110,7 +113,24 @@ public class Pair return Task.CompletedTask; }); - UiSharedService.AddContextMenuItem(args, name: "Cycle pause state", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => + if (IsPaused) + { + UiSharedService.AddContextMenuItem(args, name: "Toggle Unpause State", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => + { + _ = _apiController.Value.UnpauseAsync(UserData); + return Task.CompletedTask; + }); + } + else + { + UiSharedService.AddContextMenuItem(args, name: "Toggle Pause State", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => + { + _ = _apiController.Value.PauseAsync(UserData); + return Task.CompletedTask; + }); + } + + UiSharedService.AddContextMenuItem(args, name: "Cycle Pause State", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => { TriggerCyclePause(); return Task.CompletedTask; @@ -218,6 +238,11 @@ public class Pair handler.IsApplying, handler.IsDownloading, handler.PendingDownloadCount, - handler.ForbiddenDownloadCount); + handler.ForbiddenDownloadCount, + handler.PendingModReapply, + handler.ModApplyDeferred, + handler.MissingCriticalMods, + handler.MissingNonCriticalMods, + handler.MissingForbiddenMods); } } diff --git a/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs b/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs index 31c3236..60abf35 100644 --- a/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs +++ b/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs @@ -16,7 +16,12 @@ public sealed record PairDebugInfo( bool IsApplying, bool IsDownloading, int PendingDownloadCount, - int ForbiddenDownloadCount) + int ForbiddenDownloadCount, + bool PendingModReapply, + bool ModApplyDeferred, + int MissingCriticalMods, + int MissingNonCriticalMods, + int MissingForbiddenMods) { public static PairDebugInfo Empty { get; } = new( false, @@ -34,5 +39,10 @@ public sealed record PairDebugInfo( false, false, 0, + 0, + false, + false, + 0, + 0, 0); } diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index 706b0bc..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 58374e3..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( @@ -267,6 +268,7 @@ public sealed class Plugin : IDalamudPlugin sp.GetRequiredService>(), addonLifecycle, gameGui, + clientState, sp.GetRequiredService(), sp.GetRequiredService(), objectTable, 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 c8668eb..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; } @@ -327,8 +360,8 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber public IEnumerable GetGposeCharactersFromObjectTable() { - foreach (var actor in _actorObjectService.PlayerDescriptors - .Where(a => a.ObjectKind == DalamudObjectKind.Player && a.ObjectIndex > 200)) + foreach (var actor in _objectTable + .Where(a => a.ObjectIndex > 200 && a.ObjectKind == DalamudObjectKind.Player)) { var character = _objectTable.CreateObjectReference(actor.Address) as ICharacter; if (character != null) @@ -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) @@ -490,6 +535,10 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber { EnsureIsOnFramework(); var playerChar = GetPlayerCharacter(); + + if (playerChar == null || playerChar.Address == IntPtr.Zero) + return 0; + return ((BattleChara*)playerChar.Address)->Character.ContentId; } @@ -780,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; @@ -846,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/LightFinderPlateHandler.cs b/LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs index d78563c..5048ab7 100644 --- a/LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs +++ b/LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs @@ -4,6 +4,7 @@ using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; using Dalamud.Game.ClientState.Objects.Enums; using Dalamud.Game.Text; using Dalamud.Interface; +using Dalamud.Interface.Utility; using Dalamud.Plugin; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.System.Framework; @@ -16,22 +17,27 @@ using LightlessSync.UI; using LightlessSync.UI.Services; using LightlessSync.Utils; using LightlessSync.UtilsEnum.Enum; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Pictomancy; using System.Collections.Immutable; using System.Globalization; using System.Numerics; +using System.Runtime.InteropServices; using Task = System.Threading.Tasks.Task; namespace LightlessSync.Services.LightFinder; +/// +/// The new lightfinder nameplate handler using ImGUI (pictomancy) for rendering the icon/labels. +/// public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscriber { private readonly ILogger _logger; private readonly IAddonLifecycle _addonLifecycle; private readonly IGameGui _gameGui; private readonly IObjectTable _objectTable; + private readonly IClientState _clientState; private readonly LightlessConfigService _configService; private readonly PairUiService _pairUiService; private readonly LightlessMediator _mediator; @@ -42,21 +48,33 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe private bool _needsLabelRefresh; private bool _drawSubscribed; private AddonNamePlate* _mpNameplateAddon; - private readonly object _labelLock = new(); + private readonly Lock _labelLock = new(); private readonly NameplateBuffers _buffers = new(); private int _labelRenderCount; - private const string DefaultLabelText = "LightFinder"; - private const SeIconChar DefaultIcon = SeIconChar.Hyadelyn; - private static readonly string DefaultIconGlyph = SeIconCharExtensions.ToIconString(DefaultIcon); - private static readonly Vector2 DefaultPivot = new(0.5f, 1f); + private const string _defaultLabelText = "LightFinder"; + private const SeIconChar _defaultIcon = SeIconChar.Hyadelyn; + private static readonly string _defaultIconGlyph = SeIconCharExtensions.ToIconString(_defaultIcon); + private static readonly Vector2 _defaultPivot = new(0.5f, 1f); + private uint _lastNamePlateDrawFrame; + // / Overlay window flags + private const ImGuiWindowFlags _overlayFlags = + ImGuiWindowFlags.NoDecoration | + ImGuiWindowFlags.NoBackground | + ImGuiWindowFlags.NoMove | + ImGuiWindowFlags.NoSavedSettings | + ImGuiWindowFlags.NoNav | + ImGuiWindowFlags.NoInputs; + + private readonly List _uiRects = new(128); private ImmutableHashSet _activeBroadcastingCids = []; public LightFinderPlateHandler( ILogger logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, + IClientState clientState, LightlessConfigService configService, LightlessMediator mediator, IObjectTable objectTable, @@ -67,6 +85,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe _logger = logger; _addonLifecycle = addonLifecycle; _gameGui = gameGui; + _clientState = clientState; _configService = configService; _mediator = mediator; _objectTable = objectTable; @@ -101,6 +120,9 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe _mpNameplateAddon = null; } + /// + /// Enable nameplate handling. + /// internal void EnableNameplate() { if (!_mEnabled) @@ -118,6 +140,9 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe } } + /// + /// Disable nameplate handling. + /// internal void DisableNameplate() { if (_mEnabled) @@ -136,8 +161,21 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe } } + /// + /// Draw detour for nameplate addon. + /// + /// + /// private void NameplateDrawDetour(AddonEvent type, AddonArgs args) { + if (_clientState.IsGPosing) + { + ClearLabelBuffer(); + Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length); + _lastNamePlateDrawFrame = 0; + return; + } + if (args.Addon.Address == nint.Zero) { if (_logger.IsEnabled(LogLevel.Warning)) @@ -145,6 +183,10 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe return; } + var fw = Framework.Instance(); + if (fw != null) + _lastNamePlateDrawFrame = fw->FrameCounter; + var pNameplateAddon = (AddonNamePlate*)args.Addon.Address; if (_mpNameplateAddon != pNameplateAddon) @@ -156,6 +198,9 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe UpdateNameplateNodes(); } + /// + /// Updates the nameplate nodes with LightFinder objects. + /// private void UpdateNameplateNodes() { var currentHandle = _gameGui.GetAddonByName("NamePlate"); @@ -175,6 +220,12 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe return; } + if (!IsNamePlateAddonVisible()) + { + ClearLabelBuffer(); + return; + } + var framework = Framework.Instance(); if (framework == null) { @@ -207,7 +258,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe } var visibleUserIdsSnapshot = VisibleUserIds; - var safeCount = System.Math.Min(ui3DModule->NamePlateObjectInfoCount, vec.Length); + var safeCount = Math.Min(ui3DModule->NamePlateObjectInfoCount, vec.Length); var currentConfig = _configService.Current; var labelColor = UIColors.Get("Lightfinder"); var edgeColor = UIColors.Get("LightfinderEdge"); @@ -215,6 +266,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe for (int i = 0; i < safeCount; ++i) { + var objectInfoPtr = vec[i]; if (objectInfoPtr == null) continue; @@ -250,7 +302,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe var root = nameplateObject.RootComponentNode; var nameContainer = nameplateObject.NameContainer; var nameText = nameplateObject.NameText; - var marker = nameplateObject.MarkerIcon; if (root == null || root->Component == null || nameContainer == null || nameText == null) { @@ -261,14 +312,14 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe root->Component->UldManager.UpdateDrawNodeList(); - bool isVisible = - (marker != null && marker->AtkResNode.IsVisible()) || - (nameContainer->IsVisible() && nameText->AtkResNode.IsVisible()) || - currentConfig.LightfinderLabelShowHidden; + bool isNameplateVisible = + nameContainer->IsVisible() && + nameText->AtkResNode.IsVisible(); - if (!isVisible) + if (!currentConfig.LightfinderLabelShowHidden && !isNameplateVisible) continue; + // Prepare label content and scaling var scaleMultiplier = System.Math.Clamp(currentConfig.LightfinderLabelScale, 0.5f, 2.0f); var baseScale = currentConfig.LightfinderLabelUseIcon ? 1.0f : 0.5f; var effectiveScale = baseScale * scaleMultiplier; @@ -276,10 +327,10 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe var targetFontSize = (int)System.Math.Round(baseFontSize * scaleMultiplier); var labelContent = currentConfig.LightfinderLabelUseIcon ? NormalizeIconGlyph(currentConfig.LightfinderLabelIconGlyph) - : DefaultLabelText; + : _defaultLabelText; if (!currentConfig.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal))) - labelContent = DefaultLabelText; + labelContent = _defaultLabelText; var nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale); var nodeHeight = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale); @@ -322,6 +373,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe () => GetScaledTextWidth(nameText), nodeWidth); + // Text offset caching var textOffset = (int)System.Math.Round(nameText->AtkResNode.X); var hasValidOffset = TryCacheTextOffset(nameplateIndex, rawTextWidth, textOffset); @@ -332,65 +384,93 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe continue; } + var res = nameContainer; + + // X scale + var worldScaleX = GetWorldScaleX(res); + if (worldScaleX <= 0f) worldScaleX = 1f; + + // Y scale + var worldScaleY = GetWorldScaleY(res); + if (worldScaleY <= 0f) worldScaleY = 1f; + + positionY += currentConfig.LightfinderLabelOffsetY; + var positionYScreen = positionY * worldScaleY; + float finalX; if (currentConfig.LightfinderAutoAlign) { - var measuredWidth = System.Math.Max(1, textWidth > 0 ? textWidth : nodeWidth); + // auto X positioning + var measuredWidth = Math.Max(1, textWidth > 0 ? textWidth : nodeWidth); var measuredWidthF = (float)measuredWidth; - var alignmentType = currentConfig.LabelAlignment; - var containerScale = nameContainer->ScaleX; - if (containerScale <= 0f) - containerScale = 1f; - var containerWidthRaw = (float)nameContainer->Width; - if (containerWidthRaw <= 0f) - containerWidthRaw = measuredWidthF; - var containerWidth = containerWidthRaw * containerScale; - if (containerWidth <= 0f) - containerWidth = measuredWidthF; + // consider icon width + var containerWidthLocal = res->Width > 0 ? res->Width : measuredWidthF; + var containerWidthScreen = containerWidthLocal * worldScaleX; - var containerLeft = nameContainer->ScreenX; - var containerRight = containerLeft + containerWidth; - var containerCenter = containerLeft + (containerWidth * 0.5f); + // container bounds for positions + var containerLeft = res->ScreenX; + var containerRight = containerLeft + containerWidthScreen; + var containerCenter = containerLeft + (containerWidthScreen * 0.5f); var iconMargin = currentConfig.LightfinderLabelUseIcon - ? System.Math.Min(containerWidth * 0.1f, 14f * containerScale) + ? MathF.Min(containerWidthScreen * 0.1f, 14f * worldScaleX) : 0f; - switch (alignmentType) + var offsetXScreen = currentConfig.LightfinderLabelOffsetX * worldScaleX; + + // alignment based on config + switch (currentConfig.LabelAlignment) { case LabelAlignment.Left: - finalX = containerLeft + iconMargin; + finalX = containerLeft + iconMargin + offsetXScreen; alignment = AlignmentType.BottomLeft; break; case LabelAlignment.Right: - finalX = containerRight - iconMargin; + finalX = containerRight - iconMargin + offsetXScreen; alignment = AlignmentType.BottomRight; break; default: - finalX = containerCenter; + finalX = containerCenter + offsetXScreen; alignment = AlignmentType.Bottom; break; } - - finalX += currentConfig.LightfinderLabelOffsetX; } else { + // manual X positioning var cachedTextOffset = _buffers.TextOffsets[nameplateIndex]; var hasCachedOffset = cachedTextOffset != int.MinValue; - var baseOffsetX = (!currentConfig.LightfinderLabelUseIcon && hasValidOffset && hasCachedOffset) ? cachedTextOffset : 0; - finalX = nameContainer->ScreenX + baseOffsetX + 58 + currentConfig.LightfinderLabelOffsetX; + var baseOffsetXLocal = (!currentConfig.LightfinderLabelUseIcon && hasValidOffset && hasCachedOffset) + ? cachedTextOffset + : 0; + + finalX = + res->ScreenX + + (baseOffsetXLocal * worldScaleX) + + (58f * worldScaleX) + + (currentConfig.LightfinderLabelOffsetX * worldScaleX); + alignment = AlignmentType.Bottom; } - positionY += currentConfig.LightfinderLabelOffsetY; - alignment = (AlignmentType)System.Math.Clamp((int)alignment, 0, 8); + alignment = (AlignmentType)Math.Clamp((int)alignment, 0, 8); - var finalPosition = new Vector2(finalX, nameContainer->ScreenY + positionY); + // final position before smoothing + var finalPosition = new Vector2(finalX, res->ScreenY + positionYScreen); + var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; // often same for Y + var fw = Framework.Instance(); + float dt = fw->RealFrameDeltaTime; + + //smoothing.. + finalPosition = SnapToPixels(finalPosition, dpiScale); + finalPosition = SmoothPosition(nameplateIndex, finalPosition, dt); + finalPosition = SnapToPixels(finalPosition, dpiScale); + + // prepare label info var pivot = (currentConfig.LightfinderAutoAlign || currentConfig.LightfinderLabelUseIcon) ? AlignmentToPivot(alignment) - : DefaultPivot; + : _defaultPivot; var textColorPacked = PackColor(labelColor); var edgeColorPacked = PackColor(edgeColor); @@ -418,11 +498,42 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe } } + /// + /// On each tick, process any needed updates for the UI Builder. + /// private void OnUiBuilderDraw() { if (!_mEnabled) return; + var fw = Framework.Instance(); + if (fw == null) + return; + + // Frame skip check + var frame = fw->FrameCounter; + + if (_lastNamePlateDrawFrame == 0 || (frame - _lastNamePlateDrawFrame) > 1) + { + ClearLabelBuffer(); + Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length); + + return; + } + + //Gpose Check + if (_clientState.IsGPosing) + { + ClearLabelBuffer(); + Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length); + _lastNamePlateDrawFrame = 0; + return; + } + + // If nameplate addon is not visible, skip rendering + if (!IsNamePlateAddonVisible()) + return; + int copyCount; lock (_labelLock) { @@ -433,21 +544,84 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe Array.Copy(_buffers.LabelRender, _buffers.LabelCopy, copyCount); } + var uiModule = fw != null ? fw->GetUIModule() : null; + + if (uiModule != null) + { + var rapture = uiModule->GetRaptureAtkModule(); + if (rapture != null) + RefreshUiRects(&rapture->RaptureAtkUnitManager); + else + _uiRects.Clear(); + } + else + { + _uiRects.Clear(); + } + + // Needed for imgui overlay viewport for the multi window view. + var vp = ImGui.GetMainViewport(); + var vpPos = vp.Pos; + + ImGuiHelpers.ForceNextWindowMainViewport(); + + ImGui.SetNextWindowPos(vp.Pos); + ImGui.SetNextWindowSize(vp.Size); + + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 0); + ImGui.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 0); + + ImGui.Begin("##LightFinderOverlay", _overlayFlags); + + ImGui.PopStyleVar(2); + using var drawList = PictoService.Draw(); if (drawList == null) + { + ImGui.End(); return; + } for (int i = 0; i < copyCount; ++i) { ref var info = ref _buffers.LabelCopy[i]; + + // final draw position with viewport offset + var drawPos = info.ScreenPosition + vpPos; var font = default(ImFontPtr); if (info.UseIcon) { var ioFonts = ImGui.GetIO().Fonts; font = ioFonts.Fonts.Size > 1 ? new ImFontPtr(ioFonts.Fonts[1]) : ImGui.GetFont(); } + else + { + font = ImGui.GetFont(); + } - drawList.AddScreenText(info.ScreenPosition, info.Text, info.TextColor, info.FontSize, info.Pivot, info.EdgeColor, font); + if (!font.IsNull) + ImGui.PushFont(font); + + // calculate size for occlusion checking + var baseSize = ImGui.CalcTextSize(info.Text); + var baseFontSize = ImGui.GetFontSize(); + + if (!font.IsNull) + ImGui.PopFont(); + + // scale size based on font size + var scale = baseFontSize > 0 ? (info.FontSize / baseFontSize) : 1f; + var size = baseSize * scale; + + // label rect for occlusion checking + var topLeft = info.ScreenPosition - new Vector2(size.X * info.Pivot.X, size.Y * info.Pivot.Y); + var labelRect = new RectF(topLeft.X, topLeft.Y, topLeft.X + size.X, topLeft.Y + size.Y); + + // occlusion check + if (IsOccludedByAnyUi(labelRect)) + continue; + + drawList.AddScreenText(drawPos, info.Text, info.TextColor, info.FontSize, info.Pivot, info.EdgeColor, font); } } @@ -460,15 +634,15 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe AlignmentType.Top => new Vector2(0.5f, 0f), AlignmentType.Left => new Vector2(0f, 0.5f), AlignmentType.Right => new Vector2(1f, 0.5f), - _ => DefaultPivot + _ => _defaultPivot }; private static uint PackColor(Vector4 color) { - var r = (byte)System.Math.Clamp(color.X * 255f, 0f, 255f); - var g = (byte)System.Math.Clamp(color.Y * 255f, 0f, 255f); - var b = (byte)System.Math.Clamp(color.Z * 255f, 0f, 255f); - var a = (byte)System.Math.Clamp(color.W * 255f, 0f, 255f); + var r = (byte)Math.Clamp(color.X * 255f, 0f, 255f); + var g = (byte)Math.Clamp(color.Y * 255f, 0f, 255f); + var b = (byte)Math.Clamp(color.Z * 255f, 0f, 255f); + var a = (byte)Math.Clamp(color.W * 255f, 0f, 255f); return (uint)((a << 24) | (b << 16) | (g << 8) | r); } @@ -514,10 +688,19 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe if (scale <= 0f) scale = 1f; - var computed = (int)System.Math.Round(rawWidth * scale); - return System.Math.Max(1, computed); + var computed = (int)Math.Round(rawWidth * scale); + return Math.Max(1, computed); } + /// + /// Resolves a cached value for the given index. + /// + /// + /// + /// + /// + /// + /// private static int ResolveCache( int[] cache, int index, @@ -545,7 +728,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe private bool TryCacheTextOffset(int nameplateIndex, int measuredTextWidth, int textOffset) { - if (System.Math.Abs(measuredTextWidth) > 0 || textOffset != 0) + if (Math.Abs(measuredTextWidth) > 0 || textOffset != 0) { _buffers.TextOffsets[nameplateIndex] = textOffset; return true; @@ -554,10 +737,193 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe return false; } + /// + /// Snapping a position to pixel grid based on DPI scale. + /// + /// Position + /// DPI Scale + /// + private static Vector2 SnapToPixels(Vector2 p, float dpiScale) + { + // snap to pixel grid + var x = MathF.Round(p.X * dpiScale) / dpiScale; + var y = MathF.Round(p.Y * dpiScale) / dpiScale; + return new Vector2(x, y); + } + + + /// + /// Smooths the position using exponential smoothing. + /// + /// Nameplate Index + /// Final position + /// Delta Time + /// How responssive the smooting should be + /// + private Vector2 SmoothPosition(int idx, Vector2 target, float dt, float responsiveness = 24f) + { + // exponential smoothing + if (!_buffers.HasSmoothed[idx]) + { + _buffers.HasSmoothed[idx] = true; + _buffers.SmoothedPos[idx] = target; + return target; + } + + // get current smoothed position + var cur = _buffers.SmoothedPos[idx]; + + // compute smoothing factor + var a = 1f - MathF.Exp(-responsiveness * dt); + + // snap if close enough + if (Vector2.DistanceSquared(cur, target) < 0.25f) + return cur; + + // lerp towards target + cur = Vector2.Lerp(cur, target, a); + _buffers.SmoothedPos[idx] = cur; + return cur; + } + + /// + /// Tries to get a valid screen rect for the given addon. + /// + /// Addon UI + /// Screen positioning/param> + /// RectF of Addon + /// + private static bool TryGetAddonRect(AtkUnitBase* addon, Vector2 screen, out RectF rect) + { + // Addon existence + rect = default; + if (addon == null) + return false; + + // Visibility check + var root = addon->RootNode; + if (root == null || !root->IsVisible()) + return false; + + // Size check + float w = root->Width; + float h = root->Height; + if (w <= 0 || h <= 0) + return false; + + // Local scale + float sx = root->ScaleX; if (sx <= 0f) sx = 1f; + float sy = root->ScaleY; if (sy <= 0f) sy = 1f; + + // World/composed scale from Transform + float wsx = GetWorldScaleX(root); + float wsy = GetWorldScaleY(root); + if (wsx <= 0f) wsx = 1f; + if (wsy <= 0f) wsy = 1f; + + // World scale may include parent scaling; use it if meaningfully different. + float useX = MathF.Abs(wsx - sx) > 0.01f ? wsx : sx; + float useY = MathF.Abs(wsy - sy) > 0.01f ? wsy : sy; + + w *= useX; + h *= useY; + + if (w < 4f || h < 4f) + return false; + + // Screen coords + float l = root->ScreenX; + float t = root->ScreenY; + float r = l + w; + float b = t + h; + + // Drop fullscreen-ish / insane rects + if (w >= screen.X * 0.98f && h >= screen.Y * 0.98f) + return false; + + // Drop offscreen rects + if (l < -screen.X || t < -screen.Y || r > screen.X * 2f || b > screen.Y * 2f) + return false; + + rect = new RectF(l, t, r, b); + return true; + } + + /// + /// Refreshes the cached UI rects for occlusion checking. + /// + /// Unit Manager + private void RefreshUiRects(RaptureAtkUnitManager* unitMgr) + { + _uiRects.Clear(); + if (unitMgr == null) + return; + + var screen = ImGui.GetIO().DisplaySize; + + ref var list = ref unitMgr->AllLoadedUnitsList; + var count = (int)list.Count; + + for (int i = 0; i < count; i++) + { + var addon = list.Entries[i].Value; + if (addon == null) + continue; + + if (_mpNameplateAddon != null && addon == (AtkUnitBase*)_mpNameplateAddon) + continue; + + if (TryGetAddonRect(addon, screen, out var r)) + _uiRects.Add(r); + } + } + + /// + /// Is the given label rect occluded by any UI rects? + /// + /// UI/Label Rect + /// Is occluded or not + private bool IsOccludedByAnyUi(RectF labelRect) + { + for (int i = 0; i < _uiRects.Count; i++) + { + if (_uiRects[i].Intersects(labelRect)) + return true; + } + return false; + } + + /// + /// Gets the world scale X of the given node. + /// + /// Node + /// World Scale of node + private static float GetWorldScaleX(AtkResNode* n) + { + var t = n->Transform; + return MathF.Sqrt(t.M11 * t.M11 + t.M12 * t.M12); + } + + /// + /// Gets the world scale Y of the given node. + /// + /// Node + /// World Scale of node + private static float GetWorldScaleY(AtkResNode* n) + { + var t = n->Transform; + return MathF.Sqrt(t.M21 * t.M21 + t.M22 * t.M22); + } + + /// + /// Normalize an icon glyph input into a valid string. + /// + /// Raw glyph input + /// Normalized glyph input internal static string NormalizeIconGlyph(string? rawInput) { if (string.IsNullOrWhiteSpace(rawInput)) - return DefaultIconGlyph; + return _defaultIconGlyph; var trimmed = rawInput.Trim(); @@ -575,17 +941,36 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe if (enumerator.MoveNext()) return enumerator.Current.ToString(); - return DefaultIconGlyph; + return _defaultIconGlyph; } + /// + /// Is the nameplate addon visible? + /// + /// Is it visible? + private bool IsNamePlateAddonVisible() + { + if (_mpNameplateAddon == null) + return false; + + var root = _mpNameplateAddon->AtkUnitBase.RootNode; + return root != null && root->IsVisible(); + } + + /// + /// Converts raw icon glyph input into an icon editor string. + /// + /// Raw icon glyph input + /// Icon editor string internal static string ToIconEditorString(string? rawInput) { var normalized = NormalizeIconGlyph(rawInput); var runeEnumerator = normalized.EnumerateRunes(); return runeEnumerator.MoveNext() ? runeEnumerator.Current.Value.ToString("X4", CultureInfo.InvariantCulture) - : DefaultIconGlyph; + : _defaultIconGlyph; } + private readonly struct NameplateLabelInfo { public NameplateLabelInfo( @@ -615,6 +1000,9 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe public bool UseIcon { get; } } + /// + /// Visible paired user IDs snapshot. + /// private HashSet VisibleUserIds => [.. _pairUiService.GetSnapshot().PairsByUid.Values .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) @@ -634,6 +1022,10 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe } } + /// + /// Update the active broadcasting CIDs. + /// + /// Inbound new CIDs public void UpdateBroadcastingCids(IEnumerable cids) { var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal); @@ -646,10 +1038,16 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe FlagRefresh(); } + /// + /// Clears all nameplate related caches. + /// public void ClearNameplateCaches() { _buffers.Clear(); ClearLabelBuffer(); + + Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length); + Array.Clear(_buffers.SmoothedPos, 0, _buffers.SmoothedPos.Length); } private sealed class NameplateBuffers @@ -668,6 +1066,10 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe public NameplateLabelInfo[] LabelRender { get; } = new NameplateLabelInfo[AddonNamePlate.NumNamePlateObjects]; public NameplateLabelInfo[] LabelCopy { get; } = new NameplateLabelInfo[AddonNamePlate.NumNamePlateObjects]; + public Vector2[] SmoothedPos = new Vector2[AddonNamePlate.NumNamePlateObjects]; + + public bool[] HasSmoothed = new bool[AddonNamePlate.NumNamePlateObjects]; + public void Clear() { System.Array.Clear(TextWidths, 0, TextWidths.Length); @@ -677,16 +1079,38 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe } } + /// + /// Starts the LightFinder Plate Handler. + /// + /// Cancellation Token + /// Task Completed public Task StartAsync(CancellationToken cancellationToken) { Init(); return Task.CompletedTask; } + /// + /// Stops the LightFinder Plate Handler. + /// + /// Cancellation Token + /// Task Completed public Task StopAsync(CancellationToken cancellationToken) { Uninit(); return Task.CompletedTask; } + /// + /// Rectangle with float coordinates for intersection testing. + /// + [StructLayout(LayoutKind.Auto)] + private readonly struct RectF + { + public readonly float L, T, R, B; + public RectF(float l, float t, float r, float b) { L = l; T = t; R = r; B = b; } + + public bool Intersects(in RectF o) => + !(R <= o.L || o.R <= L || B <= o.T || o.B <= T); + } } \ No newline at end of file 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/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index b960b46..c326c58 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -164,7 +164,18 @@ public class DownloadUi : WindowMediatorSubscriberBase const float rounding = 6f; var shadowOffset = new Vector2(2, 2); - foreach (var transfer in _currentDownloads.ToList()) + List>> transfers; + try + { + transfers = _currentDownloads.ToList(); + } + catch (ArgumentException) + { + return; + } + + + foreach (var transfer in transfers) { var transferKey = transfer.Key; var rawPos = _dalamudUtilService.WorldToScreen(transferKey.GetGameObject()); diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index c30d5fa..ad570df 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -19,6 +19,7 @@ using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Events; +using LightlessSync.Services.LightFinder; using LightlessSync.Services.Mediator; using LightlessSync.Services.PairProcessing; using LightlessSync.Services.ServerConfiguration; @@ -82,6 +83,9 @@ public class SettingsUi : WindowMediatorSubscriberBase private bool _pairDebugVisibleOnly = true; private bool _pairDiagnosticsEnabled; private string? _selectedPairDebugUid = null; + private string _lightfinderIconInput = string.Empty; + private bool _lightfinderIconInputInitialized = false; + private int _lightfinderIconPresetIndex = -1; private static readonly LightlessConfig DefaultConfig = new(); private static readonly JsonSerializerOptions DebugJsonOptions = new() { WriteIndented = true }; private MainSettingsTab _selectedMainTab = MainSettingsTab.General; @@ -122,6 +126,15 @@ public class SettingsUi : WindowMediatorSubscriberBase private const float GeneralTreeHighlightDuration = 1.5f; private readonly SeluneBrush _generalSeluneBrush = new(); + private static readonly (string Label, SeIconChar Icon)[] LightfinderIconPresets = new[] + { + ("Link Marker", SeIconChar.LinkMarker), ("Hyadelyn", SeIconChar.Hyadelyn), ("Gil", SeIconChar.Gil), + ("Quest Sync", SeIconChar.QuestSync), ("Glamoured", SeIconChar.Glamoured), + ("Glamoured (Dyed)", SeIconChar.GlamouredDyed), ("Auto-Translate Open", SeIconChar.AutoTranslateOpen), + ("Auto-Translate Close", SeIconChar.AutoTranslateClose), ("Boxed Star", SeIconChar.BoxedStar), + ("Boxed Plus", SeIconChar.BoxedPlus) + }; + private enum MainSettingsTab { General, @@ -1533,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(); } @@ -1915,7 +1933,10 @@ public class SettingsUi : WindowMediatorSubscriberBase UiSharedService.TextWrapped( $"Storage validation is running: {_currentProgress.Item1}/{_currentProgress.Item2}"); - UiSharedService.TextWrapped($"Current item: {_currentProgress.Item3.ResolvedFilepath}"); + if (_currentProgress.Item3 != null) + { + UiSharedService.TextWrapped($"Current item: {_currentProgress.Item3.ResolvedFilepath}"); + } } } } @@ -2177,273 +2198,111 @@ public class SettingsUi : WindowMediatorSubscriberBase bool autoEnable = _configService.Current.LightfinderAutoEnableOnConnect; var autoAlign = _configService.Current.LightfinderAutoAlign; var offsetX = (int)_configService.Current.LightfinderLabelOffsetX; - var offsetY = (int)_configService.Current.LightfinderLabelOffsetY; - var labelScale = _configService.Current.LightfinderLabelScale; - bool showLightfinderInDtr = _configService.Current.ShowLightfinderInDtr; - var dtrLightfinderEnabled = SwapColorChannels(_configService.Current.DtrColorsLightfinderEnabled); - var dtrLightfinderDisabled = SwapColorChannels(_configService.Current.DtrColorsLightfinderDisabled); - var dtrLightfinderCooldown = SwapColorChannels(_configService.Current.DtrColorsLightfinderCooldown); - var dtrLightfinderUnavailable = SwapColorChannels(_configService.Current.DtrColorsLightfinderUnavailable); + var offsetY = (int)_configService.Current.LightfinderLabelOffsetY; + var labelScale = _configService.Current.LightfinderLabelScale; + bool showLightfinderInDtr = _configService.Current.ShowLightfinderInDtr; + var dtrLightfinderEnabled = SwapColorChannels(_configService.Current.DtrColorsLightfinderEnabled); + var dtrLightfinderDisabled = SwapColorChannels(_configService.Current.DtrColorsLightfinderDisabled); + var dtrLightfinderCooldown = SwapColorChannels(_configService.Current.DtrColorsLightfinderCooldown); + var dtrLightfinderUnavailable = SwapColorChannels(_configService.Current.DtrColorsLightfinderUnavailable); - ImGui.TextUnformatted("Connection"); - if (ImGui.Checkbox("Auto-enable Lightfinder on server connection", ref autoEnable)) - { - _configService.Current.LightfinderAutoEnableOnConnect = autoEnable; - _configService.Save(); - } - _uiShared.DrawHelpText("When enabled, Lightfinder will automatically turn on after reconnecting to the Lightless server."); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); - - ImGui.TextUnformatted("Lightfinder Nameplate Colors"); - if (ImGui.BeginTable("##LightfinderColorTable", 3, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit)) - { - ImGui.TableSetupColumn("Color", ImGuiTableColumnFlags.WidthFixed); - ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); - ImGui.TableSetupColumn("Reset", ImGuiTableColumnFlags.WidthFixed, 40f); - ImGui.TableHeadersRow(); - - var lightfinderColors = new (string Key, string Label, string Description)[] + ImGui.TextUnformatted("Connection"); + if (ImGui.Checkbox("Auto-enable Lightfinder on server connection", ref autoEnable)) { + _configService.Current.LightfinderAutoEnableOnConnect = autoEnable; + _configService.Save(); + } + _uiShared.DrawHelpText("When enabled, Lightfinder will automatically turn on after reconnecting to the Lightless server."); + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); + + ImGui.TextUnformatted("Lightfinder Nameplate Colors"); + if (ImGui.BeginTable("##LightfinderColorTable", 3, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit)) + { + ImGui.TableSetupColumn("Color", ImGuiTableColumnFlags.WidthFixed); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Reset", ImGuiTableColumnFlags.WidthFixed, 40f); + ImGui.TableHeadersRow(); + + var lightfinderColors = new (string Key, string Label, string Description)[] + { ("Lightfinder", "Nameplate Text", "Color used for Lightfinder nameplate text."), ("LightfinderEdge", "Nameplate Outline", "Outline color applied around Lightfinder nameplate text.") - }; - - foreach (var (key, label, description) in lightfinderColors) - { - ImGui.TableNextRow(); - - ImGui.TableSetColumnIndex(0); - var colorValue = UIColors.Get(key); - if (ImGui.ColorEdit4($"##color_{key}", ref colorValue, ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.AlphaPreviewHalf)) - { - UIColors.Set(key, colorValue); - } - ImGui.SameLine(); - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted(label); - - ImGui.TableSetColumnIndex(1); - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted(description); - - ImGui.TableSetColumnIndex(2); - using var resetId = ImRaii.PushId($"Reset_{key}"); - var availableWidth = ImGui.GetContentRegionAvail().X; - var isCustom = UIColors.IsCustom(key); - using (ImRaii.Disabled(!isCustom)) - { - using (ImRaii.PushFont(UiBuilder.IconFont)) - { - if (ImGui.Button(FontAwesomeIcon.Undo.ToIconString(), new Vector2(availableWidth, 0))) - { - UIColors.Reset(key); - } - } - } - UiSharedService.AttachToolTip(isCustom ? "Reset this color to default" : "Color is already at default value"); - } - - ImGui.EndTable(); - } - - ImGui.Spacing(); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); - - ImGui.TextUnformatted("Lightfinder Info Bar"); - if (ImGui.Checkbox("Show Lightfinder status in Server info bar", ref showLightfinderInDtr)) - { - _configService.Current.ShowLightfinderInDtr = showLightfinderInDtr; - _configService.Save(); - } - _uiShared.DrawHelpText("Adds a Lightfinder status to the Server info bar. Left click toggles Lightfinder when visible."); - - var lightfinderDisplayMode = _configService.Current.LightfinderDtrDisplayMode; - var lightfinderDisplayLabel = lightfinderDisplayMode switch - { - LightfinderDtrDisplayMode.PendingPairRequests => "Pending pair requests", - _ => "Nearby Lightfinder users", - }; - - ImGui.BeginDisabled(!showLightfinderInDtr); - if (ImGui.BeginCombo("Info display", lightfinderDisplayLabel)) - { - foreach (var option in Enum.GetValues()) - { - var optionLabel = option switch - { - LightfinderDtrDisplayMode.PendingPairRequests => "Pending pair requests", - _ => "Nearby Lightfinder users", }; - var selected = option == lightfinderDisplayMode; - if (ImGui.Selectable(optionLabel, selected)) + foreach (var (key, label, description) in lightfinderColors) { - _configService.Current.LightfinderDtrDisplayMode = option; - _configService.Save(); + ImGui.TableNextRow(); + + ImGui.TableSetColumnIndex(0); + var colorValue = UIColors.Get(key); + if (ImGui.ColorEdit4($"##color_{key}", ref colorValue, ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.AlphaPreviewHalf)) + { + UIColors.Set(key, colorValue); + } + ImGui.SameLine(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(label); + + ImGui.TableSetColumnIndex(1); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(description); + + ImGui.TableSetColumnIndex(2); + using var resetId = ImRaii.PushId($"Reset_{key}"); + var availableWidth = ImGui.GetContentRegionAvail().X; + var isCustom = UIColors.IsCustom(key); + using (ImRaii.Disabled(!isCustom)) + { + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + if (ImGui.Button(FontAwesomeIcon.Undo.ToIconString(), new Vector2(availableWidth, 0))) + { + UIColors.Reset(key); + } + } + } + UiSharedService.AttachToolTip(isCustom ? "Reset this color to default" : "Color is already at default value"); } - if (selected) - ImGui.SetItemDefaultFocus(); + ImGui.EndTable(); } - ImGui.EndCombo(); - } - ImGui.EndDisabled(); - _uiShared.DrawHelpText("Choose what the Lightfinder info bar displays while Lightfinder is active."); + ImGui.Spacing(); - bool useLightfinderColors = _configService.Current.UseLightfinderColorsInDtr; - if (ImGui.Checkbox("Color-code the Lightfinder info bar according to status", ref useLightfinderColors)) - { - _configService.Current.UseLightfinderColorsInDtr = useLightfinderColors; - _configService.Save(); - } + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); - ImGui.BeginDisabled(!showLightfinderInDtr || !useLightfinderColors); - const ImGuiTableFlags lightfinderInfoTableFlags = ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit; - if (ImGui.BeginTable("##LightfinderInfoBarColorTable", 3, lightfinderInfoTableFlags)) - { - ImGui.TableSetupColumn("Status", ImGuiTableColumnFlags.WidthFixed, 220f); - ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); - ImGui.TableSetupColumn("Reset", ImGuiTableColumnFlags.WidthFixed, 40f); - ImGui.TableHeadersRow(); - - DrawDtrColorRow( - "enabled", - "Enabled", - "Displayed when Lightfinder is active.", - ref dtrLightfinderEnabled, - SwapColorChannels(DefaultConfig.DtrColorsLightfinderEnabled), - value => _configService.Current.DtrColorsLightfinderEnabled = SwapColorChannels(value)); - - DrawDtrColorRow( - "disabled", - "Disabled", - "Shown when Lightfinder is turned off.", - ref dtrLightfinderDisabled, - SwapColorChannels(DefaultConfig.DtrColorsLightfinderDisabled), - value => _configService.Current.DtrColorsLightfinderDisabled = SwapColorChannels(value)); - - DrawDtrColorRow( - "cooldown", - "Cooldown", - "Displayed while Lightfinder is on cooldown.", - ref dtrLightfinderCooldown, - SwapColorChannels(DefaultConfig.DtrColorsLightfinderCooldown), - value => _configService.Current.DtrColorsLightfinderCooldown = SwapColorChannels(value)); - - DrawDtrColorRow( - "unavailable", - "Unavailable", - "Used when Lightfinder is not available on the current server.", - ref dtrLightfinderUnavailable, - SwapColorChannels(DefaultConfig.DtrColorsLightfinderUnavailable), - value => _configService.Current.DtrColorsLightfinderUnavailable = SwapColorChannels(value)); - - ImGui.EndTable(); - } - ImGui.EndDisabled(); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); - - ImGui.TextUnformatted("Alignment"); - ImGui.BeginDisabled(autoAlign); - if (ImGui.SliderInt("Label Offset X", ref offsetX, -200, 200)) - { - _configService.Current.LightfinderLabelOffsetX = (short)offsetX; - _configService.Save(); - _nameplateService.RequestRedraw(); - } - - if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) - { - _configService.Current.LightfinderLabelOffsetX = 0; - _configService.Save(); - _nameplateService.RequestRedraw(); - } - - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Right click to reset to default."); - ImGui.EndDisabled(); - _uiShared.DrawHelpText( - "Moves the Lightfinder label horizontally on player nameplates.\nUnavailable when automatic alignment is enabled."); - - - if (ImGui.SliderInt("Label Offset Y", ref offsetY, -200, 200)) - { - _configService.Current.LightfinderLabelOffsetY = (short)offsetY; - _configService.Save(); - _nameplateService.RequestRedraw(); - } - - if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) - { - _configService.Current.LightfinderLabelOffsetY = 0; - _configService.Save(); - _nameplateService.RequestRedraw(); - } - - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Right click to reset to default."); - _uiShared.DrawHelpText("Moves the Lightfinder label vertically on player nameplates."); - - if (ImGui.SliderFloat("Label Size", ref labelScale, 0.5f, 2.0f, "%.2fx")) - { - _configService.Current.LightfinderLabelScale = labelScale; - _configService.Save(); - _nameplateService.RequestRedraw(); - } - - if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) - { - _configService.Current.LightfinderLabelScale = 1.0f; - _configService.Save(); - _nameplateService.RequestRedraw(); - } - - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Right click to reset to default."); - _uiShared.DrawHelpText("Adjusts the Lightfinder label size for both text and icon modes."); - - ImGui.Dummy(new Vector2(8)); - - if (ImGui.Checkbox("Automatically align with nameplate", ref autoAlign)) - { - _configService.Current.LightfinderAutoAlign = autoAlign; - _configService.Save(); - _nameplateService.RequestRedraw(); - } - - _uiShared.DrawHelpText( - "Automatically position the label relative to the in-game nameplate. Turn off to rely entirely on manual offsets."); - - if (autoAlign) - { - var alignmentOption = _configService.Current.LabelAlignment; - var alignmentLabel = alignmentOption switch + ImGui.TextUnformatted("Lightfinder Info Bar"); + if (ImGui.Checkbox("Show Lightfinder status in Server info bar", ref showLightfinderInDtr)) { - LabelAlignment.Left => "Left", - LabelAlignment.Right => "Right", - _ => "Center", + _configService.Current.ShowLightfinderInDtr = showLightfinderInDtr; + _configService.Save(); + } + _uiShared.DrawHelpText("Adds a Lightfinder status to the Server info bar. Left click toggles Lightfinder when visible."); + + var lightfinderDisplayMode = _configService.Current.LightfinderDtrDisplayMode; + var lightfinderDisplayLabel = lightfinderDisplayMode switch + { + LightfinderDtrDisplayMode.PendingPairRequests => "Pending pair requests", + _ => "Nearby Lightfinder users", }; - if (ImGui.BeginCombo("Horizontal Alignment", alignmentLabel)) + ImGui.BeginDisabled(!showLightfinderInDtr); + if (ImGui.BeginCombo("Info display", lightfinderDisplayLabel)) { - foreach (LabelAlignment option in Enum.GetValues()) + foreach (var option in Enum.GetValues()) { var optionLabel = option switch { - LabelAlignment.Left => "Left", - LabelAlignment.Right => "Right", - _ => "Center", + LightfinderDtrDisplayMode.PendingPairRequests => "Pending pair requests", + _ => "Nearby Lightfinder users", }; - var selected = option == alignmentOption; + + var selected = option == lightfinderDisplayMode; if (ImGui.Selectable(optionLabel, selected)) { - _configService.Current.LabelAlignment = option; + _configService.Current.LightfinderDtrDisplayMode = option; _configService.Save(); - _nameplateService.RequestRedraw(); } if (selected) @@ -2452,239 +2311,480 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.EndCombo(); } + ImGui.EndDisabled(); + _uiShared.DrawHelpText("Choose what the Lightfinder info bar displays while Lightfinder is active."); - } + bool useLightfinderColors = _configService.Current.UseLightfinderColorsInDtr; + if (ImGui.Checkbox("Color-code the Lightfinder info bar according to status", ref useLightfinderColors)) + { + _configService.Current.UseLightfinderColorsInDtr = useLightfinderColors; + _configService.Save(); + } - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); + ImGui.BeginDisabled(!showLightfinderInDtr || !useLightfinderColors); + const ImGuiTableFlags lightfinderInfoTableFlags = ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit; + if (ImGui.BeginTable("##LightfinderInfoBarColorTable", 3, lightfinderInfoTableFlags)) + { + ImGui.TableSetupColumn("Status", ImGuiTableColumnFlags.WidthFixed, 220f); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Reset", ImGuiTableColumnFlags.WidthFixed, 40f); + ImGui.TableHeadersRow(); - ImGui.TextUnformatted("Visibility"); - var showOwn = _configService.Current.LightfinderLabelShowOwn; - if (ImGui.Checkbox("Show your own Lightfinder label", ref showOwn)) - { - _configService.Current.LightfinderLabelShowOwn = showOwn; - _configService.Save(); - _nameplateService.RequestRedraw(); - } + DrawDtrColorRow( + "enabled", + "Enabled", + "Displayed when Lightfinder is active.", + ref dtrLightfinderEnabled, + SwapColorChannels(DefaultConfig.DtrColorsLightfinderEnabled), + value => _configService.Current.DtrColorsLightfinderEnabled = SwapColorChannels(value)); - _uiShared.DrawHelpText("Toggles your own Lightfinder label."); + DrawDtrColorRow( + "disabled", + "Disabled", + "Shown when Lightfinder is turned off.", + ref dtrLightfinderDisabled, + SwapColorChannels(DefaultConfig.DtrColorsLightfinderDisabled), + value => _configService.Current.DtrColorsLightfinderDisabled = SwapColorChannels(value)); - var showPaired = _configService.Current.LightfinderLabelShowPaired; - if (ImGui.Checkbox("Show paired player(s) Lightfinder label", ref showPaired)) - { - _configService.Current.LightfinderLabelShowPaired = showPaired; - _configService.Save(); - _nameplateService.RequestRedraw(); - } + DrawDtrColorRow( + "cooldown", + "Cooldown", + "Displayed while Lightfinder is on cooldown.", + ref dtrLightfinderCooldown, + SwapColorChannels(DefaultConfig.DtrColorsLightfinderCooldown), + value => _configService.Current.DtrColorsLightfinderCooldown = SwapColorChannels(value)); - _uiShared.DrawHelpText("Toggles paired player(s) Lightfinder label."); + DrawDtrColorRow( + "unavailable", + "Unavailable", + "Used when Lightfinder is not available on the current server.", + ref dtrLightfinderUnavailable, + SwapColorChannels(DefaultConfig.DtrColorsLightfinderUnavailable), + value => _configService.Current.DtrColorsLightfinderUnavailable = SwapColorChannels(value)); - var showHidden = _configService.Current.LightfinderLabelShowHidden; - if (ImGui.Checkbox("Show Lightfinder label when no nameplate(s) is visible", ref showHidden)) - { - _configService.Current.LightfinderLabelShowHidden = showHidden; - _configService.Save(); - _nameplateService.RequestRedraw(); - } - _uiShared.DrawHelpText("Toggles Lightfinder label when no nameplate(s) is visible."); + ImGui.EndTable(); + } + ImGui.EndDisabled(); - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); - ImGui.TextUnformatted("Label"); - var useIcon = _configService.Current.LightfinderLabelUseIcon; - if (ImGui.Checkbox("Show icon instead of text", ref useIcon)) - { - _configService.Current.LightfinderLabelUseIcon = useIcon; - _configService.Save(); - _nameplateService.RequestRedraw(); + ImGui.TextUnformatted("Alignment"); + ImGui.BeginDisabled(autoAlign); + if (ImGui.SliderInt("Label Offset X", ref offsetX, -200, 200)) + { + _configService.Current.LightfinderLabelOffsetX = (short)offsetX; + _configService.Save(); + _nameplateService.RequestRedraw(); + } + + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + _configService.Current.LightfinderLabelOffsetX = 0; + _configService.Save(); + _nameplateService.RequestRedraw(); + } + + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Right click to reset to default."); + ImGui.EndDisabled(); + _uiShared.DrawHelpText( + "Moves the Lightfinder label horizontally on player nameplates.\nUnavailable when automatic alignment is enabled."); + + + if (ImGui.SliderInt("Label Offset Y", ref offsetY, -200, 200)) + { + _configService.Current.LightfinderLabelOffsetY = (short)offsetY; + _configService.Save(); + _nameplateService.RequestRedraw(); + } + + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + _configService.Current.LightfinderLabelOffsetY = 0; + _configService.Save(); + _nameplateService.RequestRedraw(); + } + + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Right click to reset to default."); + _uiShared.DrawHelpText("Moves the Lightfinder label vertically on player nameplates."); + + if (ImGui.SliderFloat("Label Size", ref labelScale, 0.5f, 2.0f, "%.2fx")) + { + _configService.Current.LightfinderLabelScale = labelScale; + _configService.Save(); + _nameplateService.RequestRedraw(); + } + + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + _configService.Current.LightfinderLabelScale = 1.0f; + _configService.Save(); + _nameplateService.RequestRedraw(); + } + + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Right click to reset to default."); + _uiShared.DrawHelpText("Adjusts the Lightfinder label size for both text and icon modes."); + + ImGui.Dummy(new Vector2(8)); + + if (ImGui.Checkbox("Automatically align with nameplate", ref autoAlign)) + { + _configService.Current.LightfinderAutoAlign = autoAlign; + _configService.Save(); + _nameplateService.RequestRedraw(); + } + + _uiShared.DrawHelpText( + "Automatically position the label relative to the in-game nameplate. Turn off to rely entirely on manual offsets."); + + if (autoAlign) + { + var alignmentOption = _configService.Current.LabelAlignment; + var alignmentLabel = alignmentOption switch + { + LabelAlignment.Left => "Left", + LabelAlignment.Right => "Right", + _ => "Center", + }; + + if (ImGui.BeginCombo("Horizontal Alignment", alignmentLabel)) + { + foreach (LabelAlignment option in Enum.GetValues()) + { + var optionLabel = option switch + { + LabelAlignment.Left => "Left", + LabelAlignment.Right => "Right", + _ => "Center", + }; + var selected = option == alignmentOption; + if (ImGui.Selectable(optionLabel, selected)) + { + _configService.Current.LabelAlignment = option; + _configService.Save(); + _nameplateService.RequestRedraw(); + } + + if (selected) + ImGui.SetItemDefaultFocus(); + } + + ImGui.EndCombo(); + } + + } + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); + + ImGui.TextUnformatted("Visibility"); + var showOwn = _configService.Current.LightfinderLabelShowOwn; + if (ImGui.Checkbox("Show your own Lightfinder label", ref showOwn)) + { + _configService.Current.LightfinderLabelShowOwn = showOwn; + _configService.Save(); + _nameplateService.RequestRedraw(); + } + + _uiShared.DrawHelpText("Toggles your own Lightfinder label."); + + var showPaired = _configService.Current.LightfinderLabelShowPaired; + if (ImGui.Checkbox("Show paired player(s) Lightfinder label", ref showPaired)) + { + _configService.Current.LightfinderLabelShowPaired = showPaired; + _configService.Save(); + _nameplateService.RequestRedraw(); + } + + _uiShared.DrawHelpText("Toggles paired player(s) Lightfinder label."); + + var showHidden = _configService.Current.LightfinderLabelShowHidden; + if (ImGui.Checkbox("Show Lightfinder label when no nameplate(s) is visible", ref showHidden)) + { + _configService.Current.LightfinderLabelShowHidden = showHidden; + _configService.Save(); + _nameplateService.RequestRedraw(); + } + _uiShared.DrawHelpText("Toggles Lightfinder label when no nameplate(s) is visible."); + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); + + ImGui.TextUnformatted("Label"); + var useIcon = _configService.Current.LightfinderLabelUseIcon; + if (ImGui.Checkbox("Show icon instead of text", ref useIcon)) + { + _configService.Current.LightfinderLabelUseIcon = useIcon; + _configService.Save(); + + if (useIcon) + { + RefreshLightfinderIconState(); + } + else + { + _lightfinderIconInputInitialized = false; + _lightfinderIconPresetIndex = -1; + } + } + + _uiShared.DrawHelpText("Switch between the Lightfinder text label and an icon on nameplates."); if (useIcon) { - // redo - } - } - - _uiShared.DrawHelpText("Switch between the Lightfinder text label and an icon on nameplates."); - - if (useIcon) - { - //redo - } - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); - ImGui.TreePop(); - lightfinderTree.MarkContentEnd(); - } - } - - ImGui.Separator(); - - using (var pairListTree = BeginGeneralTree("Pair List", UIColors.Get("LightlessPurple"))) - { - if (pairListTree.Visible) - { - if (ImGui.Checkbox("Show separate Visible group", ref showVisibleSeparate)) - { - _configService.Current.ShowVisibleUsersSeparately = showVisibleSeparate; - _configService.Save(); - Mediator.Publish(new RefreshUiMessage()); - } - - _uiShared.DrawHelpText( - "This will show all currently visible users in a special 'Visible' group in the main UI."); - - using (ImRaii.Disabled(!showVisibleSeparate)) - { - using var indent = ImRaii.PushIndent(); - if (ImGui.Checkbox("Show Syncshell Users in Visible Group", ref groupInVisible)) + if (!_lightfinderIconInputInitialized) { - _configService.Current.ShowSyncshellUsersInVisible = groupInVisible; - _configService.Save(); - Mediator.Publish(new RefreshUiMessage()); + RefreshLightfinderIconState(); } - } - if (ImGui.Checkbox("Show separate Offline group", ref showOfflineSeparate)) - { - _configService.Current.ShowOfflineUsersSeparately = showOfflineSeparate; - _configService.Save(); - Mediator.Publish(new RefreshUiMessage()); - } + var currentPresetLabel = _lightfinderIconPresetIndex >= 0 + ? $"{GetLightfinderPresetGlyph(_lightfinderIconPresetIndex)} {LightfinderIconPresets[_lightfinderIconPresetIndex].Label}" + : "Custom"; - _uiShared.DrawHelpText( - "This will show all currently offline users in a special 'Offline' group in the main UI."); - - using (ImRaii.Disabled(!showOfflineSeparate)) - { - using var indent = ImRaii.PushIndent(); - if (ImGui.Checkbox("Show separate Offline group for Syncshell users", ref syncshellOfflineSeparate)) + if (ImGui.BeginCombo("Preset Icon", currentPresetLabel)) { - _configService.Current.ShowSyncshellOfflineUsersSeparately = syncshellOfflineSeparate; - _configService.Save(); - Mediator.Publish(new RefreshUiMessage()); + for (int i = 0; i < LightfinderIconPresets.Length; i++) + { + var optionGlyph = GetLightfinderPresetGlyph(i); + var preview = $"{optionGlyph} {LightfinderIconPresets[i].Label}"; + var selected = i == _lightfinderIconPresetIndex; + if (ImGui.Selectable(preview, selected)) + { + _lightfinderIconInput = LightFinderPlateHandler.ToIconEditorString(optionGlyph); + _lightfinderIconPresetIndex = i; + } + } + + if (ImGui.Selectable("Custom", _lightfinderIconPresetIndex == -1)) + { + _lightfinderIconPresetIndex = -1; + } + + ImGui.EndCombo(); } + + var editorBuffer = _lightfinderIconInput; + if (ImGui.InputText("Icon Glyph", ref editorBuffer, 16)) + { + _lightfinderIconInput = editorBuffer; + _lightfinderIconPresetIndex = -1; + } + + if (ImGui.Button("Apply Icon")) + { + var normalized = LightFinderPlateHandler.NormalizeIconGlyph(_lightfinderIconInput); + ApplyLightfinderIcon(normalized, _lightfinderIconPresetIndex); + } + + ImGui.SameLine(); + if (ImGui.Button("Reset Icon")) + { + var defaultGlyph = LightFinderPlateHandler.NormalizeIconGlyph(null); + var defaultIndex = -1; + for (int i = 0; i < LightfinderIconPresets.Length; i++) + { + if (string.Equals(GetLightfinderPresetGlyph(i), defaultGlyph, StringComparison.Ordinal)) + { + defaultIndex = i; + break; + } + } + + if (defaultIndex < 0) + { + defaultIndex = 0; + } + + ApplyLightfinderIcon(GetLightfinderPresetGlyph(defaultIndex), defaultIndex); + } + + var previewGlyph = LightFinderPlateHandler.NormalizeIconGlyph(_lightfinderIconInput); + ImGui.SameLine(); + ImGui.AlignTextToFramePadding(); + ImGui.Text($"Preview: {previewGlyph}"); + _uiShared.DrawHelpText( + "Enter a hex code (e.g. E0BB), pick a preset, or paste an icon character directly."); } - - if (ImGui.Checkbox("Group up all syncshells in one folder", ref groupUpSyncshells)) + else { - _configService.Current.GroupUpSyncshells = groupUpSyncshells; - _configService.Save(); - Mediator.Publish(new RefreshUiMessage()); - } - - _uiShared.DrawHelpText( - "This will group up all Syncshells in a special 'All Syncshells' folder in the main UI."); - - if (ImGui.Checkbox("Show grouped syncshells in main screen/all syncshells", ref groupedSyncshells)) - { - _configService.Current.ShowGroupedSyncshellsInAll = groupedSyncshells; - _configService.Save(); - Mediator.Publish(new RefreshUiMessage()); - } - - _uiShared.DrawHelpText("This will show grouped syncshells in main screen or group 'All Syncshells'."); - - if (ImGui.Checkbox("Show player name for visible players", ref showNameInsteadOfNotes)) - { - _configService.Current.ShowCharacterNameInsteadOfNotesForVisible = showNameInsteadOfNotes; - _configService.Save(); - Mediator.Publish(new RefreshUiMessage()); - } - - _uiShared.DrawHelpText( - "This will show the character name instead of custom set note when a character is visible"); - - ImGui.Indent(); - if (!_configService.Current.ShowCharacterNameInsteadOfNotesForVisible) ImGui.BeginDisabled(); - if (ImGui.Checkbox("Prefer notes over player names for visible players", ref preferNotesInsteadOfName)) - { - _configService.Current.PreferNotesOverNamesForVisible = preferNotesInsteadOfName; - _configService.Save(); - Mediator.Publish(new RefreshUiMessage()); - } - - _uiShared.DrawHelpText("If you set a note for a player it will be shown instead of the player name"); - if (!_configService.Current.ShowCharacterNameInsteadOfNotesForVisible) ImGui.EndDisabled(); - ImGui.Unindent(); - - if (ImGui.Checkbox("Set visible pairs as focus targets when clicking the eye", ref useFocusTarget)) - { - _configService.Current.UseFocusTarget = useFocusTarget; - _configService.Save(); - } - - if (ImGui.Checkbox("Set visible pair icon to an green color", ref greenVisiblePair)) - { - _configService.Current.ShowVisiblePairsGreenEye = greenVisiblePair; - _configService.Save(); + _lightfinderIconInputInitialized = false; + _lightfinderIconPresetIndex = -1; } UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); - pairListTree.MarkContentEnd(); + lightfinderTree.MarkContentEnd(); } - } - ImGui.Separator(); + ImGui.Separator(); - using (var profilesTree = BeginGeneralTree("Profiles", UIColors.Get("LightlessPurple"))) - { - if (profilesTree.Visible) + using (var pairListTree = BeginGeneralTree("Pair List", UIColors.Get("LightlessPurple"))) { - if (ImGui.Checkbox("Show Lightless Profiles on Hover", ref showProfiles)) + if (pairListTree.Visible) { - Mediator.Publish(new ClearProfileUserDataMessage()); - _configService.Current.ProfilesShow = showProfiles; - _configService.Save(); + if (ImGui.Checkbox("Show separate Visible group", ref showVisibleSeparate)) + { + _configService.Current.ShowVisibleUsersSeparately = showVisibleSeparate; + _configService.Save(); + Mediator.Publish(new RefreshUiMessage()); + } + + _uiShared.DrawHelpText( + "This will show all currently visible users in a special 'Visible' group in the main UI."); + + using (ImRaii.Disabled(!showVisibleSeparate)) + { + using var indent = ImRaii.PushIndent(); + if (ImGui.Checkbox("Show Syncshell Users in Visible Group", ref groupInVisible)) + { + _configService.Current.ShowSyncshellUsersInVisible = groupInVisible; + _configService.Save(); + Mediator.Publish(new RefreshUiMessage()); + } + } + + if (ImGui.Checkbox("Show separate Offline group", ref showOfflineSeparate)) + { + _configService.Current.ShowOfflineUsersSeparately = showOfflineSeparate; + _configService.Save(); + Mediator.Publish(new RefreshUiMessage()); + } + + _uiShared.DrawHelpText( + "This will show all currently offline users in a special 'Offline' group in the main UI."); + + using (ImRaii.Disabled(!showOfflineSeparate)) + { + using var indent = ImRaii.PushIndent(); + if (ImGui.Checkbox("Show separate Offline group for Syncshell users", ref syncshellOfflineSeparate)) + { + _configService.Current.ShowSyncshellOfflineUsersSeparately = syncshellOfflineSeparate; + _configService.Save(); + Mediator.Publish(new RefreshUiMessage()); + } + } + + if (ImGui.Checkbox("Group up all syncshells in one folder", ref groupUpSyncshells)) + { + _configService.Current.GroupUpSyncshells = groupUpSyncshells; + _configService.Save(); + Mediator.Publish(new RefreshUiMessage()); + } + + _uiShared.DrawHelpText( + "This will group up all Syncshells in a special 'All Syncshells' folder in the main UI."); + + if (ImGui.Checkbox("Show grouped syncshells in main screen/all syncshells", ref groupedSyncshells)) + { + _configService.Current.ShowGroupedSyncshellsInAll = groupedSyncshells; + _configService.Save(); + Mediator.Publish(new RefreshUiMessage()); + } + + _uiShared.DrawHelpText("This will show grouped syncshells in main screen or group 'All Syncshells'."); + + if (ImGui.Checkbox("Show player name for visible players", ref showNameInsteadOfNotes)) + { + _configService.Current.ShowCharacterNameInsteadOfNotesForVisible = showNameInsteadOfNotes; + _configService.Save(); + Mediator.Publish(new RefreshUiMessage()); + } + + _uiShared.DrawHelpText( + "This will show the character name instead of custom set note when a character is visible"); + + ImGui.Indent(); + if (!_configService.Current.ShowCharacterNameInsteadOfNotesForVisible) ImGui.BeginDisabled(); + if (ImGui.Checkbox("Prefer notes over player names for visible players", ref preferNotesInsteadOfName)) + { + _configService.Current.PreferNotesOverNamesForVisible = preferNotesInsteadOfName; + _configService.Save(); + Mediator.Publish(new RefreshUiMessage()); + } + + _uiShared.DrawHelpText("If you set a note for a player it will be shown instead of the player name"); + if (!_configService.Current.ShowCharacterNameInsteadOfNotesForVisible) ImGui.EndDisabled(); + ImGui.Unindent(); + + if (ImGui.Checkbox("Set visible pairs as focus targets when clicking the eye", ref useFocusTarget)) + { + _configService.Current.UseFocusTarget = useFocusTarget; + _configService.Save(); + } + + if (ImGui.Checkbox("Set visible pair icon to an green color", ref greenVisiblePair)) + { + _configService.Current.ShowVisiblePairsGreenEye = greenVisiblePair; + _configService.Save(); + } + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + ImGui.TreePop(); + pairListTree.MarkContentEnd(); } - - _uiShared.DrawHelpText("This will show the configured user profile after a set delay"); - ImGui.Indent(); - if (!showProfiles) ImGui.BeginDisabled(); - if (ImGui.Checkbox("Popout profiles on the right", ref profileOnRight)) - { - _configService.Current.ProfilePopoutRight = profileOnRight; - _configService.Save(); - Mediator.Publish(new CompactUiChange(Vector2.Zero, Vector2.Zero)); - } - - _uiShared.DrawHelpText("Will show profiles on the right side of the main UI"); - if (ImGui.SliderFloat("Hover Delay", ref profileDelay, 1, 10)) - { - _configService.Current.ProfileDelay = profileDelay; - _configService.Save(); - } - - _uiShared.DrawHelpText("Delay until the profile should be displayed"); - if (!showProfiles) ImGui.EndDisabled(); - ImGui.Unindent(); - if (ImGui.Checkbox("Show profiles marked as NSFW", ref showNsfwProfiles)) - { - Mediator.Publish(new ClearProfileUserDataMessage()); - _configService.Current.ProfilesAllowNsfw = showNsfwProfiles; - _configService.Save(); - } - - _uiShared.DrawHelpText("Will show profiles that have the NSFW tag enabled"); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); - ImGui.TreePop(); - profilesTree.MarkContentEnd(); } - } - ImGui.Separator(); + ImGui.Separator(); - ImGui.Dummy(new Vector2(10)); - _uiShared.BigText("UI Theme"); - - using (var colorsTree = BeginGeneralTree("Colors", UIColors.Get("LightlessPurple"))) - { - if (colorsTree.Visible) + using (var profilesTree = BeginGeneralTree("Profiles", UIColors.Get("LightlessPurple"))) { - ImGui.TextUnformatted("UI Theme Colors"); - - var colorNames = new[] + if (profilesTree.Visible) { + if (ImGui.Checkbox("Show Lightless Profiles on Hover", ref showProfiles)) + { + Mediator.Publish(new ClearProfileUserDataMessage()); + _configService.Current.ProfilesShow = showProfiles; + _configService.Save(); + } + + _uiShared.DrawHelpText("This will show the configured user profile after a set delay"); + ImGui.Indent(); + if (!showProfiles) ImGui.BeginDisabled(); + if (ImGui.Checkbox("Popout profiles on the right", ref profileOnRight)) + { + _configService.Current.ProfilePopoutRight = profileOnRight; + _configService.Save(); + Mediator.Publish(new CompactUiChange(Vector2.Zero, Vector2.Zero)); + } + + _uiShared.DrawHelpText("Will show profiles on the right side of the main UI"); + if (ImGui.SliderFloat("Hover Delay", ref profileDelay, 1, 10)) + { + _configService.Current.ProfileDelay = profileDelay; + _configService.Save(); + } + + _uiShared.DrawHelpText("Delay until the profile should be displayed"); + if (!showProfiles) ImGui.EndDisabled(); + ImGui.Unindent(); + if (ImGui.Checkbox("Show profiles marked as NSFW", ref showNsfwProfiles)) + { + Mediator.Publish(new ClearProfileUserDataMessage()); + _configService.Current.ProfilesAllowNsfw = showNsfwProfiles; + _configService.Save(); + } + + _uiShared.DrawHelpText("Will show profiles that have the NSFW tag enabled"); + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + ImGui.TreePop(); + profilesTree.MarkContentEnd(); + } + } + + ImGui.Separator(); + + ImGui.Dummy(new Vector2(10)); + _uiShared.BigText("UI Theme"); + + using (var colorsTree = BeginGeneralTree("Colors", UIColors.Get("LightlessPurple"))) + { + if (colorsTree.Visible) + { + ImGui.TextUnformatted("UI Theme Colors"); + + var colorNames = new[] + { ("LightlessPurple", "Primary Purple", "Section titles and dividers"), ("LightlessPurpleActive", "Primary Purple (Active)", "Active tabs and hover highlights"), ("LightlessPurpleDefault", "Primary Purple (Inactive)", "Inactive tabs and default dividers"), @@ -2695,216 +2795,217 @@ public class SettingsUi : WindowMediatorSubscriberBase ("PairBlue", "Syncshell Blue", "Syncshell headers, toggle highlights, and moderator actions"), ("DimRed", "Error Red", "Error and offline colors") }; - if (ImGui.BeginTable("##ColorTable", 3, - ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit)) - { - ImGui.TableSetupColumn("Color", ImGuiTableColumnFlags.WidthFixed); - ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); - ImGui.TableSetupColumn("Reset", ImGuiTableColumnFlags.WidthFixed, 40); - ImGui.TableHeadersRow(); - - foreach (var (colorKey, displayName, description) in colorNames) + if (ImGui.BeginTable("##ColorTable", 3, + ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit)) { - ImGui.TableNextRow(); + ImGui.TableSetupColumn("Color", ImGuiTableColumnFlags.WidthFixed); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Reset", ImGuiTableColumnFlags.WidthFixed, 40); + ImGui.TableHeadersRow(); - ImGui.TableSetColumnIndex(0); - var currentColor = UIColors.Get(colorKey); - var colorToEdit = currentColor; - if (ImGui.ColorEdit4($"##color_{colorKey}", ref colorToEdit, - ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.AlphaPreviewHalf)) + foreach (var (colorKey, displayName, description) in colorNames) { - UIColors.Set(colorKey, colorToEdit); - } + ImGui.TableNextRow(); - ImGui.SameLine(); - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted(displayName); - - ImGui.TableSetColumnIndex(1); - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted(description); - - ImGui.TableSetColumnIndex(2); - using var resetId = ImRaii.PushId($"Reset_{colorKey}"); - var availableWidth = ImGui.GetContentRegionAvail().X; - var isCustom = UIColors.IsCustom(colorKey); - - using (ImRaii.Disabled(!isCustom)) - { - using (ImRaii.PushFont(UiBuilder.IconFont)) + ImGui.TableSetColumnIndex(0); + var currentColor = UIColors.Get(colorKey); + var colorToEdit = currentColor; + if (ImGui.ColorEdit4($"##color_{colorKey}", ref colorToEdit, + ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.AlphaPreviewHalf)) { - if (ImGui.Button(FontAwesomeIcon.Undo.ToIconString(), new Vector2(availableWidth, 0))) + UIColors.Set(colorKey, colorToEdit); + } + + ImGui.SameLine(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(displayName); + + ImGui.TableSetColumnIndex(1); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(description); + + ImGui.TableSetColumnIndex(2); + using var resetId = ImRaii.PushId($"Reset_{colorKey}"); + var availableWidth = ImGui.GetContentRegionAvail().X; + var isCustom = UIColors.IsCustom(colorKey); + + using (ImRaii.Disabled(!isCustom)) + { + using (ImRaii.PushFont(UiBuilder.IconFont)) { - UIColors.Reset(colorKey); + if (ImGui.Button(FontAwesomeIcon.Undo.ToIconString(), new Vector2(availableWidth, 0))) + { + UIColors.Reset(colorKey); + } } } + + UiSharedService.AttachToolTip(isCustom + ? "Reset this color to default" + : "Color is already at default value"); } - UiSharedService.AttachToolTip(isCustom - ? "Reset this color to default" - : "Color is already at default value"); + ImGui.EndTable(); } - ImGui.EndTable(); + ImGui.Spacing(); + if (_uiShared.IconTextButton(FontAwesomeIcon.Undo, "Reset All Theme Colors")) + { + UIColors.ResetAll(); + } + + _uiShared.DrawHelpText("This will reset all theme colors to their default values"); + + ImGui.Spacing(); + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); + + ImGui.TextUnformatted("UI Theme"); + + if (ImGui.Checkbox("Use the redesign of the UI for Lightless client", ref useLightlessRedesign)) + { + _configService.Current.UseLightlessRedesign = useLightlessRedesign; + _configService.Save(); + } + + var usePairColoredUIDs = _configService.Current.useColoredUIDs; + + if (ImGui.Checkbox("Toggle the colored UID's in pair list", ref usePairColoredUIDs)) + { + _configService.Current.useColoredUIDs = usePairColoredUIDs; + _configService.Save(); + } + + _uiShared.DrawHelpText("This changes the vanity colored UID's in pair list."); + + DrawThemeOverridesSection(); + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + ImGui.TreePop(); + colorsTree.MarkContentEnd(); } - - ImGui.Spacing(); - if (_uiShared.IconTextButton(FontAwesomeIcon.Undo, "Reset All Theme Colors")) - { - UIColors.ResetAll(); - } - - _uiShared.DrawHelpText("This will reset all theme colors to their default values"); - - ImGui.Spacing(); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); - - ImGui.TextUnformatted("UI Theme"); - - if (ImGui.Checkbox("Use the redesign of the UI for Lightless client", ref useLightlessRedesign)) - { - _configService.Current.UseLightlessRedesign = useLightlessRedesign; - _configService.Save(); - } - - var usePairColoredUIDs = _configService.Current.useColoredUIDs; - - if (ImGui.Checkbox("Toggle the colored UID's in pair list", ref usePairColoredUIDs)) - { - _configService.Current.useColoredUIDs = usePairColoredUIDs; - _configService.Save(); - } - - _uiShared.DrawHelpText("This changes the vanity colored UID's in pair list."); - - DrawThemeOverridesSection(); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); - ImGui.TreePop(); - colorsTree.MarkContentEnd(); } - } - ImGui.Separator(); + ImGui.Separator(); - using (var serverInfoTree = BeginGeneralTree("Server Info Bar", UIColors.Get("LightlessPurple"))) - { - if (serverInfoTree.Visible) + using (var serverInfoTree = BeginGeneralTree("Server Info Bar", UIColors.Get("LightlessPurple"))) { - ImGui.TextUnformatted("Server Info Bar Colors"); - - if (ImGui.Checkbox("Color-code the Server Info Bar entry according to status", ref useColorsInDtr)) + if (serverInfoTree.Visible) { - _configService.Current.UseColorsInDtr = useColorsInDtr; - _configService.Save(); + ImGui.TextUnformatted("Server Info Bar Colors"); + + if (ImGui.Checkbox("Color-code the Server Info Bar entry according to status", ref useColorsInDtr)) + { + _configService.Current.UseColorsInDtr = useColorsInDtr; + _configService.Save(); + } + + _uiShared.DrawHelpText( + "This will color the Server Info Bar entry based on connection status and visible pairs."); + + ImGui.BeginDisabled(!useColorsInDtr); + const ImGuiTableFlags serverInfoTableFlags = ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit; + if (ImGui.BeginTable("##ServerInfoBarColorTable", 3, serverInfoTableFlags)) + { + ImGui.TableSetupColumn("Status", ImGuiTableColumnFlags.WidthFixed, 220f); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Reset", ImGuiTableColumnFlags.WidthFixed, 40f); + ImGui.TableHeadersRow(); + + DrawDtrColorRow( + "server-default", + "Default", + "Displayed when connected without any special status.", + ref dtrColorsDefault, + DefaultConfig.DtrColorsDefault, + value => _configService.Current.DtrColorsDefault = value); + + DrawDtrColorRow( + "server-not-connected", + "Not Connected", + "Shown while disconnected from the Lightless server.", + ref dtrColorsNotConnected, + DefaultConfig.DtrColorsNotConnected, + value => _configService.Current.DtrColorsNotConnected = value); + + DrawDtrColorRow( + "server-pairs", + "Pairs in Range", + "Used when nearby paired players are detected.", + ref dtrColorsPairsInRange, + DefaultConfig.DtrColorsPairsInRange, + value => _configService.Current.DtrColorsPairsInRange = value); + + ImGui.EndTable(); + } + ImGui.EndDisabled(); + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + ImGui.TreePop(); + serverInfoTree.MarkContentEnd(); } - - _uiShared.DrawHelpText( - "This will color the Server Info Bar entry based on connection status and visible pairs."); - - ImGui.BeginDisabled(!useColorsInDtr); - const ImGuiTableFlags serverInfoTableFlags = ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit; - if (ImGui.BeginTable("##ServerInfoBarColorTable", 3, serverInfoTableFlags)) - { - ImGui.TableSetupColumn("Status", ImGuiTableColumnFlags.WidthFixed, 220f); - ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); - ImGui.TableSetupColumn("Reset", ImGuiTableColumnFlags.WidthFixed, 40f); - ImGui.TableHeadersRow(); - - DrawDtrColorRow( - "server-default", - "Default", - "Displayed when connected without any special status.", - ref dtrColorsDefault, - DefaultConfig.DtrColorsDefault, - value => _configService.Current.DtrColorsDefault = value); - - DrawDtrColorRow( - "server-not-connected", - "Not Connected", - "Shown while disconnected from the Lightless server.", - ref dtrColorsNotConnected, - DefaultConfig.DtrColorsNotConnected, - value => _configService.Current.DtrColorsNotConnected = value); - - DrawDtrColorRow( - "server-pairs", - "Pairs in Range", - "Used when nearby paired players are detected.", - ref dtrColorsPairsInRange, - DefaultConfig.DtrColorsPairsInRange, - value => _configService.Current.DtrColorsPairsInRange = value); - - ImGui.EndTable(); - } - ImGui.EndDisabled(); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); - ImGui.TreePop(); - serverInfoTree.MarkContentEnd(); } - } - ImGui.Separator(); + ImGui.Separator(); - using (var nameplateTree = BeginGeneralTree("Nameplate", UIColors.Get("LightlessPurple"))) - { - if (nameplateTree.Visible) + using (var nameplateTree = BeginGeneralTree("Nameplate", UIColors.Get("LightlessPurple"))) { - ImGui.TextUnformatted("Nameplate Colors"); - - var nameColorsEnabled = _configService.Current.IsNameplateColorsEnabled; - var nameColors = _configService.Current.NameplateColors; - var isFriendOverride = _configService.Current.overrideFriendColor; - var isPartyOverride = _configService.Current.overridePartyColor; - - if (ImGui.Checkbox("Override name color of visible paired players", ref nameColorsEnabled)) + if (nameplateTree.Visible) { - _configService.Current.IsNameplateColorsEnabled = nameColorsEnabled; - _configService.Save(); - _nameplateService.RequestRedraw(); + ImGui.TextUnformatted("Nameplate Colors"); + + var nameColorsEnabled = _configService.Current.IsNameplateColorsEnabled; + var nameColors = _configService.Current.NameplateColors; + var isFriendOverride = _configService.Current.overrideFriendColor; + var isPartyOverride = _configService.Current.overridePartyColor; + + if (ImGui.Checkbox("Override name color of visible paired players", ref nameColorsEnabled)) + { + _configService.Current.IsNameplateColorsEnabled = nameColorsEnabled; + _configService.Save(); + _nameplateService.RequestRedraw(); + } + + _uiShared.DrawHelpText("This will override the nameplate colors for visible paired players in-game."); + + using (ImRaii.Disabled(!nameColorsEnabled)) + { + using var indent = ImRaii.PushIndent(); + if (InputDtrColors("Name color", ref nameColors)) + { + _configService.Current.NameplateColors = nameColors; + _configService.Save(); + _nameplateService.RequestRedraw(); + } + + if (ImGui.Checkbox("Override friend color", ref isFriendOverride)) + { + _configService.Current.overrideFriendColor = isFriendOverride; + _configService.Save(); + _nameplateService.RequestRedraw(); + } + + if (ImGui.Checkbox("Override party color", ref isPartyOverride)) + { + _configService.Current.overridePartyColor = isPartyOverride; + _configService.Save(); + _nameplateService.RequestRedraw(); + } + } + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + ImGui.TreePop(); + nameplateTree.MarkContentEnd(); } - - _uiShared.DrawHelpText("This will override the nameplate colors for visible paired players in-game."); - - using (ImRaii.Disabled(!nameColorsEnabled)) - { - using var indent = ImRaii.PushIndent(); - if (InputDtrColors("Name color", ref nameColors)) - { - _configService.Current.NameplateColors = nameColors; - _configService.Save(); - _nameplateService.RequestRedraw(); - } - - if (ImGui.Checkbox("Override friend color", ref isFriendOverride)) - { - _configService.Current.overrideFriendColor = isFriendOverride; - _configService.Save(); - _nameplateService.RequestRedraw(); - } - - if (ImGui.Checkbox("Override party color", ref isPartyOverride)) - { - _configService.Current.overridePartyColor = isPartyOverride; - _configService.Save(); - _nameplateService.RequestRedraw(); - } - } - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); - ImGui.TreePop(); - nameplateTree.MarkContentEnd(); } + + ImGui.Separator(); + + ImGui.EndChild(); + ImGui.EndGroup(); + + generalSelune.DrawHighlightOnly(ImGui.GetIO().DeltaTime); } - - ImGui.Separator(); - - ImGui.EndChild(); - ImGui.EndGroup(); - - generalSelune.DrawHighlightOnly(ImGui.GetIO().DeltaTime); } private void DrawGeneralNavigation() @@ -3916,6 +4017,39 @@ public class SettingsUi : WindowMediatorSubscriberBase } } + private static string GetLightfinderPresetGlyph(int index) + { + return LightFinderPlateHandler.NormalizeIconGlyph( + SeIconCharExtensions.ToIconString(LightfinderIconPresets[index].Icon)); + } + + private void RefreshLightfinderIconState() + { + var normalized = LightFinderPlateHandler.NormalizeIconGlyph(_configService.Current.LightfinderLabelIconGlyph); + _lightfinderIconInput = LightFinderPlateHandler.ToIconEditorString(normalized); + _lightfinderIconInputInitialized = true; + + _lightfinderIconPresetIndex = -1; + for (int i = 0; i < LightfinderIconPresets.Length; i++) + { + if (string.Equals(GetLightfinderPresetGlyph(i), normalized, StringComparison.Ordinal)) + { + _lightfinderIconPresetIndex = i; + break; + } + } + } + + private void ApplyLightfinderIcon(string normalizedGlyph, int presetIndex) + { + _configService.Current.LightfinderLabelIconGlyph = normalizedGlyph; + _configService.Save(); + _lightfinderIconInput = LightFinderPlateHandler.ToIconEditorString(normalizedGlyph); + _lightfinderIconPresetIndex = presetIndex; + _lightfinderIconInputInitialized = true; + } + + private void DrawServerServiceConfiguration(ServerStorage selectedServer, ref bool useOauth) { var serverName = selectedServer.ServerName; diff --git a/LightlessSync/UI/Style/AnimatedHeader.cs b/LightlessSync/UI/Style/AnimatedHeader.cs index 0037b53..15488ac 100644 --- a/LightlessSync/UI/Style/AnimatedHeader.cs +++ b/LightlessSync/UI/Style/AnimatedHeader.cs @@ -222,7 +222,9 @@ public class AnimatedHeader if (ImGui.IsItemHovered() && !string.IsNullOrEmpty(button.Tooltip)) { + ImGui.PushFont(UiBuilder.DefaultFont); ImGui.SetTooltip(button.Tooltip); + ImGui.PopFont(); } currentX -= buttonSize.X + spacing; 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 396e63c..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; @@ -82,6 +83,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private string? _dragHoverKey; private bool _HideStateActive; private bool _HideStateWasOpen; + private bool _pushedStyle; public ZoneChatUi( ILogger logger, @@ -139,6 +141,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase var config = _chatConfigService.Current; var baseOpacity = Math.Clamp(config.ChatWindowOpacity, MinWindowOpacity, MaxWindowOpacity); _baseWindowOpacity = baseOpacity; + ImGui.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 0); + _pushedStyle = true; if (config.FadeWhenUnfocused) { @@ -208,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); @@ -397,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(); + } } } @@ -823,6 +826,21 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.EndPopup(); } + public override void PostDraw() + { + if (_pushedStyle) + { + ImGui.PopStyleVar(1); + _pushedStyle = false; + } + if (_titleBarStylePopCount > 0) + { + ImGui.PopStyleColor(_titleBarStylePopCount); + _titleBarStylePopCount = 0; + } + base.PostDraw(); + } + private void OpenReportPopup(ChatChannelSnapshot channel, ChatMessageEntry message) { if (message.Payload is not { } payload) 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); }